feat: block templates (#240)

* feat: block templates

* feat: improve code

* feat: improve code

* feat: schema  settings for block templates

* feat: improve code

* feat: transition

* feat: transition
This commit is contained in:
chenos 2022-03-17 09:41:48 +08:00 committed by GitHub
parent 5c92d3ba46
commit 2fb019c392
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 1025 additions and 193 deletions

View File

@ -5,6 +5,8 @@ import {
AntdSchemaComponentProvider,
APIClientProvider,
AuthLayout,
BlockTemplateDetails,
BlockTemplatePage,
ChinaRegionProvider,
CollectionManagerShortcut,
compose,
@ -13,19 +15,20 @@ import {
i18n,
MenuItemInitializers,
PluginManagerProvider,
RemoteRouteSwitchProvider,
// RemoteCollectionManagerProvider,
RouteSchemaComponent,
RouteSwitch,
RouteSwitchProvider,
SchemaComponentProvider,
SchemaInitializerProvider,
SchemaTemplateShortcut,
SigninPage,
SignupPage,
SystemSettingsProvider,
SystemSettingsShortcut,
useRequest
useRoutes
} from '@nocobase/client';
import { notification, Spin } from 'antd';
import { notification } from 'antd';
import 'antd/dist/antd.css';
import React from 'react';
import { I18nextProvider } from 'react-i18next';
@ -50,6 +53,20 @@ const providers = [
[APIClientProvider, { apiClient }],
[I18nextProvider, { i18n }],
[AntdConfigProvider, { remoteLocale: true }],
[
RemoteRouteSwitchProvider,
{
components: {
AuthLayout,
AdminLayout,
RouteSchemaComponent,
SigninPage,
SignupPage,
BlockTemplatePage,
BlockTemplateDetails,
},
},
],
SystemSettingsProvider,
[
PluginManagerProvider,
@ -59,39 +76,30 @@ const providers = [
DesignableSwitch,
CollectionManagerShortcut,
SystemSettingsShortcut,
SchemaTemplateShortcut,
},
},
],
[SchemaComponentProvider, { components: { Link, NavLink } }],
// RemoteCollectionManagerProvider,
[SchemaInitializerProvider, { initializers: { MenuItemInitializers } }],
AntdSchemaComponentProvider,
ChinaRegionProvider,
[DocumentTitleProvider, { addonAfter: 'NocoBase' }],
[
RouteSwitchProvider,
SchemaInitializerProvider,
{
components: {
AuthLayout,
AdminLayout,
RouteSchemaComponent,
SigninPage,
SignupPage,
initializers: {
MenuItemInitializers,
},
},
],
AntdSchemaComponentProvider,
ChinaRegionProvider,
[DocumentTitleProvider, { addonAfter: 'NocoBase' }],
];
const App = compose(...providers)(() => {
const { data, loading } = useRequest({
url: 'uiRoutes:getAccessible',
});
if (loading) {
return <Spin />;
}
const routes = useRoutes();
return (
<div>
<RouteSwitch routes={data?.data || []} />
<RouteSwitch routes={routes} />
</div>
);
});

View File

@ -19,6 +19,7 @@ export * from './route-switch';
export * from './schema-component';
export * from './schema-initializer';
export * from './schema-settings';
export * from './schema-templates';
export * from './settings-form';
export * from './system-settings';
export * from './user';

View File

@ -279,5 +279,10 @@
"Add card": "添加卡片",
"edit title": "修改标题",
"turn page": "翻页"
"turn page": "翻页",
"Save as template": "保存为模板",
"Block templates": "区块模板",
"Convert reference to duplicate": "模板引用转为复制",
"Template name": "模板名称",
"Block type": "区块类型"
}

View File

@ -1,8 +1,20 @@
import { Spin } from 'antd';
import React from 'react';
import { useRequest } from '../api-client';
import { RouteSwitchContext } from './context';
import { RouteSwitchProviderProps } from './types';
export function RouteSwitchProvider(props: RouteSwitchProviderProps) {
const { children, components } = props;
return <RouteSwitchContext.Provider value={{ components }}>{children}</RouteSwitchContext.Provider>;
const { children, components, routes } = props;
return <RouteSwitchContext.Provider value={{ routes, components }}>{children}</RouteSwitchContext.Provider>;
}
export function RemoteRouteSwitchProvider(props: RouteSwitchProviderProps) {
const { data, loading } = useRequest({
url: 'uiRoutes:getAccessible',
});
if (loading) {
return <Spin />;
}
return <RouteSwitchProvider {...props} routes={data?.data || []} />;
}

View File

@ -10,6 +10,7 @@ import {
PluginManager,
RemoteCollectionManagerProvider,
RemoteSchemaComponent,
RemoteSchemaTemplateManagerProvider,
useDocumentTitle,
useRoute,
useSystemSettings
@ -94,6 +95,7 @@ const InternalAdminLayout = (props: any) => {
{ component: 'DesignableSwitch', pin: true },
{ component: 'CollectionManagerShortcut', pin: true },
{ component: 'ACLShortcut', pin: true },
{ component: 'SchemaTemplateShortcut', pin: true },
{ component: 'SystemSettingsShortcut' },
]}
/>
@ -110,11 +112,13 @@ const InternalAdminLayout = (props: any) => {
export const AdminLayout = (props) => {
return (
<RemoteCollectionManagerProvider>
<CurrentUserProvider>
<InternalAdminLayout {...props} />
</CurrentUserProvider>
</RemoteCollectionManagerProvider>
<RemoteSchemaTemplateManagerProvider>
<RemoteCollectionManagerProvider>
<CurrentUserProvider>
<InternalAdminLayout {...props} />
</CurrentUserProvider>
</RemoteCollectionManagerProvider>
</RemoteSchemaTemplateManagerProvider>
);
};

View File

@ -3,6 +3,7 @@ import { RouteProps } from './types';
export const RouteSwitchContext = createContext({
components: {},
routes: [],
});
export const RouteContext = createContext<RouteProps>(null);

View File

@ -1,5 +1,5 @@
import { useContext } from 'react';
import get from 'lodash/get';
import { useContext } from 'react';
import { RouteContext, RouteSwitchContext } from './context';
export function useRouteComponent(name?: string) {
@ -13,3 +13,8 @@ export function useRouteComponent(name?: string) {
export function useRoute() {
return useContext(RouteContext);
}
export function useRoutes() {
const { routes } = useContext(RouteSwitchContext);
return routes || [];
}

View File

@ -23,6 +23,7 @@ export interface RouteProps {
export interface RouteSwitchProviderProps {
components?: any;
children?: any;
routes?: RouteRedirectProps[];
}
export type RouteRedirectProps = RedirectProps | RouteProps;

View File

@ -4,6 +4,7 @@ import { useCompile, useDesignable } from '../..';
import { useCollection, useResourceActionContext } from '../../../collection-manager';
import { useCollectionFilterOptions } from '../../../collection-manager/action-hooks';
import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings';
import { useSchemaTemplate } from '../../../schema-templates';
export const CalendarDesigner = () => {
const field = useField();
@ -13,6 +14,7 @@ export const CalendarDesigner = () => {
const ctx = useResourceActionContext();
const { dn } = useDesignable();
const compile = useCompile();
const template = useSchemaTemplate();
const defaultFilter = fieldSchema?.['x-decorator-props']?.request?.params?.filter || {};
const options = fields?.map((field) => {
return {
@ -23,7 +25,7 @@ export const CalendarDesigner = () => {
const calendarSchema = fieldSchema.properties.calendar;
const fieldNames = calendarSchema?.['x-component-props']?.['fieldNames'] || {};
return (
<GeneralSchemaDesigner title={title || name}>
<GeneralSchemaDesigner template={template} title={title || name}>
<SchemaSettings.SelectItem
title={'标题字段'}
value={fieldNames.title}
@ -128,6 +130,8 @@ export const CalendarDesigner = () => {
}}
/>
<SchemaSettings.Divider />
<SchemaSettings.Template componentName={'Calendar'} collectionName={name} />
<SchemaSettings.Divider />
<SchemaSettings.Remove
removeParentsIfNoChildren
breakRemoveOn={{

View File

@ -6,6 +6,8 @@ export const FormDesigner = () => {
const { name, title } = useCollection();
return (
<GeneralSchemaDesigner title={title || name}>
<SchemaSettings.Template componentName={'Form'} collectionName={name} />
<SchemaSettings.Divider />
<SchemaSettings.Remove
removeParentsIfNoChildren
breakRemoveOn={{

View File

@ -8,6 +8,7 @@ import { useAttach, useComponent } from '../..';
import { useRequest } from '../../../api-client';
import { useCollection } from '../../../collection-manager';
import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings';
import { useSchemaTemplate } from '../../../schema-templates';
type Opts = Options<any, any> & { uid?: string };
@ -113,8 +114,11 @@ export const Form: React.FC<FormProps> & { Designer?: any } = observer((props) =
Form.Designer = () => {
const { name, title } = useCollection();
const template = useSchemaTemplate();
return (
<GeneralSchemaDesigner title={title || name}>
<GeneralSchemaDesigner template={template} title={title || name}>
<SchemaSettings.Template componentName={'Form'} collectionName={name} />
<SchemaSettings.Divider />
<SchemaSettings.Remove
removeParentsIfNoChildren
breakRemoveOn={{

View File

@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useCollection, useResourceActionContext } from '../../../collection-manager';
import { useCollectionFilterOptions } from '../../../collection-manager/action-hooks';
import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings';
import { useSchemaTemplate } from '../../../schema-templates';
import { useDesignable } from '../../hooks';
export const KanbanDesigner = () => {
@ -15,8 +16,9 @@ export const KanbanDesigner = () => {
const { t } = useTranslation();
const { dn } = useDesignable();
const defaultFilter = fieldSchema?.['x-decorator-props']?.request?.params?.filter || {};
const template = useSchemaTemplate();
return (
<GeneralSchemaDesigner title={title || name}>
<GeneralSchemaDesigner template={template} title={title || name}>
<SchemaSettings.ModalItem
title={'设置数据范围'}
schema={
@ -49,6 +51,8 @@ export const KanbanDesigner = () => {
}}
/>
<SchemaSettings.Divider />
<SchemaSettings.Template componentName={'Kanban'} collectionName={name} />
<SchemaSettings.Divider />
<SchemaSettings.Remove
removeParentsIfNoChildren
breakRemoveOn={{

View File

@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { useCollection, useResourceActionContext } from '../../../collection-manager';
import { useCollectionFilterOptions, useSortFields } from '../../../collection-manager/action-hooks';
import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings';
import { useSchemaTemplate } from '../../../schema-templates';
import { useDesignable } from '../../hooks';
export const TableVoidDesigner = () => {
@ -29,8 +30,9 @@ export const TableVoidDesigner = () => {
direction: 'asc',
};
});
const template = useSchemaTemplate();
return (
<GeneralSchemaDesigner title={title || name}>
<GeneralSchemaDesigner template={template} title={title || name}>
<SchemaSettings.SwitchItem
title={t('Enable drag and drop sorting')}
checked={field.decoratorProps.dragSort}
@ -193,6 +195,8 @@ export const TableVoidDesigner = () => {
}}
/>
<SchemaSettings.Divider />
<SchemaSettings.Template componentName={'Table'} collectionName={name}/>
<SchemaSettings.Divider />
<SchemaSettings.Remove
removeParentsIfNoChildren
breakRemoveOn={{

View File

@ -11,6 +11,7 @@ interface CreateDesignableProps {
current: Schema;
api?: APIClient;
refresh?: () => void;
onSuccess?: any;
}
export function createDesignable(options: CreateDesignableProps) {
@ -26,6 +27,7 @@ interface InsertAdjacentOptions {
wrap?: (s: ISchema) => ISchema;
removeParentsIfNoChildren?: boolean;
breakRemoveOn?: ISchema | BreakFn;
onSuccess?: any;
}
type BreakFn = (s: ISchema) => boolean;
@ -80,7 +82,7 @@ export class Designable {
if (!api) {
return;
}
this.on('insertAdjacent', async ({ current, position, schema, removed }) => {
this.on('insertAdjacent', async ({ onSuccess, current, position, schema, removed }) => {
refresh();
await api.request({
url: `/uiSchemas:insertAdjacent/${current['x-uid']}?position=${position}`,
@ -93,6 +95,7 @@ export class Designable {
method: 'post',
});
}
onSuccess?.();
message.success('配置保存成功!', 0.2);
});
this.on('patch', async ({ schema }) => {
@ -229,6 +232,19 @@ export class Designable {
this.emit('remove', { removed });
}
removeWithoutEmit(schema?: Schema, options: RemoveOptions = {}) {
const { breakRemoveOn, removeParentsIfNoChildren } = options;
let s = schema || this.current;
let removed = s.parent.removeProperty(s.name);
if (removeParentsIfNoChildren) {
const parent = this.recursiveRemoveIfNoChildren(s.parent, { breakRemoveOn });
if (parent) {
removed = parent;
}
}
return removed;
}
insertBeforeBeginOrAfterEnd(schema: ISchema, options: InsertAdjacentOptions = {}) {
if (!Schema.isSchemaInstance(this.current)) {
return;
@ -393,7 +409,7 @@ export class Designable {
if (!Schema.isSchemaInstance(this.current)) {
return;
}
const opts = {};
const opts = { onSuccess: options?.onSuccess };
const { wrap = defaultWrap, breakRemoveOn, removeParentsIfNoChildren } = options;
if (Schema.isSchemaInstance(schema)) {
if (this.parentsIn(schema)) {

View File

@ -5,7 +5,9 @@ import React, { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { useCollectionManager } from '../../../collection-manager';
import { SchemaComponent, SchemaComponentOptions } from '../../../schema-component';
import { useSchemaTemplateManager } from '../../../schema-templates';
import { SchemaInitializer } from '../../SchemaInitializer';
import { useCollectionDataSourceItems } from '../utils';
const createSchema = (collectionName, { title, start, end }) => {
const schema: ISchema = {
@ -102,11 +104,17 @@ export const CalendarBlockInitializer = (props) => {
const { collections, getCollection } = useCollectionManager();
const { t } = useTranslation();
const options = useContext(SchemaOptionsContext);
const { getTemplateSchemaByMode } = useSchemaTemplateManager();
return (
<SchemaInitializer.Item
{...props}
icon={<FormOutlined />}
onClick={async ({ item }) => {
if (item.template) {
const s = await getTemplateSchemaByMode(item);
insert(s);
return;
}
const collection = getCollection(item.name);
const stringFields = collection?.fields
?.filter((field) => field.type === 'string')
@ -161,23 +169,10 @@ export const CalendarBlockInitializer = (props) => {
}).open({
initialValues: {},
});
insert(createSchema(item.name, values));
}}
items={[
{
type: 'itemGroup',
title: t('Select data source'),
children: collections
?.filter((item) => !item.inherit)
?.map((item) => {
return {
type: 'item',
name: item.name,
title: item.title,
};
}),
},
]}
items={useCollectionDataSourceItems('Calendar')}
/>
);
};

View File

@ -4,6 +4,8 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import { SchemaInitializer } from '../..';
import { useCollectionManager } from '../../../collection-manager';
import { useSchemaTemplateManager } from '../../../schema-templates';
import { useCollectionDataSourceItems } from '../utils';
const createSchema = (collectionName) => {
const schema: ISchema = {
@ -55,28 +57,20 @@ export const FormBlockInitializer = (props) => {
const { insert } = props;
const { collections } = useCollectionManager();
const { t } = useTranslation();
const { getTemplateSchemaByMode } = useSchemaTemplateManager();
return (
<SchemaInitializer.Item
{...props}
icon={<FormOutlined />}
onClick={({ item }) => {
insert(createSchema(item.name));
onClick={async ({ item }) => {
if (item.template) {
const s = await getTemplateSchemaByMode(item);
insert(s);
} else {
insert(createSchema(item.name));
}
}}
items={[
{
type: 'itemGroup',
title: t('Select data source'),
children: collections
?.filter((item) => !item.inherit)
?.map((item) => {
return {
type: 'item',
name: item.name,
title: item.title,
};
}),
},
]}
items={useCollectionDataSourceItems('Form')}
/>
);
};

View File

@ -6,7 +6,9 @@ import { useTranslation } from 'react-i18next';
import { useAPIClient } from '../../../api-client';
import { useCollectionManager } from '../../../collection-manager';
import { SchemaComponent, SchemaComponentOptions } from '../../../schema-component';
import { useSchemaTemplateManager } from '../../../schema-templates';
import { SchemaInitializer } from '../../SchemaInitializer';
import { useCollectionDataSourceItems } from '../utils';
const createSchema = (collectionName, { groupField, sortName }) => {
const schema: ISchema = {
@ -158,6 +160,7 @@ export const KanbanBlockInitializer = (props) => {
const { insert } = props;
const { collections, getCollection } = useCollectionManager();
const { t } = useTranslation();
const { getTemplateSchemaByMode } = useSchemaTemplateManager();
const options = useContext(SchemaOptionsContext);
const api = useAPIClient();
return (
@ -165,6 +168,11 @@ export const KanbanBlockInitializer = (props) => {
{...props}
icon={<FormOutlined />}
onClick={async ({ item }) => {
if (item.template) {
const s = await getTemplateSchemaByMode(item);
insert(s);
return;
}
const collection = getCollection(item.name);
const fields = collection?.fields
?.filter((field) => ['select', 'radioGroup'].includes(field.interface))
@ -219,21 +227,7 @@ export const KanbanBlockInitializer = (props) => {
}
insert(createSchema(item.name, { ...values, sortName }));
}}
items={[
{
type: 'itemGroup',
title: t('Select data source'),
children: collections
?.filter((item) => !item.inherit)
?.map((item) => {
return {
type: 'item',
name: item.name,
title: item.title,
};
}),
},
]}
items={useCollectionDataSourceItems('Kanban')}
/>
);
};

View File

@ -1,11 +1,11 @@
import { TableOutlined } from '@ant-design/icons';
import { ISchema } from '@formily/react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { SchemaInitializer } from '../..';
import { useCollectionManager } from '../../../collection-manager';
import { useSchemaTemplateManager } from '../../../schema-templates';
import { useCollectionDataSourceItems } from '../utils';
const createSchema = (collectionName) => {
export const createTableBlockSchema = (collectionName) => {
const schema: ISchema = {
type: 'void',
'x-collection': 'collections',
@ -79,30 +79,20 @@ const createSchema = (collectionName) => {
export const TableBlockInitializer = (props) => {
const { insert } = props;
const { collections } = useCollectionManager();
const { t } = useTranslation();
const { getTemplateSchemaByMode } = useSchemaTemplateManager();
return (
<SchemaInitializer.Item
{...props}
icon={<TableOutlined />}
onClick={({ item }) => {
insert(createSchema(item.name));
onClick={async ({ item }) => {
if (item.template) {
const s = await getTemplateSchemaByMode(item);
insert(s);
} else {
insert(createTableBlockSchema(item.name));
}
}}
items={[
{
type: 'itemGroup',
title: t('Select data source'),
children: collections
?.filter((item) => !item.inherit)
?.map((item) => {
return {
type: 'item',
name: item.name,
title: item.title,
};
}),
},
]}
items={useCollectionDataSourceItems('Table')}
/>
);
};

View File

@ -1,8 +1,10 @@
import { ISchema, Schema, useFieldSchema } from '@formily/react';
import { uid } from '@formily/shared';
import { useTranslation } from 'react-i18next';
import { SchemaInitializerItemOptions } from '../';
import { useCollection } from '../../collection-manager';
import { useCollection, useCollectionManager } from '../../collection-manager';
import { useDesignable } from '../../schema-component';
import { useSchemaTemplateManager } from '../../schema-templates';
export const gridRowColWrap = (schema: ISchema) => {
return {
@ -203,3 +205,76 @@ export const useCurrentSchema = (action: string, key: string, find = findSchema,
},
};
};
export const useCollectionDataSourceItems = (componentName) => {
const { t } = useTranslation();
const { collections } = useCollectionManager();
const { getTemplatesByCollection } = useSchemaTemplateManager();
return [
{
key: 'tableBlock',
type: 'itemGroup',
title: t('Select data source'),
children: collections
?.filter((item) => !item.inherit)
?.map((item, index) => {
const templates = getTemplatesByCollection(item.name).filter((template) => {
return componentName && template.componentName === componentName;
});
if (!templates.length) {
return {
type: 'item',
name: item.name,
title: item.title,
};
}
return {
key: `${componentName}_table_subMenu_${index}`,
type: 'subMenu',
name: `${item.name}_${index}`,
title: item.title,
children: [
{
type: 'item',
name: item.name,
title: '空白区块',
},
{
type: 'divider',
},
{
key: `${componentName}_table_subMenu_${index}_copy`,
type: 'subMenu',
name: 'copy',
title: '复制模板',
children: templates.map((template) => {
return {
type: 'item',
mode: 'copy',
name: item.name,
template,
title: template.name || '未命名',
};
}),
},
{
key: `${componentName}_table_subMenu_${index}_ref`,
type: 'subMenu',
name: 'ref',
title: '引用模板',
children: templates.map((template) => {
return {
type: 'item',
mode: 'reference',
name: item.name,
template,
title: template.name || '未命名',
};
}),
},
],
};
}),
},
];
};

View File

@ -42,76 +42,56 @@ SchemaInitializer.Button = observer((props: SchemaInitializerButtonProps) => {
insertAdjacent(insertPosition, wrap(schema));
}
};
const menu = (
<Menu>
{items?.map((item, indexA) => {
if (item.type === 'divider') {
return <Menu.Divider key={`item-${indexA}`} />;
}
if (item.type === 'item' && item.component) {
const Component = findComponent(item.component);
return (
Component && (
<SchemaInitializerItemContext.Provider
key={`item-${indexA}`}
value={{
index: indexA,
item,
info: item,
insert: insertSchema,
}}
>
<Component
{...item}
item={{
...item,
title: compile(item.title),
}}
insert={insertSchema}
/>
</SchemaInitializerItemContext.Provider>
)
);
}
const renderItems = (items: any) => {
return items?.map((item, indexA) => {
if (item.type === 'divider') {
return <Menu.Divider key={item.key || `item-${indexA}`} />;
}
if (item.type === 'item' && item.component) {
const Component = findComponent(item.component);
return (
item?.children?.length > 0 && (
<Menu.ItemGroup key={`item-group-${indexA}`} title={compile(item.title)}>
{item?.children?.map((child, indexB) => {
if (!child.component) {
return null;
}
const Component = findComponent(child.component);
return (
Component && (
<SchemaInitializerItemContext.Provider
key={`item-${indexB}`}
value={{
index: indexB,
item: child,
info: child,
insert: insertSchema,
}}
>
<Component
{...child}
item={{
...child,
title: compile(child.title),
}}
insert={insertSchema}
/>
</SchemaInitializerItemContext.Provider>
)
);
})}
</Menu.ItemGroup>
Component && (
<SchemaInitializerItemContext.Provider
key={`item-${indexA}`}
value={{
index: indexA,
item,
info: item,
insert: insertSchema,
}}
>
<Component
{...item}
item={{
...item,
title: compile(item.title),
}}
insert={insertSchema}
/>
</SchemaInitializerItemContext.Provider>
)
);
})}
</Menu>
);
}
if (item.type === 'itemGroup') {
return (
<Menu.ItemGroup key={item.key || `item-group-${indexA}`} title={compile(item.title)}>
{renderItems(item.children)}
</Menu.ItemGroup>
);
}
if (item.type === 'subMenu') {
return (
<Menu.SubMenu key={item.key || `item-group-${indexA}`} title={compile(item.title)}>
{renderItems(item.children)}
</Menu.SubMenu>
);
}
});
};
if (!designable) {
const menu = <Menu>{renderItems(items)}</Menu>;
if (!designable && props.designable !== true) {
return null;
}
@ -124,15 +104,17 @@ SchemaInitializer.Button = observer((props: SchemaInitializerButtonProps) => {
{...dropdown}
overlay={menu}
>
{component ? component : (
{component ? (
component
) : (
<Button
{...others}
type={'dashed'}
style={{
...style,
borderColor: '#f18b62',
color: '#f18b62',
...style,
}}
{...others}
>
{compile(props.children || props.title)}
</Button>
@ -156,16 +138,24 @@ SchemaInitializer.Item = (props: SchemaInitializerItemProps) => {
}
if (item.type === 'itemGroup') {
return (
// @ts-ignore
<Menu.ItemGroup eventKey={`item-group-${indexA}`} key={`item-group-${indexA}`} title={compile(item.title)}>
<Menu.ItemGroup
// @ts-ignore
eventKey={item.key || `item-group-${indexA}`}
key={item.key || `item-group-${indexA}`}
title={compile(item.title)}
>
{renderMenuItem(item.children)}
</Menu.ItemGroup>
);
}
if (item.type === 'subMenu') {
return (
// @ts-ignore
<Menu.SubMenu eventKey={`sub-menu-${indexA}`} key={`sub-menu-${indexA}`} title={compile(item.title)}>
<Menu.SubMenu
// @ts-ignore
eventKey={item.key || `sub-menu-${indexA}`}
key={item.key || `sub-menu-${indexA}`}
title={compile(item.title)}
>
{renderMenuItem(item.children)}
</Menu.SubMenu>
);

View File

@ -8,12 +8,14 @@ export interface SchemaInitializerButtonProps extends ButtonProps {
items?: SchemaInitializerItemOptions[];
dropdown?: DropDownProps;
component?: any;
designable?: boolean;
}
export type SchemaInitializerItemOptions = ItemGroupOptions | SubMenuOptions | ItemOptions | DividerOptions;
interface ItemCommonOptions {
title?: any;
key?: string;
}
interface ItemGroupOptions extends ItemCommonOptions {

View File

@ -11,19 +11,26 @@ const titleCss = css`
pointer-events: none;
position: absolute;
font-size: 12px;
background: #f18b62;
color: #fff;
padding: 0 5px;
/* background: #f18b62;
color: #fff; */
padding: 0;
line-height: 16px;
height: 16px;
border-bottom-right-radius: 2px;
border-radius: 2px;
top: 2px;
left: 2px;
.title-tag {
padding: 0 3px;
border-radius: 2px;
background: #f18b62;
color: #fff;
display: block;
}
`;
export const GeneralSchemaDesigner = (props: any) => {
const { title, draggable = true } = props;
const { title, template, draggable = true } = props;
const { dn, designable } = useDesignable();
const field = useField();
const fieldSchema = useFieldSchema();
@ -38,7 +45,14 @@ export const GeneralSchemaDesigner = (props: any) => {
}
return (
<div className={'general-schema-designer'}>
{title && <div className={classNames('general-schema-designer-title', titleCss)}>{compile(title)}</div>}
{title && (
<div className={classNames('general-schema-designer-title', titleCss)}>
<Space size={2}>
<span className={'title-tag'}>{compile(title)}</span>
{template && <span className={'title-tag'}>: {template?.name || '未命名'}</span>}
</Space>
</div>
)}
<div className={'general-schema-designer-icons'}>
<Space size={2} align={'center'}>
{draggable && (

View File

@ -1,11 +1,22 @@
import { FormDialog, FormLayout } from '@formily/antd';
import { FormDialog, FormItem, FormLayout, Input } from '@formily/antd';
import { GeneralField } from '@formily/core';
import { ISchema, Schema, SchemaOptionsContext } from '@formily/react';
import { uid } from '@formily/shared';
import { Dropdown, Menu, MenuItemProps, Modal, Select, Switch } from 'antd';
import React, { createContext, useContext, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ActionContext, Designable, SchemaComponent, SchemaComponentOptions, useActionContext } from '..';
import {
ActionContext,
createDesignable,
Designable,
SchemaComponent,
SchemaComponentOptions,
useActionContext,
useAPIClient,
useCollection
} from '..';
import { useSchemaTemplateManager } from '../schema-templates';
import { useBlockTemplateContext } from '../schema-templates/BlockTemplate';
interface SchemaSettingsProps {
title?: any;
@ -20,6 +31,8 @@ interface SchemaSettingsContextProps {
fieldSchema?: Schema;
setVisible?: any;
visible?: any;
template?: any;
collectionName?: any;
}
const SchemaSettingsContext = createContext<SchemaSettingsContextProps>(null);
@ -48,19 +61,24 @@ interface SchemaSettingsProviderProps {
fieldSchema?: Schema;
setVisible?: any;
visible?: any;
template?: any;
collectionName?: any;
}
export const SchemaSettingsProvider: React.FC<SchemaSettingsProviderProps> = (props) => {
const { visible, setVisible, dn, field, fieldSchema, children } = props;
const { children, fieldSchema, ...others } = props;
const { getTemplateBySchemaId } = useSchemaTemplateManager();
const { name } = useCollection();
const template = getTemplateBySchemaId(fieldSchema['x-uid']);
return (
<SchemaSettingsContext.Provider value={{ visible, setVisible, dn, field, fieldSchema }}>
<SchemaSettingsContext.Provider value={{ collectionName: name, template, fieldSchema, ...others }}>
{children}
</SchemaSettingsContext.Provider>
);
};
export const SchemaSettings: React.FC<SchemaSettingsProps> & SchemaSettingsNested = (props) => {
const { title, dn, field, fieldSchema } = props;
const { title, dn, ...others } = props;
const [visible, setVisible] = useState(false);
const DropdownMenu = (
<Dropdown
@ -75,7 +93,7 @@ export const SchemaSettings: React.FC<SchemaSettingsProps> & SchemaSettingsNeste
);
if (dn) {
return (
<SchemaSettingsProvider visible={visible} setVisible={setVisible} dn={dn} field={field} fieldSchema={fieldSchema}>
<SchemaSettingsProvider visible={visible} setVisible={setVisible} dn={dn} {...others}>
{DropdownMenu}
</SchemaSettingsProvider>
);
@ -83,6 +101,86 @@ export const SchemaSettings: React.FC<SchemaSettingsProps> & SchemaSettingsNeste
return DropdownMenu;
};
SchemaSettings.Template = (props) => {
const { componentName, collectionName } = props;
const { t } = useTranslation();
const { dn, setVisible, template, fieldSchema } = useSchemaSettings();
const api = useAPIClient();
const { dn: tdn } = useBlockTemplateContext();
const { saveAsTemplate, copyTemplateSchema } = useSchemaTemplateManager();
if (!collectionName) {
return null;
}
if (template) {
return (
<SchemaSettings.Item
onClick={async () => {
const schema = await copyTemplateSchema(template);
const removed = tdn.removeWithoutEmit();
tdn.insertAfterEnd(schema, {
async onSuccess() {
await api.request({
url: `/uiSchemas:remove/${removed['x-uid']}`,
});
},
});
}}
>
{t('Convert reference to duplicate')}
</SchemaSettings.Item>
);
}
return (
<SchemaSettings.Item
onClick={async () => {
setVisible(false);
const values = await FormDialog('Save as template', () => {
return (
<FormLayout layout={'vertical'}>
<SchemaComponent
components={{ Input, FormItem }}
schema={{
type: 'object',
properties: {
name: {
title: '模板名称',
required: true,
'x-decorator': 'FormItem',
'x-component': 'Input',
},
},
}}
/>
</FormLayout>
);
}).open({});
const sdn = createDesignable({
api,
refresh: dn.refresh.bind(dn),
current: fieldSchema.parent,
});
sdn.loadAPIClientEvents();
const { key } = await saveAsTemplate({
collectionName,
componentName,
name: values.name,
uid: fieldSchema['x-uid'],
});
sdn.removeWithoutEmit(fieldSchema);
sdn.insertBeforeEnd({
type: 'void',
'x-component': 'BlockTemplate',
'x-component-props': {
templateId: key,
},
});
}}
>
{t('Save as template')}
</SchemaSettings.Item>
);
};
SchemaSettings.Item = (props) => {
const { eventKey } = props;
return (
@ -116,8 +214,9 @@ SchemaSettings.Divider = (props) => {
SchemaSettings.Remove = (props: any) => {
const { confirm, removeParentsIfNoChildren, breakRemoveOn } = props;
const { dn } = useSchemaSettings();
const { dn, template } = useSchemaSettings();
const { t } = useTranslation();
const ctx = useBlockTemplateContext();
return (
<SchemaSettings.Item
onClick={() => {
@ -126,10 +225,15 @@ SchemaSettings.Remove = (props: any) => {
content: t('Are you sure you want to delete it?'),
...confirm,
onOk() {
dn.remove(null, {
const options = {
removeParentsIfNoChildren,
breakRemoveOn,
});
};
if (template && ctx?.dn) {
ctx?.dn.remove(null, options);
} else {
dn.remove(null, options);
}
},
});
}}

View File

@ -0,0 +1,59 @@
import { uid } from '@formily/shared';
import { Button, Dropdown, Menu } from 'antd';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import { useAPIClient, useCollectionManager, useCompile, useSchemaTemplateManager } from '..';
import { createTableBlockSchema } from '../schema-initializer/Initializers/Items';
export const AddBlockTemplate = (props: any) => {
const { t } = useTranslation();
const history = useHistory();
const api = useAPIClient();
const { refresh } = useSchemaTemplateManager();
const { collections } = useCollectionManager();
const compile = useCompile();
const insert = ({ uiSchema, collectionName }) => {
const key = uid();
api
.resource('uiSchemaTemplates')
.create({
values: {
key,
collectionName,
uiSchema,
},
})
.then((res) => {
refresh();
history.push(`/admin/block-templates/${key}`);
});
};
return (
<>
<Dropdown
overlay={
<Menu>
<Menu.SubMenu title={'Table'}>
{collections?.map((collection) => {
return (
<Menu.Item
key={collection.name}
onClick={() => {
const uiSchema = createTableBlockSchema(collection.name);
insert({ uiSchema, collectionName: collection.name });
}}
>
{compile(collection.title)}
</Menu.Item>
);
})}
</Menu.SubMenu>
</Menu>
}
>
<Button type={'primary'}>{t('Add block template')}</Button>
</Dropdown>
</>
);
};

View File

@ -0,0 +1,26 @@
import { observer, useField, useFieldSchema } from '@formily/react';
import React, { createContext, useContext, useMemo } from 'react';
import { RemoteSchemaComponent, useDesignable } from '..';
import { useSchemaTemplateManager } from './SchemaTemplateManagerProvider';
const BlockTemplateContext = createContext<any>({});
export const useBlockTemplateContext = () => {
return useContext(BlockTemplateContext);
};
export const BlockTemplate = observer((props: any) => {
const { templateId } = props;
const { getTemplateById } = useSchemaTemplateManager();
const field = useField();
const fieldSchema = useFieldSchema();
const { dn } = useDesignable();
const template = useMemo(() => getTemplateById(templateId), [templateId]);
return (
<div>
<BlockTemplateContext.Provider value={{ dn, field, fieldSchema, template }}>
<RemoteSchemaComponent uid={template?.uid} />
</BlockTemplateContext.Provider>
</div>
);
});

View File

@ -0,0 +1,91 @@
import { Input, PageHeader as AntdPageHeader, Spin } from 'antd';
import React, { useContext, useState } from 'react';
import { useHistory, useRouteMatch } from 'react-router-dom';
import { useAPIClient, useRequest, useSchemaTemplateManager } from '..';
import { RemoteSchemaComponent, SchemaComponentContext } from '../schema-component';
const EditableTitle = (props) => {
const [title, setTitle] = useState(props.title);
const [visible, setVisible] = useState(false);
const { refresh } = useSchemaTemplateManager();
const api = useAPIClient();
const { run } = useRequest(
{
resource: 'uiSchemaTemplates',
action: 'update',
params: {
filterByTk: props.filterByTk,
},
},
{
manual: true,
debounceWait: 500,
onSuccess() {
refresh();
},
},
);
return (
<div>
{visible ? (
<Input
defaultValue={title}
size={'large'}
onBlur={() => {
setVisible(false);
}}
onChange={(e) => {
setTitle(e.target.value);
run({
filterByTk: props.filterByTk,
values: {
name: e.target.value,
},
});
}}
/>
) : (
<div
onClick={() => {
setVisible(true);
}}
>
{title || <span style={{ color: '#bbb' }}></span>}
</div>
)}
</div>
);
};
export const BlockTemplateDetails = () => {
const history = useHistory();
const match = useRouteMatch<any>();
const key = match?.params?.key;
const value = useContext(SchemaComponentContext);
const { data, loading } = useRequest({
resource: 'uiSchemaTemplates',
action: 'get',
params: {
filterByTk: key,
},
});
if (loading) {
return <Spin />;
}
return (
<div>
<AntdPageHeader
onBack={() => {
history.push('/admin/block-templates');
}}
ghost={false}
title={<EditableTitle filterByTk={key} title={data?.data?.name} />}
/>
<div style={{ margin: 24 }}>
<SchemaComponentContext.Provider value={{ ...value, designable: true }}>
<RemoteSchemaComponent uid={data?.data?.uid} />
</SchemaComponentContext.Provider>
</div>
</div>
);
};

View File

@ -0,0 +1,56 @@
import { Card, PageHeader as AntdPageHeader, Table } from 'antd';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useCompile, useRequest } from '..';
export const BlockTemplatePage = () => {
const { data, loading } = useRequest({
resource: 'uiSchemaTemplates',
action: 'list',
params: {
appends: ['collection'],
sort: ['-createdAt'],
},
});
const compile = useCompile();
const { t } = useTranslation();
return (
<div>
<AntdPageHeader ghost={false} title={t('Block templates')} />
<div style={{ margin: 24 }}>
<Card bordered={false}>
<Table
rowSelection={{
type: 'checkbox',
}}
columns={[
{
dataIndex: 'name',
title: t('Template name'),
render: (value) => <>{value || '未命名'}</>,
},
{
dataIndex: ['collection', 'title'],
title: t('Collection display name'),
render: (value) => compile(value),
},
{
dataIndex: 'componentName',
title: t('Block type'),
render: (value) => value,
},
{
dataIndex: 'actions',
title: t('Actions'),
render: (_, record) => <Link to={`/admin/block-templates/${record.key}`}></Link>,
},
]}
loading={loading}
dataSource={data?.data}
/>
</Card>
</div>
</div>
);
};

View File

@ -0,0 +1,149 @@
import { ISchema, useFieldSchema } from '@formily/react';
import { uid } from '@formily/shared';
import { Spin } from 'antd';
import { cloneDeep } from 'lodash';
import React, { createContext, useContext, useMemo } from 'react';
import { useAPIClient, useRequest } from '../api-client';
import { RouteSwitchContext } from '../route-switch';
import { SchemaComponentOptions } from '../schema-component';
import { BlockTemplate } from './BlockTemplate';
export const SchemaTemplateManagerContext = createContext<any>({});
const SchemaTemplateRouteProvider = (props) => {
const { routes, ...others } = useContext(RouteSwitchContext);
routes[1].routes.unshift(
{
type: 'route',
path: '/admin/block-templates/:key',
component: 'BlockTemplateDetails',
},
{
type: 'route',
path: '/admin/block-templates',
component: 'BlockTemplatePage',
},
);
return <RouteSwitchContext.Provider value={{ ...others, routes }}>{props.children}</RouteSwitchContext.Provider>;
};
export const SchemaTemplateManagerProvider: React.FC<any> = (props) => {
const { templates, refresh } = props;
return (
<SchemaTemplateManagerContext.Provider value={{ templates, refresh }}>
<SchemaTemplateRouteProvider>
<SchemaComponentOptions components={{ BlockTemplate }}>{props.children}</SchemaComponentOptions>
</SchemaTemplateRouteProvider>
</SchemaTemplateManagerContext.Provider>
);
};
const regenerateUid = (s: ISchema) => {
s['x-uid'] = uid();
Object.keys(s.properties || {}).forEach((key) => {
regenerateUid(s.properties[key]);
});
};
export const useSchemaTemplate = () => {
const { getTemplateBySchemaId } = useSchemaTemplateManager();
const fieldSchema = useFieldSchema();
const schemaId = fieldSchema['x-uid'];
return useMemo(() => getTemplateBySchemaId(schemaId), [schemaId]);
};
export const useSchemaTemplateManager = () => {
const { refresh, templates = [] } = useContext(SchemaTemplateManagerContext);
const api = useAPIClient();
return {
templates,
refresh,
async getTemplateSchemaByMode(options) {
const { mode, template } = options;
if (mode === 'copy') {
const { data } = await api.request({
url: `/uiSchemas:getJsonSchema/${template.uid}`,
});
const s = data?.data || {};
regenerateUid(s);
return cloneDeep(s);
} else if (mode === 'reference') {
return {
type: 'void',
'x-component': 'BlockTemplate',
'x-component-props': {
templateId: template.key,
},
};
}
},
async copyTemplateSchema(template) {
const { data } = await api.request({
url: `/uiSchemas:getJsonSchema/${template.uid}`,
});
const s = data?.data || {};
regenerateUid(s);
return cloneDeep(s);
},
async saveAsTemplate(values) {
const { uid: schemaId } = values;
const key = uid();
await api.resource('uiSchemaTemplates').create({
values: {
key,
...values,
},
});
await api.request({
url: `/uiSchemas:clearAncestor/${schemaId}`,
});
await refresh();
return { key };
},
async duplicate(template) {
const { data } = await api.request({
url: `/uiSchemas:getJsonSchema/${template.uid}`,
});
const s = data?.data || {};
regenerateUid(s);
return s;
},
getTemplateBySchemaId(schemaId) {
return templates?.find((template) => template.uid === schemaId);
},
getTemplateById(key) {
return templates?.find((template) => template.key === key);
},
getTemplatesByCollection(collectionName: string) {
const items = templates?.filter?.((template) => template.collectionName === collectionName);
return items || [];
},
};
};
export const RemoteSchemaTemplateManagerProvider: React.FC = (props) => {
const api = useAPIClient();
const options = {
resource: 'uiSchemaTemplates',
action: 'list',
params: {
appends: ['collection'],
paginate: false,
},
};
const service = useRequest(options);
if (service.loading) {
return <Spin />;
}
return (
<SchemaTemplateManagerProvider
refresh={async () => {
const { data } = await api.request(options);
service.mutate(data);
}}
templates={service?.data?.data}
>
{props.children}
</SchemaTemplateManagerProvider>
);
};

View File

@ -0,0 +1,19 @@
import { LayoutOutlined } from '@ant-design/icons';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import { PluginManager } from '../plugin-manager';
export const SchemaTemplateShortcut = () => {
const { t } = useTranslation();
const history = useHistory();
return (
<PluginManager.Toolbar.Item
icon={<LayoutOutlined />}
title={t('Block templates')}
onClick={() => {
history.push('/admin/block-templates');
}}
/>
);
};

View File

@ -0,0 +1,5 @@
export * from './BlockTemplateDetails';
export * from './BlockTemplatePage';
export * from './SchemaTemplateManagerProvider';
export * from './SchemaTemplateShortcut';

View File

@ -0,0 +1,153 @@
import { ISchema } from '@formily/react';
import { CollectionOptions } from '../../collection-manager';
const collection: CollectionOptions = {
name: 'uiSchemaTemplates',
filterTargetKey: 'name',
targetKey: 'name',
fields: [
{
type: 'integer',
name: 'name',
interface: 'input',
uiSchema: {
title: '{{ t("Template name") }}',
type: 'number',
'x-component': 'Input',
required: true,
},
},
],
};
export const uiSchemaTemplatesSchema: ISchema = {
type: 'object',
properties: {
block1: {
type: 'void',
'x-collection': 'collections',
'x-decorator': 'ResourceActionProvider',
'x-decorator-props': {
collection,
request: {
resource: 'uiSchemaTemplates',
action: 'list',
params: {
pageSize: 50,
filter: {},
// sort: ['sort'],
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: '{{ cm.useBulkDestroyActionAndRefreshCM }}',
confirm: {
title: "{{t('Delete record')}}",
content: "{{t('Are you sure you want to delete it?')}}",
},
},
},
create: {
type: 'void',
title: '{{ t("Add block template") }}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
},
},
},
},
table: {
type: 'void',
'x-uid': 'input',
'x-component': 'Table.Void',
'x-component-props': {
rowKey: 'name',
rowSelection: {
type: 'checkbox',
},
useDataSource: '{{ cm.useDataSourceFromRAC }}',
},
properties: {
column1: {
type: 'void',
'x-decorator': 'Table.Column.Decorator',
'x-component': 'Table.Column',
properties: {
title: {
'x-component': 'CollectionField',
'x-read-pretty': true,
},
},
},
column2: {
type: 'void',
'x-decorator': 'Table.Column.Decorator',
'x-component': 'Table.Column',
properties: {
name: {
type: 'string',
'x-component': 'CollectionField',
'x-read-pretty': true,
},
},
},
column3: {
type: 'void',
title: '{{ t("Actions") }}',
'x-component': 'Table.Column',
properties: {
actions: {
type: 'void',
'x-component': 'Space',
'x-component-props': {
split: '|',
},
properties: {
view: {
type: 'void',
title: '{{ t("Configure fields") }}',
'x-component': 'Action.Link',
'x-component-props': {},
},
delete: {
type: 'void',
title: '{{ t("Delete") }}',
'x-component': 'Action.Link',
'x-component-props': {
confirm: {
title: "{{t('Delete record')}}",
content: "{{t('Are you sure you want to delete it?')}}",
},
useAction: '{{ cm.useDestroyActionAndRefreshCM }}',
},
},
},
},
},
},
},
},
},
},
},
};

View File

@ -40,6 +40,16 @@ export class UiRoutesStoragePlugin extends Plugin {
path: '/admin/workflow',
component: 'WorkflowPage',
},
// {
// type: 'route',
// path: '/admin/block-templates/:key',
// component: 'BlockTemplateDetails',
// },
// {
// type: 'route',
// path: '/admin/block-templates',
// component: 'BlockTemplatePage',
// },
{
type: 'route',
path: '/admin/:name(.+)?',

View File

@ -0,0 +1,35 @@
import { defineCollection } from '@nocobase/database';
export default defineCollection({
name: 'uiSchemaTemplates',
autoGenId: false,
fields: [
{
type: 'uid',
name: 'key',
primaryKey: true,
},
{
type: 'string',
name: 'name',
},
{
type: 'string',
name: 'componentName',
},
{
type: 'belongsTo',
name: 'uiSchema',
target: 'uiSchemas',
foreignKey: 'uid',
onDelete: 'CASCADE',
},
{
type: 'belongsTo',
name: 'collection',
target: 'collections',
foreignKey: 'collectionName',
targetKey: 'name',
},
],
});