Skip to content

Commit

Permalink
move all saved objects to target workspace
Browse files Browse the repository at this point in the history
  • Loading branch information
yubonluo committed Mar 8, 2024
1 parent 0ee4023 commit 2296b15
Show file tree
Hide file tree
Showing 7 changed files with 273 additions and 5 deletions.
3 changes: 3 additions & 0 deletions src/core/public/saved_objects/simple_saved_object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export class SimpleSavedObject<T = unknown> {
public error: SavedObjectType<T>['error'];
public references: SavedObjectType<T>['references'];
public updated_at: SavedObjectType<T>['updated_at'];
public workspaces: SavedObjectType<T>['workspaces'];

constructor(
private client: SavedObjectsClientContract,
Expand All @@ -64,6 +65,7 @@ export class SimpleSavedObject<T = unknown> {
references,
migrationVersion,
updated_at: updateAt,
workspaces,
}: SavedObjectType<T>
) {
this.id = id;
Expand All @@ -73,6 +75,7 @@ export class SimpleSavedObject<T = unknown> {
this._version = version;
this.migrationVersion = migrationVersion;
this.updated_at = updateAt;
this.workspaces = workspaces || [];
if (error) {
this.error = error;
}
Expand Down
3 changes: 2 additions & 1 deletion src/core/server/saved_objects/service/lib/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1552,7 +1552,7 @@ export class SavedObjectsRepository {

let bulkGetRequestIndexCounter = 0;
const expectedBulkGetResults: Either[] = objects.map((object) => {
const { type, id } = object;
const { type, id, workspaces } = object;

if (!this._allowedTypes.includes(type)) {
return {
Expand Down Expand Up @@ -1586,6 +1586,7 @@ export class SavedObjectsRepository {
[type]: attributes,
updated_at: time,
...(Array.isArray(references) && { references }),
...(workspaces && { workspaces }),
};

const requiresNamespacesCheck = this._registry.isMultiNamespace(object.type);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export interface SavedObjectsBulkUpdateObject<T = unknown>
* Note: the default namespace's string representation is `'default'`, and its ID representation is `undefined`.
**/
namespace?: string;
workspaces?: string[];
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
* SPDX-License-Identifier: Apache-2.0
*/

import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiComboBox,
EuiComboBoxOptionOption,
EuiFieldText,
EuiModal,
EuiModalBody,
Expand All @@ -16,11 +18,28 @@ import {
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { WorkspaceAttribute } from 'opensearch-dashboards/public';
import { SavedObjectsFindOptions, WorkspaceAttribute } from 'opensearch-dashboards/public';
import { i18n } from '@osd/i18n';
import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public';
import { WorkspaceClient } from '../../workspace_client';
import { getAllowedTypes } from './get_allowed_types';
import { moveToTargetWorkspace } from './move_to_target_workspace';

type WorkspaceOption = EuiComboBoxOptionOption<WorkspaceAttribute>;
interface SaveObjectToMove {
id: string;
type: string;
workspaces: string[];
attributes: any;
}

function workspaceToOption(workspace: WorkspaceAttribute): WorkspaceOption {
return {
label: workspace.name,
key: workspace.id,
value: workspace,
};
}
interface DeleteWorkspaceModalProps {
onClose: () => void;
selectedWorkspace?: WorkspaceAttribute | null;
Expand All @@ -31,9 +50,119 @@ export function DeleteWorkspaceModal(props: DeleteWorkspaceModalProps) {
const [value, setValue] = useState('');
const { onClose, selectedWorkspace, returnToHome } = props;
const {
services: { application, notifications, http, workspaceClient },
services: { application, notifications, http, workspaceClient, savedObjects, workspaces },
} = useOpenSearchDashboards<{ workspaceClient: WorkspaceClient }>();

const savedObjectsClient = savedObjects!.client;
const [workspaceOptions, setWorkspaceOptions] = useState<WorkspaceOption[]>([]);
const [targetWorkspaceOption, setTargetWorkspaceOption] = useState<WorkspaceOption[]>([]);
const [isLoading, setIsLoading] = useState(false);
const targetWorkspaceId = targetWorkspaceOption?.at(0)?.key;
let confirmDuplicateButtonEnabled = false;
const [savedObjectsCount, setSavedObjectsCount] = useState(0);
const [allowedTypes, setAllowedTypes] = useState<string[]>([]);
const maxObjectsAmount: number = 200;
const onTargetWorkspaceChange = (targetOption: WorkspaceOption[]) => {
setTargetWorkspaceOption(targetOption);
};

if (!!targetWorkspaceId && savedObjectsCount > 0) {
confirmDuplicateButtonEnabled = true;
}

useEffect(() => {
const workspaceList = workspaces!.workspaceList$.value;
const initWorkspaceOptions = [
...workspaceList
.filter((workspace: WorkspaceAttribute) => !workspace.libraryReadonly)
.filter((workspace: WorkspaceAttribute) => workspace.id !== selectedWorkspace?.id)
.map((workspace: WorkspaceAttribute) => workspaceToOption(workspace)),
];
setWorkspaceOptions(initWorkspaceOptions);
fetchSavedObjectsCount();
}, [workspaces]);

const fetchSavedObjectsCount = async () => {
const types = await getAllowedTypes(http!);
const findOptions: SavedObjectsFindOptions = {
workspaces: [selectedWorkspace?.id as string],
fields: ['id'],
type: types,
perPage: 1,
};

try {
const resp = await savedObjectsClient.find(findOptions);
setAllowedTypes(types);
setSavedObjectsCount(resp.total);
} catch (error) {
notifications?.toasts.addDanger({
title: i18n.translate(
'workspace.deleteWorkspaceModal.unableFindSavedObjectsNotificationMessage',
{ defaultMessage: 'Unable find saved objects count' }
),
text: `${error}`,
});
}
};

const fetchObjects = async () => {
const findOptions: SavedObjectsFindOptions = {
workspaces: [selectedWorkspace?.id as string],
type: allowedTypes,
perPage: maxObjectsAmount,
};

try {
return await savedObjectsClient.find(findOptions);
} catch (error) {
notifications?.toasts.addDanger({
title: i18n.translate(
'workspace.deleteWorkspaceModal.unableFindSavedObjectsNotificationMessage',
{ defaultMessage: 'Unable find saved objects' }
),
text: `${error}`,
});
}
};

const moveObjectsToTargetWorkspace = async () => {
setIsLoading(true);
try {
for (let i = 1; i <= Math.ceil(savedObjectsCount / maxObjectsAmount); i++) {
const resp = await fetchObjects();
if (resp!.total === 0) break;
const objects: SaveObjectToMove[] = resp!.savedObjects.map((obj) => ({
id: obj.id,
type: obj.type,
workspaces: obj.workspaces!,
attributes: obj.attributes,
}));
await moveToTargetWorkspace(
http!,
objects,
selectedWorkspace?.id as string,
targetWorkspaceId as string
);
if (resp!.total < maxObjectsAmount) break;
}
setSavedObjectsCount(0);
notifications?.toasts.addSuccess({
title: i18n.translate('workspace.deleteWorkspaceModal.move.successNotification', {
defaultMessage: 'Move ' + savedObjectsCount + ' saved objects successfully',
}),
});
} catch (e) {
notifications?.toasts.addDanger({
title: i18n.translate('workspace.deleteWorkspaceModal.move.dangerNotification', {
defaultMessage: 'Unable to move ' + savedObjectsCount + ' saved objects',
}),
});
} finally {
setIsLoading(false);
}
};

const deleteWorkspace = async () => {
if (selectedWorkspace?.id) {
let result;
Expand Down Expand Up @@ -83,6 +212,37 @@ export function DeleteWorkspaceModal(props: DeleteWorkspaceModalProps) {
</EuiModalHeader>

<EuiModalBody>
<div style={{ lineHeight: 1.5 }}>
<EuiText>
Before deleting the workspace, you have the option to keep the saved objects by moving
them to a target workspace.
</EuiText>
<EuiSpacer size="s" />

<EuiComboBox
placeholder="Please select a target workspace"
options={workspaceOptions}
selectedOptions={targetWorkspaceOption}
onChange={onTargetWorkspaceChange}
singleSelection={{ asPlainText: true }}
isClearable={false}
isInvalid={!confirmDuplicateButtonEnabled}
/>
<EuiSpacer size="m" />

<EuiButton
data-test-subj="Duplicate All button"
onClick={moveObjectsToTargetWorkspace}
fill
color="primary"
size="s"
disabled={!confirmDuplicateButtonEnabled}
isLoading={isLoading}
>
Move All
</EuiButton>
<EuiSpacer />
</div>
<div style={{ lineHeight: 1.5 }}>
<p>The following workspace will be permanently deleted. This action cannot be undone.</p>
<ul style={{ listStyleType: 'disc', listStylePosition: 'inside' }}>
Expand All @@ -108,7 +268,7 @@ export function DeleteWorkspaceModal(props: DeleteWorkspaceModalProps) {
onClick={deleteWorkspace}
fill
color="danger"
disabled={value !== 'delete'}
disabled={value !== 'delete' || isLoading}
>
Delete
</EuiButton>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*
* Any modifications Copyright OpenSearch Contributors. See
* GitHub history for details.
*/

import { HttpStart } from 'src/core/public';

interface GetAllowedTypesResponse {
types: string[];
}

export async function getAllowedTypes(http: HttpStart) {
const response = await http.get<GetAllowedTypesResponse>('/api/workspaces/_allowed_types');
return response.types;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*
* Any modifications Copyright OpenSearch Contributors. See
* GitHub history for details.
*/

import { HttpStart } from 'src/core/public';

export async function moveToTargetWorkspace(
http: HttpStart,
objects: any[],
sourceWorkspaceId: string,
targetWorkspaceId: string
) {
return await http.post('/api/workspaces/_move_objects', {
body: JSON.stringify({
objects,
sourceWorkspaceId,
targetWorkspaceId,
}),
});
}
55 changes: 55 additions & 0 deletions src/plugins/workspace/server/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,4 +218,59 @@ export function registerRoutes({
return res.ok({ body: result });
})
);
router.get(
{
path: `${WORKSPACES_API_BASE_URL}/_allowed_types`,
validate: false,
},
async (context, req, res) => {
const allowedTypes = context.core.savedObjects.typeRegistry
.getImportableAndExportableTypes()
.map((type) => type.name);

return res.ok({
body: {
types: allowedTypes,
},
});
}
);
router.post(
{
path: `${WORKSPACES_API_BASE_URL}/_move_objects`,
validate: {
body: schema.object({
objects: schema.maybe(
schema.arrayOf(
schema.object({
type: schema.string(),
id: schema.string(),
workspaces: schema.arrayOf(schema.string()),
attributes: schema.recordOf(schema.string(), schema.any()),
})
)
),
sourceWorkspaceId: schema.string(),
targetWorkspaceId: schema.string(),
}),
},
},
router.handleLegacyErrors(async (context, req, res) => {
const savedObjectsClient = context.core.savedObjects.client;
const { objects, sourceWorkspaceId, targetWorkspaceId } = req.body;
const objectsToMove = objects.map((obj) => {
const sourceIndex = obj.workspaces.indexOf(sourceWorkspaceId);
if (sourceIndex !== -1) {
obj.workspaces.splice(sourceIndex, 1);
}
const targetIndex = obj.workspaces.indexOf(targetWorkspaceId);
if (targetIndex === -1) {
obj.workspaces.push(targetWorkspaceId);
}
return obj;
});
const result = await savedObjectsClient.bulkUpdate(objectsToMove);
return res.ok({ body: result });
})
);
}

0 comments on commit 2296b15

Please sign in to comment.