diff --git a/.github/workflows/aliyun-container-registry.yml b/.github/workflows/aliyun-container-registry.yml index 20ab40f502..74b1fac947 100644 --- a/.github/workflows/aliyun-container-registry.yml +++ b/.github/workflows/aliyun-container-registry.yml @@ -22,19 +22,15 @@ jobs: ports: - 4873:4873 steps: - - - name: Checkout + - name: Checkout uses: actions/checkout@v3 - - - name: Set up QEMU + - name: Set up QEMU uses: docker/setup-qemu-action@v2 - - - name: Set up Docker Buildx + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 with: driver-opts: network=host - - - name: Docker meta + - name: Docker meta id: meta uses: docker/metadata-action@v4 with: @@ -45,20 +41,19 @@ jobs: type=ref,event=pr type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} - - - name: Login to Docker Hub + - name: Login to Docker Hub uses: docker/login-action@v2 with: registry: ${{ secrets.ALI_DOCKER_REGISTRY }} username: ${{ secrets.ALI_DOCKER_USERNAME }} password: ${{ secrets.ALI_DOCKER_PASSWORD }} - - - name: Build and push + - name: Build and push uses: docker/build-push-action@v3 with: context: . file: Dockerfile - build-args: | + build-args: | VERDACCIO_URL=http://localhost:4873/ + COMMIT_HASH=${GITHUB_SHA} push: true tags: ${{ secrets.ALI_DOCKER_REGISTRY }}/${{ steps.meta.outputs.tags }} diff --git a/.vscode/launch.json b/.vscode/launch.json index af29973429..c15b597d35 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,7 +1,4 @@ { - // 使用 IntelliSense 了解相关属性。 - // 悬停以查看现有属性的描述。 - // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { diff --git a/Dockerfile b/Dockerfile index ac9a1f7605..11879fa6d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,6 @@ FROM node:16 as builder ARG VERDACCIO_URL=http://host.docker.internal:10104/ +ARG COMIT_HASH RUN apt-get update && apt-get install -y jq WORKDIR /tmp @@ -43,6 +44,8 @@ COPY --from=builder /app/nocobase.tar.gz /app/nocobase.tar.gz WORKDIR /app/nocobase +RUN mkdir -p /app/nocobase/storage/uploads/ && echo "$COMIT_HASH" >> /app/nocobase/storage/uploads/COMIT_HASH + COPY ./docker/nocobase/docker-entrypoint.sh /app/ CMD ["/app/docker-entrypoint.sh"] diff --git a/docker/nocobase/nocobase.conf b/docker/nocobase/nocobase.conf index 9a7fe50f48..a65286cf63 100644 --- a/docker/nocobase/nocobase.conf +++ b/docker/nocobase/nocobase.conf @@ -9,7 +9,7 @@ server { gzip_vary on; gzip_min_length 1024; gzip_proxied expired no-cache no-store private auth; - gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml; + gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/javascript application/xml; location /storage/uploads/ { alias /app/nocobase/storage/uploads/; diff --git a/docs/zh-CN/welcome/release/inherits.md b/docs/zh-CN/welcome/release/inherits.md new file mode 100644 index 0000000000..a4a5278ec4 --- /dev/null +++ b/docs/zh-CN/welcome/release/inherits.md @@ -0,0 +1,74 @@ +# v0.8.1: 数据表继承 + +数据表继承基于 [PostgreSQL 的 INHERITS 语法](https://www.postgresql.org/docs/current/tutorial-inheritance.html) 实现,仅限于 PostgreSQL 数据库安装的 NocoBase 时才会提供。 + +## 示例 + +我们从一个例子开始,假设要做一个教学系统,有三类用户:学生、家长和老师。 + +如果没有继承,要分别为三类用户建表: + +- 学生:姓名、年龄、性别、身份证 +- 家长:姓名、年龄、性别、职业、学历 +- 老师:姓名、年龄、性别、教龄、已婚 + +有了数据表继承之后,共同的信息就可以提炼出来: + +- 用户:姓名、年龄、性别 +- 学生:身份证 +- 家长:职业、学历 +- 老师:教龄、已婚 + +ER 图如下: + + + +注:子表 ID 和父表 ID 共享序列 + +## 配置数据表继承 + +Inherits 字段选择需要继承的数据表 + + + +通过代码配置如下: + +```ts +db.collection({ + name: 'users', +}); + +db.collection({ + name: 'students', + inherits: 'users', +}); +``` + +注意: + +- 继承的表并不能随意选择,主键必须是唯一序列,比如 uuid 或者所有继承线路上的表的 id 自增序列都用同一个 +- Inherits 参数不能被编辑 +- 如果有继承关系,被继承的父表不能被删除 + +## 数据表字段列表 + +字段列表里同步显示继承的父表字段,父表字段不可以修改,但可以重写(Override) + + + +重写父表字段的注意事项: +- 子表字段标识与父表字段一样时为重写 +- 重写字段的类型必须保持一致 +- 关系字段除了 target collection 以外的其他参数需要保持一致 + +## 父表的子表区块 + +在父表区块里可以配置子表的区块 + + + +## 新增继承的父表字段的配置 + +当有继承的父表时,配置字段时,会提供从父表继承的字段的配置 + + diff --git a/docs/zh-CN/welcome/release/inherits/configure-fields.jpg b/docs/zh-CN/welcome/release/inherits/configure-fields.jpg new file mode 100644 index 0000000000..adf256da3b Binary files /dev/null and b/docs/zh-CN/welcome/release/inherits/configure-fields.jpg differ diff --git a/docs/zh-CN/welcome/release/inherits/er.svg b/docs/zh-CN/welcome/release/inherits/er.svg new file mode 100644 index 0000000000..f1ba5c251b --- /dev/null +++ b/docs/zh-CN/welcome/release/inherits/er.svg @@ -0,0 +1,4 @@ + + + +UsersPKidnameagegenderStudentsPKididentityNumberParentsPKidoccupationTeachersPKidmarried \ No newline at end of file diff --git a/docs/zh-CN/welcome/release/inherits/form.jpg b/docs/zh-CN/welcome/release/inherits/form.jpg new file mode 100644 index 0000000000..2de047cd59 Binary files /dev/null and b/docs/zh-CN/welcome/release/inherits/form.jpg differ diff --git a/docs/zh-CN/welcome/release/inherits/inherit-fields.jpg b/docs/zh-CN/welcome/release/inherits/inherit-fields.jpg new file mode 100644 index 0000000000..acdaddda96 Binary files /dev/null and b/docs/zh-CN/welcome/release/inherits/inherit-fields.jpg differ diff --git a/docs/zh-CN/welcome/release/inherits/inherit.jpg b/docs/zh-CN/welcome/release/inherits/inherit.jpg new file mode 100644 index 0000000000..ecc419daa6 Binary files /dev/null and b/docs/zh-CN/welcome/release/inherits/inherit.jpg differ diff --git a/docs/zh-CN/welcome/release/inherits/inherited-blocks.jpg b/docs/zh-CN/welcome/release/inherits/inherited-blocks.jpg new file mode 100644 index 0000000000..e95928c20a Binary files /dev/null and b/docs/zh-CN/welcome/release/inherits/inherited-blocks.jpg differ diff --git a/packages/app/server/src/config/database.ts b/packages/app/server/src/config/database.ts index fa6df44cd8..ed5d46d007 100644 --- a/packages/app/server/src/config/database.ts +++ b/packages/app/server/src/config/database.ts @@ -1,7 +1,7 @@ import { IDatabaseOptions } from '@nocobase/database'; export default { - logging: process.env.DB_LOGGING === 'on' ? console.log : false, + logging: process.env.DB_LOGGING == 'on' ? customLogger : false, dialect: process.env.DB_DIALECT as any, storage: process.env.DB_STORAGE, username: process.env.DB_USER, @@ -12,3 +12,8 @@ export default { timezone: process.env.DB_TIMEZONE, tablePrefix: process.env.DB_TABLE_PREFIX, } as IDatabaseOptions; + +function customLogger(queryString, queryObject) { + console.log(queryString); // outputs a string + console.log(queryObject.bind); // outputs an array +} diff --git a/packages/core/client/src/block-provider/FormBlockProvider.tsx b/packages/core/client/src/block-provider/FormBlockProvider.tsx index 49f8cb5c2d..cd852417c4 100644 --- a/packages/core/client/src/block-provider/FormBlockProvider.tsx +++ b/packages/core/client/src/block-provider/FormBlockProvider.tsx @@ -1,9 +1,11 @@ import { createForm } from '@formily/core'; -import { useField } from '@formily/react'; +import { useField, useFieldSchema } from '@formily/react'; import { Spin } from 'antd'; import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react'; -import { RecordProvider } from '../record-provider'; +import { RecordProvider, useRecord } from '../record-provider'; import { BlockProvider, useBlockRequestContext } from './BlockProvider'; +import { useDesignable } from '../schema-component'; +import { useCollectionManager } from '../collection-manager'; export const FormBlockContext = createContext({}); @@ -46,10 +48,19 @@ const InternalFormBlockProvider = (props) => { }; export const FormBlockProvider = (props) => { + const record = useRecord(); + const { __tableName } = record; + const { getParentCollections } = useCollectionManager(); + const inheritCollections = getParentCollections(__tableName); + const { designable } = useDesignable(); + const flag = + !designable && __tableName && !inheritCollections.includes(props.collection) && __tableName !== props.collection; return ( - - - + !flag && ( + + + + ) ); }; diff --git a/packages/core/client/src/block-provider/hooks/index.ts b/packages/core/client/src/block-provider/hooks/index.ts index 1fdb0bb481..1d6bc93bba 100644 --- a/packages/core/client/src/block-provider/hooks/index.ts +++ b/packages/core/client/src/block-provider/hooks/index.ts @@ -639,4 +639,4 @@ export const useDetailsPaginationProps = () => { textAlign: 'center', }, }; -}; +}; \ No newline at end of file diff --git a/packages/core/client/src/collection-manager/CollectionManagerShortcut.tsx b/packages/core/client/src/collection-manager/CollectionManagerShortcut.tsx index 3a2651f756..33094b5726 100644 --- a/packages/core/client/src/collection-manager/CollectionManagerShortcut.tsx +++ b/packages/core/client/src/collection-manager/CollectionManagerShortcut.tsx @@ -7,7 +7,17 @@ import { useTranslation } from 'react-i18next'; import { useHistory } from 'react-router-dom'; import { PluginManager } from '../plugin-manager'; import { ActionContext, SchemaComponent } from '../schema-component'; -import { AddCollectionField, AddFieldAction, ConfigurationTable, EditFieldAction,EditCollectionField } from './Configuration'; +import { + AddCollectionField, + AddFieldAction, + ConfigurationTable, + EditFieldAction, + EditCollectionField, + OverridingFieldAction, + OverridingCollectionField, + ViewCollectionField, + ViewFieldAction, +} from './Configuration'; const schema: ISchema = { type: 'object', @@ -37,7 +47,20 @@ const schema2: ISchema = { export const CollectionManagerPane = () => { return ( - + ); }; @@ -68,7 +91,16 @@ export const CollectionManagerShortcut2 = () => { setVisible(true); }} /> - + ); }; diff --git a/packages/core/client/src/collection-manager/Configuration/CollectionFieldsTableArray.tsx b/packages/core/client/src/collection-manager/Configuration/CollectionFieldsTableArray.tsx index 6fb17d5d80..40b8db422e 100644 --- a/packages/core/client/src/collection-manager/Configuration/CollectionFieldsTableArray.tsx +++ b/packages/core/client/src/collection-manager/Configuration/CollectionFieldsTableArray.tsx @@ -3,52 +3,24 @@ import { ArrayField, Field } from '@formily/core'; import { observer, RecursionField, Schema, useField, useFieldSchema } from '@formily/react'; import { Table, TableColumnProps } from 'antd'; import { default as classNames } from 'classnames'; -import React, { useState } from 'react'; +import React, { useState, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { RecordIndexProvider, RecordProvider, useCollectionManager, useRequest, useSchemaInitializer } from '../..'; +import { findIndex } from 'lodash'; +import { + RecordIndexProvider, + RecordProvider, + useCollectionManager, + useRequest, + useSchemaInitializer, + useRecord, + useCompile, +} from '../..'; +import { overridingSchema } from '../Configuration/schemas/collectionFields'; const isColumnComponent = (schema: Schema) => { return schema['x-component']?.endsWith('.Column') > -1; }; -const useTableColumns = () => { - const field = useField(); - const schema = useFieldSchema(); - const { exists, render } = useSchemaInitializer(schema['x-initializer']); - const columns = schema - .reduceProperties((buf, s) => { - if (isColumnComponent(s)) { - return buf.concat([s]); - } - }, []) - .map((s: Schema) => { - return { - title: , - dataIndex: s.name, - key: s.name, - render: (v, record) => { - const index = field.value?.indexOf(record); - // console.log((Date.now() - start) / 1000); - return ( - - - - - - ); - }, - } as TableColumnProps; - }); - if (!exists) { - return columns; - } - return columns.concat({ - title: render(), - dataIndex: 'TABLE_COLUMN_INITIALIZER', - key: 'TABLE_COLUMN_INITIALIZER', - }); -}; - export const components = { body: { row: (props) => { @@ -93,7 +65,6 @@ const groupColumns = [ ]; type CategorizeKey = 'primaryAndForeignKey' | 'relation' | 'systemInfo' | 'basic'; -const sortKeyArr: Array = ['primaryAndForeignKey', 'relation', 'basic', 'systemInfo']; const CategorizeKeyNameMap = new Map([ ['primaryAndForeignKey', 'PK & FK fields'], ['relation', 'Association fields'], @@ -108,10 +79,13 @@ interface CategorizeDataItem { } export const CollectionFieldsTableArray: React.FC = observer((props) => { + const sortKeyArr: Array = ['primaryAndForeignKey', 'relation', 'basic', 'systemInfo']; const field = useField(); - const columns = useTableColumns(); + const { name } = useRecord(); const { t } = useTranslation(); - const { getInterface } = useCollectionManager(); + const compile = useCompile(); + const { getInterface, getParentCollections, getCollection, getCurrentCollectionFields, getInheritedFields } = + useCollectionManager(); const { showIndex = true, useSelectedRowKeys = useDef, @@ -120,12 +94,14 @@ export const CollectionFieldsTableArray: React.FC = observer((props) => { ...others } = props; const [selectedRowKeys, setSelectedRowKeys] = useSelectedRowKeys(); - const [categorizeData, setCategorizeData] = useState>([]); + const [expandedKeys, setExpendedKeys] = useState(selectedRowKeys); + const inherits = getParentCollections(name); + const currentFields = getCurrentCollectionFields(name); useDataSource({ onSuccess(data) { field.value = data?.data || []; - // categorize field + const tmpData: Array = []; const categorizeMap = new Map(); const addCategorizeVal = (categorizeKey: CategorizeKey, val) => { let fieldArr = categorizeMap.get(categorizeKey); @@ -151,37 +127,113 @@ export const CollectionFieldsTableArray: React.FC = observer((props) => { addCategorizeVal('basic', item); } }); - const tmpData: Array = []; + if (inherits) { + inherits.forEach((v) => { + sortKeyArr.push(v); + const parentCollection = getCollection(v); + parentCollection.fields.map((k) => { + if (k.interface) { + addCategorizeVal(v, new Proxy(k, {})); + field.value.push(new Proxy(k, {})); + } + }); + }); + } sortKeyArr.forEach((key) => { if (categorizeMap.get(key)?.length > 0) { + const parentCollection = getCollection(key); tmpData.push({ key, - name: t(CategorizeKeyNameMap.get(key)), + name: + t(CategorizeKeyNameMap.get(key)) || + t(`Parent collection fields`) + `(${compile(parentCollection.title)})`, data: categorizeMap.get(key), }); } }); + setExpendedKeys(sortKeyArr); setCategorizeData(tmpData); }, }); - const restProps = { - rowSelection: props.rowSelection - ? { - type: 'checkbox', - selectedRowKeys, - onChange(selectedRowKeys: any[]) { - setSelectedRowKeys(selectedRowKeys); - }, - ...props.rowSelection, + const useTableColumns = () => { + const schema = useFieldSchema(); + const { exists, render } = useSchemaInitializer(schema['x-initializer']); + const columns = schema + .reduceProperties((buf, s) => { + if (isColumnComponent(s)) { + return buf.concat([s]); } - : undefined, + }, []) + .map((s: Schema) => { + return { + title: , + dataIndex: s.name, + key: s.name, + render: (v, record) => { + const index = findIndex(field.value, record); + return ( + + + + + + ); + }, + } as TableColumnProps; + }); + if (!exists) { + return columns; + } + return columns.concat({ + title: render(), + dataIndex: 'TABLE_COLUMN_INITIALIZER', + key: 'TABLE_COLUMN_INITIALIZER', + }); }; - const defaultRowKey = (record: any) => { - return field.value?.indexOf?.(record); + const getIsOverriding = (record) => { + const flag = currentFields.find((v) => { + return v.name === record.name; + }); + return !flag; }; const expandedRowRender = (record: CategorizeDataItem, index, indent, expanded) => { + const columns = useTableColumns(); + if (inherits.includes(record.key)) { + columns.pop(); + columns.push({ + title: , + dataIndex: 'column4', + key: 'column4', + render: (v, record) => { + const index = findIndex(field.value, record); + const flag = getIsOverriding(record); + //@ts-ignore + overridingSchema.properties.actions.properties.overriding['x-visible'] = flag; + return ( + + + + + + ); + }, + }); + } + const restProps = { + rowSelection: + props.rowSelection && !inherits.includes(record.key) + ? { + type: 'checkbox', + selectedRowKeys, + onChange(selectedRowKeys: any[]) { + setSelectedRowKeys(selectedRowKeys); + }, + ...props.rowSelection, + } + : undefined, + }; return ( = observer((props) => { pagination={false} expandable={{ expandedRowRender, - defaultExpandedRowKeys: sortKeyArr, + expandedRowKeys: expandedKeys, + }} + onExpand={(expanded, record) => { + let keys = []; + if (expanded) { + keys = expandedKeys.concat([record.key]); + } else { + keys = expandedKeys.filter((v) => { + return v !== record.key; + }); + } + setExpendedKeys(keys); }} /> diff --git a/packages/core/client/src/collection-manager/Configuration/ConfigurationTable.tsx b/packages/core/client/src/collection-manager/Configuration/ConfigurationTable.tsx index 8310cbbf3e..d8dba5fe17 100644 --- a/packages/core/client/src/collection-manager/Configuration/ConfigurationTable.tsx +++ b/packages/core/client/src/collection-manager/Configuration/ConfigurationTable.tsx @@ -1,17 +1,19 @@ import { useForm } from '@formily/react'; import { action } from '@formily/reactive'; import { uid } from '@formily/shared'; -import React, { useContext, useEffect, useState } from 'react'; +import React, { useContext, useEffect, useRef, useState } from 'react'; +import { CollectionFieldsTable } from '.'; import { useRequest } from '../../api-client'; +import { useCurrentDatabase } from '../../database'; import { useRecord } from '../../record-provider'; import { SchemaComponent, SchemaComponentContext, useActionContext, useCompile } from '../../schema-component'; +import { useCancelAction, useUpdateCollectionActionAndRefreshCM } from '../action-hooks'; import { useCollectionManager } from '../hooks/useCollectionManager'; import { DataSourceContext } from '../sub-table'; import { AddSubFieldAction } from './AddSubFieldAction'; import { FieldSummary } from './components/FieldSummary'; import { EditSubFieldAction } from './EditSubFieldAction'; import { collectionSchema } from './schemas/collections'; -import { CollectionFieldsTable } from "."; const useAsyncDataSource = (service: any) => (field: any) => { field.loading = true; @@ -173,9 +175,14 @@ const useNewId = (prefix) => { export const ConfigurationTable = () => { const { collections = [] } = useCollectionManager(); + const { + data: { database }, + } = useCurrentDatabase(); + const collectonsRef: any = useRef(); + collectonsRef.current = collections; const compile = useCompile(); const loadCollections = async (field: any) => { - return collections + return collectonsRef.current ?.filter((item) => !(item.autoCreate && item.isThrough)) .map((item: any) => ({ label: compile(item.title), @@ -187,14 +194,15 @@ export const ConfigurationTable = () => {
{ loadCollections, useCurrentFields, useNewId, + useCancelAction, + useUpdateCollectionActionAndRefreshCM, }} /> diff --git a/packages/core/client/src/collection-manager/Configuration/OverridingCollectionField.tsx b/packages/core/client/src/collection-manager/Configuration/OverridingCollectionField.tsx new file mode 100644 index 0000000000..792397a96d --- /dev/null +++ b/packages/core/client/src/collection-manager/Configuration/OverridingCollectionField.tsx @@ -0,0 +1,180 @@ +import { ArrayTable } from '@formily/antd'; +import { ISchema, useForm } from '@formily/react'; +import { uid } from '@formily/shared'; +import cloneDeep from 'lodash/cloneDeep'; +import set from 'lodash/set'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useAPIClient, useRequest } from '../../api-client'; +import { useRecord, RecordProvider } from '../../record-provider'; +import { ActionContext, SchemaComponent, useActionContext, useCompile } from '../../schema-component'; +import { useCancelAction } from '../action-hooks'; +import { useCollectionManager } from '../hooks'; +import { IField } from '../interfaces/types'; +import { useResourceActionContext, useResourceContext } from '../ResourceActionProvider'; +import * as components from './components'; + +const getSchema = (schema: IField, record: any, compile, getContainer): ISchema => { + if (!schema) { + return; + } + const properties = cloneDeep(schema.properties) as any; + properties.name['x-disabled'] = true; + + if (schema.hasDefaultValue === true) { + properties['defaultValue'] = cloneDeep(schema.default.uiSchema); + properties['defaultValue']['title'] = compile('{{ t("Default value") }}'); + properties['defaultValue']['x-decorator'] = 'FormItem'; + } + return { + type: 'object', + properties: { + [uid()]: { + type: 'void', + 'x-component': 'Action.Drawer', + 'x-decorator': 'Form', + 'x-decorator-props': { + useValues(options) { + return useRequest( + () => + Promise.resolve({ + data: cloneDeep(schema.default), + }), + options, + ); + }, + }, + title: `${compile(record.__parent?.title)} - ${compile('{{ t("Override field") }}')}`, + properties: { + summary: { + type: 'void', + 'x-component': 'FieldSummary', + 'x-component-props': { + schemaKey: schema.name, + }, + }, + // @ts-ignore + ...properties, + footer: { + type: 'void', + 'x-component': 'Action.Drawer.Footer', + properties: { + action1: { + title: '{{ t("Cancel") }}', + 'x-component': 'Action', + 'x-component-props': { + useAction: '{{ useCancelAction }}', + }, + }, + action2: { + title: '{{ t("Submit") }}', + 'x-component': 'Action', + 'x-component-props': { + type: 'primary', + useAction: '{{ useOverridingCollectionField }}', + }, + }, + }, + }, + }, + }, + }, + }; +}; + +const useOverridingCollectionField = () => { + const form = useForm(); + const { refreshCM } = useCollectionManager(); + const ctx = useActionContext(); + const { refresh } = useResourceActionContext(); + const { resource } = useResourceContext(); + return { + async run() { + await form.submit(); + const values = cloneDeep(form.values); + if (values.autoCreateReverseField) { + } else { + delete values.reverseField; + } + delete values.autoCreateReverseField; + const { uiSchema } = values; + delete values.collectionName; + delete values.key; + await resource.create({ + values: { + ...values, + uiSchema: { title: uiSchema.title, type: uiSchema.type, 'x-component': uiSchema['x-component'] }, + }, + }); + ctx.setVisible(false); + await form.reset(); + refresh(); + await refreshCM(); + }, + }; +}; + +export const OverridingCollectionField = (props) => { + const record = useRecord(); + return ; +}; + +export const OverridingFieldAction = (props) => { + const { scope, getContainer, item: record, children } = props; + const { getInterface } = useCollectionManager(); + const [visible, setVisible] = useState(false); + const [schema, setSchema] = useState({}); + const api = useAPIClient(); + const { t } = useTranslation(); + const compile = useCompile(); + const [data, setData] = useState({}); + + return ( + + + { + const { data } = await api.resource('collections.fields', record.collectionName).get({ + filterByTk: record.name, + appends: ['uiSchema', 'reverseField'], + }); + setData(data?.data); + const interfaceConf = getInterface(record.interface); + const defaultValues: any = cloneDeep(data?.data) || {}; + if (!defaultValues?.reverseField) { + defaultValues.autoCreateReverseField = false; + defaultValues.reverseField = interfaceConf.default?.reverseField; + set(defaultValues.reverseField, 'name', `f_${uid()}`); + set(defaultValues.reverseField, 'uiSchema.title', record.__parent.title); + } + const schema = getSchema( + { + ...interfaceConf, + default: defaultValues, + }, + record, + compile, + getContainer, + ); + setSchema(schema); + setVisible(true); + }} + > + {children || t('Override')} + + + + + ); +}; diff --git a/packages/core/client/src/collection-manager/Configuration/ViewInheritedField.tsx b/packages/core/client/src/collection-manager/Configuration/ViewInheritedField.tsx new file mode 100644 index 0000000000..530dfbe4c6 --- /dev/null +++ b/packages/core/client/src/collection-manager/Configuration/ViewInheritedField.tsx @@ -0,0 +1,179 @@ +import { ArrayTable } from '@formily/antd'; +import { ISchema, useForm } from '@formily/react'; +import { uid } from '@formily/shared'; +import cloneDeep from 'lodash/cloneDeep'; +import set from 'lodash/set'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useAPIClient, useRequest } from '../../api-client'; +import { useRecord, RecordProvider } from '../../record-provider'; +import { ActionContext, SchemaComponent, useActionContext, useCompile } from '../../schema-component'; +import { useCancelAction } from '../action-hooks'; +import { useCollectionManager } from '../hooks'; +import { IField } from '../interfaces/types'; +import { useResourceActionContext, useResourceContext } from '../ResourceActionProvider'; +import * as components from './components'; + +const getSchema = (schema: IField, record: any, compile, getContainer): ISchema => { + if (!schema) { + return; + } + const properties = cloneDeep(schema.properties) as any; + properties.name['x-disabled'] = true; + if (schema.hasDefaultValue === true) { + properties['defaultValue'] = cloneDeep(schema.default.uiSchema); + properties['defaultValue']['title'] = compile('{{ t("Default value") }}'); + properties['defaultValue']['x-decorator'] = 'FormItem'; + } + return { + type: 'object', + properties: { + [uid()]: { + type: 'void', + 'x-component': 'Action.Drawer', + 'x-decorator': 'Form', + 'x-decorator-props': { + useValues(options) { + return useRequest( + () => + Promise.resolve({ + data: cloneDeep(schema.default), + }), + options, + ); + }, + }, + title: `${compile(record.__parent?.title)} - ${compile('{{ t("View") }}')}`, + properties: { + summary: { + type: 'void', + 'x-component': 'FieldSummary', + 'x-component-props': { + schemaKey: schema.name, + }, + }, + // @ts-ignore + ...properties, + // footer: { + // type: 'void', + // 'x-component': 'Action.Drawer.Footer', + // properties: { + // action1: { + // title: '{{ t("Cancel") }}', + // 'x-component': 'Action', + // 'x-component-props': { + // useAction: '{{ useCancelAction }}', + // }, + // }, + // action2: { + // title: '{{ t("Submit") }}', + // 'x-component': 'Action', + // 'x-component-props': { + // type: 'primary', + // useAction: '{{ useVieInheritedCollectionField }}', + // }, + // }, + // }, + // }, + }, + }, + }, + }; +}; + +const useVieInheritedCollectionField = () => { + const form = useForm(); + const { refreshCM } = useCollectionManager(); + const ctx = useActionContext(); + const { refresh } = useResourceActionContext(); + const { resource } = useResourceContext(); + return { + async run() { + await form.submit(); + const values = cloneDeep(form.values); + if (values.autoCreateReverseField) { + } else { + delete values.reverseField; + } + delete values.autoCreateReverseField; + const { uiSchema } = values; + delete values.collectionName; + delete values.key; + await resource.create({ + values: { + ...values, + uiSchema: { title: uiSchema.title, type: uiSchema.type, 'x-component': uiSchema['x-component'] }, + }, + }); + ctx.setVisible(false); + await form.reset(); + refresh(); + await refreshCM(); + }, + }; +}; + +export const ViewCollectionField = (props) => { + const record = useRecord(); + return ; +}; + +export const ViewFieldAction = (props) => { + const { scope, getContainer, item: record, children } = props; + const { getInterface } = useCollectionManager(); + const [visible, setVisible] = useState(false); + const [schema, setSchema] = useState({}); + const api = useAPIClient(); + const { t } = useTranslation(); + const compile = useCompile(); + const [data, setData] = useState({}); + + return ( + + + { + const { data } = await api.resource('collections.fields', record.collectionName).get({ + filterByTk: record.name, + appends: ['uiSchema', 'reverseField'], + }); + setData(data?.data); + const interfaceConf = getInterface(record.interface); + const defaultValues: any = cloneDeep(data?.data) || {}; + if (!defaultValues?.reverseField) { + defaultValues.autoCreateReverseField = false; + defaultValues.reverseField = interfaceConf.default?.reverseField; + set(defaultValues.reverseField, 'name', `f_${uid()}`); + set(defaultValues.reverseField, 'uiSchema.title', record.__parent.title); + } + const schema = getSchema( + { + ...interfaceConf, + default: defaultValues, + }, + record, + compile, + getContainer, + ); + setSchema(schema); + setVisible(true); + }} + > + {children || t('View')} + + + + + ); +}; diff --git a/packages/core/client/src/collection-manager/Configuration/index.tsx b/packages/core/client/src/collection-manager/Configuration/index.tsx index f7ead8c559..7e994d938f 100644 --- a/packages/core/client/src/collection-manager/Configuration/index.tsx +++ b/packages/core/client/src/collection-manager/Configuration/index.tsx @@ -7,6 +7,9 @@ export * from './EditFieldAction'; export * from './interfaces'; export * from './components'; export * from './CollectionFieldsTable'; +export * from './schemas/collections' +export * from './OverridingCollectionField' +export * from './ViewInheritedField' registerValidateFormats({ uid: /^[A-Za-z0-9][A-Za-z0-9_-]*$/, diff --git a/packages/core/client/src/collection-manager/Configuration/schemas/collectionFields.ts b/packages/core/client/src/collection-manager/Configuration/schemas/collectionFields.ts index eff4c464ee..695d697e74 100644 --- a/packages/core/client/src/collection-manager/Configuration/schemas/collectionFields.ts +++ b/packages/core/client/src/collection-manager/Configuration/schemas/collectionFields.ts @@ -80,10 +80,6 @@ export const collectionFieldSchema: ISchema = { }, }, }, - // 'x-component': 'CollectionProvider', - // 'x-component-props': { - // collection, - // }, properties: { summary: { type: 'void', @@ -206,3 +202,37 @@ export const collectionFieldSchema: ISchema = { }, }, }; + +export const overridingSchema: ISchema = { + type: 'void', + title: '{{ t("Actions") }}', + 'x-component': 'Table.Column', + properties: { + actions: { + type: 'void', + 'x-component': 'Space', + 'x-component-props': { + split: '|', + }, + properties: { + overriding: { + type: 'void', + title: '{{ t("Overriding") }}', + 'x-component': 'OverridingCollectionField', + 'x-component-props': { + type: 'primary', + }, + }, + view:{ + type: 'void', + title: '{{ t("View") }}', + 'x-component': 'ViewCollectionField', + 'x-component-props': { + type: 'primary', + }, + } + }, + }, + }, +}; + 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 15c2ed0559..047928bb57 100644 --- a/packages/core/client/src/collection-manager/Configuration/schemas/collections.ts +++ b/packages/core/client/src/collection-manager/Configuration/schemas/collections.ts @@ -7,300 +7,336 @@ const compile = (source) => { return Schema.compile(source, { t: i18n.t }); }; -export const collection: CollectionOptions = { - name: 'collections', - filterTargetKey: 'name', - targetKey: 'name', - fields: [ - { - type: 'integer', - name: 'title', - interface: 'input', - uiSchema: { - title: '{{ t("Collection display name") }}', - type: 'number', - 'x-component': 'Input', - required: true, +export const getCollectionOptions = (database): CollectionOptions => { + return { + name: 'collections', + filterTargetKey: 'name', + targetKey: 'name', + fields: [ + { + type: 'integer', + name: 'title', + interface: 'input', + uiSchema: { + title: '{{ t("Collection display name") }}', + type: 'number', + 'x-component': 'Input', + required: true, + }, }, - }, - { - type: 'string', - name: 'name', - interface: 'input', - uiSchema: { - title: '{{ t("Collection name") }}', + { type: 'string', - 'x-component': 'Input', - description: - '{{t("Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.")}}', + name: 'name', + interface: 'input', + uiSchema: { + title: '{{ t("Collection name") }}', + type: 'string', + 'x-component': 'Input', + description: + '{{t("Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.")}}', + }, }, - }, - { - type: 'hasMany', - name: 'fields', - target: 'fields', - collectionName: 'collections', - sourceKey: 'name', - targetKey: 'name', - uiSchema: {}, - }, - ], + { + type: 'hasMany', + name: 'fields', + target: 'fields', + collectionName: 'collections', + sourceKey: 'name', + targetKey: 'name', + uiSchema: {}, + }, + database === 'postgres' && { + type: 'hasMany', + name: 'inherits', + interface: 'select', + uiSchema: { + title: '{{ t("Inherits") }}', + type: 'string', + 'x-component': 'Select', + 'x-component-props': { + mode: 'multiple', + }, + }, + }, + ], + }; }; -export const collectionSchema: ISchema = { - type: 'object', - properties: { - block1: { - type: 'void', - 'x-collection': 'collections', - 'x-decorator': 'ResourceActionProvider', - 'x-decorator-props': { - collection, - request: { - resource: 'collections', - action: 'list', - params: { - pageSize: 50, - filter: { - 'hidden.$isFalsy': true, - }, - sort: ['sort'], - appends: [], - }, +export const createCollectionProperties = { + title: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + name: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + 'x-validator': 'uid', + }, + inherits: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + 'x-visible': '{{ enableInherits }}', + 'x-reactions': ['{{useAsyncDataSource(loadCollections)}}'], + }, + footer: { + type: 'void', + 'x-component': 'Action.Drawer.Footer', + properties: { + action1: { + title: '{{ t("Cancel") }}', + 'x-component': 'Action', + 'x-component-props': { + useAction: '{{ cm.useCancelAction }}', }, }, - // 'x-component': 'CollectionProvider', - // 'x-component-props': { - // collection, - // }, - properties: { - actions: { - type: 'void', - 'x-component': 'ActionBar', - 'x-component-props': { - style: { - marginBottom: 16, - }, - }, - properties: { - filter: { - type: 'void', - title: '{{ t("Filter") }}', - default: { - $and: [{ title: { $includes: '' } }, { name: { $includes: '' } }], - }, - 'x-action': 'filter', - 'x-component': 'Filter.Action', - 'x-component-props': { - icon: 'FilterOutlined', - useProps: '{{ cm.useFilterActionProps }}', - }, - 'x-align': 'left', - }, - delete: { - type: 'void', - title: '{{ t("Delete") }}', - 'x-component': 'Action', - 'x-component-props': { - useAction: '{{ cm.useBulkDestroyActionAndRefreshCM }}', - confirm: { - title: "{{t('Delete record')}}", - content: "{{t('Are you sure you want to delete it?')}}", - }, - }, - }, - create: { - type: 'void', - title: '{{ t("Create collection") }}', - 'x-component': 'Action', - 'x-component-props': { - type: 'primary', - }, - properties: { - drawer: { - type: 'void', - title: '{{ t("Create collection") }}', - 'x-component': 'Action.Drawer', - 'x-decorator': 'Form', - 'x-decorator-props': { - useValues: '{{ useCollectionValues }}', - }, - properties: { - title: { - 'x-component': 'CollectionField', - 'x-decorator': 'FormItem', - }, - name: { - 'x-component': 'CollectionField', - 'x-decorator': 'FormItem', - 'x-validator': 'uid', - }, - footer: { - type: 'void', - 'x-component': 'Action.Drawer.Footer', - properties: { - action1: { - title: '{{ t("Cancel") }}', - 'x-component': 'Action', - 'x-component-props': { - useAction: '{{ cm.useCancelAction }}', - }, - }, - action2: { - title: '{{ t("Submit") }}', - 'x-component': 'Action', - 'x-component-props': { - type: 'primary', - useAction: '{{ cm.useCreateActionAndRefreshCM }}', - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - table: { - type: 'void', - 'x-uid': 'input', - 'x-component': 'Table.Void', - 'x-component-props': { - rowKey: 'name', - rowSelection: { - type: 'checkbox', - }, - useDataSource: '{{ cm.useDataSourceFromRAC }}', - }, - properties: { - column1: { - type: 'void', - 'x-decorator': 'Table.Column.Decorator', - 'x-component': 'Table.Column', - properties: { - title: { - 'x-component': 'CollectionField', - 'x-read-pretty': true, - }, - }, - }, - column2: { - type: 'void', - 'x-decorator': 'Table.Column.Decorator', - 'x-component': 'Table.Column', - properties: { - name: { - type: 'string', - 'x-component': 'CollectionField', - 'x-read-pretty': true, - }, - }, - }, - column3: { - type: 'void', - title: '{{ t("Actions") }}', - 'x-component': 'Table.Column', - properties: { - actions: { - type: 'void', - 'x-component': 'Space', - 'x-component-props': { - split: '|', - }, - properties: { - view: { - type: 'void', - title: '{{ t("Configure fields") }}', - 'x-component': 'Action.Link', - 'x-component-props': {}, - properties: { - drawer: { - type: 'void', - 'x-component': 'Action.Drawer', - 'x-reactions': (field) => { - const i = field.path.segments[1]; - const table = field.form.getValuesIn(`table.${i}`); - if (table) { - field.title = `${compile(table.title)} - ${compile('{{ t("Configure fields") }}')}`; - } - }, - properties: { - collectionFieldSchema, - }, - }, - }, - }, - update: { - type: 'void', - title: '{{ t("Edit") }}', - 'x-component': 'Action.Link', - 'x-component-props': { - type: 'primary', - }, - properties: { - drawer: { - type: 'void', - 'x-component': 'Action.Drawer', - 'x-decorator': 'Form', - 'x-decorator-props': { - useValues: '{{ cm.useValuesFromRecord }}', - }, - title: '{{ t("Edit collection") }}', - properties: { - title: { - 'x-component': 'CollectionField', - 'x-decorator': 'FormItem', - }, - name: { - 'x-component': 'CollectionField', - 'x-decorator': 'FormItem', - 'x-disabled': true, - }, - footer: { - type: 'void', - 'x-component': 'Action.Drawer.Footer', - properties: { - action1: { - title: '{{ t("Cancel") }}', - 'x-component': 'Action', - 'x-component-props': { - useAction: '{{ cm.useCancelAction }}', - }, - }, - action2: { - title: '{{ t("Submit") }}', - 'x-component': 'Action', - 'x-component-props': { - type: 'primary', - useAction: '{{ cm.useUpdateCollectionActionAndRefreshCM }}', - }, - }, - }, - }, - }, - }, - }, - }, - delete: { - type: 'void', - title: '{{ t("Delete") }}', - 'x-component': 'Action.Link', - 'x-component-props': { - confirm: { - title: "{{t('Delete record')}}", - content: "{{t('Are you sure you want to delete it?')}}", - }, - useAction: '{{ cm.useDestroyActionAndRefreshCM }}', - }, - }, - }, - }, - }, - }, - }, + action2: { + title: '{{ t("Submit") }}', + 'x-component': 'Action', + 'x-component-props': { + type: 'primary', + useAction: '{{ cm.useCreateActionAndRefreshCM }}', }, }, }, }, }; + +export const editCollectionProperties = { + title: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + name: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + 'x-disabled': true, + }, + inherits: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + 'x-disabled': true, + 'x-reactions': ['{{useAsyncDataSource(loadCollections)}}'], + }, + footer: { + type: 'void', + 'x-component': 'Action.Drawer.Footer', + properties: { + action1: { + title: '{{ t("Cancel") }}', + 'x-component': 'Action', + 'x-component-props': { + useAction: '{{ useCancelAction }}', + }, + }, + action2: { + title: '{{ t("Submit") }}', + 'x-component': 'Action', + 'x-component-props': { + type: 'primary', + useAction: '{{ useUpdateCollectionActionAndRefreshCM }}', + }, + }, + }, + }, +}; + +export const collectionSchema = (database): ISchema => { + return { + type: 'object', + properties: { + block1: { + type: 'void', + 'x-collection': 'collections', + 'x-decorator': 'ResourceActionProvider', + 'x-decorator-props': { + collection: getCollectionOptions(database), + request: { + resource: 'collections', + action: 'list', + params: { + pageSize: 50, + filter: { + 'hidden.$isFalsy': true, + }, + sort: ['sort'], + appends: [], + }, + }, + }, + // 'x-component': 'CollectionProvider', + // 'x-component-props': { + // collection, + // }, + properties: { + actions: { + type: 'void', + 'x-component': 'ActionBar', + 'x-component-props': { + style: { + marginBottom: 16, + }, + }, + properties: { + filter: { + type: 'void', + title: '{{ t("Filter") }}', + default: { + $and: [{ title: { $includes: '' } }, { name: { $includes: '' } }], + }, + 'x-action': 'filter', + 'x-component': 'Filter.Action', + 'x-component-props': { + icon: 'FilterOutlined', + useProps: '{{ cm.useFilterActionProps }}', + }, + 'x-align': 'left', + }, + delete: { + type: 'void', + title: '{{ t("Delete") }}', + 'x-component': 'Action', + 'x-component-props': { + useAction: '{{ cm.useBulkDestroyActionAndRefreshCM }}', + confirm: { + title: "{{t('Delete record')}}", + content: "{{t('Are you sure you want to delete it?')}}", + }, + }, + }, + create: { + type: 'void', + title: '{{ t("Create collection") }}', + 'x-component': 'Action', + 'x-component-props': { + type: 'primary', + }, + properties: { + drawer: { + type: 'void', + title: '{{ t("Create collection") }}', + 'x-component': 'Action.Drawer', + 'x-decorator': 'Form', + 'x-decorator-props': { + useValues: '{{ useCollectionValues }}', + }, + properties: createCollectionProperties, + }, + }, + }, + }, + }, + table: { + type: 'void', + 'x-uid': 'input', + 'x-component': 'Table.Void', + 'x-component-props': { + rowKey: 'name', + rowSelection: { + type: 'checkbox', + }, + useDataSource: '{{ cm.useDataSourceFromRAC }}', + }, + properties: { + column1: { + type: 'void', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + properties: { + title: { + 'x-component': 'CollectionField', + 'x-read-pretty': true, + }, + }, + }, + column2: { + type: 'void', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + properties: { + name: { + type: 'string', + 'x-component': 'CollectionField', + 'x-read-pretty': true, + }, + }, + }, + column3: { + type: 'void', + title: '{{ t("Actions") }}', + 'x-component': 'Table.Column', + properties: { + actions: { + type: 'void', + 'x-component': 'Space', + 'x-component-props': { + split: '|', + }, + properties: { + view: { + type: 'void', + title: '{{ t("Configure fields") }}', + 'x-component': 'Action.Link', + 'x-component-props': {}, + properties: { + drawer: { + type: 'void', + 'x-component': 'Action.Drawer', + 'x-component-props': { + destroyOnClose: true, + }, + 'x-reactions': (field) => { + const i = field.path.segments[1]; + const table = field.form.getValuesIn(`table.${i}`); + if (table) { + field.title = `${compile(table.title)} - ${compile('{{ t("Configure fields") }}')}`; + } + }, + properties: { + collectionFieldSchema, + }, + }, + }, + }, + update: { + type: 'void', + title: '{{ t("Edit") }}', + 'x-component': 'Action.Link', + 'x-component-props': { + type: 'primary', + }, + properties: { + drawer: { + type: 'void', + 'x-component': 'Action.Drawer', + 'x-decorator': 'Form', + 'x-decorator-props': { + useValues: '{{ cm.useValuesFromRecord }}', + }, + title: '{{ t("Edit collection") }}', + properties: editCollectionProperties, + }, + }, + }, + delete: { + type: 'void', + title: '{{ t("Delete") }}', + 'x-component': 'Action.Link', + 'x-component-props': { + confirm: { + title: "{{t('Delete record')}}", + content: "{{t('Are you sure you want to delete it?')}}", + }, + useAction: '{{ cm.useDestroyActionAndRefreshCM }}', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; +}; diff --git a/packages/core/client/src/collection-manager/action-hooks.ts b/packages/core/client/src/collection-manager/action-hooks.ts index d73b79e80e..ecfaef4ed8 100644 --- a/packages/core/client/src/collection-manager/action-hooks.ts +++ b/packages/core/client/src/collection-manager/action-hooks.ts @@ -166,7 +166,7 @@ export const useCreateAction = () => { return { async run() { await form.submit(); - await resource.create({ values: form.values }); + await resource.create({ values: form.values}); ctx.setVisible(false); await form.reset(); refresh(); diff --git a/packages/core/client/src/collection-manager/hooks/useCollection.ts b/packages/core/client/src/collection-manager/hooks/useCollection.ts index c2da2258fc..5747e2343e 100644 --- a/packages/core/client/src/collection-manager/hooks/useCollection.ts +++ b/packages/core/client/src/collection-manager/hooks/useCollection.ts @@ -1,18 +1,38 @@ import { SchemaKey } from '@formily/react'; +import { reduce ,unionBy} from 'lodash'; import { useContext } from 'react'; import { useAPIClient } from '../../api-client'; import { CollectionContext } from '../context'; import { CollectionFieldOptions } from '../types'; +import { useCollectionManager } from './useCollectionManager'; export const useCollection = () => { const collection = useContext(CollectionContext); const api = useAPIClient(); const resource = api?.resource(collection?.name); + const { getParentCollections, getCurrentCollectionFields } = useCollectionManager(); + const currentFields = collection.fields; + const inheritKeys = getParentCollections(collection.name); + const inheritedFields = reduce( + inheritKeys, + (result, value) => { + const arr = result; + return arr.concat(getCurrentCollectionFields(value)); + }, + [], + ); + const totalFields = unionBy(currentFields?.concat(inheritedFields),'name').filter((v)=>{ + return !v.isForeignKey + }); return { ...collection, resource, getField(name: SchemaKey): CollectionFieldOptions { - return collection?.fields?.find((field) => field.name === name); + const fields = totalFields; + return fields?.find((field) => field.name === name); }, + fields: totalFields, + currentFields, + inheritedFields }; }; diff --git a/packages/core/client/src/collection-manager/hooks/useCollectionManager.ts b/packages/core/client/src/collection-manager/hooks/useCollectionManager.ts index 3656434547..53e44c8781 100644 --- a/packages/core/client/src/collection-manager/hooks/useCollectionManager.ts +++ b/packages/core/client/src/collection-manager/hooks/useCollectionManager.ts @@ -1,9 +1,32 @@ import { clone } from '@formily/shared'; import { useContext } from 'react'; +import { reduce, unionBy,uniq } from 'lodash'; import { CollectionManagerContext } from '../context'; +import { CollectionFieldOptions } from '../types'; export const useCollectionManager = () => { const { refreshCM, service, interfaces, collections } = useContext(CollectionManagerContext); + const getInheritedFields = (name) => { + const inheritKeys = getParentCollections(name); + const inheritedFields = reduce( + inheritKeys, + (result, value) => { + const arr = result; + return arr.concat(collections?.find((collection) => collection.name === value)?.fields); + }, + [], + ); + return inheritedFields; + }; + + const getCollectionFields = (name: string): CollectionFieldOptions[] => { + const currentFields = collections?.find((collection) => collection.name === name)?.fields; + const inheritedFields = getInheritedFields(name); + const totalFields = unionBy(currentFields?.concat(inheritedFields) || [], 'name').filter((v:any) => { + return !v.isForeignKey; + }); + return totalFields; + }; const getCollectionField = (name: string) => { const [collectionName, fieldName] = name.split('.'); if (!fieldName) { @@ -13,27 +36,68 @@ export const useCollectionManager = () => { if (!collection) { return; } - return collection?.fields?.find((field) => field.name === fieldName); + return getCollectionFields(collectionName)?.find((field) => field.name === fieldName); }; + const getParentCollections = (name) => { + const parents = []; + const getParents = (name) => { + const collection = collections?.find((collection) => collection.name === name); + if (collection) { + const { inherits } = collection; + if (inherits) { + for (let index = 0; index < inherits.length; index++) { + const collectionKey = inherits[index]; + parents.push(collectionKey); + getParents(collectionKey); + } + } + } + return uniq(parents); + }; + + return getParents(name); + }; + + const getChildrenCollections = (name) => { + const childrens = []; + const getChildrens = (name) => { + const inheritCollections = collections.filter((v) => { + return v.inherits?.includes(name); + }); + inheritCollections.forEach((v) => { + const collectionKey = v.name; + childrens.push(v); + return getChildrens(collectionKey); + }); + return childrens; + }; + return getChildrens(name); + }; + const getCurrentCollectionFields = (name: string) => { + const collection = collections?.find((collection) => collection.name === name); + return collection?.fields || []; + }; + return { service, interfaces, collections, + getParentCollections, + getChildrenCollections, refreshCM: () => refreshCM?.(), get(name: string) { return collections?.find((collection) => collection.name === name); }, + getInheritedFields, + getCollectionField, + getCollectionFields, + getCurrentCollectionFields, getCollection(name: any) { if (typeof name !== 'string') { return name; } return collections?.find((collection) => collection.name === name); }, - getCollectionFields(name: string) { - const collection = collections?.find((collection) => collection.name === name); - return collection?.fields || []; - }, - getCollectionField, getCollectionJoinField(name: string) { if (!name) { return; @@ -58,5 +122,23 @@ export const useCollectionManager = () => { getInterface(name: string) { return interfaces[name] ? clone(interfaces[name]) : null; }, + getParentCollectionFields: (parentCollection, currentCollection) => { + const currentFields = collections?.find((collection) => collection.name === currentCollection)?.fields; + const parentFields = collections?.find((collection) => collection.name === parentCollection)?.fields; + const inheritKeys = getParentCollections(currentCollection); + const index = inheritKeys.indexOf(parentCollection); + let filterFields = currentFields; + if (index > 0) { + inheritKeys.splice(index); + inheritKeys.forEach((v) => { + filterFields = filterFields.concat(getCurrentCollectionFields(v)); + }); + } + return parentFields.filter((v) => { + return !filterFields.find((k) => { + return k.name === v.name; + }); + }); + }, }; }; diff --git a/packages/core/client/src/collection-manager/types.ts b/packages/core/client/src/collection-manager/types.ts index 920d8cb24f..0925db7cf6 100644 --- a/packages/core/client/src/collection-manager/types.ts +++ b/packages/core/client/src/collection-manager/types.ts @@ -21,6 +21,7 @@ export interface CollectionOptions { targetKey?: string; sortable?: any; fields?: FieldOptions[]; + inherits?:string[]; } export interface ICollectionProviderProps { diff --git a/packages/core/client/src/database/CurrentDatabaseProvider.tsx b/packages/core/client/src/database/CurrentDatabaseProvider.tsx new file mode 100644 index 0000000000..b7b219f93e --- /dev/null +++ b/packages/core/client/src/database/CurrentDatabaseProvider.tsx @@ -0,0 +1,24 @@ +import React, { createContext, useContext } from 'react'; +import { Spin } from 'antd'; +import { useRequest } from '../api-client'; + +export const CurrentDatabaseContext = createContext(null); + +export const useCurrentDatabase = () => { + return useContext(CurrentDatabaseContext); +}; +export const CurrentDatabaseProvider = (props) => { + const result = useRequest({ + url: 'app:getInfo', + }); + if (result.loading) { + return ; + } + return ( + + {props.children} + + ); +}; diff --git a/packages/core/client/src/database/index.ts b/packages/core/client/src/database/index.ts new file mode 100644 index 0000000000..56e7d73693 --- /dev/null +++ b/packages/core/client/src/database/index.ts @@ -0,0 +1 @@ +export * from './CurrentDatabaseProvider'; diff --git a/packages/core/client/src/index.tsx b/packages/core/client/src/index.tsx index 57c75634c9..889a9541e6 100644 --- a/packages/core/client/src/index.tsx +++ b/packages/core/client/src/index.tsx @@ -25,4 +25,5 @@ export * from './schema-templates'; export * from './settings-form'; export * from './system-settings'; export * from './user'; +export * from './database' diff --git a/packages/core/client/src/locale/en_US.ts b/packages/core/client/src/locale/en_US.ts index ec6e93a511..4e7c6e9c0d 100644 --- a/packages/core/client/src/locale/en_US.ts +++ b/packages/core/client/src/locale/en_US.ts @@ -113,6 +113,7 @@ export default { "Create collection": "Create collection", "Collection display name": "Collection display name", "Collection name": "Collection name", + "Inherits":"Inherits", "Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.": "Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.", "Storage type": "Storage type", "Edit": "Edit", @@ -120,9 +121,12 @@ export default { "Configure fields": "Configure fields", "Configure columns": "Configure columns", "Edit field": "Edit field", + "Override":"Override", + "Override field":"Override field", "Configure fields of {{title}}": "Configure fields of {{title}}", "PK & FK fields": "PK & FK fields", "Association fields": "Association fields", + "Parent collection fields":"Parent collection fields", "System fields": "System fields", "General fields": "General fields", "Basic": "Basic", diff --git a/packages/core/client/src/locale/ja_JP.ts b/packages/core/client/src/locale/ja_JP.ts index 885a598223..a9076b32b0 100644 --- a/packages/core/client/src/locale/ja_JP.ts +++ b/packages/core/client/src/locale/ja_JP.ts @@ -114,6 +114,8 @@ export default { "Configure fields": "フィールドの設定", "Configure columns": "カラムの設定", "Edit field": "フィールドの編集", + "Override":"書き換え", + "Override field":"フィールドの上書き", "Configure fields of {{title}}": "{{title}}のフィールド設定", "Basic": "基本タイプ", "Single line text": "一行テキスト", diff --git a/packages/core/client/src/locale/zh_CN.ts b/packages/core/client/src/locale/zh_CN.ts index 91b12ab544..bb444746d4 100644 --- a/packages/core/client/src/locale/zh_CN.ts +++ b/packages/core/client/src/locale/zh_CN.ts @@ -117,6 +117,7 @@ export default { "Create collection": "创建数据表", "Collection display name": "数据表名称", "Collection name": "数据表标识", + "Inherits":"继承", "Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.": "随机生成,可修改。支持英文、数字和下划线,必须以英文字母开头。", "Storage type": "存储类型", "Edit": "编辑", @@ -124,11 +125,14 @@ export default { "Configure fields": "配置字段", "Configure columns": "配置字段", "Edit field": "编辑字段", + "Override":"重写", + "Override field":"重写字段", "Configure fields of {{title}}": "「{{title}}」的字段配置", "PK & FK fields": "主外键字段", "Association fields": "关系字段", "System fields": "系统字段", "General fields": "普通字段", + "Parent collection fields":"父表字段", "Basic": "基本类型", "Single line text": "单行文本", "Long text": "多行文本", diff --git a/packages/core/client/src/route-switch/antd/admin-layout/index.tsx b/packages/core/client/src/route-switch/antd/admin-layout/index.tsx index ab0c2efdbc..043d77c54f 100644 --- a/packages/core/client/src/route-switch/antd/admin-layout/index.tsx +++ b/packages/core/client/src/route-switch/antd/admin-layout/index.tsx @@ -7,6 +7,7 @@ import { ACLRolesCheckProvider, CurrentUser, CurrentUserProvider, + CurrentDatabaseProvider, findByUid, findMenuItem, RemoteCollectionManagerProvider, @@ -17,7 +18,7 @@ import { useDocumentTitle, useRequest, useRoute, - useSystemSettings + useSystemSettings, } from '../../../'; import { useCollectionManager } from '../../../collection-manager'; import { PoweredBy } from '../../../powered-by'; @@ -206,15 +207,17 @@ const InternalAdminLayout = (props: any) => { export const AdminLayout = (props) => { return ( - - - - - - - - - + + + + + + + + + + + ); }; diff --git a/packages/core/client/src/schema-component/antd/table-v2/Table.Column.Designer.tsx b/packages/core/client/src/schema-component/antd/table-v2/Table.Column.Designer.tsx index 76986a9a5c..f152c2180c 100644 --- a/packages/core/client/src/schema-component/antd/table-v2/Table.Column.Designer.tsx +++ b/packages/core/client/src/schema-component/antd/table-v2/Table.Column.Designer.tsx @@ -13,7 +13,7 @@ const useLabelFields = (collectionName?: any) => { const { getCollectionFields } = useCollectionManager(); const targetFields = getCollectionFields(collectionName); return targetFields - ?.filter?.((field) => field?.interface && !field?.target && field.type !== 'boolean') + ?.filter?.((field) => field?.interface && !field?.target && field.type !== 'boolean'&&!field.isForeignKey) ?.map?.((field) => { return { value: field.name, diff --git a/packages/core/client/src/schema-initializer/SchemaInitializer.tsx b/packages/core/client/src/schema-initializer/SchemaInitializer.tsx index 7aa7158b6d..a74df7b9d6 100644 --- a/packages/core/client/src/schema-initializer/SchemaInitializer.tsx +++ b/packages/core/client/src/schema-initializer/SchemaInitializer.tsx @@ -10,7 +10,7 @@ import { SchemaInitializerButtonProps, SchemaInitializerItemComponent, SchemaInitializerItemOptions, - SchemaInitializerItemProps + SchemaInitializerItemProps, } from './types'; const defaultWrap = (s: ISchema) => s; @@ -99,12 +99,11 @@ SchemaInitializer.Button = observer((props: SchemaInitializerButtonProps) => { } }); }; - const menu = {renderItems(items)}; + const menu = {renderItems(items)}; if (!designable && props.designable !== true) { return null; } - return ( { const { t } = useTranslation(); + const compile = useCompile(); const { insertPosition, component } = props; + const inheritFields = useInheritsFormItemInitializerFields(); + const fieldItems:any[]=[ + { + type: 'itemGroup', + title: t('Configure fields'), + children: useCustomFormItemInitializerFields(), + }, + ] + if (inheritFields?.length > 0) { + inheritFields.forEach((inherit) => { + fieldItems.push( + { + type: 'divider', + }, + { + type: 'itemGroup', + title: t(`Parent collection fields`) + '(' + compile(`${Object.keys(inherit)[0]}`) + ')', + children: Object.values(inherit)[0], + }, + ); + }); + } return ( { const { t } = useTranslation(); const { insertPosition, component } = props; const associationFields = useAssociatedFormItemInitializerFields({ readPretty: true, block: 'Form' }); + const inheritFields = useInheritsFormItemInitializerFields(); + const compile = useCompile(); + const fieldItems: any[] = [ + { + type: 'itemGroup', + title: t('Display fields'), + children: useFormItemInitializerFields(), + }, + ]; + if (inheritFields?.length > 0) { + inheritFields.forEach((inherit) => { + Object.values(inherit)[0].length&&fieldItems.push( + { + type: 'divider', + }, + { + type: 'itemGroup', + title: t(`Parent collection fields`) + '(' + compile(`${Object.keys(inherit)[0]}`) + ')', + children: Object.values(inherit)[0], + }, + ); + }); + } + associationFields.length > 0 && + fieldItems.push( + { + type: 'divider', + }, + { + type: 'itemGroup', + title: t('Display association fields'), + children: associationFields, + }, + ); + fieldItems.push( + { + type: 'divider', + }, + { + type: 'item', + title: t('Add text'), + component: 'BlockInitializer', + schema: { + type: 'void', + 'x-editable': false, + 'x-decorator': 'FormItem', + 'x-designer': 'Markdown.Void.Designer', + 'x-component': 'Markdown.Void', + 'x-component-props': { + content: t('This is a demo text, **supports Markdown syntax**.'), + }, + }, + }, + ); return ( ( - [ - { - type: 'itemGroup', - title: t('Display fields'), - children: useFormItemInitializerFields(), - }, - ], - associationFields.length > 0 - ? [ - { - type: 'divider', - }, - { - type: 'itemGroup', - title: t('Display association fields'), - children: associationFields, - }, - ] - : [], - [ - { - type: 'divider', - }, - { - type: 'item', - title: t('Add text'), - component: 'BlockInitializer', - schema: { - type: 'void', - 'x-editable': false, - 'x-decorator': 'FormItem', - 'x-designer': 'Markdown.Void.Designer', - 'x-component': 'Markdown.Void', - 'x-component-props': { - content: t('This is a demo text, **supports Markdown syntax**.'), - }, - }, - }, - ], - )} + items={fieldItems} insertPosition={insertPosition} component={component} title={component ? null : t('Configure fields')} diff --git a/packages/core/client/src/schema-initializer/buttons/ReadPrettyFormItemInitializers.tsx b/packages/core/client/src/schema-initializer/buttons/ReadPrettyFormItemInitializers.tsx index 02301bbcc6..ada8daec37 100644 --- a/packages/core/client/src/schema-initializer/buttons/ReadPrettyFormItemInitializers.tsx +++ b/packages/core/client/src/schema-initializer/buttons/ReadPrettyFormItemInitializers.tsx @@ -2,58 +2,73 @@ import { union } from 'lodash'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { SchemaInitializer } from '../SchemaInitializer'; -import { gridRowColWrap, useAssociatedFormItemInitializerFields, useFormItemInitializerFields } from '../utils'; +import { gridRowColWrap, useAssociatedFormItemInitializerFields, useFormItemInitializerFields ,useInheritsFormItemInitializerFields} from '../utils'; +import { useCompile } from '../../schema-component'; export const ReadPrettyFormItemInitializers = (props: any) => { const { t } = useTranslation(); const { insertPosition, component } = props; const associationFields = useAssociatedFormItemInitializerFields({ readPretty: true, block: 'Form' }); + const inheritFields = useInheritsFormItemInitializerFields(); + const compile = useCompile(); + const fieldItems: any[] = [ + { + type: 'itemGroup', + title: t('Display fields'), + children: useFormItemInitializerFields(), + }, + ]; + if (inheritFields?.length > 0) { + inheritFields.forEach((inherit) => { + fieldItems.push( + { + type: 'divider', + }, + { + type: 'itemGroup', + title: t(`Parent collection fields`) + '(' + compile(`${Object.keys(inherit)[0]}`) + ')', + children: Object.values(inherit)[0], + }, + ); + }); + } + associationFields.length > 0 && + fieldItems.push([ + { + type: 'divider', + }, + { + type: 'itemGroup', + title: t('Display association fields'), + children: associationFields, + }, + ]); + fieldItems.push( + { + type: 'divider', + }, + { + type: 'item', + title: t('Add text'), + component: 'BlockInitializer', + schema: { + type: 'void', + 'x-editable': false, + 'x-decorator': 'FormItem', + 'x-designer': 'Markdown.Void.Designer', + 'x-component': 'Markdown.Void', + 'x-component-props': { + content: t('This is a demo text, **supports Markdown syntax**.'), + }, + }, + }, + ); return ( ( - [ - { - type: 'itemGroup', - title: t('Display fields'), - children: useFormItemInitializerFields(), - }, - ], - associationFields.length > 0 - ? [ - { - type: 'divider', - }, - { - type: 'itemGroup', - title: t('Display association fields'), - children: associationFields, - }, - ] - : [], - [ - { - type: 'divider', - }, - { - type: 'item', - title: t('Add text'), - component: 'BlockInitializer', - schema: { - type: 'void', - 'x-editable': false, - 'x-decorator': 'FormItem', - 'x-designer': 'Markdown.Void.Designer', - 'x-component': 'Markdown.Void', - 'x-component-props': { - content: t('This is a demo text, **supports Markdown syntax**.'), - }, - }, - }, - ], - )} + items={fieldItems} insertPosition={insertPosition} component={component} title={component ? null : t('Configure fields')} diff --git a/packages/core/client/src/schema-initializer/buttons/RecordBlockInitializers.tsx b/packages/core/client/src/schema-initializer/buttons/RecordBlockInitializers.tsx index 31a6b8aa65..a4fed82adb 100644 --- a/packages/core/client/src/schema-initializer/buttons/RecordBlockInitializers.tsx +++ b/packages/core/client/src/schema-initializer/buttons/RecordBlockInitializers.tsx @@ -104,6 +104,23 @@ const useRelationFields = () => { return relationFields; }; +const useInheritFields = () => { + const collection = useCollection(); + const { getChildrenCollections } = useCollectionManager(); + const childrenCollections = getChildrenCollections(collection.name); + return childrenCollections.map((c) => { + return { + key: c.key, + type: 'item', + title: c?.title || c.name, + component: 'RecordReadPrettyFormBlockInitializer', + icon: false, + targetCollection: c, + actionInitializers: 'CalendarFormActionInitializers', + }; + }); +}; + export const RecordBlockInitializers = (props: any) => { const { t } = useTranslation(); const { insertPosition, component } = props; @@ -134,6 +151,11 @@ export const RecordBlockInitializers = (props: any) => { }, ], }, + { + type: 'itemGroup', + title: '{{t("Children collection blocks")}}', + children: useInheritFields(), + }, { type: 'itemGroup', title: '{{t("Relationship blocks")}}', diff --git a/packages/core/client/src/schema-initializer/buttons/TableColumnInitializers.tsx b/packages/core/client/src/schema-initializer/buttons/TableColumnInitializers.tsx index ea9a72c94d..fbe00c3f93 100644 --- a/packages/core/client/src/schema-initializer/buttons/TableColumnInitializers.tsx +++ b/packages/core/client/src/schema-initializer/buttons/TableColumnInitializers.tsx @@ -1,34 +1,65 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { SchemaInitializer } from '../SchemaInitializer'; -import { itemsMerge, useAssociatedTableColumnInitializerFields, useTableColumnInitializerFields } from '../utils'; +import { + itemsMerge, + useAssociatedTableColumnInitializerFields, + useTableColumnInitializerFields, + useInheritsTableColumnInitializerFields, +} from '../utils'; +import { useCompile } from '../../schema-component'; // 表格列配置 export const TableColumnInitializers = (props: any) => { const { items = [] } = props; const { t } = useTranslation(); const associatedFields = useAssociatedTableColumnInitializerFields(); - const fieldItems: any[] = [{ - type: 'itemGroup', - title: t('Display fields'), - children: useTableColumnInitializerFields(), - }]; - if (associatedFields?.length > 0) { - fieldItems.push({ - type: 'divider', - }, { + const inheritFields = useInheritsTableColumnInitializerFields(); + const compile = useCompile(); + const fieldItems: any[] = [ + { type: 'itemGroup', - title: t('Display association fields'), - children: associatedFields, - }) + title: t('Display fields'), + children: useTableColumnInitializerFields(), + }, + ]; + if (inheritFields?.length > 0) { + inheritFields.forEach((inherit) => { + Object.values(inherit)[0].length && + fieldItems.push( + { + type: 'divider', + }, + { + type: 'itemGroup', + title: t(`Parent collection fields`) + '(' + compile(`${Object.keys(inherit)[0]}`) + ')', + children: Object.values(inherit)[0], + }, + ); + }); } - fieldItems.push({ - type: 'divider', - }, { - type: 'item', - title: t('Action column'), - component: 'TableActionColumnInitializer', - }) + if (associatedFields?.length > 0) { + fieldItems.push( + { + type: 'divider', + }, + { + type: 'itemGroup', + title: t('Display association fields'), + children: associatedFields, + }, + ); + } + fieldItems.push( + { + type: 'divider', + }, + { + type: 'item', + title: t('Action column'), + component: 'TableActionColumnInitializer', + }, + ); return ( { }, }; }} - items={itemsMerge( - fieldItems, - items, - )} + items={itemsMerge(fieldItems, items)} > {t('Configure columns')} diff --git a/packages/core/client/src/schema-initializer/items/CalendarBlockInitializer.tsx b/packages/core/client/src/schema-initializer/items/CalendarBlockInitializer.tsx index aec48e1156..4c808ab5a3 100644 --- a/packages/core/client/src/schema-initializer/items/CalendarBlockInitializer.tsx +++ b/packages/core/client/src/schema-initializer/items/CalendarBlockInitializer.tsx @@ -12,7 +12,7 @@ import { DataBlockInitializer } from "./DataBlockInitializer"; export const CalendarBlockInitializer = (props) => { const { insert } = props; const { t } = useTranslation(); - const { getCollection } = useCollectionManager(); + const { getCollectionFields } = useCollectionManager(); const options = useContext(SchemaOptionsContext); return ( { componentType={'Calendar'} icon={} onCreateBlockSchema={async ({ item }) => { - const collection = getCollection(item.name); - const stringFields = collection?.fields + const collectionFields = getCollectionFields(item.name); + const stringFields = collectionFields ?.filter((field) => field.type === 'string') ?.map((field) => { return { @@ -29,7 +29,7 @@ export const CalendarBlockInitializer = (props) => { value: field.name, }; }); - const dateFields = collection?.fields + const dateFields = collectionFields ?.filter((field) => field.type === 'date') ?.map((field) => { return { diff --git a/packages/core/client/src/schema-initializer/items/KanbanBlockInitializer.tsx b/packages/core/client/src/schema-initializer/items/KanbanBlockInitializer.tsx index 3d78066d79..b3ae1f2587 100644 --- a/packages/core/client/src/schema-initializer/items/KanbanBlockInitializer.tsx +++ b/packages/core/client/src/schema-initializer/items/KanbanBlockInitializer.tsx @@ -13,7 +13,7 @@ import { SchemaComponent, SchemaComponentOptions } from "../../schema-component" export const KanbanBlockInitializer = (props) => { const { insert } = props; const { t } = useTranslation(); - const { getCollection } = useCollectionManager(); + const { getCollectionFields,getCollection } = useCollectionManager(); const options = useContext(SchemaOptionsContext); const api = useAPIClient(); return ( @@ -22,8 +22,8 @@ export const KanbanBlockInitializer = (props) => { componentType={'Kanban'} icon={} onCreateBlockSchema={async ({ item }) => { - const collection = getCollection(item.name); - const fields = collection?.fields + const collectionFields = getCollectionFields(item.name); + const fields = collectionFields ?.filter((field) => ['select', 'radioGroup'].includes(field.interface)) ?.map((field) => { return { @@ -64,7 +64,7 @@ export const KanbanBlockInitializer = (props) => { initialValues: {}, }); const sortName = `${values.groupField.value}_sort`; - const exists = collection?.fields?.find((field) => field.name === sortName); + const exists = collectionFields?.find((field) => field.name === sortName); if (!exists) { await api.resource('collections.fields', item.name).create({ values: { diff --git a/packages/core/client/src/schema-initializer/items/RecordReadPrettyFormBlockInitializer.tsx b/packages/core/client/src/schema-initializer/items/RecordReadPrettyFormBlockInitializer.tsx index 4081d84bd2..5643ea0277 100644 --- a/packages/core/client/src/schema-initializer/items/RecordReadPrettyFormBlockInitializer.tsx +++ b/packages/core/client/src/schema-initializer/items/RecordReadPrettyFormBlockInitializer.tsx @@ -7,10 +7,17 @@ import { SchemaInitializer } from '../SchemaInitializer'; import { createReadPrettyFormBlockSchema, useRecordCollectionDataSourceItems } from '../utils'; export const RecordReadPrettyFormBlockInitializer = (props) => { - const { onCreateBlockSchema, componentType, createBlockSchema, insert, ...others } = props; - + const { + onCreateBlockSchema, + componentType, + createBlockSchema, + insert, + icon = true, + targetCollection, + ...others + } = props; const { getTemplateSchemaByMode } = useSchemaTemplateManager(); - const collection = useCollection(); + const collection = targetCollection || useCollection(); const association = useBlockAssociationContext(); const { block } = useBlockRequestContext(); const actionInitializers = @@ -18,7 +25,7 @@ export const RecordReadPrettyFormBlockInitializer = (props) => { return ( } + icon={icon && } {...others} key={'123'} onClick={async ({ item }) => { diff --git a/packages/core/client/src/schema-initializer/utils.ts b/packages/core/client/src/schema-initializer/utils.ts index e7719d11d8..8934bdd61e 100644 --- a/packages/core/client/src/schema-initializer/utils.ts +++ b/packages/core/client/src/schema-initializer/utils.ts @@ -73,15 +73,15 @@ export const findTableColumn = (schema: Schema, key: string, action: string, dee }; export const useTableColumnInitializerFields = () => { - const { name, fields = [] } = useCollection(); + const { currentFields = [] } = useCollection(); const { getInterface } = useCollectionManager(); - return fields + return currentFields .filter((field) => field?.interface && field?.interface !== 'subTable' && !field?.isForeignKey) .map((field) => { const interfaceConfig = getInterface(field.interface); const schema = { name: field.name, - 'x-collection-field': `${name}.${field.name}`, + 'x-collection-field': `${field.name}`, 'x-component': 'CollectionField', 'x-read-pretty': true, 'x-component-props': {}, @@ -105,7 +105,6 @@ export const useTableColumnInitializerFields = () => { export const useAssociatedTableColumnInitializerFields = () => { const { name, fields } = useCollection(); const { getInterface, getCollectionFields } = useCollectionManager(); - const groups = fields ?.filter((field) => { return ['o2o', 'oho', 'obo', 'm2o'].includes(field.interface); @@ -141,7 +140,6 @@ export const useAssociatedTableColumnInitializerFields = () => { schema, } as SchemaInitializerItemOptions; }); - return { type: 'subMenu', title: field.uiSchema?.title, @@ -152,13 +150,51 @@ export const useAssociatedTableColumnInitializerFields = () => { return groups; }; +export const useInheritsTableColumnInitializerFields = () => { + const { name } = useCollection(); + const { getInterface, getParentCollections, getCollection, getParentCollectionFields } = useCollectionManager(); + const inherits = getParentCollections(name); + return inherits?.map((v) => { + const fields = getParentCollectionFields(v, name); + const targetCollection = getCollection(v); + return { + [targetCollection?.title]: fields + ?.filter((field) => { + return field?.interface; + }) + .map((k) => { + const interfaceConfig = getInterface(k.interface); + const schema = { + name: `${k.name}`, + 'x-component': 'CollectionField', + 'x-read-pretty': true, + 'x-collection-field': `${k.name}`, + 'x-component-props': {}, + }; + return { + type: 'item', + title: k?.uiSchema?.title || k.name, + component: 'TableCollectionFieldInitializer', + find: findTableColumn, + remove: removeTableColumn, + schemaInitialize: (s) => { + interfaceConfig?.schemaInitialize?.(s, { field: k, readPretty: true, block: 'Table' }); + }, + field: k, + schema, + } as SchemaInitializerItemOptions; + }), + }; + }); +}; + export const useFormItemInitializerFields = (options?: any) => { - const { name, fields } = useCollection(); + const { name, currentFields } = useCollection(); const { getInterface } = useCollectionManager(); const form = useForm(); const { readPretty = form.readPretty, block = 'Form' } = options || {}; - return fields + return currentFields ?.filter((field) => field?.interface && !field?.isForeignKey) ?.map((field) => { const interfaceConfig = getInterface(field.interface); @@ -170,7 +206,7 @@ export const useFormItemInitializerFields = (options?: any) => { 'x-designer': 'FormItem.Designer', 'x-component': field.interface === 'o2m' ? 'TableField' : 'CollectionField', 'x-decorator': 'FormItem', - 'x-collection-field': `${name}.${field.name}`, + 'x-collection-field': `${field.name}`, 'x-component-props': {}, 'x-read-pretty': field?.uiSchema?.['x-read-pretty'], }; @@ -240,13 +276,52 @@ export const useAssociatedFormItemInitializerFields = (options?: any) => { return groups; }; +export const useInheritsFormItemInitializerFields = (options?) => { + const { name } = useCollection(); + const { getInterface, getParentCollections, getCollection, getParentCollectionFields } = useCollectionManager(); + const inherits = getParentCollections(name); + return inherits?.map((v) => { + const fields = getParentCollectionFields(v, name); + const form = useForm(); + const { readPretty = form.readPretty, block = 'Form' } = options || {}; + const targetCollection = getCollection(v); + return { + [targetCollection.title]: fields + ?.filter((field) => field?.interface && !field?.isForeignKey) + ?.map((field) => { + const interfaceConfig = getInterface(field.interface); + const schema = { + type: 'string', + name: field.name, + title: field?.uiSchema?.title || field.name, + 'x-designer': 'FormItem.Designer', + 'x-component': field.interface === 'o2m' ? 'TableField' : 'CollectionField', + 'x-decorator': 'FormItem', + 'x-collection-field': `${field.name}`, + 'x-component-props': {}, + 'x-read-pretty': field?.uiSchema?.['x-read-pretty'], + }; + return { + type: 'item', + title: field?.uiSchema?.title || field.name, + component: 'CollectionFieldInitializer', + remove: removeGridFormItem, + schemaInitialize: (s) => { + interfaceConfig?.schemaInitialize?.(s, { field, block, readPretty }); + }, + schema, + } as SchemaInitializerItemOptions; + }), + }; + }); +}; export const useCustomFormItemInitializerFields = (options?: any) => { - const { name, fields } = useCollection(); + const { name, currentFields } = useCollection(); const { getInterface } = useCollectionManager(); const form = useForm(); const { readPretty = form.readPretty, block = 'Form' } = options || {}; const remove = useRemoveGridFormItem(); - return fields + return currentFields ?.filter((field) => { return field?.interface && !field?.uiSchema?.['x-read-pretty']; }) @@ -259,7 +334,7 @@ export const useCustomFormItemInitializerFields = (options?: any) => { 'x-designer': 'FormItem.Designer', 'x-component': 'AssignedField', 'x-decorator': 'FormItem', - 'x-collection-field': `${name}.${field.name}`, + 'x-collection-field': `${field.name}`, }; return { type: 'item', @@ -293,7 +368,7 @@ export const useCustomBulkEditFormItemInitializerFields = (options?: any) => { 'x-designer': 'FormItem.Designer', 'x-component': 'BulkEditField', 'x-decorator': 'FormItem', - 'x-collection-field': `${name}.${field.name}`, + 'x-collection-field': `${field.name}`, }; return { type: 'item', @@ -344,6 +419,7 @@ export const useCurrentSchema = (action: string, key: string, find = findSchema, } const { remove } = useDesignable(); const schema = find(fieldSchema, key, action); + console.log(fieldSchema, key, action); return { schema, exists: !!schema, diff --git a/packages/core/client/src/user/CurrentUser.tsx b/packages/core/client/src/user/CurrentUser.tsx index 7346a064ed..13aebc73e5 100644 --- a/packages/core/client/src/user/CurrentUser.tsx +++ b/packages/core/client/src/user/CurrentUser.tsx @@ -1,5 +1,5 @@ import { Dropdown, Menu } from 'antd'; -import React, { createContext, useState } from 'react'; +import React, { createContext, useState, useContext } from 'react'; import { useTranslation } from 'react-i18next'; import { useHistory } from 'react-router-dom'; import { useAPIClient, useCurrentUserContext } from '..'; @@ -8,18 +8,15 @@ import { ChangePassword } from './ChangePassword'; import { EditProfile } from './EditProfile'; import { LanguageSettings } from './LanguageSettings'; import { SwitchRole } from './SwitchRole'; +import {useCurrentDatabase} from '../database/CurrentDatabaseProvider' + const ApplicationVersion = () => { - const { data, loading } = useRequest({ - url: 'app:getInfo', - }); - if (loading) { - return null; - } + const data=useCurrentDatabase(); return ( - - Version {data?.data?.version} - + + Version {data?.data?.version} + ); }; diff --git a/packages/core/database/src/__tests__/inhertits/collection-inherits-sync.test.ts b/packages/core/database/src/__tests__/inhertits/collection-inherits-sync.test.ts new file mode 100644 index 0000000000..8bddfe899c --- /dev/null +++ b/packages/core/database/src/__tests__/inhertits/collection-inherits-sync.test.ts @@ -0,0 +1,38 @@ +import { Collection } from '../../collection'; +import Database from '../../database'; +import { InheritedCollection } from '../../inherited-collection'; +import { mockDatabase } from '../index'; +import pgOnly from './helper'; + +pgOnly()('sync inherits', () => { + let db: Database; + + beforeEach(async () => { + db = mockDatabase(); + }); + + afterEach(async () => { + await db.close(); + }); + + it('should update table fields', async () => { + const person = db.collection({ + name: 'person', + fields: [{ type: 'string', name: 'name' }], + }); + + const student = db.collection({ + name: 'student', + inherits: 'person', + }); + + await db.sync(); + + student.setField('score', { type: 'integer' }); + + await db.sync(); + + const studentTableInfo = await db.sequelize.getQueryInterface().describeTable(student.model.tableName); + expect(studentTableInfo.score).toBeDefined(); + }); +}); diff --git a/packages/core/database/src/__tests__/inhertits/collection-inherits.test.ts b/packages/core/database/src/__tests__/inhertits/collection-inherits.test.ts new file mode 100644 index 0000000000..9c3e50fd17 --- /dev/null +++ b/packages/core/database/src/__tests__/inhertits/collection-inherits.test.ts @@ -0,0 +1,706 @@ +import Database from '../../database'; +import { InheritedCollection } from '../../inherited-collection'; +import { mockDatabase } from '../index'; +import pgOnly from './helper'; + +pgOnly()('collection inherits', () => { + let db: Database; + + beforeEach(async () => { + db = mockDatabase(); + await db.clean({ drop: true }); + }); + + afterEach(async () => { + await db.close(); + }); + + it('can update relation with child table', async () => { + const A = db.collection({ + name: 'a', + fields: [ + { + name: 'a-field', + type: 'string', + }, + { + name: 'a1s', + type: 'hasMany', + target: 'a1', + foreignKey: 'aId', + }, + ], + }); + + const A1 = db.collection({ + name: 'a1', + inherits: ['a'], + fields: [ + { + name: 'a1-field', + type: 'string', + }, + ], + }); + + await db.sync(); + + await A.repository.create({ + values: { + 'a-field': 'a-1', + }, + }); + + let a11 = await A1.repository.create({ + values: { + 'a1-field': 'a1-1', + 'a-field': 'a1-1', + }, + }); + + const a12 = await A1.repository.create({ + values: { + 'a1-field': 'a1-2', + 'a-field': 'a1-2', + }, + }); + + await A1.repository.update({ + filterByTk: a11.get('id'), + values: { + a1s: [{ id: a12.get('id') }], + }, + }); + + a11 = await A1.repository.findOne({ + filter: { + 'a1-field': 'a1-1', + }, + appends: ['a1s'], + }); + + const a11a1s = a11.get('a1s'); + expect(a11a1s[0].get('id')).toBe(a12.get('id')); + }); + + it('can create relation with child table', async () => { + const A = db.collection({ + name: 'a', + fields: [ + { + name: 'af', + type: 'string', + }, + { + name: 'bs', + type: 'hasMany', + target: 'b', + }, + ], + }); + + const B = db.collection({ + name: 'b', + inherits: ['a'], + fields: [ + { + name: 'bf', + type: 'string', + }, + { + name: 'a', + type: 'belongsTo', + target: 'a', + }, + ], + }); + + await db.sync(); + + const a1 = await B.repository.create({ + values: { + af: 'a1', + bs: [{ bf: 'b1' }, { bf: 'b2' }], + }, + }); + + expect(a1.get('bs').length).toBe(2); + + const b1 = await B.repository.findOne({ + filter: { + af: 'a1', + }, + appends: ['bs'], + }); + + expect(b1.get('bs').length).toBe(2); + }); + + it('should inherit belongsToMany field', async () => { + db.collection({ + name: 'person', + fields: [ + { + name: 'name', + type: 'string', + }, + { + name: 'tags', + type: 'belongsToMany', + }, + ], + }); + + db.collection({ + name: 'tags', + fields: [ + { + name: 'name', + type: 'string', + }, + { + name: 'person', + type: 'belongsToMany', + }, + ], + }); + + db.collection({ + name: 'students', + inherits: 'person', + fields: [ + { + name: 'score', + type: 'integer', + }, + ], + }); + + await db.sync(); + + await db.getCollection('students').repository.create({ + values: { + name: 'John', + score: 100, + tags: [ + { + name: 't1', + }, + { + name: 't2', + }, + { + name: 't3', + }, + ], + }, + }); + + await db.getCollection('person').repository.create({ + values: { + name: 'Max', + tags: [ + { + name: 't2', + }, + { + name: 't4', + }, + ], + }, + }); + + const john = await db.getCollection('students').repository.findOne({ + appends: ['tags'], + }); + + expect(john.get('name')).toBe('John'); + expect(john.get('tags')).toHaveLength(3); + + const max = await db.getCollection('person').repository.findOne({ + appends: ['tags'], + filter: { + name: 'Max', + }, + }); + + expect(max.get('name')).toBe('Max'); + expect(max.get('tags')).toHaveLength(2); + }); + + it('should inherit hasMany field', async () => { + db.collection({ + name: 'person', + fields: [ + { + name: 'name', + type: 'string', + }, + { + name: 'pets', + type: 'hasMany', + }, + ], + }); + + db.collection({ + name: 'pets', + fields: [ + { + name: 'name', + type: 'string', + }, + ], + }); + + db.collection({ + name: 'students', + inherits: 'person', + fields: [ + { + name: 'score', + type: 'integer', + }, + ], + }); + + await db.sync(); + + await db.getCollection('person').repository.create({ + values: { + name: 'Max', + pets: [ + { + name: 'doge1', + }, + { + name: 'kitty1', + }, + ], + }, + }); + + await db.getCollection('students').repository.create({ + values: { + name: 'John', + score: 100, + pets: [ + { + name: 'doge', + }, + { + name: 'kitty', + }, + { + name: 'doge2', + }, + ], + }, + }); + + const john = await db.getCollection('students').repository.findOne({ + appends: ['pets'], + }); + + expect(john.get('name')).toBe('John'); + expect(john.get('pets')).toHaveLength(3); + }); + + it('can inherit from multiple collections', async () => { + const a = db.collection({ + name: 'a', + fields: [{ type: 'string', name: 'a1' }], + }); + + const b = db.collection({ + name: 'b', + fields: [{ type: 'string', name: 'b1' }], + }); + + const c = db.collection({ + name: 'c', + inherits: ['a', 'b'], + fields: [ + { type: 'integer', name: 'id', autoIncrement: true }, + { type: 'string', name: 'c1' }, + ], + }); + + await db.sync(); + + expect(c.getField('a1')).toBeTruthy(); + expect(c.getField('b1')).toBeTruthy(); + expect(c.getField('c1')).toBeTruthy(); + + const c1 = await c.repository.create({ + values: { + a1: 'a1', + b1: 'b1', + c1: 'c1', + }, + }); + + expect(c1.get('a1')).toBe('a1'); + expect(c1.get('b1')).toBe('b1'); + expect(c1.get('c1')).toBe('c1'); + + const a2 = await a.repository.create({ + values: { + a1: 'a2', + }, + }); + + expect(a2.get('id')).toEqual(2); + }); + + it('should update inherit field when parent field update', async () => { + db.collection({ + name: 'person', + fields: [{ name: 'name', type: 'string', title: 'parent-name' }], + }); + + db.collection({ + name: 'students', + inherits: 'person', + }); + + expect(db.getCollection('students').getField('name').get('title')).toBe('parent-name'); + + db.getCollection('person').setField('name', { type: 'string', title: 'new-name' }); + + expect(db.getCollection('person').getField('name').get('title')).toBe('new-name'); + expect(db.getCollection('students').getField('name').get('title')).toBe('new-name'); + }); + + it('should not replace child field when parent field update', async () => { + db.collection({ + name: 'person', + fields: [{ name: 'name', type: 'string', title: 'parent-name' }], + }); + + db.collection({ + name: 'students', + inherits: 'person', + fields: [{ name: 'name', type: 'string', title: 'student-name' }], + }); + + expect(db.getCollection('students').getField('name').get('title')).toBe('student-name'); + + db.getCollection('person').setField('name', { type: 'string', title: 'new-name' }); + + expect(db.getCollection('person').getField('name').get('title')).toBe('new-name'); + expect(db.getCollection('students').getField('name').get('title')).toBe('student-name'); + }); + + it('should replace child association target', async () => { + db.collection({ + name: 'person', + fields: [ + { name: 'name', type: 'string' }, + { type: 'hasOne', name: 'profile' }, + ], + }); + + db.collection({ + name: 'profiles', + fields: [ + { name: 'age', type: 'integer' }, + { + type: 'belongsTo', + name: 'person', + }, + ], + }); + + db.collection({ + name: 'teachers', + inherits: 'person', + fields: [{ name: 'salary', type: 'integer' }], + }); + + db.collection({ + name: 'students', + inherits: 'person', + fields: [ + { name: 'score', type: 'integer' }, + { + type: 'hasOne', + name: 'profile', + target: 'studentProfiles', + }, + ], + }); + + db.collection({ + name: 'studentProfiles', + fields: [{ name: 'grade', type: 'string' }], + }); + + await db.sync(); + + const student = await db.getCollection('students').repository.create({ + values: { + name: 'foo', + score: 100, + profile: { + grade: 'A', + }, + }, + }); + + expect(student.get('profile').get('grade')).toBe('A'); + + const teacher = await db.getCollection('teachers').repository.create({ + values: { + name: 'bar', + salary: 1000, + profile: { + age: 30, + }, + }, + }); + + expect(teacher.get('profile').get('age')).toBe(30); + }); + + it('should replace hasOne association field', async () => { + const person = db.collection({ + name: 'person', + fields: [ + { name: 'name', type: 'string' }, + { type: 'hasOne', name: 'profile', target: 'profiles', foreignKey: 'person_id' }, + ], + }); + + const profile = db.collection({ + name: 'profiles', + fields: [ + { name: 'age', type: 'integer' }, + { + type: 'belongsTo', + name: 'person', + }, + ], + }); + + const student = db.collection({ + name: 'students', + inherits: 'person', + fields: [{ name: 'profile', type: 'hasOne', target: 'studentProfiles', foreignKey: 'student_id' }], + }); + + const studentProfile = db.collection({ + name: 'studentProfiles', + fields: [{ name: 'score', type: 'integer' }], + }); + + await db.sync(); + + const student1 = await student.repository.create({ + values: { + name: 'student-1', + profile: { + score: '100', + }, + }, + }); + + let person1 = await person.repository.findOne(); + await person.repository + .relation('profile') + .of(person1.get('id')) + .create({ + values: { + age: 30, + }, + }); + + person1 = await person.repository.findOne({ + appends: ['profile'], + }); + + expect(person1.get('profile').get('age')).toBe(30); + + expect(student1.get('profile').get('score')).toBe(100); + }); + + it('should inherit hasOne association field', async () => { + const person = db.collection({ + name: 'person', + fields: [ + { name: 'name', type: 'string' }, + { type: 'hasOne', name: 'profile' }, + ], + }); + + const profile = db.collection({ + name: 'profiles', + fields: [ + { name: 'age', type: 'integer' }, + { + type: 'belongsTo', + name: 'person', + }, + ], + }); + + db.collection({ + name: 'students', + inherits: 'person', + fields: [{ name: 'score', type: 'integer' }], + }); + + db.collection({ + name: 'teachers', + inherits: 'person', + fields: [{ name: 'salary', type: 'integer' }], + }); + + await db.sync(); + + await db.getCollection('students').repository.create({ + values: { + name: 'foo', + score: 100, + profile: { + age: 18, + }, + }, + }); + + await db.getCollection('teachers').repository.create({ + values: { + name: 'bar', + salary: 1000, + profile: { + age: 30, + }, + }, + }); + + const studentFoo = await db.getCollection('students').repository.findOne({ + appends: ['profile'], + }); + + const teacherBar = await db.getCollection('teachers').repository.findOne({ + appends: ['profile'], + }); + + expect(studentFoo.get('profile').age).toBe(18); + expect(teacherBar.get('profile').age).toBe(30); + }); + + it('should inherit from Collection', async () => { + const person = db.collection({ + name: 'person', + fields: [{ name: 'name', type: 'string' }], + }); + + const student = db.collection({ + name: 'student', + inherits: 'person', + fields: [{ name: 'score', type: 'integer' }], + }); + + await db.sync(); + + const StudentRepository = student.repository; + + await StudentRepository.create({ + values: { name: 'student1' }, + }); + + expect(await person.repository.count()).toBe(1); + }); + + it('should create inherited table', async () => { + const person = db.collection({ + name: 'person', + fields: [{ name: 'name', type: 'string' }], + }); + + const student = db.collection({ + name: 'student', + inherits: 'person', + fields: [{ name: 'score', type: 'integer' }], + }); + + await db.sync(); + + const studentTableInfo = await db.sequelize.getQueryInterface().describeTable(student.model.tableName); + + expect(studentTableInfo.score).toBeDefined(); + expect(studentTableInfo.name).toBeDefined(); + expect(studentTableInfo.id).toBeDefined(); + expect(studentTableInfo.createdAt).toBeDefined(); + expect(studentTableInfo.updatedAt).toBeDefined(); + }); + + it('should get parent fields', async () => { + const root = db.collection({ + name: 'root', + fields: [{ name: 'rootField', type: 'string' }], + }); + + const parent1 = db.collection({ + name: 'parent1', + inherits: 'root', + fields: [{ name: 'parent1Field', type: 'string' }], + }); + + const parent2 = db.collection({ + name: 'parent2', + inherits: 'parent1', + fields: [{ name: 'parent2Field', type: 'string' }], + }); + + const parent21 = db.collection({ + name: 'parent21', + fields: [{ name: 'parent21Field', type: 'string' }], + }); + + const child: InheritedCollection = db.collection({ + name: 'child', + inherits: ['parent2', 'parent21'], + fields: [{ name: 'childField', type: 'string' }], + }) as InheritedCollection; + + const parentFields = child.parentFields(); + expect(parentFields.size).toBe(4); + }); + + it('should sync parent fields', async () => { + const person = db.collection({ + name: 'person', + fields: [{ name: 'name', type: 'string' }], + }); + + const student = db.collection({ + name: 'student', + inherits: 'person', + fields: [{ name: 'score', type: 'integer' }], + }); + + await db.sync(); + + expect(student.fields.get('name')).toBeDefined(); + + // add new field to parent + person.setField('age', { type: 'integer' }); + + await db.sync(); + + expect(student.fields.get('age')).toBeDefined(); + + const student1 = await db.getCollection('student').repository.create({ + values: { + name: 'student1', + age: 10, + score: 100, + }, + }); + + expect(student1.get('name')).toBe('student1'); + expect(student1.get('age')).toBe(10); + }); +}); diff --git a/packages/core/database/src/__tests__/inhertits/helper.ts b/packages/core/database/src/__tests__/inhertits/helper.ts new file mode 100644 index 0000000000..c9a195ae8a --- /dev/null +++ b/packages/core/database/src/__tests__/inhertits/helper.ts @@ -0,0 +1,3 @@ +const pgOnly = () => (process.env.DB_DIALECT == 'postgres' ? describe : describe.skip); + +export default pgOnly; diff --git a/packages/core/database/src/__tests__/inhertits/inherited-map.test.ts b/packages/core/database/src/__tests__/inhertits/inherited-map.test.ts new file mode 100644 index 0000000000..873c934e8a --- /dev/null +++ b/packages/core/database/src/__tests__/inhertits/inherited-map.test.ts @@ -0,0 +1,27 @@ +import InheritanceMap from '../../inherited-map'; + +describe('InheritedMap', () => { + it('should setInherits', () => { + const map = new InheritanceMap(); + map.setInheritance('b', 'a'); + + const nodeA = map.getNode('a'); + const nodeB = map.getNode('b'); + + expect(nodeA.children.has(nodeB)).toBe(true); + expect(nodeB.parents.has(nodeA)).toBe(true); + + expect(map.isParentNode('a')).toBe(true); + }); + + it('should get deep children', () => { + const map = new InheritanceMap(); + map.setInheritance('b', 'a'); + map.setInheritance('c', 'b'); + map.setInheritance('c1', 'b'); + map.setInheritance('d', 'c'); + + const children = map.getChildren('a'); + expect(children.size).toBe(4); + }); +}); diff --git a/packages/core/database/src/collection.ts b/packages/core/database/src/collection.ts index d4beb55040..c37feb6108 100644 --- a/packages/core/database/src/collection.ts +++ b/packages/core/database/src/collection.ts @@ -22,6 +22,7 @@ export type CollectionSortable = string | boolean | { name?: string; scopeKey?: export interface CollectionOptions extends Omit { name: string; tableName?: string; + inherits?: string[] | string; filterTargetKey?: string; fields?: FieldOptions[]; model?: string | ModelCtor; @@ -74,7 +75,9 @@ export class Collection< this.bindFieldEventListener(); this.modelInit(); + this.db.modelCollection.set(this.model, this); + this.db.tableNameCollectionMap.set(this.model.tableName, this); this.setFields(options.fields); this.setRepository(options.repository); @@ -181,9 +184,34 @@ export class Collection< }, ); + const oldField = this.fields.get(name); + + if (oldField && oldField.options.inherit && options.type != oldField.options.type) { + throw new Error( + `Field type conflict: cannot set "${name}" to ${options.type}, parent "${name}" type is ${oldField.options.type}`, + ); + } + this.removeField(name); this.fields.set(name, field); this.emit('field.afterAdd', field); + + if (this.isParent()) { + for (const child of this.context.database.inheritanceMap.getChildren(this.name, { + deep: false, + })) { + const childCollection = this.db.getCollection(child); + const existField = childCollection.getField(name); + + if (!existField || existField.options.inherit) { + childCollection.setField(name, { + ...options, + inherit: true, + }); + } + } + } + return field; } @@ -371,6 +399,7 @@ export class Collection< for (const associationKey in associations) { const association = associations[associationKey]; modelNames.add(association.target.name); + if ((association).through) { modelNames.add((association).through.model.name); } @@ -388,4 +417,12 @@ export class Collection< await model.sync(syncOptions); } } + + public isInherited() { + return false; + } + + public isParent() { + return this.context.database.inheritanceMap.isParentNode(this.name); + } } diff --git a/packages/core/database/src/database.ts b/packages/core/database/src/database.ts index 07695e8951..72e5c22d92 100644 --- a/packages/core/database/src/database.ts +++ b/packages/core/database/src/database.ts @@ -56,6 +56,8 @@ import { } from './types'; import { referentialIntegrityCheck } from './features/referential-integrity-check'; import ReferencesMap from './features/ReferencesMap'; +import { InheritedCollection } from './inherited-collection'; +import InheritanceMap from './inherited-map'; export interface MergeOptions extends merge.Options {} @@ -148,7 +150,10 @@ export class Database extends EventEmitter implements AsyncEmitter { collections = new Map(); pendingFields = new Map(); modelCollection = new Map, Collection>(); + tableNameCollectionMap = new Map(); + referenceMap = new ReferencesMap(); + inheritanceMap = new InheritanceMap(); modelHook: ModelHook; version: DatabaseVersion; @@ -293,9 +298,13 @@ export class Database extends EventEmitter implements AsyncEmitter { ): Collection { this.emit('beforeDefineCollection', options); - const collection = new Collection(options, { - database: this, - }); + const collection = options.inherits + ? new InheritedCollection(options, { + database: this, + }) + : new Collection(options, { + database: this, + }); this.collections.set(collection.name, collection); @@ -429,6 +438,7 @@ export class Database extends EventEmitter implements AsyncEmitter { if (isMySQL) { await this.sequelize.query('SET FOREIGN_KEY_CHECKS = 1', null); } + return result; } diff --git a/packages/core/database/src/fields/field.ts b/packages/core/database/src/fields/field.ts index 281e48496f..a5ffd1d314 100644 --- a/packages/core/database/src/fields/field.ts +++ b/packages/core/database/src/fields/field.ts @@ -10,6 +10,7 @@ import { import { Collection } from '../collection'; import { Database } from '../database'; import { ModelEventTypes } from '../types'; +import { InheritedCollection } from '../inherited-collection'; export interface FieldContext { database: Database; @@ -19,6 +20,7 @@ export interface FieldContext { export interface BaseFieldOptions { name?: string; hidden?: boolean; + [key: string]: any; } @@ -32,8 +34,17 @@ export abstract class Field { context: FieldContext; database: Database; collection: Collection; + [key: string]: any; + constructor(options?: any, context?: FieldContext) { + this.context = context; + this.database = context.database; + this.collection = context.collection; + this.options = options || {}; + this.init(); + } + get name() { return this.options.name; } @@ -46,14 +57,6 @@ export abstract class Field { return this.options.dataType; } - constructor(options?: any, context?: FieldContext) { - this.context = context; - this.database = context.database; - this.collection = context.collection; - this.options = options || {}; - this.init(); - } - async sync(syncOptions: SyncOptions) { await this.collection.sync({ ...syncOptions, @@ -88,11 +91,18 @@ export abstract class Field { } async removeFromDb(options?: QueryInterfaceOptions) { - if (!this.collection.model.rawAttributes[this.name]) { + const attribute = this.collection.model.rawAttributes[this.name]; + + if (!attribute) { this.remove(); // console.log('field is not attribute'); return; } + + if (this.collection.isInherited() && (this.collection).parentFields().has(this.name)) { + return; + } + if ((this.collection.model as any)._virtualAttributes.has(this.name)) { this.remove(); // console.log('field is virtual attribute'); @@ -168,7 +178,7 @@ export abstract class Field { } bind() { - const { model } = this.context.collection; + const {model} = this.context.collection; model.rawAttributes[this.name] = this.toSequelize(); // @ts-ignore model.refreshAttributes(); @@ -178,7 +188,7 @@ export abstract class Field { } unbind() { - const { model } = this.context.collection; + const {model} = this.context.collection; model.removeAttribute(this.name); if (this.options.index || this.options.unique) { this.context.collection.removeIndex([this.name]); @@ -188,7 +198,7 @@ export abstract class Field { toSequelize(): any { const opts = _.omit(this.options, ['name']); if (this.dataType) { - Object.assign(opts, { type: this.dataType }); + Object.assign(opts, {type: this.dataType}); } return opts; } diff --git a/packages/core/database/src/index.ts b/packages/core/database/src/index.ts index 811d04eda3..16495fa453 100644 --- a/packages/core/database/src/index.ts +++ b/packages/core/database/src/index.ts @@ -1,5 +1,6 @@ export { DataTypes, ModelCtor, Op, SyncOptions } from 'sequelize'; export * from './collection'; +export * from './inherited-collection'; export * from './database'; export { Database as default } from './database'; export * from './fields'; @@ -14,4 +15,3 @@ export * from './relation-repository/multiple-relation-repository'; export * from './relation-repository/single-relation-repository'; export * from './repository'; export * from './update-associations'; - diff --git a/packages/core/database/src/inherited-collection.ts b/packages/core/database/src/inherited-collection.ts new file mode 100644 index 0000000000..41a49e1827 --- /dev/null +++ b/packages/core/database/src/inherited-collection.ts @@ -0,0 +1,73 @@ +import { Collection, CollectionContext, CollectionOptions } from './collection'; +import { default as lodash } from 'lodash'; +import { Field } from '.'; + +export class InheritedCollection extends Collection { + parents?: Collection[]; + + constructor(options: CollectionOptions, context: CollectionContext) { + if (!options.inherits) { + throw new Error('InheritedCollection must have inherits option'); + } + + super(options, context); + this.setParents(options.inherits); + this.context.database.inheritanceMap.setInheritance(this.name, options.inherits); + this.setParentFields(); + } + + protected setParents(inherits: string | string[]) { + this.parents = lodash.castArray(inherits).map((name) => this.context.database.collections.get(name)); + } + + protected setParentFields() { + for (const [name, field] of this.parentFields()) { + if (!this.hasField(name)) { + this.setField(name, { + ...field.options, + inherit: true, + }); + } + } + } + + getParents() { + return this.parents; + } + + parentFields() { + const fields = new Map(); + for (const parent of this.parents) { + if (parent.isInherited()) { + for (const [name, field] of (parent).parentFields()) { + fields.set(name, field); + } + } + + const parentFields = parent.fields; + for (const [name, field] of parentFields) { + fields.set(name, field); + } + } + return fields; + } + + parentAttributes() { + const attributes = {}; + for (const parent of this.parents) { + if (parent.isInherited()) { + Object.assign(attributes, (parent).parentAttributes()); + } + + const parentAttributes = (parent.model).tableAttributes; + + Object.assign(attributes, parentAttributes); + } + + return attributes; + } + + isInherited() { + return true; + } +} diff --git a/packages/core/database/src/inherited-map.ts b/packages/core/database/src/inherited-map.ts new file mode 100644 index 0000000000..2ece2af4de --- /dev/null +++ b/packages/core/database/src/inherited-map.ts @@ -0,0 +1,81 @@ +import lodash from 'lodash'; + +class TableNode { + name: string; + parents: Set; + children: Set; + constructor(name: string) { + this.name = name; + this.parents = new Set(); + this.children = new Set(); + } +} + +export default class InheritanceMap { + nodes: Map = new Map(); + + getOrCreateNode(name: string) { + if (!this.nodes.has(name)) { + this.nodes.set(name, new TableNode(name)); + } + return this.getNode(name); + } + + getNode(name: string) { + return this.nodes.get(name); + } + + setInheritance(name: string, inherits: string | string[]) { + const node = this.getOrCreateNode(name); + const parents = lodash.castArray(inherits).map((name) => this.getOrCreateNode(name)); + + node.parents = new Set(parents); + + for (const parent of parents) { + parent.children.add(node); + } + } + + isParentNode(name: string) { + const node = this.getNode(name); + return node && node.children.size > 0; + } + + getChildren(name: string, options: { deep: boolean } = { deep: true }): Set { + const results = new Set(); + const node = this.getNode(name); + if (!node) return results; + + for (const child of node.children) { + results.add(child.name); + if (!options.deep) { + continue; + } + + for (const grandchild of this.getChildren(child.name)) { + results.add(grandchild); + } + } + + return results; + } + + getParents(name: string, options: { deep: boolean } = { deep: true }): Set { + const results = new Set(); + const node = this.getNode(name); + if (!node) return results; + + for (const parent of node.parents) { + results.add(parent.name); + if (!options.deep) { + continue; + } + + for (const grandparent of this.getParents(parent.name)) { + results.add(grandparent); + } + } + + return results; + } +} diff --git a/packages/core/database/src/model.ts b/packages/core/database/src/model.ts index 84f94b4799..ca912c5e9e 100644 --- a/packages/core/database/src/model.ts +++ b/packages/core/database/src/model.ts @@ -3,6 +3,8 @@ import { Model as SequelizeModel, ModelCtor } from 'sequelize'; import { Collection } from './collection'; import { Database } from './database'; import { Field } from './fields'; +import type { InheritedCollection } from './inherited-collection'; +import { SyncRunner } from './sync-runner'; interface IModel { [key: string]: any; @@ -145,4 +147,14 @@ export class Model 0) { // @ts-ignore const primaryKeyField = model.primaryKeyField || model.primaryKeyAttribute; @@ -258,27 +260,38 @@ export class Repository { - return model - .findAll({ - ...omit(opts, ['limit', 'offset']), - include: include, - where, - transaction, - }) - .then((rows) => { - return { rows, include }; - }); + const options = { + ...omit(opts, ['limit', 'offset']), + include: include, + where, + transaction, + }; + + return model.findAll(options).then((rows) => { + return { rows, include }; + }); }), templateModel: ids[0].row, }); + } else { + rows = await model.findAll({ + ...opts, + transaction, + }); } - return await model.findAll({ - ...opts, - transaction, - }); + if (this.collection.isParent()) { + for (const row of rows) { + const rowCollectionName = this.database.tableNameCollectionMap.get(row.get('__tableName')).name; + row.set('__collection', rowCollectionName, { + raw: true, + }); + } + } + + return rows; } /** diff --git a/packages/core/database/src/sync-runner.ts b/packages/core/database/src/sync-runner.ts new file mode 100644 index 0000000000..53926e2d13 --- /dev/null +++ b/packages/core/database/src/sync-runner.ts @@ -0,0 +1,117 @@ +import { InheritedCollection } from './inherited-collection'; +import lodash from 'lodash'; +import { Sequelize } from 'sequelize'; + +export class SyncRunner { + static async syncInheritModel(model: any, options: any) { + const { transaction } = options; + + const inheritedCollection = model.collection as InheritedCollection; + const db = inheritedCollection.context.database; + const dialect = db.sequelize.getDialect(); + + const queryInterface = db.sequelize.getQueryInterface(); + + if (dialect != 'postgres') { + throw new Error('Inherit model is only supported on postgres'); + } + + const parents = inheritedCollection.parents; + + const parentTables = parents.map((parent) => parent.model.tableName); + + const tableName = model.getTableName(); + + const attributes = model.tableAttributes; + + const childAttributes = lodash.pickBy(attributes, (value) => { + return !value.inherit; + }); + + let maxSequenceVal = 0; + let maxSequenceName; + + if (childAttributes.id && childAttributes.id.autoIncrement) { + for (const parent of parentTables) { + const sequenceNameResult = await queryInterface.sequelize.query( + `select pg_get_serial_sequence('"${parent}"', 'id')`, + { + transaction, + }, + ); + const sequenceName = sequenceNameResult[0][0]['pg_get_serial_sequence']; + + const sequenceCurrentValResult = await queryInterface.sequelize.query( + `select last_value from ${sequenceName}`, + { + transaction, + }, + ); + const sequenceCurrentVal = sequenceCurrentValResult[0][0]['last_value']; + + if (sequenceCurrentVal > maxSequenceVal) { + maxSequenceName = sequenceName; + maxSequenceVal = sequenceCurrentVal; + } + } + } + + await this.createTable(tableName, childAttributes, options, model, parentTables); + + const parentsDeep = Array.from(db.inheritanceMap.getParents(inheritedCollection.name)).map( + (parent) => db.getCollection(parent).model.tableName, + ); + + const sequenceTables = [...parentsDeep, tableName]; + + for (const sequenceTable of sequenceTables) { + await queryInterface.sequelize.query( + `alter table "${sequenceTable}" alter column id set default nextval('${maxSequenceName}')`, + { + transaction, + }, + ); + } + + if (options.alter) { + const columns = await queryInterface.describeTable(tableName, options); + + for (const columnName in childAttributes) { + if (!columns[columnName]) { + await queryInterface.addColumn(tableName, columnName, childAttributes[columnName], options); + } + } + } + } + + static async createTable(tableName, attributes, options, model, parentTables) { + let sql = ''; + + options = { ...options }; + + if (options && options.uniqueKeys) { + lodash.forOwn(options.uniqueKeys, (uniqueKey) => { + if (uniqueKey.customIndex === undefined) { + uniqueKey.customIndex = true; + } + }); + } + + if (model) { + options.uniqueKeys = options.uniqueKeys || model.uniqueKeys; + } + + const queryGenerator = model.queryGenerator; + + attributes = lodash.mapValues(attributes, (attribute) => model.sequelize.normalizeAttribute(attribute)); + + attributes = queryGenerator.attributesToSQL(attributes, { table: tableName, context: 'createTable' }); + + sql = `${queryGenerator.createTableQuery(tableName, attributes, options)}`.replace( + ';', + ` INHERITS (${parentTables.map((t) => `"${t}"`).join(', ')});`, + ); + + return await model.sequelize.query(sql, options); + } +} diff --git a/packages/core/test/src/index.ts b/packages/core/test/src/index.ts index 4f222fecb2..89d3610ccc 100644 --- a/packages/core/test/src/index.ts +++ b/packages/core/test/src/index.ts @@ -1,3 +1,5 @@ export { mockDatabase } from '@nocobase/database'; export * from './mockServer'; +const pgOnly: () => jest.Describe = () => (process.env.DB_DIALECT == 'postgres' ? describe : describe.skip); +export { pgOnly }; diff --git a/packages/plugins/client/src/server.ts b/packages/plugins/client/src/server.ts index c92b7d4d5a..79949efbf1 100644 --- a/packages/plugins/client/src/server.ts +++ b/packages/plugins/client/src/server.ts @@ -22,6 +22,7 @@ export class ClientPlugin extends Plugin { this.app.acl.allow('app', 'getInfo'); this.app.acl.allow('app', 'getPlugins'); this.app.acl.allow('plugins', 'getPinned', 'loggedIn'); + const dialect = this.app.db.sequelize.getDialect(); this.app.resource({ name: 'app', actions: { @@ -35,6 +36,9 @@ export class ClientPlugin extends Plugin { lang = currentUser?.appLang; } ctx.body = { + database: { + dialect, + }, version: await ctx.app.version.get(), lang, }; diff --git a/packages/plugins/collection-manager/src/__tests__/http-api/collections.test.ts b/packages/plugins/collection-manager/src/__tests__/http-api/collections.test.ts index ad24931cc5..57f07fb2a6 100644 --- a/packages/plugins/collection-manager/src/__tests__/http-api/collections.test.ts +++ b/packages/plugins/collection-manager/src/__tests__/http-api/collections.test.ts @@ -8,77 +8,67 @@ describe('collections repository', () => { beforeEach(async () => { app = await createApp(); agent = app.agent(); - await agent - .resource('collections') - .create({ - values: { - name: 'tags', - fields: [ - { - name: 'title', - type: 'string', - }, - ], - }, - }); - await agent - .resource('collections') - .create({ - values: { - name: 'foos', - fields: [ - { - name: 'title', - type: 'string', - }, - ], - }, - }); - await agent - .resource('collections.fields', 'tags') - .create({ - values: { - name: 'foos', - target: 'foos', - type: 'belongsToMany', - }, - }); - await agent - .resource('collections') - .create({ - values: { - name: 'comments', - fields: [ - { - name: 'title', - type: 'string', - }, - ], - }, - }); - await agent - .resource('collections') - .create({ - values: { - name: 'posts', - fields: [ - { - name: 'title', - type: 'string', - }, - { - name: 'comments', - target: 'comments', - type: 'hasMany', - }, - { - name: 'tags', - target: 'tags', - type: 'belongsToMany', - }, - ], - }, - }); + await agent.resource('collections').create({ + values: { + name: 'tags', + fields: [ + { + name: 'title', + type: 'string', + }, + ], + }, + }); + await agent.resource('collections').create({ + values: { + name: 'foos', + fields: [ + { + name: 'title', + type: 'string', + }, + ], + }, + }); + await agent.resource('collections.fields', 'tags').create({ + values: { + name: 'foos', + target: 'foos', + type: 'belongsToMany', + }, + }); + await agent.resource('collections').create({ + values: { + name: 'comments', + fields: [ + { + name: 'title', + type: 'string', + }, + ], + }, + }); + await agent.resource('collections').create({ + values: { + name: 'posts', + fields: [ + { + name: 'title', + type: 'string', + }, + { + name: 'comments', + target: 'comments', + type: 'hasMany', + }, + { + name: 'tags', + target: 'tags', + type: 'belongsToMany', + }, + ], + }, + }); }); afterEach(async () => { @@ -95,71 +85,57 @@ describe('collections repository', () => { it('case 2', async () => { const response = await app.agent().resource('posts').create(); const postId = response.body.data.id; - await agent - .resource('posts.comments', postId) - .create({ - values: { - title: 'comment 1', - }, - }); - await agent - .resource('posts.comments', postId) - .create({ - values: { - title: 'comment 2', - }, - }); - const response2 = await agent - .resource('posts') - .list({ - filter: { - 'comments.title': 'comment 1', - }, - }); + await agent.resource('posts.comments', postId).create({ + values: { + title: 'comment 1', + }, + }); + await agent.resource('posts.comments', postId).create({ + values: { + title: 'comment 2', + }, + }); + const response2 = await agent.resource('posts').list({ + filter: { + 'comments.title': 'comment 1', + }, + }); expect(response2.body.data[0].id).toBe(1); }); it('case 3', async () => { const response = await app.agent().resource('posts').create(); const postId = response.body.data.id; - await agent - .resource('posts.comments', postId) - .create({ - values: { - title: 'comment 1', - }, - }); - const response2 = await agent - .resource('posts') - .list({ - filter: { - 'comments.id': 3, - }, - }); + await agent.resource('posts.comments', postId).create({ + values: { + title: 'comment 1', + }, + }); + const response2 = await agent.resource('posts').list({ + filter: { + 'comments.id': 3, + }, + }); expect(response2.body.data.length).toBe(0); }); it('case 4', async () => { const response = await app.agent().resource('posts').create(); const postId = response.body.data.id; - await agent - .resource('posts.comments', postId) - .create({ - values: { - title: 'comment 1', - }, - }); - const response2 = await agent - .resource('posts') - .list({ - filter: { - $and: [ - { - 'comments.title': 'comment 1', - }, - ], - }, - }); + await agent.resource('posts.comments', postId).create({ + values: { + title: 'comment 1', + }, + }); + const response2 = await agent.resource('posts').list({ + filter: { + $and: [ + { + 'comments.title': 'comment 1', + }, + ], + }, + }); expect(response2.body.data[0].id).toBe(1); }); @@ -174,126 +150,108 @@ describe('collections repository', () => { }); it('case 6', async () => { - const response = await agent - .resource('posts') - .create({ - values: { - tags: [ - {}, - { - title: 'Tag1', - }, - ], - }, - }); + const response = await agent.resource('posts').create({ + values: { + tags: [ + {}, + { + title: 'Tag1', + }, + ], + }, + }); const postId = response.body.data.id; const response1 = await app.agent().resource('posts.tags', postId).list(); expect(response1.body.data.length).toBe(2); }); it('case 7', async () => { - const response = await agent - .resource('posts') - .create({ - values: { - tags: [ - {}, - { - title: 'Tag1', - }, - ], - }, - }); + const response = await agent.resource('posts').create({ + values: { + tags: [ + {}, + { + title: 'Tag1', + }, + ], + }, + }); const postId = response.body.data.id; - const response1 = await agent - .resource('posts.tags', postId) - .list({ - filter: { - title: 'Tag1', - }, - }); + const response1 = await agent.resource('posts.tags', postId).list({ + filter: { + title: 'Tag1', + }, + }); expect(response1.body.data.length).toBe(1); }); it('case 8', async () => { - const response = await agent - .resource('posts') - .create({ - values: { - tags: [ - {}, - { - title: 'Tag1', - }, - { - title: 'Tag2', - }, - ], - }, - }); + const response = await agent.resource('posts').create({ + values: { + tags: [ + {}, + { + title: 'Tag1', + }, + { + title: 'Tag2', + }, + ], + }, + }); const postId = response.body.data.id; - const response1 = await agent - .resource('posts.tags', postId) - .list({ - filter: { - $or: [{ title: 'Tag1' }, { title: 'Tag2' }], - }, - }); + const response1 = await agent.resource('posts.tags', postId).list({ + filter: { + $or: [{ title: 'Tag1' }, { title: 'Tag2' }], + }, + }); expect(response1.body.data.length).toBe(2); }); it('case 9', async () => { - const response = await agent - .resource('posts') - .create({ - values: { - tags: [ - {}, - { - title: 'Tag1', - }, - { - title: 'Tag2', - }, - ], - }, - }); + const response = await agent.resource('posts').create({ + values: { + tags: [ + {}, + { + title: 'Tag1', + }, + { + title: 'Tag2', + }, + ], + }, + }); const postId = response.body.data.id; - const response1 = await agent - .resource('posts.tags', postId) - .list({ - filter: { - $or: [{ title: 'Tag1' }, { title: 'Tag2' }], - }, - }); + const response1 = await agent.resource('posts.tags', postId).list({ + filter: { + $or: [{ title: 'Tag1' }, { title: 'Tag2' }], + }, + }); expect(response1.body.data.length).toBe(2); }); it('case 10', async () => { - const response = await agent - .resource('posts') - .create({ - values: { - tags: [ - {}, - { - title: 'Tag1', - }, - { - title: 'Tag2', - }, - ], - }, - }); + const response = await agent.resource('posts').create({ + values: { + tags: [ + {}, + { + title: 'Tag1', + }, + { + title: 'Tag2', + }, + ], + }, + }); const postId = response.body.data.id; - const response1 = await agent - .resource('posts.tags', postId) - .list({ - appends: ['foos'], - page: 1, - pageSize: 20, - sort: ['-createdAt', '-id'], - }); + const response1 = await agent.resource('posts.tags', postId).list({ + appends: ['foos'], + page: 1, + pageSize: 20, + sort: ['-createdAt', '-id'], + }); expect(response1.body.data[0]['id']).toEqual(3); }); @@ -301,91 +259,73 @@ describe('collections repository', () => { it('case 11', async () => { const response = await app.agent().resource('posts').create(); const postId = response.body.data.id; - await agent - .resource('posts.comments', postId) - .create({ - values: { - title: 'comment 1', - }, - }); - await agent - .resource('posts.comments', postId) - .create({ - values: { - title: 'comment 2', - }, - }); + await agent.resource('posts.comments', postId).create({ + values: { + title: 'comment 1', + }, + }); + await agent.resource('posts.comments', postId).create({ + values: { + title: 'comment 2', + }, + }); const response2 = await app.agent().resource('posts').create(); const postId2 = response2.body.data.id; - await agent - .resource('posts.comments', postId2) - .create({ - values: { - title: 'comment 2', - }, - }); - await agent - .resource('posts.comments', postId2) - .create({ - values: { - title: 'comment 2', - }, - }); - const response3 = await agent - .resource('posts') - .list({ - filter: { - $or: [ - { - 'comments.title': 'comment 1', - }, - { - 'comments.title': 'comment 2', - }, - ], - }, - }); + await agent.resource('posts.comments', postId2).create({ + values: { + title: 'comment 2', + }, + }); + await agent.resource('posts.comments', postId2).create({ + values: { + title: 'comment 2', + }, + }); + const response3 = await agent.resource('posts').list({ + filter: { + $or: [ + { + 'comments.title': 'comment 1', + }, + { + 'comments.title': 'comment 2', + }, + ], + }, + }); expect(response3.body.data.length).toBe(2); }); it('case 12', async () => { const response = await app.agent().resource('posts').create(); const postId = response.body.data.id; - await agent - .resource('posts.comments', postId) - .create({ - values: { - title: 'comment 1', - }, - }); - await agent - .resource('posts.comments', postId) - .create({ - values: { - title: 'comment 2', - }, - }); - await agent - .resource('posts.comments', postId) - .create({ - values: { - title: 'comment 3', - }, - }); - const response2 = await agent - .resource('posts.comments', postId) - .list({ - filter: { - $or: [ - { - title: 'comment 1', - }, - { - title: 'comment 2', - }, - ], - }, - }); + await agent.resource('posts.comments', postId).create({ + values: { + title: 'comment 1', + }, + }); + await agent.resource('posts.comments', postId).create({ + values: { + title: 'comment 2', + }, + }); + await agent.resource('posts.comments', postId).create({ + values: { + title: 'comment 3', + }, + }); + const response2 = await agent.resource('posts.comments', postId).list({ + filter: { + $or: [ + { + title: 'comment 1', + }, + { + title: 'comment 2', + }, + ], + }, + }); expect(response2.body.data.length).toBe(2); }); @@ -394,35 +334,27 @@ describe('collections repository', () => { const tag1 = await tagRepository.create({ values: { title: 'tag1' } }); const tag2 = await tagRepository.create({ values: { title: 'tag2' } }); const tag3 = await tagRepository.create({ values: { title: 'tag3' } }); - await agent - .resource('posts') - .create({ - values: { - tags: [tag1.get('id'), tag3.get('id')], - }, - }); - await agent - .resource('posts') - .create({ - values: { - tags: [tag2.get('id')], - }, - }); - await agent - .resource('posts') - .create({ - values: { - tags: [tag2.get('id'), tag3.get('id')], - }, - }); + await agent.resource('posts').create({ + values: { + tags: [tag1.get('id'), tag3.get('id')], + }, + }); + await agent.resource('posts').create({ + values: { + tags: [tag2.get('id')], + }, + }); + await agent.resource('posts').create({ + values: { + tags: [tag2.get('id'), tag3.get('id')], + }, + }); - const response1 = await agent - .resource('posts') - .list({ - filter: { - $or: [{ 'tags.title': 'tag1' }, { 'tags.title': 'tag3' }], - }, - }); + const response1 = await agent.resource('posts').list({ + filter: { + $or: [{ 'tags.title': 'tag1' }, { 'tags.title': 'tag3' }], + }, + }); expect(response1.body.data.length).toBe(2); }); }); diff --git a/packages/plugins/collection-manager/src/__tests__/http-api/inherited-collection.test.ts b/packages/plugins/collection-manager/src/__tests__/http-api/inherited-collection.test.ts new file mode 100644 index 0000000000..6a4be7baf3 --- /dev/null +++ b/packages/plugins/collection-manager/src/__tests__/http-api/inherited-collection.test.ts @@ -0,0 +1,281 @@ +import { MockServer, pgOnly } from '@nocobase/test'; +import { createApp } from '..'; + +pgOnly()('Inherited Collection', () => { + let app: MockServer; + let agent; + + beforeEach(async () => { + app = await createApp(); + agent = app.agent(); + await agent.resource('collections').create({ + values: { + name: 'person', + fields: [ + { + name: 'name', + type: 'string', + }, + ], + }, + }); + }); + + afterEach(async () => { + await app.destroy(); + }); + + it('should not replace field with difference type when add field', async () => { + let response = await agent.resource('collections').create({ + values: { + name: 'students', + inherits: 'person', + fields: [], + }, + }); + + expect(response.statusCode).toBe(200); + + response = await agent.resource('fields').create({ + values: { + collectionName: 'students', + name: 'name', + type: 'integer', + }, + }); + + expect(response.statusCode).not.toBe(200); + }); + + it('should not replace field with difference type when create collection', async () => { + const response = await agent.resource('collections').create({ + context: {}, + values: { + name: 'students', + inherits: ['person'], + fields: [ + { + name: 'name', + type: 'integer', + }, + ], + }, + }); + expect(response.statusCode).toBe(500); + }); + + it('can create relation with child table', async () => { + await agent.resource('collections').create({ + values: { + name: 'a', + fields: [ + { + name: 'af', + type: 'string', + }, + { + name: 'bs', + type: 'hasMany', + target: 'b', + }, + ], + }, + }); + + await agent.resource('collections').create({ + values: { + name: 'b', + inherits: ['a'], + fields: [ + { + name: 'bf', + type: 'string', + }, + { + name: 'a', + type: 'belongsTo', + target: 'a', + }, + ], + }, + }); + + const res = await agent.resource('b').create({ + values: { + af: 'a1', + bs: [{ bf: 'b1' }, { bf: 'b2' }], + }, + }); + + expect(res.statusCode).toEqual(200); + + const a1 = await agent.resource('b').list({ + filter: { + af: 'a1', + }, + appends: 'bs', + }); + + expect(a1.body.data[0].bs.length).toEqual(2); + }); + + it('can drop child replaced field', async () => { + await agent.resource('collections').create({ + values: { + name: 'students', + inherits: 'person', + fields: [ + { + name: 'name', + type: 'string', + }, + ], + }, + }); + + const response = await agent.resource('collections.fields', 'students').destroy({ + filter: { + name: 'name', + }, + }); + + expect(response.status).toBe(200); + }); + + it('should reload collection when parent fields change', async () => { + await agent.resource('collections').create({ + values: { + name: 'employee', + inherits: 'person', + fields: [ + { + name: 'salary', + type: 'integer', + }, + ], + }, + }); + + await agent.resource('collections').create({ + values: { + name: 'instructor', + inherits: 'employee', + fields: [ + { + name: 'rank', + type: 'integer', + }, + ], + }, + }); + + const createInstructorResponse = await agent.resource('instructor').create({ + values: { + name: 'foo', + salary: 1000, + rank: 100, + }, + }); + + expect(createInstructorResponse.statusCode).toBe(200); + + const employeeCollection = app.db.getCollection('employee'); + expect(employeeCollection.fields.get('new-field')).not.toBeDefined(); + + // add new field to root collection + await agent.resource('fields').create({ + values: { + collectionName: 'person', + name: 'age', + type: 'integer', + }, + }); + + expect(employeeCollection.fields.get('age')).toBeDefined(); + + let listResponse = await agent.resource('employee').list(); + expect(listResponse.statusCode).toBe(200); + + expect(listResponse.body.data[0].age).toBeDefined(); + + listResponse = await agent.resource('instructor').list(); + expect(listResponse.statusCode).toBe(200); + + expect(listResponse.body.data[0].age).toBeDefined(); + }); + + it('should create inherited collection', async () => { + const response = await agent.resource('collections').create({ + values: { + name: 'students', + inherits: 'person', + fields: [ + { + name: 'score', + type: 'integer', + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + + const studentCollection = app.db.getCollection('students'); + expect(studentCollection).toBeDefined(); + + const studentFieldsResponse = await agent.resource('fields').list({ + filter: { + collectionName: 'students', + }, + }); + + expect(studentFieldsResponse.statusCode).toBe(200); + + const studentFields = studentFieldsResponse.body.data; + expect(studentFields.length).toBe(1); + + const createStudentResponse = await agent.resource('students').create({ + values: { + name: 'foo', + score: 100, + }, + }); + + expect(createStudentResponse.statusCode).toBe(200); + + const fooStudent = createStudentResponse.body.data; + + expect(fooStudent.name).toBe('foo'); + + const studentList = await agent.resource('students').list(); + expect(studentList.statusCode).toBe(200); + }); + + it('should know which child table row it is', async () => { + await agent.resource('collections').create({ + values: { + name: 'students', + inherits: 'person', + fields: [ + { + name: 'score', + type: 'integer', + }, + ], + }, + }); + + await agent.resource('students').create({ + values: { + name: 'foo', + score: 100, + }, + }); + + const personList = await agent.resource('person').list(); + + const person = personList.body.data[0]; + + expect(person['__collection']).toBe('students'); + }); +}); diff --git a/packages/plugins/collection-manager/src/__tests__/inherits/inherited-collection.test.ts b/packages/plugins/collection-manager/src/__tests__/inherits/inherited-collection.test.ts new file mode 100644 index 0000000000..60f9543842 --- /dev/null +++ b/packages/plugins/collection-manager/src/__tests__/inherits/inherited-collection.test.ts @@ -0,0 +1,320 @@ +import Database, { Repository } from '@nocobase/database'; +import Application from '@nocobase/server'; +import { createApp } from '..'; +import { pgOnly } from '@nocobase/test'; + +pgOnly()('Inherited Collection', () => { + let db: Database; + let app: Application; + + let collectionRepository: Repository; + + let fieldsRepository: Repository; + + beforeEach(async () => { + app = await createApp(); + + db = app.db; + + collectionRepository = db.getCollection('collections').repository; + fieldsRepository = db.getCollection('fields').repository; + }); + + afterEach(async () => { + await app.destroy(); + }); + + it("should not delete child's field when parent field delete that inherits from multiple table", async () => { + await collectionRepository.create({ + values: { + name: 'b', + fields: [ + { + name: 'name', + type: 'string', + }, + ], + }, + context: {}, + }); + + await collectionRepository.create({ + values: { + name: 'c', + fields: [ + { + name: 'name', + type: 'string', + }, + ], + }, + context: {}, + }); + + await collectionRepository.create({ + values: { + name: 'a', + inherits: ['b', 'c'], + }, + context: {}, + }); + + await fieldsRepository.create({ + values: { + collectionName: 'a', + name: 'name', + type: 'string', + }, + }); + + await db.getCollection('fields').repository.destroy({ + filter: { + collectionName: 'b', + name: 'name', + }, + }); + + expect( + await db.getCollection('fields').repository.findOne({ + filter: { + collectionName: 'a', + name: 'name', + }, + }), + ).not.toBeNull(); + + await db.getCollection('fields').repository.destroy({ + filter: { + collectionName: 'c', + name: 'name', + }, + }); + + expect( + await db.getCollection('fields').repository.findOne({ + filter: { + collectionName: 'a', + name: 'name', + }, + }), + ).toBeNull(); + }); + + it("should delete child's field when parent field deleted", async () => { + await collectionRepository.create({ + values: { + name: 'person', + fields: [ + { + name: 'name', + type: 'string', + }, + ], + }, + context: {}, + }); + + await collectionRepository.create({ + values: { + name: 'students', + inherits: ['person'], + }, + context: {}, + }); + + await db.getCollection('fields').repository.create({ + values: { + collectionName: 'students', + name: 'name', + type: 'string', + }, + context: {}, + }); + + await db.getCollection('fields').repository.create({ + values: { + collectionName: 'students', + name: 'age', + type: 'integer', + }, + context: {}, + }); + + const childNameField = await db.getCollection('fields').repository.findOne({ + filter: { + collectionName: 'students', + name: 'name', + }, + }); + + expect(childNameField.get('overriding')).toBeTruthy(); + + await db.getCollection('fields').repository.destroy({ + filter: { + collectionName: 'person', + name: 'name', + }, + }); + + expect( + await db.getCollection('fields').repository.findOne({ + filter: { + collectionName: 'students', + name: 'name', + }, + }), + ).toBeNull(); + + expect( + await db.getCollection('fields').repository.findOne({ + filter: { + collectionName: 'students', + name: 'age', + }, + }), + ).not.toBeNull(); + + await db.getCollection('fields').repository.create({ + values: { + collectionName: 'person', + name: 'age', + type: 'integer', + }, + context: {}, + }); + + await db.getCollection('fields').repository.destroy({ + filter: { + collectionName: 'person', + name: 'age', + }, + }); + + expect( + await db.getCollection('fields').repository.findOne({ + filter: { + collectionName: 'person', + name: 'age', + }, + }), + ).toBeNull(); + + expect( + await db.getCollection('fields').repository.findOne({ + filter: { + collectionName: 'students', + name: 'age', + }, + }), + ).not.toBeNull(); + }); + + it('should not inherit with difference type', async () => { + const personCollection = await collectionRepository.create({ + values: { + name: 'person', + fields: [ + { + name: 'name', + type: 'string', + }, + ], + }, + context: {}, + }); + + let err; + try { + const studentCollection = await collectionRepository.create({ + values: { + name: 'students', + inherits: 'person', + fields: [ + { + name: 'name', + type: 'integer', + }, + ], + }, + context: {}, + }); + } catch (e) { + err = e; + } + + expect(err).toBeDefined(); + expect(err.message.includes('type conflict')).toBeTruthy(); + }); + + it('should replace parent collection field', async () => { + const personCollection = await collectionRepository.create({ + values: { + name: 'person', + fields: [ + { + name: 'name', + type: 'string', + }, + ], + }, + context: {}, + }); + + const studentCollection = await collectionRepository.create({ + values: { + name: 'students', + inherits: 'person', + fields: [ + { + name: 'name', + type: 'string', + title: '姓名', + }, + ], + }, + context: {}, + }); + + const studentFields = await studentCollection.getFields(); + expect(studentFields.length).toBe(1); + expect(studentFields[0].get('title')).toBe('姓名'); + }); + + it('should remove parent collections field', async () => { + await collectionRepository.create({ + values: { + name: 'person', + fields: [ + { + name: 'name', + type: 'string', + }, + ], + }, + context: {}, + }); + + await collectionRepository.create({ + values: { + name: 'students', + fields: [ + { + name: 'score', + type: 'integer', + }, + ], + }, + context: {}, + }); + + const studentCollection = await db.getCollection('students'); + + console.log(studentCollection.fields); + await studentCollection.repository.create({ + values: { + name: 'foo', + score: 100, + }, + }); + }); +}); diff --git a/packages/plugins/collection-manager/src/__tests__/sync.test.ts b/packages/plugins/collection-manager/src/__tests__/sync.test.ts new file mode 100644 index 0000000000..ecb23ea6fd --- /dev/null +++ b/packages/plugins/collection-manager/src/__tests__/sync.test.ts @@ -0,0 +1,57 @@ +import Database, { Collection as DBCollection } from '@nocobase/database'; +import Application from '@nocobase/server'; +import { createApp } from '.'; + +describe('sync collection', () => { + let db: Database; + let app: Application; + + beforeEach(async () => { + app = await createApp(); + db = app.db; + }); + + afterEach(async () => { + await app.destroy(); + }); + + it('should not remove column when async with drop false', async () => { + const getTableInfo = async (tableName: string) => { + const queryInterface = db.sequelize.getQueryInterface(); + const tableInfo = await queryInterface.describeTable(tableName); + return tableInfo; + }; + + const c1 = db.collection({ + name: 'c1', + fields: [{ type: 'string', name: 'f1' }], + }); + + await db.sync({ + force: false, + alter: { + drop: false, + }, + }); + + let tableInfo1 = await getTableInfo(c1.model.tableName); + expect(tableInfo1.f1).toBeTruthy(); + + c1.setField('f2', { + type: 'string', + }); + + c1.removeField('f1'); + + await db.sync({ + force: false, + alter: { + drop: false, + }, + }); + + let tableInfo2 = await getTableInfo(c1.model.tableName); + expect(tableInfo2.f2).toBeTruthy(); + expect(tableInfo2.f1).toBeTruthy(); + }); +}); diff --git a/packages/plugins/collection-manager/src/models/collection.ts b/packages/plugins/collection-manager/src/models/collection.ts index 99212ef759..e797e5e8a8 100644 --- a/packages/plugins/collection-manager/src/models/collection.ts +++ b/packages/plugins/collection-manager/src/models/collection.ts @@ -1,6 +1,7 @@ import Database, { Collection, MagicAttributeModel } from '@nocobase/database'; import { SyncOptions, Transactionable } from 'sequelize'; import { FieldModel } from './field'; +import lodash from 'lodash'; interface LoadOptions extends Transactionable { // TODO @@ -19,25 +20,27 @@ export class CollectionModel extends MagicAttributeModel { let collection: Collection; + const collectionOptions = { + ...this.get(), + fields: [], + }; + if (this.db.hasCollection(name)) { collection = this.db.getCollection(name); + if (skipExist) { return collection; } - collection.updateOptions({ - ...this.get(), - fields: [], - }); + + collection.updateOptions(collectionOptions); } else { - collection = this.db.collection({ - ...this.get(), - fields: [], - }); + collection = this.db.collection(collectionOptions); } if (!skipField) { await this.loadFields({ transaction }); } + return collection; } @@ -83,6 +86,7 @@ export class CollectionModel extends MagicAttributeModel { const collection = await this.load({ transaction: options?.transaction, }); + try { await collection.sync({ force: false, @@ -92,8 +96,73 @@ export class CollectionModel extends MagicAttributeModel { ...options, }); } catch (error) { + console.error(error); const name = this.get('name'); this.db.removeCollection(name); + throw error; + } + } + + isInheritedModel() { + return this.get('inherits'); + } + + async findParents(options: Transactionable) { + const { transaction } = options; + + const findModelParents = async (model: CollectionModel, carry = []) => { + if (!model.get('inherits')) { + return; + } + const parents = lodash.castArray(model.get('inherits')); + + for (const parent of parents) { + const parentModel = (await this.db.getCollection('collections').repository.findOne({ + filterByTk: parent, + transaction, + })) as CollectionModel; + + carry.push(parentModel.get('name')); + + await findModelParents(parentModel, carry); + } + + return carry; + }; + + return findModelParents(this); + } + + async parentFields(options: Transactionable) { + const { transaction } = options; + + return this.db.getCollection('fields').repository.find({ + filter: { + collectionName: { $in: await this.findParents({ transaction }) }, + }, + transaction, + }); + } + + // sync fields from parents + async syncParentFields(options: Transactionable) { + const { transaction } = options; + + const ancestorFields = await this.parentFields({ transaction }); + + const selfFields = await this.getFields({ transaction }); + + const inheritedFields = ancestorFields.filter((field: FieldModel) => { + return ( + !field.isAssociationField() && + !selfFields.find((selfField: FieldModel) => selfField.get('name') == field.get('name')) + ); + }); + + for (const inheritedField of inheritedFields) { + await this.createField(lodash.omit(inheritedField.toJSON(), ['key', 'collectionName', 'sort']), { + transaction, + }); } } } diff --git a/packages/plugins/collection-manager/src/models/field.ts b/packages/plugins/collection-manager/src/models/field.ts index 6cd16f860e..43ec7aebb3 100644 --- a/packages/plugins/collection-manager/src/models/field.ts +++ b/packages/plugins/collection-manager/src/models/field.ts @@ -67,15 +67,21 @@ export class FieldModel extends MagicAttributeModel { return (this.constructor).database; } + isAssociationField() { + return ['belongsTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(this.get('type')); + } + async load(loadOptions?: LoadOptions) { const { skipExist = false } = loadOptions || {}; const collectionName = this.get('collectionName'); + if (!this.db.hasCollection(collectionName)) { return; } const collection = this.db.getCollection(collectionName); const name = this.get('name'); + if (skipExist && collection.hasField(name)) { return collection.getField(name); } @@ -123,6 +129,7 @@ export class FieldModel extends MagicAttributeModel { if (!field) { return; } + return field.removeFromDb({ transaction: options.transaction, }); diff --git a/packages/plugins/collection-manager/src/server.ts b/packages/plugins/collection-manager/src/server.ts index 7a0fd50394..5545c6db67 100644 --- a/packages/plugins/collection-manager/src/server.ts +++ b/packages/plugins/collection-manager/src/server.ts @@ -17,6 +17,7 @@ import { } from './hooks'; import { CollectionModel, FieldModel } from './models'; +import { InheritedCollection } from '@nocobase/database'; export class CollectionManagerPlugin extends Plugin { async beforeLoad() { @@ -56,6 +57,20 @@ export class CollectionManagerPlugin extends Plugin { // 要在 beforeInitOptions 之前处理 this.app.db.on('fields.beforeCreate', beforeCreateForReverseField(this.app.db)); this.app.db.on('fields.beforeCreate', beforeCreateForChildrenCollection(this.app.db)); + + this.app.db.on('fields.beforeCreate', async (model, options) => { + const collectionName = model.get('collectionName'); + const collection = this.app.db.getCollection(collectionName); + + if (!collection) { + return; + } + + if (collection.isInherited() && (collection).parentFields().has(model.get('name'))) { + model.set('overriding', true); + } + }); + this.app.db.on('fields.beforeCreate', async (model, options) => { const type = model.get('type'); const fn = beforeInitOptions[type]; @@ -66,14 +81,16 @@ export class CollectionManagerPlugin extends Plugin { this.app.db.on('fields.afterCreate', afterCreateForReverseField(this.app.db)); - this.app.db.on('collections.afterCreateWithAssociations', async (model, { context, transaction }) => { - if (context) { - await model.migrate({ - isNew: true, - transaction, - }); - } - }); + this.app.db.on( + 'collections.afterCreateWithAssociations', + async (model: CollectionModel, { context, transaction }) => { + if (context) { + await model.migrate({ + transaction, + }); + } + }, + ); this.app.db.on('fields.afterCreate', async (model: FieldModel, { context, transaction }) => { if (context) { @@ -123,7 +140,7 @@ export class CollectionManagerPlugin extends Plugin { // before field remove this.app.db.on('fields.beforeDestroy', beforeDestroyForeignKey(this.app.db)); - this.app.db.on('fields.beforeDestroy', async (model, options) => { + this.app.db.on('fields.beforeDestroy', async (model: FieldModel, options) => { await model.remove(options); }); @@ -131,6 +148,37 @@ export class CollectionManagerPlugin extends Plugin { await model.remove(options); }); + this.app.db.on('fields.afterDestroy', async (model: FieldModel, options) => { + const { transaction } = options; + const collectionName = model.get('collectionName'); + const childCollections = this.db.inheritanceMap.getChildren(collectionName); + + const childShouldRemoveField = Array.from(childCollections).filter((item) => { + const parents = Array.from(this.db.inheritanceMap.getParents(item)) + .map((parent) => { + const collection = this.db.getCollection(parent); + const field = collection.getField(model.get('name')); + return field; + }) + .filter(Boolean); + + return parents.length == 0; + }); + + await this.db.getCollection('fields').repository.destroy({ + filter: { + name: model.get('name'), + collectionName: { + $in: childShouldRemoveField, + }, + options: { + overriding: true, + }, + }, + transaction, + }); + }); + this.app.on('afterLoad', async (app, options) => { if (options?.method === 'install') { return;