合并对main的所有修改
<!-- Note --> <!-- This is a template for submitting a new feature. Use the bug fix template if you're submitting a bug fix pull request by adding `template=bug_fix.md` to your pull request URL. --> # Description <!-- Describe the new feature or modification to an existing feature clearly and consciously. --> # Motivation <!-- Explain the reason for adding or modifying this feature. --> # Key changes <!-- Provide a technically detailed description of the key changes made. --> - Frontend - Backend # Test plan ## Suggestions <!-- Provide any suggestions or recommendations for improvements in the testing plan. --> ## Underlying risk <!-- Identify any potential risks or issues that may arise from the new feature or modification. --> # Showcase <!-- Including any screenshots of the new feature or modification. --> Co-authored-by: sealday <sealday@gmail.com> Co-authored-by: wjh <wwwjh0710@163.com> Co-authored-by: 吕延祥 <2256334253@qq.com> Reviewed-on: daoyoucloud/nocobase#317
@ -66,6 +66,15 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"@typescript-eslint/no-floating-promises": "error"
|
"@typescript-eslint/no-floating-promises": "error"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"packages/plugins/@hera/**/*.{ts,js,tsx,jsx}",
|
||||||
|
"packages/plugins/@hera/**/*.{ts,js,tsx,jsx}"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"eqeqeq": ["error", "smart"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
1
.gitignore
vendored
@ -34,6 +34,7 @@ storage/tmp
|
|||||||
storage/app.watch.ts
|
storage/app.watch.ts
|
||||||
storage/logs-e2e
|
storage/logs-e2e
|
||||||
storage/uploads-e2e
|
storage/uploads-e2e
|
||||||
|
storage/fonts
|
||||||
storage/.pm2-*
|
storage/.pm2-*
|
||||||
tsconfig.paths.json
|
tsconfig.paths.json
|
||||||
/playwright
|
/playwright
|
||||||
|
11
.prettierrc
@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "all",
|
|
||||||
"printWidth": 120,
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"files": ".prettierrc",
|
|
||||||
"options": { "parser": "json" }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
24
.prettierrc.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: ['prettier-plugin-sql'],
|
||||||
|
singleQuote: true,
|
||||||
|
trailingComma: 'all',
|
||||||
|
printWidth: 120,
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: '.prettierrc',
|
||||||
|
options: { parser: 'json' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: '*.sql',
|
||||||
|
options: {
|
||||||
|
language: 'postgresql',
|
||||||
|
parser: 'sql',
|
||||||
|
keywordCase: 'upper',
|
||||||
|
paramTypes: JSON.stringify({
|
||||||
|
custom: [{ regex: String.raw`\$\{[a-zA-Z0-9_]+\}|:[a-zA-Z0-9_]+` }],
|
||||||
|
}),
|
||||||
|
formatter: 'sql-formatter',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
10
Dockerfile
@ -1,4 +1,4 @@
|
|||||||
FROM node:18-bullseye as builder
|
FROM node:20-bullseye as builder
|
||||||
ARG VERDACCIO_URL=http://host.docker.internal:10104/
|
ARG VERDACCIO_URL=http://host.docker.internal:10104/
|
||||||
ARG COMMIT_HASH
|
ARG COMMIT_HASH
|
||||||
ARG APPEND_PRESET_LOCAL_PLUGINS
|
ARG APPEND_PRESET_LOCAL_PLUGINS
|
||||||
@ -10,18 +10,18 @@ ENV PLUGINS_DIRS=${PLUGINS_DIRS}
|
|||||||
RUN apt-get update && apt-get install -y jq
|
RUN apt-get update && apt-get install -y jq
|
||||||
WORKDIR /tmp
|
WORKDIR /tmp
|
||||||
COPY . /tmp
|
COPY . /tmp
|
||||||
RUN npx npm-cli-adduser --username test --password test -e test@nocobase.com -r $VERDACCIO_URL
|
RUN npm_config_registry=$VERDACCIO_URL npx npm-cli-adduser --username test --password test -e test@nocobase.com -r $VERDACCIO_URL
|
||||||
RUN cd /tmp && \
|
RUN cd /tmp && \
|
||||||
NEWVERSION="$(cat lerna.json | jq '.version' | tr -d '"').$(date +'%Y%m%d%H%M%S')" \
|
NEWVERSION="$(cat lerna.json | jq '.version' | tr -d '"').$(date +'%Y%m%d%H%M%S')" \
|
||||||
&& tmp=$(mktemp) \
|
&& tmp=$(mktemp) \
|
||||||
&& jq ".version = \"${NEWVERSION}\"" lerna.json > "$tmp" && mv "$tmp" lerna.json
|
&& jq ".version = \"${NEWVERSION}\"" lerna.json > "$tmp" && mv "$tmp" lerna.json
|
||||||
RUN yarn install && yarn build --no-dts
|
RUN yarn install --network-timeout 600000 && yarn build --no-dts
|
||||||
|
|
||||||
RUN git checkout -b release-$(date +'%Y%m%d%H%M%S') \
|
RUN git checkout -b release-$(date +'%Y%m%d%H%M%S') \
|
||||||
&& yarn version:alpha -y
|
&& yarn version:alpha -y
|
||||||
RUN git config user.email "test@mail.com" \
|
RUN git config user.email "test@mail.com" \
|
||||||
&& git config user.name "test" && git add . \
|
&& git config user.name "test" && git add . \
|
||||||
&& git commit -m "chore(versions): test publish packages"
|
&& git commit --no-verify -m "chore(versions): test publish packages"
|
||||||
RUN yarn release:force --registry $VERDACCIO_URL
|
RUN yarn release:force --registry $VERDACCIO_URL
|
||||||
|
|
||||||
RUN yarn config set registry $VERDACCIO_URL
|
RUN yarn config set registry $VERDACCIO_URL
|
||||||
@ -41,7 +41,7 @@ RUN cd /app \
|
|||||||
&& tar -zcf ./nocobase.tar.gz -C /app/my-nocobase-app .
|
&& tar -zcf ./nocobase.tar.gz -C /app/my-nocobase-app .
|
||||||
|
|
||||||
|
|
||||||
FROM node:18-bullseye-slim
|
FROM node:20-bullseye-slim
|
||||||
RUN apt-get update && apt-get install -y nginx
|
RUN apt-get update && apt-get install -y nginx
|
||||||
RUN rm -rf /etc/nginx/sites-enabled/default
|
RUN rm -rf /etc/nginx/sites-enabled/default
|
||||||
COPY ./docker/nocobase/nocobase.conf /etc/nginx/sites-enabled/nocobase.conf
|
COPY ./docker/nocobase/nocobase.conf /etc/nginx/sites-enabled/nocobase.conf
|
||||||
|
1
build.sh
Normal file
@ -0,0 +1 @@
|
|||||||
|
DOCKER_BUILDKIT=1 docker build -f docker/hera/Dockerfile -t hera:0.3 .
|
19
docker/hera/Dockerfile
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# 设置基础镜像
|
||||||
|
FROM node:20.9.0 as builder
|
||||||
|
|
||||||
|
# 在容器中创建/app/nocobase目录
|
||||||
|
WORKDIR /app/nocobase
|
||||||
|
|
||||||
|
# 设置npm和pnpm的registry为https://registry.npmmirror.com
|
||||||
|
RUN npm config set registry https://registry.npmmirror.com && \
|
||||||
|
yarn config set registry https://registry.npmmirror.com
|
||||||
|
|
||||||
|
# 将当前目录中的所有文件复制到容器的/app/nocobase目录下
|
||||||
|
COPY . /app/nocobase
|
||||||
|
|
||||||
|
# 使用 pnpm 安装依赖
|
||||||
|
RUN yarn
|
||||||
|
RUN yarn build
|
||||||
|
|
||||||
|
# 使用 pm2-runtime 执行packages/core/app/lib/index.js文件
|
||||||
|
CMD ["yarn", "pm2-runtime", "packages/core/app/lib/index.js", "--", "start"]
|
@ -14,7 +14,11 @@ if [ ! -f "/app/nocobase/package.json" ]; then
|
|||||||
touch /app/nocobase/node_modules/@nocobase/app/dist/client/index.html
|
touch /app/nocobase/node_modules/@nocobase/app/dist/client/index.html
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cd /app/nocobase && yarn start --quickstart
|
if [ -z "$PM2_INSTANCE_NUM" ]; then
|
||||||
|
PM2_INSTANCE_NUM=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd /app/nocobase && yarn start --quickstart -i $PM2_INSTANCE_NUM
|
||||||
|
|
||||||
# Run command with node if the first argument contains a "-" or is not a system command. The last
|
# Run command with node if the first argument contains a "-" or is not a system command. The last
|
||||||
# part inside the "{}" is a workaround for the following bug in ash/dash:
|
# part inside the "{}" is a workaround for the following bug in ash/dash:
|
||||||
|
10
package.json
@ -46,8 +46,10 @@
|
|||||||
"@typescript-eslint/parser": "^6.2.0",
|
"@typescript-eslint/parser": "^6.2.0",
|
||||||
"react-router-dom": "^6.11.2",
|
"react-router-dom": "^6.11.2",
|
||||||
"react-router": "^6.11.2",
|
"react-router": "^6.11.2",
|
||||||
|
"prettier": "^3.1.1",
|
||||||
"react": "^18.0.0",
|
"react": "^18.0.0",
|
||||||
"react-dom": "^18.0.0",
|
"react-dom": "^18.0.0",
|
||||||
|
"tsx": "^4.6.2",
|
||||||
"nwsapi": "2.2.7",
|
"nwsapi": "2.2.7",
|
||||||
"antd": "5.12.8"
|
"antd": "5.12.8"
|
||||||
},
|
},
|
||||||
@ -58,7 +60,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,json}": [
|
"*.{js,json,sql}": [
|
||||||
"prettier --write"
|
"prettier --write"
|
||||||
],
|
],
|
||||||
"*.ts?(x)": [
|
"*.ts?(x)": [
|
||||||
@ -72,10 +74,14 @@
|
|||||||
"@types/react": "^17.0.0",
|
"@types/react": "^17.0.0",
|
||||||
"@types/react-dom": "^17.0.0",
|
"@types/react-dom": "^17.0.0",
|
||||||
"auto-changelog": "^2.4.0",
|
"auto-changelog": "^2.4.0",
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"commander": "^9.2.0",
|
||||||
"eslint-plugin-jest-dom": "^5.0.1",
|
"eslint-plugin-jest-dom": "^5.0.1",
|
||||||
"eslint-plugin-testing-library": "^5.11.0",
|
"eslint-plugin-testing-library": "^5.11.0",
|
||||||
"ghooks": "^2.0.4",
|
"ghooks": "^2.0.4",
|
||||||
"lint-staged": "^13.2.3",
|
"lint-staged": "^13.2.3",
|
||||||
|
"prettier": "^3.1.1",
|
||||||
|
"prettier-plugin-sql": "^0.17.0",
|
||||||
"pretty-format": "^24.0.0",
|
"pretty-format": "^24.0.0",
|
||||||
"pretty-quick": "^3.1.0",
|
"pretty-quick": "^3.1.0",
|
||||||
"react": "^18.0.0",
|
"react": "^18.0.0",
|
||||||
@ -83,7 +89,7 @@
|
|||||||
"typescript": "5.1.3"
|
"typescript": "5.1.3"
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "18.14.2",
|
"node": "20.9.0",
|
||||||
"yarn": "1.22.19"
|
"yarn": "1.22.19"
|
||||||
},
|
},
|
||||||
"dependencies": {}
|
"dependencies": {}
|
||||||
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 6.3 KiB |
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
Before Width: | Height: | Size: 376 B After Width: | Height: | Size: 573 B |
Before Width: | Height: | Size: 792 B After Width: | Height: | Size: 1.0 KiB |
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 17 KiB |
@ -299,7 +299,7 @@ export async function buildPluginClient(cwd: string, userConfig: UserConfig, sou
|
|||||||
const outDir = path.join(cwd, target_dir, 'client');
|
const outDir = path.join(cwd, target_dir, 'client');
|
||||||
|
|
||||||
const globals = excludePackages.reduce<Record<string, string>>((prev, curr) => {
|
const globals = excludePackages.reduce<Record<string, string>>((prev, curr) => {
|
||||||
if (curr.startsWith('@nocobase')) {
|
if (curr.startsWith('@nocobase') || curr.startsWith('@hera')) {
|
||||||
prev[`${curr}/client`] = curr;
|
prev[`${curr}/client`] = curr;
|
||||||
}
|
}
|
||||||
prev[curr] = curr;
|
prev[curr] = curr;
|
||||||
|
@ -3,6 +3,7 @@ const { isDev, run, postCheck, runInstall, promptForTs } = require('../util');
|
|||||||
const { existsSync, unlink } = require('fs');
|
const { existsSync, unlink } = require('fs');
|
||||||
const { resolve } = require('path');
|
const { resolve } = require('path');
|
||||||
const chalk = require('chalk');
|
const chalk = require('chalk');
|
||||||
|
const _ = require('lodash');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@ -14,6 +15,7 @@ module.exports = (cli) => {
|
|||||||
.command('start')
|
.command('start')
|
||||||
.option('-p, --port [port]')
|
.option('-p, --port [port]')
|
||||||
.option('-d, --daemon')
|
.option('-d, --daemon')
|
||||||
|
.option('-i, --instances [number]')
|
||||||
.option('--db-sync')
|
.option('--db-sync')
|
||||||
.option('--quickstart')
|
.option('--quickstart')
|
||||||
.allowUnknownOption()
|
.allowUnknownOption()
|
||||||
@ -48,6 +50,8 @@ module.exports = (cli) => {
|
|||||||
'pm2-runtime',
|
'pm2-runtime',
|
||||||
[
|
[
|
||||||
'start',
|
'start',
|
||||||
|
'-i',
|
||||||
|
_.toNumber(opts.instances || 1),
|
||||||
`${APP_PACKAGE_ROOT}/lib/index.js`,
|
`${APP_PACKAGE_ROOT}/lib/index.js`,
|
||||||
NODE_ARGS ? `--node-args="${NODE_ARGS}"` : undefined,
|
NODE_ARGS ? `--node-args="${NODE_ARGS}"` : undefined,
|
||||||
'--',
|
'--',
|
||||||
|
@ -32,7 +32,7 @@
|
|||||||
"ahooks": "^3.7.2",
|
"ahooks": "^3.7.2",
|
||||||
"antd": "^5.12.8",
|
"antd": "^5.12.8",
|
||||||
"antd-style": "3.4.5",
|
"antd-style": "3.4.5",
|
||||||
"axios": "^0.26.1",
|
"axios": "^1.6.2",
|
||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
"cronstrue": "^2.11.0",
|
"cronstrue": "^2.11.0",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
|
@ -44,6 +44,8 @@ export class Plugin<T = any> {
|
|||||||
|
|
||||||
async load() {}
|
async load() {}
|
||||||
|
|
||||||
|
async afterLoad() {}
|
||||||
|
|
||||||
t(text: TFuncKey | TFuncKey[], options: TOptions = {}) {
|
t(text: TFuncKey | TFuncKey[], options: TOptions = {}) {
|
||||||
return this.app.i18n.t(text, { ns: this.options?.['packageName'], ...(options as any) });
|
return this.app.i18n.t(text, { ns: this.options?.['packageName'], ...(options as any) });
|
||||||
}
|
}
|
||||||
|
@ -89,5 +89,9 @@ export class PluginManager {
|
|||||||
for (const plugin of this.pluginInstances.values()) {
|
for (const plugin of this.pluginInstances.values()) {
|
||||||
await plugin.load();
|
await plugin.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const plugin of this.pluginInstances.values()) {
|
||||||
|
await plugin.afterLoad();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1273,6 +1273,13 @@ export const useAssociationNames = (dataSource?: string) => {
|
|||||||
collectAppends(condition);
|
collectAppends(condition);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// 处理多对一标题字段
|
||||||
|
if (s['x-component-props']?.['x-next-title']) {
|
||||||
|
const pre = prefix && prefix !== '' ? prefix + '.' + s.name : s.name;
|
||||||
|
const title = s['x-component-props']['x-next-title'];
|
||||||
|
const path = pre + '.' + title.label;
|
||||||
|
appends.add(path);
|
||||||
|
}
|
||||||
|
|
||||||
const isTreeCollection = isAssociationField && getCollection(collectionField.target)?.template === 'tree';
|
const isTreeCollection = isAssociationField && getCollection(collectionField.target)?.template === 'tree';
|
||||||
if (collectionField && (isAssociationField || isAssociationSubfield) && s['x-component'] !== 'TableField') {
|
if (collectionField && (isAssociationField || isAssociationSubfield) && s['x-component'] !== 'TableField') {
|
||||||
|
@ -19,6 +19,13 @@ export class CheckboxFieldInterface extends CollectionFieldInterface {
|
|||||||
hasDefaultValue = true;
|
hasDefaultValue = true;
|
||||||
properties = {
|
properties = {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
|
'uiSchema.x-component-props.showUnchecked': {
|
||||||
|
type: 'boolean',
|
||||||
|
title: '{{t("Display X when unchecked")}}',
|
||||||
|
default: false,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'Checkbox',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
filterable = {
|
filterable = {
|
||||||
operators: operators.boolean,
|
operators: operators.boolean,
|
||||||
|
@ -35,8 +35,8 @@ export const DataBlockResourceProvider: FC<{ children?: ReactNode }> = ({ childr
|
|||||||
if (association) {
|
if (association) {
|
||||||
return api.resource(association, sourceIdValue, headers);
|
return api.resource(association, sourceIdValue, headers);
|
||||||
}
|
}
|
||||||
return api.resource(collectionName, undefined, headers);
|
return api.resource(dataBlockProps.resource ?? collectionName, undefined, headers);
|
||||||
}, [api, association, collection, sourceIdValue, headers]);
|
}, [api, association, collection, sourceIdValue, headers, dataBlockProps.resource]);
|
||||||
return <DataBlockResourceContext.Provider value={resource}>{children}</DataBlockResourceContext.Provider>;
|
return <DataBlockResourceContext.Provider value={resource}>{children}</DataBlockResourceContext.Provider>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ export const isTitleField = (dm: DataSourceManager, field: CollectionFieldOption
|
|||||||
return !field.isForeignKey && dm.collectionFieldInterfaceManager.getFieldInterface(field.interface)?.titleUsable;
|
return !field.isForeignKey && dm.collectionFieldInterfaceManager.getFieldInterface(field.interface)?.titleUsable;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useDataSourceHeaders = (dataSource?: string) => {
|
export const useDataSourceHeaders = (dataSource?: string): any => {
|
||||||
const headers = useMemo(() => {
|
const headers = useMemo(() => {
|
||||||
if (dataSource && dataSource !== DEFAULT_DATA_SOURCE_KEY) {
|
if (dataSource && dataSource !== DEFAULT_DATA_SOURCE_KEY) {
|
||||||
return { 'x-data-source': dataSource };
|
return { 'x-data-source': dataSource };
|
||||||
|
@ -58,3 +58,10 @@ export * from './modules/blocks/BlockSchemaToolbar';
|
|||||||
export * from './modules/blocks/data-blocks/table';
|
export * from './modules/blocks/data-blocks/table';
|
||||||
export * from './modules/blocks/data-blocks/form';
|
export * from './modules/blocks/data-blocks/form';
|
||||||
export * from './modules/blocks/data-blocks/table-selector';
|
export * from './modules/blocks/data-blocks/table-selector';
|
||||||
|
export * as __UNSAFE__ from './unsafe';
|
||||||
|
export type {
|
||||||
|
DynamicComponentProps as __UNSAFE__DynamicComponentProps,
|
||||||
|
VariablesContextType as __UNSAFE__VariablesContextType,
|
||||||
|
VariableOption as __UNSAFE__VariableOption,
|
||||||
|
Option as __UNSAFE__VariableInputOption,
|
||||||
|
} from './unsafe';
|
||||||
|
@ -4,6 +4,9 @@ export const createFormActionInitializers = new SchemaInitializer({
|
|||||||
name: 'CreateFormActionInitializers',
|
name: 'CreateFormActionInitializers',
|
||||||
title: '{{t("Configure actions")}}',
|
title: '{{t("Configure actions")}}',
|
||||||
icon: 'SettingOutlined',
|
icon: 'SettingOutlined',
|
||||||
|
style: {
|
||||||
|
marginLeft: '8px',
|
||||||
|
},
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
type: 'itemGroup',
|
type: 'itemGroup',
|
||||||
|
@ -4,6 +4,9 @@ export const updateFormActionInitializers = new SchemaInitializer({
|
|||||||
name: 'UpdateFormActionInitializers',
|
name: 'UpdateFormActionInitializers',
|
||||||
title: '{{t("Configure actions")}}',
|
title: '{{t("Configure actions")}}',
|
||||||
icon: 'SettingOutlined',
|
icon: 'SettingOutlined',
|
||||||
|
style: {
|
||||||
|
marginLeft: '8px',
|
||||||
|
},
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
type: 'itemGroup',
|
type: 'itemGroup',
|
||||||
|
@ -75,6 +75,8 @@ const titleField: any = {
|
|||||||
const fieldSchema = tableColumnSchema || schema;
|
const fieldSchema = tableColumnSchema || schema;
|
||||||
const targetCollectionField = useCollectionField();
|
const targetCollectionField = useCollectionField();
|
||||||
const collectionField = tableColumnField || targetCollectionField;
|
const collectionField = tableColumnField || targetCollectionField;
|
||||||
|
// 处理多对一关系标题显示
|
||||||
|
const { getCollectionFields } = useCollectionManager_deprecated();
|
||||||
const fieldNames = {
|
const fieldNames = {
|
||||||
...collectionField?.uiSchema?.['x-component-props']?.['fieldNames'],
|
...collectionField?.uiSchema?.['x-component-props']?.['fieldNames'],
|
||||||
...field?.componentProps?.fieldNames,
|
...field?.componentProps?.fieldNames,
|
||||||
@ -95,6 +97,16 @@ const titleField: any = {
|
|||||||
};
|
};
|
||||||
fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {};
|
fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {};
|
||||||
fieldSchema['x-component-props']['fieldNames'] = newFieldNames;
|
fieldSchema['x-component-props']['fieldNames'] = newFieldNames;
|
||||||
|
// 处理多对一关系标题显示
|
||||||
|
const target = getCollectionFields(collectionField.target).find((field) => field.name === label);
|
||||||
|
if (target.interface === 'm2o') {
|
||||||
|
fieldSchema['x-component-props']['x-next-title'] = {
|
||||||
|
label,
|
||||||
|
collection: target.collectionName,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
fieldSchema['x-component-props']['x-next-title'] = null;
|
||||||
|
}
|
||||||
schema['x-component-props'] = fieldSchema['x-component-props'];
|
schema['x-component-props'] = fieldSchema['x-component-props'];
|
||||||
field.componentProps.fieldNames = fieldSchema['x-component-props'].fieldNames;
|
field.componentProps.fieldNames = fieldSchema['x-component-props'].fieldNames;
|
||||||
const path = field.path?.splice(field.path?.length - 1, 1);
|
const path = field.path?.splice(field.path?.length - 1, 1);
|
||||||
|
@ -10,6 +10,8 @@ export const PoweredBy = () => {
|
|||||||
'en-US': 'https://www.nocobase.com',
|
'en-US': 'https://www.nocobase.com',
|
||||||
'zh-CN': 'https://cn.nocobase.com',
|
'zh-CN': 'https://cn.nocobase.com',
|
||||||
};
|
};
|
||||||
|
const date = new Date();
|
||||||
|
const year = date.getFullYear();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={css`
|
className={css`
|
||||||
@ -23,7 +25,7 @@ export const PoweredBy = () => {
|
|||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
Powered by <a href={urls[i18n.language] || urls['en-US']}>NocoBase</a>
|
©2023-{year} 上海道有云网络科技有限公司 版权所有 沪ICP备2023024678号
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -757,7 +757,7 @@ export const ActionDesigner = (props) => {
|
|||||||
removeButtonProps,
|
removeButtonProps,
|
||||||
buttonEditorProps,
|
buttonEditorProps,
|
||||||
linkageRulesProps,
|
linkageRulesProps,
|
||||||
schemaSettings = 'ActionSettings',
|
schemaSettings,
|
||||||
...restProps
|
...restProps
|
||||||
} = props;
|
} = props;
|
||||||
const app = useApp();
|
const app = useApp();
|
||||||
|
@ -0,0 +1,316 @@
|
|||||||
|
import { ArrayItems, FormItem } from '@formily/antd-v5';
|
||||||
|
import { createForm, onFormValuesChange } from '@formily/core';
|
||||||
|
import { FormProvider, connect, createSchemaField, observer, useField, useFieldSchema } from '@formily/react';
|
||||||
|
import { uid } from '@formily/shared';
|
||||||
|
import { Input, Space, Spin, Tag } from 'antd';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { css, useAPIClient, useCollectionManager_deprecated } from '../../..';
|
||||||
|
import { mergeFilter } from '../../../filter-provider/utils';
|
||||||
|
import { CustomCascader, SchemaComponent, useCompile } from '../..';
|
||||||
|
import useServiceOptions, { useAssociationFieldContext } from './hooks';
|
||||||
|
|
||||||
|
const EMPTY = 'N/A';
|
||||||
|
const SchemaField = createSchemaField({
|
||||||
|
components: {
|
||||||
|
Space,
|
||||||
|
Input,
|
||||||
|
ArrayItems,
|
||||||
|
FormItem,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const Cascade = connect((props) => {
|
||||||
|
const { data, mapOptions, onChange } = props;
|
||||||
|
const [selectedOptions, setSelectedOptions] = useState([]);
|
||||||
|
const [options, setOptions] = useState(data);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const compile = useCompile();
|
||||||
|
const api = useAPIClient();
|
||||||
|
const service = useServiceOptions(props);
|
||||||
|
const { options: collectionField, field: associationField } = useAssociationFieldContext<any>();
|
||||||
|
const resource = api.resource(collectionField.target);
|
||||||
|
const { getCollectionJoinField, getInterface } = useCollectionManager_deprecated();
|
||||||
|
const fieldNames = associationField?.componentProps?.fieldNames;
|
||||||
|
const FieldSchema = useFieldSchema();
|
||||||
|
const targetField =
|
||||||
|
collectionField?.target &&
|
||||||
|
fieldNames?.label &&
|
||||||
|
getCollectionJoinField(`${collectionField.target}.${fieldNames.label}`);
|
||||||
|
const operator = useMemo(() => {
|
||||||
|
if (targetField?.interface) {
|
||||||
|
return getInterface(targetField.interface)?.filterable?.operators[0].value || '$includes';
|
||||||
|
}
|
||||||
|
return '$includes';
|
||||||
|
}, [targetField]);
|
||||||
|
const field: any = useField();
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.value) {
|
||||||
|
const values = Array.isArray(props.value)
|
||||||
|
? extractLastNonNullValueObjects(props.value?.filter((v) => v.value), true)
|
||||||
|
: transformNestedData(props.value);
|
||||||
|
const defaultData = values?.map?.((v) => {
|
||||||
|
return v.id;
|
||||||
|
});
|
||||||
|
setSelectedOptions(defaultData);
|
||||||
|
}
|
||||||
|
onDropdownVisibleChange('true');
|
||||||
|
}, []);
|
||||||
|
const handleGetOptions = async () => {
|
||||||
|
const response = await resource.list({
|
||||||
|
pageSize: 9999,
|
||||||
|
params: service?.params,
|
||||||
|
filter: mergeFilter([service?.params?.filter, filter]),
|
||||||
|
tree: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response?.data?.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = async (option) => {
|
||||||
|
if (option) {
|
||||||
|
if (['o2m', 'm2m'].includes(collectionField.interface)) {
|
||||||
|
const fieldValue = Array.isArray(associationField.fieldValue) ? associationField.fieldValue : [];
|
||||||
|
fieldValue[field.index] = option[option.length - 1];
|
||||||
|
associationField.fieldValue = fieldValue;
|
||||||
|
} else {
|
||||||
|
associationField.value = option[option.length - 1];
|
||||||
|
}
|
||||||
|
const options = [];
|
||||||
|
options.push({
|
||||||
|
key: undefined,
|
||||||
|
children: [],
|
||||||
|
value: option[0],
|
||||||
|
});
|
||||||
|
option.forEach((item, index) => {
|
||||||
|
if (index === option.length - 1) {
|
||||||
|
options.push({
|
||||||
|
key: item.id,
|
||||||
|
children: null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
options.push({
|
||||||
|
key: item.id,
|
||||||
|
children: item.children,
|
||||||
|
value: option[index + 1],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onChange?.(options);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const cascadeOption = (option) => {
|
||||||
|
option.forEach((item) => {
|
||||||
|
item['value'] = item[fieldNames.value];
|
||||||
|
item['label'] = item[fieldNames.label];
|
||||||
|
if (item.children) {
|
||||||
|
item.children = cascadeOption(item.children);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return option;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDropdownVisibleChange = async (visible) => {
|
||||||
|
if (visible) {
|
||||||
|
setLoading(true);
|
||||||
|
let result = await handleGetOptions();
|
||||||
|
result = cascadeOption(result);
|
||||||
|
setLoading(false);
|
||||||
|
setOptions(result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const filter = (inputValue: string, path) => path.some((option) => (option.label as string).includes(inputValue));
|
||||||
|
return (
|
||||||
|
<Space
|
||||||
|
wrap
|
||||||
|
className={css`
|
||||||
|
display: flex;
|
||||||
|
> .ant-space-item {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<CustomCascader
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
showSearch={{ filter }}
|
||||||
|
fieldNames={fieldNames}
|
||||||
|
key={selectedOptions[0] ?? []}
|
||||||
|
defaultValue={selectedOptions}
|
||||||
|
options={options}
|
||||||
|
onChange={(value, option) => handleSelect(option)}
|
||||||
|
changeOnSelect
|
||||||
|
placeholder="Please select"
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const AssociationCascadeSelect = connect((props: any) => {
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%' }}>
|
||||||
|
<Cascade {...props} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const InternalCascader = observer(
|
||||||
|
(props: any) => {
|
||||||
|
const { options: collectionField } = useAssociationFieldContext();
|
||||||
|
const selectForm = useMemo(() => createForm(), []);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const field: any = useField();
|
||||||
|
const fieldSchema = useFieldSchema();
|
||||||
|
useEffect(() => {
|
||||||
|
const id = uid();
|
||||||
|
selectForm.addEffects(id, () => {
|
||||||
|
onFormValuesChange((form) => {
|
||||||
|
if (collectionField.interface === 'm2o') {
|
||||||
|
const value = extractLastNonNullValueObjects(form.values?.[fieldSchema.name]);
|
||||||
|
setTimeout(() => {
|
||||||
|
form.setValuesIn(fieldSchema.name, value);
|
||||||
|
props.onChange(value);
|
||||||
|
field.value = value;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const value = extractLastNonNullValueObjects(form.values?.select_array).filter(
|
||||||
|
(v) => v && Object.keys(v).length > 0,
|
||||||
|
);
|
||||||
|
setTimeout(() => {
|
||||||
|
field.value = value;
|
||||||
|
props.onChange(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
selectForm.removeEffects(id);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
const toValue = () => {
|
||||||
|
if (Array.isArray(field.value) && field.value.length > 0) {
|
||||||
|
return field.value;
|
||||||
|
}
|
||||||
|
return [{}];
|
||||||
|
};
|
||||||
|
const defaultValue = toValue();
|
||||||
|
const schema = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
select_array: {
|
||||||
|
type: 'array',
|
||||||
|
'x-component': 'ArrayItems',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
default: defaultValue,
|
||||||
|
items: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'Space',
|
||||||
|
properties: {
|
||||||
|
sort: {
|
||||||
|
type: 'void',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'ArrayItems.SortHandle',
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
type: 'string',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': AssociationCascadeSelect,
|
||||||
|
'x-component-props': {
|
||||||
|
...props,
|
||||||
|
style: { width: '100%' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
remove: {
|
||||||
|
type: 'void',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'ArrayItems.Remove',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
add: {
|
||||||
|
type: 'void',
|
||||||
|
title: t('Add new'),
|
||||||
|
'x-component': 'ArrayItems.Addition',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<FormProvider form={selectForm}>
|
||||||
|
{collectionField.interface === 'm2o' ? (
|
||||||
|
<SchemaComponent
|
||||||
|
components={{ FormItem }}
|
||||||
|
schema={{
|
||||||
|
...fieldSchema,
|
||||||
|
default: field.value,
|
||||||
|
title: '',
|
||||||
|
'x-component': AssociationCascadeSelect,
|
||||||
|
'x-component-props': {
|
||||||
|
...props,
|
||||||
|
style: { width: '100%' },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SchemaField schema={schema} />
|
||||||
|
)}
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ displayName: 'InternalCascade' },
|
||||||
|
);
|
||||||
|
|
||||||
|
function extractLastNonNullValueObjects(data, flag?) {
|
||||||
|
let result = [];
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
for (const sublist of data) {
|
||||||
|
let lastNonNullValue = null;
|
||||||
|
if (Array.isArray(sublist)) {
|
||||||
|
for (let i = sublist?.length - 1; i >= 0; i--) {
|
||||||
|
if (sublist[i].value) {
|
||||||
|
lastNonNullValue = sublist[i].value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastNonNullValue) {
|
||||||
|
result.push(lastNonNullValue);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (sublist?.value) {
|
||||||
|
lastNonNullValue = sublist.value;
|
||||||
|
} else {
|
||||||
|
lastNonNullValue = null;
|
||||||
|
}
|
||||||
|
if (lastNonNullValue) {
|
||||||
|
if (flag) {
|
||||||
|
result?.push?.(lastNonNullValue);
|
||||||
|
} else {
|
||||||
|
result = lastNonNullValue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result?.push?.(sublist);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transformNestedData(inputData) {
|
||||||
|
const resultArray = [];
|
||||||
|
|
||||||
|
function recursiveTransform(data) {
|
||||||
|
if (data?.parent) {
|
||||||
|
const { parent } = data;
|
||||||
|
recursiveTransform(parent);
|
||||||
|
}
|
||||||
|
const { parent, ...other } = data;
|
||||||
|
resultArray.push(other);
|
||||||
|
}
|
||||||
|
if (inputData) {
|
||||||
|
recursiveTransform(inputData);
|
||||||
|
}
|
||||||
|
return resultArray;
|
||||||
|
}
|
@ -55,7 +55,12 @@ export const ReadPrettyInternalViewer: React.FC = observer(
|
|||||||
.map((o) => o?.[fieldNames?.label || 'label'])
|
.map((o) => o?.[fieldNames?.label || 'label'])
|
||||||
.join(' / ')
|
.join(' / ')
|
||||||
: isObject(value)
|
: isObject(value)
|
||||||
? JSON.stringify(value)
|
? getCollection(targetCollection?.fieldsMap?.[fieldNames?.label || 'label']?.target)?.options?.titleField
|
||||||
|
? value[
|
||||||
|
getCollection(targetCollection?.fieldsMap?.[fieldNames?.label || 'label']?.target)?.options
|
||||||
|
?.titleField
|
||||||
|
]
|
||||||
|
: JSON.stringify(value)
|
||||||
: value;
|
: value;
|
||||||
const val = toValue(compile(label), 'N/A');
|
const val = toValue(compile(label), 'N/A');
|
||||||
const labelUiSchema = getLabelUiSchema(
|
const labelUiSchema = getLabelUiSchema(
|
||||||
|
@ -158,7 +158,7 @@ export const SubTable: any = observer(
|
|||||||
showIndex
|
showIndex
|
||||||
dragSort={field.editable}
|
dragSort={field.editable}
|
||||||
showDel={field.editable}
|
showDel={field.editable}
|
||||||
pagination={false}
|
pagination={!!field.componentProps.pagination}
|
||||||
rowSelection={{ type: 'none', hideSelectAll: true }}
|
rowSelection={{ type: 'none', hideSelectAll: true }}
|
||||||
footer={() =>
|
footer={() =>
|
||||||
field.editable && (
|
field.editable && (
|
||||||
|
@ -0,0 +1,61 @@
|
|||||||
|
import { LoadingOutlined } from '@ant-design/icons';
|
||||||
|
import { ArrayField } from '@formily/core';
|
||||||
|
import { connect, mapProps, mapReadPretty, useField } from '@formily/react';
|
||||||
|
import { toArr } from '@formily/shared';
|
||||||
|
import { Cascader as AntdCascader, Space } from 'antd';
|
||||||
|
import { isBoolean, omit } from 'lodash';
|
||||||
|
import React from 'react';
|
||||||
|
import { useRequest } from '../../../api-client';
|
||||||
|
import { ReadPretty } from './ReadPretty';
|
||||||
|
import { defaultFieldNames } from './defaultFieldNames';
|
||||||
|
|
||||||
|
const useDefDataSource = (options) => {
|
||||||
|
const field = useField<ArrayField>();
|
||||||
|
return useRequest(() => Promise.resolve({ data: field.dataSource || [] }), options);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useDefLoadData = (props: any) => {
|
||||||
|
return props?.loadData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomCascader = connect(
|
||||||
|
(props: any) => {
|
||||||
|
const field = useField<ArrayField>();
|
||||||
|
const {
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
labelInValue,
|
||||||
|
// fieldNames = defaultFieldNames,
|
||||||
|
useDataSource = useDefDataSource,
|
||||||
|
useLoadData = useDefLoadData,
|
||||||
|
changeOnSelectLast,
|
||||||
|
changeOnSelect,
|
||||||
|
maxLevel,
|
||||||
|
defaultValue,
|
||||||
|
...others
|
||||||
|
} = props;
|
||||||
|
return (
|
||||||
|
<AntdCascader
|
||||||
|
{...others}
|
||||||
|
onChange={onChange}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
changeOnSelect
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
mapProps(
|
||||||
|
{
|
||||||
|
dataSource: 'options',
|
||||||
|
},
|
||||||
|
(props, field) => {
|
||||||
|
return {
|
||||||
|
...props,
|
||||||
|
suffixIcon: field?.['loading'] || field?.['validating'] ? <LoadingOutlined /> : props.suffixIcon,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
mapReadPretty(ReadPretty),
|
||||||
|
);
|
||||||
|
|
||||||
|
export default CustomCascader;
|
@ -0,0 +1,35 @@
|
|||||||
|
import { ArrayField } from '@formily/core';
|
||||||
|
import { useField } from '@formily/react';
|
||||||
|
import { toArr } from '@formily/shared';
|
||||||
|
import React from 'react';
|
||||||
|
import { defaultFieldNames } from './defaultFieldNames';
|
||||||
|
|
||||||
|
export const ReadPretty: React.FC<unknown> = (props: any) => {
|
||||||
|
const { fieldNames = defaultFieldNames } = props;
|
||||||
|
const values = toArr(props.value);
|
||||||
|
const len = values.length;
|
||||||
|
const field = useField<ArrayField>();
|
||||||
|
let dataSource = field.dataSource;
|
||||||
|
const data = [];
|
||||||
|
for (const item of values) {
|
||||||
|
if (typeof item === 'object') {
|
||||||
|
data.push(item);
|
||||||
|
} else {
|
||||||
|
const curr = dataSource?.find((v) => v[fieldNames.value] === item);
|
||||||
|
dataSource = curr?.[fieldNames.children] || [];
|
||||||
|
data.push(curr || { label: item, value: item });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{data.map((item, index) => {
|
||||||
|
return (
|
||||||
|
<span key={index}>
|
||||||
|
{typeof item === 'object' ? item[fieldNames.label] : item}
|
||||||
|
{len > index + 1 && ' / '}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,5 @@
|
|||||||
|
export const defaultFieldNames = {
|
||||||
|
label: 'label',
|
||||||
|
value: 'value',
|
||||||
|
children: 'children',
|
||||||
|
};
|
@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
group:
|
||||||
|
title: Schema Components
|
||||||
|
order: 3
|
||||||
|
---
|
||||||
|
|
||||||
|
# Cascader
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Cascader
|
||||||
|
|
||||||
|
<code src="./demos/demo1.tsx"></code>
|
||||||
|
|
||||||
|
### Asynchronous Data Source
|
||||||
|
|
||||||
|
<code src="./demos/demo2.tsx"></code>
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
基于 antd 的 [Cascader](https://ant.design/components/cascader/#API) 附加的一些属性:
|
||||||
|
|
||||||
|
- `labelInValue` 是否把每个选项的 label 包装到 value 中
|
||||||
|
- `changeOnSelectLast` 必须选到最后一级
|
||||||
|
- `useLoadData` 可调用 hook 的 loadData
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
useLoadData: (props) => {
|
||||||
|
// 这里可以写 hook
|
||||||
|
return function loadData(selectedOptions) {
|
||||||
|
// Cascader 的 loadData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
@ -0,0 +1 @@
|
|||||||
|
export * from './CustomCascader';
|
@ -1012,11 +1012,7 @@ export function useTitleFieldOptions() {
|
|||||||
const targetFields = collectionField?.target
|
const targetFields = collectionField?.target
|
||||||
? getCollectionFields(collectionField?.target)
|
? getCollectionFields(collectionField?.target)
|
||||||
: getCollectionFields(collectionField?.targetCollection) ?? [];
|
: getCollectionFields(collectionField?.targetCollection) ?? [];
|
||||||
const options = targetFields
|
const options = targetFields.map((field) => ({
|
||||||
.filter((field) => {
|
|
||||||
return isTitleField(field);
|
|
||||||
})
|
|
||||||
.map((field) => ({
|
|
||||||
value: field?.name,
|
value: field?.name,
|
||||||
label: compile(field?.uiSchema?.title) || field?.name,
|
label: compile(field?.uiSchema?.title) || field?.name,
|
||||||
}));
|
}));
|
||||||
|
@ -160,6 +160,7 @@ const ColDivider = (props) => {
|
|||||||
ref={setDraggableNodeRef}
|
ref={setDraggableNodeRef}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
|
tabIndex={-1}
|
||||||
className={props.first || props.last || !designable ? null : 'DraggableNode'}
|
className={props.first || props.last || !designable ? null : 'DraggableNode'}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -50,5 +50,6 @@ export * from './time-picker';
|
|||||||
export * from './tree-select';
|
export * from './tree-select';
|
||||||
export * from './upload';
|
export * from './upload';
|
||||||
export * from './variable';
|
export * from './variable';
|
||||||
|
export * from './custom-cascader';
|
||||||
|
|
||||||
import './index.less';
|
import './index.less';
|
||||||
|
@ -5,7 +5,7 @@ import React from 'react';
|
|||||||
export const Space: React.FC<SpaceProps> = (props) => {
|
export const Space: React.FC<SpaceProps> = (props) => {
|
||||||
let { split } = props;
|
let { split } = props;
|
||||||
if (split === '|') {
|
if (split === '|') {
|
||||||
split = <Divider type="vertical" style={{ margin: '0 2px' }} />;
|
split = <span> </span>;
|
||||||
}
|
}
|
||||||
const layout = useFormLayout();
|
const layout = useFormLayout();
|
||||||
return React.createElement(AntdSpace, {
|
return React.createElement(AntdSpace, {
|
||||||
|
@ -30,9 +30,7 @@ export const useLabelFields = (collectionName?: any) => {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const targetFields = getCollectionFields(collectionName);
|
const targetFields = getCollectionFields(collectionName);
|
||||||
return targetFields
|
return targetFields?.map?.((field) => {
|
||||||
?.filter?.((field) => field?.interface && !field?.target && field.type !== 'boolean' && !field.isForeignKey)
|
|
||||||
?.map?.((field) => {
|
|
||||||
return {
|
return {
|
||||||
value: field.name,
|
value: field.name,
|
||||||
label: compile(field?.uiSchema?.title || field.name),
|
label: compile(field?.uiSchema?.title || field.name),
|
||||||
@ -407,6 +405,7 @@ export const TableColumnDesigner = (props) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isAllowToSetDefaultValue(isSubTableColumn) && <SchemaSettingsDefaultValue fieldSchema={fieldSchema} />}
|
{isAllowToSetDefaultValue(isSubTableColumn) && <SchemaSettingsDefaultValue fieldSchema={fieldSchema} />}
|
||||||
|
{props.children}
|
||||||
<SchemaSettingsDivider />
|
<SchemaSettingsDivider />
|
||||||
<SchemaSettingsRemove
|
<SchemaSettingsRemove
|
||||||
removeParentsIfNoChildren={!isSubTableColumn}
|
removeParentsIfNoChildren={!isSubTableColumn}
|
||||||
|
@ -44,6 +44,7 @@ const useTableColumns = (props: { showDel?: boolean; isSubTable?: boolean }) =>
|
|||||||
const { designable } = useDesignable();
|
const { designable } = useDesignable();
|
||||||
const { exists, render } = useSchemaInitializerRender(schema['x-initializer'], schema['x-initializer-props']);
|
const { exists, render } = useSchemaInitializerRender(schema['x-initializer'], schema['x-initializer-props']);
|
||||||
const parentRecordData = useCollectionParentRecordData();
|
const parentRecordData = useCollectionParentRecordData();
|
||||||
|
const dataSource = field?.value?.slice?.()?.filter?.(Boolean) || [];
|
||||||
const columns = schema
|
const columns = schema
|
||||||
.reduceProperties((buf, s) => {
|
.reduceProperties((buf, s) => {
|
||||||
if (isColumnComponent(s) && schemaInWhitelist(Object.values(s.properties || {}).pop())) {
|
if (isColumnComponent(s) && schemaInWhitelist(Object.values(s.properties || {}).pop())) {
|
||||||
@ -102,9 +103,10 @@ const useTableColumns = (props: { showDel?: boolean; isSubTable?: boolean }) =>
|
|||||||
|
|
||||||
const tableColumns = columns.concat({
|
const tableColumns = columns.concat({
|
||||||
title: render(),
|
title: render(),
|
||||||
|
fixed: 'right',
|
||||||
dataIndex: 'TABLE_COLUMN_INITIALIZER',
|
dataIndex: 'TABLE_COLUMN_INITIALIZER',
|
||||||
key: 'TABLE_COLUMN_INITIALIZER',
|
key: 'TABLE_COLUMN_INITIALIZER',
|
||||||
render: designable ? () => <div style={{ minWidth: 300 }} /> : null,
|
render: designable ? () => <span /> : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (props.showDel) {
|
if (props.showDel) {
|
||||||
@ -120,6 +122,7 @@ const useTableColumns = (props: { showDel?: boolean; isSubTable?: boolean }) =>
|
|||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
action(() => {
|
action(() => {
|
||||||
|
const index = dataSource.indexOf(record);
|
||||||
spliceArrayState(field as any, {
|
spliceArrayState(field as any, {
|
||||||
startIndex: index,
|
startIndex: index,
|
||||||
deleteCount: 1,
|
deleteCount: 1,
|
||||||
@ -134,7 +137,12 @@ const useTableColumns = (props: { showDel?: boolean; isSubTable?: boolean }) =>
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return tableColumns;
|
|
||||||
|
return [
|
||||||
|
...tableColumns.filter((column) => column.fixed === 'left'),
|
||||||
|
...tableColumns.filter((column) => !column.fixed || (column.fixed !== 'left' && column.fixed !== 'right')),
|
||||||
|
...tableColumns.filter((column) => column.fixed === 'right'),
|
||||||
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
const SortableRow = (props) => {
|
const SortableRow = (props) => {
|
||||||
|
@ -46,11 +46,8 @@ const RequestSchemaComponent: React.FC<RemoteSchemaComponentProps> = (props) =>
|
|||||||
reset && reset();
|
reset && reset();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (loading) {
|
if (loading || hidden) {
|
||||||
return <Spin />;
|
return;
|
||||||
}
|
|
||||||
if (hidden) {
|
|
||||||
return <Spin />;
|
|
||||||
}
|
}
|
||||||
return noForm ? (
|
return noForm ? (
|
||||||
<SchemaComponent memoized components={components} scope={scope} schema={schemaTransform(data?.data || {})} />
|
<SchemaComponent memoized components={components} scope={scope} schema={schemaTransform(data?.data || {})} />
|
||||||
|
@ -55,6 +55,7 @@ export const useFieldModeOptions = (props?) => {
|
|||||||
{ label: t('Cascade Select'), value: 'CascadeSelect' },
|
{ label: t('Cascade Select'), value: 'CascadeSelect' },
|
||||||
!isTableField && { label: t('Sub-form'), value: 'Nester' },
|
!isTableField && { label: t('Sub-form'), value: 'Nester' },
|
||||||
{ label: t('Sub-form(Popover)'), value: 'PopoverNester' },
|
{ label: t('Sub-form(Popover)'), value: 'PopoverNester' },
|
||||||
|
{ label: t('Cascader'), value: 'Cascader' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
switch (collectionField.interface) {
|
switch (collectionField.interface) {
|
||||||
@ -101,6 +102,7 @@ export const useFieldModeOptions = (props?) => {
|
|||||||
{ label: t('Record picker'), value: 'Picker' },
|
{ label: t('Record picker'), value: 'Picker' },
|
||||||
!isTableField && { label: t('Sub-form'), value: 'Nester' },
|
!isTableField && { label: t('Sub-form'), value: 'Nester' },
|
||||||
{ label: t('Sub-form(Popover)'), value: 'PopoverNester' },
|
{ label: t('Sub-form(Popover)'), value: 'PopoverNester' },
|
||||||
|
{ label: t('Cascader'), value: 'Cascader' },
|
||||||
];
|
];
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
@ -9,6 +9,10 @@ export const TableActionColumnInitializer = () => {
|
|||||||
title: '{{ t("Actions") }}',
|
title: '{{ t("Actions") }}',
|
||||||
'x-decorator': 'TableV2.Column.ActionBar',
|
'x-decorator': 'TableV2.Column.ActionBar',
|
||||||
'x-component': 'TableV2.Column',
|
'x-component': 'TableV2.Column',
|
||||||
|
'x-component-props': {
|
||||||
|
width: 150,
|
||||||
|
fixed: 'right',
|
||||||
|
},
|
||||||
'x-designer': 'TableV2.ActionColumnDesigner',
|
'x-designer': 'TableV2.ActionColumnDesigner',
|
||||||
'x-initializer': 'TableActionColumnInitializers',
|
'x-initializer': 'TableActionColumnInitializers',
|
||||||
'x-action-column': 'actions',
|
'x-action-column': 'actions',
|
||||||
|
@ -1159,24 +1159,23 @@ export const createFormBlockSchema = (options) => {
|
|||||||
useProps: '{{ useFormBlockProps }}',
|
useProps: '{{ useFormBlockProps }}',
|
||||||
},
|
},
|
||||||
properties: {
|
properties: {
|
||||||
grid: template || {
|
|
||||||
type: 'void',
|
|
||||||
'x-component': 'Grid',
|
|
||||||
'x-initializer': formItemInitializers,
|
|
||||||
properties: {},
|
|
||||||
},
|
|
||||||
[uid()]: {
|
[uid()]: {
|
||||||
type: 'void',
|
type: 'void',
|
||||||
'x-initializer': actionInitializers,
|
'x-initializer': actionInitializers,
|
||||||
'x-component': 'ActionBar',
|
'x-component': 'ActionBar',
|
||||||
'x-component-props': {
|
'x-component-props': {
|
||||||
layout: 'one-column',
|
|
||||||
style: {
|
style: {
|
||||||
marginTop: 24,
|
marginBottom: 'var(--nb-spacing)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
properties: actions,
|
properties: actions,
|
||||||
},
|
},
|
||||||
|
grid: template || {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'Grid',
|
||||||
|
'x-initializer': formItemInitializers,
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -1381,6 +1380,10 @@ export const createTableBlockSchema = (options) => {
|
|||||||
'x-action-column': 'actions',
|
'x-action-column': 'actions',
|
||||||
'x-decorator': 'TableV2.Column.ActionBar',
|
'x-decorator': 'TableV2.Column.ActionBar',
|
||||||
'x-component': 'TableV2.Column',
|
'x-component': 'TableV2.Column',
|
||||||
|
'x-component-props': {
|
||||||
|
width: 150,
|
||||||
|
fixed: 'right',
|
||||||
|
},
|
||||||
'x-designer': 'TableV2.ActionColumnDesigner',
|
'x-designer': 'TableV2.ActionColumnDesigner',
|
||||||
'x-initializer': tableActionColumnInitializers ?? 'TableActionColumnInitializers',
|
'x-initializer': tableActionColumnInitializers ?? 'TableActionColumnInitializers',
|
||||||
properties: {
|
properties: {
|
||||||
|
@ -31,6 +31,7 @@ export const useParentRecordVariable = (props: Props) => {
|
|||||||
uiSchema: props.schema,
|
uiSchema: props.schema,
|
||||||
name: '$nParentRecord',
|
name: '$nParentRecord',
|
||||||
title: t('Parent record'),
|
title: t('Parent record'),
|
||||||
|
maxDepth: 5,
|
||||||
collectionName: props.collectionName,
|
collectionName: props.collectionName,
|
||||||
noDisabled: props.noDisabled,
|
noDisabled: props.noDisabled,
|
||||||
targetFieldSchema: props.targetFieldSchema,
|
targetFieldSchema: props.targetFieldSchema,
|
||||||
|
58
packages/core/client/src/unsafe.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
export {
|
||||||
|
removeGridFormItem,
|
||||||
|
useInheritsFormItemInitializerFields,
|
||||||
|
useFormItemInitializerFields,
|
||||||
|
useAssociatedFormItemInitializerFields,
|
||||||
|
useFilterInheritsFormItemInitializerFields,
|
||||||
|
useFilterFormItemInitializerFields,
|
||||||
|
} from './schema-initializer/utils';
|
||||||
|
export { parseVariables } from './schema-component/common/utils/uitls';
|
||||||
|
export { linkageAction } from './schema-component/antd/action/utils';
|
||||||
|
export { useCollectionState } from './schema-settings/DataTemplates/hooks/useCollectionState';
|
||||||
|
export { useSyncFromForm } from './schema-settings/DataTemplates/utils';
|
||||||
|
export { ColumnFieldProvider } from './schema-component/antd/table-v2/components/ColumnFieldProvider';
|
||||||
|
export { default as schema } from './schema-component/antd/association-field/schema';
|
||||||
|
export { ReadPrettyInternalViewer } from './schema-component/antd/association-field/InternalViewer';
|
||||||
|
export { useSetAriaLabelForPopover } from './schema-component/antd/action/hooks/useSetAriaLabelForPopover';
|
||||||
|
export { InternalFileManager } from './schema-component/antd/association-field/FileManager';
|
||||||
|
export { CreateRecordAction } from './schema-component/antd/association-field/components/CreateRecordAction';
|
||||||
|
export { AssociationSelect } from './schema-component/antd/association-field/AssociationSelect';
|
||||||
|
export { AssociationFieldProvider } from './schema-component/antd/association-field/AssociationFieldProvider';
|
||||||
|
export { InternalCascadeSelect } from './schema-component/antd/association-field/InternalCascadeSelect';
|
||||||
|
export { InternalNester } from './schema-component/antd/association-field/InternalNester';
|
||||||
|
export { InternalPicker } from './schema-component/antd/association-field/InternalPicker';
|
||||||
|
export { InternalSubTable } from './schema-component/antd/association-field/InternalSubTable';
|
||||||
|
export { InternaPopoverNester } from './schema-component/antd/association-field/InternalPopoverNester';
|
||||||
|
export { InternalCascader } from './schema-component/antd/association-field/InternalCascader';
|
||||||
|
export { Nester } from './schema-component/antd/association-field/Nester';
|
||||||
|
export { SubTable } from './schema-component/antd/association-field/SubTable';
|
||||||
|
export { ReadPretty } from './schema-component/antd/association-field/ReadPretty';
|
||||||
|
export { SubFormProvider } from './schema-component/antd/association-field/hooks';
|
||||||
|
export { useAssociationFieldContext } from './schema-component/antd/association-field/hooks';
|
||||||
|
export { FlagProvider } from './flag-provider';
|
||||||
|
export { isVariable } from './variables/utils/isVariable';
|
||||||
|
export { transformVariableValue } from './variables/utils/transformVariableValue';
|
||||||
|
export { useVariables } from './variables';
|
||||||
|
export { VariableInput, getShouldChange } from './schema-settings/VariableInput/VariableInput';
|
||||||
|
export { useLocalVariables } from './variables';
|
||||||
|
export { VariablesContext } from './variables/VariablesProvider';
|
||||||
|
export {
|
||||||
|
EditComponent,
|
||||||
|
EditDescription,
|
||||||
|
EditOperator,
|
||||||
|
EditTitle,
|
||||||
|
EditTitleField,
|
||||||
|
EditTooltip,
|
||||||
|
EditValidationRules,
|
||||||
|
} from './schema-component/antd/form-item/SchemaSettingOptions';
|
||||||
|
export type { DynamicComponentProps } from './schema-component/antd/filter/DynamicComponent';
|
||||||
|
export { DetailsBlockProvider, useDetailsBlockContext } from './block-provider/DetailsBlockProvider';
|
||||||
|
export { getInnermostKeyAndValue } from './schema-component/common/utils/uitls';
|
||||||
|
export { default as useServiceOptions } from './schema-component/antd/association-field/hooks';
|
||||||
|
export type { VariablesContextType, VariableOption } from './variables/types';
|
||||||
|
export type { Option } from './schema-settings/VariableInput/type';
|
||||||
|
export { useBlockCollection } from './schema-settings/VariableInput/hooks/useBlockCollection';
|
||||||
|
export { useValues } from './schema-component/antd/filter/useValues';
|
||||||
|
export { useVariableOptions } from './schema-settings/VariableInput/hooks/useVariableOptions';
|
||||||
|
export { useContextAssociationFields } from './schema-settings/VariableInput/hooks/useContextAssociationFields';
|
||||||
|
export { useRecordVariable } from './schema-settings/VariableInput/hooks/useRecordVariable';
|
@ -77,7 +77,11 @@ const VariablesProvider = ({ children }) => {
|
|||||||
if (current == null) {
|
if (current == null) {
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
if (list[index] === '$nParentRecord') {
|
||||||
|
if (!(list[index + 1] in current[list[index]]) && '__parent' in current[list[index]]) {
|
||||||
|
list.splice(1, 0, '__parent');
|
||||||
|
}
|
||||||
|
}
|
||||||
const key = list[index];
|
const key = list[index];
|
||||||
const associationField: CollectionFieldOptions_deprecated = getCollectionJoinField(
|
const associationField: CollectionFieldOptions_deprecated = getCollectionJoinField(
|
||||||
getFieldPath(list.slice(0, index + 1).join('.'), _variableToCollectionName),
|
getFieldPath(list.slice(0, index + 1).join('.'), _variableToCollectionName),
|
||||||
@ -109,7 +113,12 @@ const VariablesProvider = ({ children }) => {
|
|||||||
return item?.[key];
|
return item?.[key];
|
||||||
});
|
});
|
||||||
current = _.flatten(await Promise.all(result));
|
current = _.flatten(await Promise.all(result));
|
||||||
} else if (shouldToRequest(current[key]) && current.id != null && associationField?.target) {
|
} else if (
|
||||||
|
shouldToRequest(current[key]) &&
|
||||||
|
current.id != null &&
|
||||||
|
associationField?.target &&
|
||||||
|
associationField?.type != 'virtual'
|
||||||
|
) {
|
||||||
const url = `/${collectionName}/${current.id}/${key}:${getAction(associationField.type)}`;
|
const url = `/${collectionName}/${current.id}/${key}:${getAction(associationField.type)}`;
|
||||||
let data = null;
|
let data = null;
|
||||||
if (hasRequested(url)) {
|
if (hasRequested(url)) {
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@umijs/utils": "3.5.20",
|
"@umijs/utils": "3.5.20",
|
||||||
"axios": "^0.26.1",
|
"axios": "^1.6.2",
|
||||||
"chalk": "^4.1.1",
|
"chalk": "^4.1.1",
|
||||||
"commander": "^9.2.0",
|
"commander": "^9.2.0",
|
||||||
"tar": "6.1.11"
|
"tar": "6.1.11"
|
||||||
|
@ -42,7 +42,12 @@ export async function referentialIntegrityCheck(options: ReferentialIntegrityChe
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (onDelete === 'RESTRICT') {
|
if (onDelete === 'RESTRICT') {
|
||||||
throw new Error('RESTRICT');
|
const error = new Error(
|
||||||
|
`此数据被 ${
|
||||||
|
sourceCollection.options.title || sourceCollectionName
|
||||||
|
} 表关联,关联字段(as):${sourceField},不能删除!`,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onDelete === 'CASCADE') {
|
if (onDelete === 'CASCADE') {
|
||||||
|
@ -29,7 +29,8 @@
|
|||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"fast-glob": "^3.3.1",
|
"fast-glob": "^3.3.1",
|
||||||
"lerna": "^4.0.0",
|
"lerna": "^4.0.0",
|
||||||
"prettier": "^3.0.0",
|
"prettier": "^3.1.1",
|
||||||
|
"prettier-plugin-sql": "^0.17.0",
|
||||||
"pretty-format": "^24.0.0",
|
"pretty-format": "^24.0.0",
|
||||||
"pretty-quick": "^3.1.0",
|
"pretty-quick": "^3.1.0",
|
||||||
"react": "^18.0.0",
|
"react": "^18.0.0",
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
"types": "lib/index.d.ts",
|
"types": "lib/index.d.ts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.26.1",
|
"axios": "^1.6.2",
|
||||||
"qs": "^6.10.1"
|
"qs": "^6.10.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -26,7 +26,8 @@
|
|||||||
"@types/ini": "^1.3.31",
|
"@types/ini": "^1.3.31",
|
||||||
"@types/koa-send": "^4.1.3",
|
"@types/koa-send": "^4.1.3",
|
||||||
"@types/multer": "^1.4.5",
|
"@types/multer": "^1.4.5",
|
||||||
"axios": "^0.26.1",
|
"react": "^18.2.0",
|
||||||
|
"axios": "^1.6.2",
|
||||||
"chalk": "^4.1.1",
|
"chalk": "^4.1.1",
|
||||||
"commander": "^9.2.0",
|
"commander": "^9.2.0",
|
||||||
"cron": "^2.4.4",
|
"cron": "^2.4.4",
|
||||||
|
@ -44,6 +44,9 @@ import { ApplicationVersion } from './helpers/application-version';
|
|||||||
import { Locale } from './locale';
|
import { Locale } from './locale';
|
||||||
import { Plugin } from './plugin';
|
import { Plugin } from './plugin';
|
||||||
import { InstallOptions, PluginManager } from './plugin-manager';
|
import { InstallOptions, PluginManager } from './plugin-manager';
|
||||||
|
import { createClient } from 'redis';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
import { DataSourceManager, SequelizeDataSource } from '@nocobase/data-source-manager';
|
import { DataSourceManager, SequelizeDataSource } from '@nocobase/data-source-manager';
|
||||||
import packageJson from '../package.json';
|
import packageJson from '../package.json';
|
||||||
@ -157,6 +160,13 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
private _maintainingCommandStatus: MaintainingCommandStatus;
|
private _maintainingCommandStatus: MaintainingCommandStatus;
|
||||||
private _maintainingStatusBeforeCommand: MaintainingCommandStatus | null;
|
private _maintainingStatusBeforeCommand: MaintainingCommandStatus | null;
|
||||||
private _actionCommand: Command;
|
private _actionCommand: Command;
|
||||||
|
private redisClient = createClient({
|
||||||
|
url: process.env.REDIS_URL ?? 'redis://127.0.0.1:6379',
|
||||||
|
});
|
||||||
|
private redisPubClient = this.redisClient.duplicate();
|
||||||
|
private redisSubClient = this.redisClient.duplicate();
|
||||||
|
static KEY_CORE_APP_PREFIX = 'KEY_CORE_APP_';
|
||||||
|
private currentId = nanoid();
|
||||||
|
|
||||||
constructor(public options: ApplicationOptions) {
|
constructor(public options: ApplicationOptions) {
|
||||||
super();
|
super();
|
||||||
@ -164,6 +174,20 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
this.rawOptions = this.name == 'main' ? lodash.cloneDeep(options) : {};
|
this.rawOptions = this.name == 'main' ? lodash.cloneDeep(options) : {};
|
||||||
this.init();
|
this.init();
|
||||||
|
|
||||||
|
Promise.all([this.redisClient.connect(), this.redisPubClient.connect(), this.redisSubClient.connect()])
|
||||||
|
.then(() => {
|
||||||
|
console.log(`[APPLICATION] ${this.name} redis connected.`);
|
||||||
|
this.redisSubClient.SUBSCRIBE(Application.KEY_CORE_APP_PREFIX + this.name, async (data) => {
|
||||||
|
const payload = JSON.parse(data);
|
||||||
|
if (payload.id !== this.currentId) {
|
||||||
|
this.runAsCLI(payload.argv, payload.options, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
|
||||||
this._appSupervisor.addApp(this);
|
this._appSupervisor.addApp(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -605,7 +629,26 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
await this.pm.loadCommands();
|
await this.pm.loadCommands();
|
||||||
}
|
}
|
||||||
|
|
||||||
async runAsCLI(argv = process.argv, options?: ParseOptions & { throwError?: boolean; reqId?: string }) {
|
async runAsCLI(
|
||||||
|
argv = process.argv,
|
||||||
|
options?: ParseOptions & { throwError?: boolean; reqId?: string },
|
||||||
|
broadcast = true,
|
||||||
|
) {
|
||||||
|
if (broadcast) {
|
||||||
|
if (_.first(argv) === 'pm' || _.first(argv) === 'restart') {
|
||||||
|
console.log('[broadcast]:', argv);
|
||||||
|
setTimeout(() => {
|
||||||
|
this.redisPubClient.PUBLISH(
|
||||||
|
Application.KEY_CORE_APP_PREFIX + this.name,
|
||||||
|
JSON.stringify({
|
||||||
|
id: this.currentId,
|
||||||
|
argv: ['restart'],
|
||||||
|
options: { from: 'user' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (this.activatedCommand) {
|
if (this.activatedCommand) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@ import { IncomingMessage } from 'http';
|
|||||||
import { AppSupervisor } from '../app-supervisor';
|
import { AppSupervisor } from '../app-supervisor';
|
||||||
import { applyErrorWithArgs, getErrorWithCode } from './errors';
|
import { applyErrorWithArgs, getErrorWithCode } from './errors';
|
||||||
import lodash from 'lodash';
|
import lodash from 'lodash';
|
||||||
|
import { createClient } from 'redis';
|
||||||
import { Logger } from '@nocobase/logger';
|
import { Logger } from '@nocobase/logger';
|
||||||
|
|
||||||
declare class WebSocketWithId extends WebSocket {
|
declare class WebSocketWithId extends WebSocket {
|
||||||
@ -27,11 +28,32 @@ function getPayloadByErrorCode(code, options) {
|
|||||||
export class WSServer {
|
export class WSServer {
|
||||||
wss: WebSocket.Server;
|
wss: WebSocket.Server;
|
||||||
webSocketClients = new Map<string, WebSocketClient>();
|
webSocketClients = new Map<string, WebSocketClient>();
|
||||||
|
private redisClient = createClient({
|
||||||
|
url: process.env.REDIS_URL ?? 'redis://127.0.0.1:6379',
|
||||||
|
});
|
||||||
|
private redisPubClient = this.redisClient.duplicate();
|
||||||
|
private redisSubClient = this.redisClient.duplicate();
|
||||||
|
static KEY_CORE_MESSAGE = 'KEY_CORE_MESSAGE';
|
||||||
|
private currentId = nanoid();
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.wss = new WebSocketServer({ noServer: true });
|
this.wss = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
|
Promise.all([this.redisClient.connect(), this.redisPubClient.connect(), this.redisSubClient.connect()])
|
||||||
|
.then(() => {
|
||||||
|
console.log('[WSServer]: redis connected.');
|
||||||
|
this.redisSubClient.SUBSCRIBE(WSServer.KEY_CORE_MESSAGE, async (data) => {
|
||||||
|
const payload = JSON.parse(data);
|
||||||
|
if (payload.id !== this.currentId) {
|
||||||
|
this.sendToConnectionsByTag(payload.tagName, payload.tagValue, payload.sendMessage, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
|
||||||
this.wss.on('connection', (ws: WebSocketWithId, request: IncomingMessage) => {
|
this.wss.on('connection', (ws: WebSocketWithId, request: IncomingMessage) => {
|
||||||
const client = this.addNewConnection(ws, request);
|
const client = this.addNewConnection(ws, request);
|
||||||
|
|
||||||
@ -175,7 +197,18 @@ export class WSServer {
|
|||||||
client.ws.send(JSON.stringify(sendMessage));
|
client.ws.send(JSON.stringify(sendMessage));
|
||||||
}
|
}
|
||||||
|
|
||||||
sendToConnectionsByTag(tagName: string, tagValue: string, sendMessage: object) {
|
sendToConnectionsByTag(tagName: string, tagValue: string, sendMessage: object, broadcast = true) {
|
||||||
|
if (broadcast) {
|
||||||
|
this.redisPubClient.PUBLISH(
|
||||||
|
WSServer.KEY_CORE_MESSAGE,
|
||||||
|
JSON.stringify({
|
||||||
|
id: this.currentId,
|
||||||
|
tagName,
|
||||||
|
tagValue,
|
||||||
|
sendMessage,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
this.loopThroughConnections((client: WebSocketClient) => {
|
this.loopThroughConnections((client: WebSocketClient) => {
|
||||||
if (client.tags.includes(`${tagName}#${tagValue}`)) {
|
if (client.tags.includes(`${tagName}#${tagValue}`)) {
|
||||||
this.sendMessageToConnection(client, sendMessage);
|
this.sendMessageToConnection(client, sendMessage);
|
||||||
|
@ -44,7 +44,7 @@ const deps: Record<string, string> = {
|
|||||||
'pg-hstore': '2.x',
|
'pg-hstore': '2.x',
|
||||||
sqlite3: '5.x',
|
sqlite3: '5.x',
|
||||||
supertest: '6.x',
|
supertest: '6.x',
|
||||||
axios: '0.26.x',
|
axios: '1.x',
|
||||||
'@emotion/css': '11.x',
|
'@emotion/css': '11.x',
|
||||||
ahooks: '3.x',
|
ahooks: '3.x',
|
||||||
lodash: '4.x',
|
lodash: '4.x',
|
||||||
|
@ -12,6 +12,7 @@ import utc from 'dayjs/plugin/utc';
|
|||||||
import weekOfYear from 'dayjs/plugin/weekOfYear';
|
import weekOfYear from 'dayjs/plugin/weekOfYear';
|
||||||
import weekYear from 'dayjs/plugin/weekYear';
|
import weekYear from 'dayjs/plugin/weekYear';
|
||||||
import weekday from 'dayjs/plugin/weekday';
|
import weekday from 'dayjs/plugin/weekday';
|
||||||
|
import calendar from 'dayjs/plugin/calendar';
|
||||||
|
|
||||||
dayjs.extend(weekday);
|
dayjs.extend(weekday);
|
||||||
dayjs.extend(localeData);
|
dayjs.extend(localeData);
|
||||||
@ -26,5 +27,6 @@ dayjs.extend(weekOfYear);
|
|||||||
dayjs.extend(weekYear);
|
dayjs.extend(weekYear);
|
||||||
dayjs.extend(customParseFormat);
|
dayjs.extend(customParseFormat);
|
||||||
dayjs.extend(advancedFormat);
|
dayjs.extend(advancedFormat);
|
||||||
|
dayjs.extend(calendar);
|
||||||
|
|
||||||
export { dayjs };
|
export { dayjs };
|
||||||
|
61
packages/core/utils/src/decorators.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import Container, { Inject, Service } from './typedi';
|
||||||
|
|
||||||
|
// declare type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;
|
||||||
|
// declare type PropertyDecorator = (target: Object, propertyKey: string | symbol) => void;
|
||||||
|
// declare type MethodDecorator = <T>(
|
||||||
|
// target: Object,
|
||||||
|
// propertyKey: string | symbol,
|
||||||
|
// descriptor: TypedPropertyDescriptor<T>,
|
||||||
|
// ) => TypedPropertyDescriptor<T> | void;
|
||||||
|
// declare type ParameterDecorator = (
|
||||||
|
// target: Object,
|
||||||
|
// propertyKey: string | symbol | undefined,
|
||||||
|
// parameterIndex: number,
|
||||||
|
// ) => void;
|
||||||
|
|
||||||
|
export interface ActionDef {
|
||||||
|
type: string;
|
||||||
|
resourceName?: string;
|
||||||
|
actionName?: string;
|
||||||
|
method?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// init actions
|
||||||
|
Container.set({ id: 'actions', value: new Map<Function, ActionDef[]>() });
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return Inject('app');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Db() {
|
||||||
|
return Inject('db');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Controller(name: string) {
|
||||||
|
return function (target: any) {
|
||||||
|
const serviceOptions = { id: 'controller', multiple: true };
|
||||||
|
Service(serviceOptions)(target);
|
||||||
|
const actions = Container.get('actions') as Map<Function, ActionDef[]>;
|
||||||
|
if (!actions.has(target)) {
|
||||||
|
actions.set(target, []);
|
||||||
|
}
|
||||||
|
actions.get(target).push({
|
||||||
|
type: 'resource',
|
||||||
|
resourceName: name,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Action(name: string) {
|
||||||
|
return function (target: Object, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||||
|
const actions = Container.get('actions') as Map<Function, ActionDef[]>;
|
||||||
|
if (!actions.has(target.constructor)) {
|
||||||
|
actions.set(target.constructor, []);
|
||||||
|
}
|
||||||
|
actions.get(target.constructor).push({
|
||||||
|
type: 'action',
|
||||||
|
method: propertyKey,
|
||||||
|
actionName: name,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
@ -23,5 +23,7 @@ export * from './requireModule';
|
|||||||
export * from './toposort';
|
export * from './toposort';
|
||||||
export * from './uid';
|
export * from './uid';
|
||||||
export * from './url';
|
export * from './url';
|
||||||
|
export * from './typedi';
|
||||||
|
export * from './decorators';
|
||||||
|
|
||||||
export { dayjs, lodash };
|
export { dayjs, lodash };
|
||||||
|
488
packages/core/utils/src/typedi/container-instance.class.ts
Normal file
@ -0,0 +1,488 @@
|
|||||||
|
import { ServiceNotFoundError } from './error/service-not-found.error';
|
||||||
|
import { CannotInstantiateValueError } from './error/cannot-instantiate-value.error';
|
||||||
|
import { Token } from './token.class';
|
||||||
|
import { Constructable } from './types/constructable.type';
|
||||||
|
import { ServiceIdentifier } from './types/service-identifier.type';
|
||||||
|
import { ServiceMetadata } from './interfaces/service-metadata.interface';
|
||||||
|
import { ServiceOptions } from './interfaces/service-options.interface';
|
||||||
|
import { EMPTY_VALUE } from './empty.const';
|
||||||
|
import { ContainerIdentifier } from './types/container-identifier.type';
|
||||||
|
import { Handler } from './interfaces/handler.interface';
|
||||||
|
import { ContainerRegistry } from './container-registry.class';
|
||||||
|
import { ContainerScope } from './types/container-scope.type';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TypeDI can have multiple containers.
|
||||||
|
* One container is ContainerInstance.
|
||||||
|
*/
|
||||||
|
export class ContainerInstance {
|
||||||
|
/** Container instance id. */
|
||||||
|
public readonly id!: ContainerIdentifier;
|
||||||
|
|
||||||
|
/** Metadata for all registered services in this container. */
|
||||||
|
private metadataMap: Map<ServiceIdentifier, ServiceMetadata<unknown>> = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Services registered with 'multiple: true' are saved as simple services
|
||||||
|
* with a generated token and the mapping between the original ID and the
|
||||||
|
* generated one is stored here. This is handled like this to allow simplifying
|
||||||
|
* the inner workings of the service instance.
|
||||||
|
*/
|
||||||
|
private multiServiceIds: Map<ServiceIdentifier, { tokens: Token<unknown>[]; scope: ContainerScope }> = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All registered handlers. The @Inject() decorator uses handlers internally to mark a property for injection.
|
||||||
|
**/
|
||||||
|
private readonly handlers: Handler[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default global container. By default services are registered into this
|
||||||
|
* container when registered via `Container.set()` or `@Service` decorator.
|
||||||
|
*/
|
||||||
|
private static _default: ContainerInstance;
|
||||||
|
|
||||||
|
public static get default(): ContainerInstance {
|
||||||
|
if (!this._default) {
|
||||||
|
this._default = new ContainerInstance('default');
|
||||||
|
ContainerRegistry.registerContainer(this._default);
|
||||||
|
}
|
||||||
|
return this._default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates if the container has been disposed or not.
|
||||||
|
* Any function call should fail when called after being disposed.
|
||||||
|
*
|
||||||
|
* NOTE: Currently not in used
|
||||||
|
*/
|
||||||
|
private disposed = false;
|
||||||
|
|
||||||
|
constructor(id: ContainerIdentifier) {
|
||||||
|
this.id = id;
|
||||||
|
|
||||||
|
if (id !== 'default') {
|
||||||
|
ContainerRegistry.registerContainer(this);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO: This is to replicate the old functionality. This should be copied only
|
||||||
|
* TODO: if the container decides to inherit registered classes from a parent container.
|
||||||
|
*/
|
||||||
|
this.handlers = ContainerInstance.default.handlers || [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the service with given name or type is registered service container.
|
||||||
|
* Optionally, parameters can be passed in case if instance is initialized in the container for the first time.
|
||||||
|
*/
|
||||||
|
public has<T = unknown>(identifier: ServiceIdentifier<T>): boolean {
|
||||||
|
this.throwIfDisposed();
|
||||||
|
|
||||||
|
return !!this.metadataMap.has(identifier) || !!this.multiServiceIds.has(identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the service with given name or type from the service container.
|
||||||
|
* Optionally, parameters can be passed in case if instance is initialized in the container for the first time.
|
||||||
|
*/
|
||||||
|
public get<T = unknown>(identifier: ServiceIdentifier<T>): T {
|
||||||
|
this.throwIfDisposed();
|
||||||
|
|
||||||
|
const global = ContainerInstance.default.metadataMap.get(identifier);
|
||||||
|
const local = this.metadataMap.get(identifier);
|
||||||
|
/** If the service is registered as global we load it from there, otherwise we use the local one. */
|
||||||
|
const metadata = global?.scope === 'singleton' ? global : local;
|
||||||
|
|
||||||
|
/** This should never happen as multi services are masked with custom token in Container.set. */
|
||||||
|
if (metadata && metadata.multiple === true) {
|
||||||
|
throw new Error(`Cannot resolve multiple values for ${identifier.toString()} service!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Otherwise it's returned from the current container. */
|
||||||
|
if (metadata) {
|
||||||
|
return this.getServiceValue(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If it's the first time requested in the child container we load it from parent and set it.
|
||||||
|
* TODO: This will be removed with the container inheritance rework.
|
||||||
|
*/
|
||||||
|
if (global && this !== ContainerInstance.default) {
|
||||||
|
const clonedService = { ...global };
|
||||||
|
clonedService.value = EMPTY_VALUE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We need to immediately set the empty value from the root container
|
||||||
|
* to prevent infinite lookup in cyclic dependencies.
|
||||||
|
*/
|
||||||
|
this.set(clonedService);
|
||||||
|
|
||||||
|
const value = this.getServiceValue(clonedService);
|
||||||
|
this.set({ ...clonedService, value });
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ServiceNotFoundError(identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all instances registered in the container of the given service identifier.
|
||||||
|
* Used when service defined with multiple: true flag.
|
||||||
|
*/
|
||||||
|
public getMany<T = unknown>(identifier: ServiceIdentifier<T>): T[] {
|
||||||
|
this.throwIfDisposed();
|
||||||
|
|
||||||
|
const globalIdMap = ContainerInstance.default.multiServiceIds.get(identifier);
|
||||||
|
const localIdMap = this.multiServiceIds.get(identifier);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the service is registered as singleton we load it from default
|
||||||
|
* container, otherwise we use the local one.
|
||||||
|
*/
|
||||||
|
if (globalIdMap?.scope === 'singleton') {
|
||||||
|
return globalIdMap.tokens.map((generatedId) => ContainerInstance.default.get<T>(generatedId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localIdMap) {
|
||||||
|
return localIdMap.tokens.map((generatedId) => this.get<T>(generatedId));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ServiceNotFoundError(identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a value for the given type or service name in the container.
|
||||||
|
*/
|
||||||
|
public set<T = unknown>(serviceOptions: ServiceOptions<T>): this {
|
||||||
|
this.throwIfDisposed();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the service is marked as singleton, we set it in the default container.
|
||||||
|
* (And avoid an infinite loop via checking if we are in the default container or not.)
|
||||||
|
*/
|
||||||
|
if (serviceOptions.scope === 'singleton' && ContainerInstance.default !== this) {
|
||||||
|
ContainerInstance.default.set(serviceOptions);
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMetadata: ServiceMetadata<T> = {
|
||||||
|
/**
|
||||||
|
* Typescript cannot understand that if ID doesn't exists then type must exists based on the
|
||||||
|
* typing so we need to explicitly cast this to a `ServiceIdentifier`
|
||||||
|
*/
|
||||||
|
id: ((serviceOptions as any).id || (serviceOptions as any).type) as ServiceIdentifier,
|
||||||
|
type: (serviceOptions as ServiceMetadata<T>).type || null,
|
||||||
|
factory: (serviceOptions as ServiceMetadata<T>).factory,
|
||||||
|
value: (serviceOptions as ServiceMetadata<T>).value || EMPTY_VALUE,
|
||||||
|
multiple: serviceOptions.multiple || false,
|
||||||
|
eager: serviceOptions.eager || false,
|
||||||
|
scope: serviceOptions.scope || 'container',
|
||||||
|
/** We allow overriding the above options via the received config object. */
|
||||||
|
...serviceOptions,
|
||||||
|
referencedBy: new Map().set(this.id, this),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** If the incoming metadata is marked as multiple we mask the ID and continue saving as single value. */
|
||||||
|
if (serviceOptions.multiple) {
|
||||||
|
const maskedToken = new Token(`MultiMaskToken-${newMetadata.id.toString()}`);
|
||||||
|
const existingMultiGroup = this.multiServiceIds.get(newMetadata.id);
|
||||||
|
|
||||||
|
if (existingMultiGroup) {
|
||||||
|
existingMultiGroup.tokens.push(maskedToken);
|
||||||
|
} else {
|
||||||
|
this.multiServiceIds.set(newMetadata.id, { scope: newMetadata.scope, tokens: [maskedToken] });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We mask the original metadata with this generated ID, mark the service
|
||||||
|
* as and continue multiple: false and continue. Marking it as
|
||||||
|
* non-multiple is important otherwise Container.get would refuse to
|
||||||
|
* resolve the value.
|
||||||
|
*/
|
||||||
|
newMetadata.id = maskedToken;
|
||||||
|
newMetadata.multiple = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingMetadata = this.metadataMap.get(newMetadata.id);
|
||||||
|
|
||||||
|
if (existingMetadata) {
|
||||||
|
/** Service already exists, we overwrite it. (This is legacy behavior.) */
|
||||||
|
// TODO: Here we should differentiate based on the received set option.
|
||||||
|
Object.assign(existingMetadata, newMetadata);
|
||||||
|
} else {
|
||||||
|
/** This service hasn't been registered yet, so we register it. */
|
||||||
|
this.metadataMap.set(newMetadata.id, newMetadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the service is eager, we need to create an instance immediately except
|
||||||
|
* when the service is also marked as transient. In that case we ignore
|
||||||
|
* the eager flag to prevent creating a service what cannot be disposed later.
|
||||||
|
*/
|
||||||
|
if (newMetadata.eager && newMetadata.scope !== 'transient') {
|
||||||
|
this.get(newMetadata.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes services with a given service identifiers.
|
||||||
|
*/
|
||||||
|
public remove(identifierOrIdentifierArray: ServiceIdentifier | ServiceIdentifier[]): this {
|
||||||
|
this.throwIfDisposed();
|
||||||
|
|
||||||
|
if (Array.isArray(identifierOrIdentifierArray)) {
|
||||||
|
identifierOrIdentifierArray.forEach((id) => this.remove(id));
|
||||||
|
} else {
|
||||||
|
const serviceMetadata = this.metadataMap.get(identifierOrIdentifierArray);
|
||||||
|
|
||||||
|
if (serviceMetadata) {
|
||||||
|
this.disposeServiceInstance(serviceMetadata);
|
||||||
|
this.metadataMap.delete(identifierOrIdentifierArray);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a separate container instance for the given instance id.
|
||||||
|
*/
|
||||||
|
public of(containerId: ContainerIdentifier = 'default'): ContainerInstance {
|
||||||
|
this.throwIfDisposed();
|
||||||
|
|
||||||
|
if (containerId === 'default') {
|
||||||
|
return ContainerInstance.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
let container: ContainerInstance;
|
||||||
|
|
||||||
|
if (ContainerRegistry.hasContainer(containerId)) {
|
||||||
|
container = ContainerRegistry.getContainer(containerId);
|
||||||
|
} else {
|
||||||
|
/**
|
||||||
|
* This is deprecated functionality, for now we create the container if it's doesn't exists.
|
||||||
|
* This will be reworked when container inheritance is reworked.
|
||||||
|
*/
|
||||||
|
container = new ContainerInstance(containerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a new handler.
|
||||||
|
*/
|
||||||
|
public registerHandler(handler: Handler): ContainerInstance {
|
||||||
|
this.handlers.push(handler);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method that imports given services.
|
||||||
|
*/
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
|
||||||
|
public import(services: Function[]): ContainerInstance {
|
||||||
|
this.throwIfDisposed();
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Completely resets the container by removing all previously registered services from it.
|
||||||
|
*/
|
||||||
|
public reset(options: { strategy: 'resetValue' | 'resetServices' } = { strategy: 'resetValue' }): this {
|
||||||
|
this.throwIfDisposed();
|
||||||
|
|
||||||
|
switch (options.strategy) {
|
||||||
|
case 'resetValue':
|
||||||
|
this.metadataMap.forEach((service) => this.disposeServiceInstance(service));
|
||||||
|
break;
|
||||||
|
case 'resetServices':
|
||||||
|
this.metadataMap.forEach((service) => this.disposeServiceInstance(service));
|
||||||
|
this.metadataMap.clear();
|
||||||
|
this.multiServiceIds.clear();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error('Received invalid reset strategy.');
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async dispose(): Promise<void> {
|
||||||
|
this.reset({ strategy: 'resetServices' });
|
||||||
|
|
||||||
|
/** We mark the container as disposed, forbidding any further interaction with it. */
|
||||||
|
this.disposed = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder, this function returns a promise in preparation to support async services.
|
||||||
|
*/
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
private throwIfDisposed() {
|
||||||
|
if (this.disposed) {
|
||||||
|
// TODO: Use custom error.
|
||||||
|
throw new Error('Cannot use container after it has been disposed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the value belonging to passed in `ServiceMetadata` instance.
|
||||||
|
*
|
||||||
|
* - if `serviceMetadata.value` is already set it is immediately returned
|
||||||
|
* - otherwise the requested type is resolved to the value saved to `serviceMetadata.value` and returned
|
||||||
|
*/
|
||||||
|
private getServiceValue(serviceMetadata: ServiceMetadata<unknown>): any {
|
||||||
|
let value: unknown = EMPTY_VALUE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the service value has been set to anything prior to this call we return that value.
|
||||||
|
* NOTE: This part builds on the assumption that transient dependencies has no value set ever.
|
||||||
|
*/
|
||||||
|
if (serviceMetadata.value !== EMPTY_VALUE) {
|
||||||
|
return serviceMetadata.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** If both factory and type is missing, we cannot resolve the requested ID. */
|
||||||
|
if (!serviceMetadata.factory && typeof serviceMetadata.type === 'undefined') {
|
||||||
|
throw new CannotInstantiateValueError(serviceMetadata.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If a factory is defined it takes priority over creating an instance via `new`.
|
||||||
|
* The return value of the factory is not checked, we believe by design that the user knows what he/she is doing.
|
||||||
|
*/
|
||||||
|
if (serviceMetadata.factory) {
|
||||||
|
/**
|
||||||
|
* If we received the factory in the [Constructable<Factory>, "functionName"] format, we need to create the
|
||||||
|
* factory first and then call the specified function on it.
|
||||||
|
*/
|
||||||
|
if (serviceMetadata.factory instanceof Array) {
|
||||||
|
let factoryInstance;
|
||||||
|
|
||||||
|
try {
|
||||||
|
/** Try to get the factory from TypeDI first, if failed, fall back to simply initiating the class. */
|
||||||
|
factoryInstance = this.get<any>(serviceMetadata.factory[0]);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ServiceNotFoundError) {
|
||||||
|
factoryInstance = new serviceMetadata.factory[0]();
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
value = factoryInstance[serviceMetadata.factory[1]](this, serviceMetadata.id);
|
||||||
|
} else {
|
||||||
|
/** If only a simple function was provided we simply call it. */
|
||||||
|
value = serviceMetadata.factory(this, serviceMetadata.id);
|
||||||
|
}
|
||||||
|
} else if (typeof serviceMetadata.type === 'function') {
|
||||||
|
value = new serviceMetadata.type();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** If this is not a transient service, and we resolved something, then we set it as the value. */
|
||||||
|
if (serviceMetadata.scope !== 'transient' && value !== EMPTY_VALUE) {
|
||||||
|
serviceMetadata.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === EMPTY_VALUE) {
|
||||||
|
/** This branch should never execute, but better to be safe than sorry. */
|
||||||
|
throw new CannotInstantiateValueError(serviceMetadata.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serviceMetadata.type) {
|
||||||
|
this.applyPropertyHandlers(serviceMetadata.type, value as Record<string, any>);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes all parameter types for a given target service class.
|
||||||
|
*/
|
||||||
|
private initializeParams(target: Function, paramTypes: any[]): unknown[] {
|
||||||
|
return paramTypes.map((paramType, index) => {
|
||||||
|
const paramHandler =
|
||||||
|
this.handlers.find((handler) => {
|
||||||
|
/**
|
||||||
|
* @Inject()-ed values are stored as parameter handlers and they reference their target
|
||||||
|
* when created. So when a class is extended the @Inject()-ed values are not inherited
|
||||||
|
* because the handler still points to the old object only.
|
||||||
|
*
|
||||||
|
* As a quick fix a single level parent lookup is added via `Object.getPrototypeOf(target)`,
|
||||||
|
* however this should be updated to a more robust solution.
|
||||||
|
*
|
||||||
|
* TODO: Add proper inheritance handling: either copy the handlers when a class is registered what
|
||||||
|
* TODO: has it's parent already registered as dependency or make the lookup search up to the base Object.
|
||||||
|
*/
|
||||||
|
return handler.object === target && handler.index === index;
|
||||||
|
}) ||
|
||||||
|
this.handlers.find((handler) => {
|
||||||
|
return handler.object === Object.getPrototypeOf(target) && handler.index === index;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (paramHandler) return paramHandler.value(this);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
if (paramType && paramType.name && !this.isPrimitiveParamType(paramType.name)) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
return this.get(paramType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if given parameter type is primitive type or not.
|
||||||
|
*/
|
||||||
|
private isPrimitiveParamType(paramTypeName: string): boolean {
|
||||||
|
return ['string', 'boolean', 'number', 'object'].includes(paramTypeName.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies all registered handlers on a given target class.
|
||||||
|
*/
|
||||||
|
private applyPropertyHandlers(target: Function, instance: { [key: string]: any }) {
|
||||||
|
this.handlers.forEach((handler) => {
|
||||||
|
if (typeof handler.index === 'number') return;
|
||||||
|
if (handler.object.constructor !== target && !(target.prototype instanceof handler.object.constructor)) return;
|
||||||
|
|
||||||
|
if (handler.propertyName) {
|
||||||
|
instance[handler.propertyName] = handler.value(this);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the given service metadata contains a destroyable service instance and destroys it in place. If the service
|
||||||
|
* contains a callable function named `destroy` it is called but not awaited and the return value is ignored..
|
||||||
|
*
|
||||||
|
* @param serviceMetadata the service metadata containing the instance to destroy
|
||||||
|
* @param force when true the service will be always destroyed even if it's cannot be re-created
|
||||||
|
*/
|
||||||
|
private disposeServiceInstance(serviceMetadata: ServiceMetadata, force = false) {
|
||||||
|
this.throwIfDisposed();
|
||||||
|
|
||||||
|
/** We reset value only if we can re-create it (aka type or factory exists). */
|
||||||
|
const shouldResetValue = force || !!serviceMetadata.type || !!serviceMetadata.factory;
|
||||||
|
|
||||||
|
if (shouldResetValue) {
|
||||||
|
/** If we wound a function named destroy we call it without any params. */
|
||||||
|
if (typeof (serviceMetadata?.value as Record<string, unknown>)['dispose'] === 'function') {
|
||||||
|
try {
|
||||||
|
(serviceMetadata.value as { dispose: CallableFunction }).dispose();
|
||||||
|
} catch (error) {
|
||||||
|
/** We simply ignore the errors from the destroy function. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceMetadata.value = EMPTY_VALUE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
92
packages/core/utils/src/typedi/container-registry.class.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { ContainerInstance } from './container-instance.class';
|
||||||
|
import { ContainerIdentifier } from './types/container-identifier.type';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The container registry is responsible for holding the default and every
|
||||||
|
* created container instance for later access.
|
||||||
|
*
|
||||||
|
* _Note: This class is for internal use and it's API may break in minor or
|
||||||
|
* patch releases without warning._
|
||||||
|
*/
|
||||||
|
export class ContainerRegistry {
|
||||||
|
/**
|
||||||
|
* The list of all known container. Created containers are automatically added
|
||||||
|
* to this list. Two container cannot be registered with the same ID.
|
||||||
|
*
|
||||||
|
* This map doesn't contains the default container.
|
||||||
|
*/
|
||||||
|
private static readonly containerMap: Map<ContainerIdentifier, ContainerInstance> = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers the given container instance or throws an error.
|
||||||
|
*
|
||||||
|
* _Note: This function is auto-called when a Container instance is created,
|
||||||
|
* it doesn't need to be called manually!_
|
||||||
|
*
|
||||||
|
* @param container the container to add to the registry
|
||||||
|
*/
|
||||||
|
public static registerContainer(container: ContainerInstance): void {
|
||||||
|
if (container instanceof ContainerInstance === false) {
|
||||||
|
// TODO: Create custom error for this.
|
||||||
|
throw new Error('Only ContainerInstance instances can be registered.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ContainerRegistry.containerMap.has(container.id)) {
|
||||||
|
// TODO: Create custom error for this.
|
||||||
|
throw new Error('Cannot register container with same ID.');
|
||||||
|
}
|
||||||
|
|
||||||
|
ContainerRegistry.containerMap.set(container.id, container);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if a container exists with the given ID or false otherwise.
|
||||||
|
*
|
||||||
|
* @param container the ID of the container
|
||||||
|
*/
|
||||||
|
public static hasContainer(id: ContainerIdentifier): boolean {
|
||||||
|
return ContainerRegistry.containerMap.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the container for requested ID or throws an error if no container
|
||||||
|
* is registered with the given ID.
|
||||||
|
*
|
||||||
|
* @param container the ID of the container
|
||||||
|
*/
|
||||||
|
public static getContainer(id: ContainerIdentifier): ContainerInstance {
|
||||||
|
const registeredContainer = this.containerMap.get(id);
|
||||||
|
|
||||||
|
if (registeredContainer === undefined) {
|
||||||
|
// TODO: Create custom error for this.
|
||||||
|
throw new Error('No container is registered with the given ID.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return registeredContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the given container from the registry and disposes all services
|
||||||
|
* registered only in this container.
|
||||||
|
*
|
||||||
|
* This function throws an error if no
|
||||||
|
* - container exists with the given ID
|
||||||
|
* - any of the registered services threw an error during it's disposal
|
||||||
|
*
|
||||||
|
* @param container the container to remove from the registry
|
||||||
|
*/
|
||||||
|
public static async removeContainer(container: ContainerInstance): Promise<void> {
|
||||||
|
const registeredContainer = ContainerRegistry.containerMap.get(container.id);
|
||||||
|
|
||||||
|
if (registeredContainer === undefined) {
|
||||||
|
// TODO: Create custom error for this.
|
||||||
|
throw new Error('No container is registered with the given ID.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** We remove the container first. */
|
||||||
|
ContainerRegistry.containerMap.delete(container.id);
|
||||||
|
|
||||||
|
/** We dispose all registered classes in the container. */
|
||||||
|
await registeredContainer.dispose();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,42 @@
|
|||||||
|
import { Token } from '../token.class';
|
||||||
|
import { CannotInjectValueError } from '../error/cannot-inject-value.error';
|
||||||
|
import { resolveToTypeWrapper } from '../utils/resolve-to-type-wrapper.util';
|
||||||
|
import { Constructable } from '../types/constructable.type';
|
||||||
|
import { ServiceIdentifier } from '../types/service-identifier.type';
|
||||||
|
import { ContainerInstance } from '../container-instance.class';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injects a list of services into a class property or constructor parameter.
|
||||||
|
*/
|
||||||
|
export function InjectMany(): Function;
|
||||||
|
export function InjectMany(type?: (type?: any) => Function): Function;
|
||||||
|
export function InjectMany(serviceName?: string): Function;
|
||||||
|
export function InjectMany(token: Token<any>): Function;
|
||||||
|
export function InjectMany(
|
||||||
|
typeOrIdentifier?: ((type?: never) => Constructable<unknown>) | ServiceIdentifier<unknown>,
|
||||||
|
): Function {
|
||||||
|
return function (target: Object, propertyName: string | Symbol, index?: number): void {
|
||||||
|
const typeWrapper = resolveToTypeWrapper(typeOrIdentifier, target, propertyName, index);
|
||||||
|
|
||||||
|
/** If no type was inferred, or the general Object type was inferred we throw an error. */
|
||||||
|
if (typeWrapper === undefined || typeWrapper.eagerType === undefined || typeWrapper.eagerType === Object) {
|
||||||
|
throw new CannotInjectValueError(target as Constructable<unknown>, propertyName as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
ContainerInstance.default.registerHandler({
|
||||||
|
object: target as Constructable<unknown>,
|
||||||
|
propertyName: propertyName as string,
|
||||||
|
index: index,
|
||||||
|
value: (containerInstance) => {
|
||||||
|
const evaluatedLazyType = typeWrapper.lazyType();
|
||||||
|
|
||||||
|
/** If no type was inferred lazily, or the general Object type was inferred we throw an error. */
|
||||||
|
if (evaluatedLazyType === undefined || evaluatedLazyType === Object) {
|
||||||
|
throw new CannotInjectValueError(target as Constructable<unknown>, propertyName as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
return containerInstance.getMany<unknown>(evaluatedLazyType);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,42 @@
|
|||||||
|
import { Token } from '../token.class';
|
||||||
|
import { CannotInjectValueError } from '../error/cannot-inject-value.error';
|
||||||
|
import { ServiceIdentifier } from '../types/service-identifier.type';
|
||||||
|
import { Constructable } from '../types/constructable.type';
|
||||||
|
import { resolveToTypeWrapper } from '../utils/resolve-to-type-wrapper.util';
|
||||||
|
import { ContainerInstance } from '../container-instance.class';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injects a service into a class property or constructor parameter.
|
||||||
|
*/
|
||||||
|
export function Inject(): Function;
|
||||||
|
export function Inject(typeFn: (type?: never) => Constructable<unknown>): Function;
|
||||||
|
export function Inject(serviceName?: string): Function;
|
||||||
|
export function Inject(token: Token<unknown>): Function;
|
||||||
|
export function Inject(
|
||||||
|
typeOrIdentifier?: ((type?: never) => Constructable<unknown>) | ServiceIdentifier<unknown>,
|
||||||
|
): ParameterDecorator | PropertyDecorator {
|
||||||
|
return function (target: Object, propertyName: string | Symbol, index?: number): void {
|
||||||
|
const typeWrapper = resolveToTypeWrapper(typeOrIdentifier, target, propertyName, index);
|
||||||
|
|
||||||
|
/** If no type was inferred, or the general Object type was inferred we throw an error. */
|
||||||
|
if (typeWrapper === undefined || typeWrapper.eagerType === undefined || typeWrapper.eagerType === Object) {
|
||||||
|
throw new CannotInjectValueError(target as Constructable<unknown>, propertyName as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
ContainerInstance.default.registerHandler({
|
||||||
|
object: target as Constructable<unknown>,
|
||||||
|
propertyName: propertyName as string,
|
||||||
|
index: index,
|
||||||
|
value: (containerInstance) => {
|
||||||
|
const evaluatedLazyType = typeWrapper.lazyType();
|
||||||
|
|
||||||
|
/** If no type was inferred lazily, or the general Object type was inferred we throw an error. */
|
||||||
|
if (evaluatedLazyType === undefined || evaluatedLazyType === Object) {
|
||||||
|
throw new CannotInjectValueError(target as Constructable<unknown>, propertyName as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
return containerInstance.get<unknown>(evaluatedLazyType);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
import { ServiceMetadata } from '../interfaces/service-metadata.interface';
|
||||||
|
import { ServiceOptions } from '../interfaces/service-options.interface';
|
||||||
|
import { EMPTY_VALUE } from '../empty.const';
|
||||||
|
import { Constructable } from '../types/constructable.type';
|
||||||
|
import { ContainerInstance } from '../container-instance.class';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks class as a service that can be injected using Container.
|
||||||
|
*/
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
|
||||||
|
export function Service<T = unknown>(): Function;
|
||||||
|
export function Service<T = unknown>(options: ServiceOptions<T>): Function;
|
||||||
|
export function Service<T>(options: ServiceOptions<T> = {}): ClassDecorator {
|
||||||
|
return (targetConstructor) => {
|
||||||
|
const serviceMetadata: ServiceMetadata<T> = {
|
||||||
|
id: options.id || targetConstructor,
|
||||||
|
type: targetConstructor as unknown as Constructable<T>,
|
||||||
|
factory: (options as any).factory || undefined,
|
||||||
|
multiple: options.multiple || false,
|
||||||
|
eager: options.eager || false,
|
||||||
|
scope: options.scope || 'container',
|
||||||
|
referencedBy: new Map().set(ContainerInstance.default.id, ContainerInstance.default),
|
||||||
|
value: EMPTY_VALUE,
|
||||||
|
};
|
||||||
|
|
||||||
|
ContainerInstance.default.set(serviceMetadata);
|
||||||
|
};
|
||||||
|
}
|
6
packages/core/utils/src/typedi/empty.const.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Indicates that a service has not been initialized yet.
|
||||||
|
*
|
||||||
|
* _Note: This value is for internal use only._
|
||||||
|
*/
|
||||||
|
export const EMPTY_VALUE = Symbol('EMPTY_VALUE');
|
@ -0,0 +1,22 @@
|
|||||||
|
import { Constructable } from '../types/constructable.type';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when DI cannot inject value into property decorated by @Inject decorator.
|
||||||
|
*/
|
||||||
|
export class CannotInjectValueError extends Error {
|
||||||
|
public name = 'CannotInjectValueError';
|
||||||
|
|
||||||
|
get message(): string {
|
||||||
|
return (
|
||||||
|
`Cannot inject value into "${this.target.constructor.name}.${this.propertyName}". ` +
|
||||||
|
`Please make sure you setup reflect-metadata properly and you don't use interfaces without service tokens as injection value.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private target: Constructable<unknown>,
|
||||||
|
private propertyName: string,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,34 @@
|
|||||||
|
import { ServiceIdentifier } from '../types/service-identifier.type';
|
||||||
|
import { Token } from '../token.class';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when DI cannot inject value into property decorated by @Inject decorator.
|
||||||
|
*/
|
||||||
|
export class CannotInstantiateValueError extends Error {
|
||||||
|
public name = 'CannotInstantiateValueError';
|
||||||
|
|
||||||
|
/** Normalized identifier name used in the error message. */
|
||||||
|
private normalizedIdentifier = '<UNKNOWN_IDENTIFIER>';
|
||||||
|
|
||||||
|
get message(): string {
|
||||||
|
return (
|
||||||
|
`Cannot instantiate the requested value for the "${this.normalizedIdentifier}" identifier. ` +
|
||||||
|
`The related metadata doesn't contain a factory or a type to instantiate.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(identifier: ServiceIdentifier) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
// TODO: Extract this to a helper function and share between this and NotFoundError.
|
||||||
|
if (typeof identifier === 'string') {
|
||||||
|
this.normalizedIdentifier = identifier;
|
||||||
|
} else if (identifier instanceof Token) {
|
||||||
|
this.normalizedIdentifier = `Token<${identifier.name || 'UNSET_NAME'}>`;
|
||||||
|
} else if (identifier && (identifier.name || identifier.prototype?.name)) {
|
||||||
|
this.normalizedIdentifier =
|
||||||
|
`MaybeConstructable<${identifier.name}>` ||
|
||||||
|
`MaybeConstructable<${(identifier.prototype as { name: string })?.name}>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,33 @@
|
|||||||
|
import { ServiceIdentifier } from '../types/service-identifier.type';
|
||||||
|
import { Token } from '../token.class';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when requested service was not found.
|
||||||
|
*/
|
||||||
|
export class ServiceNotFoundError extends Error {
|
||||||
|
public name = 'ServiceNotFoundError';
|
||||||
|
|
||||||
|
/** Normalized identifier name used in the error message. */
|
||||||
|
private normalizedIdentifier = '<UNKNOWN_IDENTIFIER>';
|
||||||
|
|
||||||
|
get message(): string {
|
||||||
|
return (
|
||||||
|
`Service with "${this.normalizedIdentifier}" identifier was not found in the container. ` +
|
||||||
|
`Register it before usage via explicitly calling the "Container.set" function or using the "@Service()" decorator.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(identifier: ServiceIdentifier) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
if (typeof identifier === 'string') {
|
||||||
|
this.normalizedIdentifier = identifier;
|
||||||
|
} else if (identifier instanceof Token) {
|
||||||
|
this.normalizedIdentifier = `Token<${identifier.name || 'UNSET_NAME'}>`;
|
||||||
|
} else if (identifier && (identifier.name || identifier.prototype?.name)) {
|
||||||
|
this.normalizedIdentifier =
|
||||||
|
`MaybeConstructable<${identifier.name}>` ||
|
||||||
|
`MaybeConstructable<${(identifier.prototype as { name: string })?.name}>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
22
packages/core/utils/src/typedi/index.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { ContainerInstance } from './container-instance.class';
|
||||||
|
|
||||||
|
export * from './decorators/inject-many.decorator';
|
||||||
|
export * from './decorators/inject.decorator';
|
||||||
|
export * from './decorators/service.decorator';
|
||||||
|
|
||||||
|
export * from './error/cannot-inject-value.error';
|
||||||
|
export * from './error/cannot-instantiate-value.error';
|
||||||
|
export * from './error/service-not-found.error';
|
||||||
|
|
||||||
|
export { Handler } from './interfaces/handler.interface';
|
||||||
|
export { ServiceMetadata } from './interfaces/service-metadata.interface';
|
||||||
|
export { ServiceOptions } from './interfaces/service-options.interface';
|
||||||
|
export { Constructable } from './types/constructable.type';
|
||||||
|
export { ServiceIdentifier } from './types/service-identifier.type';
|
||||||
|
|
||||||
|
export { ContainerInstance } from './container-instance.class';
|
||||||
|
export { Token } from './token.class';
|
||||||
|
|
||||||
|
/** We export the default container under the Container alias. */
|
||||||
|
export const Container = ContainerInstance.default;
|
||||||
|
export default Container;
|
@ -0,0 +1,48 @@
|
|||||||
|
export interface ContainerOptions {
|
||||||
|
/**
|
||||||
|
* Controls the behavior when a service is already registered with the same ID. The following values are allowed:
|
||||||
|
*
|
||||||
|
* - `throw` - a `ContainerCannotBeCreatedError` error is raised
|
||||||
|
* - `overwrite` - the previous container is disposed and a new one is created
|
||||||
|
* - `returnExisting` - returns the existing container or raises a `ContainerCannotBeCreatedError` error if the
|
||||||
|
* specified options differ from the options of the existing container
|
||||||
|
*
|
||||||
|
* The default value is `returnExisting`.
|
||||||
|
*/
|
||||||
|
onConflict: 'throw' | 'overwrite' | 'returnExisting';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controls the behavior when a requested type doesn't exists in the current container. The following values are allowed:
|
||||||
|
*
|
||||||
|
* - `allowLookup` - the parent container will be checked for the dependency
|
||||||
|
* - `localOnly` - a `ServiceNotFoundError` error is raised
|
||||||
|
*
|
||||||
|
* The default value is `allowLookup`.
|
||||||
|
*/
|
||||||
|
lookupStrategy: 'allowLookup' | 'localOnly';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enables the lookup for global (singleton) services before checking in the current container. By default every
|
||||||
|
* type is first checked in the default container to return singleton services. This check bypasses the lookup strategy,
|
||||||
|
* set in the container so if this behavior is not desired it can be disabled via this flag.
|
||||||
|
*
|
||||||
|
* The default value is `true`.
|
||||||
|
*/
|
||||||
|
allowSingletonLookup: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controls how the child container inherits the service definitions from it's parent. The following values are allowed:
|
||||||
|
*
|
||||||
|
* - `none` - no metadata is inherited
|
||||||
|
* - `definitionOnly` - only metadata is inherited, a new instance will be created for each class
|
||||||
|
* - eager classes created as soon as the container is created
|
||||||
|
* - non-eager classes are created the first time they are requested
|
||||||
|
* - `definitionWithValues` - both metadata and service instances are inherited
|
||||||
|
* - when parent class is disposed the instances in this container are preserved
|
||||||
|
* - if a service is registered but not created yet, it will be shared when created between the two container
|
||||||
|
* - newly registered services won't be shared between the two container
|
||||||
|
*
|
||||||
|
* The default value is `none`.
|
||||||
|
*/
|
||||||
|
inheritanceStrategy: 'none' | 'definitionOnly' | 'definitionWithValues';
|
||||||
|
}
|
@ -0,0 +1,31 @@
|
|||||||
|
import { ContainerInstance } from '../container-instance.class';
|
||||||
|
import { Constructable } from '../types/constructable.type';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to register special "handler" which will be executed on a service class during its initialization.
|
||||||
|
* It can be used to create custom decorators and set/replace service class properties and constructor parameters.
|
||||||
|
*/
|
||||||
|
export interface Handler<T = unknown> {
|
||||||
|
/**
|
||||||
|
* Service object used to apply handler to.
|
||||||
|
*/
|
||||||
|
object: Constructable<T>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class property name to set/replace value of.
|
||||||
|
* Used if handler is applied on a class property.
|
||||||
|
*/
|
||||||
|
propertyName?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameter index to set/replace value of.
|
||||||
|
* Used if handler is applied on a constructor parameter.
|
||||||
|
*/
|
||||||
|
index?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function that produces value that will be set to class property or constructor parameter.
|
||||||
|
* Accepts container instance which requested the value.
|
||||||
|
*/
|
||||||
|
value: (container: ContainerInstance) => any;
|
||||||
|
}
|
@ -0,0 +1,61 @@
|
|||||||
|
import { ContainerInstance } from '../container-instance.class';
|
||||||
|
import { Constructable } from '../types/constructable.type';
|
||||||
|
import { ContainerIdentifier } from '../types/container-identifier.type';
|
||||||
|
import { ContainerScope } from '../types/container-scope.type';
|
||||||
|
import { ServiceIdentifier } from '../types/service-identifier.type';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service metadata is used to initialize service and store its state.
|
||||||
|
*/
|
||||||
|
export interface ServiceMetadata<Type = unknown> {
|
||||||
|
/** Unique identifier of the referenced service. */
|
||||||
|
id: ServiceIdentifier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The injection scope for the service.
|
||||||
|
* - a `singleton` service always will be created in the default container regardless of who registering it
|
||||||
|
* - a `container` scoped service will be created once when requested from the given container
|
||||||
|
* - a `transient` service will be created each time it is requested
|
||||||
|
*/
|
||||||
|
scope: ContainerScope;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class definition of the service what is used to initialize given service.
|
||||||
|
* This property maybe null if the value of the service is set manually.
|
||||||
|
* If id is not set then it serves as service id.
|
||||||
|
*/
|
||||||
|
type: Constructable<Type> | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function used to initialize this service.
|
||||||
|
* Can be regular function ("createCar" for example),
|
||||||
|
* or other service which produces this instance ([CarFactory, "createCar"] for example).
|
||||||
|
*/
|
||||||
|
factory: [Constructable<unknown>, string] | CallableFunction | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instance of the target class.
|
||||||
|
*/
|
||||||
|
value: unknown | Symbol;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows to setup multiple instances the different classes under a single service id string or token.
|
||||||
|
*/
|
||||||
|
multiple: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates whether a new instance should be created as soon as the class is registered.
|
||||||
|
* By default the registered classes are only instantiated when they are requested from the container.
|
||||||
|
*
|
||||||
|
* _Note: This option is ignored for transient services._
|
||||||
|
*/
|
||||||
|
eager: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of containers referencing this metadata. This is used when a container
|
||||||
|
* is inheriting it's parents definitions and values to track the lifecycle of
|
||||||
|
* the metadata. Namely, a service can be disposed only if it's only referenced
|
||||||
|
* by the container being disposed.
|
||||||
|
*/
|
||||||
|
referencedBy: Map<ContainerIdentifier, ContainerInstance>;
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
import { ServiceMetadata } from './service-metadata.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public ServiceOptions is partial object of ServiceMetadata and either one
|
||||||
|
* of the following is set: `type`, `factory`, `value` but not more than one.
|
||||||
|
*/
|
||||||
|
export type ServiceOptions<T = unknown> =
|
||||||
|
| Omit<Partial<ServiceMetadata<T>>, 'referencedBy' | 'type' | 'factory'>
|
||||||
|
| Omit<Partial<ServiceMetadata<T>>, 'referencedBy' | 'value' | 'factory'>
|
||||||
|
| Omit<Partial<ServiceMetadata<T>>, 'referencedBy' | 'value' | 'type'>;
|
11
packages/core/utils/src/typedi/token.class.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Used to create unique typed service identifier.
|
||||||
|
* Useful when service has only interface, but don't have a class.
|
||||||
|
*/
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
|
||||||
|
export class Token<T> {
|
||||||
|
/**
|
||||||
|
* @param name Token name, optional and only used for debugging purposes.
|
||||||
|
*/
|
||||||
|
constructor(public name?: string) {}
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Generic type for abstract class definitions.
|
||||||
|
*
|
||||||
|
* Explanation: This describes a newable Function with a prototype Which is
|
||||||
|
* what an abstract class is - no constructor, just the prototype.
|
||||||
|
*/
|
||||||
|
export type AbstractConstructable<T> = NewableFunction & { prototype: T };
|
10
packages/core/utils/src/typedi/types/constructable.type.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Generic type for class definitions.
|
||||||
|
* Example usage:
|
||||||
|
* ```
|
||||||
|
* function createSomeInstance(myClassDefinition: Constructable<MyClass>) {
|
||||||
|
* return new myClassDefinition()
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export type Constructable<T> = new (...args: any[]) => T;
|
@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* A container identifier. This value must be unique across all containers.
|
||||||
|
*/
|
||||||
|
export type ContainerIdentifier = string | Symbol;
|
@ -0,0 +1 @@
|
|||||||
|
export type ContainerScope = 'singleton' | 'container' | 'transient';
|
@ -0,0 +1,14 @@
|
|||||||
|
import { Token } from '../token.class';
|
||||||
|
import { Constructable } from './constructable.type';
|
||||||
|
import { AbstractConstructable } from './abstract-constructable.type';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unique service identifier.
|
||||||
|
* Can be some class type, or string id, or instance of Token.
|
||||||
|
*/
|
||||||
|
export type ServiceIdentifier<T = unknown> =
|
||||||
|
| Constructable<T>
|
||||||
|
| AbstractConstructable<T>
|
||||||
|
| CallableFunction
|
||||||
|
| Token<T>
|
||||||
|
| string;
|
@ -0,0 +1,43 @@
|
|||||||
|
import { Token } from '../token.class';
|
||||||
|
import { Constructable } from '../types/constructable.type';
|
||||||
|
import { ServiceIdentifier } from '../types/service-identifier.type';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function used in inject decorators to resolve the received identifier to
|
||||||
|
* an eager type when possible or to a lazy type when cyclic dependencies are possibly involved.
|
||||||
|
*
|
||||||
|
* @param typeOrIdentifier a service identifier or a function returning a type acting as service identifier or nothing
|
||||||
|
* @param target the class definition of the target of the decorator
|
||||||
|
* @param propertyName the name of the property in case of a PropertyDecorator
|
||||||
|
* @param index the index of the parameter in the constructor in case of ParameterDecorator
|
||||||
|
*/
|
||||||
|
export function resolveToTypeWrapper(
|
||||||
|
typeOrIdentifier: ((type?: never) => Constructable<unknown>) | ServiceIdentifier<unknown> | undefined,
|
||||||
|
target: Object,
|
||||||
|
propertyName: string | Symbol,
|
||||||
|
index?: number,
|
||||||
|
): { eagerType: ServiceIdentifier | null; lazyType: (type?: never) => ServiceIdentifier } {
|
||||||
|
/**
|
||||||
|
* ? We want to error out as soon as possible when looking up services to inject, however
|
||||||
|
* ? we cannot determine the type at decorator execution when cyclic dependencies are involved
|
||||||
|
* ? because calling the received `() => MyType` function right away would cause a JS error:
|
||||||
|
* ? "Cannot access 'MyType' before initialization", so we need to execute the function in the handler,
|
||||||
|
* ? when the classes are already created. To overcome this, we use a wrapper:
|
||||||
|
* ? - the lazyType is executed in the handler so we never have a JS error
|
||||||
|
* ? - the eagerType is checked when decorator is running and an error is raised if an unknown type is encountered
|
||||||
|
*/
|
||||||
|
let typeWrapper!: { eagerType: ServiceIdentifier | null; lazyType: (type?: never) => ServiceIdentifier };
|
||||||
|
|
||||||
|
/** If requested type is explicitly set via a string ID or token, we set it explicitly. */
|
||||||
|
if ((typeOrIdentifier && typeof typeOrIdentifier === 'string') || typeOrIdentifier instanceof Token) {
|
||||||
|
typeWrapper = { eagerType: typeOrIdentifier, lazyType: () => typeOrIdentifier };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** If requested type is explicitly set via a () => MyClassType format, we set it explicitly. */
|
||||||
|
if (typeOrIdentifier && typeof typeOrIdentifier === 'function') {
|
||||||
|
/** We set eagerType to null, preventing the raising of the CannotInjectValueError in decorators. */
|
||||||
|
typeWrapper = { eagerType: null, lazyType: () => (typeOrIdentifier as CallableFunction)() };
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeWrapper;
|
||||||
|
}
|
@ -1,11 +1,17 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Popover, Space, Tag } from 'antd';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
export type Transformer = (val: any, locale?: string) => string | number;
|
export type Transformer = (val: any, locale?: string) => string | number | React.JSX.Element;
|
||||||
|
|
||||||
const transformers: {
|
const transformers: {
|
||||||
[key: string]: {
|
[key: string]: {
|
||||||
|
[key: string]:
|
||||||
|
| Transformer
|
||||||
|
| {
|
||||||
[key: string]: Transformer;
|
[key: string]: Transformer;
|
||||||
};
|
};
|
||||||
|
};
|
||||||
} = {
|
} = {
|
||||||
datetime: {
|
datetime: {
|
||||||
YYYY: (val: string) => dayjs(val).format('YYYY'),
|
YYYY: (val: string) => dayjs(val).format('YYYY'),
|
||||||
@ -48,6 +54,42 @@ const transformers: {
|
|||||||
},
|
},
|
||||||
Exponential: (val: number | string) => (+val)?.toExponential(),
|
Exponential: (val: number | string) => (+val)?.toExponential(),
|
||||||
Abbreviation: (val: number, locale = 'en-US') => new Intl.NumberFormat(locale, { notation: 'compact' }).format(val),
|
Abbreviation: (val: number, locale = 'en-US') => new Intl.NumberFormat(locale, { notation: 'compact' }).format(val),
|
||||||
|
Decimal: {
|
||||||
|
TwoDigits: (val: number) =>
|
||||||
|
new Intl.NumberFormat('en-US', { style: 'decimal', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
|
||||||
|
val,
|
||||||
|
),
|
||||||
|
ThreeDigits: (val: number) =>
|
||||||
|
new Intl.NumberFormat('en-US', { style: 'decimal', minimumFractionDigits: 3, maximumFractionDigits: 3 }).format(
|
||||||
|
val,
|
||||||
|
),
|
||||||
|
FourDigits: (val: number) =>
|
||||||
|
new Intl.NumberFormat('en-US', { style: 'decimal', minimumFractionDigits: 4, maximumFractionDigits: 4 }).format(
|
||||||
|
val,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
array: {
|
||||||
|
title: (items: string[]) => {
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
content={
|
||||||
|
<Space>
|
||||||
|
{items.map((item) => (
|
||||||
|
<Tag key={item}>{item}</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Space>
|
||||||
|
{items.slice(0, 2).map((item) => (
|
||||||
|
<Tag key={item}>{item}</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
{items.length > 2 ? '...' : ''}
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -0,0 +1,115 @@
|
|||||||
|
import { uid } from '@nocobase/utils/client';
|
||||||
|
import { RenderProps } from '../chart';
|
||||||
|
import { AntdChart } from './antd';
|
||||||
|
import { Table as AntdTable } from 'antd';
|
||||||
|
|
||||||
|
export class GroupedTable extends AntdChart {
|
||||||
|
constructor() {
|
||||||
|
super({ name: 'groupedTable', title: 'GroupedTable', component: AntdTable });
|
||||||
|
}
|
||||||
|
|
||||||
|
getProps({ data, fieldProps, general, advanced, ctx }: RenderProps) {
|
||||||
|
const { transform } = ctx;
|
||||||
|
const columns = data.length
|
||||||
|
? Object.keys(data[0]).map((item) => ({
|
||||||
|
title: fieldProps[item]?.label || item,
|
||||||
|
dataIndex: item,
|
||||||
|
key: item,
|
||||||
|
calculate: true,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
const dataSource = [];
|
||||||
|
let key = 0;
|
||||||
|
data.forEach((item: any, index) => {
|
||||||
|
Object.keys(item).forEach((key: string) => {
|
||||||
|
const props = fieldProps[key];
|
||||||
|
if (props?.interface === 'percent') {
|
||||||
|
const value = Math.round(parseFloat(item[key]) * 100).toFixed(2);
|
||||||
|
item[key] = `${value}%`;
|
||||||
|
}
|
||||||
|
if (typeof item[key] === 'boolean') {
|
||||||
|
item[key] = item[key].toString();
|
||||||
|
}
|
||||||
|
if (props?.transformer) {
|
||||||
|
item[key] = props.transformer(item[key]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const dataValue = dataSource.filter((value) => value['product.name'] == item['product.name'])[0];
|
||||||
|
if (dataValue) {
|
||||||
|
dataSource[dataValue.key].children.push({
|
||||||
|
key: `key${uid()}${uid()}`,
|
||||||
|
...item,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
dataSource.push({
|
||||||
|
key: key,
|
||||||
|
...item,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: `key${uid()}`,
|
||||||
|
...item,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
key++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
advanced?.columns?.forEach((dataValue) => {
|
||||||
|
dataSource.forEach((value) => {
|
||||||
|
if (dataValue.calculate) {
|
||||||
|
let number: any = transform.filter((value) => value.field == dataValue.key)[0];
|
||||||
|
if (number) {
|
||||||
|
number = number.specific ? number.specific : 3;
|
||||||
|
switch (number) {
|
||||||
|
case 'TwoDigits':
|
||||||
|
number = 2;
|
||||||
|
break;
|
||||||
|
case 'ThreeDigits':
|
||||||
|
number = 3;
|
||||||
|
break;
|
||||||
|
case 'FourDigits':
|
||||||
|
number = 4;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
number = 3;
|
||||||
|
}
|
||||||
|
const options = {
|
||||||
|
style: 'decimal',
|
||||||
|
minimumFractionDigits: number,
|
||||||
|
maximumFractionDigits: number,
|
||||||
|
};
|
||||||
|
const numberFormat = new Intl.NumberFormat('zh-CN', options);
|
||||||
|
const num = String(value[dataValue.key]).includes(',')
|
||||||
|
? String(value[dataValue.key]).replace(/,/g, '')
|
||||||
|
: value[dataValue.key];
|
||||||
|
if (!isNaN(num)) {
|
||||||
|
const sum = value.children.reduce((sum, curr) => {
|
||||||
|
const sub = String(curr[dataValue.key]).includes(',')
|
||||||
|
? String(curr[dataValue.key]).replace(/,/g, '')
|
||||||
|
: curr[dataValue.key];
|
||||||
|
return sum + parseFloat(sub);
|
||||||
|
}, 0);
|
||||||
|
value[dataValue.key] = numberFormat.format(sum);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
value[dataValue.key] = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
bordered: true,
|
||||||
|
size: 'middle',
|
||||||
|
pagination: false,
|
||||||
|
dataSource,
|
||||||
|
columns,
|
||||||
|
scroll: {
|
||||||
|
x: 'max-content',
|
||||||
|
},
|
||||||
|
rowKey: (record) => record.key,
|
||||||
|
...general,
|
||||||
|
...advanced,
|
||||||
|
expandRowByClick: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,5 @@
|
|||||||
import { Statistic } from './statistic';
|
import { Statistic } from './statistic';
|
||||||
import { Table } from './table';
|
import { Table } from './table';
|
||||||
|
import { GroupedTable } from './GroupedTable';
|
||||||
|
|
||||||
export default [new Statistic(), new Table()];
|
export default [new Statistic(), new Table(), new GroupedTable()];
|
||||||
|
@ -1,22 +1,23 @@
|
|||||||
import React from 'react';
|
import React, { useContext } from 'react';
|
||||||
import { FieldOption } from '../hooks';
|
import { FieldOption } from '../hooks';
|
||||||
import { QueryProps } from '../renderer';
|
import { ChartRendererContext, QueryProps } from '../renderer';
|
||||||
import { parseField } from '../utils';
|
import { parseField } from '../utils';
|
||||||
import { ISchema } from '@formily/react';
|
import { ISchema } from '@formily/react';
|
||||||
import configs, { AnySchemaProperties, Config } from './configs';
|
import configs, { AnySchemaProperties, Config } from './configs';
|
||||||
import { Transformer } from '../block/transformers';
|
import { Transformer } from '../block/transformers';
|
||||||
|
|
||||||
export type RenderProps = {
|
export type RenderProps = {
|
||||||
data: Record<string, any>[];
|
data: any;
|
||||||
general: any;
|
general: any;
|
||||||
advanced: any;
|
advanced: any;
|
||||||
fieldProps: {
|
fieldProps: {
|
||||||
[field: string]: {
|
[field: string]: {
|
||||||
label: string;
|
label: string;
|
||||||
transformer: Transformer;
|
transformer: Transformer;
|
||||||
interface: string;
|
interface: any;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
ctx: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ChartType {
|
export interface ChartType {
|
||||||
@ -168,10 +169,10 @@ export class Chart implements ChartType {
|
|||||||
return props;
|
return props;
|
||||||
}
|
}
|
||||||
|
|
||||||
render({ data, general, advanced, fieldProps }: RenderProps) {
|
render({ data, general, advanced, fieldProps, ctx }: RenderProps) {
|
||||||
return () =>
|
return () =>
|
||||||
React.createElement(this.component, {
|
React.createElement(this.component, {
|
||||||
...this.getProps({ data, general, advanced, fieldProps }),
|
...this.getProps({ data, general, advanced, fieldProps, ctx }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { G2PlotChart } from './g2plot';
|
import { G2PlotChart } from './g2plot';
|
||||||
import { ChartType, RenderProps } from '../chart';
|
import { ChartType, RenderProps } from '../chart';
|
||||||
import React from 'react';
|
import React, { useContext } from 'react';
|
||||||
import { DualAxes as G2DualAxes } from '@ant-design/plots';
|
import { DualAxes as G2DualAxes } from '@ant-design/plots';
|
||||||
|
import { ChartRendererContext } from '../../renderer';
|
||||||
|
|
||||||
export class DualAxes extends G2PlotChart {
|
export class DualAxes extends G2PlotChart {
|
||||||
constructor() {
|
constructor() {
|
||||||
@ -64,8 +65,8 @@ export class DualAxes extends G2PlotChart {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
render({ data, general, advanced, fieldProps }: RenderProps) {
|
render({ data, general, advanced, fieldProps, ctx }: RenderProps) {
|
||||||
const props = this.getProps({ data, general, advanced, fieldProps });
|
const props = this.getProps({ data, general, advanced, fieldProps, ctx });
|
||||||
const { data: _data } = props;
|
const { data: _data } = props;
|
||||||
return () =>
|
return () =>
|
||||||
React.createElement(this.component, {
|
React.createElement(this.component, {
|
||||||
|
@ -24,6 +24,7 @@ import {
|
|||||||
useOrderFieldsOptions,
|
useOrderFieldsOptions,
|
||||||
useOrderReaction,
|
useOrderReaction,
|
||||||
useTransformers,
|
useTransformers,
|
||||||
|
useTransformersDecimal,
|
||||||
} from '../hooks';
|
} from '../hooks';
|
||||||
import { useChartsTranslation } from '../locale';
|
import { useChartsTranslation } from '../locale';
|
||||||
import { ChartRenderer, ChartRendererContext } from '../renderer';
|
import { ChartRenderer, ChartRendererContext } from '../renderer';
|
||||||
@ -401,7 +402,7 @@ ChartConfigure.Transform = function Transform() {
|
|||||||
<SchemaComponent
|
<SchemaComponent
|
||||||
schema={transformSchema}
|
schema={transformSchema}
|
||||||
components={{ FormItem, ArrayItems, Space }}
|
components={{ FormItem, ArrayItems, Space }}
|
||||||
scope={{ useChartFields: getChartFields, useFieldTypeOptions, useTransformers, t }}
|
scope={{ useChartFields: getChartFields, useFieldTypeOptions, useTransformers, useTransformersDecimal, t }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -211,6 +211,7 @@ export const querySchema: ISchema = {
|
|||||||
{ label: '{{t("Avg")}}', value: 'avg' },
|
{ label: '{{t("Avg")}}', value: 'avg' },
|
||||||
{ label: '{{t("Max")}}', value: 'max' },
|
{ label: '{{t("Max")}}', value: 'max' },
|
||||||
{ label: '{{t("Min")}}', value: 'min' },
|
{ label: '{{t("Min")}}', value: 'min' },
|
||||||
|
{ label: 'array_agg', value: 'array_agg' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
alias: {
|
alias: {
|
||||||
@ -490,6 +491,16 @@ export const transformSchema: ISchema = {
|
|||||||
'x-reactions': '{{ useTransformers }}',
|
'x-reactions': '{{ useTransformers }}',
|
||||||
'x-visible': '{{ $self.dataSource && $self.dataSource.length }}',
|
'x-visible': '{{ $self.dataSource && $self.dataSource.length }}',
|
||||||
},
|
},
|
||||||
|
specific: {
|
||||||
|
type: 'string',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'Select',
|
||||||
|
'x-component-props': {
|
||||||
|
placeholder: '{{t("Format")}}',
|
||||||
|
},
|
||||||
|
'x-reactions': '{{ useTransformersDecimal }}',
|
||||||
|
'x-visible': '{{ $self.dataSource && $self.dataSource.length }}',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'x-decorator-props': {
|
'x-decorator-props': {
|
||||||
|
@ -309,9 +309,9 @@ export const useChartFilter = () => {
|
|||||||
.filter((chart) => hasFilter(chart, filterValues))
|
.filter((chart) => hasFilter(chart, filterValues))
|
||||||
.map((chart) => async () => {
|
.map((chart) => async () => {
|
||||||
const { service, collection } = chart;
|
const { service, collection } = chart;
|
||||||
await service.runAsync(collection, appendFilter(chart, filterValues), true);
|
return await service.runAsync(collection, appendFilter(chart, filterValues), true);
|
||||||
});
|
});
|
||||||
await Promise.all(requests.map((request) => request()));
|
return await Promise.all(requests.map((request) => request()));
|
||||||
};
|
};
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
|
@ -38,7 +38,6 @@ export const useFieldTypes = (fields: FieldOption[]) => (field: any) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
field.setState({
|
field.setState({
|
||||||
value: null,
|
|
||||||
disabled: false,
|
disabled: false,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -56,11 +55,39 @@ export const useTransformers = (field: any) => {
|
|||||||
field.dataSource = options;
|
field.dataSource = options;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
//添加小数判断方法
|
||||||
|
export const useTransformersDecimal = (field: any) => {
|
||||||
|
const selectedType = field.query('.type').get('value');
|
||||||
|
const selectedFormat = field.query('.format').get('value');
|
||||||
|
if (!selectedFormat || !selectedType || selectedFormat != 'Decimal') {
|
||||||
|
field.dataSource = [];
|
||||||
|
} else {
|
||||||
|
const options = Object.keys(transformers[selectedType][selectedFormat] || {}).map((key) => ({
|
||||||
|
label: lang(key),
|
||||||
|
value: key,
|
||||||
|
}));
|
||||||
|
field.dataSource = options;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const useFieldTransformer = (transform: ChartRendererProps['transform'], locale = 'en-US') => {
|
export const useFieldTransformer = (transform: ChartRendererProps['transform'], locale = 'en-US') => {
|
||||||
return (transform || [])
|
return (transform || [])
|
||||||
.filter((item) => item.field && item.type && item.format)
|
.filter((item) => {
|
||||||
|
if (item.format) {
|
||||||
|
//对小数做了判断
|
||||||
|
if (item.format == 'Decimal') {
|
||||||
|
if (item.specific) {
|
||||||
|
return item.field && item.type && item.format && item.specific;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return item.field && item.type && item.format;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
.reduce((mp, item) => {
|
.reduce((mp, item) => {
|
||||||
const transformer = transformers[item.type][item.format];
|
const transformer = item.specific
|
||||||
|
? transformers[item.type][item.format][item.specific]
|
||||||
|
: transformers[item.type][item.format];
|
||||||
if (!transformer) {
|
if (!transformer) {
|
||||||
return mp;
|
return mp;
|
||||||
}
|
}
|
||||||
|
@ -34,5 +34,6 @@ export default DataVisualizationPlugin;
|
|||||||
export { Chart } from './chart/chart';
|
export { Chart } from './chart/chart';
|
||||||
export type { ChartProps, ChartType, RenderProps } from './chart/chart';
|
export type { ChartProps, ChartType, RenderProps } from './chart/chart';
|
||||||
export type { FieldOption } from './hooks';
|
export type { FieldOption } from './hooks';
|
||||||
|
export { useChartFilter } from './hooks';
|
||||||
export type { QueryProps } from './renderer';
|
export type { QueryProps } from './renderer';
|
||||||
export { ChartConfigContext } from './configure';
|
export { ChartConfigContext } from './configure';
|
||||||
|
@ -49,6 +49,7 @@ export const ChartRenderer: React.FC & {
|
|||||||
}
|
}
|
||||||
return props;
|
return props;
|
||||||
}, {}),
|
}, {}),
|
||||||
|
ctx,
|
||||||
});
|
});
|
||||||
|
|
||||||
const C = () =>
|
const C = () =>
|
||||||
|
@ -29,6 +29,7 @@ export type TransformProps = {
|
|||||||
field: string;
|
field: string;
|
||||||
type: string;
|
type: string;
|
||||||
format: string;
|
format: string;
|
||||||
|
specific?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type QueryProps = Partial<{
|
export type QueryProps = Partial<{
|
||||||
|
@ -80,5 +80,9 @@
|
|||||||
"Time range": "Time range",
|
"Time range": "Time range",
|
||||||
"Edit field properties": "Edit field properties",
|
"Edit field properties": "Edit field properties",
|
||||||
"Select a source field to use metadata of the field": "Select a source field to use metadata of the field",
|
"Select a source field to use metadata of the field": "Select a source field to use metadata of the field",
|
||||||
"Original field": "Original field"
|
"Original field": "Original field",
|
||||||
|
"Decimal":"Decimal",
|
||||||
|
"TwoDigits":"TwoDigits",
|
||||||
|
"ThreeDigits":"ThreeDigits",
|
||||||
|
"FourDigits":"FourDigits"
|
||||||
}
|
}
|
||||||
|
@ -81,5 +81,9 @@
|
|||||||
"Time range": "时间范围",
|
"Time range": "时间范围",
|
||||||
"Edit field properties": "编辑字段属性",
|
"Edit field properties": "编辑字段属性",
|
||||||
"Select a source field to use metadata of the field": "选择来源字段可以复用字段的元数据配置",
|
"Select a source field to use metadata of the field": "选择来源字段可以复用字段的元数据配置",
|
||||||
"Original field": "原始字段"
|
"Original field": "原始字段",
|
||||||
|
"Decimal":"小数",
|
||||||
|
"TwoDigits":"保留两位",
|
||||||
|
"ThreeDigits":"保留三位",
|
||||||
|
"FourDigits":"保留四位"
|
||||||
}
|
}
|
||||||
|
@ -301,6 +301,10 @@ export const cacheMiddleware = async (ctx: Context, next: Next) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const checkPermission = (ctx: Context, next: Next) => {
|
const checkPermission = (ctx: Context, next: Next) => {
|
||||||
|
// fix params not in the body
|
||||||
|
if (ctx.action.params.values === undefined) {
|
||||||
|
ctx.action.params.values = ctx.action.params;
|
||||||
|
}
|
||||||
const { collection } = ctx.action.params.values as QueryParams;
|
const { collection } = ctx.action.params.values as QueryParams;
|
||||||
const roleName = ctx.state.currentRole || 'anonymous';
|
const roleName = ctx.state.currentRole || 'anonymous';
|
||||||
const can = ctx.app.acl.can({ role: roleName, resource: collection, action: 'list' });
|
const can = ctx.app.acl.can({ role: roleName, resource: collection, action: 'list' });
|
||||||
|
@ -96,6 +96,12 @@ export class FormulaFieldInterface extends CollectionFieldInterface {
|
|||||||
required: true,
|
required: true,
|
||||||
default: 'double',
|
default: 'double',
|
||||||
},
|
},
|
||||||
|
'uiSchema.x-component-props.addonAfter': {
|
||||||
|
type: 'string',
|
||||||
|
title: '后缀',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'Input',
|
||||||
|
},
|
||||||
// 'uiSchema.x-component-props.showUnchecked': {
|
// 'uiSchema.x-component-props.showUnchecked': {
|
||||||
// type: 'boolean',
|
// type: 'boolean',
|
||||||
// title: '{{t("Display X when unchecked")}}',
|
// title: '{{t("Display X when unchecked")}}',
|
||||||
@ -195,7 +201,7 @@ export class FormulaFieldInterface extends CollectionFieldInterface {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
filterable = {
|
filterable = {
|
||||||
operators: operators.number,
|
operators: operators.string,
|
||||||
};
|
};
|
||||||
titleUsable = true;
|
titleUsable = true;
|
||||||
}
|
}
|
||||||
|