feat: add chart plugin (#1477)

* feat: add pie schema template

* chore: refactor

* chore: update

* chore: init server

* feat: add pie schema template

* chore: add chart utils

* chore: update

* chore: update

* chore: update

* chore: update

* feat: mvp

* chore: update

* chore: test

* feat: example

* fix: templates map error

* chore: update

* feat: add ChartBlockEngineDesigner.tsx

* chore: clean code

* chore: update templates

* chore: init bat template

* chore: update chart block engine

* feat: add chart block engine designer

* chore: update chart block initializer

* chore: update pie template

* chore: update

* feat: split sql

* chore: update

* chore: clean

* feat: support bar

* chore: add bar template

* chore: clean code

* chore: clean code

* fix: chart block initializer title

* chore: update

* fix: use title

* chore: update

* fix: bugs

* chore: clean code

* chore: update pie template

* chore: update ChartBlockInitializer.tsx

* chore: update

* feat: support chart config

* chore: add local plugin

* feat: add column template

* feat: add column chart action

* feat: add line chart template

* feat: add line action

* feat: add area template

* feat: add area action

* chore: update pie template

* chore: update

* chore: update dep

* fix: export missing utils

* chore: update dep

* chore: update dep

* chore: update pie template

* fix(charts): sql table capitalization

* fix(charts): improve sql compatibility

* fix: support choices type

* chore: update

* chore: clean code

* chore: get collection fields in chart engine

* feat(demo): chart block initializer

* fix: hello block

* chore: init data analyse plugin

* chore: add data-analyse plugin

* chore: init plugin

* chore: update

* chore: export schema initializers

* feat: add data analyse block

* chore: update data analyse block initializer

* chore: rename plugin name

* chore: update

* chore: update enum

* chore: update add block

* feat: add pie schema template

* chore: refactor

* chore: update

* chore: init server

* feat: add pie schema template

* chore: add chart utils

* chore: update

* chore: update

* chore: update

* chore: update

* feat: mvp

* chore: update

* chore: test

* feat: example

* fix: templates map error

* chore: update

* feat: add ChartBlockEngineDesigner.tsx

* chore: clean code

* chore: update templates

* chore: init bat template

* chore: update chart block engine

* feat: add chart block engine designer

* chore: update chart block initializer

* chore: update pie template

* chore: update

* feat: split sql

* chore: update

* chore: clean

* feat: support bar

* chore: add bar template

* chore: clean code

* chore: clean code

* fix: chart block initializer title

* chore: update

* fix: use title

* chore: update

* fix: bugs

* chore: clean code

* chore: update pie template

* chore: update ChartBlockInitializer.tsx

* chore: update

* feat: support chart config

* chore: add local plugin

* feat: add column template

* feat: add column chart action

* feat: add line chart template

* feat: add line action

* feat: add area template

* feat: add area action

* chore: update pie template

* chore: update

* chore: update dep

* fix: export missing utils

* chore: update dep

* chore: update dep

* chore: update pie template

* fix(charts): sql table capitalization

* fix(charts): improve sql compatibility

* fix: support choices type

* chore: update

* chore: clean code

* chore: get collection fields in chart engine

* chore: init dataset plugin

* chore: init dataset designer

* chore: init dataset block initializer

* chore: update

* chore: update

* chore: update deps

* chore: add tests

* chore: update utils

* chore: init plugin

* feat: add datasets client

* chore: update tests

* chore: update datastructures

* chore: update

* feat: add DataSetBlockInitializer

* chore: use dataset initializer

* chore: update deps

* chore: update

* chore: update pie template

* chore: update ChartBlockInitializer

* chore: update ChartBlockEngine

* chore: remove mdg file

* chore: update ChartBlockEngine

* chore: update

* chore: update g2plot

* chore: update template

* chore: update pie handler

* chore: update DataSetPreviewTable

* chore: update ChartBlockEngine

* chore: update ChartBlockEngineDesigner

* chore: remove useless files

* chore: update ChartBlockInitializer

* chore: update template

* chore: feat area template

* chore: update tests

* chore: update local plugins

* chore: update deps

* chore: update templates

* chore: update template

* feat: add CustomSelect

* chore: update select component

* fix: seriesField color error

* chore: update template

* chore: update

* fix: bar

* chore: update templates & clean code

* chore: update templates

* feat: add FunnelTemplate

* feat: charts queries

* fix: data shift

* feat: add ChartQueryMetadataProvider

* chore: update

* chore: remove dataset

* chore: update

* chore: update

* chore: clean code

* fix: delete query query list wrong behavior

* fix: delete all queries query list behavior

* chore: add empty occupancy

* chore: update description

* chore: update chart icon

* chore: update funnel template

* chore: update template title

* chore: add references

* chore: update dialog height

* fix: scatter template

* chore: update popup description

* refactor: schema level

* refactor: charts schema

* chore: remove console

* chore: remove console

* fix: error message

* chore: remove console

* chore: update DataSetPreviewTable

* feat: chart icons

* feat: update i18n

* feat: update i18n

* chore: update i18n

* chore: update i18n

* chore: update i18n

* feat: add tittle

* chore: update icons

* chore: update i18n

* chore: update i18n

* chore: update i18n

* chore: update i18n

* chore: update i18n

* fix: improve code

* chore: update i18n

* chore: update i18n

* fix: chart icon

* feat: quick add query (#1527)

* feat: quick add query

* feat: query

* chore: remove useless files

* fix: json5 validator error

* chore: update

* chore: update style

* chore: lint

* feat: improve code

* feat: sql validation

* refactor: clean code

* Update utils.ts

* chore: format code

* chore: remove useless file

* chore: update

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
Frozen FIsh 2023-03-04 19:36:58 +08:00 committed by GitHub
parent 555378c342
commit b276ce1258
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
62 changed files with 3004 additions and 124 deletions

View File

@ -0,0 +1 @@
export { default } from '@nocobase/plugin-charts/client';

View File

@ -33,7 +33,7 @@ import {
Venn, Venn,
Violin, Violin,
Waterfall, Waterfall,
WordCloud WordCloud,
} from '@antv/g2plot'; } from '@antv/g2plot';
import { Field } from '@formily/core'; import { Field } from '@formily/core';
import { observer, useField } from '@formily/react'; import { observer, useField } from '@formily/react';
@ -90,7 +90,6 @@ const plots = {
export const G2PlotRenderer = forwardRef(function <O = any>(props: ReactG2PlotProps<O>, ref: any) { export const G2PlotRenderer = forwardRef(function <O = any>(props: ReactG2PlotProps<O>, ref: any) {
const { className, plot, config } = props; const { className, plot, config } = props;
const containerRef = useRef(undefined); const containerRef = useRef(undefined);
const plotRef = useRef(undefined); const plotRef = useRef(undefined);
@ -107,7 +106,7 @@ export const G2PlotRenderer = forwardRef(function <O = any>(props: ReactG2PlotPr
plotRef.current.update(config); plotRef.current.update(config);
} else { } else {
plotRef.current = new plot(containerRef.current, config); plotRef.current = new plot(containerRef.current, config);
plotRef.current.render(); plotRef?.current?.render();
} }
syncRef(plotRef, ref); syncRef(plotRef, ref);
@ -122,7 +121,6 @@ export const G2PlotRenderer = forwardRef(function <O = any>(props: ReactG2PlotPr
useEffect(() => { useEffect(() => {
renderPlot(); renderPlot();
return () => destoryPlot(); return () => destoryPlot();
}, [config, plot]); }, [config, plot]);
@ -154,6 +152,7 @@ export const G2Plot: any = observer((props: any) => {
field.data.loading = false; field.data.loading = false;
} }
}, []); }, []);
if (!plot || !config) { if (!plot || !config) {
return <div style={{ opacity: 0.3 }}>{t('In configuration')}...</div>; return <div style={{ opacity: 0.3 }}>{t('In configuration')}...</div>;
} }

View File

@ -15,6 +15,7 @@ export const Tabs: any = observer((props: TabsProps) => {
return ( return (
<DndContext> <DndContext>
<AntdTabs <AntdTabs
style={props.style}
tabBarExtraContent={{ tabBarExtraContent={{
right: render(), right: render(),
}} }}

View File

@ -191,7 +191,11 @@ SchemaInitializer.Item = (props: SchemaInitializerItemProps) => {
key={item.key} key={item.key}
onClick={(info) => { onClick={(info) => {
item?.clearKeywords?.(); item?.clearKeywords?.();
onClick({ ...info, item }); if (item.onClick) {
item.onClick({ ...info, item });
} else {
onClick({ ...info, item });
}
}} }}
> >
{compile(item.title)} {compile(item.title)}

View File

@ -44,115 +44,6 @@ export const BlockInitializers = {
}, },
], ],
}, },
{
key: 'g2plot',
type: 'itemGroup',
title: '{{t("Chart blocks")}}',
children: [
{
key: 'column',
type: 'item',
title: '{{t("Column chart")}}',
component: 'G2PlotInitializer',
icon: 'BarChartOutlined',
schema: {
type: 'void',
'x-designer': 'G2Plot.Designer',
'x-decorator': 'CardItem',
'x-component': 'G2Plot',
'x-component-props': {
plot: 'Column',
config: chartConfig.column,
},
},
},
{
key: 'bar',
type: 'item',
title: '{{t("Bar chart")}}',
component: 'G2PlotInitializer',
icon: 'BarChartOutlined',
schema: {
type: 'void',
'x-designer': 'G2Plot.Designer',
'x-decorator': 'CardItem',
'x-component': 'G2Plot',
'x-component-props': {
plot: 'Bar',
config: chartConfig.bar,
},
},
},
{
key: 'line',
type: 'item',
title: '{{t("Line chart")}}',
component: 'G2PlotInitializer',
icon: 'LineChartOutlined',
schema: {
type: 'void',
'x-designer': 'G2Plot.Designer',
'x-decorator': 'CardItem',
'x-component': 'G2Plot',
'x-component-props': {
plot: 'Line',
config: chartConfig.line,
},
},
},
{
key: 'pie',
type: 'item',
title: '{{t("Pie chart")}}',
component: 'G2PlotInitializer',
icon: 'PieChartOutlined',
schema: {
type: 'void',
'x-designer': 'G2Plot.Designer',
'x-decorator': 'CardItem',
'x-component': 'G2Plot',
'x-component-props': {
plot: 'Pie',
config: chartConfig.pie,
},
},
},
{
key: 'area',
type: 'item',
title: '{{t("Area chart")}}',
component: 'G2PlotInitializer',
icon: 'AreaChartOutlined',
schema: {
type: 'void',
'x-designer': 'G2Plot.Designer',
'x-decorator': 'CardItem',
'x-component': 'G2Plot',
'x-component-props': {
plot: 'Area',
config: chartConfig.area,
},
},
},
{
key: 'other',
type: 'item',
title: '{{t("Other chart")}}',
component: 'G2PlotInitializer',
icon: 'AreaChartOutlined',
schema: {
type: 'void',
'x-designer': 'G2Plot.Designer',
'x-decorator': 'CardItem',
'x-component': 'G2Plot',
'x-component-props': {
// plot: 'Area',
// config: {},
},
},
},
],
},
{ {
key: 'media', key: 'media',
type: 'itemGroup', type: 'itemGroup',

View File

@ -1,3 +1,8 @@
export * from '../../schema-component/antd/association-filter/ActionBarAssociationFilterAction';
// association filter
export * from '../../schema-component/antd/association-filter/AssociationFilter';
export * from '../../schema-component/antd/association-filter/AssociationFilterDesignerDelete';
export * from '../../schema-component/antd/association-filter/AssociationFilterDesignerDisplayField';
export * from './ActionInitializer'; export * from './ActionInitializer';
export * from './BlockInitializer'; export * from './BlockInitializer';
export * from './BulkDestroyActionInitializer'; export * from './BulkDestroyActionInitializer';
@ -37,8 +42,3 @@ export * from './TableSelectorInitializer';
export * from './UpdateActionInitializer'; export * from './UpdateActionInitializer';
export * from './UpdateSubmitActionInitializer'; export * from './UpdateSubmitActionInitializer';
export * from './ViewActionInitializer'; export * from './ViewActionInitializer';
// association filter
export * from '../../schema-component/antd/association-filter/AssociationFilter';
export * from '../../schema-component/antd/association-filter/ActionBarAssociationFilterAction';
export * from '../../schema-component/antd/association-filter/AssociationFilterDesignerDisplayField';
export * from '../../schema-component/antd/association-filter/AssociationFilterDesignerDelete';

View File

@ -15,7 +15,7 @@ import {
Sequelize, Sequelize,
SyncOptions, SyncOptions,
Transactionable, Transactionable,
Utils, Utils
} from 'sequelize'; } from 'sequelize';
import { SequelizeStorage, Umzug } from 'umzug'; import { SequelizeStorage, Umzug } from 'umzug';
import { Collection, CollectionOptions, RepositoryType } from './collection'; import { Collection, CollectionOptions, RepositoryType } from './collection';
@ -58,7 +58,7 @@ import {
SyncListener, SyncListener,
UpdateListener, UpdateListener,
UpdateWithAssociationsListener, UpdateWithAssociationsListener,
ValidateListener, ValidateListener
} from './types'; } from './types';
import { patchSequelizeQueryInterface, snakeCase } from './utils'; import { patchSequelizeQueryInterface, snakeCase } from './utils';
@ -374,6 +374,10 @@ export class Database extends EventEmitter implements AsyncEmitter {
return dialect.includes(this.sequelize.getDialect()); return dialect.includes(this.sequelize.getDialect());
} }
escapeId(identifier: string) {
return this.inDialect('mysql') ? `\`${identifier}\`` : `"${identifier}"`;
}
/** /**
* Add collection to database * Add collection to database
* @param options * @param options

4
packages/plugins/charts/client.d.ts vendored Executable file
View File

@ -0,0 +1,4 @@
// @ts-nocheck
export * from './lib/client';
export { default } from './lib/client';

View File

@ -0,0 +1,30 @@
"use strict";
function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
var _index = _interopRequireWildcard(require("./lib/client"));
Object.defineProperty(exports, "__esModule", {
value: true
});
var _exportNames = {};
Object.defineProperty(exports, "default", {
enumerable: true,
get: function get() {
return _index.default;
}
});
Object.keys(_index).forEach(function (key) {
if (key === "default" || key === "__esModule") return;
if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
if (key in exports && exports[key] === _index[key]) return;
Object.defineProperty(exports, key, {
enumerable: true,
get: function get() {
return _index[key];
}
});
});

View File

@ -0,0 +1,13 @@
{
"name": "@nocobase/plugin-charts",
"version": "0.9.1-alpha.1",
"main": "lib/server/index.js",
"devDependencies": {
"@nocobase/server": "0.9.1-alpha.1",
"@nocobase/test": "0.9.1-alpha.1",
"@nocobase/client": "^0.9.1-alpha.1"
},
"dependencies": {
"json5": "^2.2.3"
}
}

4
packages/plugins/charts/server.d.ts vendored Executable file
View File

@ -0,0 +1,4 @@
// @ts-nocheck
export * from './lib/server';
export { default } from './lib/server';

View File

@ -0,0 +1,30 @@
"use strict";
function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
var _index = _interopRequireWildcard(require("./lib/server"));
Object.defineProperty(exports, "__esModule", {
value: true
});
var _exportNames = {};
Object.defineProperty(exports, "default", {
enumerable: true,
get: function get() {
return _index.default;
}
});
Object.keys(_index).forEach(function (key) {
if (key === "default" || key === "__esModule") return;
if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
if (key in exports && exports[key] === _index[key]) return;
Object.defineProperty(exports, key, {
enumerable: true,
get: function get() {
return _index[key];
}
});
});

View File

@ -0,0 +1,120 @@
import { useCompile, useRequest } from '@nocobase/client';
import { Empty, Spin } from 'antd';
import JSON5 from 'json5';
import React, { useEffect, useState } from 'react';
import { ChartBlockEngineDesigner } from './ChartBlockEngineDesigner';
import chartRenderComponentsMap from './chartRenderComponents';
import { lang } from './locale';
import { templates } from './templates';
export interface IQueryConfig {
id: number;
}
export interface IChartConfig {
type: string;
template: string;
metric: string;
dimension: string;
category?: string;
[key: string]: any;
}
export interface ChartBlockEngineMetaData {
query: IQueryConfig;
chart: IChartConfig;
}
const ChartRenderComponent = ({
chartBlockEngineMetaData,
}: {
chartBlockEngineMetaData: ChartBlockEngineMetaData;
}): JSX.Element => {
const compile = useCompile();
const chartType = chartBlockEngineMetaData.chart.type;
const renderComponent = templates.get(chartType)?.renderComponent;
const RenderComponent = chartRenderComponentsMap.get(renderComponent); //G2Plot | Echarts | D3 |Table
const chartConfig = chartBlockEngineMetaData.chart;
const { loading, dataSet, error } = useGetDataSet(chartBlockEngineMetaData.query.id);
if (error) {
return (
<>
<Empty description={<span>May be this chart block's query data has been deleted,please check!</span>} />
</>
);
}
const [currentConfig, setCurrentConfig] = useState<IChartConfig>({} as any);
useEffect(() => {
setCurrentConfig(chartConfig);
}, [JSON.stringify(chartConfig)]);
if (currentConfig.type !== chartConfig.type) {
return <></>;
}
switch (renderComponent) {
case 'G2Plot': {
let finalChartOptions;
finalChartOptions = templates.get(chartType)?.defaultChartOptions;
let template;
try {
template = JSON5.parse(chartConfig?.template);
} catch (e) {
template = {};
}
const config = compile(
{
...finalChartOptions,
...template,
data: dataSet,
},
{ ...chartConfig, category: chartConfig?.category ?? '' },
);
if (config && chartConfig) {
const { dimension, metric, category } = chartConfig;
if (!metric || !dimension) {
return <>{lang('Please check the chart config')}</>;
}
}
return <>{loading ? <Spin /> : <RenderComponent plot={chartConfig.type} config={config} />}</>;
}
}
return <></>;
};
export const useGetDataSet = (chartQueryId: number) => {
const { data, loading, error } = useRequest({
url: `/chartsQueries:getData/${chartQueryId}`,
});
const dataSet = data?.data;
return {
loading,
dataSet: dataSet,
error,
};
};
const ChartBlockEngine = ({ chartBlockEngineMetaData }: { chartBlockEngineMetaData: ChartBlockEngineMetaData }) => {
let renderComponent;
const chartType = chartBlockEngineMetaData?.chart?.type;
if (chartType) {
renderComponent = templates.get(chartType)?.renderComponent;
}
if (!chartType || !renderComponent) {
return <>{lang('Please check the chart config')}</>;
}
return (
<>
<ChartRenderComponent chartBlockEngineMetaData={chartBlockEngineMetaData} />
</>
);
};
ChartBlockEngine.Designer = ChartBlockEngineDesigner;
export { ChartBlockEngine };

View File

@ -0,0 +1,238 @@
import { css } from '@emotion/css';
import { FormDialog, FormLayout } from '@formily/antd';
import { SchemaOptionsContext, useField, useFieldSchema } from '@formily/react';
import {
APIClientProvider,
GeneralSchemaDesigner,
i18n,
SchemaComponent,
SchemaComponentOptions,
SchemaSettings,
useAPIClient,
useCompile,
useDesignable,
} from '@nocobase/client';
import { Card } from 'antd';
import JSON5 from 'json5';
import React, { useContext, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChartBlockEngineMetaData } from './ChartBlockEngine';
import { Options } from './ChartBlockInitializer';
import DataSetPreviewTable from './DataSetPreviewTable';
import { useFieldsById } from './hooks';
import { lang } from './locale';
import { templates } from './templates';
export const jsonConfigDesc = (title: string, link: string) => {
return (
<span>
{lang('Json config references: ')}
<a href={link} target="_blank">
{lang(title)}
</a>
</span>
);
};
const validateJSON = {
validator: `{{(value, rule)=> {
if (!value) {
return '';
}
try {
const val = JSON.parse(value);
if(!isNaN(val)) {
return false;
}
return true;
} catch(error) {
console.error(error);
return false;
}
}}}`,
message: '{{t("Invalid JSON format",{ ns: "charts" })}}',
};
export const ChartBlockEngineDesigner = () => {
const fieldSchema = useFieldSchema();
const { chartBlockEngineMetaData } = fieldSchema?.['x-component-props'];
return (
<GeneralSchemaDesigner>
<ChartBlockEngineDesignerInitializer chartBlockEngineMetaData={chartBlockEngineMetaData} />
<SchemaSettings.Divider />
<SchemaSettings.Remove
removeParentsIfNoChildren
breakRemoveOn={{
'x-component': 'Grid',
}}
/>
</GeneralSchemaDesigner>
);
};
export const ChartBlockEngineDesignerInitializer = (props) => {
const { chartBlockEngineMetaData }: { chartBlockEngineMetaData: ChartBlockEngineMetaData } = props;
const { t } = useTranslation();
const options = useContext(SchemaOptionsContext);
const { dn } = useDesignable();
const fieldSchema = useFieldSchema();
const api = useAPIClient();
const field = useField();
const compile = useCompile();
const { chart, query } = chartBlockEngineMetaData;
const { fields } = useFieldsById(query.id);
const dataSource = fields.map((field) => {
return {
label: field.name,
value: field.name,
};
});
return (
<SchemaSettings.Item
onClick={async () => {
FormDialog(
{
okText: compile('{{t("Submit")}}'),
title: lang('Edit chart block'),
width: 1200,
bodyStyle: { background: '#f0f2f5', maxHeight: '65vh', overflow: 'auto' },
},
(form) => {
const [chartBlockEngineMetaData, setChartBlockEngineMetaData] = useState<ChartBlockEngineMetaData>(null);
useEffect(() => {
const chartBlockEngineMetaData = {
query: {
id: query?.id,
},
chart: form.values, //TODO
};
setChartBlockEngineMetaData(chartBlockEngineMetaData);
}, [form.values.type]);
return (
<APIClientProvider apiClient={api}>
<SchemaComponentOptions scope={options.scope} components={{ ...options.components }}>
<section
className={css`
display: flex;
gap: 24px;
`}
>
{/* left*/}
<Card
bordered={false}
title={i18n.t('Chart config')}
size={'default'}
className={css`
flex: 1;
`}
>
<FormLayout layout={'vertical'}>
<SchemaComponent
scope={{ dataSource, JSON5, jsonConfigDesc }}
components={{ Options }}
schema={{
properties: {
// title: {
// title: lang('Chart title'),
// 'x-component': 'Input',
// 'x-decorator': 'FormItem',
// },
type: {
title: t('Chart type'),
required: true,
'x-component': 'CustomSelect',
'x-decorator': 'FormItem',
enum: [...templates.values()].map((template) => {
return {
title: template.title,
key: template.type,
description: template.description,
group: template.group,
iconId: template.iconId,
};
}),
},
options: {
type: 'void',
'x-component': 'Options',
},
},
}}
/>
</FormLayout>
</Card>
{/* right*/}
<div
className={css`
flex: 1;
min-width: 600px;
`}
>
<Card size={'default'} title={lang('Chart preview')}>
{/* Chart Preview*/}
{chartBlockEngineMetaData && (
<>
<SchemaComponent
schema={{
properties: {
chartPreview: {
type: 'void',
'x-decorator': 'CardItem',
'x-component': 'ChartBlockEngine',
'x-component-props': {
chartBlockEngineMetaData: chartBlockEngineMetaData,
},
},
},
}}
/>
</>
)}
</Card>
<Card
size={'default'}
title={lang('Data preview')}
className={css`
margin-top: 24px;
overflow: scroll;
`}
>
{/*Data preview*/}
{chartBlockEngineMetaData?.query?.id && (
<DataSetPreviewTable queryId={chartBlockEngineMetaData?.query?.id} fields={fields} />
)}
</Card>
</div>
</section>
</SchemaComponentOptions>
</APIClientProvider>
);
},
)
.open({
initialValues: { ...chart }, //reset before chartBlockMetaData
})
.then((values) => {
//patch updates
values = {
query,
chart: values,
};
field.title = values.chart.title;
fieldSchema['title'] = values.chart.title;
field.componentProps.chartBlockEngineMetaData = values;
fieldSchema['x-component-props'].chartBlockEngineMetaData = values;
dn.emit('patch', {
schema: {
'x-uid': fieldSchema['x-uid'],
'x-component-props': fieldSchema['x-component-props'],
},
});
dn.refresh();
});
}}
>
{props.children || props.title || lang('Edit chart block')}
</SchemaSettings.Item>
);
};

View File

@ -0,0 +1,216 @@
import { css } from '@emotion/css';
import { FormDialog, FormLayout } from '@formily/antd';
import { Field } from '@formily/core';
import { observer, RecursionField, Schema, SchemaOptionsContext, useField, useForm } from '@formily/react';
import {
APIClientProvider,
FormProvider,
SchemaComponent,
SchemaComponentOptions,
useAPIClient,
useCompile,
} from '@nocobase/client';
import { Card } from 'antd';
import JSON5 from 'json5';
import React, { useContext, useEffect, useState } from 'react';
import { ChartBlockEngineMetaData } from './ChartBlockEngine';
import { jsonConfigDesc } from './ChartBlockEngineDesigner';
import { ChartQueryBlockInitializer, ChartQueryMetadata } from './ChartQueryBlockInitializer';
import DataSetPreviewTable from './DataSetPreviewTable';
import { lang, useChartsTranslation } from './locale';
import { templates } from './templates';
export const Options = observer((props) => {
const form = useForm<ChartFormInterface>();
const field = useField<Field>();
const [s, setSchema] = useState(new Schema({}));
const [chartType, setChartType] = useState(form.values.type);
useEffect(() => {
// form.clearFormGraph('options.*');
setChartType(form?.values?.type);
if (chartType !== form?.values?.type) {
form.clearFormGraph('options.*');
}
if (form.values.type) {
const template = templates.get(form.values.type);
setSchema(new Schema(template.configurableProperties || {}));
}
}, [form.values.type]);
return <RecursionField schema={s} />;
});
interface ChartFormInterface {
type: string;
template: string;
metric: string;
dimension: string;
category?: string;
[key: string]: string;
}
export const ChartBlockInitializer = (props) => {
const { insert } = props;
const options = useContext(SchemaOptionsContext);
const api = useAPIClient();
const compile = useCompile();
const { t } = useChartsTranslation();
return (
<ChartQueryBlockInitializer
{...props}
componentType={'Charts'}
onCreateBlockSchema={async ({ item: chartQueryMetadata }: { item: ChartQueryMetadata }) => {
const dataSource = chartQueryMetadata?.fields.map((field) => {
return {
label: field.name,
value: field.name,
};
});
let values = await FormDialog(
{
okText: compile('{{t("Submit")}}'),
title: lang('Create chart block'),
width: 1200,
bodyStyle: { background: '#f0f2f5', maxHeight: '65vh', overflow: 'auto' },
},
() => {
const form = useForm<ChartFormInterface>();
const [chartBlockEngineMetaData, setChartBlockEngineMetaData] = useState<ChartBlockEngineMetaData>(null);
useEffect(() => {
const chartBlockEngineMetaData = {
query: {
id: chartQueryMetadata?.id,
},
chart: form.values, //TODO
};
setChartBlockEngineMetaData(chartBlockEngineMetaData);
}, [form.values.type]);
return (
<APIClientProvider apiClient={api}>
<SchemaComponentOptions scope={options.scope} components={{ ...options.components }}>
<section
className={css`
display: flex;
gap: 24px;
`}
>
{/* left*/}
<Card
title={lang('Chart config')}
size={'default'}
className={css`
flex: 1;
`}
>
<FormProvider form={form}>
<FormLayout layout={'vertical'}>
<SchemaComponent
scope={{ dataSource, JSON5, jsonConfigDesc }}
components={{ Options }}
schema={{
properties: {
// title: {
// title: lang('Chart title'),
// 'x-component': 'Input',
// 'x-decorator': 'FormItem',
// },
type: {
title: lang('Chart type'),
required: true,
'x-component': 'CustomSelect',
'x-decorator': 'FormItem',
enum: [...templates.values()].map((template) => {
return {
title: template.title,
key: template.type,
description: template.description,
group: template.group,
iconId: template.iconId,
};
}),
},
options: {
type: 'void',
'x-component': 'Options',
},
},
}}
/>
</FormLayout>
</FormProvider>
</Card>
{/* right*/}
<div
className={css`
flex: 1;
min-width: 600px;
`}
>
<Card size={'default'} title={lang('Chart preview')}>
{/* Chart Preview*/}
{chartBlockEngineMetaData && (
<>
<SchemaComponent
schema={{
properties: {
chartPreview: {
type: 'void',
'x-decorator': 'CardItem',
'x-component': 'ChartBlockEngine',
'x-component-props': {
chartBlockEngineMetaData,
},
},
},
}}
/>
</>
)}
</Card>
<Card
size={'default'}
title={lang('Data preview')}
className={css`
margin-top: 24px;
overflow: scroll;
`}
>
{/*Data preview*/}
{chartBlockEngineMetaData?.query?.id && (
<DataSetPreviewTable
queryId={chartBlockEngineMetaData?.query?.id}
fields={chartQueryMetadata?.fields}
/>
)}
</Card>
</div>
</section>
</SchemaComponentOptions>
</APIClientProvider>
);
},
).open({
initialValues: {},
});
if (values) {
const chartBlockEngineMetaData: ChartBlockEngineMetaData = {
query: {
id: chartQueryMetadata.id,
},
chart: values,
};
insert({
type: 'void',
title: values?.title,
'x-designer': 'ChartBlockEngine.Designer',
'x-decorator': 'CardItem',
'x-component': 'ChartBlockEngine',
'x-component-props': {
chartBlockEngineMetaData,
},
});
}
}}
/>
);
};

View File

@ -0,0 +1,136 @@
import { TableOutlined } from '@ant-design/icons';
import { FormDialog, FormLayout } from '@formily/antd';
import { SchemaOptionsContext } from '@formily/react';
import {
SchemaComponent,
SchemaComponentOptions,
SchemaInitializer,
SchemaInitializerButtonContext,
useAPIClient,
} from '@nocobase/client';
import React, { useContext, useEffect, useState } from 'react';
import { useChartQueryMetadataContext } from './ChartQueryMetadataProvider';
import { lang } from './locale';
import { getQueryTypeSchema } from './settings/queryTypes';
export interface ChartQueryMetadata {
id: number;
title: string;
type: string;
fields: { name: string }[];
}
export const ChartQueryBlockInitializer = (props) => {
const defaultItems: any = [
{
type: 'itemGroup',
title: lang('Select query data'),
children: [],
},
];
const { templateWrap, onCreateBlockSchema, componentType, createBlockSchema, insert, ...others } = props;
const { setVisible } = useContext(SchemaInitializerButtonContext);
const [items, setItems] = useState(defaultItems);
const apiClient = useAPIClient();
const ctx = useChartQueryMetadataContext();
const options = useContext(SchemaOptionsContext);
const onAddQuery = (info) => {
FormDialog(
{
sql: lang('Add SQL query'),
json: lang('Add JSON query'),
}[info.key],
() => {
return (
<div>
<SchemaComponentOptions scope={options.scope} components={{ ...options.components }}>
<FormLayout layout={'vertical'}>
<SchemaComponent
schema={{
type: 'object',
properties: {
title: {
title: lang('Title'),
required: true,
'x-component': 'Input',
'x-decorator': 'FormItem',
},
options: getQueryTypeSchema(info.key),
},
}}
/>
</FormLayout>
</SchemaComponentOptions>
</div>
);
},
)
.open({
initialValues: {
type: info.key,
},
})
.then(async (values) => {
try {
const { data } = await apiClient.resource('chartsQueries')?.create?.({ values });
const items = (await ctx.refresh()) as any;
const item = items.find((item) => item.id === data?.data?.id);
onCreateBlockSchema({ item });
setVisible(false);
} catch (error) {}
})
.catch(() => {});
};
useEffect(() => {
const chartQueryMetadata = ctx.data;
if (chartQueryMetadata && Array.isArray(chartQueryMetadata)) {
setItems(
[
chartQueryMetadata.length > 0
? {
type: 'itemGroup',
title: '{{t("Select chart query", {ns: "charts"})}}',
children: chartQueryMetadata,
}
: null,
chartQueryMetadata.length > 0
? {
type: 'divider',
}
: null,
,
{
type: 'subMenu',
title: lang('Add chart query'),
// component: AddChartQuery,
children: [
{
key: 'sql',
type: 'item',
title: 'SQL',
onClick: onAddQuery,
},
{
key: 'json',
type: 'item',
title: 'JSON',
onClick: onAddQuery,
},
],
},
].filter(Boolean),
);
}
}, []);
return (
<SchemaInitializer.Item
icon={<TableOutlined />}
{...others}
onClick={async ({ item }) => {
onCreateBlockSchema({ item });
setVisible(false);
}}
items={items}
/>
);
};

View File

@ -0,0 +1,62 @@
import { useAPIClient, useRequest } from '@nocobase/client';
import { Spin } from 'antd';
import React, { createContext, useContext, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
export const ChartQueryMetadataContext = createContext({
refresh: () => {},
data: [] as any[],
});
export const ChartQueryMetadataProvider: React.FC = (props) => {
const api = useAPIClient();
const options = {
resource: 'chartsQueries',
action: 'listMetadata',
params: {
paginate: false,
sort: ['-id'],
},
};
const location = useLocation();
const service = useRequest(options, {
manual: true,
});
const isAdminPage = location.pathname.startsWith('/admin');
const token = api.auth.getToken() || '';
useEffect(() => {
if (isAdminPage && token) {
service.run();
}
}, [isAdminPage, token]);
const refresh = async () => {
const { data } = await api.request(options);
service.mutate(data);
return data?.data || [];
};
if (service.loading) {
return <Spin />;
}
return (
<ChartQueryMetadataContext.Provider
value={{
refresh,
data: service.data?.data,
}}
>
{props.children}
</ChartQueryMetadataContext.Provider>
);
};
export const useChartQueryMetadataContext = () => {
return useContext(ChartQueryMetadataContext);
};

View File

@ -0,0 +1,73 @@
import { ISchema } from '@formily/react';
import {
BlockSchemaComponentProvider,
FormItem,
Input,
SchemaComponent,
SchemaComponentProvider,
TableV2,
} from '@nocobase/client';
import { Empty, Spin } from 'antd';
import React from 'react';
import { useGetDataSet } from './ChartBlockEngine';
export default ({ queryId, fields }: { queryId: number; fields }) => {
const { dataSet, loading, error } = useGetDataSet(queryId);
const columns = {};
if (fields) {
for (const field of fields) {
columns[field.name] = {
type: 'void',
title: field.name,
'x-component': 'TableV2.Column',
'x-component-props': {
// width: 200,
},
properties: {
[field.name]: {
type: 'string',
'x-component': 'Input',
'x-read-pretty': true,
},
},
};
}
}
const schema: ISchema = {
type: 'void',
properties: {
input: {
type: 'array',
'x-component': 'TableV2',
'x-component-props': {
rowKey: 'id',
scroll: { y: 300 },
},
default: dataSet,
properties: columns,
},
},
};
if (error) {
return (
<>
<Empty description={<span>May be this chart block's query data has been deleted,please check!</span>} />
</>
);
}
if (loading)
return (
<>
<Spin />
</>
);
return (
<SchemaComponentProvider scope={{ dataSet }} components={{ TableV2, Input, FormItem }}>
<BlockSchemaComponentProvider>
<SchemaComponent schema={schema} />
</BlockSchemaComponentProvider>
</SchemaComponentProvider>
);
};

View File

@ -0,0 +1,99 @@
import { Icon } from '@nocobase/client';
import React from 'react';
const RadarChart = () => (
<svg width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false" viewBox="0 0 1024 1024">
<path d="M523 573.5c-3.9 0.7-7.9 1.1-12 1.1-5.3 0-10.4-0.6-15.2-1.8l-62.2 189.7 167.4-76L523 573.5z"></path>
<path d="M398.3 767.3l68.9-210.1c-6.4-6-11.5-13.3-15-21.4l-192.6 52.7L398.3 767.3z"></path>
<path d="M528 448.9c4.1 1.1 8 2.6 11.7 4.5l72.5-61.8L528 263.7 528 448.9z"></path>
<path d="M259.6 555.4 447.3 504c0.4-4.2 1.3-8.3 2.5-12.2L270.9 373.4 259.6 555.4z"></path>
<path d="M655.5 396.7l208.3 185.7 30.2 4.8c4.8-24.6 7.4-49.9 7.4-75.9 0-95.9-34.6-183.7-92-251.6L655.5 396.7z"></path>
<path d="M794.4 563.4 630.9 417.7l-67 57.1c5.6 8.3 9.3 18 10.6 28.4L794.4 563.4z"></path>
<path d="M496 448.4 496 239.6l-218.8 99.6 189.4 125.4C474.7 456.8 484.8 451.1 496 448.4z"></path>
<path d="M619.3 713.3 420 803.8l-19.1 81.6c35 10.3 72.1 15.9 110.4 15.9 74.4 0 143.9-20.8 203-56.9L619.3 713.3z"></path>
<path d="M648.7 699.5l92.3 127.2c63.3-46.2 112.2-111 138.6-186.4L648.7 699.5z"></path>
<path d="M386.2 804 226.1 597.7l-87.9 27.4c34.9 114.7 121.3 207 232.2 250L386.2 804z"></path>
<path d="M226.9 564.3 240.2 353l-64.4-40.5c-34.6 58.2-54.4 126.2-54.4 198.8 0 28.4 3 56.2 8.8 82.9L226.9 564.3z"></path>
<path d="M842.8 609.8l-272.7-74.7c-4 9.5-10.1 17.9-17.9 24.4l77.3 112.1L842.8 609.8z"></path>
<path d="M528 205.4l108.8 165.2 151-134.5c-67-67.3-158.4-110.2-259.7-114.5L528.1 205.4z"></path>
<path d="M245.7 318.4l250.3-114 0-82.8c-125 4.8-234.8 68.5-302.8 164L245.7 318.4z"></path>
</svg>
);
const FunnelChart = () => (
<svg width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false" viewBox="0 0 1024 1024">
<path d="M533.6 784.5c6.6 16.4-1.3 35-17.6 41.7l0 0c-16.4 6.6-35-1.3-41.7-17.6L129.9 236c-6.6-16.4 1.3-35 17.6-41.7l0 0c16.4-6.6 35 1.3 41.7 17.6L533.6 784.5z"></path>
<path d="M536 812.2c-6.9 16.3-25.7 23.9-41.9 17l0 0c-16.3-6.9-23.9-25.7-17-41.9l357.6-575.7c6.9-16.3 25.7-23.9 41.9-17l0 0c16.3 6.9 23.9 25.7 17 41.9L536 812.2z"></path>
<path d="M895.4 224c0 17.7-14.3 32-32 32l-704 0c-17.7 0-32-14.3-32-32l0 0c0-17.7 14.3-32 32-32l704 0C881 192 895.4 206.3 895.4 224L895.4 224z"></path>
<path d="M278.6 384l465 0 0 64-465 0 0-64Z"></path>
<path d="M391.6 576l232.5 0 0 64-232.5 0 0-64Z"></path>
</svg>
);
const ScatterChart = () => (
<svg width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false" viewBox="0 0 1024 1024">
<path d="M191 864c0 17.7-14.3 32-32 32l0 0c-17.7 0-32-14.3-32-32L127 160c0-17.7 14.3-32 32-32l0 0c17.7 0 32 14.3 32 32L191 864z"></path>
<path d="M159 896c-17.7 0-32-14.3-32-32l0 0c0-17.7 14.3-32 32-32l712 0c17.7 0 32 14.3 32 32l0 0c0 17.7-14.3 32-32 32L159 896z"></path>
<path d="M745.6 765.9"></path>
<path d="M307.6 244.3m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z"></path>
<path d="M560 479.2m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z"></path>
<path d="M391.9 387.7m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z"></path>
<path d="M540.4 244.3m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z"></path>
<path d="M732.9 292.3m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z"></path>
<path d="M792.9 717.9m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z"></path>
<path d="M307.6 527.2m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z"></path>
<path d="M439.9 662.4m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z"></path>
</svg>
);
const ColumnChart = () => (
<svg width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false" viewBox="0 0 1024 1024">
<path d="M191 864c0 17.7-14.3 32-32 32l0 0c-17.7 0-32-14.3-32-32L127 160c0-17.7 14.3-32 32-32l0 0c17.7 0 32 14.3 32 32L191 864z"></path>
<path d="M159 896c-17.7 0-32-14.3-32-32l0 0c0-17.7 14.3-32 32-32l712 0c17.7 0 32 14.3 32 32l0 0c0 17.7-14.3 32-32 32L159 896z"></path>
<path d="M745.6 765.9"></path>
<path d="M342.1 734.3c0 17.5-14.2 31.7-31.7 31.7l0 0c-17.5 0-31.7-14.2-31.7-31.7L278.7 489c0-17.5 14.2-31.7 31.7-31.7l0 0c17.5 0 31.7 14.2 31.7 31.7L342.1 734.3z"></path>
<path d="M493.8 734.3c0 17.5-14.2 31.7-31.7 31.7l0 0c-17.5 0-31.7-14.2-31.7-31.7L430.4 387c0-17.5 14.2-31.7 31.7-31.7l0 0c17.5 0 31.7 14.2 31.7 31.7L493.8 734.3z"></path>
<path d="M797.3 734.3c0 17.5-14.2 31.7-31.7 31.7l0 0c-17.5 0-31.7-14.2-31.7-31.7L733.9 234.5c0-17.5 14.2-31.7 31.7-31.7l0 0c17.5 0 31.7 14.2 31.7 31.7L797.3 734.3z"></path>
<path d="M645.5 734.3c0 17.5-14.2 31.7-31.7 31.7l0 0c-17.5 0-31.7-14.2-31.7-31.7l0-97.2c0-17.5 14.2-31.7 31.7-31.7l0 0c17.5 0 31.7 14.2 31.7 31.7L645.5 734.3z"></path>
</svg>
);
const BarChart = () => (
<svg width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false" viewBox="0 0 1024 1024">
<path d="M131.072 156.16c0-17.92 14.336-31.744 31.744-31.744 17.92 0 31.744 14.336 31.744 31.744v712.192c0 17.92-14.336 31.744-31.744 31.744-17.92 0-31.744-14.336-31.744-31.744V156.16zM292.864 338.944c-17.408 0-31.744-14.336-31.744-31.744 0-17.408 14.336-31.744 31.744-31.744h245.248c17.408 0 31.744 14.336 31.744 31.744 0 17.408-14.336 31.744-31.744 31.744h-245.248zM292.864 490.496c-17.408 0-31.744-14.336-31.744-31.744 0-17.408 14.336-31.744 31.744-31.744H640c17.408 0 31.744 14.336 31.744 31.744 0 17.408-14.336 31.744-31.744 31.744h-347.136zM292.864 794.112c-17.408 0-31.744-14.336-31.744-31.744 0-17.408 14.336-31.744 31.744-31.744h499.712c17.408 0 31.744 14.336 31.744 31.744 0 17.408-14.336 31.744-31.744 31.744h-499.712zM292.864 642.56c-17.408 0-31.744-14.336-31.744-31.744 0-17.408 14.336-31.744 31.744-31.744h97.28c17.408 0 31.744 14.336 31.744 31.744 0 17.408-14.336 31.744-31.744 31.744h-97.28z"></path>
</svg>
);
const LineChart = () => (
<svg width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false" viewBox="0 0 1024 1024">
<path d="M191 864c0 17.7-14.3 32-32 32l0 0c-17.7 0-32-14.3-32-32L127 160c0-17.7 14.3-32 32-32l0 0c17.7 0 32 14.3 32 32L191 864z"></path>
<path d="M159 896c-17.7 0-32-14.3-32-32l0 0c0-17.7 14.3-32 32-32l712 0c17.7 0 32 14.3 32 32l0 0c0 17.7-14.3 32-32 32L159 896z"></path>
<path d="M307.1 686.3c-12.4 12.4-32.4 12.4-44.8 0l0 0c-12.4-12.4-12.4-32.4 0-44.8l173.4-173.4c12.4-12.4 32.4-12.4 44.8 0l0 0c12.4 12.4 12.4 32.4 0 44.8L307.1 686.3z"></path>
<path d="M608.8 640c-12.4 12.4-32.4 12.4-44.8 0l0 0c-12.4-12.4-12.4-32.4 0-44.8l212.7-212.7c12.4-12.4 32.4-12.4 44.8 0l0 0c12.4 12.4 12.4 32.4 0 44.8L608.8 640z"></path>
<path d="M608 595.3c12.4 12.4 12.4 32.4 0 44.8l0 0c-12.4 12.4-32.4 12.4-44.8 0L436 512.8c-12.4-12.4-12.4-32.4 0-44.8l0 0c12.4-12.4 32.4-12.4 44.8 0L608 595.3z"></path>
</svg>
);
const PieChart = () => (
<svg width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false" viewBox="0 0 1024 1024">
<path d="M489.7 511 489 511 489 126.9c-201 12.3-360.1 179.2-360.1 383.3 0 212.1 171.9 384 384 384 106.5 0 202.9-43.4 272.5-113.4L489.7 511z"></path>
<path d="M773.1 237.2l6-6c-62.7-59.6-143.5-95.8-228.2-104l0 304.7L773.1 237.2z"></path>
<path d="M819.5 281.6 564.1 505.1 822 739c3-4 5.9-8.1 8.7-12.3 41.4-61.3 65.6-135.2 65.6-214.7C896.3 425.6 867.7 345.8 819.5 281.6z"></path>
</svg>
);
const AreaChart = () => (
<svg width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false" viewBox="0 0 1024 1024">
<path d="M191 864c0 17.7-14.3 32-32 32l0 0c-17.7 0-32-14.3-32-32L127 160c0-17.7 14.3-32 32-32l0 0c17.7 0 32 14.3 32 32L191 864z"></path>
<path d="M159 896c-17.7 0-32-14.3-32-32l0 0c0-17.7 14.3-32 32-32l712 0c17.7 0 32 14.3 32 32l0 0c0 17.7-14.3 32-32 32L159 896z"></path>
<path d="M830.6 338.2 830.6 338.2c0.9-9.2-2.1-18.6-9.1-25.7-12.4-12.4-32.4-12.4-44.8 0L586 503.3 480.8 398.1c-6.2-6.2-14.4-9.3-22.5-9.3-8.1 0-16.3 3.1-22.5 9.3L262.4 571.5c-6 6-9.1 13.8-9.3 21.6 0 0.1 0 0.1 0 0.2l0 111.8c0 35.3 28.7 64 64 64l449.7 0c33.8 0 61.4-26.1 63.8-59.3l0 0 0-0.2c0.1-1.5 0.2-3 0.2-4.5L830.8 593.3c0-0.3-0.1-0.7-0.2-1L830.6 338.2z"></path>
</svg>
);
Icon.register({
'icon-area': (props) => <Icon component={AreaChart} {...props} />,
'icon-pie': (props) => <Icon component={PieChart} {...props} />,
'icon-radar': (props) => <Icon component={RadarChart} {...props} />,
'icon-funnel': (props) => <Icon component={FunnelChart} {...props} />,
'icon-line': (props) => <Icon component={LineChart} {...props} />,
'icon-bar': (props) => <Icon component={BarChart} {...props} />,
'icon-column': (props) => <Icon component={ColumnChart} {...props} />,
'icon-scatter': (props) => <Icon component={ScatterChart} {...props} />,
});

View File

@ -0,0 +1,7 @@
import { G2Plot } from '@nocobase/client';
import DataSetPreviewTable from '../DataSetPreviewTable';
const chartRenderComponentsMap = new Map();
chartRenderComponentsMap.set('G2Plot', G2Plot);
chartRenderComponentsMap.set('DataSetPreviewTable', DataSetPreviewTable);
export default chartRenderComponentsMap;

View File

@ -0,0 +1,19 @@
import { useChartQueryMetadataContext } from '../ChartQueryMetadataProvider';
import { useEffect, useState } from 'react';
const useFieldsById = (queryId: number) => {
const [fields, setFields] = useState([]);
const ctx = useChartQueryMetadataContext();
useEffect(() => {
const chartQueryList = ctx?.data;
if (chartQueryList && Array.isArray(chartQueryList)) {
const currentQuery = chartQueryList.find((chartQuery) => chartQuery.id === queryId);
setFields(currentQuery?.fields);
}
}, [queryId]);
return {
fields,
};
};
export { useFieldsById };

View File

@ -0,0 +1,101 @@
import { registerValidateRules } from '@formily/core';
import {
SchemaComponentOptions,
SchemaInitializerContext,
SettingsCenterProvider,
useAPIClient,
} from '@nocobase/client';
import JSON5 from 'json5';
import React, { useContext } from 'react';
import { ChartBlockEngine } from './ChartBlockEngine';
import { ChartBlockInitializer } from './ChartBlockInitializer';
import { ChartQueryMetadataProvider } from './ChartQueryMetadataProvider';
import './Icons';
import { lang } from './locale';
import { CustomSelect } from './select';
import { QueriesTable } from './settings/QueriesTable';
registerValidateRules({
json5: (value, rule) => {
if (!value) {
return '';
}
try {
const val = JSON5.parse(value);
if (!isNaN(val)) {
return {
type: 'error',
message: lang('Invalid JSON format'),
};
}
return '';
} catch (error) {
console.error(error);
return {
type: 'error',
message: lang('Invalid JSON format'),
};
}
},
});
export default React.memo((props) => {
const api = useAPIClient();
const items = useContext(SchemaInitializerContext);
const children = items.BlockInitializers.items[0].children;
if (children) {
const hasChartItem = children.some((child) => child?.component === 'ChartBlockInitializer');
if (!hasChartItem) {
children.push({
key: 'chart',
type: 'item',
icon: 'PieChartOutlined',
title: '{{t("Chart",{ns:"charts"})}}',
component: 'ChartBlockInitializer',
});
}
}
const validateSQL = (sql) => {
return new Promise((resolve) => {
api
.request({
url: 'chartsQueries:validate',
method: 'post',
data: {
sql,
},
})
.then(({ data }) => {
resolve(data?.data?.errorMessage);
})
.catch(() => {
resolve('Invalid SQL');
});
});
};
return (
<ChartQueryMetadataProvider>
<SettingsCenterProvider
settings={{
charts: {
title: '{{t("Charts", {ns:"charts"})}}',
icon: 'PieChartOutlined',
tabs: {
queries: {
title: '{{t("Queries", {ns:"charts"})}}',
component: QueriesTable,
},
},
},
}}
>
<SchemaComponentOptions
scope={{ validateSQL }}
components={{ CustomSelect, ChartBlockInitializer, ChartBlockEngine }}
>
<SchemaInitializerContext.Provider value={items}>{props.children}</SchemaInitializerContext.Provider>
</SchemaComponentOptions>
</SettingsCenterProvider>
</ChartQueryMetadataProvider>
);
});

View File

@ -0,0 +1,23 @@
export default {
Edit: 'Edit',
Delete: 'Delete',
Cancel: 'Cancel',
Submit: 'Submit',
Actions: 'Actions',
Title: 'Title',
Enable: 'Enable',
'SAML manager': 'SAML manager',
'SAML Providers': 'SAML Providers',
'Redirect url': 'Redirect url',
'SP entity id': 'SP entity id',
'Add provider': 'Add',
'Edit provider': 'Edit',
'Client id': 'Client id',
'Entity id or issuer': 'Entity id or issuer',
'Login Url': 'Login Url',
'Public cert': 'Public cert',
'Delete provider': 'Delete',
'Are you sure you want to delete it?': 'Are you sure you want to delete it?',
'Sign in button name, which will be displayed on the sign in page':
'Sign in button name, which will be displayed on the sign in page',
};

View File

@ -0,0 +1,18 @@
import { i18n } from '@nocobase/client';
import { useTranslation } from 'react-i18next';
export const NAMESPACE = 'charts';
// i18n.addResources('zh-CN', NAMESPACE, zhCN);
// i18n.addResources('en-US', NAMESPACE, enUS);
// i18n.addResources('ja-JP', NAMESPACE, jaJP);
// i18n.addResources('ru-RU', NAMESPACE, ruRU);
// i18n.addResources('tr-TR', NAMESPACE, trTR);
export function lang(key: string) {
return i18n.t(key, { ns: NAMESPACE });
}
export function useChartsTranslation() {
return useTranslation(NAMESPACE);
}

View File

@ -0,0 +1 @@
export default {};

View File

@ -0,0 +1 @@
export default {};

View File

@ -0,0 +1 @@
export default {};

View File

@ -0,0 +1,63 @@
export default {
Edit: '编辑',
Delete: '删除',
Cancel: '取消',
Submit: '提交',
Actions: '操作',
Title: '名称',
Enable: '启用',
Chart: '图表',
Charts: '图表',
Queries: '查询列表',
'Select chart query': '选择图表数据',
'Select query data': '选择查询数据',
Type: '类型',
'Chart type': '图表类型',
'Chart title': '图表标题',
'Basic charts': '基础图表',
'More charts': '更多图表',
'Chart config': '图表配置',
'Add query': '添加查询',
'Edit query': '编辑查询',
'Invalid JSON format,must be an object array.': '无效的JSON格式必须是对象数组。',
Area: '面积图',
Bar: '条形图',
Column: '柱状图',
Funnel: '漏斗图',
Line: '折线图',
Pie: '饼图',
Radar: '雷达图',
Scatter: '散点图',
'Edit chart block': '编辑图表区块',
'Chart preview': '图表预览',
'Delete queries': '删除查询列表',
'Delete query': '删除查询',
'Add chart query': '添加图表查询',
'Add SQL query': '添加SQL查询',
'Add JSON query': '添加JSON查询',
'Data preview': '数据预览',
'Category axis / Dimension': '类别轴 / 纬度',
'Value axis / Metrics': '值轴 / 度量',
'JSON config': 'JSON 配置',
'Json config references': 'JSON配置参考',
'Create chart block': '创建图表区块',
'Invalid JSON format': '无效的JSON格式',
'Json config references: ': 'JSON配置参考: ',
'Sector Angle / Metric': '扇形角 / 度量',
'Sector label / Dimensional': '扇形标签 / 纬度',
'Color legend / Dimensional': '颜色系列 / 纬度',
'Funnel Layer Width/Metrics': '漏斗层宽度 / 度量',
'Branch Tags/Dimensions': '分支标签 / 纬度',
'Branch Length/Metrics': '分支长度 / 度量',
'Please check the chart config': '请检查图表配置',
'1 「time」or 「Ordered Noun」 field,1 「Numerical」 field,1 「Unordered Noun」 field (optional)':
'1 个「时间」或「有序名词」字段,1 个「数值」字段,1 个「无序名词」字段(可选)',
'1 「time」 or 「ordered noun」 field, 1 「value」 field, 0~ 1 「unordered noun」':
'1 个「时间」或「有序名词」字段,1 个「数值」字段,0 1 个「无序名词」',
'1 「time」 or 「ordered noun」 field, 1 「value」 field, 0 to 1 「unordered noun」':
'1 个「时间」或「有序名词」字段,1 个「数值」字段,0 1 个「无序名词」',
'1 「Unordered Noun」 field, 1 「Numeric」 field': '1 个「无序名词」字段,1 个「数值」字段',
'1 「Time」 or 「Order Noun」 field, 1 「Value」 field': '1 个「时间」或「有序名词」字段,1 个「数值」字段',
'1~ 2 「Unordered Noun」 fields, 1 「Numeric」 field': '1 ~ 2 个「无序名词」字段,1 个「数值」字段',
'1 「Numeric」 field, 0~ 1 「Unordered Noun」 field': '1 个「数值」字段,0 ~ 1 个「无序名词」字段',
};

View File

@ -0,0 +1,127 @@
import { LoadingOutlined } from '@ant-design/icons';
import { css } from '@emotion/css';
import { connect, mapProps, mapReadPretty } from '@formily/react';
import { isValid } from '@formily/shared';
import { Icon } from '@nocobase/client';
import type { SelectProps } from 'antd';
import { Popover, Select as AntdSelect } from 'antd';
import React from 'react';
import { lang } from '../locale';
import { ReadPretty } from './ReadPretty';
type Props = SelectProps<any, any> & { objectValue?: boolean; onChange?: (v: any) => void; multiple: boolean };
const isEmptyObject = (val: any) => !isValid(val) || (typeof val === 'object' && Object.keys(val).length === 0);
const { Option, OptGroup } = AntdSelect;
const filterOption = (input, option) => (option?.label ?? '').toLowerCase().includes((input || '').toLowerCase());
const InternalSelect = connect(
(props: Props) => {
const { ...others } = props;
const { options, ...othersProps } = { ...others };
const mode = props.mode || props.multiple ? 'multiple' : undefined;
const group1 = options.filter((option) => option.group === 2);
const group2 = options.filter((option) => option.group === 1);
return (
<AntdSelect
showSearch
filterOption={filterOption}
allowClear
{...othersProps}
onChange={(changed) => {
props.onChange?.(changed === undefined ? null : changed);
}}
mode={mode}
>
<OptGroup label={lang('Basic charts')}>
{group1.map((option) => (
<Option value={option.key} label={lang(option.title)}>
<Popover
placement={'right'}
zIndex={99999999999}
content={() => (
<span>
{lang(option?.description)
?.split(',')
.map((item) => (
<div>{item}</div>
))}
</span>
)}
trigger="hover"
>
<div
className={css`
display: flex;
gap: 4px;
align-items: center;
`}
>
<Icon type={option.iconId} />
<span role="img" aria-label={lang(option.title)}>
{lang(option.title)}
</span>
</div>
</Popover>
</Option>
))}
</OptGroup>
<OptGroup label={lang('More charts')}>
{group2.map((option) => (
<Option value={option.key} label={lang(option.title)}>
<Popover
placement={'right'}
zIndex={99999999999}
content={() => (
<span>
{lang(option?.description)
?.split(',')
.map((item) => (
<div>{item}</div>
))}
</span>
)}
trigger="hover"
>
<div
className={css`
display: flex;
gap: 4px;
align-items: center;
`}
>
<Icon type={option.iconId} />
<span role="img" aria-label={lang(option.title)}>
{lang(option.title)}
</span>
</div>
</Popover>
</Option>
))}
</OptGroup>
</AntdSelect>
);
},
mapProps(
{
dataSource: 'options',
loading: true,
},
(props, field) => {
return {
...props,
suffixIcon: field?.['loading'] || field?.['validating'] ? <LoadingOutlined /> : props?.suffixIcon,
};
},
),
mapReadPretty(ReadPretty),
);
export const CustomSelect = InternalSelect as unknown as typeof InternalSelect & {
ReadPretty: typeof ReadPretty;
};
CustomSelect.ReadPretty = ReadPretty;
export default CustomSelect;

View File

@ -0,0 +1,36 @@
import { isArrayField } from '@formily/core';
import { observer, useField } from '@formily/react';
import { isValid } from '@formily/shared';
import { Tag } from 'antd';
import React from 'react';
import { defaultFieldNames, getCurrentOptions } from './shared';
import { useCompile } from '@nocobase/client';
type Composed = {
Select?: React.FC<any>;
Object?: React.FC<any>;
};
export const ReadPretty = observer((props: any) => {
const fieldNames = { ...defaultFieldNames, ...props.fieldNames };
const field = useField<any>();
const compile = useCompile();
if (!isValid(props.value)) {
return <div />;
}
if (isArrayField(field) && field?.value?.length === 0) {
return <div />;
}
const dataSource = field.dataSource || props.options || [];
const options = getCurrentOptions(field.value, dataSource, fieldNames);
return (
<div>
{options.map((option, key) => (
<Tag key={key} color={option[fieldNames.color]} icon={option.icon}>
{compile(option[fieldNames.label])}
</Tag>
))}
</div>
);
});

View File

@ -0,0 +1,38 @@
---
nav:
path: /client
group:
path: /schema-components
---
# Select
## Examples
### 单选
<code src="./demos/demo1.tsx" />
### 多选
<code src="./demos/demo2.tsx" />
### 值为 Object 类型的 Select
<code src="./demos/demo3.tsx" />
## API
基于 Ant Design 的 [Select](https://ant.design/components/select/#API),相关扩展属性有:
- `objectValue` 值为 object 类型
- `fieldNames` 默认值有区别
```ts
export const defaultFieldNames = {
label: 'label',
value: 'value',
color: 'color',
options: 'children',
};
```

View File

@ -0,0 +1,2 @@
export * from './CustomSelect';
export * from './shared';

View File

@ -0,0 +1,36 @@
import { castArray } from 'lodash';
export const defaultFieldNames = {
label: 'label',
value: 'value',
color: 'color',
options: 'children',
};
export const getCurrentOptions = (values, dataSource, fieldNames) => {
function flatData(data) {
let newArr = [];
for (let i = 0; i < data.length; i++) {
const children = data[i][fieldNames.options];
if (Array.isArray(children)) {
newArr.push(...flatData(children));
}
newArr.push({ ...data[i] });
}
return newArr;
}
const result = flatData(dataSource);
values = castArray(values)
.filter((item) => item != null)
.map((val) => (typeof val === 'object' ? val[fieldNames.value] : val));
const findOptions = (options: any[]) => {
if (!options) return [];
let current = [];
for (const value of values) {
const option = options.find((v) => v[fieldNames.value] === value) || { value: value, label: value };
current.push(option);
}
return current;
};
return findOptions(result);
};

View File

@ -0,0 +1,161 @@
import { DownOutlined, PlusOutlined } from '@ant-design/icons';
import { createForm } from '@formily/core';
import { ISchema, useForm } from '@formily/react';
import { uid } from '@formily/shared';
import {
ActionContext,
SchemaComponent,
useActionContext,
useRecord,
useResourceActionContext,
useResourceContext,
} from '@nocobase/client';
import { Button, Dropdown, Menu } from 'antd';
import React, { useMemo, useState } from 'react';
import { useChartQueryMetadataContext } from '../ChartQueryMetadataProvider';
import { getQueryTypeSchema } from './queryTypes';
import { lang } from '../locale';
const useCreateAction = () => {
const { setVisible } = useActionContext();
const form = useForm();
const { refresh } = useResourceActionContext();
const { resource } = useResourceContext();
const ctx = useChartQueryMetadataContext();
return {
async run() {
await form.submit();
await resource.create({ values: form.values });
setVisible(false);
await form.reset();
refresh();
ctx.refresh();
},
};
};
const useUpdateAction = () => {
const { setVisible } = useActionContext();
const form = useForm();
const { refresh } = useResourceActionContext();
const { resource, targetKey } = useResourceContext();
const { [targetKey]: filterByTk } = useRecord();
const ctx = useChartQueryMetadataContext();
return {
async run() {
await form.submit();
await resource.update({ filterByTk, values: form.values });
setVisible(false);
await form.reset();
refresh();
ctx.refresh();
},
};
};
const useCloseAction = () => {
const { setVisible } = useActionContext();
return {
async run() {
setVisible(false);
},
};
};
const getSchema = (initialValue, { form, isNewRecord }) => {
const type = initialValue.type;
const schema: ISchema = {
type: 'void',
name: uid(),
'x-component': 'Action.Drawer',
'x-decorator': 'Form',
'x-decorator-props': {
form,
// initialValue: JSON.parse(JSON.stringify(initialValue)),
},
title: isNewRecord ? lang('Add query') : lang('Edit query'),
properties: {
title: {
title: lang('Title'),
required: true,
'x-component': 'Input',
'x-decorator': 'FormItem',
},
options: getQueryTypeSchema(type),
footer: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
cancel: {
'x-component': 'Action',
title: lang('Cancel'),
'x-component-props': {
useAction: '{{ useCloseAction }}',
},
},
submit: {
'x-component': 'Action',
title: lang('Submit'),
'x-component-props': {
type: 'primary',
useAction: '{{ useSubmitAction }}',
},
},
},
},
},
};
return schema;
};
export const AddNewQuery = () => {
const [visible, setVisible] = useState(false);
const [schema, setSchema] = useState({});
const form = useMemo(() => createForm(), []);
const menu = (
<Menu
onClick={(info) => {
setVisible(true);
form.setValues({ type: info.key });
setSchema(getSchema({ type: info.key }, { form, isNewRecord: true }));
}}
>
<Menu.Item key={'json'}>JSON</Menu.Item>
<Menu.Item key={'sql'}>SQL</Menu.Item>
<Menu.Item disabled key={'api'}>
API
</Menu.Item>
<Menu.Item disabled>Collection</Menu.Item>
</Menu>
);
return (
<ActionContext.Provider value={{ visible, setVisible }}>
<Dropdown overlay={menu}>
<Button icon={<PlusOutlined />} type={'primary'}>
{lang('Add query')} <DownOutlined />
</Button>
</Dropdown>
<SchemaComponent schema={schema} scope={{ useCloseAction, useSubmitAction: useCreateAction }} />
</ActionContext.Provider>
);
};
export const EditQuery = () => {
const [visible, setVisible] = useState(false);
const record = useRecord();
const form = useMemo(() => createForm(), []);
const schema = getSchema(record, { form, isNewRecord: false });
return (
<ActionContext.Provider value={{ visible, setVisible }}>
<a
onClick={() => {
form.setValues(record);
setVisible(true);
}}
>
{lang('Edit')}
</a>
<SchemaComponent schema={schema} scope={{ useCloseAction, useSubmitAction: useUpdateAction }} />
</ActionContext.Provider>
);
};

View File

@ -0,0 +1,18 @@
import { useRecord } from '@nocobase/client';
import { Table } from 'antd';
import React from 'react';
export const ConfigureFields = () => {
const record = useRecord();
return (
<Table
columns={[
{
title: '字段标识',
dataIndex: 'name',
},
]}
dataSource={record.fields || []}
/>
);
};

View File

@ -0,0 +1,26 @@
import { SchemaComponent } from '@nocobase/client';
import { Card } from 'antd';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AddNewQuery, EditQuery } from './AddNewQuery';
import { ConfigureFields } from './ConfigureFields';
import {
chartsQueriesSchema,
useDestroyAllSelectedQueriesAction,
useDestroyQueryItemAction,
} from './schemas/chartsQueries';
import JSON5 from 'json5';
export const QueriesTable = () => {
const [visible, setVisible] = useState(false);
const { t } = useTranslation();
return (
<Card bordered={false}>
<SchemaComponent
scope={{ JSON5, useDestroyQueryItemAction, useDestroyAllSelectedQueriesAction }}
schema={chartsQueriesSchema}
components={{ AddNewQuery, EditQuery, ConfigureFields }}
/>
</Card>
);
};

View File

@ -0,0 +1,64 @@
import { ISchema } from '@formily/react';
import cloneDeep from 'lodash/cloneDeep';
export const json: ISchema = {
type: 'object',
properties: {
data: {
title: 'JSON',
required: true,
'x-component': 'Input.TextArea',
'x-validator': { json5: true },
'x-component-props': {
autoSize: {
maxRows: 20,
minRows: 10,
},
},
'x-decorator': 'FormItem',
},
},
};
export const sql: ISchema = {
type: 'object',
properties: {
sql: {
title: 'SQL',
required: true,
'x-component': 'Input.TextArea',
'x-decorator': 'FormItem',
'x-validator': {
triggerType: 'onBlur',
validator: '{{validateSQL}}',
},
'x-component-props': {
autoSize: {
maxRows: 20,
minRows: 10,
},
},
},
},
};
export const api: ISchema = {
type: 'object',
properties: {
api: {
title: 'API',
required: true,
'x-component': 'Input',
'x-decorator': 'FormItem',
},
},
};
const types = {
json,
sql,
api,
};
export const getQueryTypeSchema = (type) => {
return cloneDeep(types[type]);
};

View File

@ -0,0 +1,319 @@
import { ISchema } from '@formily/react';
import { uid } from '@formily/shared';
import {
useActionContext,
useRecord,
useRequest,
useResourceActionContext,
useResourceContext,
} from '@nocobase/client';
import { useChartQueryMetadataContext } from '../../ChartQueryMetadataProvider';
const collection = {
name: 'chartsQueries',
fields: [
{
type: 'integer',
name: 'title',
interface: 'input',
uiSchema: {
title: '{{t("Title",{ns:"charts"})}}',
type: 'string',
'x-component': 'Input',
required: true,
} as ISchema,
},
{
type: 'string',
name: 'type',
interface: 'select',
uiSchema: {
title: '{{t("Type",{ns:"charts"})}}',
type: 'string',
'x-component': 'Select',
required: true,
enum: [
{ label: '{{t("API")}}', value: 'api' },
{ label: '{{t("SQL")}}', value: 'sql' },
{ label: '{{t("JSON")}}', value: 'json' },
],
} as ISchema,
},
],
};
export const useDestroyQueryItemAction = () => {
const ctx = useChartQueryMetadataContext();
const { refresh } = useResourceActionContext();
const { resource, targetKey } = useResourceContext();
const { [targetKey]: filterByTk } = useRecord();
return {
async run() {
await resource.destroy({ filterByTk });
refresh();
ctx.refresh();
},
};
};
export const useDestroyAllSelectedQueriesAction = () => {
const ctx = useChartQueryMetadataContext();
const { state, setState, refresh } = useResourceActionContext();
const { resource, targetKey } = useResourceContext();
return {
async run() {
await resource.destroy({
filterByTk: state?.selectedRowKeys || [],
});
setState?.({ selectedRowKeys: [] });
refresh();
ctx.refresh();
},
};
};
export const chartsQueriesSchema: ISchema = {
type: 'object',
properties: {
block1: {
type: 'void',
'x-decorator': 'ResourceActionProvider',
'x-decorator-props': {
collection,
resourceName: 'chartsQueries',
request: {
resource: 'chartsQueries',
action: 'list',
params: {
pageSize: 50,
sort: ['-id'],
appends: [],
},
},
},
'x-component': 'CollectionProvider',
'x-component-props': {
collection,
},
properties: {
actions: {
type: 'void',
'x-component': 'ActionBar',
'x-component-props': {
style: {
marginBottom: 16,
},
},
properties: {
delete: {
type: 'void',
title: '{{ t("Delete") }}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ useDestroyAllSelectedQueriesAction }}',
confirm: {
title: '{{t("Delete queries",{ns:"charts"})}}',
content: "{{t('Are you sure you want to delete it?')}}",
},
},
},
create: {
type: 'void',
title: '{{t("Add query")}}',
'x-component': 'AddNewQuery',
'x-component-props': {
type: 'primary',
},
properties: {
drawer: {
type: 'void',
'x-component': 'Action.Drawer',
'x-decorator': 'Form',
'x-decorator-props': {
useValues(options) {
const ctx = useActionContext();
return useRequest(
() =>
Promise.resolve({
data: {
name: `s_${uid()}`,
},
}),
{ ...options, refreshDeps: [ctx.visible] },
);
},
},
title: '{{t("Add query",{ns:"charts"})}}',
properties: {
title: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
type: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
footer: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
cancel: {
title: '{{t("Cancel",{ns:"charts"})}}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ cm.useCancelAction }}',
},
},
submit: {
title: '{{t("Submit",{ns:"charts"})}}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: '{{ cm.useCreateAction }}',
},
},
},
},
},
},
},
},
},
},
table: {
type: 'void',
'x-uid': 'input',
'x-component': 'Table.Void',
'x-component-props': {
rowKey: 'id',
rowSelection: {
type: 'checkbox',
},
useDataSource: '{{ cm.useDataSourceFromRAC }}',
},
properties: {
title: {
type: 'void',
'x-decorator': 'Table.Column.Decorator',
'x-component': 'Table.Column',
properties: {
title: {
type: 'number',
'x-component': 'CollectionField',
'x-read-pretty': true,
},
},
},
type: {
type: 'void',
'x-decorator': 'Table.Column.Decorator',
'x-component': 'Table.Column',
properties: {
type: {
type: 'string',
'x-component': 'CollectionField',
'x-read-pretty': true,
},
},
},
actions: {
type: 'void',
title: '{{t("Actions")}}',
'x-component': 'Table.Column',
properties: {
actions: {
type: 'void',
'x-component': 'Space',
'x-component-props': {
split: '|',
},
properties: {
fields: {
type: 'void',
title: '{{t("Configure fields")}}',
'x-component': 'Action.Link',
'x-component-props': {
type: 'primary',
},
properties: {
drawer: {
type: 'void',
'x-component': 'Action.Drawer',
title: '{{t("Configure fields")}}',
properties: {
configure: {
type: 'void',
'x-component': 'ConfigureFields',
},
},
},
},
},
update: {
type: 'void',
title: '{{t("Edit")}}',
'x-component': 'EditQuery',
'x-component-props': {
type: 'primary',
},
properties: {
drawer: {
type: 'void',
'x-component': 'Action.Drawer',
'x-decorator': 'Form',
'x-decorator-props': {
useValues: '{{ cm.useValuesFromRecord }}',
},
title: '{{t("Edit")}}',
properties: {
title: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
footer: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
cancel: {
title: '{{t("Cancel",{ns:"charts"})}}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ cm.useCancelAction }}',
},
},
submit: {
title: '{{t("Submit",{ns:"charts"})}}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: '{{ cm.useUpdateAction }}',
},
},
},
},
},
},
},
},
delete: {
type: 'void',
title: '{{ t("Delete") }}',
'x-component': 'Action.Link',
'x-component-props': {
confirm: {
title: '{{t("Delete query",{ns:"charts"})}}',
content: "{{t('Are you sure you want to delete it?')}}",
},
useAction: '{{ useDestroyQueryItemAction }}',
},
},
},
},
},
},
},
},
},
},
},
};

View File

@ -0,0 +1,64 @@
import JSON5 from 'json5';
const chartConfig = {
yField: '{{metric}}',
xField: '{{dimension}}',
seriesField: '{{category}}',
};
export const areaTemplate = {
description: '1 「time」or 「Ordered Noun」 field,1 「Numerical」 field,1 「Unordered Noun」 field (optional)',
type: 'Area',
title: 'Area',
iconId: 'icon-area',
group: 2,
renderComponent: 'G2Plot',
defaultChartOptions: chartConfig,
configurableProperties: {
type: 'void',
properties: {
dimension: {
required: true,
type: 'string',
title: '{{t("Category axis / Dimension",{ns:"charts"})}}',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: '{{dataSource}}',
},
metric: {
required: true,
type: 'string',
title: '{{t("Value axis / Metrics",{ns:"charts"})}}',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: '{{dataSource}}',
},
category: {
type: 'string',
title: '{{t("Color legend / Dimensional",{ns:"charts"})}}',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: '{{dataSource}}',
},
jsonConfig: {
type: 'void',
'x-component': 'div',
properties: {
template: {
required: true,
title: '{{t("JSON config",{ns:"charts"})}}',
type: 'string',
default: JSON5.stringify(chartConfig, null, 2),
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
'x-component-props': {
autoSize: { minRows: 8, maxRows: 16 },
},
description: '{{jsonConfigDesc("Area | G2Plot","https://g2plot.antv.antgroup.com/api/plots/area")}}',
'x-validator': { json5: true },
},
},
},
},
},
};

View File

@ -0,0 +1,80 @@
import JSON5 from 'json5';
const chartConfig = {
appendPadding: 10,
isGroup: true,
yField: '{{dimension}}',
xField: '{{metric}}',
seriesField: '{{category}}',
label: {
// 可手动配置 label 数据标签位置
position: 'middle', // 'top', 'bottom', 'middle',
// 配置样式
style: {
fill: '#FFFFFF',
opacity: 0.6,
},
},
xAxis: {
label: {
autoHide: true,
autoRotate: false,
},
},
};
export const barTemplate = {
description: '1 「time」 or 「ordered noun」 field, 1 「value」 field, 0~ 1 「unordered noun」',
type: 'Bar',
title: 'Bar',
iconId: 'icon-bar',
group: 2,
renderComponent: 'G2Plot',
defaultChartOptions: chartConfig,
configurableProperties: {
type: 'void',
properties: {
dimension: {
required: true,
type: 'string',
title: '{{t("Category axis / Dimension",{ns:"charts"})}}',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: '{{dataSource}}',
},
metric: {
required: true,
type: 'string',
title: '{{t("Value axis / Metrics",{ns:"charts"})}}',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: '{{dataSource}}',
},
category: {
type: 'string',
title: '{{t("Color legend / Dimensional",{ns:"charts"})}}',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: '{{dataSource}}',
},
jsonConfig: {
type: 'void',
'x-component': 'div',
properties: {
template: {
required: true,
title: '{{t("JSON config",{ns:"charts"})}}',
type: 'string',
default: JSON5.stringify(chartConfig, null, 2),
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
'x-component-props': {
autoSize: { minRows: 8, maxRows: 16 },
},
description: '{{jsonConfigDesc("Bar | G2Plot","https://g2plot.antv.antgroup.com/api/plots/bar")}}',
'x-validator': { json5: true },
},
},
},
},
},
};

View File

@ -0,0 +1,81 @@
import JSON5 from 'json5';
const chartConfig = {
appendPadding: 10,
isGroup: true,
yField: '{{metric}}',
xField: '{{dimension}}',
seriesField: '{{category}}',
label: {
// 可手动配置 label 数据标签位置
position: 'middle', // 'top', 'bottom', 'middle',
// 配置样式
style: {
fill: '#FFFFFF',
opacity: 0.6,
},
},
xAxis: {
label: {
autoHide: true,
autoRotate: false,
},
},
};
export const columnTemplate = {
description: '1 「time」 or 「ordered noun」 field, 1 「value」 field, 0 to 1 「unordered noun」',
type: 'Column',
title: 'Column',
iconId: 'icon-column',
group: 2,
renderComponent: 'G2Plot',
defaultChartOptions: chartConfig,
configurableProperties: {
type: 'void',
properties: {
dimension: {
required: true,
type: 'string',
title: '{{t("Category axis / Dimension",{ns:"charts"})}}',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: '{{dataSource}}',
},
metric: {
required: true,
type: 'string',
title: '{{t("Value axis / Metrics",{ns:"charts"})}}',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: '{{dataSource}}',
},
category: {
type: 'string',
title: '{{t("Color legend / Dimensional",{ns:"charts"})}}',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: '{{dataSource}}',
},
jsonConfig: {
type: 'void',
'x-component': 'div',
properties: {
template: {
required: true,
title: '{{t("JSON config",{ns:"charts"})}}',
type: 'string',
default: JSON5.stringify(chartConfig, null, 2),
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
'x-component-props': {
autoSize: { minRows: 8, maxRows: 16 },
},
description: '{{jsonConfigDesc("Column | G2Plot","https://g2plot.antv.antgroup.com/api/plots/column")}}',
'x-validator': { json5: true },
},
},
},
},
},
};

View File

@ -0,0 +1,65 @@
import JSON5 from 'json5';
const chartConfig = {
xField: '{{dimension}}',
yField: '{{metric}}',
seriesField: '{{category}}',
legend: false,
};
export const funnelTemplate = {
description: '1 「Unordered Noun」 field, 1 「Numeric」 field',
type: 'Funnel',
title: 'Funnel',
iconId: 'icon-funnel',
group: 1,
renderComponent: 'G2Plot',
defaultChartOptions: chartConfig,
configurableProperties: {
type: 'void',
properties: {
dimension: {
required: true,
type: 'string',
title: '{{t("Sector label / Dimensional",{ns:"charts"})}}',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: '{{dataSource}}',
},
metric: {
required: true,
type: 'string',
title: '{{t("Funnel Layer Width/Metrics",{ns:"charts"})}}',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: '{{dataSource}}',
},
category: {
type: 'string',
title: '{{t("Color legend / Dimensional",{ns:"charts"})}}',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: '{{dataSource}}',
},
jsonConfig: {
type: 'void',
'x-component': 'div',
properties: {
template: {
required: true,
title: '{{t("JSON config",{ns:"charts"})}}',
type: 'string',
default: JSON5.stringify(chartConfig, null, 2),
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
'x-component-props': {
autoSize: { minRows: 8, maxRows: 16 },
},
description: '{{jsonConfigDesc("Funnel | G2Plot","https://g2plot.antv.antgroup.com/api/plots/funnel")}}',
'x-validator': { json5: true },
},
},
},
},
},
};

View File

@ -0,0 +1,72 @@
import JSON5 from 'json5';
const chartConfig = {
yField: '{{metric}}',
xField: '{{dimension}}',
seriesField: '{{category}}',
xAxis: {
//type: 'time',
},
yAxis: {
// label: {
// formatter: '{{(v) => `${v}`.replace(/\d{1,3}(?=(\d{3})+$)/g, (s) => `${s},`)}}',
// },
},
};
export const lineTemplate = {
description: '1 「Time」 or 「Order Noun」 field, 1 「Value」 field',
type: 'Line',
title: 'Line',
iconId: 'icon-line',
group: 2,
renderComponent: 'G2Plot',
defaultChartOptions: chartConfig,
configurableProperties: {
type: 'void',
properties: {
dimension: {
required: true,
type: 'string',
title: '{{t("Category axis / Dimension",{ns:"charts"})}}',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: '{{dataSource}}',
},
metric: {
required: true,
type: 'string',
title: '{{t("Value axis / Metrics",{ns:"charts"})}}',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: '{{dataSource}}',
},
category: {
type: 'string',
title: '{{t("Color legend / Dimensional",{ns:"charts"})}}',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: '{{dataSource}}',
},
jsonConfig: {
type: 'void',
'x-component': 'div',
properties: {
template: {
required: true,
title: '{{t("JSON config",{ns:"charts"})}}',
type: 'string',
default: JSON5.stringify(chartConfig, null, 2),
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
'x-component-props': {
autoSize: { minRows: 8, maxRows: 16 },
},
description: '{{jsonConfigDesc("Line | G2Plot","https://g2plot.antv.antgroup.com/api/plots/line")}}',
'x-validator': { json5: true },
},
},
},
},
},
};

View File

@ -0,0 +1,68 @@
import JSON5 from 'json5';
const chartConfig = {
appendPadding: 10,
angleField: '{{metric}}',
colorField: '{{dimension}}',
radius: 0.9,
label: {
type: 'inner',
offset: '-30%',
content: '{{({percent}) => `${(percent * 100).toFixed(0)}%`}}',
style: {
fontSize: 14,
textAlign: 'center',
},
},
interactions: [{ type: 'element-active' }],
};
export const pieTemplate = {
description: '1 「Time」 or 「Order Noun」 field, 1 「Value」 field',
title: 'Pie',
type: 'Pie',
iconId: 'icon-pie',
group: 2,
renderComponent: 'G2Plot',
defaultChartOptions: chartConfig,
configurableProperties: {
type: 'void',
properties: {
dimension: {
required: true,
type: 'string',
title: '{{t("Sector label / Dimensional",{ns:"charts"})}}',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: '{{dataSource}}',
},
metric: {
required: true,
type: 'string',
title: '{{t("Sector Angle / Metric",{ns:"charts"})}}',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: '{{dataSource}}',
},
jsonConfig: {
type: 'void',
'x-component': 'div',
properties: {
template: {
required: true,
title: '{{t("JSON config",{ns:"charts"})}}',
type: 'string',
default: JSON5.stringify(chartConfig, null, 2),
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
'x-component-props': {
autoSize: { minRows: 8, maxRows: 16 },
},
description: '{{jsonConfigDesc("Pie | G2Plot","https://g2plot.antv.antgroup.com/api/plots/pie")}}',
'x-validator': { json5: true },
},
},
},
},
},
};

View File

@ -0,0 +1,71 @@
import JSON5 from 'json5';
const chartConfig = {
xField: '{{dimension}}',
yField: '{{metric}}',
appendPadding: [0, 10, 0, 10],
xAxis: {
tickLine: null,
},
yAxis: {
label: false,
grid: {
alternateColor: 'rgba(0, 0, 0, 0.04)',
},
},
// 开启辅助点
point: {
size: 2,
},
area: {},
};
export const radarTemplate = {
description: '1~ 2 「Unordered Noun」 fields, 1 「Numeric」 field',
type: 'Radar',
title: 'Radar',
iconId: 'icon-radar',
group: 1,
renderComponent: 'G2Plot',
defaultChartOptions: chartConfig,
configurableProperties: {
type: 'void',
properties: {
dimension: {
required: true,
type: 'string',
title: '{{t("Branch Tags/Dimensions",{ns:"charts"})}}',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: '{{dataSource}}',
},
metric: {
required: true,
type: 'string',
title: '{{t("Branch Length/Metrics",{ns:"charts"})}}',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: '{{dataSource}}',
},
jsonConfig: {
type: 'void',
'x-component': 'div',
properties: {
template: {
required: true,
title: '{{t("JSON config",{ns:"charts"})}}',
type: 'string',
default: JSON5.stringify(chartConfig, null, 2),
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
'x-component-props': {
autoSize: { minRows: 8, maxRows: 16 },
},
description: '{{jsonConfigDesc("Radar | G2Plot","https://g2plot.antv.antgroup.com/api/plots/radar")}}',
'x-validator': { json5: true },
},
},
},
},
},
};

View File

@ -0,0 +1,90 @@
import JSON5 from 'json5';
const chartConfig = {
appendPadding: 10,
xField: '{{metric}}',
yField: '{{dimension}}',
colorField: '{{category}}',
shape: 'circle',
size: 4,
yAxis: {
nice: true,
line: {
style: {
stroke: '#aaa',
},
},
},
xAxis: {
min: -100,
grid: {
line: {
style: {
stroke: '#eee',
},
},
},
line: {
style: {
stroke: '#aaa',
},
},
},
};
export const scatterTemplate = {
description: '1 「Numeric」 field, 0~ 1 「Unordered Noun」 field',
type: 'Scatter',
title: 'Scatter',
iconId: 'icon-scatter',
group: 2,
renderComponent: 'G2Plot',
defaultChartOptions: chartConfig,
configurableProperties: {
type: 'void',
properties: {
dimension: {
required: true,
type: 'string',
title: '{{t("Category axis / Dimension",{ns:"charts"})}}',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: '{{dataSource}}',
},
metric: {
required: true,
type: 'string',
title: '{{t("Value axis / Metrics",{ns:"charts"})}}',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: '{{dataSource}}',
},
category: {
type: 'string',
title: '{{t("Color legend / Dimensional",{ns:"charts"})}}',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: '{{dataSource}}',
},
jsonConfig: {
type: 'void',
'x-component': 'div',
properties: {
template: {
required: true,
title: '{{t("JSON config",{ns:"charts"})}}',
type: 'string',
default: JSON5.stringify(chartConfig, null, 2),
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
'x-component-props': {
autoSize: { minRows: 8, maxRows: 16 },
},
description: '{{jsonConfigDesc("Scatter | G2Plot","https://g2plot.antv.antgroup.com/api/plots/scatter")}}',
'x-validator': { json5: true },
},
},
},
},
},
};

View File

@ -0,0 +1,48 @@
import JSON5 from 'json5';
const validateJSON = {
validator: `{{(value, rule)=> {
if (!value) {
return '';
}
try {
const val = JSON5.parse(value);
if(!isNaN(val)) {
return false;
}
return true;
} catch(error) {
console.error(error);
return false;
}
}}}`,
message: '{{t("Invalid JSON format",{ ns: "charts" })}}',
};
const chartConfig = {
appendPadding: 10,
angleField: '{{metric}}',
colorField: '{{dimension}}',
radius: 0.9,
label: {
type: 'inner',
offset: '-30%',
content: '{{({percent}) => `${(percent * 100).toFixed(0)}%`}}',
style: {
fontSize: 14,
textAlign: 'center',
},
},
interactions: [{ type: 'element-active' }],
};
export const tableTemplate = {
title: '表格展示',
type: 'DataSetPreviewTable',
group: 2,
renderComponent: 'DataSetPreviewTable',
defaultChartOptions: chartConfig,
configurableProperties: {
type: 'void',
properties: {},
},
};

View File

@ -0,0 +1,21 @@
import { pieTemplate } from './PieTemplate';
import { barTemplate } from './BarTemplate';
import { columnTemplate } from './ColumnTemplate';
import { lineTemplate } from './LineTemplate';
import { areaTemplate } from './AreaTemplate';
import { tableTemplate } from './TableTemplate';
import { scatterTemplate } from './ScatterTemplate';
import { radarTemplate } from './RadarTemplate';
import { funnelTemplate } from './FunnelTemplate';
export const templates = new Map();
templates.set('Pie', pieTemplate);
templates.set('Line', lineTemplate);
templates.set('Area', areaTemplate);
templates.set('Bar', barTemplate);
templates.set('Column', columnTemplate);
templates.set('Scatter', scatterTemplate);
templates.set('Radar', radarTemplate);
templates.set('Funnel', funnelTemplate);
// templates.set('DataSetPreviewTable', tableTemplate);

View File

@ -0,0 +1,39 @@
import JSON5 from 'json5';
import { uid } from '@formily/shared';
const validateArray = (value) => {
try {
value = JSON5.parse(value);
} catch (e) {
return 'Please input validate dataset';
}
if (Array.isArray(value)) {
if (
value.every((item) => {
return typeof item === 'object' && Object.keys(item).length > 1;
})
)
return true;
}
return 'Please input validate dataset';
};
const parseDataSetString = (str) => {
const dataSetDataArray = JSON5.parse(str);
if (Array.isArray(dataSetDataArray)) {
if (
dataSetDataArray.every((item) => {
return typeof item === 'object' && Object.keys(item).length > 1;
})
)
dataSetDataArray.map((item) => {
if (!item?.id) {
item.id = uid();
}
return item;
});
}
return dataSetDataArray;
};
export { validateArray, parseDataSetString };

View File

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

View File

@ -0,0 +1,44 @@
import JSON5 from 'json5';
import { query } from '../query';
export const getData = async (ctx, next) => {
const { filterByTk } = ctx.action.params;
const r = ctx.db.getRepository('chartsQueries');
const instance = await r.findOne({ filterByTk });
const result = await query[instance.type](instance.options, { db: ctx.db, skipError: true });
if (typeof result === 'string') {
ctx.body = JSON5.parse(result);
} else {
ctx.body = result;
}
return next();
};
export const validate = async (ctx, next) => {
const { values } = ctx.action.params;
ctx.body = {
errorMessage: '',
};
try {
await query.sql(values, { db: ctx.db, validateSQL: true });
} catch (error) {
ctx.body = {
errorMessage: error.message,
};
}
return next();
};
export const listMetadata = async (ctx, next) => {
const r = ctx.db.getRepository('chartsQueries');
const items = await r.find({ sort: '-id' });
ctx.body = items.map((item) => {
return {
id: item.id,
title: item.title,
type: item.type,
fields: item.fields,
};
});
return next();
};

View File

@ -0,0 +1,24 @@
import { defineCollection } from '@nocobase/database';
export default defineCollection({
name: 'chartsQueries',
fields: [
{
name: 'title',
type: 'string',
},
{
name: 'type',
type: 'string',
},
{
name: 'options',
type: 'json',
},
{
name: 'fields',
type: 'json',
defaultValue: [],
},
],
});

View File

@ -0,0 +1 @@
export { default } from './plugin';

View File

@ -0,0 +1,61 @@
import { InstallOptions, Plugin } from '@nocobase/server';
import JSON5 from 'json5';
import { resolve } from 'path';
import { getData, listMetadata, validate } from './actions/chartsQueries';
import { query } from './query';
export class ChartsPlugin extends Plugin {
syncFields = async (instance, { transaction }) => {
const _data = await query[instance.type](instance.options, { db: this.db, transaction, validateSQL: true });
let data;
if (typeof _data === 'string') {
data = JSON5.parse(_data);
} else {
data = _data;
}
const d = Array.isArray(data) ? data?.[0] : data;
const fields = Object.keys(d || {}).map((f) => {
return {
name: f,
};
});
instance.set('fields', fields);
};
afterAdd() {}
beforeLoad() {
this.app.db.on('chartsQueries.beforeCreate', this.syncFields);
this.app.db.on('chartsQueries.beforeUpdate', this.syncFields);
}
async load() {
await this.db.import({
directory: resolve(__dirname, 'collections'),
});
this.app.resourcer.registerActionHandlers({
'chartsQueries:getData': getData,
'chartsQueries:listMetadata': listMetadata,
'chartsQueries:validate': validate,
});
this.app.acl.registerSnippet({
name: 'pm.charts.queries',
actions: ['chartsQueries:*'],
});
this.app.acl.allow('chartsQueries', 'getData', 'loggedIn');
this.app.acl.allow('chartsQueries', 'listMetadata', 'loggedIn');
}
async install(options?: InstallOptions) {}
async afterEnable() {}
async afterDisable() {}
async remove() {}
}
export default ChartsPlugin;

View File

@ -0,0 +1,39 @@
import { Database } from '@nocobase/database';
export const query = {
api: async (options) => {
return [];
},
json: async (options) => {
return options.data || [];
},
sql: async (
options,
{
db,
transaction,
skipError,
validateSQL,
}: { db: Database; transaction?: any; skipError?: boolean; validateSQL?: boolean },
) => {
try {
// 分号截取,只取第一段
const sql: string = options.sql.trim().split(';').shift();
if (!sql) {
throw new Error('SQL is empty');
}
if (!/^select/i.test(sql) && !/^with([\s\S]+)select([\s\S]+)/i.test(sql)) {
throw new Error('Only select query allowed');
}
const [data] = await db.sequelize.query(sql, { transaction });
return data;
} catch (error) {
if (skipError) {
return [];
}
throw error;
}
},
};
export default query;

View File

@ -0,0 +1,2 @@
const choicesTypeInterfaceArray = ['radioGroup', 'select'];
export { choicesTypeInterfaceArray };

View File

@ -33,6 +33,7 @@
"@nocobase/plugin-users": "0.9.1-alpha.1", "@nocobase/plugin-users": "0.9.1-alpha.1",
"@nocobase/plugin-verification": "0.9.1-alpha.1", "@nocobase/plugin-verification": "0.9.1-alpha.1",
"@nocobase/plugin-workflow": "0.9.1-alpha.1", "@nocobase/plugin-workflow": "0.9.1-alpha.1",
"@nocobase/plugin-charts": "0.9.1-alpha.1",
"@nocobase/server": "0.9.1-alpha.1" "@nocobase/server": "0.9.1-alpha.1"
}, },
"repository": { "repository": {

View File

@ -23,6 +23,7 @@ export class PresetNocoBase extends Plugin {
'duplicator', 'duplicator',
'iframe-block', 'iframe-block',
'formula-field', 'formula-field',
'charts',
]; ];
localPlugins = [ localPlugins = [

View File

@ -37,7 +37,7 @@ export const HelloBlockInitializer = (props) => {
export default React.memo((props) => { export default React.memo((props) => {
const items = useContext(SchemaInitializerContext); const items = useContext(SchemaInitializerContext);
const children = items.BlockInitializers.items[2].children; const children = items.BlockInitializers.items[1].children;
children.push({ children.push({
key: 'hello', key: 'hello',
type: 'item', type: 'item',

View File

@ -6442,7 +6442,14 @@
resolved "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" resolved "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
"@types/react-dom@^16.9.8", "@types/react-dom@^17.0.0": "@types/react-dom@^16.9.8":
version "16.9.18"
resolved "https://registry.npmmirror.com/@types/react-dom/-/react-dom-16.9.18.tgz#1fda8b84370b1339d639a797a84c16d5a195b419"
integrity sha512-lmNARUX3+rNF/nmoAFqasG0jAA7q6MeGZK/fdeLwY3kAA4NPgHHrG5bNQe2B5xmD4B+x6Z6h0rEJQ7MEEgQxsw==
dependencies:
"@types/react" "^16"
"@types/react-dom@^17.0.0":
version "17.0.11" version "17.0.11"
resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.11.tgz#e1eadc3c5e86bdb5f7684e00274ae228e7bcc466" resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.11.tgz#e1eadc3c5e86bdb5f7684e00274ae228e7bcc466"
integrity sha512-f96K3k+24RaLGVu/Y2Ng3e1EbZ8/cVJvypZWd7cy0ofCBaf2lcM46xNhycMZ2xGwbBjRql7hOlZ+e2WlJ5MH3Q== integrity sha512-f96K3k+24RaLGVu/Y2Ng3e1EbZ8/cVJvypZWd7cy0ofCBaf2lcM46xNhycMZ2xGwbBjRql7hOlZ+e2WlJ5MH3Q==
@ -6502,7 +6509,7 @@
"@types/history" "*" "@types/history" "*"
"@types/react" "*" "@types/react" "*"
"@types/react@*", "@types/react@>=16.9.11", "@types/react@^16.9.43", "@types/react@^17.0.0": "@types/react@*", "@types/react@>=16.9.11", "@types/react@^17.0.0":
version "17.0.34" version "17.0.34"
resolved "https://registry.npmjs.org/@types/react/-/react-17.0.34.tgz#797b66d359b692e3f19991b6b07e4b0c706c0102" resolved "https://registry.npmjs.org/@types/react/-/react-17.0.34.tgz#797b66d359b692e3f19991b6b07e4b0c706c0102"
integrity sha512-46FEGrMjc2+8XhHXILr+3+/sTe3OfzSPU9YGKILLrUYbQ1CLQC9Daqo1KzENGXAWwrFwiY0l4ZbF20gRvgpWTg== integrity sha512-46FEGrMjc2+8XhHXILr+3+/sTe3OfzSPU9YGKILLrUYbQ1CLQC9Daqo1KzENGXAWwrFwiY0l4ZbF20gRvgpWTg==
@ -6511,6 +6518,15 @@
"@types/scheduler" "*" "@types/scheduler" "*"
csstype "^3.0.2" csstype "^3.0.2"
"@types/react@^16", "@types/react@^16.9.43":
version "16.14.35"
resolved "https://registry.npmmirror.com/@types/react/-/react-16.14.35.tgz#9d3cf047d85aca8006c4776693124a5be90ee429"
integrity sha512-NUEiwmSS1XXtmBcsm1NyRRPYjoZF2YTE89/5QiLt5mlGffYK9FQqOKuOLuXNrjPQV04oQgaZG+Yq02ZfHoFyyg==
dependencies:
"@types/prop-types" "*"
"@types/scheduler" "*"
csstype "^3.0.2"
"@types/resolve@1.17.1": "@types/resolve@1.17.1":
version "1.17.1" version "1.17.1"
resolved "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6" resolved "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6"
@ -15613,6 +15629,11 @@ json5@^2.1.0, json5@^2.2.1:
resolved "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz#655d50ed1e6f95ad1a3caababd2b0efda10b395c" resolved "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz#655d50ed1e6f95ad1a3caababd2b0efda10b395c"
integrity sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA== integrity sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==
json5@^2.2.3:
version "2.2.3"
resolved "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
jsonfile@^4.0.0: jsonfile@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" resolved "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb"