From ac3f63b110e942d04118f33aa5c49aca7fb74fde Mon Sep 17 00:00:00 2001 From: ChengLei Shao Date: Mon, 6 Nov 2023 11:50:37 +0800 Subject: [PATCH] feat: drop table with cascade option (#2973) * refactor: delete collection support cascade * chore: remove collection from database * feat: remove collection with cascade option * chore: test * chore: test * chore: delete collection support cascade * refactor: code improve * refactor: locale improve * style: delete collection modal style improve * revert: filemanger-plugin * revert: code improve * revert: code improve * revert: fix: bluk dele collection * fix: cascade === true && cascade === 'true' --------- Co-authored-by: katherinehhh Co-authored-by: chenos --- .../CollectionManagerShortcut.tsx | 4 + .../Configuration/DeleteCollectionAction.tsx | 186 ++++++++++++++++++ .../Configuration/index.tsx | 1 + .../Configuration/schemas/collections.ts | 22 +-- packages/core/client/src/locale/en_US.ts | 2 + packages/core/client/src/locale/ja_JP.ts | 2 + packages/core/client/src/locale/pt_BR.ts | 4 +- packages/core/client/src/locale/zh_CN.ts | 2 + .../schema-component/antd/action/Action.tsx | 2 +- .../database/src/__tests__/collection.test.ts | 25 ++- packages/core/database/src/collection.ts | 5 +- .../__tests__/resources/collections.test.ts | 25 +++ .../src/server/models/collection.ts | 8 +- .../src/server/server.ts | 13 +- .../src/client/action-hooks.tsx | 3 + .../components/DeleteCollectionAction.tsx | 22 +++ .../src/client/components/Entity.tsx | 15 +- 17 files changed, 309 insertions(+), 32 deletions(-) create mode 100644 packages/core/client/src/collection-manager/Configuration/DeleteCollectionAction.tsx create mode 100644 packages/plugins/@nocobase/plugin-graph-collection-manager/src/client/components/DeleteCollectionAction.tsx diff --git a/packages/core/client/src/collection-manager/CollectionManagerShortcut.tsx b/packages/core/client/src/collection-manager/CollectionManagerShortcut.tsx index fa4691d33..2f0f3dcee 100644 --- a/packages/core/client/src/collection-manager/CollectionManagerShortcut.tsx +++ b/packages/core/client/src/collection-manager/CollectionManagerShortcut.tsx @@ -24,6 +24,8 @@ import { ViewCollectionField, ViewFieldAction, SyncSQLFieldsAction, + DeleteCollection, + DeleteCollectionAction, } from './Configuration'; import { CollectionCategroriesProvider } from './CollectionManagerProvider'; @@ -71,6 +73,8 @@ export const CollectionManagerPane = () => { AddCategory, EditCollection, EditCollectionAction, + DeleteCollection, + DeleteCollectionAction, EditFieldAction, EditCollectionField, OverridingCollectionField, diff --git a/packages/core/client/src/collection-manager/Configuration/DeleteCollectionAction.tsx b/packages/core/client/src/collection-manager/Configuration/DeleteCollectionAction.tsx new file mode 100644 index 000000000..b3e7a04f8 --- /dev/null +++ b/packages/core/client/src/collection-manager/Configuration/DeleteCollectionAction.tsx @@ -0,0 +1,186 @@ +import React, { useState } from 'react'; +import { css } from '@emotion/css'; +import { useTranslation } from 'react-i18next'; +import { Button, message } from 'antd'; +import { useForm } from '@formily/react'; +import { DeleteOutlined, ExclamationCircleFilled } from '@ant-design/icons'; +import { RecordProvider, useRecord } from '../../record-provider'; +import { ActionContextProvider, SchemaComponent } from '../../schema-component'; +import * as components from './components'; +import { useCollectionManager, useResourceActionContext, useResourceContext, useActionContext } from '../../'; +import { useCancelAction } from '../action-hooks'; + +export const DeleteCollection = (props) => { + const record = useRecord(); + return ; +}; + +export const useDestroyActionAndRefreshCM = () => { + const { run } = useDestroyAction(); + const { refreshCM } = useCollectionManager(); + return { + async run() { + await run(); + await refreshCM(); + }, + }; +}; +export const useBulkDestroyActionAndRefreshCM = () => { + const { run } = useBulkDestroyAction(); + const { refreshCM } = useCollectionManager(); + return { + async run() { + await run(); + await refreshCM(); + }, + }; +}; +export const useDestroyAction = () => { + const { refresh } = useResourceActionContext(); + const { resource, targetKey } = useResourceContext(); + const { [targetKey]: filterByTk } = useRecord(); + const ctx = useActionContext(); + const form = useForm(); + const { cascade } = form?.values || {}; + return { + async run() { + await resource.destroy({ filterByTk, cascade }); + ctx?.setVisible?.(false); + refresh(); + }, + }; +}; + +export const useBulkDestroyAction = () => { + const { state, setState, refresh } = useResourceActionContext(); + const { resource } = useResourceContext(); + const ctx = useActionContext(); + const { t } = useTranslation(); + const form = useForm(); + const { cascade } = form?.values || {}; + return { + async run() { + if (!state?.selectedRowKeys?.length) { + return message.error(t('Please select the records you want to delete')); + } + await resource.destroy({ + filterByTk: state?.selectedRowKeys || [], + cascade, + }); + form.reset(); + ctx?.setVisible?.(false); + setState?.({ selectedRowKeys: [] }); + refresh(); + }, + }; +}; +export const DeleteCollectionAction = (props) => { + const { scope, getContainer, item: record, children, isBulk, useAction, ...otherProps } = props; + const { t } = useTranslation(); + const [visible, setVisible] = useState(false); + const getDestroyCollectionAction = () => { + if (isBulk) { + return useBulkDestroyActionAndRefreshCM; + } else { + if (useAction) { + return useAction; + } + return useDestroyActionAndRefreshCM; + } + }; + const Title = () => { + return ( + + + {t('Delete record')} + + ); + }; + + return ( + + + {isBulk ? ( + + ) : ( + setVisible(true)} {...otherProps}> + {children || t('Delete')} + + )} + , + 'x-component-props': { + width: 500, + getContainer: '{{ getContainer }}', + className: css` + .ant-modal-body { + margin-left: 35px; + margin-bottom: 35px; + .ant-checkbox-wrapper { + height: 25px; + } + } + `, + }, + properties: { + info: { + type: 'string', + 'x-component': 'div', + 'x-content': "{{t('Are you sure you want to delete it?')}}", + }, + cascade: { + type: 'boolean', + 'x-decorator': 'FormItem', + 'x-component': 'Checkbox', + default: false, + 'x-content': t( + 'Automatically drop objects that depend on the collection (such as views), and in turn all objects that depend on those objects', + ), + }, + footer: { + type: 'void', + 'x-component': 'Action.Modal.Footer', + properties: { + action1: { + title: '{{ t("Cancel") }}', + 'x-component': 'Action', + 'x-component-props': { + useAction: '{{ useCancelAction }}', + }, + }, + action2: { + title: '{{ t("Ok") }}', + 'x-component': 'Action', + 'x-component-props': { + type: 'primary', + useAction: '{{ useDestroyCollectionAction }}', + }, + }, + }, + }, + }, + }, + }, + }} + components={{ ...components }} + scope={{ + getContainer, + useDestroyCollectionAction: getDestroyCollectionAction(), + useCancelAction, + ...scope, + }} + /> + + + ); +}; +DeleteCollectionAction.displayName = 'DeleteCollectionAction'; diff --git a/packages/core/client/src/collection-manager/Configuration/index.tsx b/packages/core/client/src/collection-manager/Configuration/index.tsx index ad184f595..011a1c1eb 100644 --- a/packages/core/client/src/collection-manager/Configuration/index.tsx +++ b/packages/core/client/src/collection-manager/Configuration/index.tsx @@ -16,6 +16,7 @@ export * from './AddCategoryAction'; export * from './EditCategoryAction'; export * from './SyncFieldsAction'; export * from './SyncSQLFieldsAction'; +export * from './DeleteCollectionAction'; registerValidateFormats({ uid: /^[A-Za-z0-9][A-Za-z0-9_-]*$/, diff --git a/packages/core/client/src/collection-manager/Configuration/schemas/collections.ts b/packages/core/client/src/collection-manager/Configuration/schemas/collections.ts index 0de2a37fd..72f6ec0da 100644 --- a/packages/core/client/src/collection-manager/Configuration/schemas/collections.ts +++ b/packages/core/client/src/collection-manager/Configuration/schemas/collections.ts @@ -7,6 +7,7 @@ import { i18n } from '../../../i18n'; import { CollectionOptions } from '../../types'; import { CollectionCategory } from '../components/CollectionCategory'; import { CollectionTemplate } from '../components/CollectionTemplate'; + const compile = (source) => { return Schema.compile(source, { t: i18n.t }); }; @@ -147,14 +148,10 @@ export const collectionTableSchema: ISchema = { delete: { type: 'void', title: '{{ t("Delete") }}', - 'x-component': 'Action', + 'x-component': 'DeleteCollection', 'x-component-props': { - icon: 'DeleteOutlined', - useAction: '{{ cm.useBulkDestroyActionAndRefreshCM }}', - confirm: { - title: "{{t('Delete record')}}", - content: "{{t('Are you sure you want to delete it?')}}", - }, + role: 'button', + isBulk: true, }, }, create: { @@ -309,13 +306,12 @@ export const collectionTableSchema: ISchema = { delete: { type: 'void', title: '{{ t("Delete") }}', - 'x-component': 'Action.Link', + 'x-component': 'DeleteCollection', 'x-component-props': { - confirm: { - title: "{{t('Delete record')}}", - content: "{{t('Are you sure you want to delete it?')}}", - }, - useAction: '{{ cm.useDestroyActionAndRefreshCM }}', + role: 'button', + 'aria-label': '{{ "delete-button-" + $record.name }}', + type: 'primary', + className: 'nb-action-link', }, }, }, diff --git a/packages/core/client/src/locale/en_US.ts b/packages/core/client/src/locale/en_US.ts index 187458764..868d8eee4 100644 --- a/packages/core/client/src/locale/en_US.ts +++ b/packages/core/client/src/locale/en_US.ts @@ -783,4 +783,6 @@ export default { Execute: 'Execute', 'Please use a valid SELECT or WITH AS statement': 'Please use a valid SELECT or WITH AS statement', 'Please confirm the SQL statement first': 'Please confirm the SQL statement first', + 'Automatically drop objects that depend on the collection (such as views), and in turn all objects that depend on those objects': + 'Automatically drop objects that depend on the collection (such as views), and in turn all objects that depend on those objects', }; diff --git a/packages/core/client/src/locale/ja_JP.ts b/packages/core/client/src/locale/ja_JP.ts index c9fb96d5d..4b0b985a8 100644 --- a/packages/core/client/src/locale/ja_JP.ts +++ b/packages/core/client/src/locale/ja_JP.ts @@ -668,4 +668,6 @@ export default { "Search plugin": "プラグインを検索", "Author": "著者", "Plugin loading failed. Please check the server logs.": "プラグインのロードに失敗しました。サーバーログを確認してください。", + 'Automatically drop objects that depend on the collection (such as views), and in turn all objects that depend on those objects': + 'テーブルに依存するオブジェクト、およびそれらに依存するオブジェクトを自動的に削除する', } diff --git a/packages/core/client/src/locale/pt_BR.ts b/packages/core/client/src/locale/pt_BR.ts index f0c46cc38..b3dbc6e3d 100644 --- a/packages/core/client/src/locale/pt_BR.ts +++ b/packages/core/client/src/locale/pt_BR.ts @@ -710,5 +710,7 @@ export default { 'Form data templates': 'Modelos de dados do formulário', "Data template": "Modelo de dados", "Not found":"Não encontrado", - "Add":"Adicionar" + "Add":"Adicionar", + 'Automatically drop objects that depend on the collection (such as views), and in turn all objects that depend on those objects': + 'Excluir automaticamente objetos que dependem desta tabela, bem como objetos que dependem desses objetos', }; diff --git a/packages/core/client/src/locale/zh_CN.ts b/packages/core/client/src/locale/zh_CN.ts index 468d46f4f..f69229664 100644 --- a/packages/core/client/src/locale/zh_CN.ts +++ b/packages/core/client/src/locale/zh_CN.ts @@ -878,4 +878,6 @@ export default { Execute: '执行', 'Please use a valid SELECT or WITH AS statement': '请使用有效的 SELECT 或 WITH AS 语句', 'Please confirm the SQL statement first': '请先确认 SQL 语句', + 'Automatically drop objects that depend on the collection (such as views), and in turn all objects that depend on those objects': + '自动删除依赖于该表的对象,以及依赖这些对象的对象', }; diff --git a/packages/core/client/src/schema-component/antd/action/Action.tsx b/packages/core/client/src/schema-component/antd/action/Action.tsx index 90964d2c8..deec37c7d 100644 --- a/packages/core/client/src/schema-component/antd/action/Action.tsx +++ b/packages/core/client/src/schema-component/antd/action/Action.tsx @@ -2,7 +2,7 @@ import { observer, RecursionField, useField, useFieldSchema, useForm } from '@fo import { isPortalInBody } from '@nocobase/utils/client'; import { App, Button, Popover } from 'antd'; import classnames from 'classnames'; -import { default as lodash } from 'lodash'; +import { default as lodash, isFunction } from 'lodash'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useActionContext } from '../..'; diff --git a/packages/core/database/src/__tests__/collection.test.ts b/packages/core/database/src/__tests__/collection.test.ts index 0fd65394c..4cedfd156 100644 --- a/packages/core/database/src/__tests__/collection.test.ts +++ b/packages/core/database/src/__tests__/collection.test.ts @@ -83,7 +83,6 @@ describe('collection', () => { }); test('removeFromDb', async () => { - await db.clean({ drop: true }); const collection = db.collection({ name: 'test', fields: [ @@ -109,6 +108,30 @@ describe('collection', () => { expect(r4).toBe(false); }); + test('remove from db with cascade', async () => { + const testCollection = db.collection({ + name: 'test', + fields: [ + { + type: 'string', + name: 'name', + }, + ], + }); + + await db.sync(); + + const viewName = `test_view`; + const viewSQL = `create view ${viewName} as select * from ${testCollection.getTableNameWithSchemaAsString()}`; + await db.sequelize.query(viewSQL); + + await expect( + testCollection.removeFromDb({ + cascade: true, + }), + ).resolves.toBeTruthy(); + }); + test('collection disable authGenId', async () => { const Test = db.collection({ name: 'test', diff --git a/packages/core/database/src/collection.ts b/packages/core/database/src/collection.ts index 8075a34f9..be736eefc 100644 --- a/packages/core/database/src/collection.ts +++ b/packages/core/database/src/collection.ts @@ -383,7 +383,7 @@ export class Collection< } remove() { - this.context.database.removeCollection(this.name); + return this.context.database.removeCollection(this.name); } async removeFromDb(options?: QueryInterfaceDropTableOptions) { @@ -396,7 +396,8 @@ export class Collection< const queryInterface = this.db.sequelize.getQueryInterface(); await queryInterface.dropTable(this.getTableNameWithSchema(), options); } - this.remove(); + + return this.remove(); } async existsInDb(options?: Transactionable) { diff --git a/packages/plugins/@nocobase/plugin-collection-manager/src/server/__tests__/resources/collections.test.ts b/packages/plugins/@nocobase/plugin-collection-manager/src/server/__tests__/resources/collections.test.ts index 10e74434a..cb1cd8da8 100644 --- a/packages/plugins/@nocobase/plugin-collection-manager/src/server/__tests__/resources/collections.test.ts +++ b/packages/plugins/@nocobase/plugin-collection-manager/src/server/__tests__/resources/collections.test.ts @@ -13,6 +13,31 @@ describe('collections', () => { await app.destroy(); }); + test('remove collection with cascade options', async () => { + await app + .agent() + .resource('collections') + .create({ + values: { + name: 'test', + }, + }); + const collection = app.db.getCollection('test'); + expect(await collection.existsInDb()).toBeTruthy(); + + // create a database view for test + await app.db.sequelize.query(` + CREATE VIEW test_view AS SELECT * FROM ${collection.getTableNameWithSchemaAsString()}; + `); + + await app.agent().resource('collections').destroy({ + filterByTk: 'test', + cascade: true, + }); + + expect(await collection.existsInDb()).toBeFalsy(); + }); + test('remove collection 1', async () => { await app .agent() diff --git a/packages/plugins/@nocobase/plugin-collection-manager/src/server/models/collection.ts b/packages/plugins/@nocobase/plugin-collection-manager/src/server/models/collection.ts index 856634d32..08a3675d2 100644 --- a/packages/plugins/@nocobase/plugin-collection-manager/src/server/models/collection.ts +++ b/packages/plugins/@nocobase/plugin-collection-manager/src/server/models/collection.ts @@ -1,7 +1,7 @@ import Database, { Collection, MagicAttributeModel, SyncOptions, Transactionable } from '@nocobase/database'; import lodash from 'lodash'; import { FieldModel } from './field'; -import { async } from 'fast-glob'; +import { QueryInterfaceDropTableOptions } from 'sequelize'; interface LoadOptions extends Transactionable { // TODO @@ -85,7 +85,7 @@ export class CollectionModel extends MagicAttributeModel { } } - async remove(options?: any) { + async remove(options?: Transactionable & QueryInterfaceDropTableOptions) { const { transaction } = options || {}; const name = this.get('name'); const collection = this.db.getCollection(name); @@ -109,9 +109,7 @@ export class CollectionModel extends MagicAttributeModel { } } - await collection.removeFromDb({ - transaction, - }); + await collection.removeFromDb(options); } async migrate(options?: SyncOptions & Transactionable) { diff --git a/packages/plugins/@nocobase/plugin-collection-manager/src/server/server.ts b/packages/plugins/@nocobase/plugin-collection-manager/src/server/server.ts index ddb49f512..c9c6d6b1a 100644 --- a/packages/plugins/@nocobase/plugin-collection-manager/src/server/server.ts +++ b/packages/plugins/@nocobase/plugin-collection-manager/src/server/server.ts @@ -76,7 +76,18 @@ export class CollectionManagerPlugin extends Plugin { ); this.app.db.on('collections.beforeDestroy', async (model: CollectionModel, options) => { - await model.remove(options); + const removeOptions = {}; + if (options.transaction) { + removeOptions['transaction'] = options.transaction; + } + + const cascade = lodash.get(options, 'context.action.params.cascade', false); + + if (cascade === true || cascade === 'true') { + removeOptions['cascade'] = true; + } + + await model.remove(removeOptions); }); // 要在 beforeInitOptions 之前处理 diff --git a/packages/plugins/@nocobase/plugin-graph-collection-manager/src/client/action-hooks.tsx b/packages/plugins/@nocobase/plugin-graph-collection-manager/src/client/action-hooks.tsx index 466527be8..451b562b6 100644 --- a/packages/plugins/@nocobase/plugin-graph-collection-manager/src/client/action-hooks.tsx +++ b/packages/plugins/@nocobase/plugin-graph-collection-manager/src/client/action-hooks.tsx @@ -169,10 +169,13 @@ export const useUpdateCollectionActionAndRefreshCM = () => { const useDestroyAction = (name) => { const api = useAPIClient(); + const form = useForm(); + const { cascade } = form?.values || {}; return { async run() { await api.resource('collections').destroy({ filterByTk: name, + cascade, }); await api.resource('graphPositions').destroy({ filter: { collectionName: name }, diff --git a/packages/plugins/@nocobase/plugin-graph-collection-manager/src/client/components/DeleteCollectionAction.tsx b/packages/plugins/@nocobase/plugin-graph-collection-manager/src/client/components/DeleteCollectionAction.tsx new file mode 100644 index 000000000..783b5878c --- /dev/null +++ b/packages/plugins/@nocobase/plugin-graph-collection-manager/src/client/components/DeleteCollectionAction.tsx @@ -0,0 +1,22 @@ +import { DeleteOutlined } from '@ant-design/icons'; +import { DeleteCollection } from '@nocobase/client'; +import React from 'react'; +import { useCancelAction, useUpdateCollectionActionAndRefreshCM } from '../action-hooks'; +import { getPopupContainer } from '../utils'; + +export const DeleteCollectionAction = ({ item: record, className, ...other }) => { + return ( + + + + ); +}; diff --git a/packages/plugins/@nocobase/plugin-graph-collection-manager/src/client/components/Entity.tsx b/packages/plugins/@nocobase/plugin-graph-collection-manager/src/client/components/Entity.tsx index 22d9a0d5d..054626c66 100644 --- a/packages/plugins/@nocobase/plugin-graph-collection-manager/src/client/components/Entity.tsx +++ b/packages/plugins/@nocobase/plugin-graph-collection-manager/src/client/components/Entity.tsx @@ -34,6 +34,7 @@ import { ConnectAssociationAction } from './ConnectAssociationAction'; import { ConnectChildAction } from './ConnectChildAction'; import { ConnectParentAction } from './ConnectParentAction'; import { EditCollectionAction } from './EditCollectionAction'; +import { DeleteCollectionAction } from './DeleteCollectionAction'; import { EditFieldAction } from './EditFieldAction'; import { FieldSummary } from './FieldSummary'; import { OverrideFieldAction } from './OverrideFieldAction'; @@ -436,6 +437,7 @@ const Entity: React.FC<{ components={{ EditOutlined, EditCollectionAction, + DeleteCollectionAction, ConnectChildAction, ConnectParentAction, ...options.components, @@ -475,17 +477,14 @@ const Entity: React.FC<{ delete: { type: 'void', 'x-action': 'destroy', - 'x-component': 'Action', + 'x-component': 'DeleteCollectionAction', 'x-component-props': { - component: DeleteOutlined, - icon: 'DeleteOutlined', className: 'btn-del', - confirm: { - title: "{{t('Delete record')}}", - getContainer: getPopupContainer, - collectionConten: "{{t('Are you sure you want to delete it?')}}", + getContainer: getPopupContainer, + item: collectionData.current, + useAction: () => { + return useDestroyActionAndRefreshCM({ name, id }); }, - useAction: () => useDestroyActionAndRefreshCM({ name, id }), }, }, },