Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor workspace datasource association #8545

Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelogs/fragments/8545.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
feat:
- Refactor data source list page to include data source association features for workspace ([#8545](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8545))
1 change: 1 addition & 0 deletions src/core/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ export {
WorkspacesService,
WorkspaceObject,
IWorkspaceClient,
IWorkspaceResponse,
} from './workspace';

export { debounce } from './utils';
10 changes: 3 additions & 7 deletions src/core/public/workspace/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,6 @@
* SPDX-License-Identifier: Apache-2.0
*/

export {
WorkspacesStart,
WorkspacesService,
WorkspacesSetup,
WorkspaceObject,
IWorkspaceClient,
} from './workspaces_service';
export { WorkspacesStart, WorkspacesService, WorkspacesSetup } from './workspaces_service';

export { IWorkspaceClient, IWorkspaceResponse, WorkspaceObject } from './types';
81 changes: 81 additions & 0 deletions src/core/public/workspace/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { WorkspaceAttribute } from '../../types';

export type WorkspaceObject = WorkspaceAttribute & { readonly?: boolean };

export type IWorkspaceResponse<T> =
| {
result: T;
success: true;
}
| {
success: false;
error?: string;
};

export interface AssociationResult {
id: string;
error?: string;
}

export interface IWorkspaceClient {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we may need a TODO here to add other methods of client.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, those should be refactored as well

/**
* copy saved objects to target workspace
*
* @param {Array<{ id: string; type: string }>} objects
* @param {string} targetWorkspace
* @param {boolean} includeReferencesDeep
* @returns {Promise<IResponse<any>>} result for this operation
*/
copy(objects: any[], targetWorkspace: string, includeReferencesDeep?: boolean): Promise<any>;

/**
* Associates a list of objects with the given workspace ID.
*
* This method takes a workspace ID and an array of objects, where each object contains
* an `id` and `type`. It attempts to associate each object with the specified workspace.
* If the association succeeds, the object is included in the result without an error.
* If there is an issue associating an object, an error message is returned for that object.
*
* @returns A promise that resolves to a response object containing an array of results for each object.
* Each result will include the object's `id` and, if there was an error during association, an `error` field
* with the error message.
*/
associate(
savedObjects: Array<{ id: string; type: string }>,
workspaceId: string
): Promise<IWorkspaceResponse<AssociationResult[]>>;

/**
* Dissociates a list of objects from the given workspace ID.
*
* This method takes a workspace ID and an array of objects, where each object contains
* an `id` and `type`. It attempts to dissociate each object from the specified workspace.
* If the dissociation succeeds, the object is included in the result without an error.
* If there is an issue dissociating an object, an error message is returned for that object.
*
* @returns A promise that resolves to a response object containing an array of results for each object.
* Each result will include the object's `id` and, if there was an error during dissociation, an `error` field
* with the error message.
*/
dissociate(
savedObjects: Array<{ id: string; type: string }>,
workspaceId: string
): Promise<IWorkspaceResponse<AssociationResult[]>>;

ui(): WorkspaceUI;
}

interface DataSourceAssociationProps {
excludedDataSourceIds: string[];
onComplete?: () => void;
onError?: () => void;
}

export interface WorkspaceUI {
DataSourceAssociation: (props: DataSourceAssociationProps) => JSX.Element;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: How about use typeof DataSourceAssociation here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is interface definition in core, DataSourceAssociation is the implementation in workspace plugin, so it should be the other way around.

}
3 changes: 2 additions & 1 deletion src/core/public/workspace/workspaces_service.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
import { BehaviorSubject } from 'rxjs';
import type { PublicMethodsOf } from '@osd/utility-types';

import { WorkspacesService, WorkspaceObject, IWorkspaceClient } from './workspaces_service';
import { IWorkspaceClient, WorkspaceObject } from './types';
import { WorkspacesService } from './workspaces_service';

const createWorkspacesSetupContractMock = () => {
const currentWorkspaceId$ = new BehaviorSubject<string>('');
Expand Down
15 changes: 8 additions & 7 deletions src/core/public/workspace/workspaces_service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,8 @@
* SPDX-License-Identifier: Apache-2.0
*/

import {
WorkspacesService,
WorkspacesSetup,
WorkspacesStart,
IWorkspaceClient,
} from './workspaces_service';
import { IWorkspaceClient } from './types';
import { WorkspacesService, WorkspacesSetup, WorkspacesStart } from './workspaces_service';

describe('WorkspacesService', () => {
let workspaces: WorkspacesService;
Expand Down Expand Up @@ -43,7 +39,12 @@ describe('WorkspacesService', () => {
});

it('client is updated when set client', () => {
const client: IWorkspaceClient = { copy: jest.fn() };
const client: IWorkspaceClient = {
copy: jest.fn(),
associate: jest.fn(),
dissociate: jest.fn(),
ui: jest.fn(),
};
workspacesSetUp.setClient(client);
expect(workspacesStart.client$.value).toEqual(client);
});
Expand Down
9 changes: 2 additions & 7 deletions src/core/public/workspace/workspaces_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,8 @@
import { BehaviorSubject, combineLatest } from 'rxjs';
import { isEqual } from 'lodash';

import { CoreService, WorkspaceAttribute } from '../../types';

export type WorkspaceObject = WorkspaceAttribute & { readonly?: boolean };

export interface IWorkspaceClient {
copy(objects: any[], targetWorkspace: string, includeReferencesDeep?: boolean): Promise<any>;
}
import { CoreService } from '../../types';
import { IWorkspaceClient, WorkspaceObject } from './types';

interface WorkspaceObservables {
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,16 @@ import {
EuiSpacer,
EuiText,
EuiSearchBarProps,
EuiBasicTableColumn,
EuiButtonIcon,
} from '@elastic/eui';
import React, { useCallback, useState } from 'react';
import React, { useCallback, useState, useRef } from 'react';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import { useEffectOnce } from 'react-use';
import { useEffectOnce, useObservable } from 'react-use';
import { of } from 'rxjs';
import { i18n } from '@osd/i18n';
import { FormattedMessage } from '@osd/i18n/react';
import { TopNavControlComponentData } from 'src/plugins/navigation/public';
import {
reactRouterNavigate,
useOpenSearchDashboards,
Expand All @@ -33,10 +37,10 @@ import {
deleteMultipleDataSources,
getDataSources,
setFirstDataSourceAsDefault,
getDefaultDataSourceId,
fetchDataSourceConnections,
} from '../utils';
import { LoadingMask } from '../loading_mask';
import { DEFAULT_DATA_SOURCE_UI_SETTINGS_ID } from '../constants';

/* Table config */
const pagination = {
Expand All @@ -59,7 +63,18 @@ export const DataSourceTable = ({ history }: RouteComponentProps) => {
notifications,
uiSettings,
application,
navigation,
workspaces,
overlays,
} = useOpenSearchDashboards<DataSourceManagementContext>().services;
const { HeaderControl } = navigation.ui;
const workspaceClient = useObservable(workspaces.client$);
const DataSourceAssociation = workspaceClient?.ui().DataSourceAssociation;
const defaultDataSourceIdRef = useRef(
uiSettings.get$<string | null>(DEFAULT_DATA_SOURCE_UI_SETTINGS_ID)
);
const defaultDataSourceId = useObservable(defaultDataSourceIdRef.current);
const useUpdatedUX = uiSettings.get('home:useNewHomePage');

/* Component state variables */
const [dataSources, setDataSources] = useState<DataSourceTableItem[]>([]);
Expand All @@ -68,6 +83,7 @@ export const DataSourceTable = ({ history }: RouteComponentProps) => {
const [isDeleting, setIsDeleting] = React.useState<boolean>(false);
const [confirmDeleteVisible, setConfirmDeleteVisible] = React.useState(false);
const canManageDataSource = !!application.capabilities?.dataSource?.canManage;
const currentWorkspace = useObservable(workspaces ? workspaces.currentWorkspace$ : of(null));

/* useEffectOnce hook to avoid these methods called multiple times when state is updated. */
useEffectOnce(() => {
Expand All @@ -82,9 +98,20 @@ export const DataSourceTable = ({ history }: RouteComponentProps) => {
fetchDataSources();
});

const associateDataSourceButton = DataSourceAssociation && [
{
renderComponent: (
<DataSourceAssociation
excludedDataSourceIds={dataSources.map((ds) => ds.id)}
onComplete={() => fetchDataSources()}
/>
),
} as TopNavControlComponentData,
];

const fetchDataSources = () => {
setIsLoading(true);
getDataSources(savedObjects.client)
return getDataSources(savedObjects.client)
.then((response: DataSourceTableItem[]) => {
return fetchDataSourceConnections(response, http, notifications, false);
})
Expand All @@ -107,6 +134,28 @@ export const DataSourceTable = ({ history }: RouteComponentProps) => {
});
};

const onDissociate = async (item: DataSourceTableItem) => {
const confirmed = await overlays.openConfirm('', {
title: i18n.translate('dataSourcesManagement.dataSourcesTable.removeAssociation', {
defaultMessage: 'Remove association',
}),
buttonColor: 'danger',
});
if (confirmed) {
setIsLoading(true);
if (workspaceClient && currentWorkspace) {
await workspaceClient.dissociate(
[{ id: item.id, type: 'data-source' }],
currentWorkspace.id
);
}
await fetchDataSources();
if (defaultDataSourceId === item.id) {
setFirstDataSourceAsDefault(savedObjects.client, uiSettings, true);
}
}
};

/* Table search config */
const renderToolsLeft = useCallback(() => {
return selectedDataSources.length > 0
Expand Down Expand Up @@ -157,7 +206,7 @@ export const DataSourceTable = ({ history }: RouteComponentProps) => {
};

/* Table columns */
const columns = [
const columns: Array<EuiBasicTableColumn<DataSourceTableItem>> = [
{
field: 'title',
name: i18n.translate('dataSourcesManagement.dataSourcesTable.dataSourceField', {
Expand All @@ -177,7 +226,7 @@ export const DataSourceTable = ({ history }: RouteComponentProps) => {
<EuiButtonEmpty size="xs" {...reactRouterNavigate(history, `${index.id}`)} flush="left">
{name}
</EuiButtonEmpty>
{index.id === getDefaultDataSourceId(uiSettings) ? (
{index.id === defaultDataSourceId ? (
<EuiBadge iconType="starFilled" iconSide="left">
Default
</EuiBadge>
Expand Down Expand Up @@ -295,7 +344,7 @@ export const DataSourceTable = ({ history }: RouteComponentProps) => {
const setDefaultDataSource = async () => {
try {
for (const dataSource of selectedDataSources) {
if (getDefaultDataSourceId(uiSettings) === dataSource.id) {
if (defaultDataSourceId === dataSource.id) {
await setFirstDataSourceAsDefault(savedObjects.client, uiSettings, true);
break;
}
Expand Down Expand Up @@ -384,8 +433,70 @@ export const DataSourceTable = ({ history }: RouteComponentProps) => {
);
};

const isDashboardAdmin = !!application?.capabilities?.dashboards?.isDashboardAdmin;
const canAssociateDataSource =
!!currentWorkspace && !currentWorkspace.readonly && isDashboardAdmin;

const actionColumn: EuiBasicTableColumn<DataSourceTableItem> = {
name: 'Action',
actions: [],
};

// Add remove association action
if (canAssociateDataSource) {
actionColumn.actions.push({
name: i18n.translate('dataSourcesManagement.dataSourcesTable.removeAssociation.label', {
defaultMessage: 'Remove association',
}),
isPrimary: true,
description: i18n.translate(
'dataSourcesManagement.dataSourcesTable.removeAssociation.description',
{
defaultMessage: 'Remove association',
}
),
icon: 'unlink',
type: 'icon',
onClick: async (item: DataSourceTableItem) => {
onDissociate(item);
},
'data-test-subj': 'dataSourcesManagement-dataSourceTable-dissociateButton',
});
}

// Add set as default action when data source list page opened within a workspace
if (currentWorkspace) {
actionColumn.actions.push({
render: (item) => {
return (
<EuiButtonIcon
isDisabled={defaultDataSourceId === item.id}
aria-label="Set as default data source"
title={i18n.translate('dataSourcesManagement.dataSourcesTable.setAsDefault.label', {
defaultMessage: 'Set as default',
})}
iconType="flag"
onClick={async () => {
await uiSettings.set(DEFAULT_DATA_SOURCE_UI_SETTINGS_ID, item.id);
}}
/>
);
},
});
}

if (actionColumn.actions.length > 0) {
columns.push(actionColumn);
}

return (
<>
{useUpdatedUX && canAssociateDataSource && associateDataSourceButton && (
<HeaderControl
setMountPoint={application.setAppRightControls}
controls={associateDataSourceButton}
/>
)}
{tableRenderDeleteModal()}
{!isLoading && (!dataSources || !dataSources.length)
? renderEmptyState()
Expand Down
Loading
Loading