合并对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
This commit is contained in:
sealday 2024-03-07 20:45:00 +08:00 committed by sealday
parent 9e88972ba6
commit 2d991ba7cf
104 changed files with 16999 additions and 8812 deletions

View File

@ -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
View File

@ -34,6 +34,7 @@ storage/tmp
storage/app.watch.ts
storage/logs-e2e
storage/uploads-e2e
storage/fonts
storage/.pm2-*
tsconfig.paths.json
/playwright

View File

@ -1,11 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 120,
"overrides": [
{
"files": ".prettierrc",
"options": { "parser": "json" }
}
]
}

24
.prettierrc.js Normal file
View 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',
},
},
],
};

View File

@ -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
View File

@ -0,0 +1 @@
DOCKER_BUILDKIT=1 docker build -f docker/hera/Dockerfile -t hera:0.3 .

19
docker/hera/Dockerfile Normal file
View 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"]

View File

@ -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:

View File

@ -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": {}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 376 B

After

Width:  |  Height:  |  Size: 573 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 792 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

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

View File

@ -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,
'--',

View File

@ -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",

View File

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

View File

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

View File

@ -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') {

View File

@ -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,

View File

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

View File

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

View File

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

View File

@ -4,6 +4,9 @@ export const createFormActionInitializers = new SchemaInitializer({
name: 'CreateFormActionInitializers',
title: '{{t("Configure actions")}}',
icon: 'SettingOutlined',
style: {
marginLeft: '8px',
},
items: [
{
type: 'itemGroup',

View File

@ -4,6 +4,9 @@ export const updateFormActionInitializers = new SchemaInitializer({
name: 'UpdateFormActionInitializers',
title: '{{t("Configure actions")}}',
icon: 'SettingOutlined',
style: {
marginLeft: '8px',
},
items: [
{
type: 'itemGroup',

View File

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

View File

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

View File

@ -757,7 +757,7 @@ export const ActionDesigner = (props) => {
removeButtonProps,
buttonEditorProps,
linkageRulesProps,
schemaSettings = 'ActionSettings',
schemaSettings,
...restProps
} = props;
const app = useApp();

View File

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

View File

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

View File

@ -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 && (

View File

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

View File

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

View File

@ -0,0 +1,5 @@
export const defaultFieldNames = {
label: 'label',
value: 'value',
children: 'children',
};

View File

@ -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
}
}
}
```

View File

@ -0,0 +1 @@
export * from './CustomCascader';

View File

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

View File

@ -160,6 +160,7 @@ const ColDivider = (props) => {
ref={setDraggableNodeRef}
{...listeners}
{...attributes}
tabIndex={-1}
className={props.first || props.last || !designable ? null : 'DraggableNode'}
></div>
</div>

View File

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

View File

@ -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>&nbsp;</span>;
}
const layout = useFormLayout();
return React.createElement(AntdSpace, {

View File

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

View File

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

View File

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

View File

@ -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:

View File

@ -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',

View File

@ -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: {

View File

@ -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,

View 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';

View File

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

View File

@ -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"

View File

@ -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') {

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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": {

View File

@ -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",

View File

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

View File

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

View File

@ -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',

View File

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

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

View File

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

View 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;
}
}
}

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

View File

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

View File

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

View File

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

View 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');

View File

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

View File

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

View File

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

View 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;

View File

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

View File

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

View File

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

View File

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

View 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) {}
}

View File

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

View 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;

View File

@ -0,0 +1,4 @@
/**
* A container identifier. This value must be unique across all containers.
*/
export type ContainerIdentifier = string | Symbol;

View File

@ -0,0 +1 @@
export type ContainerScope = 'singleton' | 'container' | 'transient';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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, {

View File

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

View File

@ -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': {

View File

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

View File

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

View File

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

View File

@ -49,6 +49,7 @@ export const ChartRenderer: React.FC & {
}
return props;
}, {}),
ctx,
});
const C = () =>

View File

@ -29,6 +29,7 @@ export type TransformProps = {
field: string;
type: string;
format: string;
specific?: string;
};
export type QueryProps = Partial<{

View File

@ -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"
}

View File

@ -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":"保留四位"
}

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More