diff --git a/packages/core/client/src/collection-manager/Configuration/AddCollectionAction.tsx b/packages/core/client/src/collection-manager/Configuration/AddCollectionAction.tsx index 7e8b646179..da2d3d8bd5 100644 --- a/packages/core/client/src/collection-manager/Configuration/AddCollectionAction.tsx +++ b/packages/core/client/src/collection-manager/Configuration/AddCollectionAction.tsx @@ -1,6 +1,6 @@ import { DownOutlined, PlusOutlined } from '@ant-design/icons'; import { ArrayTable } from '@formily/antd'; -import { ISchema, useForm } from '@formily/react'; +import { ISchema, useField, useForm } from '@formily/react'; import { uid } from '@formily/shared'; import { Button, Dropdown, Menu } from 'antd'; import { cloneDeep } from 'lodash'; @@ -28,7 +28,7 @@ const getSchema = (schema, category, compile): ISchema => { properties['defaultValue']['x-decorator'] = 'FormItem'; } const initialValue: any = { - name: schema.name !== 'view' ? `t_${uid()}` : null, + name: `t_${uid()}`, template: schema.name, view: schema.name === 'view', category, @@ -194,9 +194,12 @@ const useCreateCollection = (schema?: any) => { const { refreshCM } = useCollectionManager(); const ctx = useActionContext(); const { refresh } = useResourceActionContext(); - const { resource, collection } = useResourceContext(); + const { resource } = useResourceContext(); + const field = useField(); return { async run() { + field.data = field.data || {}; + field.data.loading = true; await form.submit(); const values = cloneDeep(form.values); if (schema?.events?.beforeSubmit) { @@ -218,6 +221,7 @@ const useCreateCollection = (schema?: any) => { }); ctx.setVisible(false); await form.reset(); + field.data.loading = false; refresh(); await refreshCM(); }, diff --git a/packages/core/client/src/collection-manager/Configuration/AddFieldAction.tsx b/packages/core/client/src/collection-manager/Configuration/AddFieldAction.tsx index 77b77c2935..8ea05eac36 100644 --- a/packages/core/client/src/collection-manager/Configuration/AddFieldAction.tsx +++ b/packages/core/client/src/collection-manager/Configuration/AddFieldAction.tsx @@ -1,6 +1,6 @@ import { PlusOutlined } from '@ant-design/icons'; import { ArrayTable } from '@formily/antd'; -import { useForm } from '@formily/react'; +import { useForm, useField } from '@formily/react'; import { uid } from '@formily/shared'; import { Button, Dropdown, Menu } from 'antd'; import { cloneDeep } from 'lodash'; @@ -136,9 +136,12 @@ const useCreateCollectionField = () => { const ctx = useActionContext(); const { refresh } = useResourceActionContext(); const { resource } = useResourceContext(); + const field = useField(); return { async run() { await form.submit(); + field.data = field.data || {}; + field.data.loading = true; const values = cloneDeep(form.values); if (values.autoCreateReverseField) { } else { @@ -148,6 +151,7 @@ const useCreateCollectionField = () => { await resource.create({ values }); ctx.setVisible(false); await form.reset(); + field.data.loading = false; refresh(); await refreshCM(); }, diff --git a/packages/core/client/src/collection-manager/templates/components/PreviewFields.tsx b/packages/core/client/src/collection-manager/templates/components/PreviewFields.tsx index 47b6bf09b7..d62cf65df7 100644 --- a/packages/core/client/src/collection-manager/templates/components/PreviewFields.tsx +++ b/packages/core/client/src/collection-manager/templates/components/PreviewFields.tsx @@ -21,7 +21,7 @@ const getInterfaceOptions = (data, type) => { return interfaceOptions.filter((v) => v.children.length > 0); }; const PreviewCom = (props) => { - const { name, sources, viewName, schema } = props; + const { databaseView, viewName,sources, schema } = props; const { data: fields } = useContext(ResourceActionContext); const api = useAPIClient(); const { t } = useTranslation(); @@ -47,10 +47,10 @@ const PreviewCom = (props) => { }); }); setSourceFields(data); - }, [sources, name]); + }, [sources, databaseView]); useEffect(() => { - if (name) { + if (databaseView) { setLoading(true); api .resource(`dbViews`) @@ -72,7 +72,7 @@ const PreviewCom = (props) => { } }); } - }, [name]); + }, [databaseView]); const handleFieldChange = (record, index) => { dataSource.splice(index, 1, record); @@ -175,7 +175,7 @@ const PreviewCom = (props) => { const item = dataSource[index]; return ( handleFieldChange({ ...item, uiSchema: { ...item?.uiSchema, title: e.target.value } }, index) } @@ -204,7 +204,7 @@ const PreviewCom = (props) => { scroll={{ y: 300 }} pagination={false} rowClassName="editable-row" - key={name} + key={viewName} /> )} diff --git a/packages/core/client/src/collection-manager/templates/components/PreviewTable.tsx b/packages/core/client/src/collection-manager/templates/components/PreviewTable.tsx index 9d8d7d5f2c..90741bc237 100644 --- a/packages/core/client/src/collection-manager/templates/components/PreviewTable.tsx +++ b/packages/core/client/src/collection-manager/templates/components/PreviewTable.tsx @@ -7,7 +7,7 @@ import { useAPIClient } from '../../../api-client'; import { useCollectionManager } from '../../hooks/useCollectionManager'; export const PreviewTable = (props) => { - const { name, viewName, schema, fields } = props; + const { databaseView, schema, viewName, fields } = props; const [previewColumns, setPreviewColumns] = useState([]); const [previewData, setPreviewData] = useState([]); const compile = useCompile(); @@ -17,10 +17,10 @@ export const PreviewTable = (props) => { const { t } = useTranslation(); const form = useForm(); useEffect(() => { - if (name) { + if (databaseView) { getPreviewData(); } - }, [name]); + }, [databaseView]); useEffect(() => { const pColumns = formatPreviewColumns(fields); @@ -96,7 +96,7 @@ export const PreviewTable = (props) => { columns={previewColumns} dataSource={previewData} scroll={{ x: 1000, y: 300 }} - key={name} + key={viewName} />, ]} diff --git a/packages/core/client/src/collection-manager/templates/view.tsx b/packages/core/client/src/collection-manager/templates/view.tsx index 5469e9863b..c39575965d 100644 --- a/packages/core/client/src/collection-manager/templates/view.tsx +++ b/packages/core/client/src/collection-manager/templates/view.tsx @@ -3,7 +3,6 @@ import { ICollectionTemplate } from './types'; import { PreviewFields } from './components/PreviewFields'; import { PreviewTable } from './components/PreviewTable'; - export const view: ICollectionTemplate = { name: 'view', title: '{{t("Connect to database view")}}', @@ -21,7 +20,8 @@ export const view: ICollectionTemplate = { 'x-decorator': 'FormItem', 'x-component': 'Input', }, - name: { + + databaseView: { title: '{{t("Connect to database view")}}', type: 'single', required: true, @@ -30,12 +30,37 @@ export const view: ICollectionTemplate = { 'x-reactions': ['{{useAsyncDataSource(loadDBViews)}}'], 'x-disabled': '{{ !createOnly }}', }, + name: { + type: 'string', + title: '{{t("Collection name")}}', + required: true, + 'x-disabled': '{{ !createOnly }}', + 'x-decorator': 'FormItem', + 'x-component': 'Input', + 'x-validator': 'uid', + description: + "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}", + 'x-reactions': { + dependencies: ['databaseView'], + when: '{{isPG}}', + fulfill: { + state: { + initialValue: '{{$deps[0]&&$deps[0].match(/^([^_]+)_(.*)$/)?.[2]}}', + }, + }, + otherwise: { + state: { + value: null, + }, + }, + }, + }, schema: { type: 'string', 'x-hidden': true, 'x-reactions': { - dependencies: ['name'], - when: "{{isPG}}", + dependencies: ['databaseView'], + when: '{{isPG}}', fulfill: { state: { value: "{{$deps[0].split('_')?.[0]}}", @@ -52,8 +77,8 @@ export const view: ICollectionTemplate = { type: 'string', 'x-hidden': true, 'x-reactions': { - dependencies: ['name'], - when: "{{isPG}}", + dependencies: ['databaseView'], + when: '{{isPG}}', fulfill: { state: { value: '{{$deps[0].match(/^([^_]+)_(.*)$/)?.[2]}}', @@ -80,6 +105,7 @@ export const view: ICollectionTemplate = { fields: { type: 'array', 'x-component': PreviewFields, + 'x-visible': '{{ createOnly }}', 'x-reactions': { dependencies: ['name'], fulfill: { @@ -91,9 +117,10 @@ export const view: ICollectionTemplate = { }, preview: { type: 'object', + 'x-visible': '{{ createOnly }}', 'x-component': PreviewTable, 'x-reactions': { - dependencies: ['name','fields'], + dependencies: ['name', 'fields'], fulfill: { schema: { 'x-component-props': '{{$form.values}}', //任意层次属性都支持表达式 diff --git a/packages/plugins/collection-manager/src/__tests__/http-api/view-collection.test.ts b/packages/plugins/collection-manager/src/__tests__/http-api/view-collection.test.ts index 451357db84..56de328c70 100644 --- a/packages/plugins/collection-manager/src/__tests__/http-api/view-collection.test.ts +++ b/packages/plugins/collection-manager/src/__tests__/http-api/view-collection.test.ts @@ -47,6 +47,25 @@ SELECT * FROM numbers; const response = await agent.resource('dbViews').list(); expect(response.status).toBe(200); expect(response.body.data.find((item) => item.name === testViewName)).toBeTruthy(); + + await app.db.getCollection('collections').repository.create({ + values: { + name: testViewName, + view: true, + schema: app.db.inDialect('postgres') ? 'public' : undefined, + fields: [ + { + name: 'numbers', + type: 'integer', + }, + ], + }, + context: {}, + }); + + const response2 = await agent.resource('dbViews').list(); + expect(response2.status).toBe(200); + expect(response2.body.data.find((item) => item.name === testViewName)).toBeFalsy(); }); it('should query views data', async () => { diff --git a/packages/plugins/collection-manager/src/__tests__/view/view-collection.test.ts b/packages/plugins/collection-manager/src/__tests__/view/view-collection.test.ts index 8f80ef6567..35c568837c 100644 --- a/packages/plugins/collection-manager/src/__tests__/view/view-collection.test.ts +++ b/packages/plugins/collection-manager/src/__tests__/view/view-collection.test.ts @@ -1,6 +1,7 @@ import Database, { Repository, ViewCollection } from '@nocobase/database'; import Application from '@nocobase/server'; import { createApp } from '../index'; +import { uid } from '@nocobase/utils'; describe('view collection', function () { let db: Database; @@ -27,8 +28,67 @@ describe('view collection', function () { await app.destroy(); }); + it('should save view collection in difference schema', async () => { + if (!db.inDialect('postgres')) { + return; + } + + const viewName = 'test_view'; + const dbSchema = db.options.schema || 'public'; + const randomSchema = `s_${uid(6)}`; + await db.sequelize.query(`CREATE SCHEMA IF NOT EXISTS ${randomSchema};`); + await db.sequelize.query(`CREATE OR REPLACE VIEW ${dbSchema}.${viewName} AS select 1+1 as "view_1"`); + await db.sequelize.query(`CREATE OR REPLACE VIEW ${randomSchema}.${viewName} AS select 1+1 as "view_2"`); + + await collectionRepository.create({ + values: { + name: viewName, + view: true, + fields: [{ type: 'string', name: 'view_1' }], + schema: dbSchema, + }, + context: {}, + }); + + const viewCollection = db.getCollection(viewName); + expect(viewCollection).toBeInstanceOf(ViewCollection); + + let err; + try { + await collectionRepository.create({ + values: { + name: viewName, + view: true, + fields: [{ type: 'string', name: 'view_2' }], + schema: randomSchema, + }, + context: {}, + }); + } catch (e) { + err = e; + } + + expect(err).toBeTruthy(); + + await collectionRepository.create({ + values: { + name: `${randomSchema}_${viewName}`, + view: true, + viewName: 'test_view', + fields: [{ type: 'string', name: 'view_2' }], + schema: randomSchema, + }, + context: {}, + }); + + const otherSchemaView = db.getCollection(`${randomSchema}_${viewName}`); + expect(otherSchemaView.options.viewName).toBe(viewName); + expect(otherSchemaView.options.schema).toBe(randomSchema); + }); + it('should support view with dot field', async () => { const dropViewSQL = `DROP VIEW IF EXISTS test_view`; + await db.sequelize.query(dropViewSQL); const viewSQL = `CREATE VIEW test_view AS select 1+1 as "dot.results"`; await db.sequelize.query(viewSQL); diff --git a/packages/plugins/collection-manager/src/hooks/beforeCreateForViewCollection.ts b/packages/plugins/collection-manager/src/hooks/beforeCreateForViewCollection.ts index d44ea868e5..6cd094dc61 100644 --- a/packages/plugins/collection-manager/src/hooks/beforeCreateForViewCollection.ts +++ b/packages/plugins/collection-manager/src/hooks/beforeCreateForViewCollection.ts @@ -1,16 +1,5 @@ import { Database } from '@nocobase/database'; export function beforeCreateForViewCollection(db: Database) { - return async (model, { transaction, context }) => { - if (model.get('viewSQL')) { - const name = model.get('name'); - const sql = model.get('viewSQL'); - - await db.sequelize.query(`CREATE OR REPLACE VIEW "${name}" AS ${sql}`, { - transaction, - }); - - model.set('viewName', name); - } - }; + return async (model, { transaction, context }) => {}; } diff --git a/packages/plugins/collection-manager/src/resourcers/views.ts b/packages/plugins/collection-manager/src/resourcers/views.ts index 7676362aa5..6e1c9a8660 100644 --- a/packages/plugins/collection-manager/src/resourcers/views.ts +++ b/packages/plugins/collection-manager/src/resourcers/views.ts @@ -27,14 +27,30 @@ export default { await next(); }, - async list(ctx, next) { + + list: async function (ctx, next) { const db = ctx.app.db as Database; const dbViews = await db.queryInterface.listViews(); - ctx.body = dbViews.map((dbView) => { - return { - ...dbView, - }; - }); + + const viewCollections = Array.from(db.collections.values()).filter((collection) => collection.isView()); + + ctx.body = dbViews + .map((dbView) => { + return { + ...dbView, + }; + }) + .filter((dbView) => { + // if view is connected, skip + return !viewCollections.find((collection) => { + const viewName = dbView.name; + const schema = dbView.schema; + + const collectionViewName = collection.options.viewName || collection.options.name; + + return collectionViewName === viewName && collection.options.schema === schema; + }); + }); await next(); }, @@ -46,7 +62,9 @@ export default { const limit = 1 * pageSize; const sql = `SELECT * - FROM ${ctx.app.db.utils.quoteTable(ctx.app.db.utils.addSchema(filterByTk, schema))} LIMIT ${limit} OFFSET ${offset}`; + FROM ${ctx.app.db.utils.quoteTable( + ctx.app.db.utils.addSchema(filterByTk, schema), + )} LIMIT ${limit} OFFSET ${offset}`; ctx.body = await ctx.app.db.sequelize.query(sql, { type: 'SELECT' }); await next(); diff --git a/packages/plugins/collection-manager/src/server.ts b/packages/plugins/collection-manager/src/server.ts index 7d639b4ca3..45e84598af 100644 --- a/packages/plugins/collection-manager/src/server.ts +++ b/packages/plugins/collection-manager/src/server.ts @@ -11,7 +11,7 @@ import { afterCreateForReverseField, beforeCreateForReverseField, beforeDestroyForeignKey, - beforeInitOptions + beforeInitOptions, } from './hooks'; import { InheritedCollection } from '@nocobase/database'; @@ -277,16 +277,22 @@ export class CollectionManagerPlugin extends Plugin { for (const field of castArray(fields)) { if (field.get('source')) { const [collectionSource, fieldSource] = field.get('source').split('.'); + // find original field const collectionField = this.app.db.getCollection(collectionSource).getField(fieldSource); const newOptions = {}; + + // write original field options lodash.merge(newOptions, collectionField.options); + + // merge with current field options lodash.mergeWith(newOptions, field.get(), (objValue, srcValue) => { if (srcValue === null) { return objValue; } }); + // set final options field.set('options', newOptions); } }