feat(data-vi): improved user experiences (refer to pr) (#4013)

* feat(data-vi): improved user experiences (refer to pr)

* feat: enhance transformers

* fix: transformer

* fix: test

* fix: tooltips

* feat: add format

* chore: add locales and tip
This commit is contained in:
YANG QIA 2024-04-12 22:21:15 +08:00 committed by GitHub
parent 91254bdf55
commit 91fdd84ea1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 752 additions and 275 deletions

View File

@ -1,13 +1,12 @@
import * as client from '@nocobase/client';
import { renderHook } from '@testing-library/react';
import { vi } from 'vitest';
import formatters from '../block/formatters';
import transformers from '../block/transformers';
import formatters from '../configure/formatters';
import transformers from '../transformers';
import {
useChartFields,
useFieldsWithAssociation,
useFieldTransformer,
useFieldTypes,
useFormatters,
useOrderFieldsOptions,
useTransformers,
@ -174,41 +173,6 @@ describe('hooks', () => {
expect(field.dataSource).toEqual(formatters.datetime);
});
test('useFieldTypes', () => {
const fields = renderHook(() => useFieldsWithAssociation('main', 'orders')).result.current;
const { result } = renderHook(() => useFieldTypes(fields));
const func = result.current;
let state1 = {};
let state2 = {};
const field = {
dataSource: [],
state: {},
};
const query = (path: string, val: string) => ({
get: () => {
if (path === 'query') {
return { measures: [{ field: ['price'] }, { field: ['name'] }] };
}
return val;
},
});
const field1 = {
query: (path: string) => query(path, 'price'),
setState: (state) => (state1 = state),
...field,
};
const field2 = {
query: (path: string) => query(path, 'name'),
setState: (state) => (state2 = state),
...field,
};
func(field1);
func(field2);
expect(field1.dataSource.map((item) => item.value)).toEqual(Object.keys(transformers));
expect(state1).toEqual({ value: 'number', disabled: true });
expect(state2).toEqual({ value: null, disabled: false });
});
test('useTransformers', () => {
const field = {
query: () => ({
@ -217,7 +181,9 @@ describe('hooks', () => {
dataSource: [],
};
renderHook(() => useTransformers(field));
expect(field.dataSource.map((item) => item.value)).toEqual(Object.keys(transformers['datetime']));
expect(field.dataSource.map((item) => item.value)).toEqual(
Object.keys({ ...transformers['general'], ...transformers['datetime'] }),
);
});
test('useFieldTransformers', () => {

View File

@ -1,54 +0,0 @@
import dayjs from 'dayjs';
export type Transformer = (val: any, locale?: string) => string | number;
const transformers: {
[key: string]: {
[key: string]: Transformer;
};
} = {
datetime: {
YYYY: (val: string) => dayjs(val).format('YYYY'),
MM: (val: string) => dayjs(val).format('MM'),
DD: (val: string) => dayjs(val).format('DD'),
'YYYY-MM': (val: string) => dayjs(val).format('YYYY-MM'),
'YYYY-MM-DD': (val: string) => dayjs(val).format('YYYY-MM-DD'),
'YYYY-MM-DD hh:mm': (val: string) => dayjs(val).format('YYYY-MM-DD hh:mm'),
'YYYY-MM-DD hh:mm:ss': (val: string) => dayjs(val).format('YYYY-MM-DD hh:mm:ss'),
},
date: {
YYYY: (val: string) => dayjs(val).format('YYYY'),
MM: (val: string) => dayjs(val).format('MM'),
DD: (val: string) => dayjs(val).format('DD'),
'YYYY-MM': (val: string) => dayjs(val).format('YYYY-MM'),
'YYYY-MM-DD': (val: string) => dayjs(val).format('YYYY-MM-DD'),
},
time: {
'hh:mm:ss': (val: string) => dayjs(val).format('hh:mm:ss'),
'hh:mm': (val: string) => dayjs(val).format('hh:mm'),
hh: (val: string) => dayjs(val).format('hh'),
},
number: {
Percent: (val: number) =>
new Intl.NumberFormat('en-US', { style: 'percent', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
val,
),
Currency: (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);
},
Exponential: (val: number | string) => (+val)?.toExponential(),
Abbreviation: (val: number, locale = 'en-US') => new Intl.NumberFormat(locale, { notation: 'compact' }).format(val),
},
};
export default transformers;

View File

@ -4,7 +4,7 @@ import { QueryProps } from '../renderer';
import { parseField } from '../utils';
import { ISchema } from '@formily/react';
import configs, { AnySchemaProperties, Config } from './configs';
import { Transformer } from '../block/transformers';
import { Transformer } from '../transformers';
export type RenderProps = {
data: Record<string, any>[];

View File

@ -25,36 +25,49 @@ export class G2PlotChart extends Chart {
};
getProps({ data, general, advanced, fieldProps }: RenderProps) {
const xFieldProps = fieldProps[general.xField];
const yFieldProps = fieldProps[general.yField];
const seriesFieldProps = fieldProps[general.seriesField];
const config = {
legend: {
color: {
itemLabelText: (datnum: { label: string }) => {
const props = fieldProps[general.seriesField];
const transformer = props?.transformer;
const transformer = seriesFieldProps?.transformer;
return transformer ? transformer(datnum.label) : datnum.label;
},
},
},
tooltip: (d, index: number, data, column: any) => {
const field = column.y?.field;
const props = fieldProps[field];
const name = props?.label || field;
const transformer = props?.transformer;
const value = column.y?.value[index];
return { name, value: transformer ? transformer(value) : value };
tooltip: {
title: (data: any) => {
const { [general.xField]: x } = data;
return xFieldProps?.transformer ? xFieldProps.transformer(x) : x;
},
items: [
(data: any) => {
const { [general.xField]: x, [general.yField]: y, [general.seriesField]: series } = data;
let name = '';
if (series) {
name = seriesFieldProps.transformer ? seriesFieldProps.transformer(series) : series;
} else {
name = yFieldProps?.label || general.yField;
}
return {
name,
value: yFieldProps?.transformer ? yFieldProps.transformer(y) : y,
};
},
],
},
axis: {
x: {
labelFormatter: (datnum: any) => {
const props = fieldProps[general.xField];
const transformer = props?.transformer;
const transformer = xFieldProps?.transformer;
return transformer ? transformer(datnum) : datnum;
},
},
y: {
labelFormatter: (datnum: any) => {
const props = fieldProps[general.yField];
const transformer = props?.transformer;
const transformer = yFieldProps?.transformer;
return transformer ? transformer(datnum) : datnum;
},
},

View File

@ -32,16 +32,19 @@ export class Pie extends G2PlotChart {
};
getProps({ data, general, advanced, fieldProps }: RenderProps) {
const angleFieldProps = fieldProps[general.angleField];
const props = super.getProps({ data, general, advanced, fieldProps });
return {
...props,
tooltip: (d, index: number, data, column: any) => {
const field = column.y0.field;
const props = fieldProps[field];
const name = props?.label || field;
const transformer = props?.transformer;
const value = column.y0.value[index];
return { name, value: transformer ? transformer(value) : value };
tooltip: {
items: [
(data: any) => {
const { [general.colorField]: color, [general.angleField]: angle } = data;
const name = color || angleFieldProps?.label || general.angleField;
const transformer = angleFieldProps?.transformer;
return { name, value: transformer ? transformer(angle) : angle };
},
],
},
};
}

View File

@ -30,18 +30,9 @@ export const ChartConfigProvider: React.FC = (props) => {
const { insertAdjacent } = useDesignable();
const [visible, setVisible] = useState(false);
const [current, setCurrent] = useState<ChartConfigCurrent>({} as any);
const { token } = theme.useToken();
return (
<ChartConfigContext.Provider value={{ visible, setVisible, current, setCurrent }}>
<div
className={css`
.ant-card {
border: ${token.lineWidth}px ${token.lineType} ${token.colorBorderSecondary};
}
`}
>
{props.children}
</div>
{props.children}
<ChartRendererProvider {...current.field?.decoratorProps}>
<ChartConfigure insert={(schema, options) => insertAdjacent('beforeEnd', schema, options)} />
</ChartRendererProvider>

View File

@ -2,22 +2,33 @@ import { RightSquareOutlined } from '@ant-design/icons';
import { ArrayItems, Editable, FormCollapse, FormItem, FormLayout, Switch } from '@formily/antd-v5';
import { Form as FormType, ObjectField, createForm, onFieldChange, onFormInit } from '@formily/core';
import { FormConsumer, ISchema, Schema } from '@formily/react';
import { AutoComplete, FormProvider, SchemaComponent, gridRowColWrap, useDesignable } from '@nocobase/client';
import { Alert, App, Button, Card, Col, Modal, Row, Space, Table, Tabs, Typography } from 'antd';
import {
AutoComplete,
FormProvider,
SchemaComponent,
Select,
gridRowColWrap,
useDesignable,
withDynamicSchemaProps,
} from '@nocobase/client';
import { Alert, App, Button, Card, Col, Modal, Row, Space, Table, Tabs, Typography, theme } from 'antd';
import { cloneDeep, isEqual } from 'lodash';
import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react';
import React, { useContext, useEffect, useMemo, useRef } from 'react';
import {
useChartFields,
useCollectionOptions,
useCollectionFieldsOptions,
useCollectionFilterOptions,
useData,
useFieldTypes,
useFieldsWithAssociation,
useFormatters,
useOrderFieldsOptions,
useOrderReaction,
useFieldTypeSelectProps,
useArgument,
useTransformers,
useTransformerSelectProps,
useFieldSelectProps,
} from '../hooks';
import { useChartsTranslation } from '../locale';
import { ChartRenderer, ChartRendererContext } from '../renderer';
@ -27,6 +38,8 @@ import { useChartTypes, useCharts, useDefaultChartType } from '../chart/group';
import { FilterDynamicComponent } from './FilterDynamicComponent';
import { ChartConfigContext } from './ChartConfigProvider';
const { Paragraph, Text } = Typography;
import { css } from '@emotion/css';
import { TransformerDynamicComponent } from './TransformerDynamicComponent';
export type SelectedField = {
field: string | string[];
@ -100,18 +113,21 @@ export const ChartConfigure: React.FC<{
};
const chartType = useDefaultChartType();
const form = useMemo(
() =>
createForm({
() => {
const decoratorProps = initialValues || field?.decoratorProps;
const config = decoratorProps?.config || {};
return createForm({
values: {
config: { chartType },
...(initialValues || field?.decoratorProps),
...decoratorProps,
config: { chartType, ...config, title: config.title || field?.componentProps?.title },
collection: [dataSource, collection],
},
effects: (form) => {
onFieldChange('config.chartType', () => initChart(true));
onFormInit(() => queryReact(form));
},
}),
});
},
// visible, dataSource, collection added here to re-initialize form when visible, dataSource, collection change
// eslint-disable-next-line react-hooks/exhaustive-deps
[field, visible, dataSource, collection],
@ -159,6 +175,7 @@ export const ChartConfigure: React.FC<{
open={visible}
onOk={() => {
const { query, config, transform, mode } = form.values;
const { title, bordered } = config || {};
const afterSave = () => {
setVisible(false);
current.service?.run(dataSource, collection, query);
@ -175,8 +192,18 @@ export const ChartConfigure: React.FC<{
mode: mode || 'builder',
};
if (schema && schema['x-uid']) {
schema['x-component-props'] = {
...schema['x-component-props'],
title,
bordered,
};
schema['x-decorator-props'] = rendererProps;
field.decoratorProps = rendererProps;
field.componentProps = {
...field.componentProps,
title,
bordered,
};
field['x-acl-action'] = `${collection}:list`;
dn.emit('patch', {
schema,
@ -204,10 +231,21 @@ export const ChartConfigure: React.FC<{
});
}}
width={'95%'}
className={css`
.ant-modal-content {
padding: 0;
}
`}
styles={{
header: {
padding: '12px 24px 0px 24px',
},
body: {
background: 'rgba(128, 128, 128, 0.08)',
},
footer: {
padding: '0px 24px 12px 24px',
},
}}
>
<FormProvider form={form}>
@ -216,10 +254,11 @@ export const ChartConfigure: React.FC<{
<Col span={7}>
<Card
style={{
height: 'calc(100vh - 300px)',
height: 'calc(100vh - 288px)',
overflow: 'auto',
margin: '12px 0 12px 12px',
margin: '6px 0 6px 12px',
}}
bodyStyle={{ padding: '0 16px' }}
ref={queryRef}
>
<Tabs
@ -242,10 +281,11 @@ export const ChartConfigure: React.FC<{
<Col span={6}>
<Card
style={{
height: 'calc(100vh - 300px)',
height: 'calc(100vh - 288px)',
overflow: 'auto',
margin: '12px 3px 12px 3px',
margin: '6px 0',
}}
bodyStyle={{ padding: '0 16px 10px 16px' }}
ref={configRef}
>
<Tabs
@ -256,8 +296,8 @@ export const ChartConfigure: React.FC<{
children: <ChartConfigure.Config />,
},
{
label: t('Transform'),
key: 'transform',
label: t('Transformation'),
key: 'transformation',
children: <ChartConfigure.Transform />,
},
]}
@ -265,13 +305,7 @@ export const ChartConfigure: React.FC<{
</Card>
</Col>
<Col span={11}>
<Card
style={{
margin: '12px 12px 12px 0',
}}
>
<ChartConfigure.Renderer />
</Card>
<ChartConfigure.Renderer />
</Col>
</Row>
</FormLayout>
@ -292,9 +326,18 @@ ChartConfigure.Renderer = function Renderer(props) {
const config = cloneDeep(form.values.config);
const transform = cloneDeep(form.values.transform);
return (
<ChartRendererContext.Provider value={{ collection, config, transform, service, data }}>
<ChartRenderer {...props} />
</ChartRendererContext.Provider>
<Card
size="small"
title={form.values.config?.title}
style={{
margin: '6px 12px 6px 0',
}}
bordered={form.values.config?.bordered}
>
<ChartRendererContext.Provider value={{ collection, config, transform, service, data }}>
<ChartRenderer {...props} />
</ChartRendererContext.Provider>
</Card>
);
}}
</FormConsumer>
@ -311,6 +354,7 @@ ChartConfigure.Query = function Query() {
const fieldOptions = useCollectionFieldsOptions(dataSource, collection, 1);
const compiledFieldOptions = Schema.compile(fieldOptions, { t });
const filterOptions = useCollectionFilterOptions(dataSource, collection);
const { token } = theme.useToken();
const { service } = useContext(ChartRendererContext);
const onCollectionChange = (value: string[]) => {
@ -348,6 +392,7 @@ ChartConfigure.Query = function Query() {
onCollectionChange,
collection: current?.collection,
useOrderReaction: useOrderReaction(compiledFieldOptions, fields),
collapsePanelBg: token.colorBgContainer,
}}
components={{ ArrayItems, Editable, FormCollapse, FormItem, Space, Switch, FromSql, FilterDynamicComponent }}
/>
@ -373,6 +418,7 @@ ChartConfigure.Config = function Config() {
</span>
);
};
const formCollapse = FormCollapse.createFormCollapse(['card', 'basic']);
return (
<FormConsumer>
@ -383,8 +429,8 @@ ChartConfigure.Config = function Config() {
return (
<SchemaComponent
schema={getConfigSchema(schema)}
scope={{ t, chartTypes, useChartFields: getChartFields, getReference }}
components={{ FormItem, ArrayItems, Space, AutoComplete }}
scope={{ t, chartTypes, useChartFields: getChartFields, getReference, formCollapse }}
components={{ FormItem, ArrayItems, Space, AutoComplete, FormCollapse }}
/>
);
}}
@ -395,14 +441,41 @@ ChartConfigure.Config = function Config() {
ChartConfigure.Transform = function Transform() {
const { t } = useChartsTranslation();
const fields = useFieldsWithAssociation();
const useFieldTypeOptions = useFieldTypes(fields);
const getChartFields = useChartFields(fields);
return (
<SchemaComponent
schema={transformSchema}
components={{ FormItem, ArrayItems, Space }}
scope={{ useChartFields: getChartFields, useFieldTypeOptions, useTransformers, t }}
/>
<>
<Alert type="info" style={{ marginBottom: '20px' }} message={t('Transformation tip')} closable />
<div
className={css`
.ant-formily-item-feedback-layout-loose {
margin-bottom: 0;
}
.ant-space {
margin-bottom: 15px;
}
`}
>
<SchemaComponent
schema={transformSchema}
components={{
FormItem,
ArrayItems,
Space,
TransformerDynamicComponent,
Select: withDynamicSchemaProps(Select),
}}
scope={{
useChartFields: getChartFields,
useTransformers,
useTransformerSelectProps,
useFieldSelectProps: useFieldSelectProps(fields),
useFieldTypeSelectProps,
useArgument,
t,
}}
/>
</div>
</>
);
};

View File

@ -0,0 +1,41 @@
import { useField } from '@formily/react';
import React from 'react';
import { SchemaComponent } from '@nocobase/client';
import { Field } from '@formily/core';
export const TransformerDynamicComponent: React.FC<{
schema: any;
}> = (props) => {
const { schema } = props;
const field = useField<Field>();
if (!schema) {
return null;
}
return (
<SchemaComponent
schema={{
type: 'void',
properties: {
[schema.name]: {
type: 'void',
...schema,
value: field.value,
'x-component-props': {
...schema['x-component-props'],
defaultValue: field.value,
onChange: (e: any) => {
if (typeof e === 'object' && e?.target) {
field.value = e.target.value;
return;
}
field.value = e;
},
},
},
},
}}
/>
);
};

View File

@ -44,48 +44,97 @@ export const getConfigSchema = (general: any): ISchema => ({
config: {
type: 'object',
properties: {
chartType: {
type: 'string',
title: '{{t("Chart type")}}',
'x-decorator': 'FormItem',
'x-component': 'Select',
collapse: {
type: 'void',
'x-component': 'FormCollapse',
'x-component-props': {
placeholder: '{{t("Please select a chart type.")}}',
formCollapse: '{{formCollapse}}',
size: 'small',
style: {
border: 'none',
boxShadow: 'none',
},
},
enum: '{{ chartTypes }}',
},
[uid()]: {
type: 'void',
properties: {
general,
},
},
[uid()]: {
type: 'void',
properties: {
advanced: {
type: 'json',
title: '{{t("JSON config")}}',
'x-decorator': 'FormItem',
'x-decorator-props': {
extra: lang('Same properties set in the form above will be overwritten by this JSON config.'),
},
'x-component': 'Input.JSON',
pane1: {
type: 'void',
'x-component': 'FormCollapse.CollapsePanel',
'x-component-props': {
autoSize: {
minRows: 3,
header: lang('Container'),
key: 'card',
},
properties: {
title: {
type: 'string',
title: '{{t("Title")}}',
'x-decorator': 'FormItem',
'x-component': 'Input',
},
bordered: {
type: 'boolean',
'x-content': lang('Show border'),
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
},
},
},
},
},
reference: {
type: 'string',
'x-reactions': {
dependencies: ['.chartType'],
fulfill: {
schema: {
'x-content': '{{ getReference($deps[0]) }}',
pane2: {
type: 'void',
'x-component': 'FormCollapse.CollapsePanel',
'x-component-props': {
header: lang('Chart'),
key: 'basic',
style: {
border: 'none',
},
},
properties: {
chartType: {
type: 'string',
title: '{{t("Chart type")}}',
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-component-props': {
placeholder: '{{t("Please select a chart type.")}}',
},
enum: '{{ chartTypes }}',
},
[uid()]: {
type: 'void',
properties: {
general,
},
},
[uid()]: {
type: 'void',
properties: {
advanced: {
type: 'json',
title: '{{t("JSON config")}}',
'x-decorator': 'FormItem',
'x-decorator-props': {
extra: lang('Same properties set in the form above will be overwritten by this JSON config.'),
},
'x-component': 'Input.JSON',
'x-component-props': {
autoSize: {
minRows: 3,
},
},
},
},
},
reference: {
type: 'string',
'x-reactions': {
dependencies: ['.chartType'],
fulfill: {
schema: {
'x-content': '{{ getReference($deps[0]) }}',
},
},
},
},
},
},
},
@ -171,6 +220,11 @@ export const querySchema: ISchema = {
'x-component': 'FormCollapse',
'x-component-props': {
formCollapse: '{{formCollapse}}',
size: 'small',
style: {
border: 'none',
boxShadow: 'none',
},
},
properties: {
pane1: {
@ -326,6 +380,9 @@ export const querySchema: ISchema = {
'x-component-props': {
header: lang('Sort'),
key: 'sort',
style: {
border: 'none',
},
},
properties: {
orders: getArraySchema(
@ -457,47 +514,80 @@ export const querySchema: ISchema = {
export const transformSchema: ISchema = {
type: 'void',
properties: {
transform: getArraySchema(
{
field: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-component-props': {
placeholder: '{{t("Field")}}',
style: {
maxWidth: '100px',
transform: {
type: 'array',
'x-decorator': 'FormItem',
'x-component': 'ArrayItems',
items: {
type: 'object',
properties: {
space: {
type: 'void',
'x-component': 'Space',
'x-component-props': {
wrap: true,
},
properties: {
sort: {
type: 'void',
'x-decorator': 'FormItem',
'x-component': 'ArrayItems.SortHandle',
},
field: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-component-props': {
placeholder: '{{t("Field")}}',
},
'x-use-component-props': 'useFieldSelectProps',
'x-reactions': '{{ useChartFields }}',
},
type: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-component-props': {
placeholder: '{{t("Type")}}',
},
'x-use-component-props': 'useFieldTypeSelectProps',
},
format: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-component-props': {
placeholder: '{{t("Transformer")}}',
style: {
maxWidth: '200px',
},
},
'x-use-component-props': 'useTransformerSelectProps',
'x-reactions': '{{ useTransformers }}',
'x-visible': '{{ $self.dataSource && $self.dataSource.length }}',
},
argument: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'TransformerDynamicComponent',
'x-reactions': '{{ useArgument }}',
},
remove: {
type: 'void',
'x-decorator': 'FormItem',
'x-component': 'ArrayItems.Remove',
},
},
},
'x-reactions': '{{ useChartFields }}',
},
type: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-component-props': {
placeholder: '{{t("Type")}}',
},
'x-reactions': '{{ useFieldTypeOptions }}',
},
format: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-component-props': {
placeholder: '{{t("Format")}}',
},
'x-reactions': '{{ useTransformers }}',
'x-visible': '{{ $self.dataSource && $self.dataSource.length }}',
},
},
{
'x-decorator-props': {
style: {
width: '50%',
},
properties: {
add: {
type: 'void',
title: '{{t("Add transformation")}}',
'x-component': 'ArrayItems.Addition',
},
},
),
},
},
};

View File

@ -103,6 +103,9 @@ const EditOperator = () => {
if (!operatorList.length) {
const names = fieldName.split('.');
const name = names.pop();
if (names.length < 2) {
return null;
}
props = cm.getCollectionField(names.join('.'));
if (!props) {
return null;

View File

@ -12,7 +12,7 @@ import {
import { useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ChartConfigContext } from '../configure';
import formatters from '../block/formatters';
import formatters from '../configure/formatters';
import { useChartsTranslation } from '../locale';
import { ChartRendererContext } from '../renderer';
import { getField, getSelectedFields, parseField, processData } from '../utils';

View File

@ -1,46 +1,85 @@
import transformers from '../block/transformers';
import transformers, { Transformer, TransformerConfig } from '../transformers';
import { lang } from '../locale';
import { ChartRendererProps } from '../renderer';
import { getSelectedFields } from '../utils';
import { FieldOption } from './query';
import { useField } from '@formily/react';
import { Field } from '@formily/core';
import { uid } from '@formily/shared';
/**
* useFieldTypes
* Get field types for using transformers
* Only supported types will be displayed
* Some interfaces and types will be mapped to supported types
*/
export const useFieldTypes = (fields: FieldOption[]) => (field: any) => {
const selectedField = field.query('.field').get('value');
const query = field.query('query').get('value') || {};
const selectedFields = getSelectedFields(fields, query);
const fieldProps = selectedFields.find((field) => field.value === selectedField);
const supports = Object.keys(transformers);
field.dataSource = supports.map((key) => ({
export const useFieldSelectProps = (fields: FieldOption[]) =>
function useFieldSelectProps() {
const field = useField<Field>();
const query = field.query('query').get('value') || {};
const selectedFields = getSelectedFields(fields, query);
const supports = Object.keys(transformers).filter((key) => key !== 'general');
return {
onChange: (value: string) => {
field.value = value;
const typeField = field.query('.type').take() as Field;
if (!value) {
typeField.setState({
value: null,
disabled: true,
});
}
const fieldProps = selectedFields.find((field) => field.value === value);
typeField.dataSource = supports.map((key) => ({
label: lang(key),
value: key,
}));
const map = {
createdAt: 'datetime',
updatedAt: 'datetime',
double: 'number',
integer: 'number',
percent: 'number',
};
const fieldInterface = fieldProps?.interface;
const fieldType = fieldProps?.type;
const key = map[fieldInterface] || map[fieldType] || fieldType;
if (supports.includes(key)) {
typeField.setState({
value: key,
disabled: true,
});
return;
}
typeField.setState({
value: null,
disabled: false,
});
},
};
};
export const useFieldTypeSelectProps = () => {
const field = useField<Field>();
const supports = Object.keys(transformers).filter((key) => key !== 'general');
const options = supports.map((key) => ({
label: lang(key),
value: key,
}));
const map = {
createdAt: 'datetime',
updatedAt: 'datetime',
double: 'number',
integer: 'number',
percent: 'number',
return {
options,
onChange: (value: string) => {
field.value = value;
const transformerField = field.query('.format').take() as Field;
transformerField.setValue(null);
},
};
};
export const useTransformerSelectProps = () => {
const field = useField<Field>();
return {
onChange: (value: string) => {
field.value = value;
const argumentField = field.query('.argument').take() as Field;
argumentField.setValue(null);
},
};
const fieldInterface = fieldProps?.interface;
const fieldType = fieldProps?.type;
const key = map[fieldInterface] || map[fieldType] || fieldType;
if (supports.includes(key)) {
field.setState({
value: key,
disabled: true,
});
return;
}
field.setState({
value: null,
disabled: false,
});
};
export const useTransformers = (field: any) => {
@ -49,22 +88,82 @@ export const useTransformers = (field: any) => {
field.dataSource = [];
return;
}
const options = Object.keys(transformers[selectedType] || {}).map((key) => ({
label: lang(key),
value: key,
}));
const options = Object.entries({ ...transformers.general, ...(transformers[selectedType] || {}) }).map(
([key, config]) => {
const label = typeof config === 'function' ? key : config.label || key;
return {
label: lang(label),
value: key,
};
},
);
field.dataSource = options;
};
export const useArgument = (field: any) => {
const selectedType = field.query('.type').get('value');
const format = field.query('.format').get('value');
if (!format || !selectedType) {
field.setComponentProps({
schema: null,
});
return;
}
const config = transformers[selectedType][format] || transformers['general'][format];
if (!config || typeof config === 'function') {
field.setComponentProps({
schema: null,
});
return;
}
const id = uid();
field.setComponentProps({
schema: {
name: id,
...config.schema,
},
});
};
export const useFieldTransformer = (transform: ChartRendererProps['transform'], locale = 'en-US') => {
return (transform || [])
const transformersMap: {
[field: string]: {
transformer: TransformerConfig;
argument?: string | number;
}[];
} = (transform || [])
.filter((item) => item.field && item.type && item.format)
.reduce((mp, item) => {
const transformer = transformers[item.type][item.format];
const transformer = transformers[item.type][item.format] || transformers.general[item.format];
if (!transformer) {
return mp;
}
mp[item.field] = (val: any) => transformer(val, locale);
mp[item.field] = [...(mp[item.field] || []), { transformer, argument: item.argument }];
return mp;
}, {});
const result = {};
Object.entries(transformersMap).forEach(([field, transformers]) => {
result[field] = transformers.reduce(
(fn: Transformer, config) => {
const { transformer } = config;
let { argument } = config;
return (val) => {
try {
if (typeof transformer === 'function') {
return transformer(fn(val), argument);
}
if (!argument && !transformer.schema) {
argument = locale;
}
return transformer.fn(fn(val), argument);
} catch (e) {
console.log(e);
return val;
}
};
},
(val) => val,
);
});
return result;
};

View File

@ -21,6 +21,7 @@ import { createRendererSchema, getField } from '../utils';
import { ChartRendererContext } from './ChartRendererProvider';
import { useChart } from '../chart/group';
import { ChartDataContext } from '../block/ChartDataProvider';
import { Schema } from '@formily/react';
const { Paragraph, Text } = Typography;
export const ChartRenderer: React.FC & {
@ -50,6 +51,7 @@ export const ChartRenderer: React.FC & {
return props;
}, {}),
});
const compiledProps = Schema.compile(chartProps);
const C = chart?.Component;
if (!chart) {
@ -67,7 +69,7 @@ export const ChartRenderer: React.FC & {
}}
FallbackComponent={ErrorFallback}
>
<C {...chartProps} />
<C {...compiledProps} />
</ErrorBoundary>
</Spin>
);
@ -102,7 +104,7 @@ ChartRenderer.Designer = function Designer() {
>
{t('Duplicate')}
</SchemaSettingsItem>
<SchemaSettingsBlockTitleItem />
{/* <SchemaSettingsBlockTitleItem /> */}
<SchemaSettingsDivider />
<SchemaSettingsRemove
// removeParentsIfNoChildren

View File

@ -30,6 +30,7 @@ export type TransformProps = {
field: string;
type: string;
format: string;
argument?: string | number;
};
export type QueryProps = Partial<{

View File

@ -0,0 +1,232 @@
import dayjs from 'dayjs';
export type Transformer = (val: any, ...args: any[]) => string | number;
export type TransformerConfig =
| Transformer
| {
label?: string;
schema?: any;
fn: Transformer;
};
const transformers: {
[key: string]: {
[key: string]: TransformerConfig;
};
} = {
general: {
Prefix: {
schema: {
'x-component': 'Input',
},
fn: (val: string, prefix: string) => (prefix ? `${prefix}${val}` : val),
},
Suffix: {
schema: {
'x-component': 'Input',
},
fn: (val: string, suffix: string) => (suffix ? `${val}${suffix}` : val),
},
},
datetime: {
Format: {
schema: {
'x-component': 'AutoComplete',
'x-component-props': {
allowClear: true,
style: {
minWidth: 200,
},
},
enum: [
{ label: 'YYYY', value: 'YYYY' },
{ label: 'YYYY-MM', value: 'YYYY-MM' },
{ label: 'YYYY-MM-DD', value: 'YYYY-MM-DD' },
{ label: 'YYYY-MM-DD hh:mm', value: 'YYYY-MM-DD hh:mm' },
{ label: 'YYYY-MM-DD hh:mm:ss', value: 'YYYY-MM-DD hh:mm:ss' },
],
},
fn: (val: string, format: string) => dayjs(val).format(format),
},
YYYY: {
label: 'YYYY (Deprecated, use Format instead)',
fn: (val: string) => dayjs(val).format('YYYY'),
},
MM: {
label: 'MM (Deprecated, use Format instead)',
fn: (val: string) => dayjs(val).format('MM'),
},
DD: {
label: 'DD (Deprecated, use Format instead)',
fn: (val: string) => dayjs(val).format('DD'),
},
'YYYY-MM': {
label: 'YYYY-MM (Deprecated, use Format instead)',
fn: (val: string) => dayjs(val).format('YYYY-MM'),
},
'YYYY-MM-DD': {
label: 'YYYY-MM-DD (Deprecated, use Format instead)',
fn: (val: string) => dayjs(val).format('YYYY-MM-DD'),
},
'YYYY-MM-DD hh:mm': {
label: 'YYYY-MM-DD hh:mm (Deprecated, use Format instead)',
fn: (val: string) => dayjs(val).format('YYYY-MM-DD hh:mm'),
},
'YYYY-MM-DD hh:mm:ss': {
label: 'YYYY-MM-DD hh:mm:ss (Deprecated, use Format instead)',
fn: (val: string) => dayjs(val).format('YYYY-MM-DD hh:mm:ss'),
},
},
date: {
Format: {
schema: {
'x-component': 'AutoComplete',
'x-component-props': {
allowClear: true,
style: {
minWidth: 200,
},
},
enum: [
{ label: 'YYYY', value: 'YYYY' },
{ label: 'MM', value: 'MM' },
{ label: 'DD', value: 'DD' },
{ label: 'YYYY-MM', value: 'YYYY-MM' },
{ label: 'YYYY-MM-DD', value: 'YYYY-MM-DD' },
],
},
fn: (val: string, format: string) => dayjs(val).format(format),
},
YYYY: {
label: 'YYYY (Deprecated, use Format instead)',
fn: (val: string) => dayjs(val).format('YYYY'),
},
MM: {
label: 'MM (Deprecated, use Format instead)',
fn: (val: string) => dayjs(val).format('MM'),
},
DD: {
label: 'DD (Deprecated, use Format instead)',
fn: (val: string) => dayjs(val).format('DD'),
},
'YYYY-MM': {
label: 'YYYY-MM (Deprecated, use Format instead)',
fn: (val: string) => dayjs(val).format('YYYY-MM'),
},
'YYYY-MM-DD': {
label: 'YYYY-MM-DD (Deprecated, use Format instead)',
fn: (val: string) => dayjs(val).format('YYYY-MM-DD'),
},
},
time: {
Format: {
schema: {
'x-component': 'AutoComplete',
'x-component-props': {
allowClear: true,
style: {
minWidth: 200,
},
},
enum: [
{ label: 'hh:mm:ss', value: 'hh:mm:ss' },
{ label: 'hh:mm', value: 'hh:mm' },
{ label: 'hh', value: 'hh' },
],
},
fn: (val: string, format: string) => dayjs(val).format(format),
},
'hh:mm:ss': {
label: 'hh:mm:ss (Deprecated, use Format instead)',
fn: (val: string) => dayjs(val).format('hh:mm:ss'),
},
'hh:mm': {
label: 'hh:mm (Deprecated, use Format instead)',
fn: (val: string) => dayjs(val).format('hh:mm'),
},
hh: {
label: 'hh (Deprecated, use Format instead)',
fn: (val: string) => dayjs(val).format('hh'),
},
},
number: {
Precision: {
schema: {
'x-component': 'Select',
enum: [
{ label: '1', value: 0 },
{ label: '1.0', value: 1 },
{ label: '1.00', value: 2 },
{ label: '1.000', value: 3 },
],
},
fn: (val: number, precision: number) => val.toFixed(precision),
},
Separator: {
schema: {
'x-component': 'Select',
enum: [
{ label: '100,000.00', value: 'en-US' },
{ label: '100.000,00', value: 'de-DE' },
{ label: '100 000.00', value: 'ru-RU' },
],
},
fn: (val: number, separator: string) => {
switch (separator) {
case 'en-US':
return val.toLocaleString('en-US', { maximumFractionDigits: 2 });
case 'de-DE':
return val.toLocaleString('de-DE', { maximumFractionDigits: 2 });
case 'ru-RU':
return val.toLocaleString('ru-RU', { maximumFractionDigits: 2 });
default:
return val;
}
},
},
Percent: (val: number) =>
new Intl.NumberFormat('en-US', { style: 'percent', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
val,
),
Currency: {
label: 'Currency (Deprecated, use Separator & Prefix instead)',
fn: (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] || 'USD';
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(val);
},
},
Exponential: (val: number | string) => (+val)?.toExponential(),
Abbreviation: (val: number, locale = 'en-US') => new Intl.NumberFormat(locale, { notation: 'compact' }).format(val),
},
string: {
'Type conversion': {
schema: {
'x-component': 'Select',
enum: [
{ label: 'Number', value: 'Number' },
{ label: 'Date', value: 'Date' },
],
},
fn: (val: number, targetType: string) => {
try {
return new Function(`return ${targetType}(${val})`)();
} catch (err) {
console.log(err);
return val;
}
},
},
},
};
export default transformers;

View File

@ -6,7 +6,8 @@ import { FieldOption } from './hooks';
import { QueryProps } from './renderer';
export const createRendererSchema = (decoratorProps: any, componentProps = {}) => {
const { collection } = decoratorProps;
const { collection, config } = decoratorProps;
const { title, bordered } = config || {};
return {
type: 'void',
'x-decorator': 'ChartRendererProvider',
@ -16,6 +17,8 @@ export const createRendererSchema = (decoratorProps: any, componentProps = {}) =
'x-component': 'CardItem',
'x-component-props': {
size: 'small',
title,
bordered,
},
'x-initializer': 'charts:addBlock',
properties: {

View File

@ -80,5 +80,12 @@
"Time range": "Time range",
"Edit field properties": "Edit field properties",
"Select a source field to use metadata of the field": "Select a source field to use metadata of the field",
"Original field": "Original field"
"Original field": "Original field",
"Transformation": "Transformation",
"Add transformation": "Add transformation",
"Container": "Container",
"Show border": "Show border",
"Transformation tip": "Fields allow multiple transformations, applied sequentially. Pay attention to data type changes after each transformation. Drag-and-drop functionality enables adjustment of transformation order.",
"Type conversion": "Type conversion",
"Transformer": "Transformer"
}

View File

@ -81,5 +81,12 @@
"Time range": "时间范围",
"Edit field properties": "编辑字段属性",
"Select a source field to use metadata of the field": "选择来源字段可以复用字段的元数据配置",
"Original field": "原始字段"
"Original field": "原始字段",
"Transformation": "数据转换",
"Add transformation": "添加数据转换",
"Container": "容器",
"Show border": "显示边框",
"Transformation tip": "一个字段可以应用多次转换,会按照顺序执行,请注意每次转换后的数据类型,拖动可以调整转换顺序。",
"Type conversion": "类型转换",
"Transformer": "转换方法"
}