diff --git a/.env.example b/.env.example index 40d6f63b4..52fdb02c5 100644 --- a/.env.example +++ b/.env.example @@ -77,3 +77,6 @@ INIT_ALI_SMS_VERIFY_CODE_SIGN= # use any string name (no space) DEFAULT_SMS_VERIFY_CODE_PROVIDER= + +# in nodejs 17+ that SSL v3 causes some ecosystem libraries to become incompatible. Configuring this option can prevent upgrading SSL V3 +# NODE_OPTIONS=--openssl-legacy-provider diff --git a/.github/workflows/nocobase-test.yml b/.github/workflows/nocobase-test.yml index 91de994e4..ab3c1b1a7 100644 --- a/.github/workflows/nocobase-test.yml +++ b/.github/workflows/nocobase-test.yml @@ -17,6 +17,100 @@ on: jobs: sqlite-test: + strategy: + matrix: + node_version: ['16', '18'] + runs-on: ubuntu-latest + container: node:${{ matrix.node_version }} + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node_version }} + uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node_version }} + cache: 'yarn' + - run: yarn install + - run: yarn build + - name: Test with Sqlite + run: yarn test + env: + DB_DIALECT: sqlite + DB_STORAGE: /tmp/db.sqlite + + postgres-test: + strategy: + matrix: + node_version: ['16', '18'] + + runs-on: ubuntu-latest + container: node:${{ matrix.node_version }} + services: + # Label used to access the service container + postgres: + # Docker Hub image + image: postgres:10 + # Provide the password for postgres + env: + POSTGRES_USER: nocobase + POSTGRES_PASSWORD: password + # Set health checks to wait until postgres has started + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node_version }} + uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node_version }} + cache: 'yarn' + - run: yarn install + - run: yarn build + - name: Test with postgres + run: yarn test + env: + DB_DIALECT: postgres + DB_HOST: postgres + DB_PORT: 5432 + DB_USER: nocobase + DB_PASSWORD: password + DB_DATABASE: nocobase + + mysql-test: + strategy: + matrix: + node_version: ['16', '18'] + runs-on: ubuntu-latest + container: node:${{ matrix.node_version }} + services: + mysql: + image: mysql:8 + env: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: nocobase + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node_version }} + uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node_version }} + cache: 'yarn' + - run: yarn install + - run: yarn build + - name: Test with MySQL + run: yarn test + env: + DB_DIALECT: mysql + DB_HOST: mysql + DB_PORT: 3306 + DB_USER: root + DB_PASSWORD: password + DB_DATABASE: nocobase + + sqlite-underscored-test: strategy: matrix: node_version: ['16'] @@ -35,8 +129,9 @@ jobs: env: DB_DIALECT: sqlite DB_STORAGE: /tmp/db.sqlite + DB_UNDERSCORED: true - postgres-test: + postgres-underscored-test: strategy: matrix: node_version: ['16'] @@ -75,8 +170,9 @@ jobs: DB_USER: nocobase DB_PASSWORD: password DB_DATABASE: nocobase + DB_UNDERSCORED: true - mysql-test: + mysql-underscored-test: strategy: matrix: node_version: ['16'] @@ -106,3 +202,4 @@ jobs: DB_USER: root DB_PASSWORD: password DB_DATABASE: nocobase + DB_UNDERSCORED: true diff --git a/docs/en-US/api/resourcer/action.md b/docs/en-US/api/resourcer/action.md index 9b5a0968a..9afed49b8 100644 --- a/docs/en-US/api/resourcer/action.md +++ b/docs/en-US/api/resourcer/action.md @@ -1,67 +1,67 @@ # Action -Action 是对资源的操作过程的描述,通常包含数据库处理等,类似其他框架中的 service 层,最简化的实现可以是一个 Koa 的中间件函数。在资源管理器里,针对特定资源定义的普通操作函数会被包装成 Action 类型的实例,当请求匹配对应资源的操作时,执行对应的操作过程。 +Action is the description of the operation process on resource, including database processing and so on. It is like the service layer in other frameworks, and the most simplified implementation can be a Koa middleware function. In the resourcer, common action functions defined for particular resources are wrapped into instances of the type Action, and when the request matches the action of the corresponding resource, the corresponding action is executed. -## 构造函数 +## Constructor -通常不需要直接实例化 Action,而是由资源管理器自动调用 `Action` 的静态方法 `toInstanceMap()` 进行实例化。 +Instead of being instantiated directly, the Action is usually instantiated automatically by the resourcer by calling the static method `toInstanceMap()` of `Action`. ### `constructor(options: ActionOptions)` -**参数** +**Parameter** -| 参数名 | 类型 | 默认值 | 描述 | +| Name | Type | Default | Description | | --- | --- | --- | --- | -| `handler` | `Function` | - | 操作函数 | -| `middlewares?` | `Middleware \| Middleware[]` | - | 针对操作的中间件 | -| `values?` | `Object` | - | 默认的操作数据 | -| `fields?` | `string[]` | - | 默认针对的字段组 | -| `appends?` | `string[]` | - | 默认附加的关联字段组 | -| `except?` | `string[]` | - | 默认排除的字段组 | -| `filter` | `FilterOptions` | - | 默认的过滤参数 | -| `sort` | `string[]` | - | 默认的排序参数 | -| `page` | `number` | - | 默认的页码 | -| `pageSize` | `number` | - | 默认的每页数量 | -| `maxPageSize` | `number` | - | 默认最大每页数量 | +| `handler` | `Function` | - | Handler function | +| `middlewares?` | `Middleware \| Middleware[]` | - | Middlewares for the action | +| `values?` | `Object` | - | Default action data | +| `fields?` | `string[]` | - | Default list of targeted fields | +| `appends?` | `string[]` | - | Default list of associated fields to append | +| `except?` | `string[]` | - | Default list of fields to exclude | +| `filter` | `FilterOptions` | - | Default filtering options | +| `sort` | `string[]` | - | Default sorting options | +| `page` | `number` | - | Default page number | +| `pageSize` | `number` | - | Default page size | +| `maxPageSize` | `number` | - | Default maximum page size | -## 实例成员 +## Instance Members ### `actionName` -被实例化后对应的操作名称。在实例化时从请求中解析获取。 +Name of the action that corresponds to when it is instantiated. It is parsed and fetched from the request at instantiation. ### `resourceName` -被实例化后对应的资源名称。在实例化时从请求中解析获取。 +Name of the resource that corresponds to when the action is instantiated. It is parsed and fetched from the request at instantiation. ### `resourceOf` -被实例化后对应的关系资源的主键值。在实例化时从请求中解析获取。 +Value of the primary key of the relational resource that corresponds to when the action is instantiated. It is parsed and fetched from the request at instantiation. ### `readonly middlewares` -针对操作的中间件列表。 +List of middlewares targeting the action. ### `params` -操作参数。包含对应操作的所有相关参数,实例化时根据定义的 action 参数初始化,之后请求中解析前端传入的参数并根据对应参数的合并策略合并。如果有其他中间件的处理,也会有类似的合并过程。直到 handler 处理时,访问 params 得到的是经过多次合并的最终参数。 +Action parameters. It contains all relevant parameters for the corresponding action, which are initialized at instantiation according to the defined action parameters. Later when parameters passed from the front-end are parsed in requests, the corresponding parameters are merged according to the merge strategy. Similar merging process is done if there is other middleware processing. When it comes to the hander, the `params` are the final parameters that have been merged for several times. -参数的合并过程提供了针对操作处理的可扩展性,可以通过自定义中间件的方式按业务需求进行参数的前置解析和处理,例如表单提交的参数验证就可以在此环节实现。 +The merging process of parameters provides scalability for action processing, and the parameters can be pre-parsed and processed according to business requirements by means of custom middleware. For example, parameter validation for form submission can be implemented in this part. -预设的参数可以参考 [/api/actions] 中不同操作的参数。 +Refer to [/api/actions] for the pre-defined parameters of different actions. -参数中还包含请求资源路由的描述部分,具体如下: +The parameters also contain a description of the request resource route: -| 参数名 | 类型 | 默认值 | 描述 | +| Name | Type | Default | Description | | --- | --- | --- | --- | -| `resourceName` | `string` | - | 资源名称 | -| `resourceIndex` | `string \| number` | - | 资源的主键值 | -| `associatedName` | `string` | - | 所属关系资源的名称 | -| `associatedIndex` | `string \| number` | - | 所属关系资源的主键值 | -| `associated` | `Object` | - | 所属关系资源的实例 | -| `actionName` | `string` | - | 操作名称 | +| `resourceName` | `string` | - | Name of the resource | +| `resourceIndex` | `string \| number` | - | Value of the primary key of the resource | +| `associatedName` | `string` | - | Name of the associated resource it belongs to | +| `associatedIndex` | `string \| number` | - | Value of the primary key of the associated resource it belongs to | +| `associated` | `Object` | - | Instance of the associated resource it belongs to | +| `actionName` | `string` | - | Name of the action | -**示例** +**Example** ```ts app.resourcer.define('books', { @@ -88,40 +88,40 @@ app.resourcer.define('books', { }); ``` -## 实例方法 +## Instance Methods ### `mergeParams()` -将额外的参数合并至当前参数集,且可以根据不同的策略进行合并。 +Merge additional parameters to the current set of parameters according to different strategies. -**签名** +**Signature** * `mergeParams(params: ActionParams, strategies: MergeStrategies = {})` -**参数** +**Parameter** -| 参数名 | 类型 | 默认值 | 描述 | +| Name | Type | Default | Description | | --- | --- | --- | --- | -| `params` | `ActionParams` | - | 额外的参数集 | -| `strategies` | `MergeStrategies` | - | 针对每个参数的合并策略 | +| `params` | `ActionParams` | - | Additional set of parameters | +| `strategies` | `MergeStrategies` | - | Merge strategies for each parameter | -内置操作的默认合并策略如下表: +The default merge strategy for built-in actions is as follows: -| 参数名 | 类型 | 默认值 | 合并策略 | 描述 | +| Name | Type | Default | Merge Strategy |Description | | --- | --- | --- | --- | --- | -| `filterByTk` | `number \| string` | - | SQL `and` | 查询主键值 | -| `filter` | `FilterOptions` | - | SQL `and` | 查询过滤参数 | -| `fields` | `string[]` | - | 取并集 | 字段组 | -| `appends` | `string[]` | `[]` | 取并集 | 附加的关联字段组 | -| `except` | `string[]` | `[]` | 取并集 | 排除的字段组 | -| `whitelist` | `string[]` | `[]` | 取交集 | 可处理字段的白名单 | -| `blacklist` | `string[]` | `[]` | 取并集 | 可处理字段的黑名单 | -| `sort` | `string[]` | - | SQL `order by` | 查询排序参数 | -| `page` | `number` | - | 覆盖 | 页码 | -| `pageSize` | `number` | - | 覆盖 | 每页数量 | -| `values` | `Object` | - | 深度合并 | 操作提交的数据 | +| `filterByTk` | `number \| string` | - | SQL `and` | Get value of the primary key | +| `filter` | `FilterOptions` | - | SQL `and` | Get filtering options | +| `fields` | `string[]` | - | Take the union | List of fields | +| `appends` | `string[]` | `[]` | Take the union | List of associated fields to append | +| `except` | `string[]` | `[]` | Take the union | List of associated fields to exclude | +| `whitelist` | `string[]` | `[]` | Take the intersection | Whitelist of fields that can be handled | +| `blacklist` | `string[]` | `[]` | Take the union | Blacklist of fields that can be handled | +| `sort` | `string[]` | - | SQL `order by` | Get the sorting options | +| `page` | `number` | - | Override | Page number | +| `pageSize` | `number` | - | Override | Page size | +| `values` | `Object` | - | Deep merge | Operation of the submitted data | -**示例** +**Example** ```ts ctx.action.mergeParams({ diff --git a/docs/en-US/api/resourcer/middleware.md b/docs/en-US/api/resourcer/middleware.md index 1f0a4d3a8..6401cfcf8 100644 --- a/docs/en-US/api/resourcer/middleware.md +++ b/docs/en-US/api/resourcer/middleware.md @@ -1,29 +1,29 @@ # Middleware -与 Koa 的中间件类似,但提供了更多增强的功能,可以方便的进行更多的扩展。 +It is similar to the middleware of Koa, but with more enhanced features for easy extensions. -中间件定义后可以在资源管理器等多处进行插入使用,由开发者自行控制调用的时机。 +The defined middleware can be inserted for use in multiple places, such as the resourcer, and it is up to the developer for when to invoke it. -## 构造函数 +## Constructor -**签名** +**Signature** * `constructor(options: Function)` * `constructor(options: MiddlewareOptions)` -**参数** +**Parameter** -| 参数名 | 类型 | 默认值 | 描述 | +| Name | Type | Default | Description | | --- | --- | --- | --- | -| `options` | `Function` | - | 中间件处理函数 | -| `options` | `MiddlewareOptions ` | - | 中间件配置项 | -| `options.only` | `string[]` | - | 仅允许指定的操作 | -| `options.except` | `string[]` | - | 排除指定的操作 | -| `options.handler` | `Function` | - | 处理函数 | +| `options` | `Function` | - | Handler function of middlware | +| `options` | `MiddlewareOptions ` | - | Configuration items of middlware | +| `options.only` | `string[]` | - | Only the specified actions are allowed | +| `options.except` | `string[]` | - | The specified actions are excluded | +| `options.handler` | `Function` | - | Handler function | -**示例** +**Example** -简单定义: +Simple definition: ```ts const middleware = new Middleware((ctx, next) => { @@ -31,7 +31,7 @@ const middleware = new Middleware((ctx, next) => { }); ``` -使用相关参数: +Definition with relevant parameters: ```ts const middleware = new Middleware({ @@ -42,15 +42,15 @@ const middleware = new Middleware({ }); ``` -## 实例方法 +## Instance Methods ### `getHandler()` -返回已经过编排的处理函数。 +Get the orchestrated handler functions. -**示例** +**Example** -以下中间件在请求时会先输出 `1`,再输出 `2`。 +The following middleware will output `1` and then `2` when requested. ```ts const middleware = new Middleware((ctx, next) => { @@ -68,35 +68,35 @@ app.resourcer.use(middleware.getHandler()); ### `use()` -对当前中间件添加中间件函数。用于提供中间件的扩展点。示例见 `getHandler()`。 +Add a middleware function to the current middleware. Used to provide extension points for the middleware. See `getHandler()` for the examples. -**签名** +**Signature** * `use(middleware: Function)` -**参数** +**Parameter** -| 参数名 | 类型 | 默认值 | 描述 | +| Name | Type | Default | Description | | --- | --- | --- | --- | -| `middleware` | `Function` | - | 中间件处理函数 | +| `middleware` | `Function` | - | Handler function of the middleware | ### `disuse()` -移除当前中间件已添加的中间件函数。 +Remove the middleware functions that have been added to the current middleware. -**签名** +**Signature** * `disuse(middleware: Function)` -**参数** +**Parameter** -| 参数名 | 类型 | 默认值 | 描述 | +| Name | Type | Default | Description | | --- | --- | --- | --- | -| `middleware` | `Function` | - | 中间件处理函数 | +| `middleware` | `Function` | - | Handler function of the middleware | -**示例** +**Example** -以下示例在请求处理是只输出 `1`,不执行 fn1 中的 `2` 输出。 +The following example will only output `1` when requested, the output of `2` in fn1 will not be executed. ```ts const middleware = new Middleware((ctx, next) => { @@ -118,41 +118,41 @@ middleware.disuse(fn1); ### `canAccess()` -判断当前中间件针对特定操作是否要被调用,通常由资源管理器内部处理。 +Check whether the current middleware is to be invoked for a specific action, it is usually handled by the resourcer internally. -**签名** +**Signature** * `canAccess(name: string): boolean` -**参数** +**Parameter** -| 参数名 | 类型 | 默认值 | 描述 | +| Name | Type | Default | Description | | --- | --- | --- | --- | -| `name` | `string` | - | 操作名称 | +| `name` | `string` | - | Name of the action | -## 其他导出 +## Other Exports ### `branch()` -创建一个分支中间件,用于在中间件中进行分支处理。 +Create a branch middleware for branching in the middleware. -**签名** +**Signature** * `branch(map: { [key: string]: Function }, reducer: Function, options): Function` -**参数** +**Parameter** -| 参数名 | 类型 | 默认值 | 描述 | +| Name | Type | Default | Description | | --- | --- | --- | --- | -| `map` | `{ [key: string]: Function }` | - | 分支处理函数映射表,键名由后续计算函数在调用时给出 | -| `reducer` | `(ctx) => string` | - | 计算函数,用于基于上下文计算出分支的键名 | -| `options?` | `Object` | - | 分支配置项 | -| `options.keyNotFound?` | `Function` | `ctx.throw(404)` | 未找到键名时的处理函数 | -| `options.handlerNotSet?` | `Function` | `ctx.throw(404)` | 未定义处理函数时的处理 | +| `map` | `{ [key: string]: Function }` | - | Mapping table of the branch handler function, key names are given by subsequent calculation functions when called | +| `reducer` | `(ctx) => string` | - | Calculation function, it is used to calculate the key name of the branch based on the context | +| `options?` | `Object` | - | Configuration items of the branch | +| `options.keyNotFound?` | `Function` | `ctx.throw(404)` | Handler function when key name is not found | +| `options.handlerNotSet?` | `Function` | `ctx.throw(404)` | The function when no handler function is defined | -**示例** +**Example** -用户验证时,根据请求 URL 中 query 部分的 `authenticator` 参数的值决定后续需要如何处理: +When authenticating user, determine what to do next according to the value of the `authenticator` parameter in the query section of the request URL. ```ts app.resourcer.use(branch({ diff --git a/docs/zh-CN/api/resourcer/middleware.md b/docs/zh-CN/api/resourcer/middleware.md index 1f0a4d3a8..1285acf81 100644 --- a/docs/zh-CN/api/resourcer/middleware.md +++ b/docs/zh-CN/api/resourcer/middleware.md @@ -96,7 +96,7 @@ app.resourcer.use(middleware.getHandler()); **示例** -以下示例在请求处理是只输出 `1`,不执行 fn1 中的 `2` 输出。 +以下示例在请求处理时只输出 `1`,不执行 fn1 中的 `2` 输出。 ```ts const middleware = new Middleware((ctx, next) => { diff --git a/package.json b/package.json index a3565e466..8f2b816c3 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "licenses": [ { "type": "Apache-2.0", - "url": "http://www.apache.org/licenses/LICENSE-2.0" + "url": "https://www.apache.org/licenses/LICENSE-2.0" } ], "scripts": { diff --git a/packages/app/server/src/config/database.ts b/packages/app/server/src/config/database.ts index d44d8513a..b174ef4b9 100644 --- a/packages/app/server/src/config/database.ts +++ b/packages/app/server/src/config/database.ts @@ -12,6 +12,7 @@ export default { timezone: process.env.DB_TIMEZONE, tablePrefix: process.env.DB_TABLE_PREFIX, schema: process.env.DB_SCHEMA, + underscored: process.env.DB_UNDERSCORED === 'true', } as IDatabaseOptions; function customLogger(queryString, queryObject) { diff --git a/packages/core/cli/bin/index.js b/packages/core/cli/bin/index.js index 06bba306e..3b3ac86a8 100755 --- a/packages/core/cli/bin/index.js +++ b/packages/core/cli/bin/index.js @@ -35,6 +35,21 @@ for (const key in env) { } } +if (require('semver').satisfies(process.version, '>16') && !process.env.UNSET_NODE_OPTIONS) { + if (process.env.NODE_OPTIONS) { + let opts = process.env.NODE_OPTIONS; + if (!opts.includes('--openssl-legacy-provider')) { + opts = opts + ' --openssl-legacy-provider'; + } + if (!opts.includes('--no-experimental-fetch')) { + opts = opts + ' --no-experimental-fetch'; + } + process.env.NODE_OPTIONS = opts; + } else { + process.env.NODE_OPTIONS = '--openssl-legacy-provider --no-experimental-fetch'; + } +} + const cli = require('../src/cli'); cli.parse(process.argv); diff --git a/packages/core/client/src/block-provider/BlockProvider.tsx b/packages/core/client/src/block-provider/BlockProvider.tsx index badbfc81c..d64407f17 100644 --- a/packages/core/client/src/block-provider/BlockProvider.tsx +++ b/packages/core/client/src/block-provider/BlockProvider.tsx @@ -92,7 +92,7 @@ export const useResourceAction = (props, opts = {}) => { */ const { resource, action, fieldName: tableFieldName } = props; const { fields } = useCollection(); - const appends = fields?.filter((field) => field.target && field.interface !== 'snapshot').map((field) => field.name); + const appends = fields?.filter((field) => field.target).map((field) => field.name); const params = useActionParams(props); const api = useAPIClient(); const fieldSchema = useFieldSchema(); diff --git a/packages/core/client/src/block-provider/KanbanBlockProvider.tsx b/packages/core/client/src/block-provider/KanbanBlockProvider.tsx index 74d738f36..3c274aa3c 100644 --- a/packages/core/client/src/block-provider/KanbanBlockProvider.tsx +++ b/packages/core/client/src/block-provider/KanbanBlockProvider.tsx @@ -75,7 +75,7 @@ const useAssociationNames = (collection) => { const collectionFields = getCollectionFields(collection); const associationFields = new Set(); for (const collectionField of collectionFields) { - if (collectionField.target && collectionField.interface !== 'snapshot') { + if (collectionField.target) { associationFields.add(collectionField.name); const fields = getCollectionFields(collectionField.target); for (const field of fields) { diff --git a/packages/core/client/src/block-provider/TableBlockProvider.tsx b/packages/core/client/src/block-provider/TableBlockProvider.tsx index 002896834..c7cfb335c 100644 --- a/packages/core/client/src/block-provider/TableBlockProvider.tsx +++ b/packages/core/client/src/block-provider/TableBlockProvider.tsx @@ -6,7 +6,6 @@ import { useCollectionManager } from '../collection-manager'; import { BlockProvider, RenderChildrenWithAssociationFilter, useBlockRequestContext } from './BlockProvider'; import { useFixedSchema } from '../schema-component'; - export const TableBlockContext = createContext({}); const InternalTableBlockProvider = (props) => { @@ -39,7 +38,7 @@ export const useAssociationNames = (collection) => { const collectionFields = getCollectionFields(collection); const associationFields = new Set(); for (const collectionField of collectionFields) { - if (collectionField.target && collectionField.interface !== 'snapshot') { + if (collectionField.target) { associationFields.add(collectionField.name); const fields = getCollectionFields(collectionField.target); for (const field of fields) { diff --git a/packages/core/client/src/block-provider/TableSelectorProvider.tsx b/packages/core/client/src/block-provider/TableSelectorProvider.tsx index 159b91bbe..1dd28e5f7 100644 --- a/packages/core/client/src/block-provider/TableSelectorProvider.tsx +++ b/packages/core/client/src/block-provider/TableSelectorProvider.tsx @@ -37,7 +37,7 @@ const InternalTableSelectorProvider = (props) => { const useAssociationNames2 = (collection) => { const { getCollectionFields } = useCollectionManager(); const names = getCollectionFields(collection) - ?.filter((field) => field.target && field.interface !== 'snapshot') + ?.filter((field) => field.target) .map((field) => field.name); return names; }; @@ -55,7 +55,7 @@ const useAssociationNames = (collection) => { const collectionFields = getCollectionFields(collection); const associationFields = new Set(); for (const collectionField of collectionFields) { - if (collectionField.target && collectionField.interface !== 'snapshot') { + if (collectionField.target) { associationFields.add(collectionField.name); const fields = getCollectionFields(collectionField.target); for (const field of fields) { diff --git a/packages/core/client/src/collection-manager/CollectionManagerProvider.tsx b/packages/core/client/src/collection-manager/CollectionManagerProvider.tsx index a5c3987ca..c4f65117b 100644 --- a/packages/core/client/src/collection-manager/CollectionManagerProvider.tsx +++ b/packages/core/client/src/collection-manager/CollectionManagerProvider.tsx @@ -1,13 +1,13 @@ import { Spin } from 'antd'; -import React, { useContext, useState } from 'react'; import { keyBy } from 'lodash'; +import React, { useContext, useState } from 'react'; import { useAPIClient, useRequest } from '../api-client'; -import { CollectionManagerSchemaComponentProvider } from './CollectionManagerSchemaComponentProvider'; -import { CollectionManagerContext } from './context'; -import { CollectionManagerOptions } from './types'; import { templateOptions } from '../collection-manager/Configuration/templates'; -import * as defaultInterfaces from './interfaces'; import { useCollectionHistory } from './CollectionHistoryProvider'; +import { CollectionManagerSchemaComponentProvider } from './CollectionManagerSchemaComponentProvider'; +import { CollectionCategroriesContext, CollectionManagerContext } from './context'; +import * as defaultInterfaces from './interfaces'; +import { CollectionManagerOptions } from './types'; export const CollectionManagerProvider: React.FC = (props) => { const { service, interfaces, collections = [], refreshCM, templates } = props; @@ -38,7 +38,7 @@ export const RemoteCollectionManagerProvider = (props: any) => { action: 'list', params: { paginate: false, - appends: ['fields', 'fields.uiSchema'], + appends: ['fields', 'fields.uiSchema','category'], filter: { // inherit: false, }, @@ -72,3 +72,33 @@ export const RemoteCollectionManagerProvider = (props: any) => { /> ); }; + +export const CollectionCategroriesProvider = (props) => { + const api = useAPIClient(); + const options={ + url: 'collectionCategories:list', + params: { + paginate: false, + sort:['sort'] + }, + } + const result = useRequest(options); + if (result.loading) { + return ; + } + return ( + { + const { data } = await api.request(options); + result.mutate(data); + return data?.data || []; + } + }} + > + {props.children} + + ); +}; diff --git a/packages/core/client/src/collection-manager/CollectionManagerShortcut.tsx b/packages/core/client/src/collection-manager/CollectionManagerShortcut.tsx index 9817097e3..a5e3eafc0 100644 --- a/packages/core/client/src/collection-manager/CollectionManagerShortcut.tsx +++ b/packages/core/client/src/collection-manager/CollectionManagerShortcut.tsx @@ -19,10 +19,17 @@ import { ViewFieldAction, AddCollection, AddCollectionAction, + AddCategoryAction, + AddCategory, EditCollection, EditCollectionAction, + ConfigurationTabs, + EditCategory, + EditCategoryAction, } from './Configuration'; +import { CollectionCategroriesProvider } from './CollectionManagerProvider'; + const schema: ISchema = { type: 'object', properties: { @@ -43,6 +50,7 @@ const schema2: ISchema = { type: 'object', properties: { [uid()]: { + 'x-decorator': 'CollectionCategroriesProvider', 'x-component': 'ConfigurationTable', }, }, @@ -50,15 +58,19 @@ const schema2: ISchema = { export const CollectionManagerPane = () => { return ( - + // { OverridingFieldAction, ViewCollectionField, ViewFieldAction, + EditCategory, + EditCategoryAction, }} /> - + // ); }; @@ -103,12 +117,15 @@ export const CollectionManagerShortcut2 = () => { schema={schema} components={{ ConfigurationTable, + ConfigurationTabs, AddFieldAction, EditFieldAction, OverridingFieldAction, ViewFieldAction, AddCollectionAction, EditCollectionAction, + AddCategoryAction, + EditCategoryAction, }} /> diff --git a/packages/core/client/src/collection-manager/CollectionProvider.tsx b/packages/core/client/src/collection-manager/CollectionProvider.tsx index 2150ce225..63aa61ea2 100644 --- a/packages/core/client/src/collection-manager/CollectionProvider.tsx +++ b/packages/core/client/src/collection-manager/CollectionProvider.tsx @@ -3,7 +3,9 @@ import { CollectionContext } from './context'; import { useCollectionManager } from './hooks'; import { CollectionOptions } from './types'; -export const CollectionProvider: React.FC<{ allowNull?: boolean; name?: string; collection?: CollectionOptions }> = (props) => { +export const CollectionProvider: React.FC<{ allowNull?: boolean; name?: string; collection?: CollectionOptions }> = ( + props, +) => { const { allowNull, name, collection, children } = props; const { getCollection } = useCollectionManager(); const value = getCollection(collection || name); diff --git a/packages/core/client/src/collection-manager/Configuration/AddCategoryAction.tsx b/packages/core/client/src/collection-manager/Configuration/AddCategoryAction.tsx new file mode 100644 index 000000000..c5bc64195 --- /dev/null +++ b/packages/core/client/src/collection-manager/Configuration/AddCategoryAction.tsx @@ -0,0 +1,60 @@ +import { PlusOutlined } from '@ant-design/icons'; +import { useForm } from '@formily/react'; +import { cloneDeep } from 'lodash'; +import React, { useContext, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useAPIClient } from '../../api-client'; +import { ActionContext, SchemaComponent, useActionContext } from '../../schema-component'; +import { useCancelAction } from '../action-hooks'; +import { CollectionCategroriesContext } from '../context'; +import * as components from './components'; +import { collectionCategorySchema } from './schemas/collections'; + +const useCreateCategry = () => { + const form = useForm(); + const ctx = useActionContext(); + const { refresh } = useContext(CollectionCategroriesContext); + const api = useAPIClient(); + return { + async run() { + await form.submit(); + const values = cloneDeep(form.values); + await api.resource('collectionCategories').create({ + values: { + ...values, + }, + }); + ctx.setVisible(false); + await form.reset(); + await refresh(); + }, + }; +}; + +export const AddCategory = (props) => { + return ; +}; + +export const AddCategoryAction = (props) => { + const { scope, getContainer, children } = props; + const [visible, setVisible] = useState(false); + const { t } = useTranslation(); + return ( + +
setVisible(true)} title={t('Add category')}> + {children || } +
+ +
+ ); +}; diff --git a/packages/core/client/src/collection-manager/Configuration/AddCollectionAction.tsx b/packages/core/client/src/collection-manager/Configuration/AddCollectionAction.tsx index 358f3d818..9bf610f1c 100644 --- a/packages/core/client/src/collection-manager/Configuration/AddCollectionAction.tsx +++ b/packages/core/client/src/collection-manager/Configuration/AddCollectionAction.tsx @@ -15,7 +15,7 @@ import { useResourceActionContext, useResourceContext } from '../ResourceActionP import * as components from './components'; import { templateOptions } from './templates'; -const getSchema = (schema, record: any, compile): ISchema => { +const getSchema = (schema, category, compile): ISchema => { if (!schema) { return; } @@ -30,6 +30,7 @@ const getSchema = (schema, record: any, compile): ISchema => { const initialValue: any = { name: `t_${uid()}`, template: schema.name, + category, ...cloneDeep(schema.default), }; if (initialValue.reverseField) { @@ -234,6 +235,9 @@ export const AddCollectionAction = (props) => { const items = templateOptions().map((option) => { return { label: compile(option.title), key: option.name }; }); + const { + state: { category }, + } = useResourceActionContext(); return ( @@ -248,7 +252,7 @@ export const AddCollectionAction = (props) => { overflow: 'auto', }} onClick={(info) => { - const schema = getSchema(getTemplate(info.key), record, compile); + const schema = getSchema(getTemplate(info.key), category, compile); setSchema(schema); setVisible(true); }} diff --git a/packages/core/client/src/collection-manager/Configuration/ConfigurationTable.tsx b/packages/core/client/src/collection-manager/Configuration/ConfigurationTable.tsx index 54c94c74c..ec7981ab2 100644 --- a/packages/core/client/src/collection-manager/Configuration/ConfigurationTable.tsx +++ b/packages/core/client/src/collection-manager/Configuration/ConfigurationTable.tsx @@ -7,6 +7,7 @@ import { useCurrentAppInfo } from '../../appInfo'; import { useRecord } from '../../record-provider'; import { SchemaComponent, SchemaComponentContext, useCompile } from '../../schema-component'; import { useCancelAction } from '../action-hooks'; +import { CollectionCategroriesContext } from '../context'; import { useCollectionManager } from '../hooks/useCollectionManager'; import { DataSourceContext } from '../sub-table'; import { AddSubFieldAction } from './AddSubFieldAction'; @@ -76,6 +77,7 @@ export const ConfigurationTable = () => { const { data: { database }, } = useCurrentAppInfo(); + const data = useContext(CollectionCategroriesContext); const collectonsRef: any = useRef(); collectonsRef.current = collections; const compile = useCompile(); @@ -93,9 +95,14 @@ export const ConfigurationTable = () => { value: item.name, })); }; + const loadCategories = async () => { + return data.data.map((item: any) => ({ + label: compile(item.name), + value: item.id, + })); + }; const ctx = useContext(SchemaComponentContext); return ( -
{ useSelectedRowKeys, useAsyncDataSource, loadCollections, + loadCategories, useCurrentFields, useNewId, useCancelAction, @@ -119,6 +127,5 @@ export const ConfigurationTable = () => { }} /> -
); }; diff --git a/packages/core/client/src/collection-manager/Configuration/ConfigurationTabs.tsx b/packages/core/client/src/collection-manager/Configuration/ConfigurationTabs.tsx new file mode 100644 index 000000000..513cfc47d --- /dev/null +++ b/packages/core/client/src/collection-manager/Configuration/ConfigurationTabs.tsx @@ -0,0 +1,255 @@ +import { MenuOutlined } from '@ant-design/icons'; +import { + DndContext, + DragEndEvent, + DragOverlay, + MouseSensor, + useDraggable, + useDroppable, + useSensor, + useSensors +} from '@dnd-kit/core'; +import { observer, RecursionField } from '@formily/react'; +import { uid } from '@formily/shared'; +import { Badge, Card, Dropdown, Menu, Modal, Tabs } from 'antd'; +import React, { useContext, useState } from 'react'; +import { useAPIClient } from '../../api-client'; +import { SchemaComponent, SchemaComponentOptions, useCompile } from '../../schema-component'; +import { CollectionCategroriesContext } from '../context'; +import { useResourceActionContext } from '../ResourceActionProvider'; +import { collectionTableSchema } from './schemas/collections'; + +function Draggable(props) { + const { attributes, listeners, setNodeRef } = useDraggable({ + id: props.id, + data: props.data, + }); + return ( +
+
{props.children}
+
+ ); +} + +function Droppable(props) { + const { isOver, setNodeRef } = useDroppable({ + id: props.id, + data: props.data, + }); + const style = isOver + ? { + color: 'green', + } + : undefined; + + return ( +
+ {props.children} +
+ ); +} + +const TabTitle = observer(({ item }: { item: any }) => { + return ( + +
+ + + +
+
+ ); +}); + +const TabBar = ({ item }) => { + const compile = useCompile(); + return ( + + + {compile(item.name)} + + ); +}; +const DndProvider = observer((props) => { + const [activeTab, setActiveId] = useState(null); + const { refresh } = useContext(CollectionCategroriesContext); + const { refresh: refreshCM } = useResourceActionContext(); + const api = useAPIClient(); + const onDragEnd = async (props: DragEndEvent) => { + const { active, over } = props; + setTimeout(() => { + setActiveId(null); + }); + if (over && over.id !== active.id) { + await api.resource('collectionCategories').move({ + sourceId: active.id, + targetId: over.id, + }); + await refresh(); + await refreshCM(); + } + }; + + function onDragStart(event) { + setActiveId(event.active?.data.current); + } + + const mouseSensor = useSensor(MouseSensor, { + activationConstraint: { + distance: 10, + }, + }); + const sensors = useSensors(mouseSensor); + return ( + + {props.children} + + {activeTab ? {} : null} + + + ); +}); +export const ConfigurationTabs = () => { + const { data, refresh } = useContext(CollectionCategroriesContext); + const { refresh: refreshCM, run, defaultRequest, setState } = useResourceActionContext(); + const [key, setKey] = useState('all'); + const tabsItems = data + .sort((a, b) => b.sort - a.sort) + .concat() + .map((v) => { + return { + ...v, + schema: collectionTableSchema, + }; + }); + !tabsItems.find((v) => v.id === 'all') && + tabsItems.unshift({ + name: '{{t("All collections")}}', + id: 'all', + sort: 0, + closable: false, + schema: collectionTableSchema, + }); + const compile = useCompile(); + const [activeKey, setActiveKey] = useState('all'); + const api = useAPIClient(); + const onChange = (key: string) => { + setActiveKey(key); + setKey(uid()); + if (key !== 'all') { + const prevFilter = defaultRequest?.params?.filter; + const filter = { $and: [prevFilter, { 'category.id': key }] }; + run({ filter }); + setState?.({ category: [+key], params: [{ filter }] }); + } else { + run(); + setState?.({ category: [], params: [] }); + } + }; + + const remove = (key: any) => { + Modal.confirm({ + title: compile("{{t('Delete category')}}"), + content: compile("{{t('Are you sure you want to delete it?')}}"), + onOk: async () => { + await api.resource('collectionCategories').destroy({ + filter: { + id: key, + }, + }); + key === +activeKey && setActiveKey('all'); + await refresh(); + await refreshCM(); + }, + }); + }; + + const loadCategories = async () => { + return data.map((item: any) => ({ + label: compile(item.name), + value: item.id, + })); + }; + const menu = (item) => ( + + + + + remove(item.id)}> + {compile("{{t('Delete category')}}")} + + + ); + return ( + + + } + onChange={onChange} + defaultActiveKey="all" + type="editable-card" + destroyInactiveTabPane={true} + tabBarStyle={{ marginBottom: '0px' }} + > + {tabsItems.map((item) => { + return ( + + + + ) : ( + compile(item.name) + ) + } + key={item.id} + closable={item.closable} + closeIcon={ + + + + } + > + + + + + + + ); + })} + + + ); +}; diff --git a/packages/core/client/src/collection-manager/Configuration/EditCategoryAction.tsx b/packages/core/client/src/collection-manager/Configuration/EditCategoryAction.tsx new file mode 100644 index 000000000..39ab96f04 --- /dev/null +++ b/packages/core/client/src/collection-manager/Configuration/EditCategoryAction.tsx @@ -0,0 +1,81 @@ +import { useForm } from '@formily/react'; +import { cloneDeep } from 'lodash'; +import React, { useContext, useEffect, useState } from 'react'; +import { useAPIClient, useRequest } from '../../api-client'; +import { RecordProvider, useRecord } from '../../record-provider'; +import { ActionContext, SchemaComponent, useActionContext, useCompile } from '../../schema-component'; +import { useCancelAction } from '../action-hooks'; +import { CollectionCategroriesContext } from '../context'; +import { useResourceActionContext } from '../ResourceActionProvider'; +import * as components from './components'; +import { collectionCategoryEditSchema } from './schemas/collections'; + +const useEditCategry = () => { + const form = useForm(); + const ctx = useActionContext(); + const { refresh } = useContext(CollectionCategroriesContext); + const { refresh: refreshCM } = useResourceActionContext(); + + const api = useAPIClient(); + const { id } = useRecord(); + return { + async run() { + await form.submit(); + const values = cloneDeep(form.values); + await api.resource('collectionCategories').update({ + filter: { id: id }, + values: { + ...values, + }, + }); + ctx.setVisible(false); + await form.reset(); + await refresh(); + await refreshCM(); + }, + }; +}; + +const useValuesFromRecord = (options) => { + const record = useRecord(); + const result = useRequest(() => Promise.resolve({ data: { ...record } }), { + ...options, + manual: true, + }); + const ctx = useActionContext(); + useEffect(() => { + if (ctx.visible) { + result.run(); + } + }, [ctx.visible]); + return result; +}; + +export const EditCategory = (props) => { + return ; +}; + +export const EditCategoryAction = (props) => { + const { scope, getContainer, item, children } = props; + const [visible, setVisible] = useState(false); + const compile = useCompile(); + return ( + + + <>{children || setVisible(true)}>{compile('{{ t("Edit category") }}')}} + + + + ); +}; diff --git a/packages/core/client/src/collection-manager/Configuration/EditCollectionAction.tsx b/packages/core/client/src/collection-manager/Configuration/EditCollectionAction.tsx index 3799e1dee..981eae8e7 100644 --- a/packages/core/client/src/collection-manager/Configuration/EditCollectionAction.tsx +++ b/packages/core/client/src/collection-manager/Configuration/EditCollectionAction.tsx @@ -78,7 +78,7 @@ const getSchema = (schema: IField, record: any, compile, getContainer): ISchema export const useValuesFromRecord = (options) => { const record = useRecord(); - const result = useRequest(() => Promise.resolve({ data: { autoGenId: true, ...record } }), { + const result = useRequest(() => Promise.resolve({ data: { autoGenId: true, ...record,category:record?.category.map((v)=>v.id) } }), { ...options, manual: true, }); diff --git a/packages/core/client/src/collection-manager/Configuration/components/CollectionCategory.tsx b/packages/core/client/src/collection-manager/Configuration/components/CollectionCategory.tsx new file mode 100644 index 000000000..e06c0c658 --- /dev/null +++ b/packages/core/client/src/collection-manager/Configuration/components/CollectionCategory.tsx @@ -0,0 +1,16 @@ +import { observer } from '@formily/react'; +import { Tag } from 'antd'; +import React from 'react'; +import { useCompile } from '../../../schema-component'; + +export const CollectionCategory = observer((props: any) => { + const { value } = props; + const compile = useCompile(); + return ( + <> + {value.map((item) => { + return {compile(item?.name)}; + })} + + ); +}); diff --git a/packages/core/client/src/collection-manager/Configuration/index.tsx b/packages/core/client/src/collection-manager/Configuration/index.tsx index 9eae73586..c69d4c0ea 100644 --- a/packages/core/client/src/collection-manager/Configuration/index.tsx +++ b/packages/core/client/src/collection-manager/Configuration/index.tsx @@ -11,6 +11,9 @@ export * from './OverridingCollectionField'; export * from './ViewInheritedField'; export * from './AddCollectionAction'; export * from './EditCollectionAction'; +export * from './ConfigurationTabs'; +export * from './AddCategoryAction'; +export * from './EditCategoryAction' registerValidateFormats({ uid: /^[A-Za-z0-9][A-Za-z0-9_-]*$/, 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 34dd448a3..4cdb3e03e 100644 --- a/packages/core/client/src/collection-manager/Configuration/schemas/collections.ts +++ b/packages/core/client/src/collection-manager/Configuration/schemas/collections.ts @@ -1,10 +1,12 @@ import { ISchema, Schema } from '@formily/react'; import { message } from 'antd'; +import { uid } from '@formily/shared'; import { useTranslation } from 'react-i18next'; import { useAPIClient } from '../../../api-client'; import { i18n } from '../../../i18n'; import { CollectionOptions } from '../../types'; import { CollectionTemplate } from '../components/CollectionTemplate'; +import { CollectionCategory } from '../components/CollectionCategory'; import { collectionFieldSchema } from './collectionFields'; const compile = (source) => { @@ -94,175 +96,197 @@ export const collectionSchema: ISchema = { filter: { 'hidden.$isFalsy': true, }, - appends: [], + appends: ['category'], }, }, }, properties: { - actions: { + tabs: { type: 'void', - 'x-component': 'ActionBar', - 'x-component-props': { - style: { - marginBottom: 16, - }, + 'x-component': 'ConfigurationTabs', + }, + }, + }, + }, +}; + +export const collectionTableSchema: ISchema = { + type: 'object', + properties: { + [uid()]: { + type: 'void', + 'x-component': 'ActionBar', + 'x-component-props': { + style: { + marginBottom: 16, + }, + }, + properties: { + filter: { + type: 'void', + title: '{{ t("Filter") }}', + default: { + $and: [{ title: { $includes: '' } }, { name: { $includes: '' } }], }, - 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': { - icon: 'DeleteOutlined', - 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': 'AddCollection', - 'x-component-props': { - type: 'primary', - }, + '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': { + icon: 'DeleteOutlined', + useAction: '{{ cm.useBulkDestroyActionAndRefreshCM }}', + confirm: { + title: "{{t('Delete record')}}", + content: "{{t('Are you sure you want to delete it?')}}", }, }, }, - table: { + create: { type: 'void', - 'x-uid': 'input', - 'x-component': 'Table.Void', + title: '{{ t("Create collection") }}', + 'x-component': 'AddCollection', 'x-component-props': { - rowKey: 'name', - rowSelection: { - type: 'checkbox', + type: 'primary', + }, + }, + }, + }, + [uid()]: { + type: 'void', + 'x-uid': 'input', + 'x-component': 'Table.Void', + 'x-component-props': { + rowKey: 'name', + rowSelection: { + type: 'checkbox', + }, + useDataSource: '{{ cm.useDataSourceFromRAC }}', + useAction() { + const api = useAPIClient(); + const { t } = useTranslation(); + return { + async move(from, to) { + await api.resource('collections').move({ + sourceId: from.key, + targetId: to.key, + }); + message.success(t('Saved successfully'), 0.2); }, - useDataSource: '{{ cm.useDataSourceFromRAC }}', - useAction() { - const api = useAPIClient(); - const { t } = useTranslation(); - return { - async move(from, to) { - console.log(from, to); - await api.resource('collections').move({ - sourceId: from.key, - targetId: to.key, - }); - message.success(t('Saved successfully'), 0.2); - }, - }; + }; + }, + }, + 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: { - column1: { - type: 'void', - 'x-decorator': 'Table.Column.Decorator', - 'x-component': 'Table.Column', - properties: { - title: { - 'x-component': 'CollectionField', - 'x-read-pretty': true, - }, - }, + name: { + type: 'string', + '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', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + title: '{{t("Collection template")}}', + properties: { + template: { + 'x-component': CollectionTemplate, + 'x-read-pretty': true, }, - column3: { - type: 'void', - 'x-decorator': 'Table.Column.Decorator', - 'x-component': 'Table.Column', - title: '{{t("Collection template")}}', - properties: { - template: { - 'x-component': CollectionTemplate, - 'x-read-pretty': true, - }, - }, + }, + }, + column4: { + type: 'void', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + 'x-visible': 'categoryVisible', + title: '{{t("Collection category")}}', + properties: { + category: { + 'x-component': CollectionCategory, + 'x-read-pretty': true, }, - column4: { + }, + }, + column5: { + type: 'void', + title: '{{ t("Actions") }}', + 'x-component': 'Table.Column', + properties: { + actions: { type: 'void', - title: '{{ t("Actions") }}', - 'x-component': 'Table.Column', + 'x-component': 'Space', + 'x-component-props': { + split: '|', + }, properties: { - actions: { + view: { type: 'void', - 'x-component': 'Space', - 'x-component-props': { - split: '|', - }, + title: '{{ t("Configure fields") }}', + 'x-component': 'Action.Link', + 'x-component-props': {}, properties: { - view: { + drawer: { type: 'void', - title: '{{ t("Configure fields") }}', - 'x-component': 'Action.Link', - 'x-component-props': {}, + '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: { - 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, - }, - }, + collectionFieldSchema, }, }, - update: { - type: 'void', - title: '{{ t("Edit") }}', - 'x-component': 'EditCollection', - 'x-component-props': { - type: 'primary', - }, - }, - 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 }}', - }, + }, + }, + update: { + type: 'void', + title: '{{ t("Edit") }}', + 'x-component': 'EditCollection', + 'x-component-props': { + type: 'primary', + }, + }, + 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 }}', }, }, }, @@ -273,3 +297,114 @@ export const collectionSchema: ISchema = { }, }, }; + +export const collectionCategorySchema: ISchema = { + type: 'object', + properties: { + form: { + type: 'void', + 'x-decorator': 'Form', + 'x-component': 'Action.Modal', + title: '{{ t("Add category") }}', + 'x-component-props': { + width: 520, + getContainer: '{{ getContainer }}', + }, + properties: { + name: { + type: 'string', + title: '{{t("Category name")}}', + required: true, + 'x-disabled': '{{ !createOnly }}', + 'x-decorator': 'FormItem', + 'x-component': 'Input', + }, + color: { + type: 'string', + title: '{{t("Color")}}', + required: false, + 'x-decorator': 'FormItem', + 'x-component': 'ColorSelect', + }, + footer: { + type: 'void', + 'x-component': 'Action.Modal.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: '{{ useCreateCategry }}', + }, + }, + }, + }, + }, + }, + }, +}; + +export const collectionCategoryEditSchema: ISchema = { + type: 'object', + properties: { + form: { + type: 'void', + 'x-decorator': 'Form', + 'x-decorator-props': { + useValues: '{{ useValuesFromRecord }}', + }, + 'x-component': 'Action.Modal', + title: '{{ t("Edit category") }}', + 'x-component-props': { + width: 520, + getContainer: '{{ getContainer }}', + }, + properties: { + name: { + type: 'string', + title: '{{t("Category name")}}', + required: true, + 'x-disabled': '{{ !createOnly }}', + 'x-decorator': 'FormItem', + 'x-component': 'Input', + }, + color: { + type: 'string', + title: '{{t("Color")}}', + required: false, + 'x-decorator': 'FormItem', + 'x-component': 'ColorSelect', + }, + footer: { + type: 'void', + 'x-component': 'Action.Modal.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: '{{ useEditCategry }}', + }, + }, + }, + }, + }, + }, + }, +}; diff --git a/packages/core/client/src/collection-manager/action-hooks.ts b/packages/core/client/src/collection-manager/action-hooks.ts index 1cd259b52..5698a19f6 100644 --- a/packages/core/client/src/collection-manager/action-hooks.ts +++ b/packages/core/client/src/collection-manager/action-hooks.ts @@ -329,5 +329,9 @@ export const useFilterActionProps = () => { const { collection } = useResourceContext(); const options = useFilterFieldOptions(collection.fields); const service = useResourceActionContext(); - return useFilterFieldProps({ options, params: service.params, service }); + return useFilterFieldProps({ + options, + params: service.state?.params?.[0] || service.params, + service, + }); }; diff --git a/packages/core/client/src/collection-manager/context.ts b/packages/core/client/src/collection-manager/context.ts index 581428314..476ca34e5 100644 --- a/packages/core/client/src/collection-manager/context.ts +++ b/packages/core/client/src/collection-manager/context.ts @@ -9,3 +9,5 @@ export const CollectionManagerContext = createContext( export const CollectionContext = createContext({}); export const CollectionFieldContext = createContext({}); + +export const CollectionCategroriesContext = createContext({ data: [], refresh: () => {} }); diff --git a/packages/core/client/src/collection-manager/templates/calendar.tsx b/packages/core/client/src/collection-manager/templates/calendar.tsx index 92bf0977e..1ff3bfa7b 100644 --- a/packages/core/client/src/collection-manager/templates/calendar.tsx +++ b/packages/core/client/src/collection-manager/templates/calendar.tsx @@ -38,5 +38,5 @@ export const calendar: ICollectionTemplate = { availableFieldInterfaces: { include: [], }, - configurableProperties: getConfigurableProperties('title', 'name', 'inherits'), + configurableProperties: getConfigurableProperties('title', 'name', 'inherits','category'), }; diff --git a/packages/core/client/src/collection-manager/templates/general.tsx b/packages/core/client/src/collection-manager/templates/general.tsx index ed60bc7d8..347ebd5f7 100644 --- a/packages/core/client/src/collection-manager/templates/general.tsx +++ b/packages/core/client/src/collection-manager/templates/general.tsx @@ -9,5 +9,5 @@ export const general: ICollectionTemplate = { default: { fields: [], }, - configurableProperties: getConfigurableProperties('title', 'name', 'inherits', 'moreOptions'), + configurableProperties: getConfigurableProperties('title', 'name', 'inherits', 'category', 'moreOptions'), }; diff --git a/packages/core/client/src/collection-manager/templates/properties/index.ts b/packages/core/client/src/collection-manager/templates/properties/index.ts index 22af1eb5f..2b674e9cf 100644 --- a/packages/core/client/src/collection-manager/templates/properties/index.ts +++ b/packages/core/client/src/collection-manager/templates/properties/index.ts @@ -102,6 +102,17 @@ export const defaultConfigurableProperties = { 'x-visible': '{{ enableInherits}}', 'x-reactions': ['{{useAsyncDataSource(loadCollections)}}'], }, + category: { + title: '{{t("Categories")}}', + type: 'hasMany', + name: 'category', + 'x-decorator': 'FormItem', + 'x-component': 'Select', + 'x-component-props': { + mode: 'multiple', + }, + 'x-reactions': ['{{useAsyncDataSource(loadCategories)}}'], + }, ...moreOptions, moreOptions: { title: '{{t("More options")}}', @@ -124,6 +135,7 @@ export type DefaultConfigurableKeys = | 'name' | 'title' | 'inherits' + | 'category' | 'autoGenId' | 'createdBy' | 'updatedBy' diff --git a/packages/core/client/src/locale/en_US.ts b/packages/core/client/src/locale/en_US.ts index 0fe92836d..4b53ebab6 100644 --- a/packages/core/client/src/locale/en_US.ts +++ b/packages/core/client/src/locale/en_US.ts @@ -34,6 +34,14 @@ export default { "UI editor": "UI editor", "Collection": "Collection", "Collections & Fields": "Collections & Fields", + "All collections":"All collections", + "Add category":"Add category", + "Delete category":"Delete category", + "Edit category":"Edit category", + "Collection category":"Collection category", + "Sort":"Sort", + "Categories":"Categories", + "Category name":"Category name", "Roles & Permissions": "Roles & Permissions", "Edit profile": "Edit profile", "Change password": "Change password", diff --git a/packages/core/client/src/locale/ja_JP.ts b/packages/core/client/src/locale/ja_JP.ts index 2d82bab56..0256c325d 100644 --- a/packages/core/client/src/locale/ja_JP.ts +++ b/packages/core/client/src/locale/ja_JP.ts @@ -34,6 +34,14 @@ export default { "UI editor": "UI エディタ", "Collection": "コレクション", "Collections & Fields": "コレクションとフィールド", + "All collections":"すべてのデータテーブル", + "Add category":"分類の追加", + "Edit category":"分類の編集", + "Sort":"ソート#ソート#", + "Categories":"データテーブルカテゴリ", + "Category name":"分類名", + "Delete category":"分類の削除", + "Collection category":"Collection category", "Roles & Permissions": "役割と権限", "Edit profile": "プロフィール", "Change password": "パスワード変更", diff --git a/packages/core/client/src/locale/ru_RU.ts b/packages/core/client/src/locale/ru_RU.ts index ef9d66c60..d1e342d18 100644 --- a/packages/core/client/src/locale/ru_RU.ts +++ b/packages/core/client/src/locale/ru_RU.ts @@ -107,6 +107,7 @@ export default { "Create collection": "Создать Коллекцию", "Collection display name": "Отображение имени Коллекции", "Collection name": "Имя Коллекции", + "Categories":"Категории таблиц данных", "Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.": "Случайно сгенерированный и может быть изменен. Поддерживает буквы, цифры и подчеркивания, должно начинаться с буквы.", "Storage type": "Тип Хранилища", "Edit": "Изменить", diff --git a/packages/core/client/src/locale/zh_CN.ts b/packages/core/client/src/locale/zh_CN.ts index 134be6d7d..977215627 100644 --- a/packages/core/client/src/locale/zh_CN.ts +++ b/packages/core/client/src/locale/zh_CN.ts @@ -34,6 +34,14 @@ export default { "UI editor": "界面配置", "Collection": "数据表", "Collections & Fields": "数据表配置", + "All collections":"全部数据表", + "Add category":"添加分类", + "Delete category":"删除分类", + "Edit category":"编辑分类", + "Collection category":"数据表类别", + "Sort":"排序", + "Categories":"数据表类别", + "Category name":"分类名称", "Roles & Permissions": "角色和权限", "Edit profile": "个人资料", "Change password": "修改密码", diff --git a/packages/core/client/src/schema-component/antd/form-item/FormItem.tsx b/packages/core/client/src/schema-component/antd/form-item/FormItem.tsx index 8c412c06a..81d7a9385 100644 --- a/packages/core/client/src/schema-component/antd/form-item/FormItem.tsx +++ b/packages/core/client/src/schema-component/antd/form-item/FormItem.tsx @@ -67,7 +67,9 @@ FormItem.Designer = (props) => { const interfaceConfig = getInterface(collectionField?.interface); const validateSchema = interfaceConfig?.['validateSchema']?.(fieldSchema); const originalTitle = collectionField?.uiSchema?.title; - const targetFields = collectionField?.target ? getCollectionFields(collectionField.target) : []; + const targetFields = collectionField?.target + ? getCollectionFields(collectionField.target) + : getCollectionFields(collectionField?.targetCollection) ?? []; const fieldComponentOptions = useFieldComponentOptions(); const isSubFormAssocitionField = field.address.segments.includes('__form_grid'); const initialValue = { @@ -466,7 +468,7 @@ FormItem.Designer = (props) => { }} /> )} - {collectionField?.target && fieldSchema['x-component'] === 'CollectionField' && ( + {options.length > 0 && fieldSchema['x-component'] === 'CollectionField' && ( { return collectionField ? (
- + {renderRecords()} 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 b69c36f18..eead4655f 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 @@ -31,7 +31,7 @@ export const TableColumnDesigner = (props) => { const { dn } = useDesignable(); const fieldNames = fieldSchema?.['x-component-props']?.['fieldNames'] || uiSchema?.['x-component-props']?.['fieldNames']; - const options = useLabelFields(collectionField?.target); + const options = useLabelFields(collectionField?.target ?? collectionField?.targetCollection); const intefaceCfg = getInterface(collectionField?.interface); return ( diff --git a/packages/core/client/src/schema-component/antd/table/Table.Array.tsx b/packages/core/client/src/schema-component/antd/table/Table.Array.tsx index 0d22ea16d..37aeb56af 100644 --- a/packages/core/client/src/schema-component/antd/table/Table.Array.tsx +++ b/packages/core/client/src/schema-component/antd/table/Table.Array.tsx @@ -1,10 +1,17 @@ import { MenuOutlined } from '@ant-design/icons'; import { css } from '@emotion/css'; import { ArrayField, Field } from '@formily/core'; -import { observer, RecursionField, Schema, useField, useFieldSchema } from '@formily/react'; +import { + observer, + RecursionField, + Schema, + useField, + useFieldSchema, + SchemaExpressionScopeContext, +} from '@formily/react'; import { Table, TableColumnProps } from 'antd'; import { default as classNames, default as cls } from 'classnames'; -import React, { useState } from 'react'; +import React, { useState, useContext } from 'react'; import ReactDragListView from 'react-drag-listview'; import { DndContext } from '../..'; import { RecordIndexProvider, RecordProvider, useRequest, useSchemaInitializer } from '../../../'; @@ -13,6 +20,11 @@ const isColumnComponent = (schema: Schema) => { return schema['x-component']?.endsWith('.Column') > -1; }; +const useScope = (key: any) => { + const scope = useContext(SchemaExpressionScopeContext); + return scope[key] !== false; +}; + const useTableColumns = () => { const start = Date.now(); const field = useField(); @@ -20,9 +32,10 @@ const useTableColumns = () => { const { exists, render } = useSchemaInitializer(schema['x-initializer']); const columns = schema .reduceProperties((buf, s) => { - if (isColumnComponent(s)) { + if (isColumnComponent(s) && useScope(s['x-visible'])) { return buf.concat([s]); } + return buf }, []) .map((s: Schema) => { return { diff --git a/packages/core/database/src/__tests__/bigint.test.ts b/packages/core/database/src/__tests__/bigint.test.ts index 0e379e98d..130283e47 100644 --- a/packages/core/database/src/__tests__/bigint.test.ts +++ b/packages/core/database/src/__tests__/bigint.test.ts @@ -43,6 +43,6 @@ excludeSqlite()('collection', () => { const profileTableInfo = await db.sequelize.getQueryInterface().describeTable(profile.model.tableName); - expect(profileTableInfo['userId'].type).toBe('BIGINT'); + expect(profileTableInfo[profile.model.rawAttributes['userId'].field].type).toBe('BIGINT'); }); }); diff --git a/packages/core/database/src/__tests__/collection.test.ts b/packages/core/database/src/__tests__/collection.test.ts index 5dab3cdc4..80fe60836 100644 --- a/packages/core/database/src/__tests__/collection.test.ts +++ b/packages/core/database/src/__tests__/collection.test.ts @@ -8,9 +8,7 @@ describe('collection', () => { let db: Database; beforeEach(async () => { - db = mockDatabase({ - logging: console.log, - }); + db = mockDatabase(); await db.clean({ drop: true }); }); @@ -240,9 +238,15 @@ describe('collection sync', () => { await collection.sync(); const tableFields = await (collection.model).queryInterface.describeTable(`${db.getTablePrefix()}users`); - expect(tableFields).toHaveProperty('firstName'); - expect(tableFields).toHaveProperty('lastName'); - expect(tableFields).toHaveProperty('age'); + if (db.options.underscored) { + expect(tableFields).toHaveProperty('first_name'); + expect(tableFields).toHaveProperty('last_name'); + expect(tableFields).toHaveProperty('age'); + } else { + expect(tableFields).toHaveProperty('firstName'); + expect(tableFields).toHaveProperty('lastName'); + expect(tableFields).toHaveProperty('age'); + } }); test('sync with association not exists', async () => { @@ -290,9 +294,15 @@ describe('collection sync', () => { const model = collection.model; await collection.sync(); - const tableFields = await (model).queryInterface.describeTable(`${db.getTablePrefix()}postsTags`); - expect(tableFields['postId']).toBeDefined(); - expect(tableFields['tagId']).toBeDefined(); + if (db.options.underscored) { + const tableFields = await (model).queryInterface.describeTable(`${db.getTablePrefix()}posts_tags`); + expect(tableFields['post_id']).toBeDefined(); + expect(tableFields['tag_id']).toBeDefined(); + } else { + const tableFields = await (model).queryInterface.describeTable(`${db.getTablePrefix()}postsTags`); + expect(tableFields['postId']).toBeDefined(); + expect(tableFields['tagId']).toBeDefined(); + } }); test('limit table name length', async () => { diff --git a/packages/core/database/src/__tests__/inhertits/collection-inherits.test.ts b/packages/core/database/src/__tests__/inhertits/collection-inherits.test.ts index dcd93a8f4..ce52cf365 100644 --- a/packages/core/database/src/__tests__/inhertits/collection-inherits.test.ts +++ b/packages/core/database/src/__tests__/inhertits/collection-inherits.test.ts @@ -886,11 +886,13 @@ pgOnly()('collection inherits', () => { 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(); + const getField = (name) => student.model.rawAttributes[name].field; + + expect(studentTableInfo[getField('score')]).toBeDefined(); + expect(studentTableInfo[getField('name')]).toBeDefined(); + expect(studentTableInfo[getField('id')]).toBeDefined(); + expect(studentTableInfo[getField('createdAt')]).toBeDefined(); + expect(studentTableInfo[getField('updatedAt')]).toBeDefined(); }); it('should get parent fields', async () => { diff --git a/packages/core/database/src/__tests__/underscored-options.test.ts b/packages/core/database/src/__tests__/underscored-options.test.ts new file mode 100644 index 000000000..4efa43708 --- /dev/null +++ b/packages/core/database/src/__tests__/underscored-options.test.ts @@ -0,0 +1,207 @@ +import { Database, mockDatabase } from '@nocobase/database'; + +describe('underscored options', () => { + let db: Database; + + beforeEach(async () => { + db = mockDatabase({ + underscored: true, + }); + + await db.clean({ drop: true }); + }); + + afterEach(async () => { + await db.close(); + }); + + it('should set two field with same type', async () => { + const collection = db.collection({ + name: 'test', + fields: [ + { + type: 'string', + name: 'test_field', + }, + { + type: 'string', + name: 'testField', + }, + ], + }); + + await db.sync(); + }); + + it('should not set two field with difference type but same field name', async () => { + const collection = db.collection({ + name: 'test', + fields: [ + { + type: 'string', + name: 'test_field', + }, + ], + }); + + expect(() => { + collection.addField('testField', { type: 'integer' }); + }).toThrowError(); + + expect(() => { + collection.addField('test123', { type: 'integer', field: 'test_field' }); + }).toThrowError(); + }); + + it('should create index', async () => { + const collectionA = db.collection({ + name: 'testCollection', + fields: [ + { + type: 'string', + name: 'aField', + }, + { + type: 'string', + name: 'bField', + }, + ], + indexes: [ + { + type: 'UNIQUE', + fields: ['aField', 'bField'], + }, + ], + }); + + await db.sync(); + }); + + it('should use underscored option', async () => { + const collectionA = db.collection({ + name: 'testCollection', + underscored: true, + fields: [ + { + type: 'string', + name: 'testField', + }, + ], + }); + + await db.sync(); + + const tableName = collectionA.model.tableName; + + expect(tableName.includes('test_collection')).toBeTruthy(); + + const repository = db.getRepository('testCollection'); + + await repository.create({ + values: { + testField: 'test', + }, + }); + + const record = await repository.findOne({}); + + expect(record.get('testField')).toBe('test'); + }); + + it('should use database options', async () => { + const collectionA = db.collection({ + name: 'testCollection', + fields: [ + { + type: 'string', + name: 'testField', + }, + ], + }); + + await db.sync(); + + const tableName = collectionA.model.tableName; + + expect(tableName.includes('test_collection')).toBeTruthy(); + }); + + test('through table', async () => { + db.collection({ + name: 'posts', + fields: [ + { + type: 'string', + name: 'name', + }, + { + type: 'belongsToMany', + name: 'tags', + through: 'collectionCategory', + target: 'posts', + sourceKey: 'name', + foreignKey: 'postsName', + targetKey: 'name', + otherKey: 'tagsName', + }, + ], + }); + + db.collection({ + name: 'tags', + fields: [ + { + type: 'string', + name: 'name', + }, + { + type: 'belongsToMany', + name: 'posts', + target: 'posts', + through: 'collectionCategory', + sourceKey: 'name', + foreignKey: 'tagsName', + targetKey: 'name', + otherKey: 'postsName', + }, + ], + }); + + await db.sync(); + + const through = db.getCollection('collectionCategory'); + + expect(through.model.tableName.includes('collection_category')).toBeTruthy(); + }); + + test('db collectionExists', async () => { + const collectionA = db.collection({ + name: 'testCollection', + underscored: true, + fields: [ + { + type: 'string', + name: 'testField', + }, + ], + }); + + expect(await db.collectionExistsInDb('testCollection')).toBeFalsy(); + + await db.sync(); + + expect(await db.collectionExistsInDb('testCollection')).toBeTruthy(); + }); + + it('should throw error when table names conflict', async () => { + db.collection({ + name: 'b1_z', + }); + + expect(() => { + db.collection({ + name: 'b1Z', + }); + }).toThrowError(); + }); +}); diff --git a/packages/core/database/src/collection.ts b/packages/core/database/src/collection.ts index c9ca36bf2..3c43342bf 100644 --- a/packages/core/database/src/collection.ts +++ b/packages/core/database/src/collection.ts @@ -7,13 +7,13 @@ import { QueryInterfaceDropTableOptions, SyncOptions, Transactionable, - Utils, + Utils } from 'sequelize'; import { Database } from './database'; import { Field, FieldOptions } from './fields'; import { Model } from './model'; import { Repository } from './repository'; -import { checkIdentifier, md5 } from './utils'; +import { checkIdentifier, md5, snakeCase } from './utils'; export type RepositoryType = typeof Repository; @@ -36,6 +36,7 @@ export interface CollectionOptions extends Omit * @default 'options' */ magicAttribute?: string; + [key: string]: any; } @@ -72,11 +73,11 @@ export class Collection< constructor(options: CollectionOptions, context: CollectionContext) { super(); - this.checkOptions(options); - this.context = context; this.options = options; + this.checkOptions(options); + this.bindFieldEventListener(); this.modelInit(); @@ -93,15 +94,31 @@ export class Collection< private checkOptions(options: CollectionOptions) { checkIdentifier(options.name); + this.checkTableName(); + } + + private checkTableName() { + const tableName = this.tableName(); + for (const [k, collection] of this.db.collections) { + if (collection.name != this.options.name && tableName === collection.tableName()) { + throw new Error(`collection ${collection.name} and ${this.name} have same tableName "${tableName}"`); + } + } + } + + tableName() { + const { name, tableName } = this.options; + const tName = tableName || name; + return this.db.options.underscored ? snakeCase(tName) : tName; } private sequelizeModelOptions() { - const { name, tableName } = this.options; + const { name } = this.options; return { ..._.omit(this.options, ['name', 'fields', 'model', 'targetKey']), modelName: name, sequelize: this.context.database.sequelize, - tableName: tableName || name, + tableName: this.tableName(), }; } @@ -112,8 +129,10 @@ export class Collection< if (this.model) { return; } + const { name, model, autoGenId = true } = this.options; let M: ModelStatic = Model; + if (this.context.database.sequelize.isDefined(name)) { const m = this.context.database.sequelize.model(name); if ((m as any).isThrough) { @@ -126,11 +145,13 @@ export class Collection< return; } } + if (typeof model === 'string') { M = this.context.database.models.get(model) || Model; } else if (model) { M = model; } + // @ts-ignore this.model = class extends M {}; this.model.init(null, this.sequelizeModelOptions()); @@ -183,8 +204,31 @@ export class Collection< return this.setField(name, options); } + checkFieldType(name: string, options: FieldOptions) { + if (!this.db.options.underscored) { + return; + } + const fieldName = options.field || snakeCase(name); + const field = this.findField((f) => { + if (f.name === name) { + return false; + } + if (f.field) { + return f.field === fieldName; + } + return snakeCase(f.name) === fieldName; + }); + if (!field) { + return; + } + if (options.type !== field.type) { + throw new Error(`fields with same column must be of the same type ${JSON.stringify(options)}`); + } + } + setField(name: string, options: FieldOptions): Field { checkIdentifier(name); + this.checkFieldType(name, options); const { database } = this.context; @@ -307,6 +351,7 @@ export class Collection< updateOptions(options: CollectionOptions, mergeOptions?: any) { let newOptions = lodash.cloneDeep(options); newOptions = merge(this.options, newOptions, mergeOptions); + this.options = newOptions; this.context.database.emit('beforeUpdateCollection', this, newOptions); @@ -361,9 +406,13 @@ export class Collection< if (!index) { return; } + + // collection defined indexes let indexes: any = this.model.options.indexes || []; + let indexName = []; let indexItem; + if (typeof index === 'string') { indexItem = { fields: [index], @@ -378,13 +427,17 @@ export class Collection< indexItem = index; indexName = index.fields; } + if (lodash.isEqual(this.model.primaryKeyAttributes, indexName)) { return; } + const name: string = this.model.primaryKeyAttributes.join(','); + if (name.startsWith(`${indexName.join(',')},`)) { return; } + for (const item of indexes) { if (lodash.isEqual(item.fields, indexName)) { return; @@ -394,11 +447,13 @@ export class Collection< return; } } + if (!indexItem) { return; } + indexes.push(indexItem); - this.model.options.indexes = indexes; + const tableName = this.model.getTableName(); // @ts-ignore this.model._indexes = this.model.options.indexes @@ -410,6 +465,7 @@ export class Collection< } return item; }); + this.refreshIndexes(); } @@ -429,15 +485,17 @@ export class Collection< refreshIndexes() { // @ts-ignore const indexes: any[] = this.model._indexes; + // @ts-ignore - this.model._indexes = indexes.filter((item) => { - for (const field of item.fields) { - if (!this.model.rawAttributes[field]) { - return false; + this.model._indexes = lodash.uniqBy( + indexes.map((item) => { + if (this.options.underscored) { + item.fields = item.fields.map((field) => snakeCase(field)); } - } - return true; - }); + return item; + }), + 'name', + ); } async sync(syncOptions?: SyncOptions) { diff --git a/packages/core/database/src/database.ts b/packages/core/database/src/database.ts index a1eface0b..9224bfed8 100644 --- a/packages/core/database/src/database.ts +++ b/packages/core/database/src/database.ts @@ -15,7 +15,7 @@ import { Sequelize, SyncOptions, Transactionable, - Utils, + Utils } from 'sequelize'; import { SequelizeStorage, Umzug } from 'umzug'; import { Collection, CollectionOptions, RepositoryType } from './collection'; @@ -58,8 +58,9 @@ import { SyncListener, UpdateListener, UpdateWithAssociationsListener, - ValidateListener, + ValidateListener } from './types'; +import { snakeCase } from './utils'; import DatabaseUtils from './database-utils'; @@ -78,6 +79,7 @@ export interface IDatabaseOptions extends Options { tablePrefix?: string; migrator?: any; usingBigIntForId?: boolean; + underscored?: boolean; } export type DatabaseOptions = IDatabaseOptions; @@ -170,7 +172,6 @@ export class Database extends EventEmitter implements AsyncEmitter { constructor(options: DatabaseOptions) { super(); - this.version = new DatabaseVersion(this); const opts = { @@ -253,6 +254,8 @@ export class Database extends EventEmitter implements AsyncEmitter { name: 'migrations', autoGenId: false, timestamps: false, + namespace: 'core', + duplicator: 'required', fields: [{ type: 'string', name: 'name' }], }); @@ -266,6 +269,12 @@ export class Database extends EventEmitter implements AsyncEmitter { } initListener() { + this.on('beforeDefine', (model, options) => { + if (this.options.underscored) { + options.underscored = true; + } + }); + this.on('afterCreate', async (instance) => { instance?.toChangedWithAssociations?.(); }); @@ -295,6 +304,27 @@ export class Database extends EventEmitter implements AsyncEmitter { } } }); + + this.on('beforeDefineCollection', (options) => { + if (options.underscored) { + if (lodash.get(options, 'sortable.scopeKey')) { + options.sortable.scopeKey = snakeCase(options.sortable.scopeKey); + } + + if (lodash.get(options, 'indexes')) { + // change index fields to snake case + options.indexes = options.indexes.map((index) => { + if (index.fields) { + index.fields = index.fields.map((field) => { + return snakeCase(field); + }); + } + + return index; + }); + } + } + }); } addMigration(item: MigrationItem) { @@ -329,6 +359,10 @@ export class Database extends EventEmitter implements AsyncEmitter { collection( options: CollectionOptions, ): Collection { + if (this.options.underscored) { + options.underscored = true; + } + this.emit('beforeDefineCollection', options); const hasValidInheritsOptions = (() => { @@ -478,6 +512,10 @@ export class Database extends EventEmitter implements AsyncEmitter { throw Error(`unsupported field type ${type}`); } + if (options.field && this.options.underscored) { + options.field = snakeCase(options.field); + } + return new Field(options, context); } @@ -526,18 +564,24 @@ export class Database extends EventEmitter implements AsyncEmitter { await this.sequelize.getQueryInterface().dropAllTables(others); } - async collectionExistsInDb(name, options?: Transactionable) { + async collectionExistsInDb(name: string, options?: Transactionable) { + const collection = this.getCollection(name); + if (!collection) { + return false; + } + const tables = await this.sequelize.getQueryInterface().showAllTables({ transaction: options?.transaction, }); - return !!tables.find((table) => table === `${this.getTablePrefix()}${name}`); + + return tables.includes(this.getCollection(name).model.tableName); } public isSqliteMemory() { return this.sequelize.getDialect() === 'sqlite' && lodash.get(this.options, 'storage') == ':memory:'; } - async auth(options: QueryOptions & { retry?: number } = {}) { + async auth(options: Omit & { retry?: number | Pick } = {}) { const { retry = 10, ...others } = options; const delay = (ms) => new Promise((yea) => setTimeout(yea, ms)); let count = 1; @@ -615,6 +659,7 @@ export class Database extends EventEmitter implements AsyncEmitter { } extendCollection(collectionOptions: CollectionOptions, mergeOptions?: MergeOptions) { + collectionOptions = lodash.cloneDeep(collectionOptions); const collectionName = collectionOptions.name; const existCollection = this.getCollection(collectionName); if (existCollection) { diff --git a/packages/core/database/src/fields/belongs-to-field.ts b/packages/core/database/src/fields/belongs-to-field.ts index f28007118..f4d1a8ea5 100644 --- a/packages/core/database/src/fields/belongs-to-field.ts +++ b/packages/core/database/src/fields/belongs-to-field.ts @@ -1,5 +1,5 @@ -import { omit } from 'lodash'; -import { BelongsTo, BelongsToOptions as SequelizeBelongsToOptions, Utils } from 'sequelize'; +import lodash, { omit } from 'lodash'; +import { BelongsToOptions as SequelizeBelongsToOptions, Utils } from 'sequelize'; import { Reference } from '../features/ReferencesMap'; import { checkIdentifier } from '../utils'; import { BaseRelationFieldOptions, RelationField } from './relation-field'; diff --git a/packages/core/database/src/fields/belongs-to-many-field.ts b/packages/core/database/src/fields/belongs-to-many-field.ts index bf49f5cbb..b0fc12ac2 100644 --- a/packages/core/database/src/fields/belongs-to-many-field.ts +++ b/packages/core/database/src/fields/belongs-to-many-field.ts @@ -57,7 +57,6 @@ export class BelongsToManyField extends RelationField { } else { Through = database.collection({ name: through, - // timestamps: false, }); Object.defineProperty(Through.model, 'isThrough', { value: true }); diff --git a/packages/core/database/src/fields/field.ts b/packages/core/database/src/fields/field.ts index 1bb9d7ad6..65fed3d0e 100644 --- a/packages/core/database/src/fields/field.ts +++ b/packages/core/database/src/fields/field.ts @@ -12,6 +12,7 @@ import { Collection } from '../collection'; import { Database } from '../database'; import { InheritedCollection } from '../inherited-collection'; import { ModelEventTypes } from '../types'; +import { snakeCase } from '../utils'; export interface FieldContext { database: Database; @@ -89,6 +90,18 @@ export abstract class Field { return this.collection.removeField(this.name); } + columnName() { + if (this.options.field) { + return this.options.field; + } + + if (this.database.options.underscored) { + return snakeCase(this.name); + } + + return this.name; + } + async removeFromDb(options?: QueryInterfaceOptions) { const attribute = this.collection.model.rawAttributes[this.name]; @@ -113,7 +126,12 @@ export abstract class Field { } if (this.collection.model.options.timestamps !== false) { // timestamps 相关字段不删除 - if (['createdAt', 'updatedAt', 'deletedAt'].includes(this.name)) { + let timestampsFields = ['createdAt', 'updatedAt', 'deletedAt']; + if (this.database.options.underscored) { + timestampsFields = timestampsFields.map((field) => snakeCase(field)); + } + if (timestampsFields.includes(this.columnName())) { + this.collection.fields.delete(this.name); return; } } @@ -132,19 +150,28 @@ export abstract class Field { return; } } - if (this.options.field && this.name !== this.options.field) { - // field 指向的是真实的字段名,如果与 name 不一样,说明字段只是引用 - this.remove(); - return; - } + + // if (this.options.field && this.name !== this.options.field) { + // // field 指向的是真实的字段名,如果与 name 不一样,说明字段只是引用 + // this.remove(); + // return; + // } + + const columnReferencesCount = _.filter( + this.collection.model.rawAttributes, + (attr) => attr.field == this.columnName(), + ).length; + if ( - await this.existsInDb({ + (await this.existsInDb({ transaction: options?.transaction, - }) + })) && + columnReferencesCount == 1 ) { const queryInterface = this.database.sequelize.getQueryInterface(); - await queryInterface.removeColumn(this.collection.model.tableName, this.name, options); + await queryInterface.removeColumn(this.collection.model.tableName, this.columnName(), options); } + this.remove(); } @@ -154,18 +181,20 @@ export abstract class Field { }; let sql; if (this.database.sequelize.getDialect() === 'sqlite') { - sql = `SELECT * from pragma_table_info('${this.collection.model.tableName}') WHERE name = '${this.name}'`; + sql = `SELECT * from pragma_table_info('${this.collection.model.tableName}') WHERE name = '${this.columnName()}'`; } else if (this.database.inDialect('mysql')) { sql = ` select column_name from INFORMATION_SCHEMA.COLUMNS - where TABLE_SCHEMA='${this.database.options.database}' AND TABLE_NAME='${this.collection.model.tableName}' AND column_name='${this.name}' + where TABLE_SCHEMA='${this.database.options.database}' AND TABLE_NAME='${ + this.collection.model.tableName + }' AND column_name='${this.columnName()}' `; } else { sql = ` select column_name from INFORMATION_SCHEMA.COLUMNS - where TABLE_NAME='${this.collection.model.tableName}' AND column_name='${this.name}' + where TABLE_NAME='${this.collection.model.tableName}' AND column_name='${this.columnName()}' `; } const [rows] = await this.database.sequelize.query(sql, opts); diff --git a/packages/core/database/src/fields/has-one-field.ts b/packages/core/database/src/fields/has-one-field.ts index f72de6018..799660398 100644 --- a/packages/core/database/src/fields/has-one-field.ts +++ b/packages/core/database/src/fields/has-one-field.ts @@ -1,4 +1,4 @@ -import { omit } from 'lodash'; +import lodash, { omit } from 'lodash'; import { AssociationScope, DataType, @@ -8,7 +8,7 @@ import { Utils, } from 'sequelize'; import { Collection } from '../collection'; -import { checkIdentifier } from '../utils'; +import { checkIdentifier, snakeCase } from '../utils'; import { BaseRelationFieldOptions, RelationField } from './relation-field'; import { Reference } from '../features/ReferencesMap'; @@ -84,11 +84,15 @@ export class HasOneField extends RelationField { } get foreignKey() { - if (this.options.foreignKey) { - return this.options.foreignKey; - } - const { model } = this.context.collection; - return Utils.camelize([model.options.name.singular, model.primaryKeyAttribute].join('_')); + const foreignKey = (() => { + if (this.options.foreignKey) { + return this.options.foreignKey; + } + const { model } = this.context.collection; + return Utils.camelize([model.options.name.singular, model.primaryKeyAttribute].join('_')); + })(); + + return foreignKey; } reference(association): Reference { diff --git a/packages/core/database/src/index.ts b/packages/core/database/src/index.ts index ba9a5144e..35f8a2bce 100644 --- a/packages/core/database/src/index.ts +++ b/packages/core/database/src/index.ts @@ -18,3 +18,4 @@ export * from './update-associations'; export * from './collection-importer'; export * from './filter-match'; export * from './field-repository/array-field-repository'; +export { snakeCase } from './utils'; diff --git a/packages/core/database/src/mock-database.ts b/packages/core/database/src/mock-database.ts index 6a993a97d..3e093d081 100644 --- a/packages/core/database/src/mock-database.ts +++ b/packages/core/database/src/mock-database.ts @@ -31,10 +31,11 @@ export function getConfigByEnv() { collate: 'utf8mb4_unicode_ci', }, timezone: process.env.DB_TIMEZONE, + underscored: process.env.DB_UNDERSCORED === 'true', }; } export function mockDatabase(options: IDatabaseOptions = {}): MockDatabase { - const dbOptions = merge(getConfigByEnv(), options); + const dbOptions = merge(getConfigByEnv(), options) as any; return new MockDatabase(dbOptions); } diff --git a/packages/core/database/src/model.ts b/packages/core/database/src/model.ts index 2e1c5af4f..38f189b6e 100644 --- a/packages/core/database/src/model.ts +++ b/packages/core/database/src/model.ts @@ -1,9 +1,8 @@ import lodash from 'lodash'; -import { DataTypes, Model as SequelizeModel, ModelStatic } from 'sequelize'; +import { Model as SequelizeModel, ModelStatic } 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'; const _ = lodash; @@ -28,6 +27,7 @@ export class Model = sortKey.replace('-', '').split('.'); + if (this.database.inDialect('postgres', 'sqlite')) { direction = `${direction} NULLS LAST`; } @@ -86,7 +87,11 @@ export class OptionsParser { sortField[i] = associationModel.associations[associationKey].target; associationModel = sortField[i]; } + } else { + const rawField = this.model.rawAttributes[sortField[0]]; + sortField[0] = rawField?.field || sortField[0]; } + sortField.push(direction); if (this.database.inDialect('mysql')) { orderParams.push([Sequelize.fn('ISNULL', Sequelize.col(`${this.model.name}.${sortField[0]}`))]); diff --git a/packages/core/database/src/relation-repository/belongs-to-many-repository.ts b/packages/core/database/src/relation-repository/belongs-to-many-repository.ts index 1d5438e93..2495036cf 100644 --- a/packages/core/database/src/relation-repository/belongs-to-many-repository.ts +++ b/packages/core/database/src/relation-repository/belongs-to-many-repository.ts @@ -62,6 +62,8 @@ export class BelongsToManyRepository extends MultipleRelationRepository implemen const transaction = await this.getTransaction(options); const association = this.association; + const throughModel = this.throughModel(); + const instancesToIds = (instances) => { return instances.map((instance) => instance.get(this.targetKey())); }; @@ -69,7 +71,7 @@ export class BelongsToManyRepository extends MultipleRelationRepository implemen // Through Table const throughTableWhere: Array = [ { - [association.foreignKey]: this.sourceKeyValue, + [throughModel.rawAttributes[association.foreignKey].field]: this.sourceKeyValue, }, ]; @@ -100,7 +102,7 @@ export class BelongsToManyRepository extends MultipleRelationRepository implemen } throughTableWhere.push({ - [association.otherKey]: { + [throughModel.rawAttributes[association.otherKey].field]: { [Op.in]: ids, }, }); diff --git a/packages/core/database/src/repository.ts b/packages/core/database/src/repository.ts index 5582652e5..fc26bbffb 100644 --- a/packages/core/database/src/repository.ts +++ b/packages/core/database/src/repository.ts @@ -11,7 +11,7 @@ import { Op, Transactionable, UpdateOptions as SequelizeUpdateOptions, - WhereOperators + WhereOperators, } from 'sequelize'; import { Collection } from './collection'; import { Database } from './database'; @@ -407,7 +407,12 @@ export class Repository(values, { @@ -476,7 +481,7 @@ export class Repository; action: UpdateAction; + underscored: boolean; private associationKeysToBeUpdate: AssociationKeysToBeUpdate; private blackList: BlackList; private whiteList: WhiteList; @@ -162,6 +163,11 @@ export class UpdateGuard { guard.setBlackList(options.blacklist); guard.setAction(lodash.get(options, 'action', 'update')); guard.setAssociationKeysToBeUpdate(options.updateAssociationValues); + + if (options.underscored) { + guard.underscored = options.underscored; + } + return guard; } } diff --git a/packages/core/database/src/utils.ts b/packages/core/database/src/utils.ts index 90a92ee42..173fc17b8 100644 --- a/packages/core/database/src/utils.ts +++ b/packages/core/database/src/utils.ts @@ -1,6 +1,7 @@ import crypto from 'crypto'; import { IdentifierError } from './errors/identifier-error'; import { Model } from './model'; +import lodash from 'lodash'; type HandleAppendsQueryOptions = { templateModel: any; @@ -73,3 +74,11 @@ export function checkIdentifier(value: string) { throw new IdentifierError(`Identifier ${value} is too long`); } } + +export function getTableName(collectionName: string, options) { + return options.underscored ? snakeCase(collectionName) : collectionName; +} + +export function snakeCase(name: string) { + return require('sequelize').Utils.underscore(name); +} diff --git a/packages/core/server/src/application.ts b/packages/core/server/src/application.ts index 33c1fa21b..80ac85902 100644 --- a/packages/core/server/src/application.ts +++ b/packages/core/server/src/application.ts @@ -101,6 +101,8 @@ export class ApplicationVersion { if (!app.db.hasCollection('applicationVersion')) { app.db.collection({ name: 'applicationVersion', + namespace: 'core', + duplicator: 'required', timestamps: false, fields: [{ name: 'value', type: 'string' }], }); diff --git a/packages/core/server/src/plugin-manager/PluginManager.ts b/packages/core/server/src/plugin-manager/PluginManager.ts index 559a2ba59..6e986682a 100644 --- a/packages/core/server/src/plugin-manager/PluginManager.ts +++ b/packages/core/server/src/plugin-manager/PluginManager.ts @@ -76,6 +76,7 @@ export class PluginManager { await this.repository.load(); } }); + this.app.on('beforeUpgrade', async () => { await this.collection.sync(); }); @@ -195,6 +196,7 @@ export class PluginManager { if (this.plugins.has(pluginName)) { throw new Error(`plugin name [${pluginName}] already exists`); } + this.plugins.set(pluginName, instance); return instance; } diff --git a/packages/core/server/src/plugin-manager/options/collection.ts b/packages/core/server/src/plugin-manager/options/collection.ts index 3b9cffb93..0cc02c537 100644 --- a/packages/core/server/src/plugin-manager/options/collection.ts +++ b/packages/core/server/src/plugin-manager/options/collection.ts @@ -1,5 +1,7 @@ export default { name: 'applicationPlugins', + namespace: 'core', + duplicator: 'required', repository: 'PluginManagerRepository', fields: [ { type: 'string', name: 'name', unique: true }, diff --git a/packages/plugins/acl/README.md b/packages/plugins/acl/README.md new file mode 100644 index 000000000..af0dd46bb --- /dev/null +++ b/packages/plugins/acl/README.md @@ -0,0 +1,11 @@ +# acl + +English | [中文](./README.zh-CN.md) + +基于角色的权限控制插件。 + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/acl/README.zh-CN.md b/packages/plugins/acl/README.zh-CN.md new file mode 100644 index 000000000..8260a114b --- /dev/null +++ b/packages/plugins/acl/README.zh-CN.md @@ -0,0 +1,11 @@ +# acl + +[English](./README.md) | 中文 + +基于角色的权限控制插件。 + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 \ No newline at end of file diff --git a/packages/plugins/acl/src/__tests__/list-action.test.ts b/packages/plugins/acl/src/__tests__/list-action.test.ts index cd65c4dcf..90330e056 100644 --- a/packages/plugins/acl/src/__tests__/list-action.test.ts +++ b/packages/plugins/acl/src/__tests__/list-action.test.ts @@ -32,12 +32,22 @@ describe('list action with acl', () => { role: 'user', }); + app.acl.addFixedParams('tests', 'destroy', () => { + return { + filter: { + $and: [{ 'name.$ne': 't1' }, { 'name.$ne': 't2' }], + }, + }; + }); + userRole.grantAction('tests:view', {}); userRole.grantAction('tests:update', { own: true, }); + userRole.grantAction('tests:destroy', {}); + const Test = app.db.collection({ name: 'tests', fields: [ @@ -74,13 +84,13 @@ describe('list action with acl', () => { before: 'acl', }, ); - + const response = await app.agent().set('X-With-ACL-Meta', true).resource('tests').list({}); const data = response.body; expect(data.meta.allowedActions.view).toEqual(['t1', 't2', 't3']); expect(data.meta.allowedActions.update).toEqual(['t1', 't2']); - expect(data.meta.allowedActions.destroy).toEqual([]); + expect(data.meta.allowedActions.destroy).toEqual(['t3']); }); it('should list items with meta permission', async () => { @@ -241,12 +251,15 @@ describe('list association action with acl', () => { }); const userPlugin = app.getPlugin('users'); - const userAgent = app.agent().set('X-With-ACL-Meta', true).auth( - userPlugin.jwtService.sign({ - userId: user.get('id'), - }), - { type: 'bearer' }, - ); + const userAgent = app + .agent() + .set('X-With-ACL-Meta', true) + .auth( + userPlugin.jwtService.sign({ + userId: user.get('id'), + }), + { type: 'bearer' }, + ); await userAgent.resource('posts').create({ values: { diff --git a/packages/plugins/acl/src/collections/roles-users.ts b/packages/plugins/acl/src/collections/roles-users.ts index c59a4564c..abe0fe97d 100644 --- a/packages/plugins/acl/src/collections/roles-users.ts +++ b/packages/plugins/acl/src/collections/roles-users.ts @@ -2,5 +2,7 @@ import { CollectionOptions } from '@nocobase/database'; export default { name: 'rolesUsers', + duplicator: 'optional', + namespace: 'acl', fields: [{ type: 'boolean', name: 'default' }], } as CollectionOptions; diff --git a/packages/plugins/acl/src/collections/roles.ts b/packages/plugins/acl/src/collections/roles.ts index 8a58a3086..ef28e72c2 100644 --- a/packages/plugins/acl/src/collections/roles.ts +++ b/packages/plugins/acl/src/collections/roles.ts @@ -1,6 +1,8 @@ import { CollectionOptions } from '@nocobase/database'; export default { + namespace: 'acl', + duplicator: 'required', name: 'roles', title: '{{t("Roles")}}', autoGenId: false, diff --git a/packages/plugins/acl/src/collections/rolesResources.ts b/packages/plugins/acl/src/collections/rolesResources.ts index 8a59485ef..a43b8c5ed 100644 --- a/packages/plugins/acl/src/collections/rolesResources.ts +++ b/packages/plugins/acl/src/collections/rolesResources.ts @@ -1,6 +1,8 @@ import { CollectionOptions } from '@nocobase/database'; export default { + namespace: 'acl', + duplicator: 'required', name: 'rolesResources', model: 'RoleResourceModel', indexes: [ diff --git a/packages/plugins/acl/src/collections/rolesResourcesActions.ts b/packages/plugins/acl/src/collections/rolesResourcesActions.ts index 18dcd8631..a18c5e1c1 100644 --- a/packages/plugins/acl/src/collections/rolesResourcesActions.ts +++ b/packages/plugins/acl/src/collections/rolesResourcesActions.ts @@ -1,6 +1,8 @@ import { CollectionOptions } from '@nocobase/database'; export default { + namespace: 'acl', + duplicator: 'required', name: 'rolesResourcesActions', model: 'RoleResourceActionModel', fields: [ diff --git a/packages/plugins/acl/src/collections/rolesResourcesScopes.ts b/packages/plugins/acl/src/collections/rolesResourcesScopes.ts index 3281d4e78..f3a759d60 100644 --- a/packages/plugins/acl/src/collections/rolesResourcesScopes.ts +++ b/packages/plugins/acl/src/collections/rolesResourcesScopes.ts @@ -1,6 +1,8 @@ import { CollectionOptions } from '@nocobase/database'; export default { + namespace: 'acl', + duplicator: 'required', name: 'rolesResourcesScopes', fields: [ { diff --git a/packages/plugins/acl/src/server.ts b/packages/plugins/acl/src/server.ts index bce65fbc5..dd512da09 100644 --- a/packages/plugins/acl/src/server.ts +++ b/packages/plugins/acl/src/server.ts @@ -1,6 +1,6 @@ import { NoPermissionError } from '@nocobase/acl'; import { Context, utils as actionUtils } from '@nocobase/actions'; -import { Collection, RelationField } from '@nocobase/database'; +import { Collection, RelationField, snakeCase } from '@nocobase/database'; import { Plugin } from '@nocobase/server'; import lodash from 'lodash'; import { resolve } from 'path'; @@ -262,6 +262,7 @@ export class PluginACL extends Plugin { this.app.db.on('rolesResources.afterDestroy', async (model, options) => { const role = this.acl.getRole(model.get('roleName')); + if (role) { role.revokeResource(model.get('name')); } @@ -281,6 +282,7 @@ export class PluginACL extends Plugin { const { transaction } = options; const collectionName = model.get('collectionName'); + const fieldName = model.get('name'); const resourceActions = (await this.app.db.getRepository('rolesResourcesActions').find({ @@ -695,16 +697,57 @@ export class PluginACL extends Plugin { const actionSql = ctx.db.sequelize.queryInterface.queryGenerator.selectQuery( Model.getTableName(), { - // ...queryParams, - where: queryParams.where, + where: (() => { + const filterObj = queryParams.where; + if (!this.db.options.underscored) { + return filterObj; + } + + const iterate = (rootObj, path = []) => { + const obj = path.length == 0 ? rootObj : lodash.get(rootObj, path); + + if (Array.isArray(obj)) { + for (let i = 0; i < obj.length; i++) { + if (obj[i] === null) { + continue; + } + + if (typeof obj[i] === 'object') { + iterate(rootObj, [...path, i]); + } + } + + return; + } + + Reflect.ownKeys(obj).forEach((key) => { + if (Array.isArray(obj) && key == 'length') { + return; + } + + if ((typeof obj[key] === 'object' && obj[key] !== null) || typeof obj[key] === 'symbol') { + iterate(rootObj, [...path, key]); + } + + if (typeof key === 'string' && key !== snakeCase(key)) { + lodash.set(rootObj, [...path, snakeCase(key)], lodash.cloneDeep(obj[key])); + lodash.unset(rootObj, [...path, key]); + } + }); + }; + + iterate(filterObj); + + return filterObj; + })(), attributes: [primaryKeyField], includeIgnoreAttributes: false, - // include: queryParams.include, }, Model, ); const whereCase = actionSql.match(/WHERE (.*?);/)[1]; + conditions.push({ whereCase, action, @@ -772,6 +815,11 @@ export class PluginACL extends Plugin { async load() { await this.importCollections(resolve(__dirname, 'collections')); + this.db.extendCollection({ + name: 'rolesUischemas', + namespace: 'acl', + duplicator: 'required', + }); } } diff --git a/packages/plugins/audit-logs/README.md b/packages/plugins/audit-logs/README.md new file mode 100644 index 000000000..e617cc5b1 --- /dev/null +++ b/packages/plugins/audit-logs/README.md @@ -0,0 +1,11 @@ +# audit-logs + +English | [中文](./README.zh-CN.md) + +审计日志插件。 + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/audit-logs/README.zh-CN.md b/packages/plugins/audit-logs/README.zh-CN.md new file mode 100644 index 000000000..8dc810586 --- /dev/null +++ b/packages/plugins/audit-logs/README.zh-CN.md @@ -0,0 +1,11 @@ +# audit-logs + +[English](./README.md) | 中文 + +审计日志插件。 + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/audit-logs/src/server/collections/auditChanges.ts b/packages/plugins/audit-logs/src/server/collections/auditChanges.ts index cae4c356f..261207a75 100644 --- a/packages/plugins/audit-logs/src/server/collections/auditChanges.ts +++ b/packages/plugins/audit-logs/src/server/collections/auditChanges.ts @@ -1,6 +1,8 @@ import { defineCollection } from '@nocobase/database'; export default defineCollection({ + namespace: 'audit-logs', + duplicator: 'optional', name: 'auditChanges', title: '变动值', createdBy: false, diff --git a/packages/plugins/audit-logs/src/server/collections/auditLogs.ts b/packages/plugins/audit-logs/src/server/collections/auditLogs.ts index df90dd076..932d4e6e7 100644 --- a/packages/plugins/audit-logs/src/server/collections/auditLogs.ts +++ b/packages/plugins/audit-logs/src/server/collections/auditLogs.ts @@ -1,6 +1,8 @@ import { defineCollection } from '@nocobase/database'; export default defineCollection({ + namespace: 'audit-logs', + duplicator: 'optional', name: 'auditLogs', createdBy: false, updatedBy: false, diff --git a/packages/plugins/china-region/README.md b/packages/plugins/china-region/README.md new file mode 100644 index 000000000..e762c6d8d --- /dev/null +++ b/packages/plugins/china-region/README.md @@ -0,0 +1,11 @@ +# china-region + +English | [中文](./README.zh-CN.md) + +中国行政区划插件。 + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/china-region/README.zh-CN.md b/packages/plugins/china-region/README.zh-CN.md new file mode 100644 index 000000000..9180fab21 --- /dev/null +++ b/packages/plugins/china-region/README.zh-CN.md @@ -0,0 +1,11 @@ +# china-region + +[English](./README.md) | 中文 + +中国行政区划插件。 + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/china-region/src/server/collections/chinaRegions.ts b/packages/plugins/china-region/src/server/collections/chinaRegions.ts index 14c40cbe7..fa431b142 100644 --- a/packages/plugins/china-region/src/server/collections/chinaRegions.ts +++ b/packages/plugins/china-region/src/server/collections/chinaRegions.ts @@ -1,6 +1,8 @@ import { defineCollection } from '@nocobase/database'; export default defineCollection({ + namespace: 'china-region', + duplicator: 'skip', name: 'chinaRegions', title: '中国行政区划', autoGenId: false, diff --git a/packages/plugins/client/README.md b/packages/plugins/client/README.md new file mode 100644 index 000000000..cc7dc122c --- /dev/null +++ b/packages/plugins/client/README.md @@ -0,0 +1,9 @@ +# client + +English | [中文](./README.zh-CN.md) + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/client/README.zh-CN.md b/packages/plugins/client/README.zh-CN.md new file mode 100644 index 000000000..7eb06a44e --- /dev/null +++ b/packages/plugins/client/README.zh-CN.md @@ -0,0 +1,9 @@ +# client + +[English](./README.md) | 中文 + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/collection-manager/README.md b/packages/plugins/collection-manager/README.md new file mode 100644 index 000000000..43b192fae --- /dev/null +++ b/packages/plugins/collection-manager/README.md @@ -0,0 +1,9 @@ +# collection-manager + +English | [中文](./README.zh-CN.md) + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/collection-manager/README.zh-CN.md b/packages/plugins/collection-manager/README.zh-CN.md new file mode 100644 index 000000000..3140973a8 --- /dev/null +++ b/packages/plugins/collection-manager/README.zh-CN.md @@ -0,0 +1,9 @@ +# collection-manager + +[English](./README.md) | 中文 + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/collection-manager/src/__tests__/collections.repository.test.ts b/packages/plugins/collection-manager/src/__tests__/collections.repository.test.ts index 6fa9869c1..13309e424 100644 --- a/packages/plugins/collection-manager/src/__tests__/collections.repository.test.ts +++ b/packages/plugins/collection-manager/src/__tests__/collections.repository.test.ts @@ -19,6 +19,32 @@ describe('collections repository', () => { await app.destroy(); }); + test('create underscored field', async () => { + if (process.env.DB_UNDERSCORED !== 'true') { + return; + } + + const collection = await Collection.repository.create({ + values: { + name: 'testCollection', + createdAt: true, + fields: [ + { + type: 'date', + field: 'createdAt', + name: 'createdAt', + }, + ], + }, + }); + + await collection.migrate(); + + const testCollection = db.getCollection('testCollection'); + + expect(testCollection.model.rawAttributes.createdAt.field).toEqual('created_at'); + }); + it('case 1', async () => { // 什么都没提供,随机 name 和 key const data = await Collection.repository.create({ @@ -157,4 +183,164 @@ describe('collections repository', () => { ], }); }); + + it('should destroy when fields refer to the same field', async () => { + await Collection.repository.create({ + context: {}, + values: { + name: 'tests', + timestamps: true, + fields: [ + { + type: 'date', + name: 'dateA', + }, + { + type: 'date', + name: 'date_a', + }, + ], + }, + }); + + const testCollection = db.getCollection('tests'); + const getTableInfo = async () => + await db.sequelize.getQueryInterface().describeTable(testCollection.model.tableName); + + const tableInfo0 = await getTableInfo(); + expect(tableInfo0['date_a']).toBeDefined(); + + await Field.repository.destroy({ + context: {}, + filter: { + name: ['dateA', 'date_a'], + }, + }); + + const count = await Field.repository.count(); + expect(count).toBe(0); + const tableInfo1 = await getTableInfo(); + expect(tableInfo1['dateA']).not.toBeDefined(); + expect(tableInfo1['date_a']).not.toBeDefined(); + }); + + it('should not destroy timestamps columns', async () => { + const createdAt = db.options.underscored ? 'created_at' : 'createdAt'; + await Collection.repository.create({ + context: {}, + values: { + name: 'tests', + timestamps: true, + fields: [ + { + type: 'date', + name: 'createdAt', + }, + ], + }, + }); + + const testCollection = db.getCollection('tests'); + const getTableInfo = async () => + await db.sequelize.getQueryInterface().describeTable(testCollection.model.tableName); + + const tableInfo0 = await getTableInfo(); + expect(tableInfo0[createdAt]).toBeDefined(); + + await Field.repository.destroy({ + context: {}, + filter: { + name: 'createdAt', + }, + }); + + const tableInfo1 = await getTableInfo(); + expect(tableInfo1[createdAt]).toBeDefined(); + expect(testCollection.hasField('createdAt')).toBeFalsy(); + expect(testCollection.model.rawAttributes['createdAt']).toBeDefined(); + }); + + it('should not destroy column when column belongs to a field', async () => { + if (db.options.underscored !== true) return; + + await Collection.repository.create({ + context: {}, + values: { + name: 'tests', + fields: [ + { + type: 'string', + name: 'test_field', + }, + { + type: 'string', + name: 'testField', + }, + { + type: 'string', + name: 'test123', + field: 'test_field', + }, + { + type: 'string', + name: 'otherField', + }, + ], + }, + }); + + const testCollection = db.getCollection('tests'); + + expect( + testCollection.model.rawAttributes.test_field.field === testCollection.model.rawAttributes.testField.field, + ).toBe(true); + const getTableInfo = async () => + await db.sequelize.getQueryInterface().describeTable(testCollection.model.tableName); + + const tableInfo0 = await getTableInfo(); + + expect(tableInfo0['other_field']).toBeDefined(); + + await Field.repository.destroy({ + context: {}, + filter: { + name: 'otherField', + }, + }); + + expect(testCollection.model.rawAttributes['otherField']).toBeUndefined(); + const tableInfo1 = await getTableInfo(); + expect(tableInfo1['other_field']).not.toBeDefined(); + + await Field.repository.destroy({ + context: {}, + filter: { + name: 'testField', + }, + }); + + expect(testCollection.model.rawAttributes['testField']).toBeUndefined(); + const tableInfo2 = await getTableInfo(); + expect(tableInfo2['test_field']).toBeDefined(); + + await Field.repository.destroy({ + context: {}, + filter: { + name: 'test_field', + }, + }); + + const tableInfo3 = await getTableInfo(); + expect(tableInfo3['test_field']).toBeDefined(); + + await Field.repository.destroy({ + context: {}, + filter: { + name: 'test123', + }, + }); + + const tableInfo4 = await getTableInfo(); + expect(tableInfo4['test_field']).not.toBeDefined(); + }); }); diff --git a/packages/plugins/collection-manager/src/__tests__/field-options/indexes.test.ts b/packages/plugins/collection-manager/src/__tests__/field-options/indexes.test.ts index 91e9f20f5..f87046628 100644 --- a/packages/plugins/collection-manager/src/__tests__/field-options/indexes.test.ts +++ b/packages/plugins/collection-manager/src/__tests__/field-options/indexes.test.ts @@ -69,6 +69,8 @@ describe('field indexes', () => { }, }); + expect(field.status).toBe(200); + // create a record const response1 = await agent.resource(tableName).create({ values: { diff --git a/packages/plugins/collection-manager/src/__tests__/fields/reverseField.test.ts b/packages/plugins/collection-manager/src/__tests__/fields/reverseField.test.ts index bce332781..2998b738b 100644 --- a/packages/plugins/collection-manager/src/__tests__/fields/reverseField.test.ts +++ b/packages/plugins/collection-manager/src/__tests__/fields/reverseField.test.ts @@ -38,6 +38,7 @@ describe('reverseField options', () => { reverseField: {}, }, }); + const json = JSON.parse(JSON.stringify(field.toJSON())); expect(json).toMatchObject({ type: 'hasMany', 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 4d0652d06..208c4faac 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 @@ -396,4 +396,144 @@ describe('collections repository', () => { }); expect(response1.body.data.length).toBe(2); }); + + it('should update field with default value', async () => { + const createCollectionResponse = await app + .agent() + .resource('collections') + .create({ + values: { + name: 'test', + fields: [ + { + name: 'testField', + type: 'string', + }, + ], + }, + }); + + const testField = await app.db.getRepository('fields').findOne({ + filter: { + name: 'testField', + }, + }); + + // update field with unique index + const addDefaultValueResponse = await app + .agent() + .resource('fields') + .update({ + values: { + defaultValue: '1231', + }, + filterByTk: testField.get('key'), + }); + + expect(addDefaultValueResponse.statusCode).toEqual(200); + }); + + it('should create collection field', async () => { + await app + .agent() + .resource('collections') + .create({ + values: { + name: 'test', + }, + }); + + const addFieldResponse = await app + .agent() + .resource('fields') + .create({ + values: { + name: 'testField', + collectionName: 'test', + type: 'string', + }, + }); + + expect(addFieldResponse.statusCode).toEqual(200); + + const collection = app.db.getCollection('test'); + + const columnName = collection.model.rawAttributes.testField.field; + + const tableInfo = await app.db.sequelize.getQueryInterface().describeTable(collection.model.tableName); + + expect(tableInfo[columnName]).toBeDefined(); + }); + + it('should update field with unique index', async () => { + const createCollectionResponse = await app + .agent() + .resource('collections') + .create({ + values: { + name: 'test', + fields: [ + { + name: 'testField', + type: 'string', + }, + ], + }, + }); + + expect(createCollectionResponse.statusCode).toEqual(200); + + const testField = await app.db.getRepository('fields').findOne({ + filter: { + name: 'testField', + }, + }); + + // update field with unique index + const addIndexResponse = await app + .agent() + .resource('fields') + .update({ + values: { + unique: true, + }, + filterByTk: testField.get('key'), + }); + + expect(addIndexResponse.statusCode).toEqual(200); + + const indexes = (await app.db.sequelize + .getQueryInterface() + .showIndex(app.db.getCollection('test').model.tableName)) as any; + + const columnName = app.db.getCollection('test').model.rawAttributes.testField.field; + + expect( + indexes.find( + (index) => index.unique == true && index.fields[0].attribute == columnName && index.fields.length === 1, + ), + ).toBeDefined(); + + const removeIndexResponse = await app + .agent() + .resource('fields') + .update({ + values: { + unique: false, + }, + filterByTk: testField.get('key'), + }); + + expect(removeIndexResponse.statusCode).toEqual(200); + + const afterIndexes = (await app.db.sequelize + .getQueryInterface() + .showIndex(app.db.getCollection('test').model.tableName)) as any; + + expect( + afterIndexes.find( + (index) => index.unique == true && index.fields[0].attribute == columnName && index.fields.length === 1, + ), + ).not.toBeDefined(); + }); }); 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 index 6a4be7baf..b4a367bcc 100644 --- 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 @@ -64,7 +64,7 @@ pgOnly()('Inherited Collection', () => { expect(response.statusCode).toBe(500); }); - it('can create relation with child table', async () => { + it('can create relation with child table', async () => { await agent.resource('collections').create({ values: { name: 'a', @@ -100,6 +100,8 @@ pgOnly()('Inherited Collection', () => { }, }); + const collectionB = app.db.getCollection('b'); + const res = await agent.resource('b').create({ values: { af: 'a1', diff --git a/packages/plugins/collection-manager/src/__tests__/migrations/update-id-to-bigint.test.ts b/packages/plugins/collection-manager/src/__tests__/migrations/update-id-to-bigint.test.ts index 546461f41..4e88826d7 100644 --- a/packages/plugins/collection-manager/src/__tests__/migrations/update-id-to-bigint.test.ts +++ b/packages/plugins/collection-manager/src/__tests__/migrations/update-id-to-bigint.test.ts @@ -1,5 +1,6 @@ import { Database, MigrationContext } from '@nocobase/database'; import Migrator from '../../migrations/20221121111113-update-id-to-bigint'; +import lodash from 'lodash'; const excludeSqlite = () => (process.env.DB_DIALECT != 'sqlite' ? describe : describe.skip); @@ -72,6 +73,11 @@ excludeSqlite()('update id to bigint test', () => { .describeTable( db.getCollection(collectionName) ? db.getCollection(collectionName).model.tableName : collectionName, ); + + if (process.env.DB_UNDERSCORED) { + fieldName = lodash.snakeCase(fieldName); + } + console.log(`${collectionName}, ${fieldName}`, tableInfo[fieldName].type); expect(tableInfo[fieldName].type).toBe('BIGINT'); }; diff --git a/packages/plugins/collection-manager/src/__tests__/resources/collections.fields.test.ts b/packages/plugins/collection-manager/src/__tests__/resources/collections.fields.test.ts index 584be2cc3..9449c57e0 100644 --- a/packages/plugins/collection-manager/src/__tests__/resources/collections.fields.test.ts +++ b/packages/plugins/collection-manager/src/__tests__/resources/collections.fields.test.ts @@ -27,15 +27,21 @@ describe('collections.fields', () => { ], }, }); + const collection = app.db.getCollection('test1'); const field = collection.getField('name'); + expect(collection.hasField('name')).toBeTruthy(); + const r1 = await field.existsInDb(); expect(r1).toBeTruthy(); + await app.agent().resource('collections.fields', 'test1').destroy({ filterByTk: 'name', }); + expect(collection.hasField('name')).toBeFalsy(); + const r2 = await field.existsInDb(); expect(r2).toBeFalsy(); }); diff --git a/packages/plugins/collection-manager/src/collections/collectionCategories.ts b/packages/plugins/collection-manager/src/collections/collectionCategories.ts new file mode 100644 index 000000000..10ccfedee --- /dev/null +++ b/packages/plugins/collection-manager/src/collections/collectionCategories.ts @@ -0,0 +1,34 @@ +import { CollectionOptions } from '@nocobase/database'; + +export default { + namespace: 'collection-manager', + duplicator: 'required', + name: 'collectionCategories', + autoGenId: true, + sortable: true, + fields: [ + { + type: 'string', + name: 'name', + }, + // { + // type: 'integer', + // name: 'sort', + // defaultValue: 0, + // }, + { + type: 'string', + name: 'color', + defaultValue: 'default', + }, + { + type: 'belongsToMany', + name: 'collections', + target: 'collections', + foreignKey: 'categoryId', + otherKey: 'collectionName', + targetKey: 'name', + through: 'collectionCategory', + }, + ], +} as CollectionOptions; diff --git a/packages/plugins/collection-manager/src/collections/collections.ts b/packages/plugins/collection-manager/src/collections/collections.ts index 2b08305ba..b3d52dc87 100644 --- a/packages/plugins/collection-manager/src/collections/collections.ts +++ b/packages/plugins/collection-manager/src/collections/collections.ts @@ -1,6 +1,8 @@ import { CollectionOptions } from '@nocobase/database'; export default { + namespace: 'collection-manager', + duplicator: 'required', name: 'collections', title: '数据表配置', sortable: 'sort', @@ -50,5 +52,15 @@ export default { foreignKey: 'collectionName', sortBy: 'sort', }, + { + type: 'belongsToMany', + name: 'category', + target: 'collectionCategories', + sourceKey: 'name', + foreignKey: 'collectionName', + otherKey: 'categoryId', + targetKey: 'id', + through: 'collectionCategory', + }, ], } as CollectionOptions; diff --git a/packages/plugins/collection-manager/src/collections/fields.ts b/packages/plugins/collection-manager/src/collections/fields.ts index 66010a0bb..b6b219c70 100644 --- a/packages/plugins/collection-manager/src/collections/fields.ts +++ b/packages/plugins/collection-manager/src/collections/fields.ts @@ -1,6 +1,8 @@ import { CollectionOptions } from '@nocobase/database'; export default { + namespace: 'collection-manager', + duplicator: 'required', name: 'fields', autoGenId: false, model: 'FieldModel', diff --git a/packages/plugins/collection-manager/src/migrations/20221121111113-update-id-to-bigint.ts b/packages/plugins/collection-manager/src/migrations/20221121111113-update-id-to-bigint.ts index 1e7f2d397..48e7cabbe 100644 --- a/packages/plugins/collection-manager/src/migrations/20221121111113-update-id-to-bigint.ts +++ b/packages/plugins/collection-manager/src/migrations/20221121111113-update-id-to-bigint.ts @@ -30,6 +30,7 @@ export default class UpdateIdToBigIntMigrator extends Migration { const queryGenerator = queryInterface.queryGenerator as any; const updateToBigInt = async (model, fieldName) => { + const columnName = model.rawAttributes[fieldName].field; let sql; const tableName = model.tableName; @@ -51,7 +52,7 @@ export default class UpdateIdToBigIntMigrator extends Migration { if (model.rawAttributes[fieldName].type instanceof DataTypes.INTEGER) { if (db.inDialect('postgres')) { - sql = `ALTER TABLE "${tableName}" ALTER COLUMN "${fieldName}" SET DATA TYPE BIGINT;`; + sql = `ALTER TABLE "${tableName}" ALTER COLUMN "${columnName}" SET DATA TYPE BIGINT;`; } else if (db.inDialect('mysql')) { const dataTypeOrOptions = model.rawAttributes[fieldName]; const attributeName = fieldName; @@ -68,6 +69,7 @@ export default class UpdateIdToBigIntMigrator extends Migration { table: tableName, }, ); + sql = queryGenerator.changeColumnQuery(tableName, query); sql = sql.replace(' PRIMARY KEY;', ' ;'); @@ -83,7 +85,7 @@ export default class UpdateIdToBigIntMigrator extends Migration { } if (db.inDialect('postgres')) { - const sequenceQuery = `SELECT pg_get_serial_sequence('"${model.tableName}"', '${fieldName}');`; + const sequenceQuery = `SELECT pg_get_serial_sequence('"${model.tableName}"', '${columnName}');`; const [result] = await this.sequelize.query(sequenceQuery, {}); const sequenceName = result[0]['pg_get_serial_sequence']; diff --git a/packages/plugins/collection-manager/src/models/field.ts b/packages/plugins/collection-manager/src/models/field.ts index 1ab04ed48..239a989b2 100644 --- a/packages/plugins/collection-manager/src/models/field.ts +++ b/packages/plugins/collection-manager/src/models/field.ts @@ -1,5 +1,5 @@ -import Database, { Collection, Field, MagicAttributeModel } from '@nocobase/database'; -import { SyncOptions, Transactionable, UniqueConstraintError } from 'sequelize'; +import Database, { Collection, MagicAttributeModel, snakeCase } from '@nocobase/database'; +import { SyncOptions, Transactionable } from 'sequelize'; interface LoadOptions extends Transactionable { // TODO @@ -10,58 +10,6 @@ interface MigrateOptions extends SyncOptions, Transactionable { isNew?: boolean; } -async function migrate(field: Field, options: MigrateOptions): Promise { - const { unique } = field.options; - const { model } = field.collection; - - const ukName = `${model.tableName}_${field.name}_uk`; - const queryInterface = model.sequelize.getQueryInterface(); - const fieldAttribute = model.rawAttributes[field.name]; - - // @ts-ignore - const existedConstraints = (await queryInterface.showConstraint(model.tableName, ukName, { - transaction: options.transaction, - })) as any[]; - - const constraintBefore = existedConstraints.find((item) => item.constraintName === ukName); - - if (typeof fieldAttribute?.unique !== 'undefined') { - if (constraintBefore && !unique) { - await queryInterface.removeConstraint(model.tableName, ukName, { transaction: options.transaction }); - } - - fieldAttribute.unique = Boolean(constraintBefore); - } - - await field.sync(options); - - if (!constraintBefore && unique) { - await queryInterface.addConstraint(model.tableName, { - type: 'unique', - fields: [field.name], - name: ukName, - transaction: options.transaction, - }); - } - - if (typeof fieldAttribute?.unique !== 'undefined') { - fieldAttribute.unique = unique; - } - - // @ts-ignore - const updatedConstraints = (await queryInterface.showConstraint(model.tableName, ukName, { - transaction: options.transaction, - })) as any[]; - - const indexAfter = updatedConstraints.find((item) => item.constraintName === ukName); - - if (unique && !indexAfter) { - throw new UniqueConstraintError({ - fields: { [field.name]: undefined }, - }); - } -} - export class FieldModel extends MagicAttributeModel { get db(): Database { return (this.constructor).database; @@ -106,10 +54,24 @@ export class FieldModel extends MagicAttributeModel { field = await this.load({ transaction: options.transaction, }); + if (!field) { return; } - await migrate(field, options); + const collection = this.getFieldCollection(); + + if (isNew && collection.model.rawAttributes[this.get('name')] && this.get('unique')) { + // trick: set unique to false to avoid auto sync unique index + collection.model.rawAttributes[this.get('name')].unique = false; + } + + await field.sync(options); + + if (isNew && this.get('unique')) { + await this.syncUniqueIndex({ + transaction: options.transaction, + }); + } } catch (error) { // field sync failed, delete from memory if (isNew && field) { @@ -136,6 +98,56 @@ export class FieldModel extends MagicAttributeModel { }); } + async syncUniqueIndex(options: Transactionable) { + const unique = this.get('unique'); + const collection = this.getFieldCollection(); + const field = collection.getField(this.get('name')); + const columnName = collection.model.rawAttributes[this.get('name')].field; + const tableName = collection.model.tableName; + + const queryInterface = this.db.sequelize.getQueryInterface() as any; + + const existsIndexes = await queryInterface.showIndex(collection.model.tableName, { + transaction: options.transaction, + }); + + const existUniqueIndex = existsIndexes.find((item) => { + return item.unique && item.fields[0].attribute === columnName && item.fields.length === 1; + }); + + let existsUniqueConstraint; + + let constraintName = `${tableName}_${field.name}_uk`; + + if (existUniqueIndex) { + const existsUniqueConstraints = await queryInterface.showConstraint( + collection.model.tableName, + constraintName, + {}, + ); + + existsUniqueConstraint = existsUniqueConstraints[0]; + } + + if (unique && !existsUniqueConstraint) { + // @ts-ignore + await collection.sync({ ...options, force: false, alter: { drop: false } }); + + await queryInterface.addConstraint(tableName, { + type: 'unique', + fields: [columnName], + name: constraintName, + transaction: options.transaction, + }); + } + + if (!unique && existsUniqueConstraint) { + await queryInterface.removeConstraint(collection.model.tableName, constraintName, { + transaction: options.transaction, + }); + } + } + async syncDefaultValue( options: Transactionable & { defaultValue: any; @@ -153,7 +165,7 @@ export class FieldModel extends MagicAttributeModel { await queryInterface.changeColumn( collection.model.tableName, - this.get('name'), + collection.model.rawAttributes[this.get('name')].field, { type: field.dataType, defaultValue: options.defaultValue, diff --git a/packages/plugins/collection-manager/src/server.ts b/packages/plugins/collection-manager/src/server.ts index fd0796918..0c064edd5 100644 --- a/packages/plugins/collection-manager/src/server.ts +++ b/packages/plugins/collection-manager/src/server.ts @@ -5,6 +5,7 @@ import { UniqueConstraintError } from 'sequelize'; import PluginErrorHandler from '@nocobase/plugin-error-handler'; import { Plugin } from '@nocobase/server'; +import { Mutex } from 'async-mutex'; import { CollectionRepository } from '.'; import { @@ -122,7 +123,7 @@ export class CollectionManagerPlugin extends Plugin { const next = currentOptions['unique']; if (Boolean(prev) !== Boolean(next)) { - await model.migrate({ transaction }); + await model.syncUniqueIndex({ transaction }); } } @@ -149,8 +150,12 @@ export class CollectionManagerPlugin extends Plugin { // before field remove this.app.db.on('fields.beforeDestroy', beforeDestroyForeignKey(this.app.db)); + + const mutex = new Mutex(); this.app.db.on('fields.beforeDestroy', async (model: FieldModel, options) => { - await model.remove(options); + await mutex.runExclusive(async () => { + await model.remove(options); + }); }); this.app.db.on('collections.beforeDestroy', async (model: CollectionModel, options) => { @@ -245,6 +250,12 @@ export class CollectionManagerPlugin extends Plugin { } await next(); }); + + this.app.db.extendCollection({ + name: 'collectionCategory', + namespace: 'collection-manager', + duplicator: 'required', + }); } } diff --git a/packages/plugins/duplicator/README.md b/packages/plugins/duplicator/README.md new file mode 100644 index 000000000..3ebd07f18 --- /dev/null +++ b/packages/plugins/duplicator/README.md @@ -0,0 +1,118 @@ +# Duplicator + +English | [中文](./README.zh-CN.md) + +NocoBase 应用的备份与还原插件,可用于应用的复制、迁移、升级等场景。 + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 + +Duplicator 插件提供了 `dump` 和 `restore` 命令,分别用于备份和还原应用数据,可用于单应用的备份和还原,也可以跨应用。如果跨应用还原数据,请保证目标应用 NocoBase 版本与源应用一致,相对应插件也已下载本地。 + +**⚠️ 如果使用了继承(PostgreSQL)、视图、触发器等不兼容的特性,跨数据库还原备份数据可能失败。** + +### 备份数据 + +```bash +yarn nocobase dump +``` + +选择需要备份的插件表结构及其数据 + +```bash +? Select the plugin collections to be dumped (Press to select, to toggle all, to invert selection, and to proceed) + == Required == + - migration (core) (Disabled) + - collections (collection-manager) (Disabled) + - uiSchemas (ui-schema-storage) (Disabled) + - uiRoutes (ui-routes-storage) (Disabled) + - acl (acl) (Disabled) + - workflowConfig (workflow) (Disabled) + - snapshot-field (snapshot-field) (Disabled) + - sequences (sequence-field) (Disabled) + == Optional == +❯◉ executionLogs (workflow) + ◉ users (users) + ◉ storageSetting (file-manager) + ◉ attachmentRecords (file-manager) + ◉ systemSettings (system-settings) + ◉ verificationProviders (verification) + ◉ verificationData (verification) + ◉ oidcProviders (oidc) + ◉ samlProviders (saml) + ◉ mapConfiguration (map) +(Move up and down to reveal more choices) +``` + +选择需要备份的其他数据表的记录 + +```bash +? Select the collection records to be dumped (Press to select, to toggle all, to invert selection, and to proceed) +❯◉ Test1 +❯◉ Test2 +❯◉ Test3 +``` + +数据备份成功之后,备份文件位于 `storage/duplicator` 目录下: + +```bash +dumped to /your/apps/a/storage/duplicator/dump-20230210T223910.nbdump +dumped file size: 20.8 kB +``` + +### 还原数据 + +```bash +yarn nocobase restore /your/apps/a/storage/duplicator/dump-20230210T223910.nbdump +``` + +导入前请先备份数据 + +```bash +? Danger !!! This action will overwrite your current data, please make sure you have a backup❗️❗️ (y/N) +``` + +选择需要还原的插件表结构及其数据 + +```bash +? Select the plugin collections to be restored (Press to select, to toggle all, to invert selection, and to proceed) + == Required == + - migration (core) (Disabled) + - collections (collection-manager) (Disabled) + - uiSchemas (ui-schema-storage) (Disabled) + - uiRoutes (ui-routes-storage) (Disabled) + - acl (acl) (Disabled) + - workflowConfig (workflow) (Disabled) + - sequences (sequence-field) (Disabled) + == Optional == +❯◯ executionLogs (workflow) + ◯ users (users) + ◯ storageSetting (file-manager) + ◯ attachmentRecords (file-manager) + ◯ systemSettings (system-settings) + ◯ verificationProviders (verification) + ◯ verificationData (verification) + ◯ auditLogs (audit-logs) + ◯ iframe html storage (iframe-block) +``` + +选择需要还原的其他数据表的记录 + +```bash +? Select the collection records to be restored (Press to select, to toggle all, to invert selection, and to proceed) +❯◉ Test1 +❯◉ Test2 +❯◉ Test3 +``` + +成功之后,重启应用 + +```bash +# for development +yarn dev +# for production +yarn start +``` diff --git a/packages/plugins/duplicator/README.zh-CN.md b/packages/plugins/duplicator/README.zh-CN.md new file mode 100644 index 000000000..2deed9c0f --- /dev/null +++ b/packages/plugins/duplicator/README.zh-CN.md @@ -0,0 +1,118 @@ +# Duplicator + +[English](./README.md) | 中文 + +NocoBase 应用的备份与还原插件,可用于应用的复制、迁移、升级等场景。 + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 + +Duplicator 插件提供了 `dump` 和 `restore` 命令,分别用于备份和还原应用数据,可用于单应用的备份和还原,也可以跨应用。如果跨应用还原数据,请保证目标应用 NocoBase 版本与源应用一致,相对应插件也已下载本地。 + +**⚠️ 如果使用了继承(PostgreSQL)、视图、触发器等不兼容的特性,跨数据库还原备份数据可能失败。** + +### 备份数据 + +```bash +yarn nocobase dump +``` + +选择需要备份的插件表结构及其数据 + +```bash +? Select the plugin collections to be dumped (Press to select, to toggle all, to invert selection, and to proceed) + == Required == + - migration (core) (Disabled) + - collections (collection-manager) (Disabled) + - uiSchemas (ui-schema-storage) (Disabled) + - uiRoutes (ui-routes-storage) (Disabled) + - acl (acl) (Disabled) + - workflowConfig (workflow) (Disabled) + - snapshot-field (snapshot-field) (Disabled) + - sequences (sequence-field) (Disabled) + == Optional == +❯◉ executionLogs (workflow) + ◉ users (users) + ◉ storageSetting (file-manager) + ◉ attachmentRecords (file-manager) + ◉ systemSettings (system-settings) + ◉ verificationProviders (verification) + ◉ verificationData (verification) + ◉ oidcProviders (oidc) + ◉ samlProviders (saml) + ◉ mapConfiguration (map) +(Move up and down to reveal more choices) +``` + +选择需要备份的其他数据表的记录 + +```bash +? Select the collection records to be dumped (Press to select, to toggle all, to invert selection, and to proceed) +❯◉ Test1 +❯◉ Test2 +❯◉ Test3 +``` + +数据备份成功之后,备份文件位于 `storage/duplicator` 目录下: + +```bash +dumped to /your/apps/a/storage/duplicator/dump-20230210T223910.nbdump +dumped file size: 20.8 kB +``` + +### 还原数据 + +```bash +yarn nocobase restore /your/apps/a/storage/duplicator/dump-20230210T223910.nbdump +``` + +导入前请先备份数据 + +```bash +? Danger !!! This action will overwrite your current data, please make sure you have a backup❗️❗️ (y/N) +``` + +选择需要还原的插件表结构及其数据 + +```bash +? Select the plugin collections to be restored (Press to select, to toggle all, to invert selection, and to proceed) + == Required == + - migration (core) (Disabled) + - collections (collection-manager) (Disabled) + - uiSchemas (ui-schema-storage) (Disabled) + - uiRoutes (ui-routes-storage) (Disabled) + - acl (acl) (Disabled) + - workflowConfig (workflow) (Disabled) + - sequences (sequence-field) (Disabled) + == Optional == +❯◯ executionLogs (workflow) + ◯ users (users) + ◯ storageSetting (file-manager) + ◯ attachmentRecords (file-manager) + ◯ systemSettings (system-settings) + ◯ verificationProviders (verification) + ◯ verificationData (verification) + ◯ auditLogs (audit-logs) + ◯ iframe html storage (iframe-block) +``` + +选择需要还原的其他数据表的记录 + +```bash +? Select the collection records to be restored (Press to select, to toggle all, to invert selection, and to proceed) +❯◉ Test1 +❯◉ Test2 +❯◉ Test3 +``` + +成功之后,重启应用 + +```bash +# for development +yarn dev +# for production +yarn start +``` diff --git a/packages/plugins/duplicator/src/server/__tests__/dump.test.ts b/packages/plugins/duplicator/src/server/__tests__/dump.test.ts index b6c119b41..cb532bdb2 100644 --- a/packages/plugins/duplicator/src/server/__tests__/dump.test.ts +++ b/packages/plugins/duplicator/src/server/__tests__/dump.test.ts @@ -1,5 +1,5 @@ import { mockServer, MockServer } from '@nocobase/test'; -import { Database } from '@nocobase/database'; +import { Database, Model } from '@nocobase/database'; import * as os from 'os'; import path from 'path'; import lodash from 'lodash'; @@ -98,7 +98,11 @@ describe('dump', () => { const collectionMeta = JSON.parse(collectionMetaFile); expect(collectionMeta.count).toEqual(2); - expect(collectionMeta.columns).toEqual(Object.keys(db.getCollection('users').model.rawAttributes)); + expect(collectionMeta.columns).toEqual( + Object.keys(db.getCollection('users').model.rawAttributes).map( + (fieldName) => db.getCollection('users').model.rawAttributes[fieldName].field, + ), + ); const dataPath = path.resolve(testDir, 'collections', 'users', 'data'); diff --git a/packages/plugins/duplicator/src/server/app-migrator.ts b/packages/plugins/duplicator/src/server/app-migrator.ts index c0cd8428c..9554e4ef1 100644 --- a/packages/plugins/duplicator/src/server/app-migrator.ts +++ b/packages/plugins/duplicator/src/server/app-migrator.ts @@ -1,12 +1,12 @@ import { Application } from '@nocobase/server'; +import { applyMixins, AsyncEmitter } from '@nocobase/utils'; +import crypto from 'crypto'; +import EventEmitter from 'events'; +import fsPromises from 'fs/promises'; +import inquirer from 'inquirer'; +import lodash from 'lodash'; import * as os from 'os'; import path from 'path'; -import lodash from 'lodash'; -import fsPromises from 'fs/promises'; -import crypto from 'crypto'; -import inquirer from 'inquirer'; -import EventEmitter from 'events'; -import { applyMixins, AsyncEmitter, requireModule } from '@nocobase/utils'; abstract class AppMigrator extends EventEmitter { protected workDir: string; @@ -64,11 +64,11 @@ abstract class AppMigrator extends EventEmitter { return { type: 'checkbox', name: 'collectionGroups', - message: `选择需要${this.direction}的插件数据`, + message: `Select the plugin collections to be ${this.direction === 'dump' ? 'dumped' : 'restored'}`, loop: false, pageSize: 20, choices: [ - new inquirer.Separator('== 必选数据 =='), + new inquirer.Separator('== Required =='), ...requiredGroups.map((collectionGroup) => ({ name: `${collectionGroup.function} (${collectionGroup.pluginName})`, value: `${collectionGroup.pluginName}.${collectionGroup.function}`, @@ -76,7 +76,7 @@ abstract class AppMigrator extends EventEmitter { disabled: true, })), - new inquirer.Separator('== 可选数据 =='), + new inquirer.Separator('== Optional =='), ...optionalGroups.map((collectionGroup) => ({ name: `${collectionGroup.function} (${collectionGroup.pluginName})`, value: `${collectionGroup.pluginName}.${collectionGroup.function}`, @@ -95,7 +95,7 @@ abstract class AppMigrator extends EventEmitter { return { type: 'checkbox', name: 'userCollections', - message: `选择需要${this.direction}的Collection数据`, + message: `Select the collection records to be ${this.direction === 'dump' ? 'dumped' : 'restored'}`, loop: false, pageSize: 30, choices: collections.map((collection) => { diff --git a/packages/plugins/duplicator/src/server/collection-group-manager.ts b/packages/plugins/duplicator/src/server/collection-group-manager.ts index a693adb80..8c02ac591 100644 --- a/packages/plugins/duplicator/src/server/collection-group-manager.ts +++ b/packages/plugins/duplicator/src/server/collection-group-manager.ts @@ -1,4 +1,3 @@ -import { Application } from '@nocobase/server'; import lodash from 'lodash'; import { Restorer } from './restorer'; @@ -70,7 +69,7 @@ CollectionGroupManager.registerCollectionGroup({ CollectionGroupManager.registerCollectionGroup({ pluginName: 'collection-manager', function: 'collections', - collections: ['collections', 'fields'], + collections: ['collections', 'fields', 'collectionCategories', 'collectionCategory'], dumpable: 'required', }); @@ -252,5 +251,5 @@ CollectionGroupManager.registerCollectionGroup({ pluginName: 'iframe-block', function: 'iframe html storage', collections: ['iframeHtml'], - dumpable: 'optional', + dumpable: 'required', }); diff --git a/packages/plugins/duplicator/src/server/restorer.ts b/packages/plugins/duplicator/src/server/restorer.ts index 2d20eb030..1414088b3 100644 --- a/packages/plugins/duplicator/src/server/restorer.ts +++ b/packages/plugins/duplicator/src/server/restorer.ts @@ -1,4 +1,5 @@ import decompress from 'decompress'; +import fs from 'fs'; import fsPromises from 'fs/promises'; import inquirer from 'inquirer'; import path from 'path'; @@ -6,15 +7,22 @@ import { AppMigrator } from './app-migrator'; import { CollectionGroupManager } from './collection-group-manager'; import { FieldValueWriter } from './field-value-writer'; import { readLines, sqlAdapter } from './utils'; -import fs from 'fs'; export class Restorer extends AppMigrator { direction = 'restore' as const; importedCollections: string[] = []; async restore(backupFilePath: string) { - const dirname = path.resolve(process.cwd(), 'storage', 'duplicator'); - const filePath = path.isAbsolute(backupFilePath) ? backupFilePath : path.resolve(dirname, backupFilePath); + let filePath: string; + + if (path.isAbsolute(backupFilePath)) { + filePath = backupFilePath; + } else if (path.basename(backupFilePath) === backupFilePath) { + const dirname = path.resolve(process.cwd(), 'storage', 'duplicator'); + filePath = path.resolve(dirname, backupFilePath); + } else { + filePath = path.resolve(process.cwd(), backupFilePath); + } const results = await inquirer.prompt([ { diff --git a/packages/plugins/duplicator/src/server/server.ts b/packages/plugins/duplicator/src/server/server.ts index 8bf96652a..bd5ebf9e2 100644 --- a/packages/plugins/duplicator/src/server/server.ts +++ b/packages/plugins/duplicator/src/server/server.ts @@ -11,4 +11,48 @@ export default class Duplicator extends Plugin { addDumpCommand(this.app); addRestoreCommand(this.app); } + + async load() { + this.app.resourcer.define({ + name: 'duplicator', + actions: { + getDict: async (ctx, next) => { + ctx.withoutDataWrapping = true; + let collectionNames = await this.db.getRepository('collections').find(); + collectionNames = collectionNames.map((item) => item.get('name')); + const collections: any[] = []; + for (const [name, collection] of this.db.collections) { + const columns: any[] = []; + for (const key in collection.model.rawAttributes) { + if (Object.prototype.hasOwnProperty.call(collection.model.rawAttributes, key)) { + const attribute = collection.model.rawAttributes[key]; + columns.push({ + realName: attribute.field, + name: key, + }); + } + } + const item = { + name, + title: collection.options.title, + namespace: collection.options.namespace, + duplicator: collection.options.duplicator, + // columns, + }; + if (!item.namespace && collectionNames.includes(name)) { + item.namespace = 'collection-manager'; + if (!item.duplicator) { + item.duplicator = 'optional'; + } + } + collections.push(item); + } + ctx.body = collections; + await next(); + }, + }, + }); + + this.app.acl.allow('duplicator', 'getDict'); + } } diff --git a/packages/plugins/error-handler/README.md b/packages/plugins/error-handler/README.md new file mode 100644 index 000000000..5a9532ed6 --- /dev/null +++ b/packages/plugins/error-handler/README.md @@ -0,0 +1,9 @@ +# error-handler + +English | [中文](./README.zh-CN.md) + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/error-handler/README.zh-CN.md b/packages/plugins/error-handler/README.zh-CN.md new file mode 100644 index 000000000..1537b4899 --- /dev/null +++ b/packages/plugins/error-handler/README.zh-CN.md @@ -0,0 +1,9 @@ +# error-handler + +[English](./README.md) | 中文 + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/excel-formula-field/README.md b/packages/plugins/excel-formula-field/README.md new file mode 100644 index 000000000..8b0979dd3 --- /dev/null +++ b/packages/plugins/excel-formula-field/README.md @@ -0,0 +1,9 @@ +# excel-formula-field + +English | [中文](./README.zh-CN.md) + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/excel-formula-field/README.zh-CN.md b/packages/plugins/excel-formula-field/README.zh-CN.md new file mode 100644 index 000000000..4171af5ea --- /dev/null +++ b/packages/plugins/excel-formula-field/README.zh-CN.md @@ -0,0 +1,9 @@ +# excel-formula-field + +[English](./README.md) | 中文 + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/export/README.md b/packages/plugins/export/README.md new file mode 100644 index 000000000..454084231 --- /dev/null +++ b/packages/plugins/export/README.md @@ -0,0 +1,9 @@ +# export + +English | [中文](./README.zh-CN.md) + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/export/README.zh-CN.md b/packages/plugins/export/README.zh-CN.md new file mode 100644 index 000000000..3e5e641b2 --- /dev/null +++ b/packages/plugins/export/README.zh-CN.md @@ -0,0 +1,9 @@ +# Export + +[English](./README.md) | 中文 + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/file-manager/README.md b/packages/plugins/file-manager/README.md new file mode 100644 index 000000000..36ca419eb --- /dev/null +++ b/packages/plugins/file-manager/README.md @@ -0,0 +1,9 @@ +# file-manager + +English | [中文](./README.zh-CN.md) + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/file-manager/README.zh-CN.md b/packages/plugins/file-manager/README.zh-CN.md new file mode 100644 index 000000000..4fe1a5a48 --- /dev/null +++ b/packages/plugins/file-manager/README.zh-CN.md @@ -0,0 +1,9 @@ +# file-manager + +[English](./README.md) | 中文 + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/file-manager/src/client/StorageOptions.tsx b/packages/plugins/file-manager/src/client/StorageOptions.tsx index 218c7a144..cc6fdddd6 100644 --- a/packages/plugins/file-manager/src/client/StorageOptions.tsx +++ b/packages/plugins/file-manager/src/client/StorageOptions.tsx @@ -13,12 +13,6 @@ const schema = { 'x-component': 'Input', required: true, }, - path: { - title: '{{t("Path")}}', - type: 'string', - 'x-decorator': 'FormItem', - 'x-component': 'Input', - }, serve: { type: 'string', 'x-decorator': 'FormItem', @@ -58,12 +52,6 @@ const schema = { 'x-component': 'Input', required: true, }, - path: { - title: '{{t("Path")}}', - type: 'string', - 'x-decorator': 'FormItem', - 'x-component': 'Input', - }, }, }, 'tx-cos': { @@ -128,12 +116,6 @@ const schema = { 'x-component': 'Input', required: true, }, - path: { - title: '{{t("Path")}}', - type: 'string', - 'x-decorator': 'FormItem', - 'x-component': 'Input', - }, }, }, }; diff --git a/packages/plugins/file-manager/src/client/schemas/storage.ts b/packages/plugins/file-manager/src/client/schemas/storage.ts index 87368b89d..0c7e33912 100644 --- a/packages/plugins/file-manager/src/client/schemas/storage.ts +++ b/packages/plugins/file-manager/src/client/schemas/storage.ts @@ -54,6 +54,16 @@ const collection = { required: true, } as ISchema, }, + { + type: 'string', + name: 'path', + interface: 'input', + uiSchema: { + title: '{{t("Path")}}', + type: 'string', + 'x-component': 'Input', + } as ISchema + }, { type: 'boolean', name: 'default', @@ -162,6 +172,10 @@ export const storageSchema: ISchema = { type: 'object', 'x-component': 'StorageOptions', }, + path: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, default: { 'x-component': 'CollectionField', 'x-decorator': 'FormItem', @@ -207,7 +221,7 @@ export const storageSchema: ISchema = { useDataSource: '{{ cm.useDataSourceFromRAC }}', }, properties: { - column1: { + title: { type: 'void', 'x-decorator': 'Table.Column.Decorator', 'x-component': 'Table.Column', @@ -219,7 +233,7 @@ export const storageSchema: ISchema = { }, }, }, - column2: { + name: { type: 'void', 'x-decorator': 'Table.Column.Decorator', 'x-component': 'Table.Column', @@ -231,7 +245,7 @@ export const storageSchema: ISchema = { }, }, }, - column3: { + default: { type: 'void', 'x-decorator': 'Table.Column.Decorator', 'x-component': 'Table.Column', @@ -243,7 +257,7 @@ export const storageSchema: ISchema = { }, }, }, - column4: { + actions: { type: 'void', title: '{{t("Actions")}}', 'x-component': 'Table.Column', @@ -294,6 +308,10 @@ export const storageSchema: ISchema = { type: 'object', 'x-component': 'StorageOptions', }, + path: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, default: { title: '', 'x-component': 'CollectionField', diff --git a/packages/plugins/file-manager/src/server/__tests__/attachment.test.ts b/packages/plugins/file-manager/src/server/__tests__/attachment.test.ts new file mode 100644 index 000000000..8c529eb70 --- /dev/null +++ b/packages/plugins/file-manager/src/server/__tests__/attachment.test.ts @@ -0,0 +1,50 @@ +import { getApp } from '.'; + +describe('attachment', () => { + let db; + let app; + + beforeEach(async () => { + app = await getApp(); + db = app.db; + }); + + afterEach(async () => { + await app.destroy(); + }); + + it('should linked to a instance', async () => { + const testCollection = db.collection({ + name: 'test', + fields: [ + { + name: 'name', + type: 'string', + }, + { + type: 'belongsTo', + name: 'logo', + target: 'attachments', + }, + ], + }); + + await db.sync(); + + await testCollection.repository.create({ + values: { + name: 'test', + logo: { + title: 'nocobase-logo', + filename: '682e5ad037dd02a0fe4800a3e91c283b.png', + extname: '.png', + mimetype: 'image/png', + url: 'https://nocobase.oss-cn-beijing.aliyuncs.com/682e5ad037dd02a0fe4800a3e91c283b.png', + }, + }, + }); + + const item = await testCollection.repository.findOne({}); + expect(item.get('logoId')).toBeDefined(); + }); +}); diff --git a/packages/plugins/file-manager/src/server/__tests__/index.ts b/packages/plugins/file-manager/src/server/__tests__/index.ts index 67e49fed5..d7b1853b3 100644 --- a/packages/plugins/file-manager/src/server/__tests__/index.ts +++ b/packages/plugins/file-manager/src/server/__tests__/index.ts @@ -13,6 +13,8 @@ export async function getApp(options = {}): Promise { acl: false, }); + await app.cleanDb(); + app.plugin(plugin); app.db.import({ diff --git a/packages/plugins/file-manager/src/server/collections/attachments.ts b/packages/plugins/file-manager/src/server/collections/attachments.ts index b3d570068..aa2f461eb 100644 --- a/packages/plugins/file-manager/src/server/collections/attachments.ts +++ b/packages/plugins/file-manager/src/server/collections/attachments.ts @@ -1,6 +1,8 @@ import { CollectionOptions } from '@nocobase/database'; export default { + namespace: 'file-manager', + duplicator: 'optional', name: 'attachments', title: '文件管理器', createdBy: true, diff --git a/packages/plugins/file-manager/src/server/collections/storages.ts b/packages/plugins/file-manager/src/server/collections/storages.ts index 979c8a242..029cb3dd8 100644 --- a/packages/plugins/file-manager/src/server/collections/storages.ts +++ b/packages/plugins/file-manager/src/server/collections/storages.ts @@ -1,6 +1,8 @@ import { CollectionOptions } from '@nocobase/database'; export default { + namespace: 'file-manager', + duplicator: 'optional', name: 'storages', title: '存储引擎', fields: [ diff --git a/packages/plugins/graph-collection-manager/README.md b/packages/plugins/graph-collection-manager/README.md new file mode 100644 index 000000000..9dc8a57f1 --- /dev/null +++ b/packages/plugins/graph-collection-manager/README.md @@ -0,0 +1,11 @@ +# graph-collection-manager + +English | [中文](./README.zh-CN.md) + +## 安装激活 + +```bash +yarn pm enable graph-collection-manager +``` + +## 使用方法 diff --git a/packages/plugins/graph-collection-manager/README.zh-CN.md b/packages/plugins/graph-collection-manager/README.zh-CN.md new file mode 100644 index 000000000..e36cca20a --- /dev/null +++ b/packages/plugins/graph-collection-manager/README.zh-CN.md @@ -0,0 +1,11 @@ +# graph-collection-manager + +[English](./README.md) | 中文 + +## 安装激活 + +```bash +yarn pm enable graph-collection-manager +``` + +## 使用方法 diff --git a/packages/plugins/graph-collection-manager/src/client/components/Entity.tsx b/packages/plugins/graph-collection-manager/src/client/components/Entity.tsx index 60b89b07f..000bcb415 100644 --- a/packages/plugins/graph-collection-manager/src/client/components/Entity.tsx +++ b/packages/plugins/graph-collection-manager/src/client/components/Entity.tsx @@ -22,7 +22,7 @@ import { useCollectionManager, useCompile, useCurrentAppInfo, - useRecord + useRecord, } from '@nocobase/client'; import { Badge, Dropdown, Popover, Tag } from 'antd'; import { groupBy } from 'lodash'; @@ -33,7 +33,7 @@ import { useDestroyActionAndRefreshCM, useDestroyFieldActionAndRefreshCM, useUpdateCollectionActionAndRefreshCM, - useValuesFromRecord + useValuesFromRecord, } from '../action-hooks'; import { collectiionPopoverClass, entityContainer, headClass, tableBtnClass, tableNameClass } from '../style'; import { useGCMTranslation } from '../utils'; @@ -60,6 +60,7 @@ const Entity: React.FC<{ const database = useCurrentAppInfo(); const collectionData = useRef(); collectionData.current = { ...item, title, inherits: item.inherits && new Proxy(item.inherits, {}) }; + const { category } = item; const compile = useCompile(); const loadCollections = async (field: any) => { return targetGraph.collections?.map((collection: any) => ({ @@ -79,8 +80,21 @@ const Entity: React.FC<{ className={cx(entityContainer)} style={{ boxShadow: attrs?.boxShadow, border: select ? '2px dashed #f5a20a' : 0 }} > -
+ {category.map((v, index) => { + return ( + + ); + })} +
{compile(title)} +
@@ -154,6 +168,7 @@ const Entity: React.FC<{
+
); diff --git a/packages/plugins/graph-collection-manager/src/client/style.tsx b/packages/plugins/graph-collection-manager/src/client/style.tsx index b0ff32195..d95f100b0 100644 --- a/packages/plugins/graph-collection-manager/src/client/style.tsx +++ b/packages/plugins/graph-collection-manager/src/client/style.tsx @@ -128,7 +128,7 @@ export const entityContainer = css` `; export const headClass = css` - height: 40px; + height: 50px; font-size: 14px; font-weight: 500; display: flex; diff --git a/packages/plugins/graph-collection-manager/src/server/collections/graphPositions.ts b/packages/plugins/graph-collection-manager/src/server/collections/graphPositions.ts index 1b7ad4116..3ca8ba30c 100644 --- a/packages/plugins/graph-collection-manager/src/server/collections/graphPositions.ts +++ b/packages/plugins/graph-collection-manager/src/server/collections/graphPositions.ts @@ -1,6 +1,8 @@ import { defineCollection } from '@nocobase/database'; export default defineCollection({ + namespace: 'graph-collection-manager', + duplicator: 'required', name: 'graphPositions', fields: [ { diff --git a/packages/plugins/iframe-block/README.md b/packages/plugins/iframe-block/README.md new file mode 100644 index 000000000..490d73457 --- /dev/null +++ b/packages/plugins/iframe-block/README.md @@ -0,0 +1,11 @@ +# iframe-block + +English | [中文](./README.zh-CN.md) + +Iframe 区块插件。 + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/iframe-block/README.zh-CN.md b/packages/plugins/iframe-block/README.zh-CN.md new file mode 100644 index 000000000..2b000415f --- /dev/null +++ b/packages/plugins/iframe-block/README.zh-CN.md @@ -0,0 +1,11 @@ +# iframe-block + +[English](./README.md) | 中文 + +Iframe 区块插件。 + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/iframe-block/src/server/collections/iframe-html.ts b/packages/plugins/iframe-block/src/server/collections/iframe-html.ts index 0fca8db1a..6d5772449 100644 --- a/packages/plugins/iframe-block/src/server/collections/iframe-html.ts +++ b/packages/plugins/iframe-block/src/server/collections/iframe-html.ts @@ -1,6 +1,8 @@ import { CollectionOptions } from '@nocobase/database'; export default { + namespace: 'iframe-block', + duplicator: 'required', name: 'iframeHtml', createdBy: true, updatedBy: true, diff --git a/packages/plugins/import/README.md b/packages/plugins/import/README.md new file mode 100644 index 000000000..938722f9e --- /dev/null +++ b/packages/plugins/import/README.md @@ -0,0 +1,11 @@ +# import + +English | [中文](./README.zh-CN.md) + +Excel 数据导入插件。 + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/import/README.zh-CN.md b/packages/plugins/import/README.zh-CN.md new file mode 100644 index 000000000..ef02d799c --- /dev/null +++ b/packages/plugins/import/README.zh-CN.md @@ -0,0 +1,11 @@ +# import + +[English](./README.md) | 中文 + +Excel 数据导入插件。 + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/import/src/server/actions/importXlsx.ts b/packages/plugins/import/src/server/actions/importXlsx.ts index f74fe85bd..a1290925a 100644 --- a/packages/plugins/import/src/server/actions/importXlsx.ts +++ b/packages/plugins/import/src/server/actions/importXlsx.ts @@ -7,7 +7,7 @@ import { transform } from '../utils'; const IMPORT_LIMIT_COUNT = 10000; export async function importXlsx(ctx: Context, next: Next) { - let { columns } = ctx.request.body; + let { columns } = ctx.request.body as any; const { ['file']: file } = ctx; const { resourceName, resourceOf } = ctx.action; if (typeof columns === 'string') { diff --git a/packages/plugins/map/README.md b/packages/plugins/map/README.md new file mode 100644 index 000000000..d5fe6974a --- /dev/null +++ b/packages/plugins/map/README.md @@ -0,0 +1,13 @@ +# map + +English | [中文](./README.zh-CN.md) + +地图插件。 + +## 安装激活 + +```bash +yarn pm enable map +``` + +## 使用方法 diff --git a/packages/plugins/map/README.zh-CN.md b/packages/plugins/map/README.zh-CN.md new file mode 100644 index 000000000..a8b6938a5 --- /dev/null +++ b/packages/plugins/map/README.zh-CN.md @@ -0,0 +1,13 @@ +# map + +[English](./README.md) | 中文 + +地图插件。 + +## 安装激活 + +```bash +yarn pm enable map +``` + +## 使用方法 diff --git a/packages/plugins/map/src/server/collections/mapConfiguration.ts b/packages/plugins/map/src/server/collections/mapConfiguration.ts index 7089ec622..b0ff4a584 100644 --- a/packages/plugins/map/src/server/collections/mapConfiguration.ts +++ b/packages/plugins/map/src/server/collections/mapConfiguration.ts @@ -2,6 +2,8 @@ import { CollectionOptions } from "@nocobase/client"; import { MapConfigurationCollectionName } from "../constants"; export default { + namespace: 'map', + duplicator: 'optional', name: MapConfigurationCollectionName, title: '{{t("Map Manager")}}', fields: [ diff --git a/packages/plugins/math-formula-field/README.md b/packages/plugins/math-formula-field/README.md new file mode 100644 index 000000000..09eeec113 --- /dev/null +++ b/packages/plugins/math-formula-field/README.md @@ -0,0 +1,9 @@ +# math-formula-field + +English | [中文](./README.zh-CN.md) + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/math-formula-field/README.zh-CN.md b/packages/plugins/math-formula-field/README.zh-CN.md new file mode 100644 index 000000000..d408567ed --- /dev/null +++ b/packages/plugins/math-formula-field/README.zh-CN.md @@ -0,0 +1,9 @@ +# math-formula-field + +[English](./README.md) | 中文 + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/multi-app-manager/README.md b/packages/plugins/multi-app-manager/README.md new file mode 100644 index 000000000..910ff8081 --- /dev/null +++ b/packages/plugins/multi-app-manager/README.md @@ -0,0 +1,13 @@ +# multi-app-manager + +English | [中文](./README.zh-CN.md) + +多应用管理插件。 + +## 安装激活 + +```bash +yarn pm enable multi-app-manager +``` + +## 使用方法 diff --git a/packages/plugins/multi-app-manager/README.zh-CN.md b/packages/plugins/multi-app-manager/README.zh-CN.md new file mode 100644 index 000000000..c220fc200 --- /dev/null +++ b/packages/plugins/multi-app-manager/README.zh-CN.md @@ -0,0 +1,13 @@ +# multi-app-manager + +[English](./README.md) | 中文 + +多应用管理插件。 + +## 安装激活 + +```bash +yarn pm enable multi-app-manager +``` + +## 使用方法 diff --git a/packages/plugins/multi-app-manager/src/collections/applications.ts b/packages/plugins/multi-app-manager/src/collections/applications.ts index e98ac6e17..338879828 100644 --- a/packages/plugins/multi-app-manager/src/collections/applications.ts +++ b/packages/plugins/multi-app-manager/src/collections/applications.ts @@ -1,6 +1,8 @@ import { defineCollection } from '@nocobase/database'; export default defineCollection({ + namespace: 'multi-app-manager', + duplicator: 'optional', name: 'applications', model: 'ApplicationModel', autoGenId: false, diff --git a/packages/plugins/notifications/README.md b/packages/plugins/notifications/README.md new file mode 100644 index 000000000..a91904239 --- /dev/null +++ b/packages/plugins/notifications/README.md @@ -0,0 +1,9 @@ +# notifications + +English | [中文](./README.zh-CN.md) + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/notifications/README.zh-CN.md b/packages/plugins/notifications/README.zh-CN.md new file mode 100644 index 000000000..981a48b5d --- /dev/null +++ b/packages/plugins/notifications/README.zh-CN.md @@ -0,0 +1,9 @@ +# notifications + +[English](./README.md) | 中文 + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/oidc/README.md b/packages/plugins/oidc/README.md new file mode 100644 index 000000000..10ecf5224 --- /dev/null +++ b/packages/plugins/oidc/README.md @@ -0,0 +1,11 @@ +# oidc + +English | [中文](./README.zh-CN.md) + +## 安装激活 + +```bash +yarn pm enable oidc +``` + +## 使用方法 diff --git a/packages/plugins/oidc/README.zh-CN.md b/packages/plugins/oidc/README.zh-CN.md new file mode 100644 index 000000000..c7ef8987b --- /dev/null +++ b/packages/plugins/oidc/README.zh-CN.md @@ -0,0 +1,11 @@ +# OIDC + +[English](./README.md) | 中文 + +## 安装激活 + +```bash +yarn pm enable oidc +``` + +## 使用方法 diff --git a/packages/plugins/oidc/src/server/collections/oidcProviders.ts b/packages/plugins/oidc/src/server/collections/oidcProviders.ts index bf51755ca..b0091ec83 100644 --- a/packages/plugins/oidc/src/server/collections/oidcProviders.ts +++ b/packages/plugins/oidc/src/server/collections/oidcProviders.ts @@ -1,6 +1,8 @@ import { CollectionOptions } from '@nocobase/database'; export default { + namespace: 'oidc', + duplicator: 'optional', name: 'oidcProviders', title: '{{t("OIDC Providers")}}', fields: [ diff --git a/packages/plugins/saml/README.md b/packages/plugins/saml/README.md new file mode 100644 index 000000000..3ec0e4bb7 --- /dev/null +++ b/packages/plugins/saml/README.md @@ -0,0 +1,11 @@ +# SAML + +English | [中文](./README.zh-CN.md) + +## 安装激活 + +```bash +yarn pm enable saml +``` + +## 使用方法 diff --git a/packages/plugins/saml/README.zh-CN.md b/packages/plugins/saml/README.zh-CN.md new file mode 100644 index 000000000..8121817e3 --- /dev/null +++ b/packages/plugins/saml/README.zh-CN.md @@ -0,0 +1,11 @@ +# SAML + +[English](./README.md) | 中文 + +## 安装激活 + +```bash +yarn pm enable saml +``` + +## 使用方法 diff --git a/packages/plugins/saml/src/server/collections/samlProviders.ts b/packages/plugins/saml/src/server/collections/samlProviders.ts index 9545689f2..29730c495 100644 --- a/packages/plugins/saml/src/server/collections/samlProviders.ts +++ b/packages/plugins/saml/src/server/collections/samlProviders.ts @@ -1,6 +1,8 @@ import { CollectionOptions } from '@nocobase/database'; export default { + namespace: 'saml', + duplicator: 'optional', name: 'samlProviders', title: '{{t("SAML Providers")}}', fields: [ diff --git a/packages/plugins/sequence-field/README.md b/packages/plugins/sequence-field/README.md new file mode 100644 index 000000000..563158027 --- /dev/null +++ b/packages/plugins/sequence-field/README.md @@ -0,0 +1,9 @@ +# sequence-field + +English | [中文](./README.zh-CN.md) + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/sequence-field/README.zh-CN.md b/packages/plugins/sequence-field/README.zh-CN.md new file mode 100644 index 000000000..97ab1000d --- /dev/null +++ b/packages/plugins/sequence-field/README.zh-CN.md @@ -0,0 +1,9 @@ +# sequence-field + +[English](./README.md) | 中文 + +## 安装激活 + +内置插件无需手动安装激活。 + +## 使用方法 diff --git a/packages/plugins/sequence-field/src/server/__tests__/sequence-field.test.ts b/packages/plugins/sequence-field/src/server/__tests__/sequence-field.test.ts index a271b1fd9..0c7466f84 100644 --- a/packages/plugins/sequence-field/src/server/__tests__/sequence-field.test.ts +++ b/packages/plugins/sequence-field/src/server/__tests__/sequence-field.test.ts @@ -764,6 +764,7 @@ describe('sequence field', () => { type: 'sequence', name: 'seq', patterns: [ + { type: 'date' }, { type: 'integer', options: { key: 1 } } ] } @@ -800,6 +801,9 @@ describe('sequence field', () => { await db.sync(); + const now = new Date(); + const dateStr = moment(now).format('YYYYMMDD'); + const tagsRepo = db.getRepository('tags'); const tags = await tagsRepo.create({ values: [ @@ -828,9 +832,9 @@ describe('sequence field', () => { order: [['seq', 'ASC']] }); - expect(postsTags[0].seq).toBe('0'); - expect(postsTags[1].seq).toBe('1'); - expect(postsTags[2].seq).toBe('2'); + expect(postsTags[0].seq).toBe(`${dateStr}0`); + expect(postsTags[1].seq).toBe(`${dateStr}1`); + expect(postsTags[2].seq).toBe(`${dateStr}2`); }); }); }); diff --git a/packages/plugins/sequence-field/src/server/collections/sequences.ts b/packages/plugins/sequence-field/src/server/collections/sequences.ts index 38bb15b55..e49f3978d 100644 --- a/packages/plugins/sequence-field/src/server/collections/sequences.ts +++ b/packages/plugins/sequence-field/src/server/collections/sequences.ts @@ -1,4 +1,6 @@ export default { + namespace: 'sequence-field', + duplicator: 'required', name: 'sequences', fields: [ { diff --git a/packages/plugins/sequence-field/src/server/fields/sequence-field.ts b/packages/plugins/sequence-field/src/server/fields/sequence-field.ts index e30c8d85c..f4a792ab6 100644 --- a/packages/plugins/sequence-field/src/server/fields/sequence-field.ts +++ b/packages/plugins/sequence-field/src/server/fields/sequence-field.ts @@ -65,7 +65,7 @@ sequencePatterns.register('integer', { // return null; // }, async generate(this: SequenceField, instance: Model, options, { transaction }) { - const recordTime = instance.get('createdAt'); + const recordTime = instance.get('createdAt') ?? new Date(); const { digits = 1, start = 0, base = 10, cycle, key } = options; const { repository: SeqRepo, model: SeqModel } = this.database.getCollection('sequences'); const lastSeq = (await SeqRepo.findOne({ @@ -137,7 +137,7 @@ sequencePatterns.register('integer', { }); instances.forEach((instance, i) => { - const recordTime = instance.get('createdAt'); + const recordTime = instance.get('createdAt') ?? new Date(); const value = instance.get(name); if (value != null && this.options.inputable) { const matcher = this.match(value); @@ -192,7 +192,7 @@ sequencePatterns.register('integer', { }, async update(instance, value, options, { transaction }) { - const recordTime = instance.get('createdAt'); + const recordTime = instance.get('createdAt') ?? new Date(); const { digits = 1, start = 0, base = 10, cycle, key } = options; const SeqRepo = this.database.getRepository('sequences'); const lastSeq = await SeqRepo.findOne({ @@ -260,9 +260,9 @@ sequencePatterns.register('date', { return moment(instance.get(options?.field ?? 'createdAt')).format(options?.format ?? 'YYYYMMDD'); }, batchGenerate(instances, values, options) { - const { name, inputable } = options; + const { field, inputable } = options; instances.forEach((instance, i) => { - if (!inputable || instance.get(name) == null) { + if (!inputable || instance.get(field ?? 'createdAt') == null) { values[i] = sequencePatterns.get('date').generate.call(this, instance, options); } }); @@ -367,7 +367,7 @@ export class SequenceField extends Field { const array = Array(patterns.length).fill(null).map(() => Array(instances.length)); await patterns.reduce((promise, p, i) => promise.then(() => - sequencePatterns.get(p.type).batchGenerate.call(this, instances, array[i], p.options, options)), + sequencePatterns.get(p.type).batchGenerate.call(this, instances, array[i], p.options ?? {}, options)), Promise.resolve()); instances.forEach((instance, i) => { diff --git a/packages/plugins/snapshot-field/README.md b/packages/plugins/snapshot-field/README.md new file mode 100644 index 000000000..5a52e16db --- /dev/null +++ b/packages/plugins/snapshot-field/README.md @@ -0,0 +1,13 @@ +# snapshot-field + +English | [中文](./README.zh-CN.md) + +关系数据快照插件。 + +## 安装激活 + +```bash +yarn pm enable snapshot-field +``` + +## 使用方法 diff --git a/packages/plugins/snapshot-field/README.zh-CN.md b/packages/plugins/snapshot-field/README.zh-CN.md new file mode 100644 index 000000000..70c20cac3 --- /dev/null +++ b/packages/plugins/snapshot-field/README.zh-CN.md @@ -0,0 +1,13 @@ +# snapshot-field + +[English](./README.md) | 中文 + +关系数据快照插件。 + +## 安装激活 + +```bash +yarn pm enable snapshot-field +``` + +## 使用方法 diff --git a/packages/plugins/snapshot-field/src/__tests__/data/field_linkto.ts b/packages/plugins/snapshot-field/src/__tests__/data/field_linkto.ts new file mode 100644 index 000000000..61b3bc3a4 --- /dev/null +++ b/packages/plugins/snapshot-field/src/__tests__/data/field_linkto.ts @@ -0,0 +1,12 @@ +export const field_linkto = { + name: 'field_linkto', + type: 'belongsToMany', + uiSchema: { + 'x-component': 'RecordPicker', + 'x-component-props': { multiple: true, fieldNames: { label: 'id', value: 'id' } }, + title: 'field_linkto', + }, + interface: 'linkTo', + target: 'table_a', + collectionName: 'table_b', +}; diff --git a/packages/plugins/snapshot-field/src/__tests__/data/field_m2m.ts b/packages/plugins/snapshot-field/src/__tests__/data/field_m2m.ts new file mode 100644 index 000000000..cbbeb085a --- /dev/null +++ b/packages/plugins/snapshot-field/src/__tests__/data/field_m2m.ts @@ -0,0 +1,15 @@ +export const field_m2m = { + foreignKey: 'fk_table_b', + otherKey: 'fk_table_a', + name: 'field_m2m', + type: 'belongsToMany', + uiSchema: { + 'x-component': 'RecordPicker', + 'x-component-props': { multiple: true, fieldNames: { label: 'id', value: 'id' } }, + title: 'field_m2m', + }, + interface: 'm2m', + through: 'table_m2m', + target: 'table_a', + collectionName: 'table_b', +}; diff --git a/packages/plugins/snapshot-field/src/__tests__/data/field_o2m.ts b/packages/plugins/snapshot-field/src/__tests__/data/field_o2m.ts new file mode 100644 index 000000000..b53c95076 --- /dev/null +++ b/packages/plugins/snapshot-field/src/__tests__/data/field_o2m.ts @@ -0,0 +1,14 @@ +export const field_o2m = { + foreignKey: 'fk_table_b', + onDelete: 'SET NULL', + name: 'field_o2m', + type: 'hasMany', + uiSchema: { + 'x-component': 'RecordPicker', + 'x-component-props': { multiple: true, fieldNames: { label: 'id', value: 'id' } }, + title: 'field_o2m', + }, + interface: 'o2m', + target: 'table_a', + collectionName: 'table_b', +}; diff --git a/packages/plugins/snapshot-field/src/__tests__/data/field_o2o.ts b/packages/plugins/snapshot-field/src/__tests__/data/field_o2o.ts new file mode 100644 index 000000000..c2bb3785f --- /dev/null +++ b/packages/plugins/snapshot-field/src/__tests__/data/field_o2o.ts @@ -0,0 +1,14 @@ +export const field_o2o = { + foreignKey: 'fk_table_b', + onDelete: 'SET NULL', + name: 'field_o2o', + type: 'hasOne', + uiSchema: { + 'x-component': 'RecordPicker', + 'x-component-props': { multiple: false, fieldNames: { label: 'id', value: 'id' } }, + title: 'field_o2o', + }, + interface: 'oho', + target: 'table_a', + collectionName: 'table_b', +}; diff --git a/packages/plugins/snapshot-field/src/__tests__/data/snap_linkto.ts b/packages/plugins/snapshot-field/src/__tests__/data/snap_linkto.ts new file mode 100644 index 000000000..e45a90c51 --- /dev/null +++ b/packages/plugins/snapshot-field/src/__tests__/data/snap_linkto.ts @@ -0,0 +1,12 @@ +export const snap_linkto = { + name: 'snap_linkto', + type: 'snapshot', + uiSchema: { + 'x-component': 'SnapshotRecordPicker', + 'x-component-props': { multiple: true, fieldNames: { label: 'id', value: 'id' } }, + title: 'snap_linkto', + }, + interface: 'snapshot', + targetField: 'field_linkto', + collectionName: 'table_b', +}; diff --git a/packages/plugins/snapshot-field/src/__tests__/data/snap_m2m.ts b/packages/plugins/snapshot-field/src/__tests__/data/snap_m2m.ts new file mode 100644 index 000000000..f9e60cb93 --- /dev/null +++ b/packages/plugins/snapshot-field/src/__tests__/data/snap_m2m.ts @@ -0,0 +1,12 @@ +export const snap_m2m = { + name: 'snap_m2m', + type: 'snapshot', + uiSchema: { + 'x-component': 'SnapshotRecordPicker', + 'x-component-props': { multiple: true, fieldNames: { label: 'id', value: 'id' } }, + title: 'snap_m2m', + }, + interface: 'snapshot', + targetField: 'field_m2m', + collectionName: 'table_b', +}; diff --git a/packages/plugins/snapshot-field/src/__tests__/data/snap_o2m.ts b/packages/plugins/snapshot-field/src/__tests__/data/snap_o2m.ts new file mode 100644 index 000000000..7d1a81167 --- /dev/null +++ b/packages/plugins/snapshot-field/src/__tests__/data/snap_o2m.ts @@ -0,0 +1,12 @@ +export const snap_o2m = { + name: 'snap_o2m', + type: 'snapshot', + uiSchema: { + 'x-component': 'SnapshotRecordPicker', + 'x-component-props': { multiple: true, fieldNames: { label: 'id', value: 'id' } }, + title: 'snap_o2m', + }, + interface: 'snapshot', + targetField: 'field_o2m', + collectionName: 'table_b', +}; diff --git a/packages/plugins/snapshot-field/src/__tests__/data/snap_o2o.ts b/packages/plugins/snapshot-field/src/__tests__/data/snap_o2o.ts new file mode 100644 index 000000000..f85c14eea --- /dev/null +++ b/packages/plugins/snapshot-field/src/__tests__/data/snap_o2o.ts @@ -0,0 +1,13 @@ +export const snap_o2o = { + name: 'snap_o2o', + type: 'snapshot', + uiSchema: { + 'x-component': 'SnapshotRecordPicker', + 'x-component-props': { multiple: true, fieldNames: { label: 'id', value: 'id' } }, + title: 'snap_o2o', + }, + interface: 'snapshot', + targetField: 'field_o2o', + appends: ['createdBy'], + collectionName: 'table_b', +}; diff --git a/packages/plugins/snapshot-field/src/__tests__/data/table_a.ts b/packages/plugins/snapshot-field/src/__tests__/data/table_a.ts new file mode 100644 index 000000000..2baedb247 --- /dev/null +++ b/packages/plugins/snapshot-field/src/__tests__/data/table_a.ts @@ -0,0 +1,37 @@ +export const table_a = { + logging: true, + autoGenId: true, + createdBy: true, + updatedBy: false, + createdAt: false, + updatedAt: false, + sortable: true, + name: 'table_a', + template: 'general', + fields: [ + { + name: 'id', + type: 'bigInt', + autoIncrement: true, + primaryKey: true, + allowNull: false, + uiSchema: { type: 'number', title: '{{t("ID")}}', 'x-component': 'InputNumber', 'x-read-pretty': true }, + interface: 'id', + }, + { + name: 'createdBy', + interface: 'createdBy', + type: 'belongsTo', + target: 'users', + foreignKey: 'createdById', + uiSchema: { + type: 'object', + title: '{{t("Created by")}}', + 'x-component': 'RecordPicker', + 'x-component-props': { fieldNames: { value: 'id', label: 'nickname' } }, + 'x-read-pretty': true, + }, + }, + ], + title: 'table_a', +}; diff --git a/packages/plugins/snapshot-field/src/__tests__/data/table_b.ts b/packages/plugins/snapshot-field/src/__tests__/data/table_b.ts new file mode 100644 index 000000000..ac505779f --- /dev/null +++ b/packages/plugins/snapshot-field/src/__tests__/data/table_b.ts @@ -0,0 +1,37 @@ +export const table_b = { + logging: true, + autoGenId: true, + createdBy: true, + updatedBy: false, + createdAt: false, + updatedAt: false, + sortable: true, + name: 'table_b', + template: 'general', + fields: [ + { + name: 'id', + type: 'bigInt', + autoIncrement: true, + primaryKey: true, + allowNull: false, + uiSchema: { type: 'number', title: '{{t("ID")}}', 'x-component': 'InputNumber', 'x-read-pretty': true }, + interface: 'id', + }, + { + name: 'createdBy', + interface: 'createdBy', + type: 'belongsTo', + target: 'users', + foreignKey: 'createdById', + uiSchema: { + type: 'object', + title: '{{t("Created by")}}', + 'x-component': 'RecordPicker', + 'x-component-props': { fieldNames: { value: 'id', label: 'nickname' } }, + 'x-read-pretty': true, + }, + }, + ], + title: 'table_b', +}; diff --git a/packages/plugins/snapshot-field/src/__tests__/data/table_m2m.ts b/packages/plugins/snapshot-field/src/__tests__/data/table_m2m.ts new file mode 100644 index 000000000..d1efa13c1 --- /dev/null +++ b/packages/plugins/snapshot-field/src/__tests__/data/table_m2m.ts @@ -0,0 +1,37 @@ +export const table_m2m = { + logging: true, + autoGenId: true, + createdBy: true, + updatedBy: false, + createdAt: false, + updatedAt: false, + sortable: true, + name: 'table_m2m', + template: 'general', + fields: [ + { + name: 'id', + type: 'bigInt', + autoIncrement: true, + primaryKey: true, + allowNull: false, + uiSchema: { type: 'number', title: '{{t("ID")}}', 'x-component': 'InputNumber', 'x-read-pretty': true }, + interface: 'id', + }, + { + name: 'createdBy', + interface: 'createdBy', + type: 'belongsTo', + target: 'users', + foreignKey: 'createdById', + uiSchema: { + type: 'object', + title: '{{t("Created by")}}', + 'x-component': 'RecordPicker', + 'x-component-props': { fieldNames: { value: 'id', label: 'nickname' } }, + 'x-read-pretty': true, + }, + }, + ], + title: 'table_m2m', +}; diff --git a/packages/plugins/snapshot-field/src/__tests__/fieldsHistory.test.ts b/packages/plugins/snapshot-field/src/__tests__/fieldsHistory.test.ts index 0d7311d23..0317b5758 100644 --- a/packages/plugins/snapshot-field/src/__tests__/fieldsHistory.test.ts +++ b/packages/plugins/snapshot-field/src/__tests__/fieldsHistory.test.ts @@ -1,6 +1,5 @@ import { mockServer, MockServer } from '@nocobase/test'; import SnapshotFieldPlugin from '../server'; -import path from 'path'; describe('actions', () => { let app: MockServer; @@ -22,7 +21,7 @@ describe('actions', () => { await app.destroy(); }); - it.only('fieldsHistory collectionName and name conflict between tables', async () => { + it('fieldsHistory collectionName and name conflict between tables', async () => { const agent = app.agent(); const field = { diff --git a/packages/plugins/snapshot-field/src/__tests__/snapshots.test.ts b/packages/plugins/snapshot-field/src/__tests__/snapshots.test.ts new file mode 100644 index 000000000..3d04a0daa --- /dev/null +++ b/packages/plugins/snapshot-field/src/__tests__/snapshots.test.ts @@ -0,0 +1,155 @@ +import { mockServer, MockServer } from '@nocobase/test'; +import SnapshotFieldPlugin from '../server'; +import { field_linkto } from './data/field_linkto'; +import { field_m2m } from './data/field_m2m'; +import { field_o2m } from './data/field_o2m'; +import { field_o2o } from './data/field_o2o'; +import { snap_linkto } from './data/snap_linkto'; +import { snap_m2m } from './data/snap_m2m'; +import { snap_o2m } from './data/snap_o2m'; +import { snap_o2o } from './data/snap_o2o'; +import { table_a } from './data/table_a'; +import { table_b } from './data/table_b'; +import { table_m2m } from './data/table_m2m'; + +describe('actions', () => { + let app: MockServer; + + beforeEach(async () => { + app = mockServer({ + registerActions: true, + acl: false, + plugins: ['error-handler', 'users', 'ui-schema-storage', 'collection-manager'], + }); + + app.plugin(SnapshotFieldPlugin, { name: 'snapshot-field' }); + + await app.loadAndInstall({ clean: true }); + }); + + afterEach(async () => { + await app.cleanDb(); + await app.destroy(); + }); + + it.only('associations save', async () => { + const agent = app.agent(); + + await agent.resource('collections').create({ + values: table_a, + }); + + await agent.resource('collections').create({ + values: table_b, + }); + + await agent.resource('collections').create({ + values: table_m2m, + }); + + await agent.resource('fields').create({ + values: field_o2m, + }); + + await agent.resource('fields').create({ + values: field_m2m, + }); + + await agent.resource('fields').create({ + values: field_o2o, + }); + + await agent.resource('fields').create({ + values: field_linkto, + }); + + await agent.resource('fields').create({ + values: snap_o2m, + }); + + await agent.resource('fields').create({ + values: snap_m2m, + }); + + await agent.resource('fields').create({ + values: snap_o2o, + }); + + await agent.resource('fields').create({ + values: snap_linkto, + }); + + await agent.resource('table_a').create({ + values: {}, + }); + + await agent.resource('table_b').create({ + values: { + field_o2m: [{ createdById: 1, id: 1, fk_table_b: null }], + field_m2m: [{ createdById: 1, id: 1, fk_table_b: null }], + field_o2o: { createdById: 1, id: 1, fk_table_b: null }, + field_linkto: [{ createdById: 1, id: 1, fk_table_b: null }], + }, + }); + + const res = await agent.resource('table_b').list(); + const { snap_o2o: o2o, snap_m2m: m2m, snap_o2m: o2m, snap_linkto: linkto } = res.body.data[0]; + + const snapshotItem = { createdById: null, id: 1, fk_table_b: 1 }; + + expect(o2o).toMatchObject({ + collectionName: 'table_b', + data: snapshotItem, + }); + expect(m2m).toMatchObject({ + collectionName: 'table_b', + data: [snapshotItem], + }); + expect(o2m).toMatchObject({ + collectionName: 'table_b', + data: [snapshotItem], + }); + expect(linkto).toMatchObject({ + collectionName: 'table_b', + data: [snapshotItem], + }); + + await agent.resource('table_a').create({ + values: {}, + }); + + await agent.resource('collections.fields', 'table_b').destroy({ filter: { name: 'field_o2m' } }); + + const { statusCode: code2 } = await agent.resource('table_b').create({ + values: { + field_m2m: [{ createdById: 1, id: 2, fk_table_b: null }], + field_o2o: { createdById: 1, id: 2, fk_table_b: null }, + field_linkto: [{ createdById: 1, id: 2, fk_table_b: null }], + }, + }); + + expect(code2).toBe(200); + + const { statusCode: code3 } = await agent + .resource('collections.fields', 'table_a') + .destroy({ filter: { name: 'createdBy' } }); + + expect(code3).toBe(200); + + const { statusCode: code4 } = await agent.resource('table_a').create({ + values: {}, + }); + + expect(code4).toBe(200); + + const { statusCode: code5 } = await agent.resource('table_b').create({ + values: { + field_m2m: [{ createdById: 1, id: 3, fk_table_b: null }], + field_o2o: { createdById: 1, id: 3, fk_table_b: null }, + field_linkto: [{ createdById: 1, id: 3, fk_table_b: null }], + }, + }); + + expect(code5).toBe(200); + }); +}); diff --git a/packages/plugins/snapshot-field/src/client/SnapshotBlock/SnapshotBlockInitializers/SnapshotBlockInitializers.tsx b/packages/plugins/snapshot-field/src/client/SnapshotBlock/SnapshotBlockInitializers/SnapshotBlockInitializers.tsx index ea9cbbfc9..ca69b3576 100644 --- a/packages/plugins/snapshot-field/src/client/SnapshotBlock/SnapshotBlockInitializers/SnapshotBlockInitializers.tsx +++ b/packages/plugins/snapshot-field/src/client/SnapshotBlock/SnapshotBlockInitializers/SnapshotBlockInitializers.tsx @@ -41,38 +41,4 @@ export const SnapshotBlockInitializers = (props: any) => { ]} /> ); - - // const { t } = useSnapshotTranslation(); - // return ( - // - // ); }; diff --git a/packages/plugins/snapshot-field/src/client/SnapshotRecordPicker.tsx b/packages/plugins/snapshot-field/src/client/SnapshotRecordPicker.tsx index f9055cf27..b4627d030 100644 --- a/packages/plugins/snapshot-field/src/client/SnapshotRecordPicker.tsx +++ b/packages/plugins/snapshot-field/src/client/SnapshotRecordPicker.tsx @@ -1,41 +1,22 @@ -import React from 'react'; import { connect, mapReadPretty, useFieldSchema } from '@formily/react'; -import { - InputRecordPicker, - ReadPrettyRecordPicker, - useActionContext, - useCollection, - useCollectionHistory, -} from '@nocobase/client'; +import { ReadPrettyRecordPicker, useCollection } from '@nocobase/client'; +import React from 'react'; import { SnapshotHistoryCollectionProvider } from './SnapshotHistoryCollectionProvider'; -const useSnapshotFieldTargetCollectionName = () => { +const ReadPrettyRecordPickerWrapper = (props) => { const fieldSchema = useFieldSchema(); const { getField } = useCollection(); const collectionField = getField(fieldSchema.name); - const { historyCollections } = useCollectionHistory(); - return historyCollections.find((i) => i.name === collectionField.target)?.name; -}; - -const ReadPrettyRecordPickerWrapper = (props) => { - const collectionName = useSnapshotFieldTargetCollectionName(); return ( - + ); }; const SnapshotRecordPickerInner: any = connect( - (props) => { - const actionCtx = useActionContext(); - - const isUpdateAction = actionCtx.fieldSchema['x-action'] === 'update'; - - return isUpdateAction ? : ; - }, - // mapProps(mapSuffixProps), + ReadPrettyRecordPickerWrapper, mapReadPretty(ReadPrettyRecordPickerWrapper), ); diff --git a/packages/plugins/snapshot-field/src/client/components/AppendsTreeSelect.tsx b/packages/plugins/snapshot-field/src/client/components/AppendsTreeSelect.tsx new file mode 100644 index 000000000..7ff4e659b --- /dev/null +++ b/packages/plugins/snapshot-field/src/client/components/AppendsTreeSelect.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { TreeSelect, Tag } from 'antd'; +import type { DefaultOptionType } from 'rc-tree-select/lib/TreeSelect'; +import { useForm } from '@formily/react'; +import { CollectionFieldOptions, useCollectionManager, useCompile, useRecord } from '@nocobase/client'; +import { useSnapshotTranslation } from '../locale'; +import { useTopRecord } from '../interface'; + +export type TreeCacheMapNode = { + parent?: TreeCacheMapNode; + title: string; + path: string; + children?: TreeCacheMapNode[]; +}; + +export type AppendsTreeSelectProps = { + value: string[]; + onChange: (value: string[]) => void; +}; + +type TreeOptionType = Omit & { value: string }; + +export const AppendsTreeSelect: React.FC = (props) => { + const { value = [], onChange, ...restProps } = props; + const record = useTopRecord(); + const { getCollectionFields, getCollectionField } = useCollectionManager(); + const compile = useCompile(); + const formValues = useForm().values; + const { t } = useSnapshotTranslation(); + + const fieldsToOptions = ( + fields: CollectionFieldOptions[] = [], + fieldPath: CollectionFieldOptions[] = [], + ): TreeOptionType[] => { + const filter = (i: CollectionFieldOptions) => + !!i.target && !!i.interface && !fieldPath.find((p) => p.target === i.target); + return fields.filter(filter).map((i) => ({ + title: compile(i.uiSchema?.title) ?? i.name, + value: fieldPath + .map((p) => p.name) + .concat(i.name) + .join('.'), + children: fieldsToOptions(getCollectionFields(i.target), [...fieldPath, i]), + })); + }; + + const treeData = fieldsToOptions( + getCollectionFields(getCollectionField(`${record.name}.${formValues.targetField}`)?.target), + ); + + const valueMap: Record = {}; + + function loops(list: TreeOptionType[], parent?: TreeCacheMapNode) { + return (list || []).map(({ children, value, title }) => { + const node: TreeCacheMapNode = (valueMap[value] = { + parent, + path: value, + title, + }); + node.children = loops(children, node); + return node; + }); + } + + loops(treeData); + + const handleChange = (newNodes: DefaultOptionType[]) => { + const newValue = newNodes.map((i) => i.value) as string[]; + const valueSet = new Set(newValue); + const delValue = value.find((i) => !newValue.includes(i)); + + if (delValue) { + const delNode = valueMap[delValue]; + const delNodeValue = (node: TreeCacheMapNode) => { + valueSet.delete(node.path); + node.children?.forEach((child) => delNodeValue(child)); + }; + delNodeValue(delNode); + } else { + newValue.forEach((v) => { + let current = valueMap[v]; + while ((current = current.parent)) { + valueSet.add(current.path); + } + }); + } + onChange(Array.from(valueSet)); + }; + + const TreeTag = (props) => { + const { value, onClose, disabled, closable } = props; + let node = valueMap[value]; + let text = node?.title; + while ((node = node?.parent)) { + text = `${node.title} / ${text}`; + } + return ( + + {text} + + ); + }; + + const filterdValue = Array.isArray(value) ? value.filter((i) => i in valueMap) : value; + + return ( + void} + treeData={treeData} + {...restProps} + /> + ); +}; diff --git a/packages/plugins/snapshot-field/src/client/components/SnapshotOwnerCollectionFieldsSelect.tsx b/packages/plugins/snapshot-field/src/client/components/SnapshotOwnerCollectionFieldsSelect.tsx new file mode 100644 index 000000000..f77cbe0a7 --- /dev/null +++ b/packages/plugins/snapshot-field/src/client/components/SnapshotOwnerCollectionFieldsSelect.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Select, SelectProps } from 'antd'; +import { useTopRecord } from '../interface'; +import { useCollectionManager, useCompile } from '@nocobase/client'; + +export type SnapshotOwnerCollectionFieldsSelectProps = Omit; + +export const useSnapshotOwnerCollectionFields = () => { + const record = useTopRecord(); + const { getCollection } = useCollectionManager(); + const collection = getCollection(record.name); + const compile = useCompile(); + + return collection.fields + .filter((i) => !!i.target && !!i.interface) + .map((i) => ({ ...i, label: compile(i.uiSchema?.title), value: i.name })); +}; + +export const SnapshotOwnerCollectionFieldsSelect: React.FC = (props) => { + const options = useSnapshotOwnerCollectionFields(); + return