docs/docs/en/plugin-samples/schema-initializer/block-data-modal.md
In many cases, before clicking to create a block, we need to first select configuration information. For example:
Kanban block requires selecting Grouping field and Sorting field after clickingCalendar block requires selecting Title field, Start date field, End date field firstChart block requires configuring chart-related information firstThis example will create a Timeline block based on ant-design Timeline component, and before creating the block, select Time Field and Title Field.
This example is mainly to demonstrate the use of initializer. For more information about block extension, please refer to the Block Extension documentation.
The complete example code for this document can be found in plugin-samples.
<video width="100%" controls=""> <source src="https://static-docs.nocobase.com/20240529223457_rec_.mp4" type="video/mp4" /> </video>Following the Write Your First Plugin documentation, if you don't have a project yet, you can create one first. If you already have one or have cloned the source code, you can skip this step.
yarn create nocobase-app my-nocobase-app -d postgres
cd my-nocobase-app
yarn install
yarn nocobase install
Then initialize a plugin and add it to the system:
yarn pm create @nocobase-sample/plugin-initializer-block-data-modal
yarn pm enable @nocobase-sample/plugin-initializer-block-data-modal
Then start the project:
yarn dev
After logging in, visit http://localhost:13000/admin/pm/list/local/ to see that the plugin has been installed and enabled.
Before implementing this example, we need to understand some basic knowledge:
.
├── client # Client plugin
│ ├── initializer # Initializer
│ ├── component # Block component
│ ├── index.tsx # Client plugin entry
│ ├── locale.ts # Multi-language utility function
│ ├── constants.ts # Constants
│ ├── schema # Schema
│ └── settings # Schema Settings
├── locale # Multi-language files
│ ├── en-US.json # English
│ └── zh-CN.json # Chinese
├── index.ts # Server plugin entry
└── server # Server plugin
First, we need to define the block name, which will be used in various places.
We create packages/plugins/@nocobase-sample/plugin-initializer-block-data-modal/src/client/constants.ts:
export const BlockName = 'Timeline';
export const BlockNameLowercase = BlockName.toLowerCase();
This example is about a Timeline block component with the following specific requirements:
First, we create packages/plugins/@nocobase-sample/plugin-initializer-block-data-modal/src/client/component/Timeline.tsx file with the following content:
import React, { FC } from 'react';
import { Timeline as AntdTimeline, TimelineProps as AntdTimelineProps, Spin } from 'antd';
import { withDynamicSchemaProps } from "@nocobase/client";
import { BlockName } from '../constants';
export interface TimelineProps {
data?: AntdTimelineProps['items'];
loading?: boolean;
}
export const Timeline: FC<TimelineProps> = withDynamicSchemaProps((props) => {
const { data, loading } = props;
if (loading) return <div style={{ height: 100, textAlign: 'center' }}><Spin /></div>
return <AntdTimeline mode='left' items={data}></AntdTimeline>
}, { displayName: BlockName });
The Timeline component is essentially a component wrapped by withDynamicSchemaProps, which accepts 2 parameters:
loading: Data loading statedata: items property of Timeline componentwithDynamicSchemaProps is a higher-order component used to handle dynamic properties in Schema.
We need to register Timeline to the system through the plugin.
import { Plugin } from '@nocobase/client';
import { Timeline } from './component';
export class PluginInitializerBlockDataModalClient extends Plugin {
async load() {
this.app.addComponents({ Timeline })
}
}
export default PluginInitializerBlockDataModalClient;
There are 2 ways to verify components:
Timeline component to check if it meets the requirementsyarn doc plugins/@nocobase-sample/plugin-initializer-block-data-modal, and verify if it meets the requirements by writing documentation examples (TODO)We use temporary page verification as an example. We create a new page and add one or more Timeline components according to property parameters to check if they meet the requirements.
import { Plugin } from '@nocobase/client';
import { Timeline } from './component';
import React from 'react';
export class PluginInitializerBlockDataModalClient extends Plugin {
async load() {
this.app.addComponents({ Timeline })
this.app.router.add('admin.timeline-block-component', {
path: '/admin/timeline-block-component',
Component: () => {
return <>
<div style={{ marginTop: 20, marginBottom: 20 }}>
<Timeline
data={[
{
label: '2015-09-01',
children: 'user1',
},
{
label: '2015-09-02',
children: 'user2',
},
{
label: '2015-09-03',
children: 'user3',
},
]} />
</div>
<div style={{ marginTop: 20, marginBottom: 20 }}>
<Timeline loading={true} />
</div>
</>
}
})
}
}
export default PluginInitializerBlockDataModalClient;
Then visit http://localhost:13000/admin/timeline-block-component to see the corresponding test page content.
After verification, the test page needs to be deleted.
According to the requirements, we need to configure Time Field and Title Field after selecting the data table, so we need to define a configuration form, named TimelineInitializerConfigForm.
We need to understand the following knowledge first:
Action contextWe create packages/plugins/@nocobase-sample/plugin-initializer-block-data-modal/src/client/initializer/ConfigForm.tsx file with the following content:
import React, { FC, useMemo } from "react";
import { ISchema } from '@formily/react';
import { ActionContextProvider, SchemaComponent, useApp, CollectionFieldOptions } from '@nocobase/client';
import { useT } from "../locale";
const createSchema = (fields: CollectionFieldOptions, t: ReturnType<typeof useT>): ISchema => {
// TODO
}
interface TimelineConfigFormValues {
timeField: string;
titleField: string;
}
export interface TimelineConfigFormProps {
collection: string;
dataSource?: string;
onSubmit: (values: TimelineConfigFormValues) => void;
visible: boolean;
setVisible: (visible: boolean) => void;
}
export const TimelineInitializerConfigForm: FC<TimelineConfigFormProps> = ({ visible, setVisible, collection, dataSource, onSubmit }) => {
const app = useApp();
const fields = useMemo(() => app.getCollectionManager(dataSource).getCollection(collection).getFields(), [collection, dataSource])
const t = useT();
const schema = useMemo(() => createSchema(fields, t), [fields]);
return <ActionContextProvider value={{ visible, setVisible }}>
<SchemaComponent schema={schema} />
</ActionContextProvider>
}
TimelineInitializerConfigForm component accepts 4 parameters:
visible: Whether to displaysetVisible: Set whether to displaycollection: Data table namedataSource: Data source nameonSubmit: Form submit callbackAmong them, collection and dataSource are obtained after clicking the data table, so they are dynamic here.
ActionContextProvider is used to pass visible and setVisible to child nodes, SchemaComponent is used to render Schema.
We need to understand the following knowledge first:
const useCloseActionProps = () => {
const { setVisible } = useActionContext();
return {
type: 'default',
onClick() {
setVisible(false);
},
};
};
const useSubmitActionProps = (onSubmit: (values: TimelineConfigFormValues) => void) => {
const { setVisible } = useActionContext();
const form = useForm<TimelineConfigFormValues>();
return {
type: 'primary',
async onClick() {
await form.submit();
const values = form.values;
onSubmit(values);
setVisible(false);
},
};
};
const createSchema = (fields: CollectionFieldOptions[]): ISchema => {
return {
type: 'void',
name: uid(),
'x-component': 'Action.Modal',
'x-component-props': {
width: 600,
},
'x-decorator': 'FormV2',
properties: {
titleField: {
type: 'string',
title: 'Title Field',
required: true,
enum: fields.map(item => ({ label: item.uiSchema?.title || item.name, value: item.name })),
'x-decorator': 'FormItem',
'x-component': 'Select',
},
timeField: {
type: 'string',
title: 'Time Field',
required: true,
enum: fields.filter(item => item.type === 'date').map(item => ({ label: item.uiSchema?.title || item.name, value: item.name })),
'x-decorator': 'FormItem',
'x-component': 'Select',
},
footer: {
type: 'void',
'x-component': 'Action.Modal.Footer',
properties: {
close: {
title: 'Close',
'x-component': 'Action',
'x-component-props': {
type: 'default',
},
'x-use-component-props': 'useCloseActionProps',
},
submit: {
title: 'Submit',
'x-component': 'Action',
'x-use-component-props': 'useSubmitActionProps',
},
},
},
}
};
}
We defined a createSchema function to generate the configuration form Schema, which accepts a fields parameter, which is the fields of the data table.
The above effect is that there is a form inside the modal, and there are 2 selectors in the form, one is Title Field, one is Time Field, and there are a Close and Submit button.
Close and Submit buttons need to use Hooks, so we use x-use-component-propsTitle Field: All fields can be selectedTime Field: Only date type fields can be selectedThen we also need to modify TimelineInitializerConfigForm to register useSubmitActionProps and useCloseActionProps to scope.
- <SchemaComponent schema={schema}/>
+ <SchemaComponent schema={schema} scope={{ useSubmitActionProps: useSubmitActionProps.bind(null, onSubmit), useCloseActionProps }} />
import { Plugin } from '@nocobase/client';
import { Timeline } from './component';
import React, { useState } from 'react';
import { TimelineInitializerConfigForm } from './initializer/ConfigForm';
export class PluginInitializerBlockDataModalClient extends Plugin {
async load() {
this.app.addComponents({ Timeline })
this.app.router.add('admin.timeline-config-form', {
path: '/admin/timeline-config-form',
Component: () => {
const [visible, setVisible] = useState(true);
function onSubmit(values) {
console.log(values);
}
return <>
<div style={{ marginTop: 20, marginBottom: 20 }}>
<TimelineInitializerConfigForm visible={visible} onSubmit={onSubmit} setVisible={setVisible} collection='users' />
</div>
</>
}
})
}
}
export default PluginInitializerBlockDataModalClient;
Then visit http://localhost:13000/admin/timeline-config-form to see the corresponding test page content.
After verification, the test page needs to be deleted.
NocoBase's dynamic pages are all rendered through Schema, so we need to define a Schema, which will be used later to add the Timeline block to the interface. Before implementing this section, we need to understand some basic knowledge:
We create packages/plugins/@nocobase-sample/plugin-initializer-block-data-modal/src/client/schema/index.tsx file:
import { useDataBlockProps, useDataBlockRequest } from "@nocobase/client";
import { TimelineProps } from '../component';
import { BlockName, BlockNameLowercase } from "../constants";
interface GetTimelineSchemaOptions {
dataSource?: string;
collection: string;
titleField: string;
timeField: string;
}
export function getTimelineSchema(options: GetTimelineSchemaOptions) {
return {
type: 'void',
"x-toolbar": "BlockSchemaToolbar",
'x-decorator': 'DataBlockProvider',
'x-decorator-props': {
dataSource,
collection,
action: 'list',
params: {
sort: `-${timeField}`
},
[BlockNameLowercase]: {
titleField,
timeField,
}
},
'x-component': 'CardItem',
properties: {
[BlockNameLowercase]: {
type: 'void',
'x-component': BlockName,
'x-use-component-props': 'useTimelineProps',
}
}
}
}
export function useTimelineProps(): TimelineProps {
const dataProps = useDataBlockProps();
const props = dataProps[BlockNameLowercase];
const { loading, data } = useDataBlockRequest<any[]>();
return {
loading,
data: data?.data?.map((item) => ({
label: item[props.timeField],
children: item[props.titleField],
}))
}
}
There are 2 points to explain here:
getTimelineSchema() accepts dataSource, collection, titleField, timeField and returns a Schema, which is used to render the Timeline block:
type: 'void': Indicates no datax-decorator: 'DataBlockProvider': Data block provider, used to provide data. For more information about DataBlockProvider, please refer to DataBlockProviderx-decorator-props: Properties of DataBlockProviderdataSource: Data sourcecollection: Data tableaction: 'list': Operation type, here it is list, to get the data listparams: { sort }: Request parameters, here we sort timeField in descending order. For more information about request parameters, please refer to useRequestx-component: 'CardItem': CardItem component, currently all blocks are wrapped in cards, which provide styles, layouts, and drag-and-drop functionality'x-component': 'Timeline': Block component, which is the Timeline component we defined'x-use-component-props': 'useTimelineProps': Used to handle the dynamic properties of the Timeline component, and because it needs to be stored in the database, the value type here is a string type.useTimelineProps(): Dynamic properties of the Timeline component
x-decorator-propsThe above Schema is equivalent to the following React component:
<DataBlockProvider collection={collection} dataSource={dataSource} action='list' params={{ sort: `-${timeField}` }} timeline={{ titleField, timeField }}>
<CardItem>
<Timeline {...useTimelineProps()} />
</CardItem>
</DataBlockProvider>
We modify packages/plugins/@nocobase-sample/plugin-initializer-block-data-modal/src/client/index.tsx file to register useTimelineProps to the system, so that x-use-component-props can find the corresponding scope.
import { Plugin } from '@nocobase/client';
import { Timeline } from './component';
import { useTimelineProps } from './schema';
export class PluginInitializerBlockDataModalClient extends Plugin {
async load() {
this.app.addComponents({ Timeline })
this.app.addScopes({ useTimelineProps });
}
}
export default PluginInitializerBlockDataModalClient;
For more information about Scope, please refer to Global Registration of Component and Scope
Same as verifying components, we can verify the Schema by temporary page verification or documentation example verification. Here we use temporary page verification as an example:
import { Plugin, SchemaComponent } from '@nocobase/client';
import { Timeline, getTimelineSchema, useTimelineProps } from './component';
import React from 'react';
export class PluginInitializerBlockDataModalClient extends Plugin {
async load() {
// ...
this.app.router.add('admin.timeline-schema', {
path: '/admin/timeline-schema',
Component: () => {
return <>
<div style={{ marginTop: 20, marginBottom: 20 }}>
<SchemaComponent schema={{ properties: { test1: getTimelineSchema({ collection: 'users' })({ timeField: 'createdAt', titleField: 'nickname' }) } }} />
</div>
</>
}
})
}
}
export default PluginInitializerBlockDataModalClient;
components and scope required in Schema. For details, please refer to Local Registration of Component and ScopeWe visit http://localhost:13000/admin/timeline-schema to see the corresponding test page content.
After verification, the test page needs to be deleted.
We create packages/plugins/@nocobase-sample/plugin-initializer-block-data-modal/src/client/initializer/index.tsx file to define Schema Initializer Item:
import React, { useCallback, useState } from 'react';
import { FieldTimeOutlined } from '@ant-design/icons';
import { DataBlockInitializer, SchemaInitializerItemType, useSchemaInitializer } from "@nocobase/client";
import { getTimelineSchema } from '../schema';
import { useT } from '../locale';
import { TimelineConfigFormProps, TimelineInitializerConfigForm } from './ConfigForm';
import { BlockName, BlockNameLowercase } from '../constants';
export const TimelineInitializerComponent = () => {
const { insert } = useSchemaInitializer();
const [collection, setCollection] = useState<string>();
const [dataSource, setDataSource] = useState<string>();
const [showConfigForm, setShowConfigForm] = useState(false);
const t = useT()
const onSubmit: TimelineConfigFormProps['onSubmit'] = useCallback((values) => {
const schema = getTimelineSchema({ collection, dataSource, timeField: values.timeField, titleField: values.titleField });
insert(schema);
}, [collection, dataSource])
return <>
{showConfigForm && <TimelineInitializerConfigForm
visible={showConfigForm}
setVisible={setShowConfigForm}
onSubmit={onSubmit}
collection={collection}
dataSource={dataSource}
/>}
<DataBlockInitializer
name={BlockNameLowercase}
title={t(BlockName)}
icon={<FieldTimeOutlined />}
componentType={BlockName}
onCreateBlockSchema={({ item }) => {
const { name: collection, dataSource } = item;
setCollection(collection);
setDataSource(dataSource);
setShowConfigForm(true);
}}>
</DataBlockInitializer>
</>
}
export const timelineInitializerItem: SchemaInitializerItemType = {
name: 'Timeline',
Component: TimelineInitializerComponent,
}
The operation flow is to first click on the data table to get the values of collection and dataSource, then get the timeField and titleField fields through the configuration form TimelineInitializerConfigForm, and when the form is submitted, create a schema based on the data and insert it into the page.
The core to achieving the data block effect is DataBlockInitializer (documentation TODO).
timelineInitializerItem:
name: Unique identifier, used for CRUDComponent: Unlike Adding Simple Block Simple Block which uses type, here we use Component. 2 ways to define are both acceptableTimelineInitializerComponent:
DataBlockInitializer
title: Titleicon: Icon, more icons can be found at Ant Design IconscomponentType: Component type, here it is TimelineonCreateBlockSchema: Callback after clicking the data table
item: Information of the clicked data table
item.name: Data table nameitem.dataSource: Data source of the data tableFor more information about Schema Initializer definitions, please refer to the Schema Initializer documentation.
A complete Block also needs to have Schema Settings, which are used to configure some properties and operations, but Schema Settings is not the focus of this example, so we only have a remove operation here.
We create packages/plugins/@nocobase-sample/plugin-initializer-block-data-modal/src/client/settings/index.ts file with the following content:
import { SchemaSettings } from "@nocobase/client";
export const timelineSettings = new SchemaSettings({
name: 'blockSettings:info',
items: [
{
type: 'remove',
name: 'remove',
componentProps: {
removeParentsIfNoChildren: true,
breakRemoveOn: {
'x-component': 'Grid',
},
}
}
]
})
removeParentsIfNoChildren: Whether to delete the parent node if there are no child nodesbreakRemoveOn: Break condition when deleting. Because Add Block automatically wraps children in Grid, we set breakRemoveOn: { 'x-component': 'Grid' } here, so when deleting Grid, it doesn't delete upwards anymore.import { Plugin } from '@nocobase/client';
import { timelineSettings } from './settings';
export class PluginInitializerBlockDataModalClient extends Plugin {
async load() {
// ...
this.app.schemaSettingsManager.add(timelineSettings)
}
}
export default PluginInitializerBlockDataModalClient;
We need to modify the getTimelineSchema() in packages/plugins/@nocobase-sample/plugin-initializer-block-data-modal/src/client/schema/index.tsx:
+ import { timelineSettings } from '../settings';
export function getTimelineSchema(options: GetTimelineSchemaOptions) {
const { dataSource, collection, titleField, timeField } = options;
return {
type: 'void',
'x-decorator': 'DataBlockProvider',
+ 'x-settings': timelineSettings.name,
// ...
}
}
There are many Add block buttons in the system, but their names are different.
If we need to add it to the page-level Add block, we need to know the corresponding name. We can view the corresponding name through TODO method.
TODO
From the above figure, we can see that the page-level Add block corresponds to the name page:addBlock, and Data Blocks corresponds to the name dataBlocks.
Then we modify packages/plugins/@nocobase-sample/plugin-initializer-block-data-modal/src/client/index.tsx file:
import { Plugin } from '@nocobase/client';
import { Timeline } from './component';
import { useTimelineProps } from './schema';
import { timelineSettings } from './settings';
import { timelineInitializerItem } from './timelineInitializerItem';
export class PluginInitializerBlockDataModalClient extends Plugin {
async load() {
this.app.addComponents({ Timeline })
this.app.addScopes({ useTimelineProps });
this.app.schemaSettingsManager.add(timelineSettings)
this.app.schemaInitializerManager.addItem('page:addBlock', `dataBlocks.${timelineInitializerItem.name}`, timelineInitializerItem)
}
}
export default PluginInitializerBlockDataModalClient;
<video controls width='100%' src="https://static-docs.nocobase.com/20240529222118_rec_.mp4"></video>
We need to add it not only to the page-level Add block, but also to the Add block in the Table block Add new modal.
According to the method of obtaining the page-level name, we get the Add block name of the Table block as popup:addNew:addBlock, and Data Blocks corresponds to the name dataBlocks.
Then modify packages/plugins/@nocobase-sample/plugin-initializer-block-data-modal/src/client/index.tsx file:
import { Plugin } from '@nocobase/client';
import { Timeline } from './component';
import { useTimelineProps } from './schema';
import { timelineSettings } from './settings';
import { timelineInitializerItem } from './timelineInitializerItem';
export class PluginInitializerBlockDataModalClient extends Plugin {
async load() {
this.app.addComponents({ Timeline })
this.app.addScopes({ useTimelineProps });
this.app.schemaSettingsManager.add(timelineSettings)
this.app.schemaInitializerManager.addItem('page:addBlock', `dataBlocks.${timelineInitializerItem.name}`, timelineInitializerItem)
+ this.app.schemaInitializerManager.addItem('popup:addNew:addBlock', `dataBlocks.${timelineInitializerItem.name}`, timelineInitializerItem);
}
}
export default PluginInitializerBlockDataModalClient;
First, you need to activate the mobile plugin, refer to the Activate Plugin documentation.
We can add it to the mobile Add block. The method of obtaining the name will not be repeated here.
Then modify packages/plugins/@nocobase-sample/plugin-initializer-block-data-modal/src/client/index.tsx file:
// ...
export class PluginInitializerBlockDataModalClient extends Plugin {
async load() {
// ...
this.app.schemaInitializerManager.addItem('mobilePage:addBlock', `dataBlocks.${timelineInitializerItem.name}`, timelineInitializerItem);
}
}
export default PluginInitializerBlockDataModalClient;
If you need more Add block, you can continue to add them, just need to know the corresponding name.
We edit packages/plugins/@nocobase-sample/plugin-initializer-block-data-modal/src/locale/en-US.json file:
{
"Timeline": "Timeline",
"Title Field": "Title Field",
"Time Field": "Time Field"
}
We edit packages/plugins/@nocobase-sample/plugin-initializer-block-data-modal/src/locale/zh-CN.json file:
{
"Timeline": "时间线",
"Title Field": "标题字段",
"Time Field": "时间字段"
}
We can add multiple languages through http://localhost:13000/admin/settings/system-settings, and switch languages in the upper right corner.
According to the Build and Package Plugin documentation, we can package the plugin and upload it to the production environment.
If you cloned the source code, you need to execute a full build first to build the plugin's dependencies as well.
yarn build
If you used create-nocobase-app to create the project, you can directly execute:
yarn build @nocobase-sample/plugin-initializer-block-data-modal --tar
This way you can see the storage/tar/@nocobase-sample/plugin-initializer-block-data-modal.tar.gz file, and then install it by uploading.