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 <katherine_15995@163.com>
Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
ChengLei Shao 2023-11-06 11:50:37 +08:00 committed by GitHub
parent 05040a1c5f
commit ac3f63b110
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 309 additions and 32 deletions

View File

@ -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,

View File

@ -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 <DeleteCollectionAction item={record} {...props} />;
};
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 (
<span>
<ExclamationCircleFilled style={{ color: '#faad14', marginRight: '12px', fontSize: '22px' }} />
<span style={{ fontSize: '19px' }}>{t('Delete record')}</span>
</span>
);
};
return (
<RecordProvider record={record}>
<ActionContextProvider value={{ visible, setVisible }}>
{isBulk ? (
<Button icon={<DeleteOutlined />} onClick={() => setVisible(true)}>
{children || t('Delete')}
</Button>
) : (
<a onClick={() => setVisible(true)} {...otherProps}>
{children || t('Delete')}
</a>
)}
<SchemaComponent
schema={{
type: 'object',
properties: {
deleteCollection: {
type: 'void',
'x-decorator': 'Form',
'x-component': 'Action.Modal',
title: <Title />,
'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,
}}
/>
</ActionContextProvider>
</RecordProvider>
);
};
DeleteCollectionAction.displayName = 'DeleteCollectionAction';

View File

@ -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_-]*$/,

View File

@ -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',
},
},
},

View File

@ -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',
};

View File

@ -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':
'テーブルに依存するオブジェクト、およびそれらに依存するオブジェクトを自動的に削除する',
}

View File

@ -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',
};

View File

@ -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':
'自动删除依赖于该表的对象,以及依赖这些对象的对象',
};

View File

@ -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 '../..';

View File

@ -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',

View File

@ -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) {

View File

@ -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()

View File

@ -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) {

View File

@ -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 之前处理

View File

@ -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 },

View File

@ -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 (
<DeleteCollection
item={record}
scope={{
useCancelAction,
useUpdateCollectionActionAndRefreshCM,
createOnly: false,
}}
getContainer={getPopupContainer}
{...other}
>
<DeleteOutlined className={className} />
</DeleteCollection>
);
};

View File

@ -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 }),
},
},
},