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 { 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();
},

View File

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

View File

@ -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 (
<Input
defaultValue={record?.uiSchema?.title}
defaultValue={record?.uiSchema?.title||text}
onChange={(e) =>
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}
/>
</>
)}

View File

@ -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}
/>,
]}
</div>

View File

@ -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}}', //任意层次属性都支持表达式

View File

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

View File

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

View File

@ -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 }) => {};
}

View File

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

View File

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