合并对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. --> <!-- Describe the new feature or modification to an existing feature clearly and consciously. --> <!-- Explain the reason for adding or modifying this feature. --> <!-- Provide a technically detailed description of the key changes made. --> - Frontend - Backend <!-- Provide any suggestions or recommendations for improvements in the testing plan. --> <!-- Identify any potential risks or issues that may arise from the new feature or modification. --> <!-- 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": {
|
||||
"@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/logs-e2e
|
||||
storage/uploads-e2e
|
||||
storage/fonts
|
||||
storage/.pm2-*
|
||||
tsconfig.paths.json
|
||||
/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 COMMIT_HASH
|
||||
ARG APPEND_PRESET_LOCAL_PLUGINS
|
||||
@ -10,18 +10,18 @@ ENV PLUGINS_DIRS=${PLUGINS_DIRS}
|
||||
RUN apt-get update && apt-get install -y jq
|
||||
WORKDIR /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 && \
|
||||
NEWVERSION="$(cat lerna.json | jq '.version' | tr -d '"').$(date +'%Y%m%d%H%M%S')" \
|
||||
&& tmp=$(mktemp) \
|
||||
&& 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') \
|
||||
&& yarn version:alpha -y
|
||||
RUN git config user.email "test@mail.com" \
|
||||
&& 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 config set registry $VERDACCIO_URL
|
||||
@ -41,7 +41,7 @@ RUN cd /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 rm -rf /etc/nginx/sites-enabled/default
|
||||
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
|
||||
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
|
||||
# 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",
|
||||
"react-router-dom": "^6.11.2",
|
||||
"react-router": "^6.11.2",
|
||||
"prettier": "^3.1.1",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"tsx": "^4.6.2",
|
||||
"nwsapi": "2.2.7",
|
||||
"antd": "5.12.8"
|
||||
},
|
||||
@ -58,7 +60,7 @@
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,json}": [
|
||||
"*.{js,json,sql}": [
|
||||
"prettier --write"
|
||||
],
|
||||
"*.ts?(x)": [
|
||||
@ -72,10 +74,14 @@
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"auto-changelog": "^2.4.0",
|
||||
"axios": "^1.6.2",
|
||||
"commander": "^9.2.0",
|
||||
"eslint-plugin-jest-dom": "^5.0.1",
|
||||
"eslint-plugin-testing-library": "^5.11.0",
|
||||
"ghooks": "^2.0.4",
|
||||
"lint-staged": "^13.2.3",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-sql": "^0.17.0",
|
||||
"pretty-format": "^24.0.0",
|
||||
"pretty-quick": "^3.1.0",
|
||||
"react": "^18.0.0",
|
||||
@ -83,7 +89,7 @@
|
||||
"typescript": "5.1.3"
|
||||
},
|
||||
"volta": {
|
||||
"node": "18.14.2",
|
||||
"node": "20.9.0",
|
||||
"yarn": "1.22.19"
|
||||
},
|
||||
"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 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] = curr;
|
||||
|
@ -3,6 +3,7 @@ const { isDev, run, postCheck, runInstall, promptForTs } = require('../util');
|
||||
const { existsSync, unlink } = require('fs');
|
||||
const { resolve } = require('path');
|
||||
const chalk = require('chalk');
|
||||
const _ = require('lodash');
|
||||
|
||||
/**
|
||||
*
|
||||
@ -14,6 +15,7 @@ module.exports = (cli) => {
|
||||
.command('start')
|
||||
.option('-p, --port [port]')
|
||||
.option('-d, --daemon')
|
||||
.option('-i, --instances [number]')
|
||||
.option('--db-sync')
|
||||
.option('--quickstart')
|
||||
.allowUnknownOption()
|
||||
@ -48,6 +50,8 @@ module.exports = (cli) => {
|
||||
'pm2-runtime',
|
||||
[
|
||||
'start',
|
||||
'-i',
|
||||
_.toNumber(opts.instances || 1),
|
||||
`${APP_PACKAGE_ROOT}/lib/index.js`,
|
||||
NODE_ARGS ? `--node-args="${NODE_ARGS}"` : undefined,
|
||||
'--',
|
||||
|
@ -32,7 +32,7 @@
|
||||
"ahooks": "^3.7.2",
|
||||
"antd": "^5.12.8",
|
||||
"antd-style": "3.4.5",
|
||||
"axios": "^0.26.1",
|
||||
"axios": "^1.6.2",
|
||||
"classnames": "^2.3.1",
|
||||
"cronstrue": "^2.11.0",
|
||||
"file-saver": "^2.0.5",
|
||||
|
@ -44,6 +44,8 @@ export class Plugin<T = any> {
|
||||
|
||||
async load() {}
|
||||
|
||||
async afterLoad() {}
|
||||
|
||||
t(text: TFuncKey | TFuncKey[], options: TOptions = {}) {
|
||||
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()) {
|
||||
await plugin.load();
|
||||
}
|
||||
|
||||
for (const plugin of this.pluginInstances.values()) {
|
||||
await plugin.afterLoad();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1276,6 +1276,13 @@ export const useAssociationNames = (dataSource?: string) => {
|
||||
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, dataSource)?.template === 'tree';
|
||||
if (collectionField && (isAssociationField || isAssociationSubfield) && s['x-component'] !== 'TableField') {
|
||||
|
@ -19,6 +19,13 @@ export class CheckboxFieldInterface extends CollectionFieldInterface {
|
||||
hasDefaultValue = true;
|
||||
properties = {
|
||||
...defaultProps,
|
||||
'uiSchema.x-component-props.showUnchecked': {
|
||||
type: 'boolean',
|
||||
title: '{{t("Display X when unchecked")}}',
|
||||
default: false,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Checkbox',
|
||||
},
|
||||
};
|
||||
filterable = {
|
||||
operators: operators.boolean,
|
||||
|
@ -35,8 +35,8 @@ export const DataBlockResourceProvider: FC<{ children?: ReactNode }> = ({ childr
|
||||
if (association) {
|
||||
return api.resource(association, sourceIdValue, headers);
|
||||
}
|
||||
return api.resource(collectionName, undefined, headers);
|
||||
}, [api, association, collection, sourceIdValue, headers]);
|
||||
return api.resource(dataBlockProps.resource ?? collectionName, undefined, headers);
|
||||
}, [api, association, collection, sourceIdValue, headers, dataBlockProps.resource]);
|
||||
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;
|
||||
};
|
||||
|
||||
export const useDataSourceHeaders = (dataSource?: string) => {
|
||||
export const useDataSourceHeaders = (dataSource?: string): any => {
|
||||
const headers = useMemo(() => {
|
||||
if (dataSource && dataSource !== DEFAULT_DATA_SOURCE_KEY) {
|
||||
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/form';
|
||||
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',
|
||||
title: '{{t("Configure actions")}}',
|
||||
icon: 'SettingOutlined',
|
||||
style: {
|
||||
marginLeft: '8px',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: 'itemGroup',
|
||||
|
@ -4,6 +4,9 @@ export const updateFormActionInitializers = new SchemaInitializer({
|
||||
name: 'UpdateFormActionInitializers',
|
||||
title: '{{t("Configure actions")}}',
|
||||
icon: 'SettingOutlined',
|
||||
style: {
|
||||
marginLeft: '8px',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: 'itemGroup',
|
||||
|
@ -75,6 +75,8 @@ const titleField: any = {
|
||||
const fieldSchema = tableColumnSchema || schema;
|
||||
const targetCollectionField = useCollectionField();
|
||||
const collectionField = tableColumnField || targetCollectionField;
|
||||
// 处理多对一关系标题显示
|
||||
const { getCollectionFields } = useCollectionManager_deprecated();
|
||||
const fieldNames = {
|
||||
...collectionField?.uiSchema?.['x-component-props']?.['fieldNames'],
|
||||
...field?.componentProps?.fieldNames,
|
||||
@ -95,6 +97,16 @@ const titleField: any = {
|
||||
};
|
||||
fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {};
|
||||
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'];
|
||||
field.componentProps.fieldNames = fieldSchema['x-component-props'].fieldNames;
|
||||
const path = field.path?.splice(field.path?.length - 1, 1);
|
||||
|
@ -10,6 +10,8 @@ export const PoweredBy = () => {
|
||||
'en-US': 'https://www.nocobase.com',
|
||||
'zh-CN': 'https://cn.nocobase.com',
|
||||
};
|
||||
const date = new Date();
|
||||
const year = date.getFullYear();
|
||||
return (
|
||||
<div
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
@ -757,7 +757,7 @@ export const ActionDesigner = (props) => {
|
||||
removeButtonProps,
|
||||
buttonEditorProps,
|
||||
linkageRulesProps,
|
||||
schemaSettings = 'ActionSettings',
|
||||
schemaSettings,
|
||||
...restProps
|
||||
} = props;
|
||||
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'])
|
||||
.join(' / ')
|
||||
: 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;
|
||||
const val = toValue(compile(label), 'N/A');
|
||||
const labelUiSchema = getLabelUiSchema(
|
||||
|
@ -158,7 +158,7 @@ export const SubTable: any = observer(
|
||||
showIndex
|
||||
dragSort={field.editable}
|
||||
showDel={field.editable}
|
||||
pagination={false}
|
||||
pagination={!!field.componentProps.pagination}
|
||||
rowSelection={{ type: 'none', hideSelectAll: true }}
|
||||
footer={() =>
|
||||
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,13 +1012,9 @@ export function useTitleFieldOptions() {
|
||||
const targetFields = collectionField?.target
|
||||
? getCollectionFields(collectionField?.target)
|
||||
: getCollectionFields(collectionField?.targetCollection) ?? [];
|
||||
const options = targetFields
|
||||
.filter((field) => {
|
||||
return isTitleField(field);
|
||||
})
|
||||
.map((field) => ({
|
||||
value: field?.name,
|
||||
label: compile(field?.uiSchema?.title) || field?.name,
|
||||
}));
|
||||
const options = targetFields.map((field) => ({
|
||||
value: field?.name,
|
||||
label: compile(field?.uiSchema?.title) || field?.name,
|
||||
}));
|
||||
return options;
|
||||
}
|
||||
|
@ -160,6 +160,7 @@ const ColDivider = (props) => {
|
||||
ref={setDraggableNodeRef}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
tabIndex={-1}
|
||||
className={props.first || props.last || !designable ? null : 'DraggableNode'}
|
||||
></div>
|
||||
</div>
|
||||
|
@ -50,5 +50,6 @@ export * from './time-picker';
|
||||
export * from './tree-select';
|
||||
export * from './upload';
|
||||
export * from './variable';
|
||||
export * from './custom-cascader';
|
||||
|
||||
import './index.less';
|
||||
|
@ -5,7 +5,7 @@ import React from 'react';
|
||||
export const Space: React.FC<SpaceProps> = (props) => {
|
||||
let { split } = props;
|
||||
if (split === '|') {
|
||||
split = <Divider type="vertical" style={{ margin: '0 2px' }} />;
|
||||
split = <span> </span>;
|
||||
}
|
||||
const layout = useFormLayout();
|
||||
return React.createElement(AntdSpace, {
|
||||
|
@ -30,14 +30,12 @@ export const useLabelFields = (collectionName?: any) => {
|
||||
return [];
|
||||
}
|
||||
const targetFields = getCollectionFields(collectionName);
|
||||
return targetFields
|
||||
?.filter?.((field) => field?.interface && !field?.target && field.type !== 'boolean' && !field.isForeignKey)
|
||||
?.map?.((field) => {
|
||||
return {
|
||||
value: field.name,
|
||||
label: compile(field?.uiSchema?.title || field.name),
|
||||
};
|
||||
});
|
||||
return targetFields?.map?.((field) => {
|
||||
return {
|
||||
value: field.name,
|
||||
label: compile(field?.uiSchema?.title || field.name),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const useColorFields = (collectionName?: any) => {
|
||||
@ -407,6 +405,7 @@ export const TableColumnDesigner = (props) => {
|
||||
/>
|
||||
)}
|
||||
{isAllowToSetDefaultValue(isSubTableColumn) && <SchemaSettingsDefaultValue fieldSchema={fieldSchema} />}
|
||||
{props.children}
|
||||
<SchemaSettingsDivider />
|
||||
<SchemaSettingsRemove
|
||||
removeParentsIfNoChildren={!isSubTableColumn}
|
||||
|
@ -44,6 +44,7 @@ const useTableColumns = (props: { showDel?: boolean; isSubTable?: boolean }) =>
|
||||
const { designable } = useDesignable();
|
||||
const { exists, render } = useSchemaInitializerRender(schema['x-initializer'], schema['x-initializer-props']);
|
||||
const parentRecordData = useCollectionParentRecordData();
|
||||
const dataSource = field?.value?.slice?.()?.filter?.(Boolean) || [];
|
||||
const columns = schema
|
||||
.reduceProperties((buf, s) => {
|
||||
if (isColumnComponent(s) && schemaInWhitelist(Object.values(s.properties || {}).pop())) {
|
||||
@ -102,9 +103,10 @@ const useTableColumns = (props: { showDel?: boolean; isSubTable?: boolean }) =>
|
||||
|
||||
const tableColumns = columns.concat({
|
||||
title: render(),
|
||||
fixed: 'right',
|
||||
dataIndex: 'TABLE_COLUMN_INITIALIZER',
|
||||
key: 'TABLE_COLUMN_INITIALIZER',
|
||||
render: designable ? () => <div style={{ minWidth: 300 }} /> : null,
|
||||
render: designable ? () => <span /> : null,
|
||||
});
|
||||
|
||||
if (props.showDel) {
|
||||
@ -120,6 +122,7 @@ const useTableColumns = (props: { showDel?: boolean; isSubTable?: boolean }) =>
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
action(() => {
|
||||
const index = dataSource.indexOf(record);
|
||||
spliceArrayState(field as any, {
|
||||
startIndex: index,
|
||||
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) => {
|
||||
|
@ -46,11 +46,8 @@ const RequestSchemaComponent: React.FC<RemoteSchemaComponentProps> = (props) =>
|
||||
reset && reset();
|
||||
},
|
||||
});
|
||||
if (loading) {
|
||||
return <Spin />;
|
||||
}
|
||||
if (hidden) {
|
||||
return <Spin />;
|
||||
if (loading || hidden) {
|
||||
return;
|
||||
}
|
||||
return noForm ? (
|
||||
<SchemaComponent memoized components={components} scope={scope} schema={schemaTransform(data?.data || {})} />
|
||||
|
@ -55,6 +55,7 @@ export const useFieldModeOptions = (props?) => {
|
||||
{ label: t('Cascade Select'), value: 'CascadeSelect' },
|
||||
!isTableField && { label: t('Sub-form'), value: 'Nester' },
|
||||
{ label: t('Sub-form(Popover)'), value: 'PopoverNester' },
|
||||
{ label: t('Cascader'), value: 'Cascader' },
|
||||
];
|
||||
}
|
||||
switch (collectionField.interface) {
|
||||
@ -101,6 +102,7 @@ export const useFieldModeOptions = (props?) => {
|
||||
{ label: t('Record picker'), value: 'Picker' },
|
||||
!isTableField && { label: t('Sub-form'), value: 'Nester' },
|
||||
{ label: t('Sub-form(Popover)'), value: 'PopoverNester' },
|
||||
{ label: t('Cascader'), value: 'Cascader' },
|
||||
];
|
||||
|
||||
default:
|
||||
|
@ -9,6 +9,10 @@ export const TableActionColumnInitializer = () => {
|
||||
title: '{{ t("Actions") }}',
|
||||
'x-decorator': 'TableV2.Column.ActionBar',
|
||||
'x-component': 'TableV2.Column',
|
||||
'x-component-props': {
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
},
|
||||
'x-designer': 'TableV2.ActionColumnDesigner',
|
||||
'x-initializer': 'TableActionColumnInitializers',
|
||||
'x-action-column': 'actions',
|
||||
|
@ -1159,24 +1159,23 @@ export const createFormBlockSchema = (options) => {
|
||||
useProps: '{{ useFormBlockProps }}',
|
||||
},
|
||||
properties: {
|
||||
grid: template || {
|
||||
type: 'void',
|
||||
'x-component': 'Grid',
|
||||
'x-initializer': formItemInitializers,
|
||||
properties: {},
|
||||
},
|
||||
[uid()]: {
|
||||
type: 'void',
|
||||
'x-initializer': actionInitializers,
|
||||
'x-component': 'ActionBar',
|
||||
'x-component-props': {
|
||||
layout: 'one-column',
|
||||
style: {
|
||||
marginTop: 24,
|
||||
marginBottom: 'var(--nb-spacing)',
|
||||
},
|
||||
},
|
||||
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-decorator': 'TableV2.Column.ActionBar',
|
||||
'x-component': 'TableV2.Column',
|
||||
'x-component-props': {
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
},
|
||||
'x-designer': 'TableV2.ActionColumnDesigner',
|
||||
'x-initializer': tableActionColumnInitializers ?? 'TableActionColumnInitializers',
|
||||
properties: {
|
||||
|
@ -31,6 +31,7 @@ export const useParentRecordVariable = (props: Props) => {
|
||||
uiSchema: props.schema,
|
||||
name: '$nParentRecord',
|
||||
title: t('Parent record'),
|
||||
maxDepth: 5,
|
||||
collectionName: props.collectionName,
|
||||
noDisabled: props.noDisabled,
|
||||
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) {
|
||||
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 associationField: CollectionFieldOptions_deprecated = getCollectionJoinField(
|
||||
getFieldPath(list.slice(0, index + 1).join('.'), _variableToCollectionName),
|
||||
@ -109,7 +113,12 @@ const VariablesProvider = ({ children }) => {
|
||||
return item?.[key];
|
||||
});
|
||||
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)}`;
|
||||
let data = null;
|
||||
if (hasRequested(url)) {
|
||||
|
@ -5,7 +5,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@umijs/utils": "3.5.20",
|
||||
"axios": "^0.26.1",
|
||||
"axios": "^1.6.2",
|
||||
"chalk": "^4.1.1",
|
||||
"commander": "^9.2.0",
|
||||
"tar": "6.1.11"
|
||||
|
@ -42,7 +42,12 @@ export async function referentialIntegrityCheck(options: ReferentialIntegrityChe
|
||||
}
|
||||
|
||||
if (onDelete === 'RESTRICT') {
|
||||
throw new Error('RESTRICT');
|
||||
const error = new Error(
|
||||
`此数据被 ${
|
||||
sourceCollection.options.title || sourceCollectionName
|
||||
} 表关联,关联字段(as):${sourceField},不能删除!`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (onDelete === 'CASCADE') {
|
||||
|
@ -29,7 +29,8 @@
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"fast-glob": "^3.3.1",
|
||||
"lerna": "^4.0.0",
|
||||
"prettier": "^3.0.0",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-sql": "^0.17.0",
|
||||
"pretty-format": "^24.0.0",
|
||||
"pretty-quick": "^3.1.0",
|
||||
"react": "^18.0.0",
|
||||
|
@ -5,7 +5,7 @@
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
"dependencies": {
|
||||
"axios": "^0.26.1",
|
||||
"axios": "^1.6.2",
|
||||
"qs": "^6.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -26,7 +26,8 @@
|
||||
"@types/ini": "^1.3.31",
|
||||
"@types/koa-send": "^4.1.3",
|
||||
"@types/multer": "^1.4.5",
|
||||
"axios": "^0.26.1",
|
||||
"react": "^18.2.0",
|
||||
"axios": "^1.6.2",
|
||||
"chalk": "^4.1.1",
|
||||
"commander": "^9.2.0",
|
||||
"cron": "^2.4.4",
|
||||
|
@ -44,6 +44,9 @@ import { ApplicationVersion } from './helpers/application-version';
|
||||
import { Locale } from './locale';
|
||||
import { Plugin } from './plugin';
|
||||
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 packageJson from '../package.json';
|
||||
@ -157,6 +160,13 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
private _maintainingCommandStatus: MaintainingCommandStatus;
|
||||
private _maintainingStatusBeforeCommand: MaintainingCommandStatus | null;
|
||||
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) {
|
||||
super();
|
||||
@ -164,6 +174,20 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
this.rawOptions = this.name == 'main' ? lodash.cloneDeep(options) : {};
|
||||
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);
|
||||
}
|
||||
|
||||
@ -605,7 +629,26 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import { IncomingMessage } from 'http';
|
||||
import { AppSupervisor } from '../app-supervisor';
|
||||
import { applyErrorWithArgs, getErrorWithCode } from './errors';
|
||||
import lodash from 'lodash';
|
||||
import { createClient } from 'redis';
|
||||
import { Logger } from '@nocobase/logger';
|
||||
|
||||
declare class WebSocketWithId extends WebSocket {
|
||||
@ -27,11 +28,32 @@ function getPayloadByErrorCode(code, options) {
|
||||
export class WSServer {
|
||||
wss: WebSocket.Server;
|
||||
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;
|
||||
|
||||
constructor() {
|
||||
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) => {
|
||||
const client = this.addNewConnection(ws, request);
|
||||
|
||||
@ -175,7 +197,18 @@ export class WSServer {
|
||||
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) => {
|
||||
if (client.tags.includes(`${tagName}#${tagValue}`)) {
|
||||
this.sendMessageToConnection(client, sendMessage);
|
||||
|
@ -44,7 +44,7 @@ const deps: Record<string, string> = {
|
||||
'pg-hstore': '2.x',
|
||||
sqlite3: '5.x',
|
||||
supertest: '6.x',
|
||||
axios: '0.26.x',
|
||||
axios: '1.x',
|
||||
'@emotion/css': '11.x',
|
||||
ahooks: '3.x',
|
||||
lodash: '4.x',
|
||||
|
@ -12,6 +12,7 @@ import utc from 'dayjs/plugin/utc';
|
||||
import weekOfYear from 'dayjs/plugin/weekOfYear';
|
||||
import weekYear from 'dayjs/plugin/weekYear';
|
||||
import weekday from 'dayjs/plugin/weekday';
|
||||
import calendar from 'dayjs/plugin/calendar';
|
||||
|
||||
dayjs.extend(weekday);
|
||||
dayjs.extend(localeData);
|
||||
@ -26,5 +27,6 @@ dayjs.extend(weekOfYear);
|
||||
dayjs.extend(weekYear);
|
||||
dayjs.extend(customParseFormat);
|
||||
dayjs.extend(advancedFormat);
|
||||
dayjs.extend(calendar);
|
||||
|
||||
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 './uid';
|
||||
export * from './url';
|
||||
export * from './typedi';
|
||||
export * from './decorators';
|
||||
|
||||
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,10 +1,16 @@
|
||||
import React from 'react';
|
||||
import { Popover, Space, Tag } from 'antd';
|
||||
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: {
|
||||
[key: string]: {
|
||||
[key: string]: Transformer;
|
||||
[key: string]:
|
||||
| Transformer
|
||||
| {
|
||||
[key: string]: Transformer;
|
||||
};
|
||||
};
|
||||
} = {
|
||||
datetime: {
|
||||
@ -48,6 +54,42 @@ const transformers: {
|
||||
},
|
||||
Exponential: (val: number | string) => (+val)?.toExponential(),
|
||||
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 { 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 { QueryProps } from '../renderer';
|
||||
import { ChartRendererContext, QueryProps } from '../renderer';
|
||||
import { parseField } from '../utils';
|
||||
import { ISchema } from '@formily/react';
|
||||
import configs, { AnySchemaProperties, Config } from './configs';
|
||||
import { Transformer } from '../block/transformers';
|
||||
|
||||
export type RenderProps = {
|
||||
data: Record<string, any>[];
|
||||
data: any;
|
||||
general: any;
|
||||
advanced: any;
|
||||
fieldProps: {
|
||||
[field: string]: {
|
||||
label: string;
|
||||
transformer: Transformer;
|
||||
interface: string;
|
||||
interface: any;
|
||||
};
|
||||
};
|
||||
ctx: any;
|
||||
};
|
||||
|
||||
export interface ChartType {
|
||||
@ -168,10 +169,10 @@ export class Chart implements ChartType {
|
||||
return props;
|
||||
}
|
||||
|
||||
render({ data, general, advanced, fieldProps }: RenderProps) {
|
||||
render({ data, general, advanced, fieldProps, ctx }: RenderProps) {
|
||||
return () =>
|
||||
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 { ChartType, RenderProps } from '../chart';
|
||||
import React from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
import { DualAxes as G2DualAxes } from '@ant-design/plots';
|
||||
import { ChartRendererContext } from '../../renderer';
|
||||
|
||||
export class DualAxes extends G2PlotChart {
|
||||
constructor() {
|
||||
@ -64,8 +65,8 @@ export class DualAxes extends G2PlotChart {
|
||||
};
|
||||
};
|
||||
|
||||
render({ data, general, advanced, fieldProps }: RenderProps) {
|
||||
const props = this.getProps({ data, general, advanced, fieldProps });
|
||||
render({ data, general, advanced, fieldProps, ctx }: RenderProps) {
|
||||
const props = this.getProps({ data, general, advanced, fieldProps, ctx });
|
||||
const { data: _data } = props;
|
||||
return () =>
|
||||
React.createElement(this.component, {
|
||||
|
@ -24,6 +24,7 @@ import {
|
||||
useOrderFieldsOptions,
|
||||
useOrderReaction,
|
||||
useTransformers,
|
||||
useTransformersDecimal,
|
||||
} from '../hooks';
|
||||
import { useChartsTranslation } from '../locale';
|
||||
import { ChartRenderer, ChartRendererContext } from '../renderer';
|
||||
@ -401,7 +402,7 @@ ChartConfigure.Transform = function Transform() {
|
||||
<SchemaComponent
|
||||
schema={transformSchema}
|
||||
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("Max")}}', value: 'max' },
|
||||
{ label: '{{t("Min")}}', value: 'min' },
|
||||
{ label: 'array_agg', value: 'array_agg' },
|
||||
],
|
||||
},
|
||||
alias: {
|
||||
@ -490,6 +491,16 @@ export const transformSchema: ISchema = {
|
||||
'x-reactions': '{{ useTransformers }}',
|
||||
'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': {
|
||||
|
@ -309,9 +309,9 @@ export const useChartFilter = () => {
|
||||
.filter((chart) => hasFilter(chart, filterValues))
|
||||
.map((chart) => async () => {
|
||||
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 () => {
|
||||
|
@ -38,7 +38,6 @@ export const useFieldTypes = (fields: FieldOption[]) => (field: any) => {
|
||||
return;
|
||||
}
|
||||
field.setState({
|
||||
value: null,
|
||||
disabled: false,
|
||||
});
|
||||
};
|
||||
@ -56,11 +55,39 @@ export const useTransformers = (field: any) => {
|
||||
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') => {
|
||||
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) => {
|
||||
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) {
|
||||
return mp;
|
||||
}
|
||||
|
@ -34,5 +34,6 @@ export default DataVisualizationPlugin;
|
||||
export { Chart } from './chart/chart';
|
||||
export type { ChartProps, ChartType, RenderProps } from './chart/chart';
|
||||
export type { FieldOption } from './hooks';
|
||||
export { useChartFilter } from './hooks';
|
||||
export type { QueryProps } from './renderer';
|
||||
export { ChartConfigContext } from './configure';
|
||||
|
@ -49,6 +49,7 @@ export const ChartRenderer: React.FC & {
|
||||
}
|
||||
return props;
|
||||
}, {}),
|
||||
ctx,
|
||||
});
|
||||
|
||||
const C = () =>
|
||||
|
@ -29,6 +29,7 @@ export type TransformProps = {
|
||||
field: string;
|
||||
type: string;
|
||||
format: string;
|
||||
specific?: string;
|
||||
};
|
||||
|
||||
export type QueryProps = Partial<{
|
||||
|
@ -80,5 +80,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"
|
||||
"Original field": "Original field",
|
||||
"Decimal":"Decimal",
|
||||
"TwoDigits":"TwoDigits",
|
||||
"ThreeDigits":"ThreeDigits",
|
||||
"FourDigits":"FourDigits"
|
||||
}
|
||||
|
@ -81,5 +81,9 @@
|
||||
"Time range": "时间范围",
|
||||
"Edit field properties": "编辑字段属性",
|
||||
"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) => {
|
||||
// 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 roleName = ctx.state.currentRole || 'anonymous';
|
||||
const can = ctx.app.acl.can({ role: roleName, resource: collection, action: 'list' });
|
||||
|
@ -96,6 +96,12 @@ export class FormulaFieldInterface extends CollectionFieldInterface {
|
||||
required: true,
|
||||
default: 'double',
|
||||
},
|
||||
'uiSchema.x-component-props.addonAfter': {
|
||||
type: 'string',
|
||||
title: '后缀',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
},
|
||||
// 'uiSchema.x-component-props.showUnchecked': {
|
||||
// type: 'boolean',
|
||||
// title: '{{t("Display X when unchecked")}}',
|
||||
@ -195,7 +201,7 @@ export class FormulaFieldInterface extends CollectionFieldInterface {
|
||||
},
|
||||
};
|
||||
filterable = {
|
||||
operators: operators.number,
|
||||
operators: operators.string,
|
||||
};
|
||||
titleUsable = true;
|
||||
}
|
||||
|