refactor: view collection options (#1643)

* feat: add schema prefix to view only if view name exists

* chore: view-collection name without schema prefix

* chore: skip list views already connected

* chore: comment

* fix: update field error

* refactor: collection edit can not config fields

* fix: viewName set

* fix: transaction

* refactor: viewname

* chore: collection view name

* refactor: viewname

* chore: remove rename view collection name

* refactor: loading wthen action submjit

---------

Co-authored-by: katherinehhh <katherine_15995@163.com>
This commit is contained in:
ChengLei Shao 2023-04-04 10:52:47 +08:00 committed by GitHub
parent 914260ed7e
commit fff11df2ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 168 additions and 41 deletions

View File

@ -1,6 +1,6 @@
import { DownOutlined, PlusOutlined } from '@ant-design/icons'; import { DownOutlined, PlusOutlined } from '@ant-design/icons';
import { ArrayTable } from '@formily/antd'; import { ArrayTable } from '@formily/antd';
import { ISchema, useForm } from '@formily/react'; import { ISchema, useField, useForm } from '@formily/react';
import { uid } from '@formily/shared'; import { uid } from '@formily/shared';
import { Button, Dropdown, Menu } from 'antd'; import { Button, Dropdown, Menu } from 'antd';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
@ -28,7 +28,7 @@ const getSchema = (schema, category, compile): ISchema => {
properties['defaultValue']['x-decorator'] = 'FormItem'; properties['defaultValue']['x-decorator'] = 'FormItem';
} }
const initialValue: any = { const initialValue: any = {
name: schema.name !== 'view' ? `t_${uid()}` : null, name: `t_${uid()}`,
template: schema.name, template: schema.name,
view: schema.name === 'view', view: schema.name === 'view',
category, category,
@ -194,9 +194,12 @@ const useCreateCollection = (schema?: any) => {
const { refreshCM } = useCollectionManager(); const { refreshCM } = useCollectionManager();
const ctx = useActionContext(); const ctx = useActionContext();
const { refresh } = useResourceActionContext(); const { refresh } = useResourceActionContext();
const { resource, collection } = useResourceContext(); const { resource } = useResourceContext();
const field = useField();
return { return {
async run() { async run() {
field.data = field.data || {};
field.data.loading = true;
await form.submit(); await form.submit();
const values = cloneDeep(form.values); const values = cloneDeep(form.values);
if (schema?.events?.beforeSubmit) { if (schema?.events?.beforeSubmit) {
@ -218,6 +221,7 @@ const useCreateCollection = (schema?: any) => {
}); });
ctx.setVisible(false); ctx.setVisible(false);
await form.reset(); await form.reset();
field.data.loading = false;
refresh(); refresh();
await refreshCM(); await refreshCM();
}, },

View File

@ -1,6 +1,6 @@
import { PlusOutlined } from '@ant-design/icons'; import { PlusOutlined } from '@ant-design/icons';
import { ArrayTable } from '@formily/antd'; import { ArrayTable } from '@formily/antd';
import { useForm } from '@formily/react'; import { useForm, useField } from '@formily/react';
import { uid } from '@formily/shared'; import { uid } from '@formily/shared';
import { Button, Dropdown, Menu } from 'antd'; import { Button, Dropdown, Menu } from 'antd';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
@ -136,9 +136,12 @@ const useCreateCollectionField = () => {
const ctx = useActionContext(); const ctx = useActionContext();
const { refresh } = useResourceActionContext(); const { refresh } = useResourceActionContext();
const { resource } = useResourceContext(); const { resource } = useResourceContext();
const field = useField();
return { return {
async run() { async run() {
await form.submit(); await form.submit();
field.data = field.data || {};
field.data.loading = true;
const values = cloneDeep(form.values); const values = cloneDeep(form.values);
if (values.autoCreateReverseField) { if (values.autoCreateReverseField) {
} else { } else {
@ -148,6 +151,7 @@ const useCreateCollectionField = () => {
await resource.create({ values }); await resource.create({ values });
ctx.setVisible(false); ctx.setVisible(false);
await form.reset(); await form.reset();
field.data.loading = false;
refresh(); refresh();
await refreshCM(); await refreshCM();
}, },

View File

@ -21,7 +21,7 @@ const getInterfaceOptions = (data, type) => {
return interfaceOptions.filter((v) => v.children.length > 0); return interfaceOptions.filter((v) => v.children.length > 0);
}; };
const PreviewCom = (props) => { const PreviewCom = (props) => {
const { name, sources, viewName, schema } = props; const { databaseView, viewName,sources, schema } = props;
const { data: fields } = useContext(ResourceActionContext); const { data: fields } = useContext(ResourceActionContext);
const api = useAPIClient(); const api = useAPIClient();
const { t } = useTranslation(); const { t } = useTranslation();
@ -47,10 +47,10 @@ const PreviewCom = (props) => {
}); });
}); });
setSourceFields(data); setSourceFields(data);
}, [sources, name]); }, [sources, databaseView]);
useEffect(() => { useEffect(() => {
if (name) { if (databaseView) {
setLoading(true); setLoading(true);
api api
.resource(`dbViews`) .resource(`dbViews`)
@ -72,7 +72,7 @@ const PreviewCom = (props) => {
} }
}); });
} }
}, [name]); }, [databaseView]);
const handleFieldChange = (record, index) => { const handleFieldChange = (record, index) => {
dataSource.splice(index, 1, record); dataSource.splice(index, 1, record);
@ -175,7 +175,7 @@ const PreviewCom = (props) => {
const item = dataSource[index]; const item = dataSource[index];
return ( return (
<Input <Input
defaultValue={record?.uiSchema?.title} defaultValue={record?.uiSchema?.title||text}
onChange={(e) => onChange={(e) =>
handleFieldChange({ ...item, uiSchema: { ...item?.uiSchema, title: e.target.value } }, index) handleFieldChange({ ...item, uiSchema: { ...item?.uiSchema, title: e.target.value } }, index)
} }
@ -204,7 +204,7 @@ const PreviewCom = (props) => {
scroll={{ y: 300 }} scroll={{ y: 300 }}
pagination={false} pagination={false}
rowClassName="editable-row" rowClassName="editable-row"
key={name} key={viewName}
/> />
</> </>
)} )}

View File

@ -7,7 +7,7 @@ import { useAPIClient } from '../../../api-client';
import { useCollectionManager } from '../../hooks/useCollectionManager'; import { useCollectionManager } from '../../hooks/useCollectionManager';
export const PreviewTable = (props) => { export const PreviewTable = (props) => {
const { name, viewName, schema, fields } = props; const { databaseView, schema, viewName, fields } = props;
const [previewColumns, setPreviewColumns] = useState([]); const [previewColumns, setPreviewColumns] = useState([]);
const [previewData, setPreviewData] = useState([]); const [previewData, setPreviewData] = useState([]);
const compile = useCompile(); const compile = useCompile();
@ -17,10 +17,10 @@ export const PreviewTable = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const form = useForm(); const form = useForm();
useEffect(() => { useEffect(() => {
if (name) { if (databaseView) {
getPreviewData(); getPreviewData();
} }
}, [name]); }, [databaseView]);
useEffect(() => { useEffect(() => {
const pColumns = formatPreviewColumns(fields); const pColumns = formatPreviewColumns(fields);
@ -96,7 +96,7 @@ export const PreviewTable = (props) => {
columns={previewColumns} columns={previewColumns}
dataSource={previewData} dataSource={previewData}
scroll={{ x: 1000, y: 300 }} scroll={{ x: 1000, y: 300 }}
key={name} key={viewName}
/>, />,
]} ]}
</div> </div>

View File

@ -3,7 +3,6 @@ import { ICollectionTemplate } from './types';
import { PreviewFields } from './components/PreviewFields'; import { PreviewFields } from './components/PreviewFields';
import { PreviewTable } from './components/PreviewTable'; import { PreviewTable } from './components/PreviewTable';
export const view: ICollectionTemplate = { export const view: ICollectionTemplate = {
name: 'view', name: 'view',
title: '{{t("Connect to database view")}}', title: '{{t("Connect to database view")}}',
@ -21,7 +20,8 @@ export const view: ICollectionTemplate = {
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Input', 'x-component': 'Input',
}, },
name: {
databaseView: {
title: '{{t("Connect to database view")}}', title: '{{t("Connect to database view")}}',
type: 'single', type: 'single',
required: true, required: true,
@ -30,12 +30,37 @@ export const view: ICollectionTemplate = {
'x-reactions': ['{{useAsyncDataSource(loadDBViews)}}'], 'x-reactions': ['{{useAsyncDataSource(loadDBViews)}}'],
'x-disabled': '{{ !createOnly }}', '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: { schema: {
type: 'string', type: 'string',
'x-hidden': true, 'x-hidden': true,
'x-reactions': { 'x-reactions': {
dependencies: ['name'], dependencies: ['databaseView'],
when: "{{isPG}}", when: '{{isPG}}',
fulfill: { fulfill: {
state: { state: {
value: "{{$deps[0].split('_')?.[0]}}", value: "{{$deps[0].split('_')?.[0]}}",
@ -52,8 +77,8 @@ export const view: ICollectionTemplate = {
type: 'string', type: 'string',
'x-hidden': true, 'x-hidden': true,
'x-reactions': { 'x-reactions': {
dependencies: ['name'], dependencies: ['databaseView'],
when: "{{isPG}}", when: '{{isPG}}',
fulfill: { fulfill: {
state: { state: {
value: '{{$deps[0].match(/^([^_]+)_(.*)$/)?.[2]}}', value: '{{$deps[0].match(/^([^_]+)_(.*)$/)?.[2]}}',
@ -80,6 +105,7 @@ export const view: ICollectionTemplate = {
fields: { fields: {
type: 'array', type: 'array',
'x-component': PreviewFields, 'x-component': PreviewFields,
'x-visible': '{{ createOnly }}',
'x-reactions': { 'x-reactions': {
dependencies: ['name'], dependencies: ['name'],
fulfill: { fulfill: {
@ -91,9 +117,10 @@ export const view: ICollectionTemplate = {
}, },
preview: { preview: {
type: 'object', type: 'object',
'x-visible': '{{ createOnly }}',
'x-component': PreviewTable, 'x-component': PreviewTable,
'x-reactions': { 'x-reactions': {
dependencies: ['name','fields'], dependencies: ['name', 'fields'],
fulfill: { fulfill: {
schema: { schema: {
'x-component-props': '{{$form.values}}', //任意层次属性都支持表达式 'x-component-props': '{{$form.values}}', //任意层次属性都支持表达式

View File

@ -47,6 +47,25 @@ SELECT * FROM numbers;
const response = await agent.resource('dbViews').list(); const response = await agent.resource('dbViews').list();
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body.data.find((item) => item.name === testViewName)).toBeTruthy(); 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 () => { it('should query views data', async () => {

View File

@ -1,6 +1,7 @@
import Database, { Repository, ViewCollection } from '@nocobase/database'; import Database, { Repository, ViewCollection } from '@nocobase/database';
import Application from '@nocobase/server'; import Application from '@nocobase/server';
import { createApp } from '../index'; import { createApp } from '../index';
import { uid } from '@nocobase/utils';
describe('view collection', function () { describe('view collection', function () {
let db: Database; let db: Database;
@ -27,8 +28,67 @@ describe('view collection', function () {
await app.destroy(); 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 () => { it('should support view with dot field', async () => {
const dropViewSQL = `DROP VIEW IF EXISTS test_view`; const dropViewSQL = `DROP VIEW IF EXISTS test_view`;
await db.sequelize.query(dropViewSQL); await db.sequelize.query(dropViewSQL);
const viewSQL = `CREATE VIEW test_view AS select 1+1 as "dot.results"`; const viewSQL = `CREATE VIEW test_view AS select 1+1 as "dot.results"`;
await db.sequelize.query(viewSQL); await db.sequelize.query(viewSQL);

View File

@ -1,16 +1,5 @@
import { Database } from '@nocobase/database'; import { Database } from '@nocobase/database';
export function beforeCreateForViewCollection(db: Database) { export function beforeCreateForViewCollection(db: Database) {
return async (model, { transaction, context }) => { 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);
}
};
} }

View File

@ -27,14 +27,30 @@ export default {
await next(); await next();
}, },
async list(ctx, next) {
list: async function (ctx, next) {
const db = ctx.app.db as Database; const db = ctx.app.db as Database;
const dbViews = await db.queryInterface.listViews(); const dbViews = await db.queryInterface.listViews();
ctx.body = dbViews.map((dbView) => {
return { const viewCollections = Array.from(db.collections.values()).filter((collection) => collection.isView());
...dbView,
}; 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(); await next();
}, },
@ -46,7 +62,9 @@ export default {
const limit = 1 * pageSize; const limit = 1 * pageSize;
const sql = `SELECT * 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' }); ctx.body = await ctx.app.db.sequelize.query(sql, { type: 'SELECT' });
await next(); await next();

View File

@ -11,7 +11,7 @@ import {
afterCreateForReverseField, afterCreateForReverseField,
beforeCreateForReverseField, beforeCreateForReverseField,
beforeDestroyForeignKey, beforeDestroyForeignKey,
beforeInitOptions beforeInitOptions,
} from './hooks'; } from './hooks';
import { InheritedCollection } from '@nocobase/database'; import { InheritedCollection } from '@nocobase/database';
@ -277,16 +277,22 @@ export class CollectionManagerPlugin extends Plugin {
for (const field of castArray(fields)) { for (const field of castArray(fields)) {
if (field.get('source')) { if (field.get('source')) {
const [collectionSource, fieldSource] = field.get('source').split('.'); const [collectionSource, fieldSource] = field.get('source').split('.');
// find original field
const collectionField = this.app.db.getCollection(collectionSource).getField(fieldSource); const collectionField = this.app.db.getCollection(collectionSource).getField(fieldSource);
const newOptions = {}; const newOptions = {};
// write original field options
lodash.merge(newOptions, collectionField.options); lodash.merge(newOptions, collectionField.options);
// merge with current field options
lodash.mergeWith(newOptions, field.get(), (objValue, srcValue) => { lodash.mergeWith(newOptions, field.get(), (objValue, srcValue) => {
if (srcValue === null) { if (srcValue === null) {
return objValue; return objValue;
} }
}); });
// set final options
field.set('options', newOptions); field.set('options', newOptions);
} }
} }