chore: migrate to new hera

This commit is contained in:
sealday 2024-03-07 20:55:41 +08:00
parent aab43d17d0
commit 37fa7feac0
275 changed files with 25009 additions and 175 deletions

View File

@ -0,0 +1,2 @@
/node_modules
/src

View File

@ -0,0 +1 @@
# @hera/plugin-core

View File

@ -0,0 +1,2 @@
export * from './dist/client';
export { default } from './dist/client';

View File

@ -0,0 +1 @@
module.exports = require('./dist/client/index.js');

View File

@ -0,0 +1,38 @@
{
"name": "@hera/plugin-core",
"version": "1.3.0-alpha.8",
"displayName": "Hera platform",
"displayName.zh-CN": "赫拉平台",
"description": "Hera platform as nocobase plugin.",
"description.zh-CN": "提供标准赫拉平台能力",
"main": "dist/server/index.js",
"devDependencies": {
"@formily/antd-v5": "1.x",
"@formily/core": "^2.2.27",
"@formily/react": "^2.2.27",
"@formily/shared": "^2.2.27",
"@react-pdf/renderer": "^3.3.2",
"antd": "5.12.8",
"copy-to-clipboard": "^3.3.3",
"exceljs": "^4.4.0",
"fs-extra": "^11.1.1",
"mathjs2": "npm:mathjs@^10.6.0",
"qrcode": "^1.5.1",
"dayjs": "^1.11.8",
"qrcode.react": "^3.1.0",
"react": "^18.2.0",
"react-pdf": "^7.5.1",
"vitest": "0.x",
"signature_pad": "4.1.7",
"react-router-dom": "^6.11.2",
"redis": "^4.6.11"
},
"peerDependencies": {
"@nocobase/client": "0.x",
"@nocobase/server": "0.x",
"@nocobase/test": "0.x"
},
"keywords": [
"System management"
]
}

View File

@ -0,0 +1,2 @@
export * from './dist/server';
export { default } from './dist/server';

View File

@ -0,0 +1 @@
module.exports = require('./dist/server/index.js');

View File

@ -0,0 +1,15 @@
import format, { formatCurrency } from '../utils/currencyUtils';
describe('utils', () => {
beforeEach(async () => {});
afterEach(() => {});
describe('currencyUtils', () => {
it('formatCurrency', async () => {
expect(formatCurrency(100, 2)).toBe('¥100.00');
expect(formatCurrency(null, 2)).toBe('¥0.00');
expect(formatCurrency(undefined, 2)).toBe('¥0.00');
});
});
});

View File

@ -0,0 +1,86 @@
import { ISchema, useFieldSchema } from '@formily/react';
import { SchemaSettingsModalItem, useDesignable } from '@nocobase/client';
import { useTranslation } from '../locale';
import React from 'react';
export function AfterSuccess() {
const { dn } = useDesignable();
const { t } = useTranslation();
const fieldSchema = useFieldSchema();
const component = fieldSchema.parent.parent['x-component'];
const schema = {
type: 'object',
title: t('After successful submission'),
properties: {
successMessage: {
title: t('Popup message'),
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
'x-component-props': {},
},
dataClear: {
title: t('Clear data method'),
enum: [
{ label: t('Automatic clear'), value: false },
{ label: t('Manually clear'), value: true },
],
'x-decorator': 'FormItem',
'x-component': 'Radio.Group',
'x-component-props': {},
},
manualClose: {
title: t('Popup close method'),
enum: [
{ label: t('Automatic close'), value: false },
{ label: t('Manually close'), value: true },
],
'x-decorator': 'FormItem',
'x-component': 'Radio.Group',
'x-component-props': {},
},
redirecting: {
title: t('Then'),
enum: [
{ label: t('Stay on current page'), value: false },
{ label: t('Redirect to'), value: true },
],
'x-decorator': 'FormItem',
'x-component': 'Radio.Group',
'x-component-props': {},
'x-reactions': {
target: 'redirectTo',
fulfill: {
state: {
visible: '{{!!$self.value}}',
},
},
},
},
redirectTo: {
title: t('Link'),
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {},
},
},
};
if (!(component as string).includes('Form')) {
delete schema.properties.dataClear;
}
return (
<SchemaSettingsModalItem
title={t('After successful submission')}
initialValues={fieldSchema?.['x-action-settings']?.['onSuccess']}
schema={{ ...schema } as ISchema}
onSubmit={(onSuccess) => {
fieldSchema['x-action-settings']['onSuccess'] = onSuccess;
dn.emit('patch', {
schema: {
['x-uid']: fieldSchema['x-uid'],
'x-action-settings': fieldSchema['x-action-settings'],
},
});
}}
/>
);
}

View File

@ -0,0 +1,149 @@
import _ from 'lodash';
import { ISchema, RecursionField, connect, observer, useField, useFieldSchema, useForm } from '@formily/react';
import {
ActionContextProvider,
CollectionProvider_deprecated,
FormProvider,
RecordProvider,
useDesignable,
useRequest,
} from '@nocobase/client';
import React, { useCallback, useEffect, useState } from 'react';
const viewerSchema: ISchema = {
type: 'void',
title: '{{ t("View record") }}',
'x-component': 'AssociationField.Viewer',
'x-component-props': {
className: 'nb-action-popup',
},
properties: {
tabs: {
type: 'void',
'x-component': 'Tabs',
'x-component-props': {},
'x-initializer': 'TabPaneInitializers',
properties: {
tab1: {
type: 'void',
title: '{{t("Details")}}',
'x-component': 'Tabs.TabPane',
'x-designer': 'Tabs.Designer',
'x-component-props': {},
properties: {
grid: {
type: 'void',
'x-component': 'Grid',
'x-initializer': 'RecordBlockInitializers',
properties: {},
},
},
},
},
},
},
};
export const useInsertSchema = (component) => {
const fieldSchema = useFieldSchema();
const { insertAfterBegin } = useDesignable();
const insert = useCallback(
(ss) => {
const schema = fieldSchema.reduceProperties((buf, s) => {
if (s['x-component'] === 'AssociationField.' + component) {
return s;
}
return buf;
}, null);
if (!schema) {
insertAfterBegin(_.cloneDeep(ss));
}
},
[component],
);
return insert;
};
const AssociatedFieldImplement = observer<any>((props) => {
const { collection, fieldExp, dateFieldExp, sourceCollection, sourceField } = props;
const [visible, setVisible] = useState<boolean>(false);
const insertViewer = useInsertSchema('Viewer');
const field = useField();
const form = useForm();
const fieldSchema = useFieldSchema();
const { designable } = useDesignable();
const fieldName = fieldExp?.replace(/{{(.*?)}}/, '$1');
const dateFieldName = dateFieldExp?.replace(/{{(.*?)}}/, '$1');
const { data, loading, run } = useRequest<{ data: any }>(
{
url: `/${sourceCollection}:get`,
params: {
appends: [sourceField],
filter: {
contract_id: form.values[fieldName]?.id,
start_date: { $lte: form.values[dateFieldName] },
end_date: { $gte: form.values[dateFieldName] },
},
},
},
{
manual: true,
},
);
useEffect(() => {
if (form.values[fieldName]?.id && form.values[dateFieldName]) {
run();
}
}, [form.values[fieldName]?.id, form.values[dateFieldName]]);
if (loading || !data) {
return null;
}
if (!data.data?.[sourceField + '_id']) {
form.setValues({
// FIXME should add base path
[fieldSchema.name]: null,
});
return null;
}
const record = data.data[sourceField];
form.setValues({
// FIXME should add base path
[fieldSchema.name]: record,
});
return (
<span>
<a
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
if (designable) {
insertViewer(viewerSchema);
}
setVisible(true);
}}
>
{record.name}
</a>
<CollectionProvider_deprecated name={collection}>
<ActionContextProvider value={{ visible, setVisible, openMode: 'drawer', snapshot: false }}>
<RecordProvider record={record}>
<FormProvider>
<RecursionField
schema={fieldSchema}
onlyRenderProperties
basePath={field.address}
filterProperties={(s) => {
return s['x-component'] === 'AssociationField.Viewer';
}}
/>
</FormProvider>
</RecordProvider>
</ActionContextProvider>
</CollectionProvider_deprecated>
</span>
);
});
export const AssociatedField = connect(AssociatedFieldImplement);

View File

@ -0,0 +1,191 @@
import { onFormValuesChange } from '@formily/core';
import { useField, useFieldSchema, useForm, useFormEffects } from '@formily/react';
import { Input } from '@nocobase/client';
import { Descriptions, DescriptionsProps } from 'antd';
import _ from 'lodash';
import React, { useEffect, useState } from 'react';
import { evaluate } from 'mathjs2';
const transformFormula = (formula: string) => {
if (!formula) return [];
const formulaArray = formula.split(/([+\-*/?:()%])/).filter((item) => item);
return formulaArray;
};
export const CalcResult = (props) => {
// 公式,单位,前缀,后缀,小数点位数,面板逻辑代码
const { formula, prefix, suffix, decimal, panel } = props;
const form = useForm();
const fieldSchema = useFieldSchema();
const field = useField();
const path: any = field.path.entire;
const fieldPath = path?.replace(`.${fieldSchema.name}`, '');
const defaultValue = fieldSchema.name === 'subtotal' ? '¥0.00' : [];
const [value, setValue] = useState<string | DescriptionsProps['items']>(defaultValue);
const transformFormulaArray = transformFormula(formula);
let calculateData = [];
const newFormulaArray = (data): [string, object] => {
calculateData = [];
let count = 0;
const scopes = {};
if (transformFormulaArray.length === 0) return;
for (let i = 0; i < transformFormulaArray.length; i++) {
const item = transformFormulaArray[i];
if (!item) continue;
const isNumber = !isNaN(Number(item));
const symbol = ['+', '-', '*', '/', '?', ':', '(', ')', '%'].includes(item);
if (!isNumber && !symbol) {
let value;
// 举例: ${fieldObj}.fieldName
const pattern = /\${(.*?)}/g;
if (item.match(pattern)?.length) {
const target = item.match(pattern)[0].replace(/\${|}/g, '');
// 以.分割字符串
const targetField = item.split('.')[1];
if (path === fieldPath) {
// @ts-ignore
value = _.chain(data).get(target).get(targetField, 0).value();
} else {
// @ts-ignore
value = _.chain(data).get(fieldPath).get(target).get(targetField, 0).value();
}
} else {
if (path === fieldPath) {
// @ts-ignore
value = _.chain(data).get(item, 0).value();
} else {
// @ts-ignore
value = _.chain(data).get(fieldPath).get(item, 0).value();
}
}
if (!value) {
value = 0;
}
count += 1;
const varName = 'var' + count;
const varValue = value;
scopes[varName] = varValue;
calculateData.push(varName);
} else {
calculateData.push(item);
}
}
return [calculateData.join(''), scopes];
};
const fun = () => {
if (!panel && transformFormulaArray.length) {
const [code, scopes] = newFormulaArray(form.values);
let result;
try {
const pre = prefix || '';
const suf = suffix || '';
const res = evaluate(code, scopes);
const main = isNaN(res) ? res : Number(res).toFixed(decimal || 0);
result = pre + main + suf;
} catch (error) {
result = `${code}`;
console.warn('code: ' + code + ' scopes: ' + scopes + 'error: ' + result + ' error message ' + error.message);
}
setValue(result.toString());
} else if (panel) {
let items = [];
// ====================字段配置举例==================
const exampleTemplate = `
let total = 0;
let allWeight = 0;
const products = {};
for (let i = 0; i < form.values.items.length; i++) {
const item = form.values.items[i];
// 计算 合计金额
const count = item?.count || 0;
const unitPrice = item?.unit_price || 0;
// 换算数量
let scale = 1;
if (item && item.product && item.product?.ratio) {
scale = item.product?.ratio;
}
total += count * scale * unitPrice;
// 计算 理论重量
let weight = 1;
if (item && item.product && item.product?.weight) {
weight = item.product.weight || 1;
}
allWeight += weight * count;
// 计算 产品分类
if (item && item.product) {
if (products[item.product.name]) {
products[item.product.name].count += count * scale;
} else {
products[item.product.name] = {
count: count * scale,
unit: item.product?.category?.conversion_unit || item.product?.category?.unit || '',
};
}
}
}
// 生成产品分类的数据
const weight = {
key: '1',
label: '理论重量',
children: (allWeight / 1000).toFixed(3) + '吨',
};
// const totalPrice = {
// key: '2',
// label: '合计',
// children: '¥' + total.toFixed(2),
// }
// ,, totalPrice
items.push(weight);
if (Object.keys(products).length > 0) {
for (const key in products) {
if (Object.prototype.hasOwnProperty.call(products, key)) {
const value = products[key];
items.push({
key: key,
label: key,
children: value.count.toFixed(3) + value.unit,
});
}
}
}
`;
try {
eval(panel);
} catch (error) {
items = [];
items.push({
key: '1',
label: '数据异常',
children: '请检查字段配置内容error' + error,
});
}
const showItems = items.map((item) => {
return {
label: item.label,
children: <p>{item.children}</p>,
};
});
setValue(showItems);
}
};
useEffect(() => {
fun();
}, []);
useFormEffects(() => {
onFormValuesChange((form) => {
fun();
});
});
if (typeof value === 'string') {
return <Input.ReadPretty value={value as string} />;
} else {
return <Descriptions items={value as DescriptionsProps['items']} />;
}
};

View File

@ -0,0 +1,16 @@
import { SchemaComponent } from '@nocobase/client';
import React from 'react';
export const CustomAssociatedField = (props) => {
if (!props.component) return;
return (
<SchemaComponent
schema={{
type: 'string',
name: props.component,
'x-component': props.component,
'x-component-props': props,
}}
/>
);
};

View File

@ -0,0 +1,75 @@
import { SchemaComponent, SchemaSettings, useApp, useDesignable, usePlugin } from '@nocobase/client';
import React from 'react';
import { useTranslation } from '../locale';
import { useField, useFieldSchema } from '@formily/react';
import { Field } from '@nocobase/database';
import { CUSTOM_COMPONENT_TYPE_FORM_ITEM } from '..';
import { useCustomComponent } from '../hooks/useCustomComponent';
export const CustomComponentStub = (props) => {
return <div></div>;
};
export const CustomComponentDispatcher = (props) => {
if (!props.component) return;
return (
<SchemaComponent
schema={{
type: 'void',
name: props.component,
'x-component': props.component,
'x-component-props': props,
}}
/>
);
};
export const customComponentDispatcherSettings = new SchemaSettings({
name: 'customComponentDispatcherSettings',
items: [
{
name: 'component',
type: 'select',
useComponentProps() {
const formItemComponents = useCustomComponent(CUSTOM_COMPONENT_TYPE_FORM_ITEM);
const { t } = useTranslation();
const field = useField<Field>();
const fieldSchema = useFieldSchema();
const { dn } = useDesignable();
return {
title: t('component'),
value: field.componentProps?.component || 'CustomComponentStub',
options: [
{
label: '未选择组件',
value: 'CustomComponentStub',
},
...formItemComponents,
],
onChange(component) {
const schema = {
['x-uid']: fieldSchema['x-uid'],
};
fieldSchema['x-component-props']['component'] = component;
schema['x-component-props'] = fieldSchema['x-component-props'];
field.componentProps.component = component;
dn.emit('patch', {
schema,
});
},
};
},
},
{
name: 'divider',
type: 'divider',
},
{
name: 'remove',
type: 'remove',
componentProps: {
removeParentsIfNoChildren: true,
},
},
],
});

View File

@ -0,0 +1,16 @@
import { SchemaComponent } from '@nocobase/client';
import React from 'react';
export const CustomField = (props) => {
if (!props.component) return;
return (
<SchemaComponent
schema={{
type: 'string',
name: props.component,
'x-component': props.component,
'x-component-props': props,
}}
/>
);
};

View File

@ -0,0 +1,28 @@
import React from 'react';
import { useAssociatedFields, useCollectionManager_deprecated, useCompile, Variable } from '@nocobase/client';
export const Expression = (props) => {
const { value = '', useCurrentFields, onChange } = props;
const compile = useCompile();
const { interfaces, getCollectionFields } = useCollectionManager_deprecated();
const fields = useCurrentFields?.() ?? [];
const associatedFields = useAssociatedFields();
console.log(associatedFields, 'associatedFields', fields);
const options = fields.map((field) => ({
label: compile(field.uiSchema.title),
value: field.name,
children:
getCollectionFields(field.target)
?.filter((subField) => subField.uiSchema)
.map((subField) => ({
label: subField.uiSchema ? compile(subField.uiSchema.title) : '',
value: subField.name,
})) ?? [],
}));
return <Variable.Input value={value} onChange={onChange} scope={options} changeOnSelect />;
};
export default Expression;

View File

@ -0,0 +1,51 @@
import { useFieldSchema } from '@formily/react';
import { SchemaSettingsSwitchItem, useDesignable } from '@nocobase/client';
import React from 'react';
import { useTranslation } from '../locale';
// 添加跳转页面选项
export const SessionSubmit = () => {
const { dn } = useDesignable();
const { t } = useTranslation();
const fieldSchema = useFieldSchema();
return (
<SchemaSettingsSwitchItem
title={t('Navigate to new page')}
checked={!!fieldSchema?.['x-action-settings']?.sessionSubmit}
onChange={(value) => {
fieldSchema['x-action-settings'].sessionSubmit = value;
dn.emit('patch', {
schema: {
['x-uid']: fieldSchema['x-uid'],
'x-action-settings': {
...fieldSchema['x-action-settings'],
},
},
});
}}
/>
);
};
export function SessionUpdate() {
const { dn } = useDesignable();
const { t } = useTranslation();
const fieldSchema = useFieldSchema();
return (
<SchemaSettingsSwitchItem
title={t('更新询问')}
checked={!!fieldSchema?.['x-action-settings']?.sessionUpdate}
onChange={(value) => {
fieldSchema['x-action-settings'].sessionUpdate = value;
dn.emit('patch', {
schema: {
['x-uid']: fieldSchema['x-uid'],
'x-action-settings': {
...fieldSchema['x-action-settings'],
},
},
});
}}
/>
);
}

View File

@ -0,0 +1,416 @@
import { TreeSelect } from '@formily/antd-v5';
import { Field, onFieldChange } from '@formily/core';
import { ISchema, Schema, useField, useFieldSchema } from '@formily/react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { message } from 'antd';
import { saveAs } from 'file-saver';
import {
GeneralSchemaDesigner,
SchemaSettingsDivider,
SchemaSettingsItem,
SchemaSettingsModalItem,
SchemaSettingsRemove,
SchemaSettingsSubMenu,
createDesignable,
findByUid,
useAPIClient,
useDesignable,
} from '@nocobase/client';
const toItems = (properties = {}) => {
const items = [];
for (const key in properties) {
if (Object.prototype.hasOwnProperty.call(properties, key)) {
const element = properties[key];
const item = {
label: element.title,
value: `${element['x-uid']}||${element['x-component']}`,
};
if (element.properties) {
const children = toItems(element.properties);
if (children?.length) {
item['children'] = children;
}
}
items.push(item);
}
}
return items;
};
const findMenuSchema = (fieldSchema: Schema) => {
let parent = fieldSchema.parent;
while (parent) {
if (parent['x-component'] === 'Menu') {
return parent;
}
parent = parent.parent;
}
};
const InsertMenuItems = (props) => {
const { eventKey, title, insertPosition } = props;
const { t } = useTranslation();
const { dn } = useDesignable();
const fieldSchema = useFieldSchema();
const isSubMenu = fieldSchema['x-component'] === 'Menu.SubMenu';
const api = useAPIClient();
if (!isSubMenu && insertPosition === 'beforeEnd') {
return null;
}
const serverHooks = [
{
type: 'onSelfCreate',
method: 'bindMenuToRole',
},
{
type: 'onSelfSave',
method: 'extractTextToLocale',
},
];
return (
<SchemaSettingsSubMenu eventKey={eventKey} title={title}>
<SchemaSettingsModalItem
eventKey={`${insertPosition}group`}
title={t('Group')}
schema={
{
type: 'object',
title: t('Add group'),
properties: {
title: {
'x-decorator': 'FormItem',
'x-component': 'Input',
title: t('Menu item title'),
required: true,
'x-component-props': {},
// description: `原字段标题:${collectionField?.uiSchema?.title}`,
},
icon: {
title: t('Icon'),
'x-component': 'IconPicker',
'x-decorator': 'FormItem',
},
},
} as ISchema
}
onSubmit={({ title, icon }) => {
dn.insertAdjacent(insertPosition, {
type: 'void',
title,
'x-component': 'Menu.SubMenu',
'x-decorator': 'ACLMenuItemProvider',
'x-component-props': {
icon,
},
'x-server-hooks': serverHooks,
});
}}
/>
<SchemaSettingsModalItem
eventKey={`${insertPosition}page`}
title={t('Page')}
schema={
{
type: 'object',
title: t('Add page'),
properties: {
title: {
'x-decorator': 'FormItem',
'x-component': 'Input',
title: t('Menu item title'),
required: true,
'x-component-props': {},
},
icon: {
title: t('Icon'),
'x-component': 'IconPicker',
'x-decorator': 'FormItem',
},
},
} as ISchema
}
onSubmit={({ title, icon }) => {
dn.insertAdjacent(insertPosition, {
type: 'void',
title,
'x-component': 'Menu.Item',
'x-decorator': 'ACLMenuItemProvider',
'x-component-props': {
icon,
},
'x-server-hooks': serverHooks,
properties: {
page: {
type: 'void',
'x-component': 'Page',
'x-async': true,
properties: {
grid: {
type: 'void',
'x-component': 'Grid',
'x-initializer': 'BlockInitializers',
properties: {},
},
},
},
},
});
}}
/>
<SchemaSettingsModalItem
eventKey={`${insertPosition}link`}
title={t('Link')}
schema={
{
type: 'object',
title: t('Add link'),
properties: {
title: {
title: t('Menu item title'),
required: true,
'x-component': 'Input',
'x-decorator': 'FormItem',
},
icon: {
title: t('Icon'),
'x-component': 'IconPicker',
'x-decorator': 'FormItem',
},
href: {
title: t('Link'),
'x-component': 'Input',
'x-decorator': 'FormItem',
},
},
} as ISchema
}
onSubmit={({ title, icon, href }) => {
dn.insertAdjacent(insertPosition, {
type: 'void',
title,
'x-component': 'Menu.URL',
'x-decorator': 'ACLMenuItemProvider',
'x-component-props': {
icon,
href,
},
'x-server-hooks': serverHooks,
});
}}
/>
<SchemaSettingsDivider />
<SchemaSettingsModalItem
eventKey={`${insertPosition}restore`}
title="加载……"
schema={
{
type: 'object',
title: '加载……',
properties: {
title: {
'x-decorator': 'FormItem',
'x-component': 'Input',
title: t('Menu item title'),
required: true,
'x-component-props': {},
},
file: {
type: 'object',
title: '文件',
'x-decorator': 'FormItem',
'x-component': 'Upload.Attachment',
'x-component-props': {
action: 'attachments:create',
multiple: false,
},
},
},
} as ISchema
}
onSubmit={async ({ title, file }) => {
const { data } = await api.request({
url: file.url,
baseURL: '/',
});
const s = data ?? {};
s.title = title;
dn.insertAdjacent(insertPosition, s);
}}
/>
</SchemaSettingsSubMenu>
);
};
export const MenuDesigner = () => {
const field = useField();
const fieldSchema = useFieldSchema();
const api = useAPIClient();
const { dn, refresh } = useDesignable();
const { t } = useTranslation();
const menuSchema = findMenuSchema(fieldSchema);
const items = toItems(menuSchema?.properties);
const effects = (form) => {
onFieldChange('target', (field: Field) => {
const [, component] = field?.value?.split?.('||') || [];
field.query('position').take((f: Field) => {
f.dataSource =
component === 'Menu.SubMenu'
? [
{ label: t('Before'), value: 'beforeBegin' },
{ label: t('After'), value: 'afterEnd' },
{ label: t('Inner'), value: 'beforeEnd' },
]
: [
{ label: t('Before'), value: 'beforeBegin' },
{ label: t('After'), value: 'afterEnd' },
];
});
});
};
const schema = {
type: 'object',
title: t('Edit menu item'),
properties: {
title: {
title: t('Menu item title'),
required: true,
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {},
},
icon: {
title: t('Menu item icon'),
'x-component': 'IconPicker',
'x-decorator': 'FormItem',
},
},
};
const initialValues = {
title: field.title,
icon: field.componentProps.icon,
};
if (fieldSchema['x-component'] === 'Menu.URL') {
schema.properties['href'] = {
title: t('Link'),
'x-component': 'Input',
'x-decorator': 'FormItem',
};
initialValues['href'] = field.componentProps.href;
}
return (
<GeneralSchemaDesigner>
<SchemaSettingsModalItem
title={t('Edit')}
eventKey="edit"
schema={schema as ISchema}
initialValues={initialValues}
onSubmit={({ title, icon, href }) => {
const schema = {
['x-uid']: fieldSchema['x-uid'],
'x-server-hooks': [
{
type: 'onSelfSave',
method: 'extractTextToLocale',
},
],
};
if (title) {
fieldSchema.title = title;
field.title = title;
schema['title'] = title;
refresh();
}
field.componentProps.icon = icon;
field.componentProps.href = href;
schema['x-component-props'] = { icon, href };
fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {};
fieldSchema['x-component-props']['icon'] = icon;
fieldSchema['x-component-props']['href'] = href;
dn.emit('patch', {
schema,
});
}}
/>
<SchemaSettingsModalItem
title={t('Move to')}
eventKey="move-to"
components={{ TreeSelect }}
effects={effects}
schema={
{
type: 'object',
title: t('Move to'),
properties: {
target: {
title: t('Target'),
enum: items,
required: true,
'x-decorator': 'FormItem',
'x-component': 'TreeSelect',
'x-component-props': {},
},
position: {
title: t('Position'),
required: true,
enum: [
{ label: t('Before'), value: 'beforeBegin' },
{ label: t('After'), value: 'afterEnd' },
],
default: 'afterEnd',
'x-component': 'Radio.Group',
'x-decorator': 'FormItem',
},
},
} as ISchema
}
onSubmit={({ target, position }) => {
const [uid] = target?.split?.('||') || [];
if (!uid) {
return;
}
const current = findByUid(menuSchema, uid);
const dn = createDesignable({
t,
api,
refresh,
current,
});
dn.loadAPIClientEvents();
dn.insertAdjacent(position, fieldSchema);
}}
/>
<SchemaSettingsDivider />
<InsertMenuItems eventKey={'insertbeforeBegin'} title={t('Insert before')} insertPosition={'beforeBegin'} />
<InsertMenuItems eventKey={'insertafterEnd'} title={t('Insert after')} insertPosition={'afterEnd'} />
<InsertMenuItems eventKey={'insertbeforeEnd'} title={t('Insert inner')} insertPosition={'beforeEnd'} />
<SchemaSettingsDivider />
<SchemaSettingsItem
title="保存……"
onClick={async () => {
const deleteUid = (s: ISchema) => {
delete s['name'];
delete s['x-uid'];
Object.keys(s.properties || {}).forEach((key) => {
deleteUid(s.properties[key]);
});
};
const { data } = await api.request({
url: `/uiSchemas:getJsonSchema/${fieldSchema['x-uid']}?includeAsyncNode=true`,
});
const s = data?.data || {};
deleteUid(s);
const blob = new Blob([JSON.stringify(s, null, 2)], { type: 'application/json' });
saveAs(blob, 'export.json');
message.success('保存成功');
}}
/>
<SchemaSettingsRemove
confirm={{
title: t('Delete menu item'),
}}
/>
</GeneralSchemaDesigner>
);
};

View File

@ -0,0 +1,105 @@
import { css } from '@emotion/css';
import { ArrayCollapse } from '@formily/antd-v5';
import { Form } from '@formily/core';
import { observer, useField, useFieldSchema } from '@formily/react';
import {
FormBlockContext,
RecordProvider,
SchemaComponent,
getShouldChange,
useCollectionManager_deprecated,
__UNSAFE__VariablesContextType,
__UNSAFE__DynamicComponentProps,
__UNSAFE__VariableOption,
} from '@nocobase/client';
import React, { useMemo } from 'react';
import { FilterContext } from './context';
import { VariableInput } from './VariableInput';
interface usePropsReturn {
options: any;
defaultValues: any[];
collectionName: string;
form: Form;
variables: __UNSAFE__VariablesContextType;
localVariables: __UNSAFE__VariableOption | __UNSAFE__VariableOption[];
record: Record<string, any>;
/**
* create update
*/
formBlockType: 'create' | 'update';
fields: any;
}
interface Props {
useProps: () => usePropsReturn;
dynamicComponent: any;
}
export const FormFilterScope = observer(
(props: Props) => {
const fieldSchema = useFieldSchema();
const { useProps, dynamicComponent } = props;
const { options, defaultValues, collectionName, form, formBlockType, variables, localVariables, record, fields } =
useProps();
const { getAllCollectionsInheritChain } = useCollectionManager_deprecated();
const components = useMemo(() => ({ ArrayCollapse }), []);
const schema = useMemo(
() => ({
type: 'object',
properties: {
condition: {
'x-component': 'Filter',
default: defaultValues,
'x-component-props': {
collectionName,
useProps() {
return {
options,
className: css`
position: relative;
width: 100%;
margin-left: 10px;
`,
};
},
dynamicComponent: (props: __UNSAFE__DynamicComponentProps) => {
const { collectionField } = props;
return (
<VariableInput
{...props}
form={form}
record={record}
shouldChange={getShouldChange({
collectionField,
variables,
localVariables,
getAllCollectionsInheritChain,
})}
fields={fields}
/>
);
},
},
},
},
}),
[collectionName, defaultValues, form, localVariables, options, props, record, variables, fields],
);
const value = useMemo(
() => ({ field: options, fieldSchema, dynamicComponent, options: options || [] }),
[dynamicComponent, fieldSchema, options],
);
return (
<FormBlockContext.Provider value={{ form, type: formBlockType }}>
<RecordProvider record={record}>
<FilterContext.Provider value={value}>
<SchemaComponent components={components} schema={schema} />
</FilterContext.Provider>
</RecordProvider>
</FormBlockContext.Provider>
);
},
{ displayName: 'FormLinkageRules' },
);

View File

@ -0,0 +1,63 @@
import { ISchema, useField, useFieldSchema, useForm } from '@formily/react';
import { SchemaSettingsItem, useFormActiveFields, useSchemaSettings } from '@nocobase/client';
import { App, ModalFuncProps } from 'antd';
import { FC } from 'react';
import { useTranslation } from '../../locale';
import { Field } from '@formily/core';
import React from 'react';
export interface SchemaSettingsRemoveProps {
confirm?: ModalFuncProps;
removeParentsIfNoChildren?: boolean;
breakRemoveOn?: ISchema | ((s: ISchema) => boolean);
}
export const SchemaSettingsRemove: FC<SchemaSettingsRemoveProps> = (props) => {
const { confirm, removeParentsIfNoChildren, breakRemoveOn } = props;
const { dn, template } = useSchemaSettings();
const { t } = useTranslation();
const field = useField<Field>();
const fieldSchema = useFieldSchema();
const form = useForm();
const { modal } = App.useApp();
const { removeActiveFieldName } = useFormActiveFields() || {};
return (
<SchemaSettingsItem
title="Delete"
eventKey="remove"
onClick={() => {
modal.confirm({
title: t('Delete block'),
content: t('Are you sure you want to delete it?'),
...confirm,
async onOk() {
const options = {
removeParentsIfNoChildren,
breakRemoveOn,
};
if (field?.required) {
field.required = false;
fieldSchema['required'] = false;
}
await dn.remove(null, options);
await confirm?.onOk?.();
delete form.values[fieldSchema.name];
const name = fieldSchema.name as string;
const title = fieldSchema.title;
for (const key in form.fields) {
if (key.includes(name) && form.fields[key].title == title) {
delete form.fields[key];
}
}
removeActiveFieldName?.(fieldSchema.name as string);
if (field?.setInitialValue && field?.reset) {
field.setInitialValue(null);
field.reset();
}
},
});
}}
>
{t('Delete')}
</SchemaSettingsItem>
);
};

View File

@ -0,0 +1,432 @@
import { Form } from '@formily/core';
// @ts-ignore
import { Schema } from '@formily/json-schema';
import { SchemaOptionsContext, useField, useFieldSchema } from '@formily/react';
import {
CollectionFieldOptions,
useVariableScope,
__UNSAFE__VariablesContextType,
__UNSAFE__VariableOption,
Variable,
isVariable,
useUserVariable,
__UNSAFE__VariableInputOption,
__UNSAFE__,
useDateVariable,
useCollectionManager_deprecated,
useCompile,
} from '@nocobase/client';
import _ from 'lodash';
import React, { useCallback, useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { FilterContext } from './context';
const { useBlockCollection, useValues, useVariableOptions, useContextAssociationFields, useRecordVariable } =
__UNSAFE__;
interface GetShouldChangeProps {
collectionField: CollectionFieldOptions;
variables: __UNSAFE__VariablesContextType;
localVariables: __UNSAFE__VariableOption | __UNSAFE__VariableOption[];
/** `useCollectionManager` 返回的 */
getAllCollectionsInheritChain: (collectionName: string) => string[];
}
interface RenderSchemaComponentProps {
value: any;
onChange: (value: any) => void;
}
type Props = {
value: any;
onChange: (value: any, optionPath?: any[]) => void;
renderSchemaComponent: (props: RenderSchemaComponentProps) => any;
schema?: any;
/** 消费变量值的字段 */
targetFieldSchema?: Schema;
children?: any;
className?: string;
style?: React.CSSProperties;
collectionField: CollectionFieldOptions;
contextCollectionName?: string;
/**指定当前表单数据表 */
currentFormCollectionName?: string;
/**指定当前对象数据表 */
currentIterationCollectionName?: string;
/**
* `onChange` `onChange`
* @param value `onChange`
* @returns `true` `onChange`
*/
shouldChange?: (value: any, optionPath?: any[]) => Promise<boolean>;
form?: Form;
/**
*
*/
record?: Record<string, any>;
/**
* scope
* @param scope
* @returns
*/
returnScope?: (scope: __UNSAFE__VariableInputOption[]) => any[];
fields: any;
};
/**
*
* -
* -
* @param props
* @returns
*/
export const VariableInput = (props: Props) => {
const {
value,
onChange,
renderSchemaComponent: RenderSchemaComponent,
style,
schema,
className,
contextCollectionName,
collectionField,
shouldChange,
form,
record,
returnScope = _.identity,
targetFieldSchema,
currentFormCollectionName,
currentIterationCollectionName,
fields,
} = props;
const { name: blockCollectionName } = useBlockCollection();
const scope = useVariableScope();
const { operator, schema: uiSchema = collectionField?.uiSchema } = useValues();
const variableOptions = useVariableOptions({
collectionField,
form,
record,
operator,
uiSchema,
targetFieldSchema,
currentFormCollectionName,
currentIterationCollectionName,
});
const contextVariable = useContextAssociationFields({ schema, maxDepth: 2, contextCollectionName, collectionField });
const { compatOldVariables } = useCompatOldVariables({
collectionField,
uiSchema,
targetFieldSchema,
blockCollectionName,
});
if (contextCollectionName && variableOptions.every((item) => item.value !== contextVariable.value)) {
variableOptions.push(contextVariable);
}
const handleChange = useCallback(
(value: any, optionPath: any[]) => {
if (!shouldChange) {
return onChange(value);
}
// `shouldChange` 这个函数的运算量比较大,会导致展开变量列表时有明显的卡顿感,在这里加个延迟能有效解决这个问题
setTimeout(async () => {
if (await shouldChange(value, optionPath)) {
onChange(value);
}
});
},
[onChange, shouldChange],
);
const options = useVariableCustomOptions(fields);
return (
<Variable.Input
className={className}
value={value}
onChange={handleChange}
scope={options}
style={style}
changeOnSelect
>
<RenderSchemaComponent value={value} onChange={onChange} />
</Variable.Input>
);
};
/**
*
*/
export const getShouldChange = ({
collectionField,
variables,
localVariables,
getAllCollectionsInheritChain,
}: GetShouldChangeProps) => {
const collectionsInheritChain = collectionField ? getAllCollectionsInheritChain(collectionField.target) : [];
return async (value: any, optionPath: any[]) => {
if (_.isString(value) && value.includes('$nRole')) {
return true;
}
if (!isVariable(value) || !variables || !collectionField) {
return true;
}
// `json` 可以选择任意类型的变量详见https://nocobase.feishu.cn/docx/EmNEdEBOnoQohUx2UmBcqIQ5nyh#FPLfdSRDEoXR65xW0mBcdfL5n0c
if (collectionField.interface === 'json') {
return true;
}
const lastOption = optionPath[optionPath.length - 1];
// 点击叶子节点时,必须更新 value
if (lastOption && _.isEmpty(lastOption.children) && !lastOption.loadChildren) {
return true;
}
const collectionFieldOfVariable = await variables.getCollectionField(value, localVariables);
if (!collectionField) {
return false;
}
// `一对一` 和 `一对多` 的不能用于设置默认值,因为其具有唯一性
if (['o2o', 'o2m', 'oho'].includes(collectionFieldOfVariable?.interface)) {
return false;
}
if (!collectionField.target && collectionFieldOfVariable?.target) {
return false;
}
if (collectionField.target && !collectionFieldOfVariable?.target) {
return false;
}
if (
collectionField.target &&
collectionFieldOfVariable?.target &&
!collectionsInheritChain.includes(collectionFieldOfVariable?.target)
) {
return false;
}
return true;
};
};
export interface FormatVariableScopeParam {
children: any[];
disabled: boolean;
name: string;
title: string;
}
export interface FormatVariableScopeReturn {
value: string;
key: string;
label: string;
disabled: boolean;
children?: any[];
}
/**
*
* @param variables
*/
export function useCompatOldVariables(props: {
uiSchema: any;
collectionField: CollectionFieldOptions;
blockCollectionName: string;
noDisabled?: boolean;
targetFieldSchema?: Schema;
}) {
const { uiSchema, collectionField, noDisabled, targetFieldSchema, blockCollectionName } = props;
const { t } = useTranslation();
const lowLevelUserVariable = useUserVariable({
maxDepth: 1,
uiSchema: uiSchema,
collectionField,
noDisabled,
targetFieldSchema,
});
const currentRecordVariable = useRecordVariable({
schema: uiSchema,
collectionName: blockCollectionName,
collectionField,
noDisabled,
targetFieldSchema,
});
const compatOldVariables = useCallback(
(variables: __UNSAFE__VariableInputOption[], { value }) => {
if (!isVariable(value)) {
return variables;
}
variables = _.cloneDeep(variables);
const systemVariable: __UNSAFE__VariableInputOption = {
value: '$system',
key: '$system',
label: t('System variables'),
isLeaf: false,
children: [
{
value: 'now',
key: 'now',
label: t('Current time'),
isLeaf: true,
depth: 1,
},
],
depth: 0,
};
const currentTime = {
value: 'currentTime',
label: t('Current time'),
children: null,
};
if (value.includes('$system')) {
variables.push(systemVariable);
}
if (value.includes(`${blockCollectionName}.`)) {
const variable = variables.find((item) => item.value === '$nForm' || item.value === '$nRecord');
if (variable) {
variable.value = blockCollectionName;
}
}
if (value.includes('$form')) {
const variable = variables.find((item) => item.value === '$nForm');
if (variable) {
variable.value = '$form';
}
}
if (value.includes('currentUser')) {
const userVariable = variables.find((item) => item.value === '$user');
if (userVariable) {
userVariable.value = 'currentUser';
} else {
variables.unshift({ ...lowLevelUserVariable, value: 'currentUser' });
}
}
if (value.includes('currentRecord')) {
const formVariable = variables.find((item) => item.value === '$nRecord');
if (formVariable) {
formVariable.value = 'currentRecord';
} else {
variables.unshift({ ...currentRecordVariable, value: 'currentRecord' });
}
}
if (value.includes('currentTime')) {
variables.push(currentTime);
}
if (value.includes('$date')) {
const formVariable = variables.find((item) => item.value === '$nDate');
if (formVariable) {
formVariable.value = '$date';
}
}
return variables;
},
[blockCollectionName],
);
return { compatOldVariables };
}
export const useVariableCustomOptions = (fields) => {
const field = useField<any>();
const { operator, schema } = field.data || {};
const userVariable = useUserVariable({
collectionField: { uiSchema: schema },
uiSchema: schema,
});
const dateVariable = useDateVariable({ operator, schema });
const filterVariable = useFilterVariable(fields);
const result = useMemo(
() => [userVariable, dateVariable, filterVariable].filter(Boolean),
[dateVariable, userVariable, filterVariable],
);
if (!operator || !schema) return [];
return result;
};
export const useFilterVariable = (fields) => {
const { t } = useTranslation();
const compile = useCompile();
const { collections } = useCollectionManager_deprecated();
const customFields = [];
for (const field in fields) {
customFields.push({
key: field,
...fields[field],
});
}
const options = customFields
.filter((value) => value?.props?.name.includes('custom.'))
.map((custom) => {
const value = custom?.props?.name.replace(/^custom\./, '');
const collection = collections.filter((collection) => collection.name === value)[0];
let children = collection.fields.map((collectionField) => {
if (
collectionField.interface !== 'o2o' &&
collectionField.interface !== 'oho' &&
collectionField.interface !== 'm2o' &&
collectionField.interface !== 'createdBy' &&
collectionField.interface !== 'updatedBy' &&
collectionField.interface !== 'o2m' &&
collectionField.interface !== 'm2m' &&
collectionField.interface !== 'linkTo' &&
collectionField.interface !== 'chinaRegion' &&
collectionField.interface !== 'obo' &&
collectionField.interface !== 'createdAt' &&
collectionField.interface !== 'updatedAt'
) {
return {
key: collectionField.key,
value: collectionField.name,
label: compile(collectionField.uiSchema.title),
};
}
});
children = children.filter(Boolean);
return children.length
? {
key: custom?.props?.name,
value,
label: custom.title,
children,
}
: {
key: custom?.props?.name,
value,
label: custom.title,
};
});
const result = useMemo(
() => ({
label: t('Current filter'),
value: '$nFilter',
key: '$nFilter',
children: options,
}),
[options, t],
);
if (!options.length) return null;
return result;
};

View File

@ -0,0 +1,16 @@
import { ObjectField } from '@formily/core';
import { Schema } from '@formily/react';
import { createContext } from 'react';
export interface FilterContextProps {
field?: ObjectField & { collectionName?: string };
fieldSchema?: Schema;
dynamicComponent?: any;
options?: any[];
disabled?: boolean;
collectionName?: string;
}
export const RemoveConditionContext = createContext(null);
export const FilterContext = createContext<FilterContextProps>(null);
export const FilterLogicContext = createContext(null);

View File

@ -0,0 +1,21 @@
import { SchemaSettingsItem } from '@nocobase/client';
import React, { useContext } from 'react';
import { useTranslation } from '../../locale';
import { Modal } from 'antd';
import { GroupBlockContext } from '../../schema-initializer/GroupBlockInitializer';
export const GroupBlockConfigure = (props) => {
const { t } = useTranslation();
const { setVisible, visible } = useContext(GroupBlockContext);
return (
<SchemaSettingsItem
title="Configure"
key="configure"
onClick={async () => {
setVisible(true);
}}
>
{t('Configure')}
</SchemaSettingsItem>
);
};

View File

@ -0,0 +1,149 @@
import { useField, useFieldSchema } from '@formily/react';
import { useDesignable, useFieldNames } from '@nocobase/client';
import { App, Button, Flex, Modal, Select } from 'antd';
import React, { useContext, useEffect, useState } from 'react';
import { useTranslation } from '../../locale';
import { GroupBlockContext } from '../../schema-initializer/GroupBlockInitializer';
import { transformers } from './transformers';
import { DeleteOutlined } from '@ant-design/icons';
export type SelectedField = {
field: string | string[];
alias?: string;
};
export const GroupConfigure = (props) => {
const { visible, setVisible } = useContext(GroupBlockContext);
const { t } = useTranslation();
const { dn } = useDesignable();
const { modal } = App.useApp();
const fieldSchema = useFieldSchema();
const measure = fieldSchema['x-decorator-props']?.params?.measures;
const [fieldOption, setFieldOption] = useState(
JSON.parse(JSON.stringify(measure.filter((item) => item.fieldFormat))),
);
const valueOption = measure.map((item) => {
return {
label: item.label,
value: item.field[0],
};
});
const decimal = transformers.option.filter((item) => item.value === 'decimal')[0].childrens;
useEffect(() => {
if (!visible) {
return;
}
}, [visible]);
return (
<Modal
title={t('Configure Group')}
open={visible}
onOk={() => {
measure.forEach((measureItem) => {
delete measureItem['fieldFormat'];
fieldOption.forEach((fieldOptionItem) => {
if (measureItem.field[0] === fieldOptionItem?.fieldFormat?.fieldValue) {
measureItem['fieldFormat'] = { ...fieldOptionItem.fieldFormat };
}
});
});
fieldSchema['x-decorator-props']['params'].measures = measure;
dn.emit('patch', {
schema: fieldSchema,
});
setVisible(false);
dn.refresh();
}}
onCancel={() => {
modal.confirm({
title: t('Are you sure to cancel?'),
content: t('You changes are not saved. If you click OK, your changes will be lost.'),
okButtonProps: {
danger: true,
},
onOk: () => {
setVisible(false);
},
});
}}
width={'30%'}
>
<div style={{ display: 'flex', marginBottom: '10px', marginTop: '10px', flexDirection: 'column' }}>
{fieldOption.map((item, index) => {
return (
<div style={{ marginBottom: '7px' }} key={index}>
<Select
options={valueOption}
placeholder="Field"
style={{ marginRight: '5px' }}
value={item?.fieldFormat?.fieldValue ? item?.fieldFormat?.fieldValue : undefined}
onChange={(v) => {
fieldConfig('field', fieldOption, setFieldOption, index, v);
}}
/>
<Select
options={transformers.option}
placeholder="Format"
style={{ marginRight: '5px' }}
value={item?.fieldFormat?.option ? item?.fieldFormat?.option : undefined}
onChange={(v) => {
fieldConfig('format', fieldOption, setFieldOption, index, v);
}}
/>
{item?.fieldFormat?.option === 'decimal' ? (
<Select
options={decimal}
placeholder="Digits"
style={{ marginRight: '5px' }}
value={item.fieldFormat.decimal}
onChange={(v) => fieldConfig('decimal', fieldOption, setFieldOption, index, v)}
/>
) : null}
<DeleteOutlined
style={{ fontSize: '14px' }}
onClick={() => {
fieldConfig('del', fieldOption, setFieldOption, index);
}}
/>
</div>
);
})}
</div>
<Button
type="dashed"
style={{ borderRadius: '0', width: '30%' }}
onClick={() => {
fieldConfig('add', fieldOption, setFieldOption);
}}
>
+ Add Field
</Button>
</Modal>
);
};
const fieldConfig = (type, fieldOption, setFieldOption, index?, record?) => {
const option = [...fieldOption];
if (type === 'add') {
option.push({});
} else if (type === 'del') {
option.splice(index, 1);
} else if (type === 'field') {
option[index]['fieldFormat'] = {
...option[index]['fieldFormat'],
fieldValue: record,
};
} else if (type === 'format') {
option[index]['fieldFormat'] = {
...option[index]['fieldFormat'],
option: record,
};
} else if (type === 'decimal') {
option[index]['fieldFormat'] = {
...option[index]['fieldFormat'],
decimal: record,
};
}
setFieldOption(option);
};

View File

@ -0,0 +1,91 @@
export const transformers = {
option: [
{
label: 'Percent',
value: 'pertent',
component: (val: number) =>
new Intl.NumberFormat('en-US', { style: 'percent', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
val,
),
},
{
label: 'Currency',
value: 'currency',
component: (val: number, locale = 'en-US') => {
const currency = {
'zh-CN': 'CNY',
'en-US': 'USD',
'ja-JP': 'JPY',
'ko-KR': 'KRW',
'pt-BR': 'BRL',
'ru-RU': 'RUB',
'tr-TR': 'TRY',
'es-ES': 'EUR',
}[locale];
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(val);
},
},
{ label: 'Exponential', value: 'exponential', component: (val: number | string) => (+val)?.toExponential() },
{
label: 'Abbreviation',
value: 'abbreviation',
component: (val: number, locale = 'en-US') => new Intl.NumberFormat(locale, { notation: 'compact' }).format(val),
},
{
label: 'Decimal',
value: 'decimal',
childrens: [
{
label: '1.0',
value: 'OneDigits',
component: (val: number) =>
new Intl.NumberFormat('en-US', {
style: 'decimal',
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}).format(val),
},
{
label: '1.00',
value: 'TwoDigits',
component: (val: number) =>
new Intl.NumberFormat('en-US', {
style: 'decimal',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(val),
},
{
label: '1.000',
value: 'ThreeDigits',
component: (val: number) =>
new Intl.NumberFormat('en-US', {
style: 'decimal',
minimumFractionDigits: 3,
maximumFractionDigits: 3,
}).format(val),
},
{
label: '1.0000',
value: 'FourDigits',
component: (val: number) =>
new Intl.NumberFormat('en-US', {
style: 'decimal',
minimumFractionDigits: 4,
maximumFractionDigits: 4,
}).format(val),
},
{
label: '1.00000',
value: 'FiveDigits',
component: (val: number) =>
new Intl.NumberFormat('en-US', {
style: 'decimal',
minimumFractionDigits: 5,
maximumFractionDigits: 5,
}).format(val),
},
],
},
],
};

View File

@ -0,0 +1,103 @@
import React, { useEffect, useState } from 'react';
import { Button, Drawer, Form, Input, Select, Space, Table } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useRequest } from '@nocobase/client';
export const LinkManager = () => {
const [open, setOpen] = useState(false);
const [formData, setFormData] = useState<any>({});
const [tableData, setTableData] = useState<any>([]);
const { data, run } = useRequest<{ data: any }>({
url: `/link-manage:get`,
});
const { run: updateLink } = useRequest<{ data: any }>({
url: `/link-manage:set`,
params: { id: formData.id, link: formData.link },
});
useEffect(() => {
if (data) {
setTableData(data.data);
}
}, [data]);
const showDrawer = (record) => {
setFormData(record);
setOpen(true);
};
const onClose = () => {
setOpen(false);
};
const update = () => {
updateLink();
// 更新表格数据
const mergedArray = tableData.map((item) => {
const matchingItem = item.id == formData.id;
if (matchingItem) {
return formData;
}
return item;
});
setTableData(mergedArray);
setOpen(false);
setFormData({});
};
const columns: ColumnsType = [
{
title: '名称',
dataIndex: 'name',
key: 'name',
render: (text) => <a>{text}</a>,
},
{
title: '链接',
dataIndex: 'link',
key: 'link',
render: (text) => <a>{text}</a>,
},
{
title: 'Action',
key: 'action',
render: (_, record) => (
<Space size="middle">
<a onClick={() => showDrawer(record)}></a>
</Space>
),
},
];
return (
<>
{tableData && <Table columns={columns} dataSource={tableData}></Table>}
<Drawer
title="设定链接地址"
width={720}
onClose={onClose}
open={open}
styles={{
body: {
paddingBottom: 80,
},
}}
extra={
<Space>
<Button onClick={onClose}></Button>
<Button onClick={update} type="primary">
</Button>
</Space>
}
>
<Form layout="vertical">
<Form.Item name="name" label="Name">
<Input placeholder="Please enter Name" defaultValue={formData?.name} disabled />
</Form.Item>
<Form.Item name="link" label="Link">
<Input
onChange={(e) => setFormData({ ...formData, link: e.target.value })}
placeholder="Please enter Link"
defaultValue={formData?.link}
/>
</Form.Item>
</Form>
</Drawer>
</>
);
};

View File

@ -0,0 +1,36 @@
import React from 'react';
import { css } from '@nocobase/client';
import { Button, Tooltip } from 'antd';
import { MobileOutlined } from '@ant-design/icons';
import { useTranslation } from '../locale';
export const MobileLink = () => {
const { t } = useTranslation();
return (
<div
className={css`
.ant-btn {
border: 0;
height: 46px;
width: 46px;
border-radius: 0;
background: none;
color: rgba(255, 255, 255, 0.65) !important;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
}
a {
padding-top: 12px;
}
`}
style={{ display: 'inline-block' }}
>
<Tooltip title={t('Mobile UI')} placement="bottom">
<Button role="button" href="/mobile">
<MobileOutlined />
</Button>
</Tooltip>
</div>
);
};

View File

@ -0,0 +1,97 @@
import React from 'react';
import { css, useRequest } from '@nocobase/client';
import { Badge, Button, Popover, List, message } from 'antd';
import { BellOutlined } from '@ant-design/icons';
import { useLinkKey, useInitializationLinkKey } from '../hooks/useNotifications';
import { useNavigate } from 'react-router-dom';
import { dayjs } from '@nocobase/utils/client';
export const Notifications = () => {
// 注册链接关联链接管理使用nocobase代码方式注册数据表
useInitializationLinkKey();
const linkDetail = useLinkKey();
const navigate = useNavigate();
const { data, run } = useRequest<{ data: any }>({
url: `/system_notifications:get`,
});
const getNotificationList = data?.data || [];
const { run: updateRead } = useRequest<{ data: any }>({
url: `/system_notifications:update`,
params: {
ids: getNotificationList.map((item) => item.id),
},
});
const content = (
<List
style={{ width: '400px' }}
itemLayout="horizontal"
dataSource={getNotificationList}
footer={
<Button.Group style={{ width: '100%' }}>
<Button
block
onClick={() => {
updateRead();
run();
message.info('成功');
}}
>
</Button>
<Button
block
onClick={() => {
navigate(linkDetail?.link);
}}
>
</Button>
</Button.Group>
}
renderItem={(item: any) => (
<List.Item>
<List.Item.Meta title={item.title} description={dayjs(item.createdAt).calendar()} />
{item.content}
</List.Item>
)}
/>
);
return (
<div
className={css`
.ant-btn {
border: 0;
height: 46px;
width: 46px;
border-radius: 0;
background: none;
color: rgba(255, 255, 255, 0.65) !important;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
}
a {
padding-top: 12px;
}
.ant-badge-count {
min-width: 10px;
height: 10px;
line-height: 10px;
font-size: 9px;
border-radius: 5px;
padding: 0 2px;
}
`}
style={{ display: 'inline-block' }}
>
<Popover content={content}>
<Badge count={getNotificationList.length} size="small" offset={[-15, 15]}>
<Button role="button">
<BellOutlined />
</Button>
</Badge>
</Popover>
</div>
);
};

View File

@ -0,0 +1,68 @@
import { uid } from '@formily/shared';
import { css, useAPIClient, useApp } from '@nocobase/client';
import { Button, Dropdown } from 'antd';
import React, { useEffect, useState } from 'react';
import _ from 'lodash';
const OnlineUserManger = () => {
const app = useApp();
const [onlineUserItems, setOnlineUserItems] = useState([]);
const api = useAPIClient();
useEffect(() => {
app.ws.on('message', (event: MessageEvent) => {
const data = JSON.parse(event.data);
if (data?.type === 'plugin-online-user') {
const onlineUserItems = data.payload.users?.map((user) => {
if (user) {
return {
key: uid(),
label: user.nickname,
};
}
});
setOnlineUserItems(onlineUserItems);
}
});
}, [app]);
useEffect(() => {
const data = {
type: 'plugin-online-user:client',
payload: {
token: api.auth.getToken(),
},
};
app.ws.send(JSON.stringify(data));
}, []);
return (
<Dropdown menu={{ items: onlineUserItems }}>
<Button style={{ width: 'auto' }} type="text">
线 {_.size(onlineUserItems)}
</Button>
</Dropdown>
);
};
export const OnlineUserDropdown = () => {
return (
<div
className={css`
.ant-btn {
border: 0;
height: 46px;
width: 46px;
border-radius: 0;
background: none;
color: rgba(255, 255, 255, 0.65) !important;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
}
`}
style={{ display: 'inline-block' }}
>
<OnlineUserManger />
</div>
);
};

View File

@ -0,0 +1,31 @@
import React from 'react';
import { Popover, Space, Button, Input } from 'antd';
import { useFieldSchema } from '@formily/react';
import { useProps } from '@nocobase/client';
import { ShareAltOutlined } from '@ant-design/icons';
export const OutboundButton = (props) => {
const { onClick } = useProps(props);
const schema = useFieldSchema();
const url = window.location.href.split('/', 3).join('/');
return (
<Popover
placement="bottomRight"
trigger="click"
autoAdjustOverflow
content={
<Space.Compact style={{ width: '100%' }}>
<Input defaultValue={`${url}/r/${schema['x-uid']}`} />
<Button type="primary" onClick={onClick}>
</Button>
</Space.Compact>
}
>
<Button>
<ShareAltOutlined />
</Button>
</Popover>
);
};

View File

@ -0,0 +1,100 @@
import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
import { Spin } from 'antd';
import { LoadingOutlined } from '@ant-design/icons';
import { Document, Page, pdfjs } from 'react-pdf';
import { saveAs } from 'file-saver';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
import { uid } from '@formily/shared';
import { useRequest } from '@nocobase/client';
import { useTranslation } from '../locale';
// TODO CMap settings.
pdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cat.net/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.js`;
interface PDFViewerProps {
file: string;
width: number;
}
interface PDFViewerRef {
download: () => void;
print: () => void;
}
const LoadingSpin = ({ children, spinning }) => {
const { t } = useTranslation();
return (
<Spin tip={t('loading...')} indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} spinning={spinning}>
{children}
</Spin>
);
};
export const PDFViewer = forwardRef<PDFViewerRef, PDFViewerProps>((props, ref) => {
const { t } = useTranslation();
const [numPages, setNumPages] = useState<number>(0);
const [contentWindow, setContentWindow] = useState<Window>(null);
const { file, width = 960 } = props;
const { loading, data, run } = useRequest({
url: file,
responseType: 'arraybuffer',
});
useImperativeHandle(ref, () => ({
download() {
const blob = new Blob([data as ArrayBuffer], { type: 'application/pdf' });
saveAs(blob, uid() + '.pdf');
},
print() {
contentWindow.print();
},
}));
useEffect(() => {
run();
}, [file]);
useEffect(() => {
if (loading || !data) {
return;
}
const blob = new Blob([data as ArrayBuffer], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = url;
iframe.onload = () => {
setContentWindow(iframe.contentWindow);
};
document.body.appendChild(iframe);
return () => {
// 需要释放资源
URL.revokeObjectURL(url);
document.body.removeChild(iframe);
};
}, [data, loading]);
return (
<LoadingSpin spinning={loading}>
<Document
file={data as ArrayBuffer}
loading={(props) => (
<LoadingSpin {...props} spinning={true}>
<div style={{ height: '100vh' }}></div>
</LoadingSpin>
)}
onLoadSuccess={async (doc) => {
setNumPages(doc.numPages);
}}
noData={<div style={{ height: '100vh' }}></div>}
error={<div>{t('error')}</div>}
>
{Array.from(new Array(numPages), (el, index) => (
<Page width={width} key={`page_${index + 1}`} pageNumber={index + 1}>
<p style={{ marginLeft: '92%', marginBottom: '30pt', fontSize: '14px', fontFamily: 'source-han-sans' }}>
{index + 1}/{numPages}
</p>
</Page>
))}
</Document>
</LoadingSpin>
);
});

View File

@ -0,0 +1,378 @@
import { Field } from '@formily/core';
import { ISchema, useField, useFieldSchema, useForm } from '@formily/react';
import {
SchemaSettingsLinkageRules,
SchemaSettingsModalItem,
SchemaSettingsSelectItem,
SchemaSettingsSwitchItem,
css,
mergeFilter,
removeNullCondition,
useBlockRequestContext,
useCollection_deprecated,
useCollectionManager_deprecated,
useCompile,
useDesignable,
useFieldModeOptions,
useFilterFieldProps,
useFilterOptions,
useFormBlockContext,
useFormBlockType,
useLinkageCollectionFilterOptions,
useLocalVariables,
useProps,
useRecord,
useSchemaTemplateManager,
useSortFields,
useVariables,
} from '@nocobase/client';
import _, { get } from 'lodash';
import React, { useCallback, useContext, useMemo } from 'react';
import { useTranslation } from '../locale';
import { FormFilterScope } from './FormFilter/FormFilterScope';
export const useFormulaTitleOptions = () => {
const compile = useCompile();
const { getCollectionJoinField, getCollectionFields } = useCollectionManager_deprecated();
const { getField } = useCollection_deprecated();
const fieldSchema = useFieldSchema();
const { t } = useTranslation();
const { dn } = useDesignable();
const collectionManage = useCollectionManager_deprecated();
const collectionManageField = fieldSchema['x-compoent-custom']
? collectionManage.collections.filter((value) => value.name === fieldSchema['x-decorator-props'])[0]
: {};
const collectionField = getField(fieldSchema['name']) || getCollectionJoinField(fieldSchema['x-collection-field']);
let fields = [];
if (collectionField) {
fields = getCollectionFields(
collectionField
? collectionField.target
? collectionField.target
: collectionField.collectionName
: fieldSchema['name'],
);
} else if (collectionManageField) {
fields = collectionManageField['fields'];
}
const options = [];
fields?.forEach((field) => {
if (field.interface !== 'm2m') {
if (field.uiSchema) {
options.push({
label: compile(field.uiSchema.title),
value: field.name,
children:
getCollectionFields(field.target)
?.filter((subField) => subField.uiSchema)
.map((subField) => ({
label: subField.uiSchema ? compile(subField.uiSchema.title) : '',
value: subField.name,
})) ?? [],
});
}
}
});
return options;
};
export const useFormulaTitleVisible = () => {
const fieldSchema = useFieldSchema();
const options = useFormulaTitleOptions();
// FIXME 这里现在只有当设置为 select默认为 select 的时候看不到
return (
options.length > 0 &&
((fieldSchema['x-component-props']?.mode === 'Select' &&
fieldSchema['x-component-props']?.fieldNames?.value !== undefined &&
fieldSchema['x-component'] === 'CollectionField') ||
fieldSchema['x-compoent-custom'])
);
};
export const EditFormulaTitleField = () => {
const { getCollectionJoinField, collections } = useCollectionManager_deprecated();
const { getField } = useCollection_deprecated();
const field = useField<Field>();
const fieldSchema = useFieldSchema();
const { t } = useTranslation();
const { dn } = useDesignable();
let collectionField = getField(fieldSchema['name']) || getCollectionJoinField(fieldSchema['x-collection-field']);
collectionField = collectionField
? collectionField
: collections.find((value) => value.name === fieldSchema['x-decorator-props'])?.fields;
const options = useFormulaTitleOptions();
const editTitle = async (formula) => {
const schema = {
['x-uid']: fieldSchema['x-uid'],
};
const fieldNames = {
...collectionField?.uiSchema?.['x-component-props']?.['fieldNames'],
...field.componentProps.fieldNames,
formula,
};
fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {};
fieldSchema['x-component-props']['fieldNames'] = fieldNames;
schema['x-component-props'] = fieldSchema['x-component-props'];
field.componentProps.fieldNames = fieldSchema['x-component-props'].fieldNames;
dn.emit('patch', {
schema,
});
dn.refresh();
};
return (
<SchemaSettingsModalItem
key="edit-field-title"
title={t('Custom option label')}
schema={
{
type: 'object',
title: t('Custom option label'),
properties: {
formula: {
required: true,
'x-decorator': 'FormItem',
'x-component': 'Variable.TextArea',
'x-component-props': {
scope: options,
},
default: field?.componentProps?.fieldNames?.formula || '',
},
},
} as ISchema
}
onSubmit={({ formula }) => {
if (formula) {
editTitle(formula);
}
}}
/>
);
};
export const usePaginationVisible = () => {
const fieldSchema = useFieldSchema();
return fieldSchema['x-component-props']?.mode === 'SubTable';
};
export const useSetFilterScopeVisible = () => {
const fieldSchema = useFieldSchema();
return fieldSchema['x-component-props']?.useProps === '{{ useFilterBlockActionProps }}';
};
export const EditTitle = () => {
const { getCollectionJoinField } = useCollectionManager_deprecated();
const { getField } = useCollection_deprecated();
const field = useField<Field>();
const fieldSchema = useFieldSchema();
const { t } = useTranslation();
const { dn } = useDesignable();
const collectionField = getField(fieldSchema['name']) || getCollectionJoinField(fieldSchema['x-collection-field']);
return (
<SchemaSettingsModalItem
key="edit-field-title"
title={t('Edit field title')}
schema={
{
type: 'object',
title: t('Edit field title'),
properties: {
title: {
title: t('Field title'),
default: field?.title,
description: `${t('Original field title: ')}${
collectionField ? collectionField?.uiSchema?.title : fieldSchema['name']
}`,
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {},
},
},
} as ISchema
}
onSubmit={({ title }) => {
if (title) {
field.title = title;
fieldSchema.title = title;
dn.emit('patch', {
schema: {
'x-uid': fieldSchema['x-uid'],
title: fieldSchema.title,
},
});
}
dn.refresh();
}}
/>
);
};
export const EditTitleField = () => {
const { getCollectionFields, getCollectionJoinField } = useCollectionManager_deprecated();
const { getField } = useCollection_deprecated();
const field = useField<Field>();
const fieldSchema = useFieldSchema();
const { t } = useTranslation();
const { dn } = useDesignable();
const compile = useCompile();
const collectionManage = useCollectionManager_deprecated();
const collectionManageField = fieldSchema['x-compoent-custom']
? collectionManage.collections.filter((value) => value.name === fieldSchema['x-decorator-props'])[0]
: {};
const collectionField = getField(fieldSchema['name']) || getCollectionJoinField(fieldSchema['x-collection-field']);
let targetFields = [];
if (collectionField) {
targetFields = collectionField?.target
? getCollectionFields(collectionField?.target)
: getCollectionFields(collectionField?.targetCollection) ?? [];
} else if (collectionManageField) {
targetFields = collectionManageField['fields'];
}
const options = targetFields
.filter((field) => !field?.target && field.type !== 'boolean')
.map((field) => ({
value: field?.name,
label: compile(field?.uiSchema?.title) || field?.name,
}));
return options.length > 0 &&
(fieldSchema['x-component'] === 'CollectionField' || fieldSchema['x-compoent-custom']) ? (
<SchemaSettingsSelectItem
key="title-field"
title={t('Title field')}
options={options}
value={field?.componentProps?.fieldNames?.label}
onChange={(label) => {
const schema = {
['x-uid']: fieldSchema['x-uid'],
};
const fieldNames = {
...collectionField?.uiSchema?.['x-component-props']?.['fieldNames'],
...field.componentProps.fieldNames,
label,
};
fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {};
fieldSchema['x-component-props']['fieldNames'] = fieldNames;
schema['x-component-props'] = fieldSchema['x-component-props'];
field.componentProps.fieldNames = fieldSchema['x-component-props'].fieldNames;
dn.emit('patch', {
schema,
});
dn.refresh();
}}
/>
) : null;
};
export const IsTablePageSize = () => {
const { dn } = useDesignable();
const fieldSchema = useFieldSchema();
const { t } = useTranslation();
return (
<SchemaSettingsSwitchItem
title={t('Pagination')}
checked={fieldSchema['x-component-props'].pagination}
onChange={(v) => {
if (!fieldSchema['x-component-props'].pagination) {
fieldSchema['x-component-props'] = {
...fieldSchema['x-component-props'],
pagination: false,
};
}
fieldSchema['x-component-props'].pagination = v;
dn.emit('patch', {
schema: {
'x-uid': fieldSchema['x-uid'],
'x-component-props': {
...fieldSchema?.['x-component-props'],
},
},
});
dn.refresh();
}}
/>
);
};
const findGridSchema = (fieldSchema) => {
return fieldSchema.reduceProperties((buf, s) => {
if (s['x-component'] === 'FormV2') {
const f = s.reduceProperties((buf, s) => {
if (s['x-component'] === 'Grid' || s['x-component'] === 'BlockTemplate') {
return s;
}
return buf;
}, null);
if (f) {
return f;
}
}
return buf;
}, null);
};
export const SetFilterScope = (props) => {
const { t } = useTranslation();
const fieldSchema = useFieldSchema();
const field = useField<Field>();
const fields = field.form.fields;
const { collectionName } = props;
const gridSchema = findGridSchema(fieldSchema) || fieldSchema;
const { form } = useFormBlockContext();
const type = props?.type || ['Action', 'Action.Link'].includes(fieldSchema['x-component']) ? 'button' : 'field';
const variables = useVariables();
const localVariables = useLocalVariables();
const record = useRecord();
const { type: formBlockType } = useFormBlockType();
const schema = useMemo<ISchema>(
() => ({
type: 'object',
title: t('Custom filter'),
properties: {
fieldReaction: {
'x-component': FormFilterScope,
'x-component-props': {
useProps: () => {
const options = useLinkageCollectionFilterOptions(collectionName);
return {
options,
defaultValues: gridSchema?.['x-filter-rules'] || fieldSchema?.['x-filter-rules'],
type,
collectionName,
form,
variables,
localVariables,
record,
formBlockType,
fields,
};
},
},
},
},
}),
[],
);
const { getTemplateById } = useSchemaTemplateManager();
const { dn } = useDesignable();
const onSubmit = useCallback(
(v) => {
const rules = v.fieldReaction.condition;
const templateId = gridSchema['x-component'] === 'BlockTemplate' && gridSchema['x-component-props'].templateId;
const uid = (templateId && getTemplateById(templateId).uid) || gridSchema['x-uid'];
const schema = {
['x-uid']: uid,
};
gridSchema['x-filter-rules'] = rules;
schema['x-filter-rules'] = rules;
dn.emit('patch', {
schema,
});
dn.refresh();
},
[dn, getTemplateById, gridSchema],
);
return <SchemaSettingsModalItem title={t('Custom filter')} width={770} schema={schema} onSubmit={onSubmit} />;
};

View File

@ -0,0 +1,48 @@
import { connect, mapProps } from '@formily/react';
import { Button } from 'antd';
import React, { useRef, useEffect } from 'react';
import SignaturePad from './SignaturePad';
export const SignatureInput = connect(
(props) => {
const { onChange, value } = props;
const signatureRef = useRef(null);
useEffect(() => {
if (value) {
signatureRef.current.fromDataURL(value);
}
}, []);
const handleClear = () => {
if (signatureRef.current) {
signatureRef.current.clear();
onChange && onChange('');
}
};
const handleSave = () => {
if (signatureRef.current) {
const signatureDataURL = signatureRef.current.toDataURL();
onChange && onChange(signatureDataURL);
}
};
const divStyle: any = {
pointerEvents: props.disabled ? 'none' : 'auto',
opacity: props.disabled ? 0.8 : 1,
};
return (
<div style={divStyle}>
<SignaturePad ref={signatureRef} options={{ penColor: 'black', backgroundColor: 'lightgrey' }} />
{!props.disabled && (
<div>
<Button onClick={handleClear}></Button>
<Button onClick={handleSave}></Button>
</div>
)}
</div>
);
},
mapProps((props, field) => {
return {
...props,
};
}),
);

View File

@ -0,0 +1,394 @@
import * as React from 'react';
import SigPad, { Options, PointGroup, ToSVGOptions } from 'signature_pad';
import { debounce } from 'throttle-debounce';
type Props = {
width?: number;
height?: number;
options?: Options;
canvasProps?: { [key: string]: string | { [key: string]: string } };
} & DefaultProps;
type DefaultProps = {
redrawOnResize: boolean;
debounceInterval: number;
};
type State = {
canvasWidth: number;
canvasHeight: number;
};
/**
* @class
* @classdesc Signature pad component.
* @extends {PureComponent}
*/
class SignaturePad extends React.PureComponent<Props, State> {
static displayName = 'react-signature-pad-wrapper';
static defaultProps: DefaultProps = {
redrawOnResize: false,
debounceInterval: 150,
};
private canvasRef = React.createRef<HTMLCanvasElement>();
private signaturePad!: SigPad;
private callResizeHandler!: debounce<() => void>;
/**
* Create a new signature pad.
*
* @param {Props} props
*/
constructor(props: Props) {
super(props);
this.state = { canvasWidth: 0, canvasHeight: 0 };
this.callResizeHandler = debounce<() => void>(this.props.debounceInterval, this.handleResize.bind(this));
}
/**
* Initialise the signature pad once the canvas element is rendered.
*
* @return {void}
*/
componentDidMount(): void {
const canvas = this.canvasRef.current;
if (canvas) {
if (!this.props.width || !this.props.height) {
canvas.style.width = '100%';
window.addEventListener('resize', this.callResizeHandler);
}
this.signaturePad = new SigPad(canvas, this.props.options);
this.scaleCanvas(canvas);
}
}
/**
* Remove the resize event listener and switch the signature pad off on
* unmount.
*
* @return {void}
*/
componentWillUnmount(): void {
if (!this.props.width || !this.props.height) {
window.removeEventListener('resize', this.callResizeHandler);
}
this.signaturePad.off();
}
/**
* Get the original signature_pad instance.
*
* @return {SigPad}
*/
get instance(): SigPad {
return this.signaturePad;
}
/**
* Get the canvas ref.
*
* @return {Object}
*/
get canvas(): React.RefObject<HTMLCanvasElement> {
return this.canvasRef;
}
/**
* Set the radius of a single dot.
*
* @param {number} dotSize
* @return {void}
*/
set dotSize(dotSize: number) {
this.signaturePad.dotSize = dotSize;
}
/**
* Get the radius of a single dot.
*
* @return {number}
*/
get dotSize(): number {
return this.signaturePad.dotSize;
}
/**
* Set the minimum width of a line.
*
* @param {number} minWidth
* @return {void}
*/
set minWidth(minWidth: number) {
this.signaturePad.minWidth = minWidth;
}
/**
* Get the minimum width of a line.
*
* @return {number}
*/
get minWidth(): number {
return this.signaturePad.minWidth;
}
/**
* Get the maximum width of a line.
*
* @param {number} maxWidth
* @return {void}
*/
set maxWidth(maxWidth: number) {
this.signaturePad.maxWidth = maxWidth;
}
/**
* Get the maximum width of a line.
*
* @return {number}
*/
get maxWidth(): number {
return this.signaturePad.maxWidth;
}
/**
* Set the throttle for drawing the next point at most once every x ms.
*
* @param {number} throttle
* @return {void}
*/
set throttle(throttle: number) {
this.signaturePad.throttle = throttle;
}
/**
* Get the throttle for drawing the next point at most once every x ms.
*
* @return {number}
*/
get throttle(): number {
return this.signaturePad.throttle;
}
/**
* Set the color used to clear the background.
*
* @param {string} color
* @return {void}
*/
set backgroundColor(color: string) {
this.signaturePad.backgroundColor = color;
}
/**
* Get the color used to clear the background.
*
* @return {string}
*/
get backgroundColor(): string {
return this.signaturePad.backgroundColor;
}
/**
* Set the color used to draw the lines.
*
* @param {string} color
* @return {void}
*/
set penColor(color: string) {
this.signaturePad.penColor = color;
}
/**
* Get the color used to draw the lines.
*
* @return {string}
*/
get penColor(): string {
return this.signaturePad.penColor;
}
/**
* Set weight used to modify new velocity based on the previous velocity.
*
* @param {number} weight
* @return {void}
*/
set velocityFilterWeight(weight: number) {
this.signaturePad.velocityFilterWeight = weight;
}
/**
* Get weight used to modify new velocity based on the previous velocity.
*
* @return {number}
*/
get velocityFilterWeight(): number {
return this.signaturePad.velocityFilterWeight;
}
/**
* Determine if the canvas is empty.
*
* @return {boolean}
*/
isEmpty(): boolean {
return this.signaturePad.isEmpty();
}
/**
* Clear the canvas.
*
* @return {void}
*/
clear(): void {
this.signaturePad.clear();
}
/**
* Draw a signature from a data URL.
*
* @param {string} dataUrl
* @param {object} options
* @return {void}
*/
fromDataURL(
dataUrl: string,
options: Partial<{ ratio: number; width: number; height: number; xOffset: number; yOffset: number }> = {},
): void {
this.signaturePad.fromDataURL(dataUrl, options);
}
/**
* Get the signature data as a data URL.
*
* @param {?string} mime
* @param {?number} encoderOptions
* @return {string}
*/
toDataURL(type?: string, encoderOptions?: number): string {
return this.signaturePad.toDataURL(type, encoderOptions);
}
/**
* Get the signature data as an SVG string without converting to base64.
*
* @param {?ToSVGOptions} svgOptions
* @return {string}
*/
toSVG(svgOptions?: ToSVGOptions): string {
return this.signaturePad.toSVG(svgOptions);
}
/**
* Draw a signature from an array of point groups.
*
* @param {PointGroup[]} data
* @return {void}
*/
fromData(data: PointGroup[]): void {
this.signaturePad.fromData(data);
}
/**
* Get the signature pad data an array of point groups.
*
* @return {PointGroup[]}
*/
toData(): PointGroup[] {
return this.signaturePad.toData();
}
/**
* Turn the signature pad off.
*
* @return {void}
*/
off(): void {
this.signaturePad.off();
}
/**
* Turn the signature pad on.
*
* @return {void}
*/
on(): void {
this.signaturePad.on();
}
/**
* Handle a resize event.
*
* @return {void}
*/
handleResize(): void {
const canvas = this.canvasRef.current;
if (canvas) {
this.scaleCanvas(canvas);
}
}
/**
* Scale the canvas.
*
* @param {HTMLCanvasElement} canvas
* @return {void}
*/
scaleCanvas(canvas: HTMLCanvasElement): void {
const ratio = Math.max(window.devicePixelRatio || 1, 1);
const width = (this.props.width || canvas.offsetWidth) * ratio;
const height = (this.props.height || canvas.offsetHeight) * ratio;
// Avoid needlessly setting height / width if dimensions haven't changed
const { canvasWidth, canvasHeight } = this.state;
if (width === canvasWidth && height === canvasHeight) return;
let data;
if (this.props.redrawOnResize && this.signaturePad && !this.signaturePad.isEmpty()) {
data = this.signaturePad.toDataURL();
}
canvas.width = width;
canvas.height = height;
this.setState({ canvasWidth: width, canvasHeight: height });
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.scale(ratio, ratio);
}
if (data) {
this.signaturePad.fromDataURL(data);
} else if (this.signaturePad) {
this.signaturePad.clear();
}
}
/**
* Render the signature pad component.
*
* @return {ReactNode}
*/
render(): React.ReactNode {
const { canvasProps } = this.props;
return <canvas data-testid="canvas-element" ref={this.canvasRef} {...canvasProps} />;
}
}
export default SignaturePad;

View File

@ -0,0 +1,105 @@
import { useAPIClient } from '@nocobase/client';
import { useBoolean } from 'ahooks';
import { Button, Card, Form, Input, Tabs, message } from 'antd';
import React, { useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { TokenConfigurationResourceKey, getSSKey, useMapConfiguration } from '../hooks/useTokenConfiguration';
interface BaseConfigurationProps {
type: 'feishu';
}
export const TokenTypes = [{ label: '飞书', value: 'feishu' }];
const BaseConfiguration: React.FC<BaseConfigurationProps> = ({ type, children }) => {
const [isDisabled, disableAction] = useBoolean(false);
const apiClient = useAPIClient();
const [form] = Form.useForm();
const data = useMapConfiguration(type);
useEffect(() => {
if (data) {
form.setFieldsValue(data);
disableAction.toggle();
}
}, [data]);
const resource = useMemo(() => {
return apiClient.resource(TokenConfigurationResourceKey);
}, [apiClient]);
const onSubmit = (values) => {
resource
.set({
...values,
type,
})
.then((res) => {
sessionStorage.removeItem(getSSKey(type));
disableAction.toggle();
message.success('保存成功');
})
.catch((err) => {
message.success('保存失败');
});
};
return (
<Form disabled={isDisabled} form={form} layout="vertical" onFinish={onSubmit}>
{children}
{isDisabled ? (
<Button disabled={false} onClick={disableAction.toggle}>
</Button>
) : (
<Form.Item>
<Button disabled={false} type="primary" htmlType="submit">
</Button>
</Form.Item>
)}
</Form>
);
};
const AMapConfiguration = () => {
return (
<BaseConfiguration type="feishu">
<Form.Item required name="app_id" label="App ID">
<Input />
</Form.Item>
<Form.Item required name="app_secret" label="App Secret">
<Input />
</Form.Item>
<Form.Item required name="chat_id" label="Chat ID">
<Input />
</Form.Item>
</BaseConfiguration>
);
};
const components = {
feishu: AMapConfiguration,
};
const tabList = TokenTypes.map((item) => {
return {
...item,
component: components[item.value],
};
});
export const Configuration = () => {
const location = useLocation();
const search = new URLSearchParams(location.search);
return (
<Card bordered>
<Tabs type="card" defaultActiveKey={search.get('tab')}>
{tabList.map((tab) => {
return (
<Tabs.TabPane key={tab.value} tab={tab.label}>
<tab.component type={tab.value} />
</Tabs.TabPane>
);
})}
</Tabs>
</Card>
);
};

View File

@ -0,0 +1,221 @@
import { ISchema, useField, useFieldSchema, useForm } from '@formily/react';
import {
__UNSAFE__,
getFormValues,
isVariable,
transformVariableValue,
useActionContext,
useBlockRequestContext,
useCollection_deprecated,
useCompile,
useDesignable,
useFilterByTk,
useFormActiveFields,
useLocalVariables,
useRecord,
useVariables,
} from '@nocobase/client';
import { useTranslation } from 'react-i18next';
import { App, message } from 'antd';
import { useNavigate, useParams } from 'react-router-dom';
import { isURL } from '@nocobase/utils/client';
import React, { useCallback } from 'react';
import _ from 'lodash';
const pageDetailsViewer = 'PageLayout';
const viewerSchema: ISchema = {
type: 'void',
title: '{{ t("View record") }}',
'x-component': pageDetailsViewer,
'x-component-props': {
className: 'nb-action-popup',
},
properties: {
page: {
type: 'void',
title: '详情页面',
'x-designer': 'Page.Designer',
'x-component': 'Page',
'x-component-props': { disablePageHeader: true },
properties: {
grid: {
type: 'void',
'x-component': 'Grid',
'x-initializer': 'RecordBlockInitializers',
properties: {},
},
},
},
},
};
export const useInsertSchema = () => {
const fieldSchema = useFieldSchema();
const { insertAfterBegin } = useDesignable();
const insert = useCallback(
(ss) => {
const schema = fieldSchema.reduceProperties((buf, s) => {
if (s['x-component'] === pageDetailsViewer) {
return s;
}
return buf;
}, null);
if (!schema) {
insertAfterBegin(_.cloneDeep(ss));
}
},
[pageDetailsViewer],
);
return insert;
};
export const useCreateActionProps = () => {
const form = useForm();
const { field, resource, __parent } = useBlockRequestContext();
const { setVisible, fieldSchema } = useActionContext();
const navigate = useNavigate();
const actionSchema = useFieldSchema();
const actionField = useField();
const { fields, getField, getTreeParentField, name } = useCollection_deprecated();
const compile = useCompile();
const filterByTk = useFilterByTk();
const currentRecord = useRecord();
const { modal } = App.useApp();
const variables = useVariables();
const localVariables = useLocalVariables({ currentForm: form });
const { getActiveFieldsName } = useFormActiveFields() || {};
const { t } = useTranslation();
const action = actionField.componentProps.saveMode || 'create';
const filterKeys = actionField.componentProps.filterKeys?.checked || [];
const dn = useDesignable();
const params = useParams();
const insert = useInsertSchema();
let fieldSchema2 = useFieldSchema();
return {
async onClick() {
const fieldNames = fields.map((field) => field.name);
const {
assignedValues: originalAssignedValues = {},
onSuccess,
overwriteValues,
skipValidator,
triggerWorkflows,
sessionSubmit,
} = actionSchema?.['x-action-settings'] ?? {};
const addChild = fieldSchema?.['x-component-props']?.addChild;
const assignedValues = {};
const waitList = Object.keys(originalAssignedValues).map(async (key) => {
const value = originalAssignedValues[key];
const collectionField = getField(key);
if (process.env.NODE_ENV !== 'production') {
if (!collectionField) {
throw new Error(`useCreateActionProps: field "${key}" not found in collection "${name}"`);
}
}
if (isVariable(value)) {
const result = await variables?.parseVariable(value, localVariables);
if (result) {
assignedValues[key] = transformVariableValue(result, { targetCollectionField: collectionField });
}
} else if (value !== null && value !== '') {
assignedValues[key] = value;
}
});
await Promise.all(waitList);
if (!skipValidator) {
await form.submit();
}
const values = getFormValues({
filterByTk,
field,
form,
fieldNames,
getField,
resource,
actionFields: getActiveFieldsName?.('form') || [],
});
// const values = omitBy(formValues, (value) => isEqual(JSON.stringify(value), '[{}]'));
if (addChild) {
const treeParentField = getTreeParentField();
values[treeParentField?.name ?? 'parent'] = currentRecord?.__parent;
values[treeParentField?.foreignKey ?? 'parentId'] = currentRecord?.__parent?.id;
}
actionField.data = field.data || {};
actionField.data.loading = true;
try {
const data = await resource[action]({
values: {
...values,
...overwriteValues,
...assignedValues,
},
filterKeys: filterKeys,
// TODO(refactor): should change to inject by plugin
triggerWorkflows: triggerWorkflows?.length
? triggerWorkflows.map((row) => [row.workflowKey, row.context].filter(Boolean).join('!')).join(',')
: undefined,
});
actionField.data.loading = false;
actionField.data.data = data;
__parent?.service?.refresh?.();
if (!onSuccess?.successMessage) {
if (sessionSubmit) {
message.success(t('Saved successfully'));
if (dn.designable) {
insert(viewerSchema);
}
fieldSchema2.reduceProperties((buf, s) => {
if (s['x-component'] === pageDetailsViewer) {
fieldSchema2 = s;
return s;
}
return buf;
});
if (fieldSchema2['x-component'] === pageDetailsViewer) {
navigate(`page/${fieldSchema2['x-uid']}/records/${name}/${data?.data?.data?.id ?? ''}`);
}
await form.reset();
return;
}
}
if (!onSuccess?.dataClear) {
await form.reset();
}
if (onSuccess?.manualClose) {
modal.success({
title: compile(onSuccess?.successMessage),
onOk: async () => {
if (onSuccess?.redirecting && onSuccess?.redirectTo) {
if (isURL(onSuccess.redirectTo)) {
window.location.href = onSuccess.redirectTo;
} else {
navigate(onSuccess.redirectTo);
}
}
},
});
} else {
message.success(compile(onSuccess?.successMessage));
if (onSuccess?.redirecting && onSuccess?.redirectTo) {
if (isURL(onSuccess.redirectTo)) {
window.location.href = onSuccess.redirectTo;
} else {
navigate(onSuccess.redirectTo);
}
}
}
if (!onSuccess) {
setVisible?.(false);
}
} catch (error) {
actionField.data.loading = false;
}
},
};
};

View File

@ -0,0 +1,11 @@
import { useApp } from '@nocobase/client';
import _ from 'lodash';
import { KEY_CUSTOM_COMPONENT_LABEL, KEY_CUSTOM_COMPONENT_TYPE } from '..';
export const useCustomComponent = (type: string) => {
const app = useApp();
return _.filter(app.components, (component) => component[KEY_CUSTOM_COMPONENT_TYPE] === type).map((component) => ({
label: component[KEY_CUSTOM_COMPONENT_LABEL],
value: component.displayName,
}));
};

View File

@ -0,0 +1,120 @@
import { useFieldSchema, useForm } from '@formily/react';
import {
TableFieldResource,
__UNSAFE__,
isVariable,
transformVariableValue,
useBlockRequestContext,
useCollection_deprecated,
useCompile,
useFilterByTk,
useLocalVariables,
useVariables,
} from '@nocobase/client';
import { useNavigate } from 'react-router-dom';
import { App, Modal, message } from 'antd';
import { isURL } from '@nocobase/utils/client';
import React from 'react';
import { ExclamationCircleFilled } from '@ant-design/icons';
export const useCustomizeUpdateActionProps = () => {
const { resource, __parent, service } = useBlockRequestContext();
const filterByTk = useFilterByTk();
const actionSchema = useFieldSchema();
const navigate = useNavigate();
const compile = useCompile();
const form = useForm();
const { modal } = App.useApp();
const variables = useVariables();
const localVariables = useLocalVariables({ currentForm: form });
const { name, getField } = useCollection_deprecated();
const { confirm } = Modal;
return {
async onClick() {
const {
assignedValues: originalAssignedValues = {},
onSuccess,
skipValidator,
sessionUpdate,
} = actionSchema?.['x-action-settings'] ?? {};
if (sessionUpdate) {
confirm({
title: '问询',
content: '确认要继续此操作?',
icon: <ExclamationCircleFilled />,
onOk() {
sessionUpdatePopconfirm();
},
onCancel() {
message.warning('取消更新');
},
});
} else {
sessionUpdatePopconfirm();
}
async function sessionUpdatePopconfirm() {
const assignedValues = {};
const waitList = Object.keys(originalAssignedValues).map(async (key) => {
const value = originalAssignedValues[key];
const collectionField = getField(key);
if (process.env.NODE_ENV !== 'production') {
if (!collectionField) {
throw new Error(`useCustomizeUpdateActionProps: field "${key}" not found in collection "${name}"`);
}
}
if (isVariable(value)) {
const result = await variables?.parseVariable(value, localVariables);
if (result) {
assignedValues[key] = transformVariableValue(result, { targetCollectionField: collectionField });
}
} else if (value != null && value !== '') {
assignedValues[key] = value;
}
});
await Promise.all(waitList);
if (skipValidator === false) {
await form.submit();
}
await resource.update({
filterByTk,
values: { ...assignedValues },
});
service?.refresh?.();
if (!(resource instanceof TableFieldResource)) {
__parent?.service?.refresh?.();
}
if (!onSuccess?.successMessage) {
return;
}
if (onSuccess?.manualClose) {
modal.success({
title: compile(onSuccess?.successMessage),
onOk: async () => {
if (onSuccess?.redirecting && onSuccess?.redirectTo) {
if (isURL(onSuccess.redirectTo)) {
window.location.href = onSuccess.redirectTo;
} else {
navigate(onSuccess.redirectTo);
}
}
},
});
} else {
message.success(compile(onSuccess?.successMessage));
if (onSuccess?.redirecting && onSuccess?.redirectTo) {
if (isURL(onSuccess.redirectTo)) {
window.location.href = onSuccess.redirectTo;
} else {
navigate(onSuccess.redirectTo);
}
}
}
}
},
};
};

View File

@ -0,0 +1,159 @@
import { useField, useFieldSchema, useForm } from '@formily/react';
import { isEmpty } from '@formily/shared';
import {
findFilterTargets,
mergeFilter,
transformToFilter,
useCollection_deprecated,
useCollectionManager_deprecated,
useFilterBlock,
} from '@nocobase/client';
import flat from 'flat';
export const removeNullCondition = (filter, fieldSchema?) => {
const filterSchema = fieldSchema ? fieldSchema['x-filter-rules'] : '';
const filterSchemaItem = flat(filterSchema || '');
const filterItem = {};
const items = flat(filter || {});
const values = {};
let isFilterCustom = false;
if (filterSchema && (filterSchema.$and?.length || filterSchema.$or?.length)) {
for (const key in items) {
for (const filterItems in filterSchemaItem) {
const match = filterSchemaItem[filterItems].slice(11, -2);
if (key.includes(filterItems)) {
isFilterCustom = true;
break;
}
}
}
}
if (!isFilterCustom) {
if (Object.keys(items).filter((item) => item.includes('custom')).length) {
if (filterSchema && (filterSchema.$and?.length || filterSchema.$or?.length)) {
for (const filterKey in filterSchemaItem) {
const match = filterSchemaItem[filterKey]?.slice(11, -2);
const collection = match?.split('.')[0];
if (Object.keys(items).filter((item) => item.includes(collection)).length) {
for (const key in items) {
if (key.includes(collection)) {
if (key.includes(match)) {
filterSchemaItem[filterKey] = items[key];
filterItem[match] = items[key];
}
if (key.includes(collection)) delete items[key];
else {
const value = items[key];
if (value != null && !isEmpty(value)) {
values[key] = value;
}
}
}
}
} else if (Object.keys(items).filter((item) => !item.includes('custom')).length) {
const filterItem = Object.keys(items).filter((item) => !item.includes('custom'));
filterItem.forEach((key) => {
values[key] = items[key];
});
}
}
for (const item in filterSchemaItem) {
for (const key in filterItem) {
if (filterSchemaItem[item].includes(key)) {
filterSchemaItem[item] = filterItem[key];
}
}
if (filterSchemaItem[item].includes('$nFilter')) {
delete filterSchemaItem[item];
}
}
const flatValue = flat.unflatten(values);
const flatFieldSchema = flat.unflatten(filterSchemaItem);
return {
$and: [flatValue, flatFieldSchema],
};
} else {
for (const key in items) {
if (!key.includes('custom')) {
const value = items[key];
if (value != null && !isEmpty(value)) {
values[key] = value;
}
}
}
return flat.unflatten(values);
}
} else {
for (const key in items) {
const value = items[key];
if (value != null && !isEmpty(value)) {
values[key] = value;
}
}
return flat.unflatten(values);
}
} else {
return flat.unflatten(items);
}
};
export const useFilterBlockActionProps = () => {
const form = useForm();
const actionField = useField();
const fieldSchema = useFieldSchema();
const { getDataBlocks } = useFilterBlock();
const { name } = useCollection_deprecated();
const { getCollectionJoinField } = useCollectionManager_deprecated();
actionField.data = actionField.data || {};
return {
async onClick() {
const { targets = [], uid } = findFilterTargets(fieldSchema);
actionField.data.loading = true;
try {
// 收集 filter 的值
await Promise.all(
getDataBlocks().map(async (block) => {
const target = targets.find((target) => target.uid === block.uid);
if (!target) return;
const param = block.service.params?.[0] || {};
// 保留原有的 filter
const storedFilter = block.service.params?.[1]?.filters || {};
storedFilter[uid] = removeNullCondition(
transformToFilter(form.values, fieldSchema, getCollectionJoinField, name),
fieldSchema,
);
if (block.defaultFilter) {
getDataBlocks().forEach((getblock) => {
if (getblock.uid !== block.uid && getblock.collection.name === block.collection.name) {
getblock['defaultFilter'] = block.defaultFilter;
}
});
}
const mergedFilter = mergeFilter([
...Object.values(storedFilter).map((filter) => removeNullCondition(filter, fieldSchema)),
block.defaultFilter,
]);
return block.doFilter(
{
...param,
page: 1,
filter: mergedFilter,
},
{ filters: storedFilter },
);
}),
);
actionField.data.loading = false;
} catch (error) {
console.error(error);
actionField.data.loading = false;
}
},
};
};

View File

@ -0,0 +1,16 @@
import { useSchemaInitializer } from '@nocobase/client';
export const useFilterFormCustomProps = () => {
const { insert } = useSchemaInitializer();
return {
title: 'Custom',
onClick: () => {
insert({
type: 'void',
'x-decorator': 'CardItem',
'x-component': 'h1',
'x-content': 'Custom block',
});
},
};
};

View File

@ -0,0 +1,6 @@
import { CUSTOM_COMPONENT_TYPE_ASSOCIATED_FIELD } from '..';
import { useCustomComponent } from './useCustomComponent';
export const useGetCustomAssociatedComponents = () => {
return useCustomComponent(CUSTOM_COMPONENT_TYPE_ASSOCIATED_FIELD);
};

View File

@ -0,0 +1,6 @@
import { CUSTOM_COMPONENT_TYPE_FIELD } from '..';
import { useCustomComponent } from './useCustomComponent';
export const useGetCustomComponents = () => {
return useCustomComponent(CUSTOM_COMPONENT_TYPE_FIELD);
};

View File

@ -0,0 +1,24 @@
import { useRequest } from '@nocobase/client';
export const useLinkKey = () => {
const { data } = useRequest<{
data: any;
}>({
resource: 'link-manage',
action: 'get',
params: {
name: 'Notifications',
},
});
return data?.data[0];
};
export const useInitializationLinkKey = () => {
useRequest<{
data: any;
}>({
resource: 'link-manage',
action: 'init',
params: {
name: 'Notifications',
},
});
};

View File

@ -0,0 +1,22 @@
import { message } from 'antd';
import { useFieldSchema } from '@formily/react';
import copy from 'copy-to-clipboard';
export const useOutboundActionProps = () => {
let schema = useFieldSchema();
while (!('x-decorator-props' in schema)) {
schema = schema.parent;
}
const url = window.location.href.split('/', 3).join('/');
return {
async onClick() {
const c = copy(`${url}/r/${schema['x-uid']}`);
if (c) {
message.success('链接保存成功');
} else {
message.success('链接保存失败');
}
},
};
};

View File

@ -0,0 +1,22 @@
import { useRequest } from '@nocobase/client';
interface IPluginDetailData {
packageJson: PackageJSON;
}
interface PackageJSON {
name: string;
version: string;
description?: string;
repository?: string | { type: string; url: string };
homepage?: string;
license?: string;
devDependencies?: Record<string, string>;
dependencies?: Record<string, string>;
}
export const usePluginVersion = () => {
const { data } = useRequest<{ data: IPluginDetailData }>({
url: 'hera:version',
});
return data?.data?.packageJson?.version;
};

View File

@ -0,0 +1,42 @@
import { useRequest } from '@nocobase/client';
import { useMemo } from 'react';
export const TokenConfigurationResourceKey = 'token-configuration';
export const getSSKey = (type) => {
return `NOCOBASE_PLUGIN_TOKEN_CONFIGURATION_${type}`;
};
export const useMapConfiguration = (type: string) => {
// cache
const config = useMemo(() => {
const d = sessionStorage.getItem(getSSKey(type));
if (d) {
return JSON.parse(d);
}
return d;
}, [type]);
const { data } = useRequest<{
data: any;
}>(
{
resource: TokenConfigurationResourceKey,
action: 'get',
params: {
type,
},
},
{
onSuccess(data) {
sessionStorage.setItem(getSSKey(type), JSON.stringify(data?.data));
},
refreshOnWindowFocus: false,
refreshDeps: [],
manual: config ? true : false,
},
);
if (config) return config;
return data?.data;
};

View File

@ -0,0 +1,404 @@
import React, { ComponentType } from 'react';
import { autorun } from '@formily/reactive';
import {
Menu,
Plugin,
RemoteSchemaTemplateManagerProvider,
TableV2,
EditTitleField,
useCollection_deprecated,
SchemaSettingOptions,
} from '@nocobase/client';
import { HeraAdminLayout } from './layouts';
import { remove } from 'lodash';
import { CalendarBlockInitializer } from './schema-initializer/CalendarBlockInitializer';
import { MenuDesigner } from './components/ExtendedMenuDesigner';
import { SessionSubmit, SessionUpdate } from './components/ExtendedActionDesigner';
import { DetailsPage } from './pages/DetailsPage';
import { OutboundPage } from './pages/OutboundPage';
import { HomePageConfiguration } from './pages/HomePageConfiguration';
import { Configuration } from './components/TokenConfiguration';
import { HomePage } from './pages/Home';
import { InternalPDFViewer } from './schema-components/PDFViewer';
import {
PDFViewerBlockInitializer,
PDFViewerPrintActionInitializer,
PDFViewerProvider,
pdfViewActionInitializer,
usePDFViewerPrintActionProps,
} from './schema-initializer/PDFVIewerBlockInitializer';
import { OutboundButton } from './components/OutboundButton';
import { useCustomizeUpdateActionProps } from './hooks/useCustomizeUpdateActionProps';
import { OutboundLinkActionInitializer } from './schema-initializer/OutboundLinkActionInitializer';
import { CreateSubmitActionInitializer } from './schema-initializer/CreateSubmitActionInitializer';
import { FilterAssociatedFields } from './schema-initializer/FilterAssociatedFields';
import { useFieldSchema } from '@formily/react';
import { isValid } from '@formily/shared';
import { useCreateActionProps } from './hooks/useCreateActionProps';
import { PageLayout } from './pages/PageLayout';
import { useOutboundActionProps } from './hooks/useOutboundActionProps';
import { ExtendedAssociationField } from './schema-components/association-field/Editable';
import { AssociatedField } from './components/AssociatedField';
import { DatePicker } from './schema-components/date-picker';
import { Page } from './schema-components/page';
import { SettingBlockInitializer } from './schema-initializer/SettingBlockInitializer';
import {
EditFormulaTitleField,
IsTablePageSize,
useFormulaTitleVisible,
usePaginationVisible,
EditTitle,
SetFilterScope,
useSetFilterScopeVisible,
} from './components/SchemaSettingOptions';
import { SignatureInput } from './components/SignatureInput';
import { RemoteSelect } from './schema-components/remote-select';
import { Select } from './schema-components/select/Select';
import { Locale, tval } from './locale';
import { LinkManager } from './components/LinkManager';
import {
GroupBlockInitializer,
GroupBlockProvider,
GroupBlockToolbar,
groupBlockSettings,
} from './schema-initializer/GroupBlockInitializer';
import { useFilterFormCustomProps } from './hooks/useFilterFormCustomProps';
import {
FilterFormItem,
FilterItemCustomDesigner,
FilterFormItemCustom,
} from './schema-initializer/FilterFormItemCustomInitializer/FilterFormItemCustom';
import { GroupBlock } from './schema-components/GroupBlock';
import {
CustomComponentDispatcher,
CustomComponentStub,
customComponentDispatcherSettings,
} from './components/CustomComponentDispatcher';
import { useFilterBlockActionProps } from './hooks/useFilterBlockActionProps';
import { AfterSuccess } from './components/Action.Designer';
import { GroupBlockConfigure } from './components/GroupBlockConfigure/GroupBlockConfigure';
import { AssociatedFieldInterface } from './interfaces/associated';
import { CalcFieldInterface } from './interfaces/calc';
import { CustomFieldInterface } from './interfaces/custom';
import { CustomAssociatedFieldInterface } from './interfaces/customAssociated';
import { SignaturePadFieldInterface } from './interfaces/signatureSchema';
import { CalcResult } from './components/CalcResult';
import { CustomAssociatedField } from './components/CustomAssociatedField';
import Expression from './components/Expression';
import { CustomField } from './components/CustomField';
import { useGetCustomAssociatedComponents } from './hooks/useGetCustomAssociatedComponents';
import { useGetCustomComponents } from './hooks/useGetCustomComponents';
export enum CustomComponentType {
CUSTOM_FORM_ITEM,
CUSTOM_FIELD,
CUSTOM_ASSOCIATED_FIELD,
}
export interface CustomComponentOption {
label: string;
name: string;
type?: CustomComponentType;
component: ComponentType;
}
export { usePDFViewerRef } from './schema-initializer/PDFVIewerBlockInitializer';
export class PluginCoreClient extends Plugin {
locale: Locale;
async registerSettings() {
this.app.pluginSettingsManager.add('home_page', {
title: this.locale.lang('HomePage Config'),
icon: 'HomeOutlined',
Component: HomePageConfiguration,
});
this.app.pluginSettingsManager.add('token', {
title: '第三方接入配置',
icon: 'ShareAltOutlined',
Component: Configuration,
});
this.app.pluginSettingsManager.add('linkmanage', {
title: '配置链接',
icon: 'ShareAltOutlined',
Component: LinkManager,
});
this.schemaSettingsManager.add(groupBlockSettings);
this.schemaSettingsManager.add(customComponentDispatcherSettings);
}
async registerActions() {
const actionSettings = this.app.schemaSettingsManager.get('ActionSettings');
actionSettings.add('sessionSubmit', {
Component: SessionSubmit,
useVisible() {
const fieldSchema = useFieldSchema();
return isValid(fieldSchema?.['x-action-settings']?.sessionSubmit);
},
});
actionSettings.add('sessionUpdate', {
Component: SessionUpdate,
useVisible() {
const fieldSchema = useFieldSchema();
return isValid(fieldSchema?.['x-action-settings']?.sessionUpdate);
},
});
}
async registerScopesAndComponents() {
this.app.addScopes({
useOutboundActionProps,
useCustomizeUpdateActionProps,
useCreateActionProps,
useFilterFormCustomProps,
useFilterBlockActionProps,
usePDFViewerPrintActionProps,
useGetCustomAssociatedComponents,
useGetCustomComponents,
});
this.app.addComponents({
AssociatedField,
Expression,
CustomField,
CustomAssociatedField,
CalcResult,
PDFViewerPrintActionInitializer,
PDFViewerProvider,
GroupBlock,
CustomComponentStub,
CustomComponentDispatcher,
GroupBlockInitializer,
GroupBlockToolbar,
GroupBlockProvider,
Page,
DatePicker,
RemoteSelect,
SignatureInput,
AssociationField: ExtendedAssociationField,
OutboundButton,
OutboundLinkActionInitializer,
PDFViewerBlockInitializer,
PDFViwer: InternalPDFViewer,
AdminLayout: HeraAdminLayout,
ExtendedCalendarBlockInitializer: CalendarBlockInitializer,
SettingBlock: SettingBlockInitializer,
CreateSubmitActionInitializer,
PageLayout,
FilterAssociatedFields,
FilterFormItemCustom,
FilterFormItem,
FilterItemCustomDesigner,
Select,
EditTitle,
EditTitleField,
AfterSuccess,
GroupBlockConfigure,
Menu: {
...Menu,
// @ts-ignore
Designer: MenuDesigner,
},
});
}
async registerRouters() {
this.app.router.add('outbound', {
path: '/r/:id',
element: <OutboundPage />,
});
this.app.router.remove('root');
this.app.router.add('home', {
path: '/',
element: <HomePage />,
});
this.app.router.add('admin.details_page', {
path: '/admin/:name/page/:pageId/records/*',
Component: DetailsPage,
});
}
async registerTricks() {
// Loading this provider in an unauthenticated state will result in an error; remove it here.
remove(this.app.providers, ([provider]) => provider === RemoteSchemaTemplateManagerProvider);
}
async registerSchemaInitializer() {
const associationFields = {
type: 'item',
name: 'associationFields',
title: '筛选区块添加一对一的引用',
Component: 'FilterAssociatedFields',
};
const outboundItem = {
type: 'item',
name: 'enableActions.outbound',
title: '外链',
Component: 'OutboundLinkActionInitializer',
schema: {
'x-align': 'right',
},
};
const calendarBlockItem = {
name: 'calendarV2',
title: '{{t("Calendar")}}',
Component: 'ExtendedCalendarBlockInitializer',
};
const settingBlockItem = {
name: 'setting',
title: tval('System setting'),
Component: 'SettingBlock',
};
const refreshActionItem = {
type: 'item',
name: 'refreshAction',
title: "{{t('Refresh')}}",
Component: 'RefreshActionInitializer',
schema: {
'x-align': 'right',
},
};
const customItem = {
title: '自定义',
name: 'custom',
type: 'item',
Component: 'FilterFormItemCustom',
};
this.schemaInitializerManager.add(pdfViewActionInitializer);
this.app.schemaInitializerManager.addItem('TableActionInitializers', outboundItem.name, outboundItem);
this.app.schemaInitializerManager.get('ReadPrettyFormActionInitializers').add(outboundItem.name, outboundItem);
this.app.schemaInitializerManager.get('BlockInitializers').add(calendarBlockItem.name, calendarBlockItem);
this.app.schemaInitializerManager.get('BlockInitializers').add(settingBlockItem.name, settingBlockItem);
this.app.schemaInitializerManager.get('BlockInitializers').add('dataBlocks.groupBlock', {
title: tval('Group block'),
Component: 'GroupBlockInitializer',
});
this.app.schemaInitializerManager.get('KanbanActionInitializers').add(refreshActionItem.name, refreshActionItem);
this.app.schemaInitializerManager.get('KanbanActionInitializers').add(outboundItem.name, outboundItem);
this.app.schemaInitializerManager.get('FilterFormItemInitializers').add(associationFields.name, associationFields);
this.app.schemaInitializerManager.addItem('FilterFormItemInitializers', 'custom-item-divider', {
type: 'divider',
});
this.app.schemaInitializerManager.addItem('FilterFormItemInitializers', customItem.name, customItem);
const addCustomComponent = {
name: 'addCustomComponent',
title: this.locale.lang('Add custom component'),
Component: 'BlockItemInitializer',
schema: {
type: 'void',
'x-editable': false,
'x-decorator': 'FormItem',
'x-settings': 'customComponentDispatcherSettings',
'x-component': 'CustomComponentDispatcher',
'x-component-props': {
component: 'CustomComponentStub',
},
},
};
this.app.schemaInitializerManager.addItem('FormItemInitializers', addCustomComponent.name, addCustomComponent);
this.app.schemaInitializerManager.addItem(
'ReadPrettyFormItemInitializers',
addCustomComponent.name,
addCustomComponent,
);
}
async afterAdd() {}
async beforeLoad() {}
async afterLoad() {
// log for debug
if (process.env.NODE_ENV !== 'production') {
console.info('current components', this.app.components);
console.info('current schemaSettings', this.app.schemaSettingsManager.getAll());
console.info('current schemaInitializer', this.app.schemaInitializerManager.getAll());
console.info('current providers', this.app.providers);
}
await this.registerSchemaInitializer();
}
async registerInterfaces() {
this.app.dataSourceManager.addFieldInterfaces([
AssociatedFieldInterface,
CalcFieldInterface,
CustomFieldInterface,
CustomAssociatedFieldInterface,
SignaturePadFieldInterface,
]);
}
async load() {
this.locale = new Locale(this.app);
await this.registerTricks();
await this.registerScopesAndComponents();
await this.registerSettings();
await this.registerActions();
await this.registerRouters();
await this.registerInterfaces();
this.schemaSettingsManager.addItem('FilterFormItemSettings', 'formulatitleField', {
Component: EditFormulaTitleField,
useVisible: useFormulaTitleVisible,
});
this.schemaSettingsManager.addItem('FormItemSettings', 'hera-divider', {
type: 'divider',
useVisible() {
const v1 = useFormulaTitleVisible();
const v2 = usePaginationVisible();
return v1 || v2;
},
});
this.schemaSettingsManager.addItem('FormItemSettings', 'formulatitleField', {
Component: EditFormulaTitleField,
useVisible: useFormulaTitleVisible,
});
this.schemaSettingsManager.addItem('FormItemSettings', 'isTablePageSize', {
Component: IsTablePageSize,
useVisible: usePaginationVisible,
});
this.schemaSettingsManager.addItem('ActionSettings', 'Customize.setFilterScope', {
Component: SetFilterScope,
useVisible: useSetFilterScopeVisible,
useComponentProps() {
const { name } = useCollection_deprecated();
return {
collectionName: name,
};
},
});
const SchemaSettingOptionItems = this.schemaSettingsManager
.get('ActionSettings')
.items.filter((item) => item.name === 'Customize')[0].children;
SchemaSettingOptionItems.forEach((item) => {
if (item.name === 'afterSuccess') {
(item as SchemaSettingOptions).Component = AfterSuccess;
}
});
const previewBlockItem = {
title: tval('preview block'),
name: 'previewBlock',
type: 'itemGroup',
children: [],
};
this.app.schemaInitializerManager.get('RecordBlockInitializers').add(previewBlockItem.name, previewBlockItem);
// listen to connected events.
autorun(() => {
if (this.app.ws.connected) {
const data = {
type: 'plugin-online-user:client',
payload: {
token: this.app.apiClient.auth.getToken(),
},
};
this.app.ws.send(JSON.stringify(data));
}
});
}
}
export default PluginCoreClient;
export const KEY_CUSTOM_COMPONENT_TYPE = '__componentType';
export const KEY_CUSTOM_COMPONENT_LABEL = '__componentLabel';
export const CUSTOM_COMPONENT_TYPE_FIELD = 'FIELD';
export const CUSTOM_COMPONENT_TYPE_FORM_ITEM = 'FORM_ITEM';
export const CUSTOM_COMPONENT_TYPE_ASSOCIATED_FIELD = 'ASSOCIATED_FIELD';

View File

@ -0,0 +1,107 @@
import { CollectionFieldInterface, defaultProps } from '@nocobase/client';
export class AssociatedFieldInterface extends CollectionFieldInterface {
name = 'associated';
type = 'object';
group = 'relation';
order = 10;
title = '关联字段';
description = '关联字段';
isAssociation = true;
default = {
type: 'belongsTo',
// name,
uiSchema: {
// title,
'x-component': 'AssociatedField',
},
};
availableTypes = ['belongsTo'];
properties = {
...defaultProps,
'uiSchema.x-component-props.collection': {
type: 'string',
title: '关联数据表',
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-component-props': {
multiple: false,
},
enum: '{{collections}}',
required: true,
},
'uiSchema.x-component-props.sourceCollection': {
type: 'string',
title: '查询数据表',
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-component-props': {
multiple: false,
},
enum: '{{collections}}',
required: true,
},
'uiSchema.x-component-props.sourceField': {
type: 'string',
title: '查询数据表到关联数据表的路径',
'x-component': 'Input',
'x-decorator': 'FormItem',
required: true,
},
'uiSchema.x-component-props.fieldExp': {
type: 'string',
title: '关联字段',
required: true,
'x-component': 'Expression',
'x-decorator': 'FormItem',
'x-component-props': {
useCurrentFields: '{{ useCurrentFields }}',
},
},
'uiSchema.x-component-props.dateFieldExp': {
type: 'string',
title: '关联日期',
required: true,
'x-component': 'Expression',
'x-decorator': 'FormItem',
'x-component-props': {
useCurrentFields: '{{ useCurrentFields }}',
},
},
target: {
type: 'string',
title: '关联数据表',
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-component-props': {
multiple: false,
},
enum: '{{collections}}',
required: true,
'x-display': 'hidden',
'x-reactions': [
{
dependencies: ['uiSchema.x-component-props.collection'],
fulfill: {
state: {
value: '{{$deps[0]}}',
},
},
},
],
},
targetKey: {
type: 'string',
title: '关联数据表键',
required: true,
default: 'id',
'x-display': 'hidden',
'x-decorator': 'FormItem',
'x-component': 'Input',
},
};
filterable = {
nested: true,
children: [],
};
}

View File

@ -0,0 +1,111 @@
import { interfacesProperties, CollectionFieldInterface } from '@nocobase/client';
const { defaultProps } = interfacesProperties;
const formulaType = [
{
dependencies: ['dataType'],
fulfill: {
state: {
display: '{{["formula"].includes($deps[0]) ? "visible" : "none"}}',
},
},
},
];
const panelType = [
{
dependencies: ['dataType'],
fulfill: {
state: {
display: '{{["jsCode"].includes($deps[0]) ? "visible" : "none"}}',
},
},
},
];
export class CalcFieldInterface extends CollectionFieldInterface {
name = 'calc2';
type = 'object';
group = 'advanced';
title = '数据计算';
description = '数据字段计算';
sortable = true;
default = {
type: 'virtual',
uiSchema: {
type: 'string',
'x-component': 'CalcResult',
'x-component-props': {
formula: '',
prefix: '',
suffix: '',
decimal: '',
panel: '',
},
'x-read-pretty': true,
},
};
properties = {
...defaultProps,
dataType: {
type: 'string',
title: '计算类型',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: [
{ value: 'formula', label: '公式' },
{ value: 'jsCode', label: '代码' },
],
required: true,
default: 'formula',
description: '公式使用公式计算代码使用js代码计算',
},
'uiSchema.x-component-props.formula': {
type: 'string',
title: '公式',
'x-decorator': 'FormItem',
'x-component': 'Input',
default: '',
required: true,
'x-reactions': formulaType,
},
'uiSchema.x-component-props.panel': {
type: 'string',
title: 'jsCode',
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
description: '注意,此内容有值计算操作将以此内容为准,而不是公式,适用于面板中的计算',
required: true,
'x-reactions': panelType,
},
'uiSchema.x-component-props.prefix': {
type: 'string',
title: '前缀',
'x-decorator': 'FormItem',
'x-component': 'Input',
},
'uiSchema.x-component-props.suffix': {
type: 'string',
title: '后缀',
'x-decorator': 'FormItem',
'x-component': 'Input',
},
'uiSchema.x-component-props.decimal': {
type: 'string',
title: '{{t("Precision")}}',
'x-component': 'Select',
'x-decorator': 'FormItem',
required: true,
default: '0',
enum: [
{ value: '0', label: '1' },
{ value: '1', label: '1.0' },
{ value: '2', label: '1.00' },
{ value: '3', label: '1.000' },
{ value: '4', label: '1.0000' },
{ value: '5', label: '1.00000' },
],
'x-reactions': formulaType,
},
};
}

View File

@ -0,0 +1,31 @@
import { interfacesProperties, CollectionFieldInterface } from '@nocobase/client';
const { defaultProps } = interfacesProperties;
export class CustomFieldInterface extends CollectionFieldInterface {
name = 'custom';
type = 'object';
group = 'advanced';
title = '自定义字段';
description = '自定义字段';
sortable = true;
default = {
type: 'virtual',
uiSchema: {
type: 'string',
'x-component': 'CustomField',
'x-read-pretty': true,
},
};
properties = {
...defaultProps,
'uiSchema.x-component-props.component': {
type: 'string',
title: '组件',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: ' {{ useGetCustomComponents() }} ',
required: true,
description: '需要在插件中注册相应的组件后可以使用',
},
};
}

View File

@ -0,0 +1,57 @@
import { defaultProps, CollectionFieldInterface } from '@nocobase/client';
export class CustomAssociatedFieldInterface extends CollectionFieldInterface {
name = 'customAssociated';
type = 'object';
group = 'relation';
order = 10;
title = '自定义关联字段';
description = '自定义关联字段';
isAssociation = true;
default = {
type: 'virtual',
// type: 'belongsTo',
// name,
uiSchema: {
// title,
'x-component': 'CustomAssociatedField',
},
};
availableTypes = ['belongsTo'];
properties = {
...defaultProps,
'uiSchema.x-component-props.component': {
type: 'string',
title: '组件',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: ' {{ useGetCustomAssociatedComponents() }}',
required: true,
description: '需要在插件中注册相应的组件后可以使用',
},
target: {
type: 'string',
title: '关联数据表',
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-component-props': {
multiple: false,
},
enum: '{{collections}}',
required: true,
},
targetKey: {
type: 'string',
title: '关联数据表键',
required: true,
default: 'id',
'x-display': 'hidden',
'x-decorator': 'FormItem',
'x-component': 'Input',
},
};
filterable = {
nested: true,
children: [],
};
}

View File

@ -0,0 +1,25 @@
import { defaultProps, CollectionFieldInterface } from '@nocobase/client';
import { tval } from '../locale';
export class SignaturePadFieldInterface extends CollectionFieldInterface {
name = 'signatureSchema';
type = 'object';
group = 'advanced';
order = 2; // 可以调整字段的顺序
title = tval('Signature input');
sortable = true;
default = {
interface: 'signature', // 添加手写签名的接口标识
type: 'json',
uiSchema: {
type: 'signature', // 添加手写签名的 UI 类型
'x-component': 'SignatureInput',
},
};
availableTypes = ['json'];
hasDefaultValue = false; // 手写签名通常不需要默认值
properties = {
...defaultProps,
};
}

View File

@ -0,0 +1,510 @@
import { css } from '@emotion/css';
import { useSessionStorageState } from 'ahooks';
import { App, Layout, Spin, FloatButton } from 'antd';
import { ToolOutlined, CommentOutlined, CalculatorOutlined, HighlightOutlined } from '@ant-design/icons';
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { Link, Outlet, useMatch, useNavigate, useParams } from 'react-router-dom';
import {
CurrentUser,
PinnedPluginList,
SchemaComponent,
findByUid,
findMenuItem,
useACLRoleContext,
useAdminSchemaUid,
useDocumentTitle,
useRequest,
useSystemSettings,
useToken,
useApp,
AdminProvider,
RemoteSchemaComponent,
useCurrentUserSettingsMenu,
SelectWithTitle,
useDesignable,
} from '@nocobase/client';
import { Tabs } from 'antd';
import type { TabsProps } from 'antd';
import { usePluginVersion } from '../hooks/usePluginVersion';
import { OnlineUserDropdown } from '../components/OnlineUserProvider';
import { MobileLink } from '../components/MobileLink';
import { Notifications } from '../components/Notifications';
import { useTranslation } from '../locale';
export const useAppSpin = () => {
const app = useApp();
return {
render: () => (app ? app?.renderComponent?.('AppSpin') : React.createElement(Spin)),
};
};
const filterByACL = (schema, options) => {
const { allowAll, allowMenuItemIds = [] } = options;
if (allowAll) {
return schema;
}
const filterSchema = (s) => {
if (!s) {
return;
}
for (const key in s.properties) {
if (Object.prototype.hasOwnProperty.call(s.properties, key)) {
const element = s.properties[key];
if (element['x-uid'] && !allowMenuItemIds.includes(element['x-uid'])) {
delete s.properties[key];
}
if (element['x-uid']) {
filterSchema(element);
}
}
}
};
filterSchema(schema);
return schema;
};
const SchemaIdContext = createContext(null);
const useMenuProps = () => {
const defaultSelectedUid = useContext(SchemaIdContext);
return {
selectedUid: defaultSelectedUid,
defaultSelectedUid,
};
};
const MenuEditor = (props) => {
const { notification } = App.useApp();
const [hasNotice, setHasNotice] = useSessionStorageState('plugin-notice', { defaultValue: false });
const { setTitle } = useDocumentTitle();
const navigate = useNavigate();
const params = useParams<any>();
const isMatchAdmin = useMatch('/admin');
const isMatchAdminName = useMatch('/admin/:name');
const defaultSelectedUid = params.name;
const { sideMenuRef } = props;
const ctx = useACLRoleContext();
const [current, setCurrent] = useState(null);
const onSelect = ({ item }) => {
const schema = item.props.schema;
setTitle(schema.title);
setCurrent(schema);
navigate(`/admin/${schema['x-uid']}`);
};
const { render } = useAppSpin();
const adminSchemaUid = useAdminSchemaUid();
const { data, loading } = useRequest<{
data: any;
}>(
{
url: `/uiSchemas:getJsonSchema/${adminSchemaUid}`,
},
{
refreshDeps: [adminSchemaUid],
onSuccess(data) {
const schema = filterByACL(data?.data, ctx);
// url 为 `/admin` 的情况
if (isMatchAdmin) {
const s = findMenuItem(schema);
if (s) {
navigate(`/admin/${s['x-uid']}`);
setTitle(s.title);
} else {
navigate(`/admin/`);
}
return;
}
// url 不为 `/admin/xxx` 的情况,不做处理
if (!isMatchAdminName) return;
// url 为 `admin/xxx` 的情况
const s = findByUid(schema, defaultSelectedUid);
if (s) {
setTitle(s.title);
} else {
const s = findMenuItem(schema);
if (s) {
navigate(`/admin/${s['x-uid']}`);
setTitle(s.title);
} else {
navigate(`/admin/`);
}
}
},
},
);
useEffect(() => {
const properties = Object.values(current?.root?.properties || {}).shift()?.['properties'] || data?.data?.properties;
if (properties && sideMenuRef.current) {
const pageType = Object.values(properties).find(
(item) => item['x-uid'] === params.name && item['x-component'] === 'Menu.Item',
);
if (pageType) {
sideMenuRef.current.style.display = 'none';
} else {
sideMenuRef.current.style.display = 'block';
}
}
}, [data?.data, params.name, sideMenuRef]);
const schema = useMemo(() => {
const s = filterByACL(data?.data, ctx);
if (s?.['x-component-props']) {
s['x-component-props']['useProps'] = useMenuProps;
}
return s;
}, [data?.data]);
useRequest(
{
url: 'applicationPlugins:list',
params: {
sort: 'id',
paginate: false,
},
},
{
onSuccess: ({ data }) => {
setHasNotice(true);
const errorPlugins = data.filter((item) => !item.isCompatible);
if (errorPlugins.length) {
notification.error({
message: 'Plugin dependencies check failed',
description: (
<div>
<div>
These plugins failed dependency checks. Please go to the{' '}
<Link to="/admin/pm/list/local/">plugin management page</Link> for more details.{' '}
</div>
<ul>
{errorPlugins.map((item) => (
<li key={item.id}>
{item.displayName} - {item.packageName}
</li>
))}
</ul>
</div>
),
});
}
},
manual: true,
// ready: !hasNotice,
},
);
if (loading) {
return void 0;
}
return (
<SchemaIdContext.Provider value={defaultSelectedUid}>
<SchemaComponent memoized scope={{ useMenuProps, onSelect, sideMenuRef, defaultSelectedUid }} schema={schema} />
</SchemaIdContext.Provider>
);
};
export function MyRouteSchemaComponent({ name }: { name: string }) {
return <RemoteSchemaComponent onlyRenderProperties uid={name} />;
}
export const InternalAdminLayout = (props: any) => {
const app = useApp();
const sideMenuRef = useRef<HTMLDivElement>();
const result = useSystemSettings();
const params = useParams<{ name?: string }>();
const { token } = useToken();
const { render } = useAppSpin();
const { title } = useDocumentTitle();
const navigate = useNavigate();
const [items, setItems] = useState<TabsProps['items']>([]);
const pageStyle = usePageStyle();
useEffect(() => {
if (params.name && title && pageStyle === 'tab') {
setItems((items) => {
if (!items.find((value) => value.key === params.name)) {
return [
...items,
{
key: params.name,
label: title,
children: <MyRouteSchemaComponent name={params.name} />,
},
];
} else {
return items;
}
});
}
}, [params.name, title]);
const onEdit = (targetKey: React.MouseEvent | React.KeyboardEvent | string, action: 'add' | 'remove') => {
if (action === 'remove') {
setItems((items) => {
return items.filter((item) => item.key !== targetKey);
});
}
};
return (
<Layout>
<Layout.Header
className={css`
.ant-menu.ant-menu-dark .ant-menu-item-selected,
.ant-menu-submenu-popup.ant-menu-dark .ant-menu-item-selected,
.ant-menu-submenu-horizontal.ant-menu-submenu-selected {
background-color: ${token.colorBgHeaderMenuActive};
color: ${token.colorTextHeaderMenuActive};
}
.ant-menu-dark.ant-menu-horizontal > .ant-menu-item:hover {
background-color: ${token.colorBgHeaderMenuHover};
color: ${token.colorTextHeaderMenuHover};
}
position: fixed;
left: 0;
right: 0;
height: var(--nb-header-height);
line-height: var(--nb-header-height);
padding: 0;
z-index: 100;
background-color: ${token.colorBgHeader};
.ant-menu {
background-color: transparent;
}
.ant-menu-item {
color: ${token.colorTextHeaderMenu};
}
`}
>
<div
className={css`
position: relative;
width: 100%;
height: 100%;
display: flex;
`}
>
<div
className={css`
position: relative;
z-index: 1;
flex: 1 1 auto;
display: flex;
height: 100%;
`}
>
<div
className={css`
display: inline-flex;
flex-shrink: 0;
color: #fff;
padding: 0;
align-items: center;
padding: 0 12px 0 12px;
`}
>
<img
className={css`
object-fit: contain;
height: 28px;
`}
src={result?.data?.data?.logo?.url}
/>
<h1
className={css`
color: #fff;
height: 32px;
margin: 0 0 0 12px;
font-weight: 600;
font-size: 18px;
line-height: 32px;
`}
>
{result?.data?.data?.title}
</h1>
</div>
<div
className={css`
flex: 1 1 auto;
width: 0;
`}
>
<MenuEditor sideMenuRef={sideMenuRef} />
</div>
</div>
<div
className={css`
position: relative;
flex-shrink: 0;
height: 100%;
z-index: 10;
`}
>
<PinnedPluginList />
<MobileLink />
<Notifications />
<OnlineUserDropdown />
<CurrentUser />
</div>
</div>
</Layout.Header>
{params.name && (
<Layout.Sider
className={css`
height: 100%;
/* position: fixed; */
position: relative;
left: 0;
top: 0;
background: rgba(0, 0, 0, 0);
z-index: 100;
.ant-layout-sider-children {
top: var(--nb-header-height);
position: fixed;
width: 200px;
height: calc(100vh - var(--nb-header-height));
}
`}
theme={'light'}
ref={sideMenuRef}
></Layout.Sider>
)}
<Layout.Content
className={css`
display: flex;
flex-direction: column;
position: relative;
overflow-y: auto;
height: 100vh;
max-height: 100vh;
> div {
position: relative;
}
.ant-layout-footer {
position: absolute;
bottom: 0;
text-align: center;
width: 100%;
z-index: 0;
padding: 0px 50px;
}
`}
>
<header
className={css`
flex-shrink: 0;
height: var(--nb-header-height);
line-height: var(--nb-header-height);
background: transparent;
pointer-events: none;
`}
></header>
<>
{params.name && pageStyle === 'tab' ? (
<Tabs
className={css`
margin: 0;
.ant-tabs-nav {
margin: 0;
}
`}
type="editable-card"
items={items}
onEdit={onEdit}
hideAdd
onChange={(key) => {
navigate(`/admin/${key}`);
}}
activeKey={params.name}
/>
) : (
<Outlet />
)}
</>
</Layout.Content>
</Layout>
);
};
export const useTabSettings = (props) => {
return {
key: 'tab',
eventKey: 'tab',
label: <Label {...props} />,
};
};
const useHeraVersion = () => {
const version = usePluginVersion();
return {
key: 'hera-version',
eventKey: 'hera-version',
label: <span> - {version}</span>,
};
};
const usePageStyle = () => {
return useContext(PageStyleContext).style;
};
export const HeraAdminLayout = (props) => {
const { addMenuItem } = useCurrentUserSettingsMenu();
const [style, setStyle] = useState('classical');
const tabItem = useTabSettings({ style, setStyle });
const heraVersion = useHeraVersion();
const { designable, setDesignable } = useDesignable();
useEffect(() => {
addMenuItem(tabItem, { before: 'divider_3' });
}, [addMenuItem, tabItem]);
useEffect(() => {
addMenuItem(heraVersion, { before: 'divider_1' });
}, [addMenuItem, tabItem]);
const AdminComponent = (
<AdminProvider>
<PageStyleContext.Provider value={{ style }}>
<InternalAdminLayout {...props} />
</PageStyleContext.Provider>
<FloatButton.Group trigger="hover" type="primary" style={{ right: 24, zIndex: 1250 }} icon={<ToolOutlined />}>
<FloatButton icon={<HighlightOutlined />} onClick={() => setDesignable(!designable)} />
<FloatButton icon={<CalculatorOutlined />} />
<FloatButton icon={<CommentOutlined />} />
</FloatButton.Group>
</AdminProvider>
);
return AdminComponent;
};
export interface PageStyleContextValue {
style: string;
}
const PageStyleContext = createContext<PageStyleContextValue>({
style: 'classical',
});
function Label({ style, setStyle }) {
const { t } = useTranslation();
return (
<SelectWithTitle
title={t('Page style')}
defaultValue={style}
options={[
{
label: t('classical'),
value: 'classical',
},
{
label: t('tabs'),
value: 'tab',
},
]}
onChange={setStyle}
/>
);
}

View File

@ -0,0 +1,23 @@
import { Application, useApp, tval as nTval } from '@nocobase/client';
const NAMESPACE = '@hera/plugin-core';
export class Locale {
private app: Application;
constructor(app: Application) {
this.app = app;
}
lang(key: string) {
return this.app.i18n.t(key, { ns: NAMESPACE });
}
}
export const useTranslation = () => {
const { i18n } = useApp();
const t = (key: string, props = {}) => i18n.t(key, { ns: NAMESPACE, ...props });
return { t };
};
export const tval = (key: string) => nTval(key, { ns: NAMESPACE });

View File

@ -0,0 +1,44 @@
import React from 'react';
import { CollectionRecordProvider, DataBlockProvider, RemoteSchemaComponent, css } from '@nocobase/client';
import { useNavigate, useParams } from 'react-router-dom';
import pathToRegexp from 'path-to-regexp';
import { useHeadStyles } from '../pages/style';
import { PageHeader as AntdPageHeader } from '@ant-design/pro-layout';
export const DetailsPage: React.FC = () => {
const params = useParams<any>();
const regexp = pathToRegexp(':collection/:cid?/:association?/:aid?');
const match = regexp.exec(params['*']);
const [_, collection, cid] = match;
const { styles } = useHeadStyles();
const navigate = useNavigate();
return (
<div
className={css`
.ant-tabs-nav {
background: #fff;
padding: 0 24px;
margin-bottom: 0;
}
.ant-tabs-content-holder {
padding: 24px;
}
`}
>
<div className={`${styles['.pageHeaderCss']}`}>
<AntdPageHeader
ghost={false}
title={'详情页面'}
onBack={() => {
navigate(-1);
}}
/>
</div>
<DataBlockProvider collection={collection}>
<CollectionRecordProvider record={{ id: cid }} parentRecord={null}>
<RemoteSchemaComponent uid={params.pageId} onlyRenderProperties />
</CollectionRecordProvider>
</DataBlockProvider>
</div>
);
};

View File

@ -0,0 +1,68 @@
import React from 'react';
import { useStyles } from './style';
import { Carousel, Image } from 'antd';
import { useRequest } from '@nocobase/client';
import { useAppSpin } from '../layouts';
export const HomePage: React.FC<{}> = () => {
const { styles } = useStyles();
const { render } = useAppSpin();
const { data, loading } = useRequest<{ data: any }>({
url: 'home_page_presentations:list',
params: {
'appends[]': 'pictures',
},
});
if (loading) {
return render();
}
const date = new Date();
const year = date.getFullYear();
return (
<div className={styles.home}>
<header>
<div className="headerStyle">
<span className="headerTitle"></span>
<ul>
<li className="active"></li>
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
</div>
</header>
<main>
<Carousel autoplay>
{data.data.map((item) => (
<div key={item.id}>
<Image preview={false} src={item.pictures[0].url} />
</div>
))}
</Carousel>
</main>
<footer>
<ul>
<li>
<a></a>
</li>
<li>
<a></a>
</li>
<li>
<a></a>
</li>
<li>
<a></a>
</li>
<li>
<a href="/admin"></a>
</li>
</ul>
<div>
<span>©2023-{year} ICP备2023024678号</span>
</div>
</footer>
</div>
);
};

View File

@ -0,0 +1,645 @@
import React from 'react';
import { SchemaComponent } from '@nocobase/client';
export const HomePageConfiguration = (props) => {
return (
<SchemaComponent
schema={{
version: '2.0',
type: 'void',
'x-decorator': 'TableBlockProvider',
'x-acl-action': 'home_page_presentations:list',
'x-decorator-props': {
collection: 'home_page_presentations',
resource: 'home_page_presentations',
action: 'list',
params: {
pageSize: 20,
},
rowKey: 'id',
showIndex: true,
dragSort: false,
disableTemplate: false,
},
'x-component': 'CardItem',
'x-filter-targets': [],
properties: {
actions: {
version: '2.0',
type: 'void',
'x-initializer': 'TableActionInitializers',
'x-component': 'ActionBar',
'x-component-props': {
style: {
marginBottom: 'var(--nb-spacing)',
},
},
properties: {
bia185yhk1i: {
version: '2.0',
type: 'void',
'x-action': 'create',
title: "{{t('Add new')}}",
'x-component': 'Action',
'x-decorator': 'ACLActionProvider',
'x-component-props': {
openMode: 'drawer',
type: 'primary',
component: 'CreateRecordAction',
icon: 'PlusOutlined',
},
'x-align': 'right',
'x-acl-action-props': {
skipScopeCheck: true,
},
properties: {
drawer: {
version: '2.0',
type: 'void',
title: '{{ t("Add record") }}',
'x-component': 'Action.Container',
'x-component-props': {
className: 'nb-action-popup',
},
properties: {
tabs: {
version: '2.0',
type: 'void',
'x-component': 'Tabs',
'x-component-props': {},
'x-initializer': 'TabPaneInitializersForCreateFormBlock',
properties: {
tab1: {
version: '2.0',
type: 'void',
title: '{{t("Add new")}}',
'x-component': 'Tabs.TabPane',
'x-component-props': {},
properties: {
grid: {
version: '2.0',
type: 'void',
'x-component': 'Grid',
'x-initializer': 'CreateFormBlockInitializers',
properties: {
ij6ctts16tv: {
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
properties: {
mrm9wm6wk4r: {
version: '2.0',
type: 'void',
'x-component': 'Grid.Col',
properties: {
jab8tlxoft2: {
version: '2.0',
type: 'void',
'x-acl-action-props': {
skipScopeCheck: true,
},
'x-acl-action': 'home_page_presentations:create',
'x-decorator': 'FormBlockProvider',
'x-decorator-props': {
resource: 'home_page_presentations',
collection: 'home_page_presentations',
},
'x-component': 'CardItem',
'x-component-props': {},
properties: {
'8qckd230c0v': {
version: '2.0',
type: 'void',
'x-component': 'FormV2',
'x-component-props': {
useProps: '{{ useFormBlockProps }}',
},
properties: {
grid: {
version: '2.0',
type: 'void',
'x-component': 'Grid',
'x-initializer': 'FormItemInitializers',
properties: {
'7ijaitrd558': {
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
properties: {
ib3pda67y9v: {
version: '2.0',
type: 'void',
'x-component': 'Grid.Col',
properties: {
title: {
version: '2.0',
type: 'string',
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-collection-field': 'home_page_presentations.title',
'x-component-props': {},
'x-uid': 'vxy8me9095c',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'ycic8uhzuvi',
'x-async': false,
'x-index': 1,
},
},
'x-uid': '8tec19ad33p',
'x-async': false,
'x-index': 1,
},
dgch495uwir: {
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
properties: {
ullibu2n4sg: {
version: '2.0',
type: 'void',
'x-component': 'Grid.Col',
properties: {
pictures: {
version: '2.0',
type: 'string',
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-collection-field':
'home_page_presentations.pictures',
'x-component-props': {
action: 'attachments:create',
},
'x-uid': '2ys0er1yt11',
'x-async': false,
'x-index': 1,
},
},
'x-uid': '5suog4i8d3x',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'gikgxfwjxnh',
'x-async': false,
'x-index': 2,
},
},
'x-uid': 'q0jjxfzgxec',
'x-async': false,
'x-index': 1,
},
actions: {
version: '2.0',
type: 'void',
'x-initializer': 'CreateFormActionInitializers',
'x-component': 'ActionBar',
'x-component-props': {
layout: 'one-column',
style: {
marginTop: 24,
},
},
properties: {
'6zu1h8l4sjs': {
version: '2.0',
title: '{{ t("Submit") }}',
'x-action': 'submit',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
htmlType: 'submit',
useProps: '{{ useCreateActionProps }}',
},
'x-action-settings': {
triggerWorkflows: [],
},
type: 'void',
'x-uid': 'ycwy5sel90f',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'huu8pqmx0to',
'x-async': false,
'x-index': 2,
},
},
'x-uid': '9936zs1ncni',
'x-async': false,
'x-index': 1,
},
},
'x-uid': '2tjk5abg4db',
'x-async': false,
'x-index': 1,
},
},
'x-uid': '8lqi5ffepko',
'x-async': false,
'x-index': 1,
},
},
'x-uid': '4sho4nn7ffs',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'w8hhrq8tv7w',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'ujexdqygj0s',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'n1re231jc8z',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'kc5guu7w3ll',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'yr7deayw8b2',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'nm9vaco6bnm',
'x-async': false,
'x-index': 1,
},
yx9ugoktyio: {
version: '2.0',
type: 'array',
'x-initializer': 'TableColumnInitializers',
'x-component': 'TableV2',
'x-component-props': {
rowKey: 'id',
rowSelection: {
type: 'checkbox',
},
useProps: '{{ useTableBlockProps }}',
},
properties: {
actions: {
version: '2.0',
type: 'void',
title: '{{ t("Actions") }}',
'x-action-column': 'actions',
'x-decorator': 'TableV2.Column.ActionBar',
'x-component': 'TableV2.Column',
'x-initializer': 'TableActionColumnInitializers',
properties: {
actions: {
version: '2.0',
type: 'void',
'x-decorator': 'DndContext',
'x-component': 'Space',
'x-component-props': {
split: '|',
},
properties: {
yaif2vuaskw: {
version: '2.0',
type: 'void',
title: '{{ t("Edit") }}',
'x-action': 'update',
'x-component': 'Action.Link',
'x-component-props': {
openMode: 'drawer',
icon: 'EditOutlined',
},
'x-decorator': 'ACLActionProvider',
'x-designer-props': {
linkageAction: true,
},
properties: {
drawer: {
version: '2.0',
type: 'void',
title: '{{ t("Edit record") }}',
'x-component': 'Action.Container',
'x-component-props': {
className: 'nb-action-popup',
},
properties: {
tabs: {
version: '2.0',
type: 'void',
'x-component': 'Tabs',
'x-component-props': {},
'x-initializer': 'TabPaneInitializers',
properties: {
tab1: {
version: '2.0',
type: 'void',
title: '{{t("Edit")}}',
'x-component': 'Tabs.TabPane',
'x-component-props': {},
properties: {
grid: {
version: '2.0',
type: 'void',
'x-component': 'Grid',
'x-initializer': 'RecordBlockInitializers',
properties: {
og49nkup6th: {
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
properties: {
a7woyamsray: {
version: '2.0',
type: 'void',
'x-component': 'Grid.Col',
properties: {
'2pn91gtfnml': {
version: '2.0',
type: 'void',
'x-acl-action-props': {
skipScopeCheck: false,
},
'x-acl-action': 'home_page_presentations:update',
'x-decorator': 'FormBlockProvider',
'x-decorator-props': {
useSourceId: '{{ useSourceIdFromParentRecord }}',
useParams: '{{ useParamsFromRecord }}',
action: 'get',
resource: 'home_page_presentations',
collection: 'home_page_presentations',
},
'x-component': 'CardItem',
'x-component-props': {},
properties: {
j7rysan6k0n: {
version: '2.0',
type: 'void',
'x-component': 'FormV2',
'x-component-props': {
useProps: '{{ useFormBlockProps }}',
},
properties: {
grid: {
version: '2.0',
type: 'void',
'x-component': 'Grid',
'x-initializer': 'FormItemInitializers',
properties: {
og920rtknti: {
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
properties: {
t32058hd12p: {
version: '2.0',
type: 'void',
'x-component': 'Grid.Col',
properties: {
title: {
version: '2.0',
type: 'string',
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-collection-field':
'home_page_presentations.title',
'x-component-props': {},
'x-uid': '70xzdgy6vgh',
'x-async': false,
'x-index': 1,
},
},
'x-uid': '7e6gwdvzppj',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'k4m8a8dwsun',
'x-async': false,
'x-index': 1,
},
giboazc3b0y: {
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
properties: {
lpkhllvqdga: {
version: '2.0',
type: 'void',
'x-component': 'Grid.Col',
properties: {
pictures: {
version: '2.0',
type: 'string',
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-collection-field':
'home_page_presentations.pictures',
'x-component-props': {
action: 'attachments:create',
},
'x-uid': 'w6mytf69k6m',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'jzv9t7stcz0',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'chxq8h76j8o',
'x-async': false,
'x-index': 2,
},
},
'x-uid': 'eceqgc8ontl',
'x-async': false,
'x-index': 1,
},
actions: {
version: '2.0',
type: 'void',
'x-initializer': 'UpdateFormActionInitializers',
'x-component': 'ActionBar',
'x-component-props': {
layout: 'one-column',
style: {
marginTop: 24,
},
},
properties: {
zusitgob1u5: {
version: '2.0',
title: '{{ t("Submit") }}',
'x-action': 'submit',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
htmlType: 'submit',
useProps: '{{ useUpdateActionProps }}',
},
'x-action-settings': {
triggerWorkflows: [],
},
type: 'void',
'x-uid': 'pwuvu6v6eit',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'njgslgv92jr',
'x-async': false,
'x-index': 2,
},
},
'x-uid': '49xyfm0iyd4',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'zux6vavjvq7',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'u9yk8wjhla7',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'indaws297z5',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'cn3gijbx29e',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'hkvq7lffm90',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'yg2hljxi2le',
'x-async': false,
'x-index': 1,
},
},
'x-uid': '4v136x5hvps',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'ggpjjpvi54e',
'x-async': false,
'x-index': 1,
},
},
'x-uid': '7s3zizxt9l9',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'dm7808xtgog',
'x-async': false,
'x-index': 1,
},
r79uwspdiik: {
version: '2.0',
type: 'void',
'x-decorator': 'TableV2.Column.Decorator',
'x-component': 'TableV2.Column',
properties: {
title: {
version: '2.0',
'x-collection-field': 'home_page_presentations.title',
'x-component': 'CollectionField',
'x-component-props': {
ellipsis: true,
},
'x-read-pretty': true,
'x-decorator': null,
'x-decorator-props': {
labelStyle: {
display: 'none',
},
},
'x-uid': 'o34iouv6l1z',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'zfy17cqeelp',
'x-async': false,
'x-index': 2,
},
qni8cvpu4vk: {
version: '2.0',
type: 'void',
'x-decorator': 'TableV2.Column.Decorator',
'x-component': 'TableV2.Column',
properties: {
pictures: {
version: '2.0',
'x-collection-field': 'home_page_presentations.pictures',
'x-component': 'CollectionField',
'x-component-props': {
size: 'small',
action: 'attachments:create',
},
'x-read-pretty': true,
'x-decorator': null,
'x-decorator-props': {
labelStyle: {
display: 'none',
},
},
'x-uid': 'ocqfnyahz32',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'r23g6b37mnt',
'x-async': false,
'x-index': 3,
},
},
'x-uid': 'uq0ueuxd6jy',
'x-async': false,
'x-index': 2,
},
},
name: 'ed94xx03myk',
'x-uid': 'hmriicsupa7',
'x-async': false,
'x-index': 1,
}}
/>
);
};

View File

@ -0,0 +1,13 @@
import React from 'react';
import { AdminProvider, RemoteCollectionManagerProvider, RemoteSchemaComponent } from '@nocobase/client';
import { useParams } from 'react-router-dom';
export const OutboundPage: React.FC = () => {
const params = useParams();
return (
<AdminProvider>
<RemoteCollectionManagerProvider>
<RemoteSchemaComponent uid={params.id} />
</RemoteCollectionManagerProvider>
</AdminProvider>
);
};

View File

@ -0,0 +1,9 @@
import React from 'react';
export const PageLayout = (props) => {
if (props.visible) {
return <div>{props.children}</div>;
} else {
return null;
}
};

View File

@ -0,0 +1,137 @@
import { createStyles } from 'antd-style';
export const useStyles = createStyles(({ css }) => ({
home: css`
width: 100%;
height: 100%;
padding: 0;
margin: 0;
header {
.headerStyle {
width: 100%;
height: 60px;
background-color: #f7f7f7;
display: flex;
align-items: center;
border-radius: 8px;
box-shadow: 0 0 1px 1px #e3e3e3;
padding: 0px 10px 0px 15px;
.headerTitle {
height: 60px;
color: #6c6c6c;
font-size: 20px;
font-weight: 400;
line-height: 60px;
}
ul {
color: #6c6c6c;
display: flex;
align-items: center;
height: 100%;
margin-top: 0;
margin-bottom: 0;
padding: 0 0 0 25px;
li {
list-style: none;
padding-left: 5px;
padding-right: 5px;
text-align: center;
height: 100%;
line-height: 60px;
&:hover {
background-color: #e3e3e3;
}
}
.active {
background-color: #e3e3e3;
}
}
}
}
main {
padding: 20px 10px;
height: 500px;
div {
height: 500px;
img {
width: 100%;
height: auto;
}
}
.ant-carousel .slick-dots-bottom {
bottom: 50px;
}
.ant-carousel .slick-dots li {
width: 10px;
height: 10px;
border-radius: 50px;
border: 1px solid #ffffff;
}
.ant-carousel .slick-dots li button {
width: 10px;
height: 10px;
border-radius: 50px;
opacity: 0;
}
.ant-carousel .slick-dots li.slick-active button {
opacity: 1;
}
}
footer {
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
margin-top: 22px;
a {
color: #3372af;
}
ul {
display: flex;
margin: 0;
bottom: 0;
li {
list-style: none;
a {
border-right: 1.5px solid black;
padding: 0 5px;
}
&:nth-last-child(1) a {
border-right: 0;
}
}
}
div {
a {
margin-left: 5px;
}
}
}
`,
}));
export const useHeadStyles = createStyles(({ token }) => {
return {
'.pageHeaderCss': {
backgroundColor: token.colorBgContainer,
paddingInline: token.paddingXS,
'&.ant-page-header-has-footer': {
paddingTop: token.paddingSM,
paddingBottom: '0',
'.ant-page-header-heading-left': {},
'.ant-page-header-footer': { marginBlockStart: '0' },
},
'.ant-tabs-nav': { marginBottom: '0' },
'.ant-page-header-heading-title': {
color: token.colorText,
},
},
'.height0': {
fontSize: 0,
height: 0,
},
};
});

View File

@ -0,0 +1,263 @@
import { useAPIClient } from '@nocobase/client';
import { Button, Card, Drawer, Space, Spin, Table, Tag, Upload, message } from 'antd';
import type { UploadProps } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import React, { FC, useEffect, useState } from 'react';
import { saveAs } from 'file-saver';
import { UploadOutlined } from '@ant-design/icons';
const columns: ColumnsType<any> = [
{
title: '类型',
dataIndex: 'category',
key: 'category',
},
{
title: '命名空间',
dataIndex: 'namespace',
key: 'namespace',
render(value, record, index) {
return value ? <Tag>{value}</Tag> : null;
},
},
{
title: '功能',
dataIndex: 'function',
key: 'function',
},
{
title: '数据表标识集合',
dataIndex: 'collections',
key: 'collections',
render(value, record, index) {
return value ? (
<span>
{value.map((n) => (
<Tag key={n}>{n}</Tag>
))}
</span>
) : null;
},
},
{
title: '可导出',
dataIndex: 'dumpable',
key: 'dumpable',
},
{
title: '数据表标识',
dataIndex: 'name',
key: 'name',
},
{
title: '数据表名称',
dataIndex: 'title',
key: 'title',
},
];
const keyFn = (record) => [record.namespace ?? '', record.function ?? '', record.name ?? ''].join('_');
const RestoreButton = () => {
const api = useAPIClient();
const [requiredGroups, setRequiredGroups] = useState([]);
const [optionalGroups, setOptionalGroups] = useState([]);
const [userCollections, setUserCollections] = useState([]);
const [selectedOptionalGroups, setSelectedOptionalGroups] = useState([]);
const [selectedUserCollections, setSelectedUserCollections] = useState([]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [key, setKey] = useState('');
const [loading, setLoading] = useState(false);
const showModal = () => {
setIsModalOpen(true);
};
const handleOk = () => {
setLoading(true);
api
.resource('duplicator')
.restore({
values: {
restoreKey: key,
selectedOptionalGroups,
selectedUserCollections,
},
})
.then((res) => {
setIsModalOpen(false);
setLoading(false);
message.success('恢复成功!');
})
.catch(() => {
setLoading(false);
message.error('恢复失败!');
});
};
const handleCancel = () => {
setIsModalOpen(false);
};
const props: UploadProps = {
name: 'file',
action: '/api/duplicator:upload',
headers: {
authorization: `Bearer ${api.auth.getToken()}`,
},
onChange(info) {
if (info.file.status === 'done') {
message.success(`${info.file.name} file uploaded successfully`);
const key = info.file.response.data.key;
const meta = info.file.response.data.meta;
const { requiredGroups, selectedOptionalGroups, selectedUserCollections } = meta;
setKey(key);
setRequiredGroups(requiredGroups.map((i) => ({ ...i, category: 'required' })));
setOptionalGroups(selectedOptionalGroups.map((i) => ({ ...i, category: 'optional' })));
setUserCollections(
selectedUserCollections
.filter((i: string) => !i.startsWith('view_'))
.map((i) => ({ name: i, category: 'user' })),
);
} else if (info.file.status === 'error') {
message.error(`${info.file.name} file upload failed.`);
}
},
};
return (
<>
<Button onClick={showModal}></Button>
<Drawer
title="恢复"
open={isModalOpen}
onClose={handleCancel}
width={1200}
extra={
<Space>
<Button onClick={handleCancel} loading={loading}>
</Button>
<Button onClick={handleOk} type="primary" loading={loading}>
</Button>
</Space>
}
>
<Spin spinning={loading}>
<Upload {...props}>
<Button icon={<UploadOutlined />}></Button>
</Upload>
{requiredGroups.length !== 0 && (
<Table
rowKey={keyFn}
columns={columns}
dataSource={[...requiredGroups, ...optionalGroups, ...userCollections]}
rowSelection={{
type: 'checkbox',
onChange(selectedRowKeys: React.Key[], selectedRows: any[]) {
setSelectedOptionalGroups(
selectedRows
.filter((item) => item.category === 'optional')
.map((item) => item.namespace + '.' + item.function),
);
setSelectedUserCollections(
selectedRows.filter((item) => item.category === 'user').map((item) => item.name),
);
},
getCheckboxProps(record) {
return {
disabled: record.category === 'required',
};
},
defaultSelectedRowKeys: requiredGroups.map(keyFn),
}}
pagination={{
pageSize: 200,
}}
/>
)}
</Spin>
</Drawer>
</>
);
};
export const BackupCenterPanel = () => {
const api = useAPIClient();
const [requiredGroups, setRequiredGroups] = useState([]);
const [optionalGroups, setOptionalGroups] = useState([]);
const [userCollections, setUserCollections] = useState([]);
const [selectedOptionalGroups, setSelectedOptionalGroups] = useState([]);
const [selectedUserCollections, setSelectedUserCollections] = useState([]);
useEffect(() => {
api
.resource('duplicator')
.dumpableCollections()
.then((res) => {
const { requiredGroups, optionalGroups, userCollections } = res.data;
setRequiredGroups(requiredGroups.map((i) => ({ ...i, category: 'required' })));
setOptionalGroups(optionalGroups.map((i) => ({ ...i, category: 'optional' })));
setUserCollections(
userCollections
.filter((i: { name: string }) => !i.name.startsWith('view_'))
.map((i) => ({ ...i, category: 'user' })),
);
})
.catch(() => {
// ignore-error
});
}, [api]);
if (requiredGroups.length === 0) {
return null;
}
return (
<Card bordered={false}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>&nbsp;</Space>
<Space>
<Button
onClick={async () => {
const response = await api.axios.post(
'/duplicator:dump',
{
selectedOptionalGroupNames: selectedOptionalGroups,
selectedUserCollections: selectedUserCollections,
},
{ responseType: 'arraybuffer' },
);
const blob = new Blob([response.data], { type: 'application/octet-stream' });
saveAs(blob, 'backup.nbdump');
}}
>
</Button>
<RestoreButton />
</Space>
</div>
<Table
rowKey={keyFn}
columns={columns}
dataSource={[...requiredGroups, ...optionalGroups, ...userCollections]}
rowSelection={{
type: 'checkbox',
onChange(selectedRowKeys: React.Key[], selectedRows: any[]) {
setSelectedOptionalGroups(
selectedRows
.filter((item) => item.category === 'optional')
.map((item) => item.namespace + '.' + item.function),
);
setSelectedUserCollections(
selectedRows.filter((item) => item.category === 'user').map((item) => item.name),
);
},
getCheckboxProps(record) {
return {
disabled: record.category === 'required',
};
},
defaultSelectedRowKeys: requiredGroups.map(keyFn),
}}
pagination={{
pageSize: 200,
}}
/>
</Card>
);
};

View File

@ -0,0 +1,51 @@
import { useField, useFieldSchema } from '@formily/react';
import { useBlockRequestContext } from '@nocobase/client';
import { Descriptions, DescriptionsProps, Spin } from 'antd';
import { transformers } from '../components/GroupBlockConfigure/transformers';
import React from 'react';
export const GroupBlock = (props) => {
const field = useField<any>();
const fieldSchema = useFieldSchema();
const measures = fieldSchema.parent['x-decorator-props'].params?.measures;
const { resource, service } = useBlockRequestContext();
if (service.loading && !field.loaded) {
return <Spin />;
}
const data = service.data?.data[0] ?? {};
const { option: tOption } = transformers;
measures.forEach((measuresItem) => {
if (measuresItem.fieldFormat) {
const value = measuresItem.fieldFormat.fieldValue;
const option = measuresItem.fieldFormat.option;
const decimal = measuresItem.fieldFormat.decimal;
if (option && option !== 'decimal') {
const component = tOption.filter((tValue) => tValue.value === option)[0].component;
data[value] = String(data[value]).includes(',') ? String(data[value]).replace(/,/g, '') : data[value];
data[value] = component(data[value]);
} else if (option && option === 'decimal') {
const component = tOption
.filter((tValue) => tValue.value === 'decimal')[0]
.childrens.filter((decimalOption) => decimalOption.value === decimal)[0].component;
data[value] = String(data[value]).includes(',') ? String(data[value]).replace(/,/g, '') : data[value];
data[value] = component(data[value]);
}
}
});
const items: DescriptionsProps['items'] = [];
for (const key in data) {
measures?.forEach((value) => {
if (value.field[0] === key) {
if (value.display) {
items.push({
key: key,
label: value.label,
children: data[key],
});
}
}
});
}
return <Descriptions title="汇总:" items={items} />;
};

View File

@ -0,0 +1,45 @@
import React, { useLayoutEffect, useRef, useState } from 'react';
import { PDFViewer } from '../components/PDFViewer';
import { Space } from 'antd';
import { css } from '@nocobase/client';
import { usePDFViewerRef } from '../schema-initializer/PDFVIewerBlockInitializer';
export const InternalPDFViewer = (props) => {
const { usePdfPath: useMaybePdfPath } = props;
const containerRef = useRef(null);
const [width, setWidth] = useState(0);
const [, setHeight] = useState(0);
useLayoutEffect(() => {
setWidth(containerRef.current.offsetWidth);
setHeight(containerRef.current.offsetHeight);
});
const ref = usePDFViewerRef();
const usePdfPath = useMaybePdfPath ?? (() => '');
const pdfPath = usePdfPath();
return (
<div>
<div
className={css`
display: flex;
`}
>
<div
className={css`
flex: 1;
`}
></div>
<Space></Space>
</div>
<div
ref={containerRef}
className={css`
border: 1px dashed black;
margin-top: 12px;
overflow: hidden;
`}
>
{pdfPath ? <PDFViewer file={pdfPath} width={width} ref={ref} /> : ''}
</div>
</div>
);
};

View File

@ -0,0 +1,3 @@
export * from './useConfig';
export * from './usePrefixCls';
export * from './useToken';

View File

@ -0,0 +1,5 @@
import { ConfigProvider } from 'antd';
import { useContext } from 'react';
const { ConfigContext } = ConfigProvider;
export const useConfig = () => useContext(ConfigContext);

View File

@ -0,0 +1,17 @@
import { ConfigProvider } from 'antd';
import { useContext } from 'react';
export const usePrefixCls = (
tag?: string,
props?: {
prefixCls?: string;
},
) => {
const { getPrefixCls } = useContext(ConfigProvider.ConfigContext) || {};
if ('ConfigContext' in ConfigProvider) {
return getPrefixCls?.(tag, props?.prefixCls) || '';
} else {
const prefix = props?.prefixCls ?? 'ant-';
return `${prefix}${tag ?? ''}`;
}
};

View File

@ -0,0 +1,13 @@
import { theme } from 'antd';
import { CustomToken } from '../style';
interface Result extends ReturnType<typeof theme.useToken> {
token: CustomToken;
}
const useToken = () => {
const result = theme.useToken();
return result as Result;
};
export { useToken };

View File

@ -0,0 +1,4 @@
export * from './hooks';
export * from './loading';
export * from './portal';
export * from './style';

View File

@ -0,0 +1,14 @@
import { message } from 'antd';
export const loading = async (title: React.ReactNode = 'Loading...', processor: () => Promise<any>) => {
let hide: any = null;
const loading = setTimeout(() => {
hide = message.loading(title);
}, 100);
try {
return await processor();
} finally {
hide?.();
clearTimeout(loading);
}
};

View File

@ -0,0 +1,63 @@
import { Observer, ReactFC } from '@formily/react';
import { observable } from '@formily/reactive';
import React, { Fragment } from 'react';
import { createPortal } from 'react-dom';
import { render as reactRender, unmount as reactUnmount } from './render';
export interface IPortalProps {
id?: string | symbol;
}
const PortalMap = observable(new Map<string | symbol, React.ReactNode>());
export const createPortalProvider = (id: string | symbol) => {
const Portal: ReactFC<IPortalProps> = (props) => {
if (props.id && !PortalMap.has(props.id)) {
PortalMap.set(props.id, null);
}
return (
<Fragment>
{props.children}
<Observer>
{() => {
if (!props.id) return <></>;
const portal = PortalMap.get(props.id);
if (portal) return createPortal(portal, document.body);
return <></>;
}}
</Observer>
</Fragment>
);
};
Portal.defaultProps = {
id,
};
return Portal;
};
export function createPortalRoot<T extends React.ReactNode>(host: HTMLElement, id: string) {
function render(renderer?: () => T) {
if (PortalMap.has(id)) {
PortalMap.set(id, renderer?.());
} else if (host) {
reactRender(<Fragment>{renderer?.()}</Fragment>, host);
}
}
function unmount() {
if (PortalMap.has(id)) {
PortalMap.set(id, null);
}
if (host) {
const unmountResult = reactUnmount(host);
if (unmountResult && host.parentNode) {
host.parentNode?.removeChild(host);
}
}
}
return {
render,
unmount,
};
}

View File

@ -0,0 +1,89 @@
import { ReactElement } from 'react';
import * as ReactDOM from 'react-dom';
import type { Root } from 'react-dom/client';
// 移植自rc-util: https://github.com/react-component/util/blob/master/src/React/render.ts
type CreateRoot = (container: ContainerType) => Root;
// Let compiler not to search module usage
const fullClone = {
...ReactDOM,
} as typeof ReactDOM & {
__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED?: {
usingClientEntryPoint?: boolean;
};
createRoot?: CreateRoot;
};
const { version, render: reactRender, unmountComponentAtNode } = fullClone;
let createRoot: CreateRoot;
try {
const mainVersion = Number((version || '').split('.')[0]);
if (mainVersion >= 18 && fullClone.createRoot) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
createRoot = fullClone.createRoot;
}
} catch (e) {
// Do nothing;
}
function toggleWarning(skip: boolean) {
const { __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED } = fullClone;
if (
__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED &&
typeof __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED === 'object'
) {
__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.usingClientEntryPoint = skip;
}
}
const MARK = '__antd_mobile_root__';
// ========================== Render ==========================
type ContainerType = (Element | DocumentFragment) & {
[MARK]?: Root;
};
function legacyRender(node: ReactElement, container: ContainerType) {
reactRender(node, container);
}
function concurrentRender(node: ReactElement, container: ContainerType) {
toggleWarning(true);
const root = container[MARK] || createRoot(container);
toggleWarning(false);
root.render(node);
container[MARK] = root;
}
export function render(node: ReactElement, container: ContainerType) {
if (createRoot as unknown) {
concurrentRender(node, container);
return;
}
legacyRender(node, container);
}
// ========================== Unmount =========================
function legacyUnmount(container: ContainerType) {
return unmountComponentAtNode(container);
}
async function concurrentUnmount(container: ContainerType) {
// Delay to unmount to avoid React 18 sync warning
return Promise.resolve().then(() => {
container[MARK]?.unmount();
delete container[MARK];
});
}
export function unmount(container: ContainerType) {
if (createRoot as unknown) {
return concurrentUnmount(container);
}
return legacyUnmount(container);
}

View File

@ -0,0 +1,130 @@
import type { CSSInterpolation, CSSObject } from '@ant-design/cssinjs';
import { useStyleRegister } from '@ant-design/cssinjs';
import { merge } from '@formily/shared';
import type { ComponentTokenMap, GlobalToken } from 'antd/es/theme/interface';
import { AliasToken } from 'antd/es/theme/internal';
import { useConfig, usePrefixCls, useToken } from './hooks';
export interface CustomToken extends AliasToken {
/** 顶部导航栏主色 */
colorPrimaryHeader: string;
/** 导航栏背景色 */
colorBgHeader: string;
/** 导航栏菜单背景色悬浮态 */
colorBgHeaderMenuHover: string;
/** 导航栏菜单背景色激活态 */
colorBgHeaderMenuActive: string;
/** 导航栏菜单文本色 */
colorTextHeaderMenu: string;
/** 导航栏菜单文本色悬浮态 */
colorTextHeaderMenuHover: string;
/** 导航栏菜单文本色激活态 */
colorTextHeaderMenuActive: string;
/** UI 配置色 */
colorSettings: string;
/** 鼠标悬浮时显示的背景色 */
colorBgSettingsHover: string;
/** 鼠标悬浮时显示的边框色 */
colorBorderSettingsHover: string;
}
export type OverrideComponent = keyof ComponentTokenMap | string;
export interface StyleInfo {
hashId: string;
prefixCls: string;
rootPrefixCls: string;
iconPrefixCls: string;
}
export type TokenWithCommonCls<T> = T & {
/** Wrap component class with `.` prefix */
componentCls: string;
/** Origin prefix which do not have `.` prefix */
prefixCls: string;
/** Wrap icon class with `.` prefix */
iconCls: string;
/** Wrap ant prefixCls class with `.` prefix */
antCls: string;
};
export type GenerateStyle<
ComponentToken extends object = TokenWithCommonCls<GlobalToken>,
ReturnType = CSSInterpolation,
> = (token: ComponentToken, options?: any) => ReturnType;
export const genCommonStyle = (token: any, componentPrefixCls: string): CSSObject => {
const { fontFamily, fontSize } = token;
const rootPrefixSelector = `[class^="${componentPrefixCls}"], [class*=" ${componentPrefixCls}"]`;
return {
[rootPrefixSelector]: {
fontFamily,
fontSize,
boxSizing: 'border-box',
'&::before, &::after': {
boxSizing: 'border-box',
},
[rootPrefixSelector]: {
boxSizing: 'border-box',
'&::before, &::after': {
boxSizing: 'border-box',
},
},
},
};
};
export type UseComponentStyleResult = {
wrapSSR: ReturnType<typeof useStyleRegister>;
hashId: string;
componentCls: string;
rootPrefixCls: string;
};
export const genStyleHook = <ComponentName extends OverrideComponent>(
component: ComponentName,
styleFn: (token: TokenWithCommonCls<CustomToken>, props: any, info: StyleInfo) => CSSInterpolation,
) => {
return (props?: any): UseComponentStyleResult => {
const { theme, token, hashId } = useToken();
const { getPrefixCls, iconPrefixCls } = useConfig();
const prefixCls = usePrefixCls(component);
const rootPrefixCls = getPrefixCls();
return {
wrapSSR: useStyleRegister(
{
theme: theme as any,
token,
hashId,
path: ['formily-antd', component, prefixCls, iconPrefixCls],
},
() => {
const componentCls = `.${prefixCls}`;
const mergedToken: TokenWithCommonCls<CustomToken> = merge(token, {
componentCls,
prefixCls,
iconCls: `.${iconPrefixCls}`,
antCls: `.${rootPrefixCls}`,
});
const styleInterpolation = styleFn(mergedToken, props, {
hashId,
prefixCls,
rootPrefixCls,
iconPrefixCls,
});
return [genCommonStyle(token, prefixCls), styleInterpolation];
},
),
hashId,
componentCls: prefixCls,
rootPrefixCls,
};
};
};

View File

@ -0,0 +1,182 @@
import { LoadingOutlined, PlusOutlined } from '@ant-design/icons';
import { onFieldChange, FormPath } from '@formily/core';
import { RecursionField, connect, mapProps, observer, useField, useFieldSchema, useForm } from '@formily/react';
import { uid } from '@formily/shared';
import { Space, message } from 'antd';
import { isFunction } from 'mathjs2';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { RecordProvider, RemoteSelectProps, useAPIClient } from '@nocobase/client';
import { __UNSAFE__ } from '@nocobase/client';
import { RemoteSelect } from '../remote-select';
const { getInnermostKeyAndValue, useServiceOptions, useAssociationFieldContext, isVariable } = __UNSAFE__;
export type AssociationSelectProps<P = any> = RemoteSelectProps<P> & {
action?: string;
multiple?: boolean;
};
export const filterAnalyses = (filters): any[] => {
if (!filters) {
return;
}
const type = Object.keys(filters)[0] || '$and';
const conditions = filters[type];
const results = [];
conditions?.map((c) => {
const jsonlogic = getInnermostKeyAndValue(c);
const operator = jsonlogic?.key;
if (!operator) {
return true;
}
const regex = /\{\{\$(?:[a-zA-Z_]\w*)\.([a-zA-Z_]\w*)(?:\.id)?\}\}/;
const fieldName = jsonlogic?.value?.match?.(regex)?.[1];
if (fieldName) {
results.push(fieldName);
}
});
return results;
};
const InternalAssociationSelect = observer((props: AssociationSelectProps) => {
const { objectValue = true } = props;
const field: any = useField();
const fieldSchema = useFieldSchema();
const service = useServiceOptions(props);
const { options: collectionField } = useAssociationFieldContext();
const initValue = isVariable(props.value) ? undefined : props.value;
const value = Array.isArray(initValue) ? initValue.filter(Boolean) : initValue;
// 因为通过 Schema 的形式书写的组件,在值变更的时候 `value` 的值没有改变,所以需要维护一个 `innerValue` 来变更值
const [innerValue, setInnerValue] = useState(value);
const addMode = fieldSchema['x-component-props']?.addMode;
const isAllowAddNew = fieldSchema['x-add-new'];
const { t } = useTranslation();
const { multiple } = props;
const form = useForm();
const api = useAPIClient();
const resource = api.resource(collectionField.target);
const linkageFields = filterAnalyses(field.componentProps?.service?.params?.filter);
useEffect(() => {
const initValue = isVariable(field.value) ? undefined : field.value;
const value = Array.isArray(initValue) ? initValue.filter(Boolean) : initValue;
setInnerValue(value);
}, [field.value]);
useEffect(() => {
const id = uid();
form.addEffects(id, () => {
if (linkageFields?.length > 0) {
//支持深层次子表单
onFieldChange('*', (fieldPath: any) => {
if (linkageFields.includes(fieldPath.props.name) && field.value) {
props.onChange(field.initialValue);
setInnerValue(field.initialValue);
}
});
}
});
return () => {
form.removeEffects(id);
};
}, []);
const handleCreateAction = async (props) => {
const { search: value, callBack } = props;
const {
data: { data },
} = await resource.create({
values: {
[field?.componentProps?.fieldNames?.label || 'id']: value,
},
});
if (data) {
if (['m2m', 'o2m'].includes(collectionField?.interface) && multiple !== false) {
const values = form.getValuesIn(field.path) || [];
values.push(data);
form.setValuesIn(field.path, values);
field.onInput(values);
} else {
form.setValuesIn(field.path, data);
field.onInput(data);
}
isFunction(callBack) && callBack?.();
message.success(t('Saved successfully'));
}
};
const QuickAddContent = (props) => {
return (
<div
onClick={() => handleCreateAction(props)}
style={{ cursor: 'pointer', padding: '5px 12px', color: '#0d0c0c' }}
>
<PlusOutlined />
<span style={{ paddingLeft: 5 }}>{t('Add') + `${props.search}`}</span>
</div>
);
};
return (
<div key={fieldSchema.name}>
<Space.Compact style={{ display: 'flex', lineHeight: '32px' }}>
<RemoteSelect
style={{ width: '100%' }}
{...props}
size={'middle'}
objectValue={objectValue}
value={value || innerValue}
service={service}
onChange={(value) => {
const val = value?.length !== 0 ? value : null;
props.onChange?.(val);
}}
CustomDropdownRender={addMode === 'quickAdd' && QuickAddContent}
></RemoteSelect>
{(addMode === 'modalAdd' || isAllowAddNew) && (
<RecordProvider record={null}>
<RecursionField
onlyRenderProperties
basePath={field.address}
schema={fieldSchema}
filterProperties={(s) => {
return s['x-component'] === 'Action';
}}
/>
</RecordProvider>
)}
</Space.Compact>
</div>
);
});
interface AssociationSelectInterface {
(props: any): React.ReactElement;
Designer: React.FC;
FilterDesigner: React.FC;
}
export const AssociationSelect = InternalAssociationSelect as unknown as AssociationSelectInterface;
export const AssociationSelectReadPretty = connect(
(props: any) => {
const service = useServiceOptions(props);
if (props.fieldNames) {
return <RemoteSelect.ReadPretty {...props} service={service}></RemoteSelect.ReadPretty>;
}
return null;
},
mapProps(
{
dataSource: 'options',
loading: true,
},
(props, field) => {
return {
...props,
fieldNames: props.fieldNames && { ...props.fieldNames, ...field.componentProps.fieldNames },
suffixIcon: field?.['loading'] || field?.['validating'] ? <LoadingOutlined /> : props.suffixIcon,
};
},
),
);

View File

@ -0,0 +1,95 @@
import { Field } from '@formily/core';
import { connect, mapReadPretty, observer, useField, useForm } from '@formily/react';
import React from 'react';
import {
SchemaComponentOptions,
useCollection_deprecated,
useAssociationCreateActionProps as useCAP,
Action,
__UNSAFE__,
} from '@nocobase/client';
import { InternaDrawerSubTable } from './InternalDrawerSubTable';
import { AssociationSelect } from './AssociationSelect';
const {
CreateRecordAction,
useAssociationFieldContext,
InternalPicker,
InternalNester,
AssociationFieldProvider,
InternaPopoverNester,
InternalSubTable,
InternalCascader,
InternalFileManager,
InternalCascadeSelect,
SubTable,
ReadPretty,
Nester,
} = __UNSAFE__;
const EditableAssociationField = observer(
(props: any) => {
const { multiple } = props;
const field: Field = useField();
const form = useForm();
const { options: collectionField, currentMode } = useAssociationFieldContext();
const useCreateActionProps = () => {
const { onClick } = useCAP();
const actionField: any = useField();
const { getPrimaryKey } = useCollection_deprecated();
const primaryKey = getPrimaryKey();
return {
async onClick() {
await onClick();
const { data } = actionField.data?.data?.data || {};
if (data) {
if (['m2m', 'o2m'].includes(collectionField?.interface) && multiple !== false) {
const values = form.getValuesIn(field.path) || [];
if (!values.find((v) => v[primaryKey] === data[primaryKey])) {
values.push(data);
form.setValuesIn(field.path, values);
field.onInput(values);
}
} else {
form.setValuesIn(field.path, data);
field.onInput(data);
}
}
},
};
};
return (
<SchemaComponentOptions scope={{ useCreateActionProps }} components={{ CreateRecordAction }}>
{currentMode === 'Picker' && <InternalPicker {...props} />}
{currentMode === 'Nester' && <InternalNester {...props} />}
{currentMode === 'PopoverNester' && <InternaPopoverNester {...props} />}
{currentMode === 'Select' && <AssociationSelect {...props} />}
{currentMode === 'SubTable' && <InternalSubTable {...props} />}
{currentMode === 'FileManager' && <InternalFileManager {...props} />}
{currentMode === 'CascadeSelect' && <InternalCascadeSelect {...props} />}
{currentMode === 'DrawerSubTable' && <InternaDrawerSubTable {...props} />}
{currentMode === 'Cascader' && <InternalCascader {...props} />}
</SchemaComponentOptions>
);
},
{ displayName: 'EditableAssociationField' },
);
const ExtendedEditable = observer(
(props) => {
return (
<AssociationFieldProvider>
<EditableAssociationField {...props} />
</AssociationFieldProvider>
);
},
{ displayName: 'ExtendedEditable' },
);
export const ExtendedAssociationField: any = connect(ExtendedEditable, mapReadPretty(ReadPretty));
ExtendedAssociationField.SubTable = SubTable;
ExtendedAssociationField.Nester = Nester;
ExtendedAssociationField.AddNewer = Action.Container;
ExtendedAssociationField.Selector = Action.Container;
ExtendedAssociationField.Viewer = Action.Container;
ExtendedAssociationField.InternalSelect = InternalPicker;

View File

@ -0,0 +1,104 @@
import { EditOutlined } from '@ant-design/icons';
import { css } from '@emotion/css';
import { observer } from '@formily/react';
import React, { useContext, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { InternalSubTable } from './InternalSubTable';
import { ActionContext, ActionContextProvider, __UNSAFE__, useGetAriaLabelOfPopover } from '@nocobase/client';
import { Button, Drawer } from 'antd';
const { useAssociationFieldContext, useSetAriaLabelForPopover, ReadPrettyInternalViewer } = __UNSAFE__;
export const InternaDrawerSubTable = observer(
(props) => {
const { options } = useAssociationFieldContext();
const [visible, setVisible] = useState(false);
const { t } = useTranslation();
const ref = useRef();
const nesterProps = {
...props,
shouldMountElement: true,
};
const titleProps = {
...props,
enableLink: true,
};
const ctx = useContext(ActionContext);
const { getAriaLabel } = useGetAriaLabelOfPopover();
if (process.env.__E2E__) {
useSetAriaLabelForPopover(visible);
}
return (
<>
<span
style={{ cursor: 'pointer', display: 'flex' }}
onClick={() => {
setVisible(true);
}}
>
<div
className={css`
max-width: 95%;
`}
>
<ReadPrettyInternalViewer {...titleProps} />
</div>
<EditOutlined style={{ display: 'inline-flex', marginLeft: '5px' }} />
</span>
{visible && (
<div
role="button"
aria-label={getAriaLabel('mask')}
onClick={() => setVisible(false)}
className={css`
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: transparent;
z-index: 9999;
`}
/>
)}
<ActionContextProvider value={{ ...ctx, visible, setVisible, openMode: 'drawer' }}>
<Drawer
title={t(options?.uiSchema?.rawTitle)}
open={visible}
onClose={() => {
setVisible(false);
}}
style={{ backgroundColor: '#f3f3f3' }}
width={800}
destroyOnClose
footer={
<div style={{ marginLeft: '90%' }}>
<Button type="primary" onClick={() => setVisible(false)}>
</Button>
</div>
}
>
<div
ref={ref}
style={{ minWidth: '600px', maxWidth: '800px', maxHeight: '440px', overflow: 'auto' }}
className={css`
min-width: 600px;
max-height: 440px;
overflow: auto;
.ant-card {
border: 0px;
}
`}
>
<InternalSubTable {...nesterProps} />
</div>
</Drawer>
</ActionContextProvider>
</>
);
},
{ displayName: 'InternaDrawerSubTable' },
);

View File

@ -0,0 +1,69 @@
import { css, cx } from '@emotion/css';
import { FormLayout } from '@formily/antd-v5';
import { RecursionField, useField, useFieldSchema, observer } from '@formily/react';
import React, { useEffect } from 'react';
import { useInsertSchema } from '../../components/AssociatedField';
import {
ACLCollectionProvider,
CollectionProvider_deprecated,
__UNSAFE__,
useACLActionParamsContext,
} from '@nocobase/client';
const { useAssociationFieldContext, schema } = __UNSAFE__;
export const InternalSubTable = observer(
() => {
const field = useField();
const fieldSchema = useFieldSchema();
const insertNester = useInsertSchema('SubTable');
const { options: collectionField } = useAssociationFieldContext();
const showTitle = fieldSchema['x-decorator-props']?.showTitle ?? true;
const { actionName } = useACLActionParamsContext();
useEffect(() => {
insertNester(schema.SubTable);
}, []);
return (
<CollectionProvider_deprecated name={collectionField.target}>
<ACLCollectionProvider actionPath={`${collectionField.target}:${actionName}`}>
<FormLayout layout={'vertical'}>
<div
className={cx(
css`
& .ant-formily-item-layout-vertical {
margin-bottom: 10px;
}
.ant-card-body {
padding: 15px 20px 5px;
}
.ant-divider-horizontal {
margin: 10px 0;
}
`,
{
[css`
.ant-card-body {
padding: 0px 20px 20px 0px;
}
> .ant-card-bordered {
border: none;
}
`]: showTitle === false,
},
)}
>
<RecursionField
onlyRenderProperties
basePath={field.address}
schema={fieldSchema}
filterProperties={(s) => {
return s['x-component'] === 'AssociationField.SubTable';
}}
/>
</div>
</FormLayout>
</ACLCollectionProvider>
</CollectionProvider_deprecated>
);
},
{ displayName: 'InternalSubTable' },
);

View File

@ -0,0 +1,64 @@
import { connect, mapProps, mapReadPretty } from '@formily/react';
import { DatePicker as AntdDatePicker } from 'antd';
import type {
DatePickerProps as AntdDatePickerProps,
RangePickerProps as AntdRangePickerProps,
} from 'antd/es/date-picker';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ReadPretty } from './ReadPretty';
import { getDateRanges, mapDatePicker, mapRangePicker } from './util';
import dayjs from 'dayjs';
interface IDatePickerProps {
utc?: boolean;
}
type ComposedDatePicker = React.FC<AntdDatePickerProps> & {
ReadPretty?: React.FC<AntdDatePickerProps>;
RangePicker?: React.FC<AntdRangePickerProps>;
};
const DatePickerContext = React.createContext<IDatePickerProps>({ utc: true });
export const useDatePickerContext = () => React.useContext(DatePickerContext);
export const DatePickerProvider = DatePickerContext.Provider;
const InternalDatePicker: ComposedDatePicker = connect(
AntdDatePicker,
mapProps(mapDatePicker()),
mapReadPretty(ReadPretty.DatePicker),
);
const InternalRangePicker = connect(
AntdDatePicker.RangePicker,
mapProps(mapRangePicker()),
mapReadPretty(ReadPretty.DateRangePicker),
);
export const DatePicker = (props) => {
const { utc = true } = useDatePickerContext();
const value = Array.isArray(props.value) ? props.value[0] : props.value;
props = { utc, ...props };
return <InternalDatePicker {...props} value={value} />;
};
DatePicker.ReadPretty = ReadPretty.DatePicker;
DatePicker.RangePicker = function RangePicker(props) {
const { t } = useTranslation();
const { utc = true } = useDatePickerContext();
const rangesValue = getDateRanges();
const presets = [
{ label: t('This year'), value: rangesValue.thisYear },
...Array(5)
.fill(0)
.map((_, i) => ({ label: dayjs().year() - i - 1 + '年', value: rangesValue.year(dayjs().year() - i - 1) })),
{ label: t('This month'), value: rangesValue.thisMonth },
{ label: t('Last month'), value: rangesValue.lastMonth },
];
props = { utc, presets, ...props };
return <InternalRangePicker {...props} />;
};
export default DatePicker;

View File

@ -0,0 +1,51 @@
import { usePrefixCls } from '@formily/antd-v5/esm/__builtins__';
import { isArr } from '@formily/shared';
import { getDefaultFormat, str2moment } from '@nocobase/utils/client';
import type {
DatePickerProps as AntdDatePickerProps,
RangePickerProps as AntdRangePickerProps,
} from 'antd/es/date-picker';
import cls from 'classnames';
import dayjs from 'dayjs';
import React from 'react';
type Composed = {
DatePicker: React.FC<AntdDatePickerProps>;
DateRangePicker: React.FC<AntdRangePickerProps>;
};
export const ReadPretty: Composed = () => null;
ReadPretty.DatePicker = function DatePicker(props: any) {
const prefixCls = usePrefixCls('description-date-picker', props);
if (!props.value) {
return <div></div>;
}
const getLabels = () => {
const format = getDefaultFormat(props) as string;
const m = str2moment(props.value, props);
const labels = dayjs.isDayjs(m) ? m.format(format) : '';
return isArr(labels) ? labels.join('~') : labels;
};
return <div className={cls(prefixCls, props.className)}>{getLabels()}</div>;
};
ReadPretty.DateRangePicker = function DateRangePicker(props: any) {
const prefixCls = usePrefixCls('description-text', props);
const format = getDefaultFormat(props);
const getLabels = () => {
const m = str2moment(props.value, props);
if (!m) {
return '';
}
const labels = m.map((m) => m.format(format));
return isArr(labels) ? labels.join('~') : labels;
};
return (
<div className={cls(prefixCls, props.className)} style={props.style}>
{getLabels()}
</div>
);
};

View File

@ -0,0 +1,252 @@
import React from 'react';
import { render, screen, sleep, userEvent, waitFor } from 'testUtils';
import App1 from '../demos/demo1';
import App11 from '../demos/demo11';
import App2 from '../demos/demo2';
import App3 from '../demos/demo3';
import App4 from '../demos/demo4';
import App5 from '../demos/demo5';
import App6 from '../demos/demo6';
import App7 from '../demos/demo7';
import App8 from '../demos/demo8';
import App9 from '../demos/demo9';
describe('DatePicker', () => {
it('basic', async () => {
const { container, getByText } = render(<App1 />);
await sleep();
const picker = container.querySelector('.ant-picker') as HTMLElement;
const input = container.querySelector('input') as HTMLElement;
await userEvent.click(picker);
await userEvent.type(input, '2023/05/01 00:00:00');
await userEvent.click(getByText('OK'));
await waitFor(() => {
expect(input).toHaveValue('2023/05/01 00:00:00');
// Read pretty
expect(screen.getByText('2023/05/01 00:00:00', { selector: '.ant-description-date-picker' })).toBeInTheDocument();
// TODO: 需要有个方法来固定测试环境的时区
if (!process.env.GITHUB_ACTIONS) {
// Value
expect(screen.getByText('2023-04-30T16:00:00.000Z')).toBeInTheDocument();
}
});
});
it('GMT', async () => {
const { container, getByText } = render(<App2 />);
await sleep();
const picker = container.querySelector('.ant-picker') as HTMLElement;
const input = container.querySelector('input') as HTMLElement;
await userEvent.click(picker);
// 清空默认值
await userEvent.clear(input);
await userEvent.type(input, '2023/05/01 00:00:00');
await userEvent.click(getByText('OK'));
expect(input).toHaveValue('2023/05/01 00:00:00');
// Read pretty
expect(screen.getByText('2023/05/01 00:00:00', { selector: '.ant-description-date-picker' })).toBeInTheDocument();
// Value
expect(screen.getByText('2023-05-01T00:00:00.000Z')).toBeInTheDocument();
});
it('non-UTC', async () => {
const { container } = render(<App3 />);
await sleep();
const picker = container.querySelector('.ant-picker') as HTMLElement;
const input = container.querySelector('input') as HTMLElement;
await userEvent.click(picker);
await userEvent.type(input, '2023/05/01');
let selected;
await waitFor(() => {
selected = document.querySelector('.ant-picker-cell-selected') as HTMLElement;
expect(selected).toBeInTheDocument();
});
await userEvent.click(selected);
expect(input).toHaveValue('2023/05/01');
// Read pretty
expect(screen.getByText('2023/05/01 00:00:00', { selector: '.ant-description-date-picker' })).toBeInTheDocument();
// Value
expect(screen.getByText('2023-05-01')).toBeInTheDocument();
});
});
describe('RangePicker', () => {
it('GMT', async () => {
const { container, getByPlaceholderText } = render(<App4 />);
await sleep();
const picker = container.querySelector('.ant-picker') as HTMLElement;
const startInput = getByPlaceholderText('Start date');
const endInput = getByPlaceholderText('End date');
await userEvent.click(picker);
await userEvent.click(document.querySelector('[title="2023-05-01"]') as HTMLElement);
await userEvent.click(document.querySelector('[title="2023-05-02"]') as HTMLElement);
await waitFor(() => expect(startInput).toHaveValue('2023-05-01'));
await waitFor(() => expect(endInput).toHaveValue('2023-05-02'));
// Read pretty
expect(screen.getByText('2023-05-01~2023-05-02', { selector: '.ant-description-text' })).toBeInTheDocument();
// Value
expect(screen.getByText('2023-05-01T00:00:00.000Z ~ 2023-05-02T23:59:59.999Z')).toBeInTheDocument();
});
it('non-GMT', async () => {
const { container, getByPlaceholderText } = render(<App5 />);
await sleep();
const picker = container.querySelector('.ant-picker') as HTMLElement;
const startInput = getByPlaceholderText('Start date');
const endInput = getByPlaceholderText('End date');
await userEvent.click(picker);
await userEvent.click(document.querySelector('[title="2023-05-01"]') as HTMLElement);
await userEvent.click(document.querySelector('[title="2023-05-02"]') as HTMLElement);
await waitFor(() => {
expect(startInput).toHaveValue('2023-05-01');
expect(endInput).toHaveValue('2023-05-02');
// Read pretty
expect(screen.getByText('2023-05-01~2023-05-02', { selector: '.ant-description-text' })).toBeInTheDocument();
if (!process.env.GITHUB_ACTIONS) {
// Value
expect(screen.getByText(/2023-04-30t16:00:00\.000z ~ 2023-05-02t15:59:59\.999z/i)).toBeInTheDocument();
}
});
});
it('non-UTC', async () => {
const { container, getByPlaceholderText } = render(<App6 />);
await sleep();
const picker = container.querySelector('.ant-picker') as HTMLElement;
const startInput = getByPlaceholderText('Start date');
const endInput = getByPlaceholderText('End date');
await userEvent.click(picker);
await sleep();
await userEvent.click(document.querySelector('[title="2023-05-01"]') as HTMLElement);
await userEvent.click(document.querySelector('[title="2023-05-02"]') as HTMLElement);
await waitFor(() => expect(startInput).toHaveValue('2023-05-01'));
await waitFor(() => expect(endInput).toHaveValue('2023-05-02'));
// Read pretty
await waitFor(() =>
expect(screen.getByText('2023-05-01~2023-05-02', { selector: '.ant-description-text' })).toBeInTheDocument(),
);
// Value
await waitFor(() => expect(screen.getByText('2023-05-01 ~ 2023-05-02')).toBeInTheDocument());
});
it('showTime=false,gmt=true,utc=true', async () => {
const { container } = render(<App7 />);
await sleep();
const picker = container.querySelector('.ant-picker') as HTMLElement;
const input = container.querySelector('input') as HTMLElement;
await userEvent.click(picker);
await userEvent.type(input, '2023/05/01');
await userEvent.click(document.querySelector('[title="2023-05-01"]') as HTMLElement);
await waitFor(() => {
expect(input).toHaveValue('2023/05/01');
// Read pretty
expect(screen.getByText('2023/05/01', { selector: '.ant-description-date-picker' })).toBeInTheDocument();
// Value
expect(screen.getByText('2023-05-01T00:00:00.000Z')).toBeInTheDocument();
});
});
it('showTime=false,gmt=false,utc=true', async () => {
const { container } = render(<App8 />);
await sleep();
const picker = container.querySelector('.ant-picker') as HTMLElement;
const input = container.querySelector('input') as HTMLElement;
await userEvent.click(picker);
await userEvent.type(input, '2023/05/01');
await userEvent.click(document.querySelector('[title="2023-05-01"]') as HTMLElement);
await waitFor(() => {
expect(input).toHaveValue('2023/05/01');
// Read pretty
expect(screen.getByText('2023/05/01', { selector: '.ant-description-date-picker' })).toBeInTheDocument();
if (!process.env.GITHUB_ACTIONS) {
// Value
// 当 gmt 为 false 时是按照客户端本地时区进行计算的,但是这里的测试环境是 UTC+8所以会有 8 小时的误差
expect(screen.getByText('2023-04-30T16:00:00.000Z')).toBeInTheDocument();
}
});
});
it('showTime=false,gmt=true,utc=true & not input', async () => {
const currentDateString = new Date().toISOString().split('T')[0];
const { container } = render(<App9 />);
await sleep();
const picker = container.querySelector('.ant-picker') as HTMLElement;
await userEvent.click(picker);
const btn = document.querySelector(`[title="${currentDateString}"]`);
expect(btn).toBeInTheDocument();
await userEvent.click(btn as HTMLElement);
await waitFor(() => {
// Read pretty
expect(
screen.getByText(currentDateString.replace(/-/g, '/'), { selector: '.ant-description-date-picker' }),
).toBeInTheDocument();
// Value
expect(screen.getByText(`${currentDateString}T00:00:00.000Z`)).toBeInTheDocument();
});
});
// fix T-1506
it('shortcut', async () => {
const { container } = render(<App11 />);
await sleep();
const picker = container.querySelector('.ant-picker') as HTMLElement;
const startInput = screen.getByPlaceholderText('Start date');
const endInput = screen.getByPlaceholderText('End date');
await userEvent.click(picker);
// shortcut: Today
await userEvent.click(screen.getByText(/today/i));
await sleep();
// 因为 Today 快捷键的值是动态生成的,所以这里没有断言具体的值
await waitFor(() => expect(startInput.getAttribute('value')).toBeTruthy());
await waitFor(() => expect(endInput.getAttribute('value')).toBeTruthy());
});
});

View File

@ -0,0 +1,132 @@
import dayjs from 'dayjs';
import { getDateRanges } from '../util';
describe('getDateRanges', () => {
const dateRanges = getDateRanges();
it('today', () => {
const [start, end] = dateRanges.today();
expect(start.toISOString()).toBe(dayjs().startOf('day').toISOString());
expect(end.toISOString()).toBe(dayjs().endOf('day').toISOString());
});
test('yesterday', () => {
const [start, end] = dateRanges.yesterday();
expect(dayjs(start).isSame(dayjs().subtract(1, 'day'), 'day')).toBe(true);
expect(dayjs(end).isSame(dayjs().subtract(1, 'day'), 'day')).toBe(true);
});
test('tomorrow', () => {
const [start, end] = dateRanges.tomorrow();
expect(dayjs(start).isSame(dayjs().add(1, 'day'), 'day')).toBe(true);
expect(dayjs(end).isSame(dayjs().add(1, 'day'), 'day')).toBe(true);
});
it('lastWeek', () => {
const [start, end] = dateRanges.lastWeek();
expect(start.toISOString()).toBe(dayjs().add(-1, 'week').startOf('isoWeek').toISOString());
expect(end.toISOString()).toBe(dayjs().add(-1, 'week').endOf('isoWeek').toISOString());
});
it('thisWeek', () => {
const [start, end] = dateRanges.thisWeek();
expect(start.toISOString()).toBe(dayjs().startOf('isoWeek').toISOString());
expect(end.toISOString()).toBe(dayjs().endOf('isoWeek').toISOString());
});
it('nextWeek', () => {
const [start, end] = dateRanges.nextWeek();
expect(start.toISOString()).toBe(dayjs().add(1, 'week').startOf('isoWeek').toISOString());
expect(end.toISOString()).toBe(dayjs().add(1, 'week').endOf('isoWeek').toISOString());
});
it('lastMonth', () => {
const [start, end] = dateRanges.lastMonth();
expect(start.toISOString()).toBe(dayjs().add(-1, 'month').startOf('month').toISOString());
expect(end.toISOString()).toBe(dayjs().add(-1, 'month').endOf('month').toISOString());
});
it('thisMonth', () => {
const [start, end] = dateRanges.thisMonth();
expect(start.toISOString()).toBe(dayjs().startOf('month').toISOString());
expect(end.toISOString()).toBe(dayjs().endOf('month').toISOString());
});
it('nextMonth', () => {
const [start, end] = dateRanges.nextMonth();
expect(start.toISOString()).toBe(dayjs().add(1, 'month').startOf('month').toISOString());
expect(end.toISOString()).toBe(dayjs().add(1, 'month').endOf('month').toISOString());
});
it('lastQuarter', () => {
const [start, end] = dateRanges.lastQuarter();
expect(start.toISOString()).toBe(dayjs().add(-1, 'quarter').startOf('quarter').toISOString());
expect(end.toISOString()).toBe(dayjs().add(-1, 'quarter').endOf('quarter').toISOString());
});
it('thisQuarter', () => {
const [start, end] = dateRanges.thisQuarter();
expect(start.toISOString()).toBe(dayjs().startOf('quarter').toISOString());
expect(end.toISOString()).toBe(dayjs().endOf('quarter').toISOString());
});
it('nextQuarter', () => {
const [start, end] = dateRanges.nextQuarter();
expect(start.toISOString()).toBe(dayjs().add(1, 'quarter').startOf('quarter').toISOString());
expect(end.toISOString()).toBe(dayjs().add(1, 'quarter').endOf('quarter').toISOString());
});
it('lastYear', () => {
const [start, end] = dateRanges.lastYear();
expect(start.toISOString()).toBe(dayjs().add(-1, 'year').startOf('year').toISOString());
expect(end.toISOString()).toBe(dayjs().add(-1, 'year').endOf('year').toISOString());
});
it('thisYear', () => {
const [start, end] = dateRanges.thisYear();
expect(start.toISOString()).toBe(dayjs().startOf('year').toISOString());
expect(end.toISOString()).toBe(dayjs().endOf('year').toISOString());
});
it('nextYear', () => {
const [start, end] = dateRanges.nextYear();
expect(start.toISOString()).toBe(dayjs().add(1, 'year').startOf('year').toISOString());
expect(end.toISOString()).toBe(dayjs().add(1, 'year').endOf('year').toISOString());
});
it('last7Days', () => {
const [start, end] = dateRanges.last7Days();
expect(start.toISOString()).toBe(dayjs().add(-6, 'days').startOf('days').toISOString());
expect(end.toISOString()).toBe(dayjs().endOf('days').toISOString());
});
it('next7Days', () => {
const [start, end] = dateRanges.next7Days();
expect(start.toISOString()).toBe(dayjs().add(1, 'day').startOf('day').toISOString());
expect(end.toISOString()).toBe(dayjs().add(7, 'days').endOf('days').toISOString());
});
it('last30Days', () => {
const [start, end] = dateRanges.last30Days();
expect(start.toISOString()).toBe(dayjs().add(-29, 'days').startOf('days').toISOString());
expect(end.toISOString()).toBe(dayjs().endOf('days').toISOString());
});
it('next30Days', () => {
const [start, end] = dateRanges.next30Days();
expect(start.toISOString()).toBe(dayjs().add(1, 'day').startOf('day').toISOString());
expect(end.toISOString()).toBe(dayjs().add(30, 'days').endOf('days').toISOString());
});
it('last90Days', () => {
const [start, end] = dateRanges.last90Days();
expect(start.toISOString()).toBe(dayjs().add(-89, 'days').startOf('days').toISOString());
expect(end.toISOString()).toBe(dayjs().endOf('days').toISOString());
});
it('next90Days', () => {
const [start, end] = dateRanges.next90Days();
expect(start.toISOString()).toBe(dayjs().add(1, 'day').startOf('day').toISOString());
expect(end.toISOString()).toBe(dayjs().add(90, 'days').endOf('days').toISOString());
});
});

View File

@ -0,0 +1,221 @@
import dayjs from 'dayjs';
import { mapDatePicker } from '../util';
import { vi } from 'vitest';
describe('mapDatePicker', () => {
it('showTime is true and gmt is true', () => {
const props = {
value: '2022-02-22T22:22:22.000Z',
showTime: true,
gmt: true,
};
const result = mapDatePicker()(props);
expect(result.value.format('YYYY-MM-DD HH:mm:ss')).toBe('2022-02-22 22:22:22');
});
it('showTime is true and gmt is false', () => {
const props = {
value: '2022-02-22T22:22:22.000Z',
showTime: true,
gmt: false,
};
const result = mapDatePicker()(props);
expect(result.value.format('YYYY-MM-DD HH:mm:ss')).toBe(
dayjs('2022-02-22T22:22:22.000Z').format('YYYY-MM-DD HH:mm:ss'),
);
});
it('showTime is false and gmt is true', () => {
const props = {
value: '2022-02-22T00:00:00.000Z',
showTime: false,
gmt: true,
};
const result = mapDatePicker()(props);
expect(result.value.format('YYYY-MM-DD HH:mm:ss')).toBe('2022-02-22 00:00:00');
});
it('showTime is false and gmt is false', () => {
const props = {
value: '2022-02-22',
showTime: false,
gmt: false,
};
const result = mapDatePicker()(props);
expect(result.value.format('YYYY-MM-DD HH:mm:ss')).toBe('2022-02-22 00:00:00');
});
it('should call onChange with correct value when showTime is true and gmt is true', () => {
const props = {
showTime: true,
gmt: true,
onChange: vi.fn(),
};
const result = mapDatePicker()(props);
result.onChange(dayjs.utc('2022-02-22 22:22:22'));
expect(props.onChange).toHaveBeenCalledWith('2022-02-22T22:22:22.000Z');
});
it('should call onChange with correct value when showTime is true and gmt is false', () => {
const props = {
showTime: true,
gmt: false,
onChange: vi.fn(),
};
const result = mapDatePicker()(props);
const m = dayjs('2022-02-22 22:22:22');
result.onChange(m);
expect(props.onChange).toHaveBeenCalledWith(m.toISOString());
});
it('should call onChange with correct value when showTime is false and gmt is true', () => {
const props = {
showTime: false,
gmt: true,
onChange: vi.fn(),
};
const result = mapDatePicker()(props);
result.onChange(dayjs.utc('2022-02-22'));
expect(props.onChange).toHaveBeenCalledWith('2022-02-22T00:00:00.000Z');
});
it('should call onChange with correct value when showTime is false and gmt is false', () => {
const props = {
showTime: false,
gmt: false,
onChange: vi.fn(),
};
const result = mapDatePicker()(props);
const m = dayjs('2022-02-22');
result.onChange(m);
expect(props.onChange).toHaveBeenCalledWith(m.toISOString());
});
it('should call onChange with correct value when picker is year and gmt is true', () => {
const props = {
picker: 'year',
gmt: true,
onChange: vi.fn(),
};
const result = mapDatePicker()(props);
result.onChange(dayjs.utc('2022-01-01T00:00:00.000Z'));
expect(props.onChange).toHaveBeenCalledWith('2022-01-01T00:00:00.000Z');
});
it('should call onChange with correct value when picker is year and gmt is false', () => {
const props = {
picker: 'year',
gmt: false,
onChange: vi.fn(),
};
const result = mapDatePicker()(props);
const m = dayjs('2022-02-01 00:00:00');
result.onChange(m);
expect(props.onChange).toHaveBeenCalledWith(m.startOf('year').toISOString());
});
it('should call onChange with correct value when picker is month and gmt is true', () => {
const props = {
picker: 'month',
gmt: true,
onChange: vi.fn(),
};
const result = mapDatePicker()(props);
result.onChange(dayjs.utc('2022-02-22T00:00:00.000Z'));
expect(props.onChange).toHaveBeenCalledWith('2022-02-01T00:00:00.000Z');
});
it('should call onChange with correct value when picker is month and gmt is false', () => {
const props = {
picker: 'month',
gmt: false,
onChange: vi.fn(),
};
const result = mapDatePicker()(props);
const m = dayjs('2022-02-01 00:00:00');
result.onChange(m);
expect(props.onChange).toHaveBeenCalledWith(m.startOf('month').toISOString());
});
it('should call onChange with correct value when picker is quarter and gmt is true', () => {
const props = {
picker: 'quarter',
gmt: true,
onChange: vi.fn(),
};
const result = mapDatePicker()(props);
result.onChange(dayjs.utc('2022-02-22T00:00:00.000Z'));
expect(props.onChange).toHaveBeenCalledWith('2022-01-01T00:00:00.000Z');
});
it('should call onChange with correct value when picker is quarter and gmt is false', () => {
const props = {
picker: 'quarter',
gmt: false,
onChange: vi.fn(),
};
const result = mapDatePicker()(props);
const m = dayjs('2022-02-01 00:00:00');
result.onChange(m);
expect(props.onChange).toHaveBeenCalledWith(m.startOf('quarter').toISOString());
});
it('should call onChange with correct value when picker is week and gmt is true', () => {
const props = {
picker: 'week',
gmt: true,
onChange: vi.fn(),
};
const result = mapDatePicker()(props);
const m = dayjs.utc('2022-02-21T00:00:00.000Z');
result.onChange(m);
expect(props.onChange).toHaveBeenCalledWith(m.startOf('week').add(1, 'day').toISOString());
});
it('should call onChange with correct value when picker is week and gmt is false', () => {
const props = {
picker: 'week',
gmt: false,
onChange: vi.fn(),
};
const result = mapDatePicker()(props);
const m = dayjs('2022-02-21 00:00:00');
result.onChange(m);
expect(props.onChange).toHaveBeenCalledWith(m.startOf('week').add(1, 'day').toISOString());
});
it('should call onChange with correct value when utc is false', () => {
const props = {
showTime: true,
gmt: true,
utc: false,
onChange: vi.fn(),
};
const result = mapDatePicker()(props);
result.onChange(dayjs('2022-02-22 22:22:22'));
expect(props.onChange).toHaveBeenCalledWith('2022-02-22 22:22:22');
});
it('should call onChange with correct value when picker is year and utc is false', () => {
const props = {
showTime: false,
gmt: true,
utc: false,
onChange: vi.fn(),
};
const result = mapDatePicker()(props);
result.onChange(dayjs('2022-01-01 23:00:00'));
expect(props.onChange).toHaveBeenCalledWith('2022-01-01');
});
it('utc is false and gmt is true', () => {
const props = {
value: '2022-01-01 23:00:00',
showTime: true,
gmt: true,
utc: false,
};
const result = mapDatePicker()(props);
expect(result.value.format('YYYY-MM-DD HH:mm:ss')).toBe('2022-01-01 23:00:00');
});
});

View File

@ -0,0 +1,43 @@
import dayjs from 'dayjs';
import { mapRangePicker } from '../util';
describe('mapRangePicker', () => {
it('should work with showTime=false, gmt=true, utc=true', () => {
const props = {
showTime: false,
gmt: true,
utc: true,
onChange: vi.fn(),
};
const { onChange } = mapRangePicker()(props);
const value = [dayjs.utc('2023-01-01T00:00:00.000Z'), dayjs.utc('2023-01-02T00:00:00.000Z')];
onChange(value);
expect(props.onChange).toHaveBeenCalledWith(['2023-01-01T00:00:00.000Z', '2023-01-02T23:59:59.999Z']);
});
it('should work with showTime=true, gmt=true, utc=true', () => {
const props = {
showTime: true,
gmt: true,
utc: true,
onChange: vi.fn(),
};
const { onChange } = mapRangePicker()(props);
const value = [dayjs.utc('2023-01-01T00:00:00.000Z'), dayjs.utc('2023-01-02T00:00:00.000Z')];
onChange(value);
expect(props.onChange).toHaveBeenCalledWith(['2023-01-01T00:00:00.000Z', '2023-01-02T00:00:00.000Z']);
});
it('should work with showTime=false, gmt=true, utc=false', () => {
const props = {
showTime: false,
gmt: true,
utc: false,
onChange: vi.fn(),
};
const { onChange } = mapRangePicker()(props);
const value = [dayjs.utc('2023-01-01T00:00:00.000Z'), dayjs.utc('2023-01-02T00:00:00.000Z')];
onChange(value);
expect(props.onChange).toHaveBeenCalledWith(['2023-01-01', '2023-01-02']);
});
});

View File

@ -0,0 +1,140 @@
import { str2moment } from '@nocobase/utils/client';
import dayjs from 'dayjs';
import { moment2str } from '../util';
describe('str2moment', () => {
describe('string value', () => {
test('gmt date', async () => {
const m = str2moment('2022-06-21T00:00:00.000Z', { gmt: true });
expect(m.format('YYYY-MM-DD HH:mm:ss')).toBe('2022-06-21 00:00:00');
});
test('local date', async () => {
const m = str2moment('2022-06-21T00:00:00.000Z');
expect(m.toISOString()).toBe('2022-06-21T00:00:00.000Z');
});
test('value is null', async () => {
const m = str2moment(null);
expect(m).toBeNull();
});
test('picker is month', async () => {
const m = str2moment('2022-06-01T00:00:00.000Z', { picker: 'month' });
expect(m.format('YYYY-MM-DD HH:mm:ss')).toBe('2022-06-01 00:00:00');
});
});
describe('array value', () => {
test('gmt date', async () => {
const arr = str2moment(['2022-06-21T00:00:00.000Z', '2022-06-21T00:00:00.000Z'], { gmt: true });
for (const m of arr) {
expect(m.format('YYYY-MM-DD HH:mm:ss')).toBe('2022-06-21 00:00:00');
}
});
test('local date', async () => {
const arr = str2moment(['2022-06-21T00:00:00.000Z', '2022-06-21T00:00:00.000Z']);
for (const m of arr) {
expect(m.toISOString()).toBe('2022-06-21T00:00:00.000Z');
}
});
});
});
describe('moment2str', () => {
test('gmt date', () => {
const m = dayjs('2023-06-21 10:10:00');
const str = moment2str(m, { showTime: true, gmt: true });
expect(str).toBe('2023-06-21T10:10:00.000Z');
});
test('showTime is true, gmt is false', () => {
const m = dayjs('2023-06-21 10:10:00');
const str = moment2str(m, { showTime: true, gmt: false });
expect(str).toBe(m.toISOString());
});
test('gmt is true', () => {
const m = dayjs('2023-06-21 10:10:00');
const str = moment2str(m, { gmt: true });
expect(str).toBe('2023-06-21T10:10:00.000Z');
});
test('gmt is false', () => {
const m = dayjs('2023-06-21 10:10:00');
const str = moment2str(m, { gmt: false });
expect(str).toBe(dayjs('2023-06-21 10:10:00').toISOString());
});
test('with time', () => {
const m = dayjs('2023-06-21 10:10:00');
const str = moment2str(m, { showTime: true });
expect(str).toBe(m.toISOString());
});
test('picker is year, gmt is false', () => {
const m = dayjs('2023-06-21 10:10:00');
const str = moment2str(m, { picker: 'year', gmt: false });
expect(str).toBe(dayjs('2023-01-01 00:00:00').toISOString());
});
test('picker is year, gmt is true', () => {
const m = dayjs('2023-06-21 10:10:00');
const str = moment2str(m, { picker: 'year', gmt: true });
expect(str).toBe('2023-01-01T00:00:00.000Z');
});
test('picker is year', () => {
const m = dayjs('2023-06-21 10:10:00');
const str = moment2str(m, { picker: 'year' });
expect(str).toBe('2023-01-01T00:00:00.000Z');
});
test('picker is quarter, gmt is false', () => {
const m = dayjs('2023-06-21 10:10:00');
const str = moment2str(m, { picker: 'quarter', gmt: false });
expect(str).toBe(dayjs('2023-04-01 00:00:00').toISOString());
});
test('picker is quarter, gmt is true', () => {
const m = dayjs('2023-06-21 10:10:00');
const str = moment2str(m, { picker: 'quarter', gmt: true });
expect(str).toBe('2023-04-01T00:00:00.000Z');
});
test('picker is month, gmt is false', () => {
const m = dayjs('2023-06-21 10:10:00');
const str = moment2str(m, { picker: 'month', gmt: false });
expect(str).toBe(dayjs('2023-06-01 00:00:00').toISOString());
});
test('picker is month, gmt is true', () => {
const m = dayjs('2023-06-21 10:10:00');
const str = moment2str(m, { picker: 'month', gmt: true });
expect(str).toBe('2023-06-01T00:00:00.000Z');
});
test('picker is month', () => {
const m = dayjs('2023-06-21 10:10:00');
const str = moment2str(m, { picker: 'month' });
expect(str).toBe('2023-06-01T00:00:00.000Z');
});
test('picker is week, gmt is false', () => {
const m = dayjs('2023-06-21 10:10:00');
const str = moment2str(m, { picker: 'week', gmt: false });
expect(str).toBe(dayjs('2023-06-19 00:00:00').toISOString());
});
test('picker is week, gmt is true', () => {
const m = dayjs('2023-06-21 10:10:00');
const str = moment2str(m, { picker: 'week', gmt: true });
expect(str).toBe('2023-06-19T00:00:00.000Z');
});
test('value is null', async () => {
const m = moment2str(null);
expect(m).toBeNull();
});
});

View File

@ -0,0 +1,57 @@
/**
* title: DatePicker
*/
import { FormItem } from '@formily/antd-v5';
import { DatePicker, Input, SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
import React from 'react';
const schema = {
type: 'object',
properties: {
input: {
type: 'boolean',
title: `Editable`,
'x-decorator': 'FormItem',
'x-component': 'DatePicker',
'x-component-props': {
dateFormat: 'YYYY/MM/DD',
showTime: true,
},
'x-reactions': {
target: '*(read1,read2)',
fulfill: {
state: {
value: '{{$self.value}}',
},
},
},
},
read1: {
type: 'boolean',
title: `Read pretty`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'DatePicker',
'x-component-props': {
dateFormat: 'YYYY/MM/DD',
showTime: true,
},
},
read2: {
type: 'string',
title: `Value`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {},
},
},
};
export default () => {
return (
<SchemaComponentProvider components={{ Input, DatePicker, FormItem }}>
<SchemaComponent schema={schema} />
</SchemaComponentProvider>
);
};

View File

@ -0,0 +1,61 @@
/**
* title: DatePicker
*/
import { FormItem } from '@formily/antd-v5';
import { DatePicker, Input, SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
import React from 'react';
const schema = {
type: 'object',
properties: {
input: {
type: 'boolean',
title: `Editable`,
'x-decorator': 'FormItem',
'x-component': 'DatePicker',
'x-component-props': {
dateFormat: 'YYYY/MM/DD',
showTime: false,
gmt: false,
utc: true,
},
'x-reactions': {
target: '*(read1,read2)',
fulfill: {
state: {
value: '{{$self.value}}',
},
},
},
},
read1: {
type: 'boolean',
title: `Read pretty`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'DatePicker',
'x-component-props': {
dateFormat: 'YYYY/MM/DD',
showTime: false,
gmt: false,
utc: true,
},
},
read2: {
type: 'string',
title: `Value`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {},
},
},
};
export default () => {
return (
<SchemaComponentProvider components={{ Input, DatePicker, FormItem }}>
<SchemaComponent schema={schema} />
</SchemaComponentProvider>
);
};

View File

@ -0,0 +1,65 @@
/**
* title: DatePicker.RangePicker
*/
import { FormItem } from '@formily/antd-v5';
import { DatePicker, Input, SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
import React from 'react';
const schema = {
type: 'object',
properties: {
input: {
type: 'boolean',
title: `Editable`,
'x-decorator': 'FormItem',
'x-component': 'DatePicker.RangePicker',
'x-component-props': {
gmt: true,
},
'x-reactions': [
{
target: 'read1',
fulfill: {
state: {
value: '{{$self.value}}',
},
},
},
{
target: 'read2',
fulfill: {
state: {
value: '{{$self.value && $self.value.join(" ~ ")}}',
},
},
},
],
},
read1: {
type: 'boolean',
title: `Read pretty`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'DatePicker.RangePicker',
'x-component-props': {
gmt: true,
},
},
read2: {
type: 'string',
title: `Value`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {},
},
},
};
export default () => {
return (
<SchemaComponentProvider components={{ Input, DatePicker, FormItem }}>
<SchemaComponent schema={schema} />
</SchemaComponentProvider>
);
};

View File

@ -0,0 +1,60 @@
/**
* title: DatePicker (GMT)
*/
import { FormItem } from '@formily/antd-v5';
import { DatePicker, Input, SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
import React from 'react';
const schema = {
type: 'object',
properties: {
input: {
type: 'boolean',
title: `Editable`,
'x-decorator': 'FormItem',
'x-component': 'DatePicker',
'x-component-props': {
dateFormat: 'YYYY/MM/DD',
showTime: true,
gmt: true,
},
default: '2022-06-04T15:00:00.000Z',
'x-reactions': {
target: '*(read1,read2)',
fulfill: {
state: {
value: '{{$self.value}}',
},
},
},
},
read1: {
type: 'string',
title: `Read pretty`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'DatePicker',
'x-component-props': {
dateFormat: 'YYYY/MM/DD',
showTime: true,
gmt: true,
},
},
read2: {
type: 'string',
title: `Value`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {},
},
},
};
export default () => {
return (
<SchemaComponentProvider components={{ Input, DatePicker, FormItem }}>
<SchemaComponent schema={schema} />
</SchemaComponentProvider>
);
};

View File

@ -0,0 +1,58 @@
/**
* title: DatePicker
*/
import { FormItem } from '@formily/antd-v5';
import { DatePicker, Input, SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
import React from 'react';
const schema = {
type: 'object',
properties: {
input: {
type: 'boolean',
title: `Editable`,
'x-decorator': 'FormItem',
'x-component': 'DatePicker',
'x-component-props': {
dateFormat: 'YYYY/MM/DD',
showTime: false,
utc: false,
},
'x-reactions': {
target: '*(read1,read2)',
fulfill: {
state: {
value: '{{$self.value}}',
},
},
},
},
read1: {
type: 'boolean',
title: `Read pretty`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'DatePicker',
'x-component-props': {
dateFormat: 'YYYY/MM/DD',
showTime: true,
},
},
read2: {
type: 'string',
title: `Value`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {},
},
},
};
export default () => {
return (
<SchemaComponentProvider components={{ Input, DatePicker, FormItem }}>
<SchemaComponent schema={schema} />
</SchemaComponentProvider>
);
};

View File

@ -0,0 +1,68 @@
/**
* title: DatePicker.RangePicker
*/
import { FormItem } from '@formily/antd-v5';
import { DatePicker, Input, SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
import dayjs from 'dayjs';
import React from 'react';
const schema = {
type: 'object',
properties: {
input: {
type: 'boolean',
title: `Editable`,
'x-decorator': 'FormItem',
'x-component': 'DatePicker.RangePicker',
'x-component-props': {
gmt: true,
defaultPickerValue: [dayjs('2023-05-01')],
},
'x-reactions': [
{
target: 'read1',
fulfill: {
state: {
value: '{{$self.value}}',
},
},
},
{
target: 'read2',
fulfill: {
state: {
value: '{{$self.value && $self.value.join(" ~ ")}}',
},
},
},
],
},
read1: {
type: 'boolean',
title: `Read pretty`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'DatePicker.RangePicker',
'x-component-props': {
gmt: true,
defaultPickerValue: [dayjs('2023-05-01')],
},
},
read2: {
type: 'string',
title: `Value`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {},
},
},
};
export default () => {
return (
<SchemaComponentProvider components={{ Input, DatePicker, FormItem }}>
<SchemaComponent schema={schema} />
</SchemaComponentProvider>
);
};

View File

@ -0,0 +1,67 @@
/**
* title: DatePicker.RangePicker
*/
import { FormItem } from '@formily/antd-v5';
import { DatePicker, Input, SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
import dayjs from 'dayjs';
import React from 'react';
const schema = {
type: 'object',
properties: {
input: {
type: 'boolean',
title: `Editable`,
'x-decorator': 'FormItem',
'x-component': 'DatePicker.RangePicker',
'x-component-props': {
gmt: false,
defaultPickerValue: [dayjs('2023-05-01')],
},
'x-reactions': [
{
target: 'read1',
fulfill: {
state: {
value: '{{$self.value}}',
},
},
},
{
target: 'read2',
fulfill: {
state: {
value: '{{$self.value && $self.value.join(" ~ ")}}',
},
},
},
],
},
read1: {
type: 'boolean',
title: `Read pretty`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'DatePicker.RangePicker',
'x-component-props': {
defaultPickerValue: [dayjs('2023-05-01')],
},
},
read2: {
type: 'string',
title: `Value`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {},
},
},
};
export default () => {
return (
<SchemaComponentProvider components={{ Input, DatePicker, FormItem }}>
<SchemaComponent schema={schema} />
</SchemaComponentProvider>
);
};

View File

@ -0,0 +1,67 @@
/**
* title: DatePicker.RangePicker
*/
import { FormItem } from '@formily/antd-v5';
import { DatePicker, Input, SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
import dayjs from 'dayjs';
import React from 'react';
const schema = {
type: 'object',
properties: {
input: {
type: 'boolean',
title: `Editable`,
'x-decorator': 'FormItem',
'x-component': 'DatePicker.RangePicker',
'x-component-props': {
utc: false,
defaultPickerValue: [dayjs('2023-05-01')],
},
'x-reactions': [
{
target: 'read1',
fulfill: {
state: {
value: '{{$self.value}}',
},
},
},
{
target: 'read2',
fulfill: {
state: {
value: '{{$self.value && $self.value.join(" ~ ")}}',
},
},
},
],
},
read1: {
type: 'boolean',
title: `Read pretty`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'DatePicker.RangePicker',
'x-component-props': {
defaultPickerValue: [dayjs('2023-05-01')],
},
},
read2: {
type: 'string',
title: `Value`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {},
},
},
};
export default () => {
return (
<SchemaComponentProvider components={{ Input, DatePicker, FormItem }}>
<SchemaComponent schema={schema} />
</SchemaComponentProvider>
);
};

View File

@ -0,0 +1,61 @@
/**
* title: DatePicker
*/
import { FormItem } from '@formily/antd-v5';
import { DatePicker, Input, SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
import React from 'react';
const schema = {
type: 'object',
properties: {
input: {
type: 'boolean',
title: `Editable`,
'x-decorator': 'FormItem',
'x-component': 'DatePicker',
'x-component-props': {
dateFormat: 'YYYY/MM/DD',
showTime: false,
gmt: true,
utc: true,
},
'x-reactions': {
target: '*(read1,read2)',
fulfill: {
state: {
value: '{{$self.value}}',
},
},
},
},
read1: {
type: 'boolean',
title: `Read pretty`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'DatePicker',
'x-component-props': {
dateFormat: 'YYYY/MM/DD',
showTime: false,
gmt: true,
utc: true,
},
},
read2: {
type: 'string',
title: `Value`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {},
},
},
};
export default () => {
return (
<SchemaComponentProvider components={{ Input, DatePicker, FormItem }}>
<SchemaComponent schema={schema} />
</SchemaComponentProvider>
);
};

View File

@ -0,0 +1,61 @@
/**
* title: DatePicker
*/
import { FormItem } from '@formily/antd-v5';
import { DatePicker, Input, SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
import React from 'react';
const schema = {
type: 'object',
properties: {
input: {
type: 'boolean',
title: `Editable`,
'x-decorator': 'FormItem',
'x-component': 'DatePicker',
'x-component-props': {
dateFormat: 'YYYY/MM/DD',
showTime: false,
gmt: false,
utc: true,
},
'x-reactions': {
target: '*(read1,read2)',
fulfill: {
state: {
value: '{{$self.value}}',
},
},
},
},
read1: {
type: 'boolean',
title: `Read pretty`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'DatePicker',
'x-component-props': {
dateFormat: 'YYYY/MM/DD',
showTime: false,
gmt: false,
utc: true,
},
},
read2: {
type: 'string',
title: `Value`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {},
},
},
};
export default () => {
return (
<SchemaComponentProvider components={{ Input, DatePicker, FormItem }}>
<SchemaComponent schema={schema} />
</SchemaComponentProvider>
);
};

View File

@ -0,0 +1,61 @@
/**
* title: DatePicker
*/
import { FormItem } from '@formily/antd-v5';
import { DatePicker, Input, SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
import React from 'react';
const schema = {
type: 'object',
properties: {
input: {
type: 'boolean',
title: `Editable`,
'x-decorator': 'FormItem',
'x-component': 'DatePicker',
'x-component-props': {
dateFormat: 'YYYY/MM/DD',
showTime: false,
gmt: true,
utc: true,
},
'x-reactions': {
target: '*(read1,read2)',
fulfill: {
state: {
value: '{{$self.value}}',
},
},
},
},
read1: {
type: 'boolean',
title: `Read pretty`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'DatePicker',
'x-component-props': {
dateFormat: 'YYYY/MM/DD',
showTime: false,
gmt: true,
utc: true,
},
},
read2: {
type: 'string',
title: `Value`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {},
},
},
};
export default () => {
return (
<SchemaComponentProvider components={{ Input, DatePicker, FormItem }}>
<SchemaComponent schema={schema} />
</SchemaComponentProvider>
);
};

View File

@ -0,0 +1,51 @@
---
group:
title: Schema Components
order: 3
---
# DatePicker
## Examples
### Basic
<code src="./demos/demo1.tsx"></code>
### DatePicker (GMT)
<code src="./demos/demo2.tsx"></code>
### DatePicker (showTime=false,gmt=true,utc=true)
<code src="./demos/demo7.tsx"></code>
### DatePicker (showTime=false,gmt=false,utc=true)
<code src="./demos/demo8.tsx"></code>
### DatePicker (non-UTC)
<code src="./demos/demo3.tsx"></code>
### RangePicker (GMT)
<code src="./demos/demo4.tsx"></code>
### RangePicker (non-GMT)
<code src="./demos/demo5.tsx"></code>
### RangePicker (non-UTC)
<code src="./demos/demo6.tsx"></code>
## API
基于 antd 的 [DatePicker](https://ant.design/components/date-picker/#API),新增了以下扩展属性,用于支持 NocoBase 的日期字段设置。
- `dateFormat` 设置日期格式
- `timeFormat` 设置时间格式

View File

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

View File

@ -0,0 +1,168 @@
import { getDefaultFormat, str2moment, toGmt, toLocal } from '@nocobase/utils/client';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
const toStringByPicker = (value, picker, timezone: 'gmt' | 'local') => {
if (!dayjs.isDayjs(value)) return value;
if (timezone === 'local') {
const offset = new Date().getTimezoneOffset();
return dayjs(toStringByPicker(value, picker, 'gmt'))
.add(offset, 'minutes')
.toISOString();
}
if (picker === 'year') {
return value.format('YYYY') + '-01-01T00:00:00.000Z';
}
if (picker === 'month') {
return value.format('YYYY-MM') + '-01T00:00:00.000Z';
}
if (picker === 'quarter') {
return value.startOf('quarter').format('YYYY-MM') + '-01T00:00:00.000Z';
}
if (picker === 'week') {
return value.startOf('week').add(1, 'day').format('YYYY-MM-DD') + 'T00:00:00.000Z';
}
return value.format('YYYY-MM-DDTHH:mm:ss.SSS') + 'Z';
};
const toGmtByPicker = (value: Dayjs, picker?: any) => {
if (!value || !dayjs.isDayjs(value)) {
return value;
}
return toStringByPicker(value, picker, 'gmt');
};
const toLocalByPicker = (value: Dayjs, picker?: any) => {
if (!value || !dayjs.isDayjs(value)) {
return value;
}
return toStringByPicker(value, picker, 'local');
};
export interface Moment2strOptions {
showTime?: boolean;
gmt?: boolean;
utc?: boolean;
picker?: 'year' | 'month' | 'week' | 'quarter';
}
export const moment2str = (value?: Dayjs | null, options: Moment2strOptions = {}) => {
const { showTime, gmt, picker, utc = true } = options;
if (!value) {
return value;
}
if (!utc) {
const format = showTime ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD';
return value.format(format);
}
if (showTime) {
return gmt ? toGmt(value) : toLocal(value);
}
if (typeof gmt === 'boolean') {
return gmt ? toGmtByPicker(value, picker) : toLocalByPicker(value, picker);
}
return toGmtByPicker(value, picker);
};
export const mapDatePicker = function () {
return (props: any) => {
const format = getDefaultFormat(props) as any;
const onChange = props.onChange;
return {
...props,
format: format,
value: str2moment(props.value, props),
onChange: (value: Dayjs | null) => {
if (onChange) {
if (!props.showTime && value) {
value = value.startOf('day');
}
onChange(moment2str(value, props));
}
},
};
};
};
export const mapRangePicker = function () {
return (props: any) => {
const format = getDefaultFormat(props) as any;
const onChange = props.onChange;
return {
...props,
format: format,
value: str2moment(props.value, props),
onChange: (value: Dayjs[]) => {
if (onChange) {
onChange(
value
? [moment2str(getRangeStart(value[0], props), props), moment2str(getRangeEnd(value[1], props), props)]
: [],
);
}
},
} as any;
};
};
function getRangeStart(value: Dayjs, options: Moment2strOptions) {
const { showTime } = options;
if (showTime) {
return value;
}
return value.startOf('day');
}
function getRangeEnd(value: Dayjs, options: Moment2strOptions) {
const { showTime } = options;
if (showTime) {
return value;
}
return value.endOf('day');
}
const getStart = (offset: any, unit: any) => {
return dayjs()
.add(offset, unit === 'isoWeek' ? 'week' : unit)
.startOf(unit);
};
const getEnd = (offset: any, unit: any) => {
return dayjs()
.add(offset, unit === 'isoWeek' ? 'week' : unit)
.endOf(unit);
};
export const getDateRanges = () => {
return {
now: () => dayjs().toISOString(),
today: () => [getStart(0, 'day'), getEnd(0, 'day')],
yesterday: () => [getStart(-1, 'day'), getEnd(-1, 'day')],
tomorrow: () => [getStart(1, 'day'), getEnd(1, 'day')],
thisWeek: () => [getStart(0, 'isoWeek'), getEnd(0, 'isoWeek')],
lastWeek: () => [getStart(-1, 'isoWeek'), getEnd(-1, 'isoWeek')],
nextWeek: () => [getStart(1, 'isoWeek'), getEnd(1, 'isoWeek')],
year: (year: number) => [getStart(year - dayjs().year(), 'year'), getEnd(year - dayjs().year(), 'year')],
thisIsoWeek: () => [getStart(0, 'isoWeek'), getEnd(0, 'isoWeek')],
lastIsoWeek: () => [getStart(-1, 'isoWeek'), getEnd(-1, 'isoWeek')],
nextIsoWeek: () => [getStart(1, 'isoWeek'), getEnd(1, 'isoWeek')],
thisMonth: () => [getStart(0, 'month'), getEnd(0, 'month')],
lastMonth: () => [getStart(-1, 'month'), getEnd(-1, 'month')],
nextMonth: () => [getStart(1, 'month'), getEnd(1, 'month')],
thisQuarter: () => [getStart(0, 'quarter'), getEnd(0, 'quarter')],
lastQuarter: () => [getStart(-1, 'quarter'), getEnd(-1, 'quarter')],
nextQuarter: () => [getStart(1, 'quarter'), getEnd(1, 'quarter')],
thisYear: () => [getStart(0, 'year'), getEnd(0, 'year')],
lastYear: () => [getStart(-1, 'year'), getEnd(-1, 'year')],
nextYear: () => [getStart(1, 'year'), getEnd(1, 'year')],
last7Days: () => [getStart(-6, 'days'), getEnd(0, 'days')],
next7Days: () => [getStart(1, 'day'), getEnd(7, 'days')],
last30Days: () => [getStart(-29, 'days'), getEnd(0, 'days')],
next30Days: () => [getStart(1, 'day'), getEnd(30, 'days')],
last90Days: () => [getStart(-89, 'days'), getEnd(0, 'days')],
next90Days: () => [getStart(1, 'day'), getEnd(90, 'days')],
};
};

View File

@ -0,0 +1,45 @@
import { Button, Result, Typography } from 'antd';
import React, { FC } from 'react';
import { FallbackProps, useErrorBoundary } from 'react-error-boundary';
import { Trans, useTranslation } from 'react-i18next';
const { Paragraph, Text, Link } = Typography;
export const ErrorFallback: FC<FallbackProps> = ({ error }) => {
const { resetBoundary } = useErrorBoundary();
const { t } = useTranslation();
const subTitle = (
<Trans>
{'This is likely a NocoBase internals bug. Please open an issue at '}
<Link href="https://github.com/nocobase/nocobase/issues" target="_blank">
here
</Link>
</Trans>
);
return (
<div style={{ backgroundColor: 'white' }}>
<Result
style={{ maxWidth: '60vw', margin: 'auto' }}
status="error"
title={t('Render Failed')}
subTitle={subTitle}
extra={[
<Button type="primary" key="feedback" href="https://github.com/nocobase/nocobase/issues" target="_blank">
{t('Feedback')}
</Button>,
<Button key="try" onClick={resetBoundary}>
{t('Try again')}
</Button>,
]}
>
<Paragraph copyable>
<Text type="danger" style={{ whiteSpace: 'pre-line', textAlign: 'center' }}>
{error.stack}
</Text>
</Paragraph>
</Result>
</div>
);
};

View File

@ -0,0 +1,18 @@
import React from 'react';
import { render, screen } from 'testUtils';
import App1 from '../demos/demo1';
describe('ErrorFallback', () => {
it('should render correctly', () => {
render(<App1 />);
expect(screen.getByText(/render failed/i)).toBeInTheDocument();
expect(screen.getByText(/this is likely a nocobase internals bug\. please open an issue at/i)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /feedback/i })).toBeInTheDocument();
expect(screen.getByText(/try again/i)).toBeInTheDocument();
expect(screen.getByText(/error: error message/i)).toBeInTheDocument();
// 底部复制按钮
expect(document.querySelector('.ant-typography-copy')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,15 @@
import React from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { ErrorFallback } from '../ErrorFallback';
const App = () => {
throw new Error('error message');
};
export default () => {
return (
<ErrorBoundary FallbackComponent={ErrorFallback} onError={console.error}>
<App />
</ErrorBoundary>
);
};

View File

@ -0,0 +1,10 @@
---
group:
title: Schema Components
---
# ErrorFallback
## Example
<code src="./demos/demo1.tsx"></code>

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