feat(data-vi): optimize (#5299)

* refactor(data-vi): add echarts

* fix: echart

* feat: echarts

* chore: add migration

* feat: update

* feat: dark theme

* feat: add configuration

* chore: update

* chore: update

* fix: bug

* chore: update

* chore: update

* fix: test

* fix: build

* chore: update

* chore: locale
This commit is contained in:
YANG QIA 2024-10-08 20:15:00 +08:00 committed by GitHub
parent bcd154453f
commit ce74a77e96
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 515 additions and 329 deletions

View File

@ -19,43 +19,36 @@ describe('api', () => {
plugin.charts = new ChartGroup();
});
test('setGroup', () => {
const charts1 = [new Chart({ name: 'test1', title: 'Test1', Component: null })];
plugin.charts.setGroup('group', charts1);
expect(plugin.charts.charts.get('group')).toEqual(charts1);
const charts2 = [new Chart({ name: 'test2', title: 'Test2', Component: null })];
plugin.charts.setGroup('group', charts2);
expect(plugin.charts.charts.get('group')).toEqual(charts2);
});
test('addGroup', () => {
const charts1 = [new Chart({ name: 'test1', title: 'Test1', Component: null })];
plugin.charts.setGroup('group1', charts1);
plugin.charts.addGroup('group', { title: 'Group', charts: charts1 });
expect(plugin.charts.charts.get('group')).toEqual({ title: 'Group', charts: charts1 });
const charts2 = [new Chart({ name: 'test2', title: 'Test2', Component: null })];
plugin.charts.addGroup('group2', charts2);
expect(plugin.charts.charts.get('group1')).toEqual(charts1);
expect(plugin.charts.charts.get('group2')).toEqual(charts2);
try {
plugin.charts.addGroup('group', { title: 'Group2', charts: charts2 });
} catch (error) {
expect(error.message).toEqual('[data-visualization] Chart group "group" already exists');
}
});
test('add', () => {
const charts1 = [new Chart({ name: 'test1', title: 'Test1', Component: null })];
plugin.charts.setGroup('group', charts1);
plugin.charts.addGroup('group', { title: 'Group', charts: charts1 });
const chart = new Chart({ name: 'test2', title: 'Test2', Component: null });
plugin.charts.add('group', chart);
expect(plugin.charts.charts.get('group').length).toEqual(2);
expect(plugin.charts.charts.get('group')[1].name).toEqual('test2');
expect(plugin.charts.charts.get('group').charts.length).toEqual(2);
expect(plugin.charts.charts.get('group').charts[1].name).toEqual('test2');
});
test('getChartTypes', () => {
const charts1 = [new Chart({ name: 'test1', title: 'Test1', Component: null })];
plugin.charts.setGroup('group1', charts1);
plugin.charts.addGroup('group1', { title: 'Group1', charts: charts1 });
const charts2 = [new Chart({ name: 'test2', title: 'Test2', Component: null })];
plugin.charts.setGroup('group2', charts2);
plugin.charts.addGroup('group2', { title: 'Group2', charts: charts2 });
expect(plugin.charts.getChartTypes()).toEqual([
{
label: 'group1',
label: 'Group1',
children: [
{
key: 'group1.test1',
@ -65,7 +58,7 @@ describe('api', () => {
],
},
{
label: 'group2',
label: 'Group2',
children: [
{
key: 'group2.test2',
@ -79,9 +72,9 @@ describe('api', () => {
test('getCharts', () => {
const charts1 = [new Chart({ name: 'test1', title: 'Test1', Component: null })];
plugin.charts.setGroup('group1', charts1);
plugin.charts.addGroup('group1', { title: 'Group1', charts: charts1 });
const charts2 = [new Chart({ name: 'test2', title: 'Test2', Component: null })];
plugin.charts.setGroup('group2', charts2);
plugin.charts.addGroup('group2', { title: 'Group2', charts: charts2 });
expect(plugin.charts.getCharts()).toEqual({
'group1.test1': charts1[0],
'group2.test2': charts2[0],
@ -90,9 +83,9 @@ describe('api', () => {
test('getChart', () => {
const charts1 = [new Chart({ name: 'test1', title: 'Test1', Component: null })];
plugin.charts.setGroup('group1', charts1);
plugin.charts.addGroup('group1', { title: 'Group1', charts: charts1 });
const charts2 = [new Chart({ name: 'test2', title: 'Test2', Component: null })];
plugin.charts.setGroup('group2', charts2);
plugin.charts.addGroup('group2', { title: 'Group2', charts: charts2 });
expect(plugin.charts.getChart('group1.test1')).toEqual(charts1[0]);
expect(plugin.charts.getChart('group2.test2')).toEqual(charts2[0]);
});

View File

@ -17,10 +17,11 @@ export class Statistic extends AntdChart {
super({
name: 'statistic',
title: 'Statistic',
enableAdvancedConfig: true,
Component: C,
config: [
{
property: 'field',
configType: 'field',
name: 'field',
title: 'Field',
required: true,

View File

@ -13,7 +13,7 @@ import { Table as AntdTable } from 'antd';
export class Table extends AntdChart {
constructor() {
super({ name: 'table', title: 'Table', Component: AntdTable });
super({ name: 'table', title: 'Table', enableAdvancedConfig: true, Component: AntdTable });
}
getProps({ data, fieldProps, general, advanced }: RenderProps) {

View File

@ -7,12 +7,12 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import React, { memo } from 'react';
import { FieldOption } from '../hooks';
import { DimensionProps, MeasureProps, QueryProps } from '../renderer';
import { parseField } from '../utils';
import { ISchema } from '@formily/react';
import configs, { AnySchemaProperties, Config, FieldConfigProps } from './configs';
import configs, { AnySchemaProperties, Config, ConfigType } from './configs';
import { Transformer } from '../transformers';
export type RenderProps = {
@ -31,6 +31,7 @@ export type RenderProps = {
export interface ChartType {
name: string;
title: string;
enableAdvancedConfig?: boolean;
Component: React.FC<any>;
schema: ISchema;
init?: (
@ -53,6 +54,7 @@ export interface ChartType {
export type ChartProps = {
name: string;
title: string;
enableAdvancedConfig?: boolean;
Component: React.FC<any>;
config?: Config[];
};
@ -60,24 +62,26 @@ export type ChartProps = {
export class Chart implements ChartType {
name: string;
title: string;
enableAdvancedConfig = false;
Component: React.FC<any>;
config: Config[];
configs = new Map<string, Function>();
configTypes = new Map<string, ConfigType>();
constructor({ name, title, Component, config }: ChartProps) {
constructor({ name, title, enableAdvancedConfig, Component, config }: ChartProps) {
this.name = name;
this.title = title;
this.Component = Component;
this.Component = memo(Component, (prev, next) => JSON.stringify(prev) === JSON.stringify(next));
this.config = config;
this.addConfigs(configs);
this.enableAdvancedConfig = enableAdvancedConfig || false;
this.addConfigTypes(configs);
}
/*
* Generate config schema according to this.config
* How to set up this.config:
* 1. string - the config function name in config.ts
* 2. object - { property: string, ...props }
* - property is the config function name in config.ts, and the other props are the arguments of the function
* 2. object - { configType: string, ...props }
* - sttingType is the config function name in config.ts, and the other props are the arguments of the function
* 3. object - use the object directly as the properties of the schema
* 4. function - use the custom function to return the properties of the schema
*/
@ -85,35 +89,35 @@ export class Chart implements ChartType {
if (!this.config) {
return {};
}
const properties = this.config.reduce((properties, conf) => {
const properties = this.config.reduce((props, conf) => {
let schema: AnySchemaProperties = {};
if (typeof conf === 'string') {
const func = this.configs.get(conf);
schema = func?.() || {};
} else if (typeof conf === 'function') {
conf = this.configTypes.get(conf);
}
if (typeof conf === 'function') {
schema = conf();
} else {
if (conf.property) {
const func = this.configs.get(conf.property);
if (conf.configType) {
const func = this.configTypes.get(conf.configType as string) as Function;
schema = func?.(conf) || {};
} else {
schema = conf as AnySchemaProperties;
}
}
return {
...properties,
...props,
...schema,
};
}, {} as AnySchemaProperties);
}, {} as any);
return {
type: 'object',
properties,
};
}
addConfigs(configs: { [key: string]: (props: FieldConfigProps) => AnySchemaProperties }) {
addConfigTypes(configs: { [key: string]: ConfigType }) {
Object.entries(configs).forEach(([key, func]) => {
this.configs.set(key, func);
this.configTypes.set(key, func);
});
}

View File

@ -15,48 +15,241 @@ export type FieldConfigProps = Partial<{
title: string;
required: boolean;
defaultValue: any;
description: string;
options: { label: string; value: any }[];
componentProps: Record<string, any>;
}>;
export type AnySchemaProperties = SchemaProperties<any, any, any, any, any, any, any, any>;
export type ConfigProps = FieldConfigProps | AnySchemaProperties | (() => AnySchemaProperties);
export type ConfigType =
| (FieldConfigProps & { configType?: string })
| ((props?: FieldConfigProps) => AnySchemaProperties)
| AnySchemaProperties;
export type Config =
| (ConfigProps & {
property?: string;
})
| string;
export type Config = string | ConfigType;
const selectField = ({ name, title, required, defaultValue }: FieldConfigProps) => {
const field = ({ name, title, required, defaultValue, description }: FieldConfigProps) => {
return {
[name || 'field']: {
title: lang(title || 'Field'),
[name]: {
title: lang(title),
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-reactions': '{{ useChartFields }}',
required,
description,
default: defaultValue,
},
};
};
const booleanField = ({ name, title, defaultValue = false }: FieldConfigProps) => {
const select = ({ name, title, required, defaultValue, options, description }: FieldConfigProps) => {
return {
[name || 'field']: {
'x-content': lang(title || 'Field'),
[name]: {
title: lang(title),
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Select',
required,
default: defaultValue,
description,
enum: options,
},
};
};
const boolean = ({ name, title, defaultValue = false, description }: FieldConfigProps) => {
return {
[name]: {
'x-content': lang(title),
type: 'boolean',
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
default: defaultValue,
description,
},
};
};
const radio = ({ name, title, defaultValue, options, description, componentProps }: FieldConfigProps) => {
return {
[name]: {
title: lang(title),
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Radio.Group',
'x-component-props': {
...componentProps,
},
default: defaultValue,
description,
enum: options,
},
};
};
const percent = ({ name, title, defaultValue, description }: FieldConfigProps) => {
return {
[name]: {
title,
type: 'number',
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
default: defaultValue,
description,
'x-component-props': {
suffix: '%',
},
},
};
};
const input = ({ name, title, required, defaultValue, description }: FieldConfigProps) => {
return {
[name]: {
title: lang(title),
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input',
required,
default: defaultValue,
description,
},
};
};
export default {
field: selectField,
booleanField,
xField: (props: FieldConfigProps) => selectField({ name: 'xField', title: 'xField', required: true, ...props }),
yField: (props: FieldConfigProps) => selectField({ name: 'yField', title: 'yField', required: true, ...props }),
seriesField: (props: FieldConfigProps) => selectField({ name: 'seriesField', title: 'seriesField', ...props }),
colorField: (props: FieldConfigProps) => selectField({ name: 'colorField', title: 'colorField', ...props }),
field,
input,
boolean,
select,
radio,
percent,
xField: {
configType: 'field',
name: 'xField',
title: 'xField',
required: true,
},
yField: {
configType: 'field',
name: 'yField',
title: 'yField',
required: true,
},
seriesField: {
configType: 'field',
name: 'seriesField',
title: 'seriesField',
},
colorField: {
configType: 'field',
name: 'colorField',
title: 'colorField',
required: true,
},
isStack: {
configType: 'boolean',
name: 'isStack',
title: 'isStack',
},
smooth: {
configType: 'boolean',
name: 'smooth',
title: 'smooth',
},
isPercent: {
configType: 'boolean',
name: 'isPercent',
title: 'isPercent',
},
isGroup: {
configType: 'boolean',
name: 'isGroup',
title: 'isGroup',
},
size: () => ({
size: {
title: lang('Size'),
type: 'object',
'x-decorator': 'FormItem',
'x-component': 'Space',
properties: {
type: {
'x-component': 'Select',
'x-component-props': {
allowClear: false,
},
default: 'ratio',
enum: [
{
label: lang('Aspect ratio'),
value: 'ratio',
},
{
label: lang('Fixed height'),
value: 'fixed',
},
],
},
fixed: {
type: 'number',
'x-component': 'InputNumber',
'x-component-props': {
min: 0,
addonAfter: 'px',
},
'x-reactions': [
{
dependencies: ['.type'],
fulfill: {
state: {
visible: "{{$deps[0] === 'fixed'}}",
},
},
},
],
},
ratio: {
type: 'object',
'x-component': 'Space',
'x-reactions': [
{
dependencies: ['.type'],
fulfill: {
state: {
visible: "{{$deps[0] === 'ratio'}}",
},
},
},
],
properties: {
width: {
type: 'number',
'x-component': 'InputNumber',
'x-component-props': {
placeholder: lang('Width'),
min: 1,
},
},
colon: {
type: 'void',
'x-component': 'Text',
'x-component-props': {
children: ':',
},
},
height: {
type: 'number',
'x-component': 'InputNumber',
'x-component-props': {
placeholder: lang('Height'),
min: 1,
},
},
},
},
},
},
}),
};

View File

@ -7,8 +7,8 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { useContext, useEffect, useRef } from 'react';
import { ChartRendererContext } from '../../renderer';
import React from 'react';
import { useSetChartSize } from '../../hooks/chart';
import { useGlobalTheme } from '@nocobase/client';
export const getAntChart = (Component: React.FC<any>) => (props: any) => {
@ -18,31 +18,7 @@ export const getAntChart = (Component: React.FC<any>) => (props: any) => {
if (!fixedHeight && size.type === 'fixed') {
fixedHeight = size.fixed;
}
const { service } = useContext(ChartRendererContext);
const chartRef = useRef(null);
const [height, setHeight] = React.useState<number>(0);
useEffect(() => {
const el = chartRef.current;
if (!el || service.loading === true || fixedHeight) {
return;
}
let ratio = 0;
if (size.type === 'ratio' && size.ratio?.width && size.ratio?.height) {
ratio = size.ratio.height / size.ratio.width;
}
const observer = new ResizeObserver((entries) => {
entries.forEach((entry) => {
if (ratio) {
setHeight(entry.contentRect.width * ratio);
return;
}
setHeight(entry.contentRect.height);
});
});
observer.observe(el);
return () => observer.disconnect();
}, [service.loading, fixedHeight, size.type, size.ratio?.width, size.ratio?.height]);
const chartHeight = fixedHeight || height;
const { chartRef, chartHeight } = useSetChartSize(size, fixedHeight);
return (
<div ref={chartRef} style={chartHeight ? { height: `${chartHeight}px` } : {}}>

View File

@ -1,103 +0,0 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import config, { FieldConfigProps } from '../configs';
const { booleanField } = config;
import { lang } from '../../locale';
export default {
isStack: (props: FieldConfigProps) => booleanField({ name: 'isStack', title: 'isStack', ...props }),
smooth: (props: FieldConfigProps) => booleanField({ name: 'smooth', title: 'smooth', ...props }),
isPercent: (props: FieldConfigProps) => booleanField({ name: 'isPercent', title: 'isPercent', ...props }),
isGroup: (props: FieldConfigProps) => booleanField({ name: 'isGroup', title: 'isGroup', ...props }),
size: () => ({
size: {
title: lang('Size'),
type: 'object',
'x-decorator': 'FormItem',
'x-component': 'Space',
properties: {
type: {
'x-component': 'Select',
'x-component-props': {
allowClear: false,
},
default: 'ratio',
enum: [
{
label: lang('Aspect ratio'),
value: 'ratio',
},
{
label: lang('Fixed height'),
value: 'fixed',
},
],
},
fixed: {
type: 'number',
'x-component': 'InputNumber',
'x-component-props': {
min: 0,
addonAfter: 'px',
},
'x-reactions': [
{
dependencies: ['.type'],
fulfill: {
state: {
visible: "{{$deps[0] === 'fixed'}}",
},
},
},
],
},
ratio: {
type: 'object',
'x-component': 'Space',
'x-reactions': [
{
dependencies: ['.type'],
fulfill: {
state: {
visible: "{{$deps[0] === 'ratio'}}",
},
},
},
],
properties: {
width: {
type: 'number',
'x-component': 'InputNumber',
'x-component-props': {
placeholder: lang('Width'),
min: 1,
},
},
colon: {
type: 'void',
'x-component': 'Text',
'x-component-props': {
children: ':',
},
},
height: {
type: 'number',
'x-component': 'InputNumber',
'x-component-props': {
placeholder: lang('Height'),
min: 1,
},
},
},
},
},
},
}),
};

View File

@ -9,13 +9,12 @@
import { G2PlotChart } from './g2plot';
import { ChartType, RenderProps } from '../chart';
import React from 'react';
import { DualAxes as G2DualAxes } from '@ant-design/plots';
import lodash from 'lodash';
export class DualAxes extends G2PlotChart {
constructor() {
super({ name: 'dualAxes', title: 'Dual Axes Chart', Component: G2DualAxes });
super({ name: 'dualAxes', title: 'Dual axes', Component: G2DualAxes });
this.config = [
'xField',
{

View File

@ -8,7 +8,6 @@
*/
import { Chart, ChartProps, ChartType, RenderProps } from '../chart';
import configs from './configs';
import { getAntChart } from './AntChart';
export class G2PlotChart extends Chart {
@ -16,10 +15,10 @@ export class G2PlotChart extends Chart {
super({
name,
title,
enableAdvancedConfig: true,
Component: getAntChart(Component),
config: ['xField', 'yField', 'seriesField', 'size', ...(config || [])],
});
this.addConfigs(configs);
}
init: ChartType['init'] = (fields, { measures, dimensions }) => {

View File

@ -15,18 +15,20 @@ import { G2PlotChart } from './g2plot';
export default [
new G2PlotChart({
name: 'line',
title: 'Line Chart',
title: 'Line',
Component: Line,
config: ['smooth', 'isStack'],
}),
new G2PlotChart({
name: 'area',
title: 'Area Chart',
title: 'Area',
Component: Area,
config: [
'smooth',
{
property: 'isStack',
configType: 'boolean',
name: 'isStack',
title: 'isStack',
defaultValue: true,
},
'isPercent',
@ -34,17 +36,17 @@ export default [
}),
new G2PlotChart({
name: 'column',
title: 'Column Chart',
title: 'Column',
Component: Column,
config: ['isGroup', 'isStack', 'isPercent'],
}),
new G2PlotChart({
name: 'bar',
title: 'Bar Chart',
title: 'Bar',
Component: Bar,
config: ['isGroup', 'isStack', 'isPercent'],
}),
new Pie(),
new DualAxes(),
new G2PlotChart({ name: 'scatter', title: 'Scatter Chart', Component: Scatter }),
new G2PlotChart({ name: 'scatter', title: 'Scatter', Component: Scatter }),
];

View File

@ -13,16 +13,16 @@ import { ChartType, RenderProps } from '../chart';
export class Pie extends G2PlotChart {
constructor() {
super({ name: 'pie', title: 'Pie Chart', Component: G2Pie });
super({ name: 'pie', title: 'Pie', Component: G2Pie });
this.config = [
{
property: 'field',
configType: 'field',
name: 'angleField',
title: 'angleField',
required: true,
},
{
property: 'field',
configType: 'field',
name: 'colorField',
title: 'colorField',
required: true,

View File

@ -12,28 +12,37 @@ import { ChartType } from './chart';
import DataVisualizationPlugin from '..';
import { lang } from '../locale';
interface Group {
title: string;
charts: ChartType[];
sort?: number;
}
export class ChartGroup {
/**
* @internal
*/
charts: Map<string, ChartType[]> = new Map();
charts: Map<string, Group> = new Map();
setGroup(name: string, charts: ChartType[]) {
this.charts.set(name, charts);
}
addGroup(name: string, charts: ChartType[]) {
addGroup(name: string, group: Group) {
if (this.charts.has(name)) {
throw new Error(`[data-visualization] Chart group "${name}" already exists`);
}
this.setGroup(name, charts);
this.charts.set(name, group);
}
add(group: string, chart: ChartType) {
if (!this.charts.has(group)) {
this.setGroup(group, []);
add(name: string, charts: ChartType | ChartType[]) {
if (!this.charts.has(name)) {
return;
}
this.charts.get(group)?.push(chart);
if (!Array.isArray(charts)) {
charts = [charts];
}
const group = this.charts.get(name);
this.charts.set(name, {
...group,
charts: [...group.charts, ...charts],
});
}
/**
@ -48,23 +57,19 @@ export class ChartGroup {
}[];
}[] {
const result = [];
this.charts.forEach((charts, group) => {
const children = charts.map((chart) => ({
key: `${group}.${chart.name}`,
label: lang(chart.title),
value: `${group}.${chart.name}`,
}));
result.push({
label: lang(group),
children,
Array.from(this.charts.entries())
.sort(([, a], [, b]) => a.sort || 0 - b.sort || 0)
.forEach(([group, { title, charts }]) => {
const children = charts.map((chart) => ({
key: `${group}.${chart.name}`,
label: lang(chart.title),
value: `${group}.${chart.name}`,
}));
result.push({
label: lang(title),
children,
});
});
});
// Put group named "Built-in" at the first
const index = result.findIndex((item) => item.label === lang('Built-in'));
if (index > -1) {
const [item] = result.splice(index, 1);
result.unshift(item);
}
return result;
}
@ -75,7 +80,7 @@ export class ChartGroup {
[key: string]: ChartType;
} {
const result = {};
this.charts.forEach((charts, group) => {
this.charts.forEach(({ charts }, group) => {
charts.forEach((chart) => {
result[`${group}.${chart.name}`] = chart;
});

View File

@ -22,7 +22,7 @@ import {
} 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, { useContext, useEffect, useMemo, useRef } from 'react';
import React, { memo, useContext, useEffect, useMemo, useRef } from 'react';
import {
useChartFields,
useCollectionOptions,
@ -101,6 +101,7 @@ export const ChartConfigure: React.FC<{
const selectedFields = getSelectedFields(fields, query);
const { general, advanced } = chart.init(selectedFields, query);
if (general || overwrite) {
form.setInitialValuesIn('config.general', {});
form.values.config.general = general;
}
if (advanced || overwrite) {
@ -142,7 +143,7 @@ export const ChartConfigure: React.FC<{
[field, visible, dataSource, collection],
);
const RunButton: React.FC = () => (
const RunButton: React.FC = memo(() => (
<Button
type="link"
loading={service?.loading}
@ -165,7 +166,7 @@ export const ChartConfigure: React.FC<{
>
{t('Run query')}
</Button>
);
));
const queryRef = useRef(null);
const configRef = useRef(null);
@ -434,10 +435,11 @@ ChartConfigure.Config = function Config() {
{(form) => {
const chartType = form.values.config?.chartType;
const chart = charts[chartType];
const enableAdvancedConfig = chart?.enableAdvancedConfig;
const schema = chart?.schema || {};
return (
<SchemaComponent
schema={getConfigSchema(schema)}
schema={getConfigSchema(schema, enableAdvancedConfig)}
scope={{ t, chartTypes, useChartFields: getChartFields, getReference, formCollapse }}
components={{ FormItem, ArrayItems, Space, AutoComplete, FormCollapse }}
/>

View File

@ -47,7 +47,7 @@ const getArraySchema = (fields = {}, extra = {}) => ({
},
});
export const getConfigSchema = (general: any): ISchema => ({
export const getConfigSchema = (general: any, enableAdvancedConfig?: boolean): ISchema => ({
type: 'void',
properties: {
config: {
@ -115,37 +115,43 @@ export const getConfigSchema = (general: any): ISchema => ({
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,
...(enableAdvancedConfig
? {
[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,
},
json5: true,
},
},
},
json5: true,
},
},
},
},
reference: {
type: 'string',
'x-reactions': {
dependencies: ['.chartType'],
fulfill: {
schema: {
'x-content': '{{ getReference($deps[0]) }}',
reference: {
type: 'string',
'x-reactions': {
dependencies: ['.chartType'],
fulfill: {
schema: {
'x-content': '{{ getReference($deps[0]) }}',
},
},
},
},
},
},
},
}
: {}),
},
},
},

View File

@ -0,0 +1,51 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useContext, useEffect, useRef, useState } from 'react';
import { ChartRendererContext } from '../renderer';
export const useSetChartSize = (
size: {
type: 'fixed' | 'ratio';
ratio?: {
width: number;
height: number;
};
fixed?: number;
},
fixedHeight?: number,
) => {
const [height, setHeight] = useState<number>(0);
const chartRef = useRef(null);
const { service } = useContext(ChartRendererContext);
useEffect(() => {
const el = chartRef.current;
if (!el || service.loading === true || fixedHeight) {
return;
}
let ratio = 0;
if (size.type === 'ratio' && size.ratio?.width && size.ratio?.height) {
ratio = size.ratio.height / size.ratio.width;
}
const observer = new ResizeObserver((entries) => {
entries.forEach((entry) => {
if (ratio) {
setHeight(entry.contentRect.width * ratio);
return;
}
setHeight(entry.contentRect.height);
});
});
observer.observe(el);
return () => observer.disconnect();
}, [service.loading, fixedHeight, size.type, size.ratio?.width, size.ratio?.height]);
const chartHeight = fixedHeight || height;
return { chartRef, chartHeight };
};

View File

@ -11,3 +11,4 @@ export * from './query';
export * from './transformer';
export * from './useVariableOptions';
export * from './filter';
export * from './chart';

View File

@ -39,7 +39,8 @@ class PluginDataVisualiztionClient extends Plugin {
public charts: ChartGroup = new ChartGroup();
async load() {
this.charts.setGroup('Built-in', [...g2plot, ...antd]);
this.charts.addGroup('antd', { title: 'Ant Design', charts: antd });
this.charts.addGroup('ant-design-charts', { title: 'Ant Design Charts', charts: g2plot });
this.app.addComponents({
ChartV2BlockInitializer,
@ -85,5 +86,6 @@ export default PluginDataVisualiztionClient;
export { Chart } from './chart/chart';
export type { ChartProps, ChartType, RenderProps } from './chart/chart';
export { ChartConfigContext } from './configure';
export { useSetChartSize } from './hooks';
export type { FieldOption } from './hooks';
export type { QueryProps } from './renderer';

View File

@ -9,7 +9,7 @@
import { useAPIClient } from '@nocobase/client';
import { Empty, Result, Spin, Typography } from 'antd';
import React, { useContext } from 'react';
import React, { useContext, useEffect } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { useData, useFieldTransformer, useFieldsWithAssociation } from '../hooks';
import { useChartsTranslation } from '../locale';
@ -18,6 +18,7 @@ import { ChartRendererContext } from './ChartRendererProvider';
import { useChart } from '../chart/group';
import { Schema } from '@formily/react';
import { ChartRendererDesigner } from './ChartRendererDesigner';
import { uid } from '@formily/shared';
const { Paragraph, Text } = Typography;
const ErrorFallback = ({ error }) => {
@ -50,7 +51,21 @@ export const ChartRenderer: React.FC & {
const chart = useChart(config?.chartType);
const locale = api.auth.getLocale();
const transformers = useFieldTransformer(transform, locale);
const chartProps = chart?.getProps({
// error key is used for resetting error boundary when config changes
const [errorKey, setErrorKey] = React.useState(uid());
useEffect(() => {
setErrorKey(uid());
}, [config]);
if (!chart) {
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('Please configure chart')} />;
}
if (!(data && data.length) && !service.loading) {
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('No data')} />;
}
const chartProps = chart.getProps({
data,
general,
advanced,
@ -64,24 +79,18 @@ export const ChartRenderer: React.FC & {
}, {}),
});
const compiledProps = Schema.compile(chartProps);
const C = chart?.Component;
if (!chart) {
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('Please configure chart')} />;
}
if (!(data && data.length) && !service.loading) {
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('No data')} />;
}
const C = chart.Component;
return (
<Spin spinning={service.loading}>
<ErrorBoundary
key={errorKey}
onError={(error) => {
console.error(error);
}}
FallbackComponent={ErrorFallback}
>
<C {...compiledProps} />
{!service.loading && <C {...compiledProps} />}
</ErrorBoundary>
</Spin>
);

View File

@ -34,19 +34,19 @@
"Type": "Type",
"Add field": "Add field",
"Add chart": "Add chart",
"xField": "xField",
"yField": "yField",
"seriesField": "seriesField",
"angleField": "angleField",
"colorField": "colorField",
"Line Chart": "Line Chart",
"Area Chart": "Area Chart",
"Column Chart": "Column Chart",
"Bar Chart": "Bar Chart",
"Pie Chart": "Pie Chart",
"Dual Axes Chart": "Dual Axes Chart",
"Scatter Chart": "Scatter Chart",
"Gauge Chart": "Gauge Chart",
"xField": "X field",
"yField": "Y field",
"seriesField": "Series field",
"angleField": "Angle field",
"colorField": "Color field",
"Line": "Line",
"Area": "Area",
"Column": "Column",
"Bar": "Bar",
"Pie": "Pie",
"Dual axes": "Dual axes",
"Scatter": "Scatter",
"Gauge": "Gauge",
"Statistic": "Statistic",
"Currency": "Currency",
"Percent": "Percent",

View File

@ -39,14 +39,14 @@
"seriesField": "分类字段",
"angleField": "角度字段",
"colorField": "颜色字段",
"Line Chart": "折线图",
"Area Chart": "面积图",
"Column Chart": "柱状图",
"Bar Chart": "条形图",
"Pie Chart": "饼图",
"Dual Axes Chart": "双轴图",
"Scatter Chart": "散点图",
"Gauge Chart": "仪表盘",
"Line": "折线图",
"Area": "面积图",
"Column": "柱状图",
"Bar": "条形图",
"Pie": "饼图",
"Dual axes": "双轴图",
"Scatter": "散点图",
"Gauge": "仪表盘",
"Statistic": "统计",
"Currency": "货币",
"Percent": "百分比",

View File

@ -0,0 +1,43 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Repository } from '@nocobase/database';
import { Migration } from '@nocobase/server';
export default class extends Migration {
on = 'afterLoad'; // 'beforeLoad' or 'afterLoad'
appVersion = '<=1.5.0-beta';
async up() {
const r = this.db.getRepository<Repository>('uiSchemas');
const items = await r.find({
filter: {
'schema.x-decorator': 'ChartRendererProvider',
},
});
await this.db.sequelize.transaction(async (transaction) => {
for (const item of items) {
const schema = item.schema;
const chartType = schema['x-decorator-props']?.config?.chartType;
if (!chartType) {
continue;
}
if (chartType.startsWith('Built-in.')) {
if (chartType === 'Built-in.statistic' || chartType === 'Built-in.table') {
schema['x-decorator-props'].config.chartType = chartType.replace('Built-in', 'antd');
} else {
schema['x-decorator-props'].config.chartType = chartType.replace('Built-in', 'ant-design-charts');
}
item.set('schema', schema);
await item.save({ transaction });
}
}
});
}
}

View File

@ -12975,6 +12975,22 @@ ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer "^5.0.1"
echarts-for-react@^3.0.2:
version "3.0.2"
resolved "https://registry.npmmirror.com/echarts-for-react/-/echarts-for-react-3.0.2.tgz#ac5859157048a1066d4553e34b328abb24f2b7c1"
integrity sha512-DRwIiTzx8JfwPOVgGttDytBqdp5VzCSyMRIxubgU/g2n9y3VLUmF2FK7Icmg/sNVkv4+rktmrLN9w22U2yy3fA==
dependencies:
fast-deep-equal "^3.1.3"
size-sensor "^1.0.1"
echarts@^5.5.0:
version "5.5.0"
resolved "https://registry.npmmirror.com/echarts/-/echarts-5.5.0.tgz#c13945a7f3acdd67c134d8a9ac67e917830113ac"
integrity sha512-rNYnNCzqDAPCr4m/fqyUFv7fD9qIsd50S6GDFgO1DxZhncCsNsG7IfUlAlvZe5oSEQxtsjnHiUuppzccry93Xw==
dependencies:
tslib "2.3.0"
zrender "5.5.0"
editions@^2.2.0:
version "2.3.1"
resolved "https://registry.npmmirror.com/editions/-/editions-2.3.1.tgz#3bc9962f1978e801312fbd0aebfed63b49bfe698"
@ -25213,7 +25229,7 @@ string-convert@^0.2.0:
resolved "https://registry.npmmirror.com/string-convert/-/string-convert-0.2.1.tgz#6982cc3049fbb4cd85f8b24568b9d9bf39eeff97"
integrity sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==
"string-width-cjs@npm:string-width@^4.2.0":
"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -25231,15 +25247,6 @@ string-width@^1.0.1, string-width@^1.0.2:
is-fullwidth-code-point "^1.0.0"
strip-ansi "^3.0.0"
"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^2.0.0, string-width@^2.1.0, string-width@^2.1.1:
version "2.1.1"
resolved "https://registry.npmmirror.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
@ -25354,7 +25361,7 @@ stringify-entities@^4.0.0:
character-entities-html4 "^2.0.0"
character-entities-legacy "^3.0.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@ -25382,13 +25389,6 @@ strip-ansi@^5.1.0:
dependencies:
ansi-regex "^4.1.0"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^7.0.1:
version "7.1.0"
resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
@ -26318,6 +26318,11 @@ tslib@1.9.3:
resolved "https://registry.npmmirror.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286"
integrity sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==
tslib@2.3.0:
version "2.3.0"
resolved "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e"
integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==
tslib@^1.10.0, tslib@^1.11.1, tslib@^1.8.1, tslib@^1.9.0:
version "1.14.1"
resolved "https://registry.npmmirror.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
@ -27783,7 +27788,7 @@ wordwrap@^1.0.0:
resolved "https://registry.npmmirror.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@ -27809,15 +27814,6 @@ wrap-ansi@^6.0.1:
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.0.1, wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
@ -28170,6 +28166,13 @@ zip-stream@^4.1.0:
compress-commons "^4.1.2"
readable-stream "^3.6.0"
zrender@5.5.0:
version "5.5.0"
resolved "https://registry.npmmirror.com/zrender/-/zrender-5.5.0.tgz#54d0d6c4eda81a96d9f60a9cd74dc48ea026bc1e"
integrity sha512-O3MilSi/9mwoovx77m6ROZM7sXShR/O/JIanvzTwjN3FORfLSr81PsUGd7jlaYOeds9d8tw82oP44+3YucVo+w==
dependencies:
tslib "2.3.0"
zustand@^4.4.1:
version "4.4.7"
resolved "https://registry.npmmirror.com/zustand/-/zustand-4.4.7.tgz#355406be6b11ab335f59a66d2cf9815e8f24038c"