feat: tree collection (#1561)

* feat: adjacency list

* fix: error

* feat: collection extender

* Revert "feat: collection extender"

This reverts commit a942eee7699724fe831a98bae3f67a42db91a28b.

* fix: registerBuiltInListeners

* chore: getAllNodeIds

# Conflicts:
#	packages/plugins/acl/src/server.ts

* fix: get list data tree ids

# Conflicts:
#	packages/plugins/acl/src/__tests__/list-action.test.ts

* feat: suport add child

* feat: demo3

* feat: suport add child

* feat: support add child

* feat: support add child

* fix: tree true

* feat: suport expend and collapse

* feat: support expend and collapse

* feat: support expend and collapse

* feat:  table block of the selector supports tree table

* feat: expand and collapse are only displayed in the tree table

* fix: when the tree table is closed and opened, it needs to be refreshed to take effect

* fix: test

* refactor: add child is hidden when the tree table is closed

* refactor: tree table filter the children field

* refactor: tree table filter the children field

* refactor: tree table filter the children field

* refactor: expand and collapse button does not support modify name and icon

* refactor: parent cannot be modified when adding child

* refactor: expand and collapse button hide when treetable is closed

* refactor: expand and collapse button hide when treetable is closed

* refactor: expand and collapse button hide when treetable is closed

* refactor: expand and collapse button hide when treetable is closed

* refactor: expand and collapse button hide when treetable is closed

* refactor: expand and collapse button hide when treetable is closed

* refactor: expand and collapse button hide when treetable is closed

* refactor: expand and collapse button hide when treetable is closed

* refactor: filter out the node itself when selecting parent

* refactor: filter out the node itself when selecting parent

* refactor:  tree collection locale

* refactor:  tree collection locale

* fix: parameter exception when creating data selector

* fix: translation

* refactor:  tree collection locale

* feat: data selector Support tree table

* fix: failed to uncheck when multiple data selectors are selected

* fix: open or disabled the tree table, and add child does not respond immediately

* feat: data selector Support tree table

* fix: can not hide add child button

* fix: improve code

* fix: tree table

* fix: dynamic children column

---------

Co-authored-by: Chareice <chareice@live.com>
Co-authored-by: katherinehhh <katherine_15995@163.com>
This commit is contained in:
chenos 2023-03-17 09:20:56 +08:00 committed by GitHub
parent cd2340ecd0
commit a9e5e14429
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 1435 additions and 197 deletions

View File

@ -10,9 +10,6 @@
"@nocobase/database": "0.9.1-alpha.2", "@nocobase/database": "0.9.1-alpha.2",
"@nocobase/resourcer": "0.9.1-alpha.2" "@nocobase/resourcer": "0.9.1-alpha.2"
}, },
"devDependencies": {
"@nocobase/test": "0.9.1-alpha.2"
},
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/nocobase/nocobase.git", "url": "git+https://github.com/nocobase/nocobase.git",

View File

@ -1,5 +1,5 @@
import { MockServer, mockServer } from '@nocobase/test';
import { registerActions } from '@nocobase/actions'; import { registerActions } from '@nocobase/actions';
import { MockServer, mockServer } from './';
describe('get action', () => { describe('get action', () => {
let app: MockServer; let app: MockServer;
@ -10,9 +10,7 @@ describe('get action', () => {
let Profile; let Profile;
beforeEach(async () => { beforeEach(async () => {
app = mockServer({ app = mockServer();
dataWrapping: false,
});
registerActions(app); registerActions(app);
PostTag = app.collection({ PostTag = app.collection({

View File

@ -129,3 +129,201 @@ describe('list action', () => {
expect(response.body.count).toEqual(0); expect(response.body.count).toEqual(0);
}); });
}); });
describe('list-tree', () => {
let app;
beforeEach(async () => {
app = actionMockServer();
registerActions(app);
});
afterEach(async () => {
await app.destroy();
});
it('should be tree', async () => {
const values = [
{
name: '1',
__index: '0',
children: [
{
name: '1-1',
__index: '0.children.0',
children: [
{
name: '1-1-1',
__index: '0.children.0.children.0',
children: [
{
name: '1-1-1-1',
__index: '0.children.0.children.0.children.0',
},
],
},
],
},
],
},
{
name: '2',
__index: '1',
children: [
{
name: '2-1',
__index: '1.children.0',
children: [
{
name: '2-1-1',
__index: '1.children.0.children.0',
children: [
{
name: '2-1-1-1',
__index: '1.children.0.children.0.children.0',
},
],
},
],
},
],
},
];
const db = app.db;
const collection = db.collection({
name: 'categories',
tree: 'adjacency-list',
fields: [
{
type: 'string',
name: 'name',
},
{
type: 'string',
name: 'description',
},
{
type: 'belongsTo',
name: 'parent',
treeParent: true,
},
{
type: 'hasMany',
name: 'children',
treeChildren: true,
},
],
});
await db.sync();
await db.getRepository('categories').create({
values,
});
const response = await app
.agent()
.resource('categories')
.list({
tree: true,
fields: ['id', 'name'],
sort: ['id'],
});
expect(response.status).toEqual(200);
expect(response.body.rows).toMatchObject(values);
});
it.only('should be tree', async () => {
const values = [
{
name: '1',
__index: '0',
children2: [
{
name: '1-1',
__index: '0.children2.0',
children2: [
{
name: '1-1-1',
__index: '0.children2.0.children2.0',
children2: [
{
name: '1-1-1-1',
__index: '0.children2.0.children2.0.children2.0',
},
],
},
],
},
],
},
{
name: '2',
__index: '1',
children2: [
{
name: '2-1',
__index: '1.children2.0',
children2: [
{
name: '2-1-1',
__index: '1.children2.0.children2.0',
children2: [
{
name: '2-1-1-1',
__index: '1.children2.0.children2.0.children2.0',
},
],
},
],
},
],
},
];
const db = app.db;
const collection = db.collection({
name: 'categories',
tree: 'adjacency-list',
fields: [
{
type: 'string',
name: 'name',
},
{
type: 'string',
name: 'description',
},
{
type: 'belongsTo',
name: 'parent',
foreignKey: 'cid',
treeParent: true,
},
{
type: 'hasMany',
name: 'children2',
foreignKey: 'cid',
treeChildren: true,
},
],
});
await db.sync();
await db.getRepository('categories').create({
values,
});
const response = await app
.agent()
.resource('categories')
.list({
tree: true,
fields: ['id', 'name'],
sort: ['id'],
});
expect(response.status).toEqual(200);
expect(response.body.rows).toMatchObject(values);
});
});

View File

@ -1,4 +1,4 @@
import { ActionParams } from '@nocobase/resourcer'; import { assign } from '@nocobase/utils';
import { Context } from '..'; import { Context } from '..';
import { getRepositoryFromParams } from '../utils'; import { getRepositoryFromParams } from '../utils';
@ -22,9 +22,20 @@ function totalPage(total, pageSize): number {
return Math.ceil(total / pageSize); return Math.ceil(total / pageSize);
} }
function findArgs(params: ActionParams) { function findArgs(ctx: Context) {
const { fields, filter, appends, except, sort } = params; const resourceName = ctx.action.resourceName;
return { filter, fields, appends, except, sort }; const params = ctx.action.params;
if (params.tree) {
const [collectionName, associationName] = resourceName.split('.');
const collection = ctx.db.getCollection(resourceName);
// tree collection 或者关系表是 tree collection
if (collection.options.tree && !(associationName && collectionName === collection.name)) {
const foreignKey = collection.treeParentField?.foreignKey || 'parentId';
assign(params, { filter: { [foreignKey]: null } }, { filter: 'andMerge' });
}
}
const { tree, fields, filter, appends, except, sort } = params;
return { tree, filter, fields, appends, except, sort };
} }
async function listWithPagination(ctx: Context) { async function listWithPagination(ctx: Context) {
@ -34,7 +45,7 @@ async function listWithPagination(ctx: Context) {
const [rows, count] = await repository.findAndCount({ const [rows, count] = await repository.findAndCount({
context: ctx, context: ctx,
...findArgs(ctx.action.params), ...findArgs(ctx),
...pageArgsToLimitArgs(parseInt(String(page)), parseInt(String(pageSize))), ...pageArgsToLimitArgs(parseInt(String(page)), parseInt(String(pageSize))),
}); });
@ -50,7 +61,7 @@ async function listWithPagination(ctx: Context) {
async function listWithNonPaged(ctx: Context) { async function listWithNonPaged(ctx: Context) {
const repository = getRepositoryFromParams(ctx); const repository = getRepositoryFromParams(ctx);
const rows = await repository.find({ context: ctx, ...findArgs(ctx.action.params) }); const rows = await repository.find({ context: ctx, ...findArgs(ctx) });
ctx.body = rows; ctx.body = rows;
} }

View File

@ -7,6 +7,7 @@ import { useCollection } from '../collection-manager';
import { RecordProvider, useRecord } from '../record-provider'; import { RecordProvider, useRecord } from '../record-provider';
import { useDesignable } from '../schema-component'; import { useDesignable } from '../schema-component';
import { BlockProvider, useBlockRequestContext } from './BlockProvider'; import { BlockProvider, useBlockRequestContext } from './BlockProvider';
import { useActionContext } from '../schema-component';
export const FormBlockContext = createContext<any>({}); export const FormBlockContext = createContext<any>({});
@ -88,6 +89,18 @@ export const useFormBlockContext = () => {
export const useFormBlockProps = () => { export const useFormBlockProps = () => {
const ctx = useFormBlockContext(); const ctx = useFormBlockContext();
const record = useRecord();
const { fieldSchema } = useActionContext();
const { addChild } = fieldSchema?.['x-component-props']||{};
useEffect(() => {
if (addChild) {
ctx.form.query('parent').take((field) => {
field.disabled = true;
field.value = new Proxy({ ...record }, {});
});
}
});
useEffect(() => { useEffect(() => {
ctx.form.setInitialValues(ctx.service?.data?.data); ctx.form.setInitialValues(ctx.service?.data?.data);
}, []); }, []);

View File

@ -1,17 +1,18 @@
import { ArrayField, createForm } from '@formily/core'; import { ArrayField, createForm } from '@formily/core';
import { FormContext, Schema, useField, useFieldSchema } from '@formily/react'; import { FormContext, Schema, useField, useFieldSchema } from '@formily/react';
import uniq from 'lodash/uniq'; import uniq from 'lodash/uniq';
import React, { createContext, useContext, useEffect, useMemo } from 'react'; import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { useCollectionManager } from '../collection-manager'; import { useCollectionManager } from '../collection-manager';
import { SchemaComponentOptions, useFixedSchema } from '../schema-component';
import { BlockProvider, RenderChildrenWithAssociationFilter, useBlockRequestContext } from './BlockProvider'; import { BlockProvider, RenderChildrenWithAssociationFilter, useBlockRequestContext } from './BlockProvider';
import { useFixedSchema } from '../schema-component';
export const TableBlockContext = createContext<any>({}); export const TableBlockContext = createContext<any>({});
const InternalTableBlockProvider = (props) => { const InternalTableBlockProvider = (props) => {
const { params, showIndex, dragSort, rowKey } = props; const { params, showIndex, dragSort, rowKey, childrenColumnName } = props;
const field = useField(); const field = useField();
const { resource, service } = useBlockRequestContext(); const { resource, service } = useBlockRequestContext();
const [expandFlag, setExpandFlag] = useState(false);
// if (service.loading) { // if (service.loading) {
// return <Spin />; // return <Spin />;
// } // }
@ -26,6 +27,9 @@ const InternalTableBlockProvider = (props) => {
showIndex, showIndex,
dragSort, dragSort,
rowKey, rowKey,
expandFlag,
childrenColumnName,
setExpandFlag: () => setExpandFlag(!expandFlag),
}} }}
> >
<RenderChildrenWithAssociationFilter {...props} /> <RenderChildrenWithAssociationFilter {...props} />
@ -81,21 +85,44 @@ export const useAssociationNames = (collection) => {
}; };
export const TableBlockProvider = (props) => { export const TableBlockProvider = (props) => {
const resourceName = props.resource;
const params = { ...props.params }; const params = { ...props.params };
const appends = useAssociationNames(props.collection); const appends = useAssociationNames(props.collection);
const form = useMemo(() => createForm(), []); const fieldSchema = useFieldSchema();
const { getCollection, getCollectionField } = useCollectionManager();
const collection = getCollection(props.collection);
const { treeTable } = fieldSchema?.['x-decorator-props']||{};
if (props.dragSort) { if (props.dragSort) {
params['sort'] = ['sort']; params['sort'] = ['sort'];
} }
let childrenColumnName = 'children';
if (collection.tree && treeTable !== false) {
if (resourceName.includes('.')) {
const f = getCollectionField(resourceName);
if (f?.treeChildren) {
childrenColumnName = f.name;
params['tree'] = true;
}
} else {
const f = collection.fields.find(f => f.treeChildren);
if (f) {
childrenColumnName = f.name;
}
params['tree'] = true;
}
}
if (!Object.keys(params).includes('appends')) { if (!Object.keys(params).includes('appends')) {
params['appends'] = appends; params['appends'] = appends;
} }
const form = useMemo(() => createForm(), [treeTable]);
return ( return (
<SchemaComponentOptions scope={{ treeTable }}>
<FormContext.Provider value={form}> <FormContext.Provider value={form}>
<BlockProvider {...props} params={params}> <BlockProvider {...props} params={params}>
<InternalTableBlockProvider {...props} params={params} /> <InternalTableBlockProvider {...props} childrenColumnName={childrenColumnName} params={params} />
</BlockProvider> </BlockProvider>
</FormContext.Provider> </FormContext.Provider>
</SchemaComponentOptions>
); );
}; };
@ -120,6 +147,7 @@ export const useTableBlockProps = () => {
} }
}, [ctx?.service?.loading]); }, [ctx?.service?.loading]);
return { return {
childrenColumnName: ctx.childrenColumnName,
loading: ctx?.service?.loading, loading: ctx?.service?.loading,
showIndex: ctx.showIndex, showIndex: ctx.showIndex,
dragSort: ctx.dragSort, dragSort: ctx.dragSort,
@ -132,6 +160,7 @@ export const useTableBlockProps = () => {
} }
: false, : false,
onRowSelectionChange(selectedRowKeys) { onRowSelectionChange(selectedRowKeys) {
console.log(selectedRowKeys);
ctx.field.data = ctx?.field?.data || {}; ctx.field.data = ctx?.field?.data || {};
ctx.field.data.selectedRowKeys = selectedRowKeys; ctx.field.data.selectedRowKeys = selectedRowKeys;
}, },

View File

@ -1,11 +1,11 @@
import { ArrayField } from '@formily/core'; import { ArrayField } from '@formily/core';
import { Schema, useField, useFieldSchema } from '@formily/react'; import { Schema, useField, useFieldSchema } from '@formily/react';
import uniq from 'lodash/uniq'; import uniq from 'lodash/uniq';
import React, { createContext, useContext, useEffect } from 'react'; import React, { createContext, useContext, useEffect, useState } from 'react';
import { useCollectionManager } from '../collection-manager'; import { useCollectionManager } from '../collection-manager';
import { RecordProvider, useRecord } from '../record-provider'; import { RecordProvider, useRecord } from '../record-provider';
import { SchemaComponentOptions } from '../schema-component';
import { BlockProvider, RenderChildrenWithAssociationFilter, useBlockRequestContext } from './BlockProvider'; import { BlockProvider, RenderChildrenWithAssociationFilter, useBlockRequestContext } from './BlockProvider';
import { useFormBlockContext } from './FormBlockProvider';
export const TableSelectorContext = createContext<any>({}); export const TableSelectorContext = createContext<any>({});
@ -13,6 +13,7 @@ const InternalTableSelectorProvider = (props) => {
const { params, rowKey, extraFilter } = props; const { params, rowKey, extraFilter } = props;
const field = useField(); const field = useField();
const { resource, service } = useBlockRequestContext(); const { resource, service } = useBlockRequestContext();
const [expandFlag, setExpandFlag] = useState(false);
// if (service.loading) { // if (service.loading) {
// return <Spin />; // return <Spin />;
// } // }
@ -26,6 +27,10 @@ const InternalTableSelectorProvider = (props) => {
params, params,
extraFilter, extraFilter,
rowKey, rowKey,
expandFlag,
setExpandFlag: () => {
setExpandFlag(!expandFlag);
},
}} }}
> >
<RenderChildrenWithAssociationFilter {...props} /> <RenderChildrenWithAssociationFilter {...props} />
@ -99,19 +104,29 @@ const useAssociationNames = (collection) => {
export const TableSelectorProvider = (props) => { export const TableSelectorProvider = (props) => {
const fieldSchema = useFieldSchema(); const fieldSchema = useFieldSchema();
const ctx = useFormBlockContext();
const { getCollectionJoinField, getCollectionFields } = useCollectionManager(); const { getCollectionJoinField, getCollectionFields } = useCollectionManager();
const record = useRecord(); const record = useRecord();
const { getCollection } = useCollectionManager();
const collection = getCollection(props.collection);
const { treeTable } = fieldSchema?.['x-decorator-props'] || {};
const collectionFieldSchema = recursiveParent(fieldSchema, 'CollectionField'); const collectionFieldSchema = recursiveParent(fieldSchema, 'CollectionField');
// const value = ctx.form.query(collectionFieldSchema?.name).value();
const collectionField = getCollectionJoinField(collectionFieldSchema?.['x-collection-field']); const collectionField = getCollectionJoinField(collectionFieldSchema?.['x-collection-field']);
const params = { ...props.params }; const params = { ...props.params };
const appends = useAssociationNames(props.collection); const appends = useAssociationNames(props.collection);
if (props.dragSort) { if (props.dragSort) {
params['sort'] = ['sort']; params['sort'] = ['sort'];
} }
if (collection.tree && treeTable !== false) {
params['tree'] = true;
if (collectionFieldSchema.name === 'parent') {
params.filter = {
...(params.filter ?? {}),
id: record.id && {
$ne: record.id,
},
};
}
}
if (!Object.keys(params).includes('appends')) { if (!Object.keys(params).includes('appends')) {
params['appends'] = appends; params['appends'] = appends;
} }
@ -181,9 +196,11 @@ export const TableSelectorProvider = (props) => {
} }
} }
return ( return (
<SchemaComponentOptions scope={{ treeTable }}>
<BlockProvider {...props} params={params}> <BlockProvider {...props} params={params}>
<InternalTableSelectorProvider {...props} params={params} extraFilter={extraFilter} /> <InternalTableSelectorProvider {...props} params={params} extraFilter={extraFilter} />
</BlockProvider> </BlockProvider>
</SchemaComponentOptions>
); );
}; };

View File

@ -128,12 +128,12 @@ function getFormValues(filterByTk, field, form, fieldNames, getField, resource)
export const useCreateActionProps = () => { export const useCreateActionProps = () => {
const form = useForm(); const form = useForm();
const { field, resource, __parent } = useBlockRequestContext(); const { field, resource, __parent } = useBlockRequestContext();
const { visible, setVisible } = useActionContext(); const { visible, setVisible, fieldSchema } = useActionContext();
const history = useHistory(); const history = useHistory();
const { t } = useTranslation(); const { t } = useTranslation();
const actionSchema = useFieldSchema(); const actionSchema = useFieldSchema();
const actionField = useField(); const actionField = useField();
const { fields, getField } = useCollection(); const { fields, getField, getTreeParentField } = useCollection();
const compile = useCompile(); const compile = useCompile();
const filterByTk = useFilterByTk(); const filterByTk = useFilterByTk();
const currentRecord = useRecord(); const currentRecord = useRecord();
@ -148,11 +148,17 @@ export const useCreateActionProps = () => {
overwriteValues, overwriteValues,
skipValidator, skipValidator,
} = actionSchema?.['x-action-settings'] ?? {}; } = actionSchema?.['x-action-settings'] ?? {};
const { addChild } = fieldSchema?.['x-component-props'];
const assignedValues = parse(originalAssignedValues)({ currentTime: new Date(), currentRecord, currentUser }); const assignedValues = parse(originalAssignedValues)({ currentTime: new Date(), currentRecord, currentUser });
if (!skipValidator) { if (!skipValidator) {
await form.submit(); await form.submit();
} }
const values = getFormValues(filterByTk, field, form, fieldNames, getField, resource); const values = getFormValues(filterByTk, field, form, fieldNames, getField, resource);
if (addChild) {
const treeParentField = getTreeParentField();
values[treeParentField?.name ?? 'parent'] = currentRecord;
values[treeParentField?.foreignKey ?? 'parentId'] = currentRecord.id;
}
actionField.data = field.data || {}; actionField.data = field.data || {};
actionField.data.loading = true; actionField.data.loading = true;
try { try {

View File

@ -84,7 +84,7 @@ const getSchema = (schema, category, compile): ISchema => {
'x-component': 'Action', 'x-component': 'Action',
'x-component-props': { 'x-component-props': {
type: 'primary', type: 'primary',
useAction: () => useCreateCollection(), useAction: () => useCreateCollection(schema),
}, },
}, },
}, },
@ -188,16 +188,19 @@ const useDefaultCollectionFields = (values) => {
return defaults; return defaults;
}; };
const useCreateCollection = () => { const useCreateCollection = (schema?: any) => {
const form = useForm(); const form = useForm();
const { refreshCM } = useCollectionManager(); const { refreshCM } = useCollectionManager();
const ctx = useActionContext(); const ctx = useActionContext();
const { refresh } = useResourceActionContext(); const { refresh } = useResourceActionContext();
const { resource } = useResourceContext(); const { resource, collection } = useResourceContext();
return { return {
async run() { async run() {
await form.submit(); await form.submit();
const values = cloneDeep(form.values); const values = cloneDeep(form.values);
if (schema?.events?.beforeSubmit) {
schema.events.beforeSubmit(values);
}
const fields = useDefaultCollectionFields(values); const fields = useDefaultCollectionFields(values);
if (values.autoCreateReverseField) { if (values.autoCreateReverseField) {
} else { } else {

View File

@ -28,9 +28,13 @@ export const useCollection = () => {
...collection, ...collection,
resource, resource,
getField(name: SchemaKey): CollectionFieldOptions { getField(name: SchemaKey): CollectionFieldOptions {
const fields = totalFields; const fields = totalFields as any[];
return fields?.find((field) => field.name === name); return fields?.find((field) => field.name === name);
}, },
getTreeParentField() {
const fields = totalFields;
return fields?.find((field) => field.treeParent);
},
fields: totalFields, fields: totalFields,
getPrimaryKey: () => { getPrimaryKey: () => {
if (collection.targetKey || collection.filterTargetKey) { if (collection.targetKey || collection.filterTargetKey) {

View File

@ -5,7 +5,7 @@ import {
recordPickerSelector, recordPickerSelector,
recordPickerViewer, recordPickerViewer,
relationshipType, relationshipType,
reverseFieldProperties, reverseFieldProperties
} from './properties'; } from './properties';
import { IField } from './types'; import { IField } from './types';

View File

@ -1,3 +1,4 @@
export * from './calendar'; export * from './calendar';
export * from './general'; export * from './general';
export * from './tree';

View File

@ -0,0 +1,74 @@
import { getConfigurableProperties } from './properties';
import { ICollectionTemplate } from './types';
export const tree: ICollectionTemplate = {
name: 'tree',
title: '{{t("Tree collection")}}',
order: 3,
color: 'blue',
default: {
tree: 'adjacencyList',
fields: [
{
interface: 'integer',
name: 'parentId',
type: 'bigInt',
isForeignKey: true,
uiSchema: {
type: 'number',
title: '{{t("Parent ID")}}',
'x-component': 'InputNumber',
'x-read-pretty': true,
},
},
{
interface: 'm2o',
type: 'belongsTo',
name: 'parent',
foreignKey: 'parentId',
treeParent: true,
uiSchema: {
title: '{{t("Parent")}}',
'x-component': 'RecordPicker',
'x-component-props': {
// mode: 'tags',
multiple: false,
fieldNames: {
label: 'id',
value: 'id',
},
},
},
},
{
interface: 'o2m',
type: 'hasMany',
name: 'children',
foreignKey: 'parentId',
treeChildren: true,
uiSchema: {
title: '{{t("Children")}}',
'x-component': 'RecordPicker',
'x-component-props': {
// mode: 'tags',
multiple: true,
fieldNames: {
label: 'id',
value: 'id',
},
},
},
},
],
},
events: {
beforeSubmit(values) {
if (Array.isArray(values?.fields)) {
values?.fields.map((f) => {
f.target = values.name;
});
}
},
},
configurableProperties: getConfigurableProperties('title', 'name', 'inherits', 'category', 'moreOptions'),
};

View File

@ -9,6 +9,7 @@ export interface ICollectionTemplate {
order?: number; order?: number;
/** 默认配置 */ /** 默认配置 */
default?: CollectionOptions; default?: CollectionOptions;
events?: any;
/** UI 可配置的 CollectionOptions 参数(添加或编辑的 Collection 表单的字段) */ /** UI 可配置的 CollectionOptions 参数(添加或编辑的 Collection 表单的字段) */
configurableProperties?: Record<string, ISchema>; configurableProperties?: Record<string, ISchema>;
/** 当前模板可用的字段类型 */ /** 当前模板可用的字段类型 */
@ -48,4 +49,5 @@ interface CollectionOptions {
inherits?: string | string[]; inherits?: string | string[];
/* 字段列表 */ /* 字段列表 */
fields?: FieldOptions[]; fields?: FieldOptions[];
[key: string]: any;
} }

View File

@ -24,6 +24,7 @@ export interface CollectionOptions {
sortable?: any; sortable?: any;
fields?: FieldOptions[]; fields?: FieldOptions[];
inherits?: string[]; inherits?: string[];
tree?: string;
} }
export interface ICollectionProviderProps { export interface ICollectionProviderProps {

View File

@ -108,6 +108,11 @@ export default {
"Add block": "Add block", "Add block": "Add block",
"Add new": "Add new", "Add new": "Add new",
"Add record": "Add record", "Add record": "Add record",
'Add child':'Add child',
'Collapse all':'Collapse all',
'Expand all':'Expand all',
'Expand/Collapse':'Expand/Collapse',
"Tree table":"Tree table",
"Custom field display name": "Custom field display name", "Custom field display name": "Custom field display name",
"Display fields": "Display collection fields", "Display fields": "Display collection fields",
"Edit record": "Edit record", "Edit record": "Edit record",

View File

@ -42,6 +42,11 @@ export default {
"Category name":"分類名", "Category name":"分類名",
"Delete category":"分類の削除", "Delete category":"分類の削除",
"Collection category":"Collection category", "Collection category":"Collection category",
'Add child':'サブレコードの追加',
'Collapse all':'すべて閉じる',
'Expand all':'すべて展開',
'Expand/Collapse':'展開と終了',
"Tree table":"ツリーテーブル",
"Visible":"表示", "Visible":"表示",
"Read only":"読み取り専用(編集不可)", "Read only":"読み取り専用(編集不可)",
"Easy reading":"読み取り専用(読取りモード)", "Easy reading":"読み取り専用(読取りモード)",

View File

@ -58,6 +58,15 @@ export default {
"Sort":"排序", "Sort":"排序",
"Categories":"数据表类别", "Categories":"数据表类别",
"Category name":"分类名称", "Category name":"分类名称",
'Add child':'添加子记录',
'Collapse all':'全部收起',
'Expand all':'全部展开',
'Expand/Collapse':'展开/折叠',
'Tree collection': '树结构表',
"Tree table":"树表格",
'Parent ID': '父记录ID',
'Parent': '父记录',
'Children': '子记录',
"Roles & Permissions": "角色和权限", "Roles & Permissions": "角色和权限",
"Edit profile": "个人资料", "Edit profile": "个人资料",
"Change password": "修改密码", "Change password": "修改密码",

View File

@ -50,6 +50,7 @@ export const ActionDesigner = (props) => {
const actionType = fieldSchema['x-action'] ?? ''; const actionType = fieldSchema['x-action'] ?? '';
const isLinkageAction = Object.keys(useFormBlockContext()).length > 0 && Object.keys(useRecord()).length > 0; const isLinkageAction = Object.keys(useFormBlockContext()).length > 0 && Object.keys(useRecord()).length > 0;
const isChildCollectionAction = getChildrenCollections(name).length > 0 && fieldSchema['x-action'] === 'create'; const isChildCollectionAction = getChildrenCollections(name).length > 0 && fieldSchema['x-action'] === 'create';
const isSupportEditButton = fieldSchema['x-action'] !== 'expandAll';
useEffect(() => { useEffect(() => {
const schemaUid = uid(); const schemaUid = uid();
const schema: ISchema = { const schema: ISchema = {
@ -85,6 +86,7 @@ export const ActionDesigner = (props) => {
title: t('Button title'), title: t('Button title'),
default: fieldSchema.title, default: fieldSchema.title,
'x-component-props': {}, 'x-component-props': {},
'x-visible': isSupportEditButton,
// description: `原字段标题:${collectionField?.uiSchema?.title}`, // description: `原字段标题:${collectionField?.uiSchema?.title}`,
}, },
icon: { icon: {
@ -93,6 +95,7 @@ export const ActionDesigner = (props) => {
title: t('Button icon'), title: t('Button icon'),
default: fieldSchema?.['x-component-props']?.icon, default: fieldSchema?.['x-component-props']?.icon,
'x-component-props': {}, 'x-component-props': {},
'x-visible': isSupportEditButton,
// description: `原字段标题:${collectionField?.uiSchema?.title}`, // description: `原字段标题:${collectionField?.uiSchema?.title}`,
}, },
type: { type: {
@ -114,7 +117,6 @@ export const ActionDesigner = (props) => {
} as ISchema } as ISchema
} }
onSubmit={({ title, icon, type }) => { onSubmit={({ title, icon, type }) => {
if (title) {
fieldSchema.title = title; fieldSchema.title = title;
field.title = title; field.title = title;
field.componentProps.icon = icon; field.componentProps.icon = icon;
@ -134,7 +136,6 @@ export const ActionDesigner = (props) => {
}, },
}); });
dn.refresh(); dn.refresh();
}
}} }}
/> />
{isLinkageAction && <SchemaSettings.LinkageRules collectionName={name} />} {isLinkageAction && <SchemaSettings.LinkageRules collectionName={name} />}

View File

@ -12,6 +12,17 @@ import { useFieldNames } from './useFieldNames';
import { getLabelFormatValue, useLabelUiSchema } from './util'; import { getLabelFormatValue, useLabelUiSchema } from './util';
const RecordPickerContext = createContext(null); const RecordPickerContext = createContext(null);
function flatData(data) {
let newArr = [];
for (let i = 0; i < data.length; i++) {
const children = data[i]['children'];
if (Array.isArray(children)) {
newArr.push(...flatData(children));
}
newArr.push({ ...data[i] });
}
return newArr;
}
const useTableSelectorProps = () => { const useTableSelectorProps = () => {
const field = useField<ArrayField>(); const field = useField<ArrayField>();
@ -28,11 +39,12 @@ const useTableSelectorProps = () => {
}, },
onRowSelectionChange(selectedRowKeys, selectedRows) { onRowSelectionChange(selectedRowKeys, selectedRows) {
if (multiple) { if (multiple) {
const scopeRows = field.value || []; const scopeRows = flatData(field.value) || [];
const allSelectedRows = rcSelectRows || []; const allSelectedRows = rcSelectRows || [];
const otherRows = differenceBy(allSelectedRows, scopeRows, rowKey || 'id'); const otherRows = differenceBy(allSelectedRows, scopeRows, rowKey || 'id');
const unionSelectedRows = unionBy(otherRows, selectedRows, rowKey || 'id'); const unionSelectedRows = unionBy(otherRows, selectedRows, rowKey || 'id');
const unionSelectedRowKeys = unionSelectedRows.map((item) => item[rowKey || 'id']); const unionSelectedRowKeys = unionSelectedRows.map((item) => item[rowKey || 'id']);
console.log(unionSelectedRows, unionSelectedRowKeys);
setSelectedRows?.(unionSelectedRows); setSelectedRows?.(unionSelectedRows);
onRowSelectionChange?.(unionSelectedRowKeys, unionSelectedRows); onRowSelectionChange?.(unionSelectedRowKeys, unionSelectedRows);
} else { } else {
@ -92,7 +104,7 @@ export const InputRecordPicker: React.FC<any> = (props) => {
setOptions(opts); setOptions(opts);
setSelectedRows(opts); setSelectedRows(opts);
} }
}, [value,fieldNames?.label]); }, [value, fieldNames?.label]);
const getValue = () => { const getValue = () => {
if (multiple == null) return null; if (multiple == null) return null;

View File

@ -10,9 +10,15 @@ import { default as classNames, default as cls } from 'classnames';
import React, { RefCallback, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { RefCallback, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { DndContext, useDesignable } from '../..'; import { DndContext, useDesignable } from '../..';
import { RecordIndexProvider, RecordProvider, useSchemaInitializer } from '../../../'; import {
RecordIndexProvider,
RecordProvider,
useSchemaInitializer,
useTableBlockContext,
useTableSelectorContext
} from '../../../';
import { useACLFieldWhitelist } from '../../../acl/ACLProvider'; import { useACLFieldWhitelist } from '../../../acl/ACLProvider';
import { isCollectionFieldComponent, isColumnComponent } from './utils'; import { extractIndex, getIdsWithChildren, isCollectionFieldComponent, isColumnComponent } from './utils';
const useTableColumns = () => { const useTableColumns = () => {
const field = useField<ArrayField>(); const field = useField<ArrayField>();
@ -106,7 +112,7 @@ const TableIndex = (props) => {
const { index } = props; const { index } = props;
return ( return (
<div className={classNames('nb-table-index')} style={{ padding: '0 8px 0 16px' }}> <div className={classNames('nb-table-index')} style={{ padding: '0 8px 0 16px' }}>
{index + 1} {index}
</div> </div>
); );
}; };
@ -162,10 +168,16 @@ export const Table: any = observer((props: any) => {
required, required,
...others ...others
} = { ...others1, ...others2 } as any; } = { ...others1, ...others2 } as any;
const schema = useFieldSchema();
const isTableSelector = schema?.parent?.['x-decorator'] === 'TableSelectorProvider';
const ctx = isTableSelector ? useTableSelectorContext() : useTableBlockContext();
const { expandFlag } = ctx;
const onRowDragEnd = useMemoizedFn(others.onRowDragEnd || (() => {})); const onRowDragEnd = useMemoizedFn(others.onRowDragEnd || (() => {}));
const paginationProps = usePaginationProps(pagination1, pagination2); const paginationProps = usePaginationProps(pagination1, pagination2);
const requiredValidator = field.required || required; const requiredValidator = field.required || required;
const { treeTable } = schema?.parent?.['x-decorator-props'] || {};
const [expandedKeys, setExpandesKeys] = useState([]);
const [allIncludesChildren, setAllIncludesChildren] = useState([]);
useEffect(() => { useEffect(() => {
field.setValidator((value) => { field.setValidator((value) => {
if (requiredValidator) { if (requiredValidator) {
@ -174,6 +186,26 @@ export const Table: any = observer((props: any) => {
return; return;
}); });
}, [requiredValidator]); }, [requiredValidator]);
// useEffect(() => {
// const data = field.value;
// field.value = null;
// field.value = data;
// }, [treeTable]);
useEffect(() => {
if (treeTable !== false) {
const keys = getIdsWithChildren(field.value?.slice());
setAllIncludesChildren(keys);
}
}, [field.value]);
useEffect(() => {
if (expandFlag) {
setExpandesKeys(allIncludesChildren);
} else {
setExpandesKeys([]);
}
}, [expandFlag]);
const components = useMemo(() => { const components = useMemo(() => {
return { return {
header: { header: {
@ -275,7 +307,10 @@ export const Table: any = observer((props: any) => {
const current = props?.pagination?.current; const current = props?.pagination?.current;
const pageSize = props?.pagination?.pageSize || 20; const pageSize = props?.pagination?.pageSize || 20;
if (current) { if (current) {
index = index + (current - 1) * pageSize; index = index + (current - 1) * pageSize + 1;
}
if (record.__index) {
index = extractIndex(record.__index);
} }
return ( return (
<div <div
@ -284,6 +319,7 @@ export const Table: any = observer((props: any) => {
css` css`
position: relative; position: relative;
display: flex; display: flex;
float: left;
align-items: center; align-items: center;
justify-content: space-evenly; justify-content: space-evenly;
padding-right: 8px; padding-right: 8px;
@ -419,6 +455,13 @@ export const Table: any = observer((props: any) => {
tableLayout={'auto'} tableLayout={'auto'}
scroll={scroll} scroll={scroll}
columns={columns} columns={columns}
expandable={{
onExpand: (flag, record) => {
const newKeys = flag ? [...expandedKeys, record.id] : expandedKeys.filter((i) => record.id !== i);
setExpandesKeys(newKeys);
},
expandedRowKeys: expandedKeys,
}}
dataSource={field?.value?.slice?.()} dataSource={field?.value?.slice?.()}
/> />
</SortableWrapper> </SortableWrapper>

View File

@ -4,7 +4,7 @@ import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useTableBlockContext } from '../../../block-provider'; import { useTableBlockContext } from '../../../block-provider';
import { mergeFilter } from '../../../block-provider/SharedFilterProvider'; import { mergeFilter } from '../../../block-provider/SharedFilterProvider';
import { useCollection } from '../../../collection-manager'; import { useCollection, useCollectionManager } from '../../../collection-manager';
import { useCollectionFilterOptions, useSortFields } from '../../../collection-manager/action-hooks'; import { useCollectionFilterOptions, useSortFields } from '../../../collection-manager/action-hooks';
import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings'; import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings';
import { useSchemaTemplate } from '../../../schema-templates'; import { useSchemaTemplate } from '../../../schema-templates';
@ -13,6 +13,7 @@ import { useFixedBlockDesignerSetting } from '../page';
export const TableBlockDesigner = () => { export const TableBlockDesigner = () => {
const { name, title, sortable } = useCollection(); const { name, title, sortable } = useCollection();
const { getCollectionField } = useCollectionManager();
const field = useField(); const field = useField();
const fieldSchema = useFieldSchema(); const fieldSchema = useFieldSchema();
const dataSource = useCollectionFilterOptions(name); const dataSource = useCollectionFilterOptions(name);
@ -36,12 +37,33 @@ export const TableBlockDesigner = () => {
}; };
}); });
const template = useSchemaTemplate(); const template = useSchemaTemplate();
const { dragSort } = field.decoratorProps; const collection = useCollection();
const { dragSort, resource } = field.decoratorProps;
const treeChildren = resource.includes('.') ? getCollectionField(resource)?.treeChildren : !!collection?.tree;
const fixedBlockDesignerSetting = useFixedBlockDesignerSetting(); const fixedBlockDesignerSetting = useFixedBlockDesignerSetting();
return ( return (
<GeneralSchemaDesigner template={template} title={title || name}> <GeneralSchemaDesigner template={template} title={title || name}>
<SchemaSettings.BlockTitleItem /> <SchemaSettings.BlockTitleItem />
{collection?.tree && (
<SchemaSettings.SwitchItem
title={t('Tree table')}
defaultChecked={true}
checked={treeChildren ? field.decoratorProps.treeTable !== false : false}
onChange={(flag) => {
field.decoratorProps.treeTable = flag;
fieldSchema['x-decorator-props'].treeTable = flag;
const params = {
...service.params?.[0],
tree: flag ? true : null,
};
dn.emit('patch', {
schema: fieldSchema,
});
dn.refresh();
service.run(params);
}}
/>
)}
{sortable && ( {sortable && (
<SchemaSettings.SwitchItem <SchemaSettings.SwitchItem
title={t('Enable drag and drop sorting')} title={t('Enable drag and drop sorting')}

View File

@ -1,5 +1,6 @@
import { ArrayItems } from '@formily/antd'; import { ArrayItems } from '@formily/antd';
import { ISchema, useField, useFieldSchema } from '@formily/react'; import { ISchema, useField, useFieldSchema } from '@formily/react';
import { cloneDeep } from 'lodash';
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useTableSelectorContext } from '../../../block-provider'; import { useTableSelectorContext } from '../../../block-provider';
@ -8,7 +9,6 @@ import { useCollectionFilterOptions, useSortFields } from '../../../collection-m
import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings'; import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings';
import { useSchemaTemplate } from '../../../schema-templates'; import { useSchemaTemplate } from '../../../schema-templates';
import { useDesignable } from '../../hooks'; import { useDesignable } from '../../hooks';
import { cloneDeep } from 'lodash';
export const TableSelectorDesigner = () => { export const TableSelectorDesigner = () => {
const { name, title } = useCollection(); const { name, title } = useCollection();
@ -33,6 +33,7 @@ export const TableSelectorDesigner = () => {
}; };
}); });
const template = useSchemaTemplate(); const template = useSchemaTemplate();
const collection = useCollection();
const { dragSort } = field.decoratorProps; const { dragSort } = field.decoratorProps;
return ( return (
<GeneralSchemaDesigner template={template} title={title || name} disableInitializer> <GeneralSchemaDesigner template={template} title={title || name} disableInitializer>
@ -77,6 +78,27 @@ export const TableSelectorDesigner = () => {
}); });
}} }}
/> />
{collection?.tree && (
<SchemaSettings.SwitchItem
title={t('Tree table')}
defaultChecked={true}
checked={field.decoratorProps.treeTable !== false}
onChange={(flag) => {
field.form.clearFormGraph(`${field.address}.*`);
field.decoratorProps.treeTable = flag;
fieldSchema['x-decorator-props'].treeTable = flag;
const params = {
...service.params?.[0],
tree: flag ? true : null,
};
dn.emit('patch', {
schema: fieldSchema,
});
dn.refresh();
service.run(params);
}}
/>
)}
{!dragSort && ( {!dragSort && (
<SchemaSettings.ModalItem <SchemaSettings.ModalItem
title={t('Set default sorting rules')} title={t('Set default sorting rules')}

View File

@ -0,0 +1,110 @@
import { ArrayField } from '@formily/core';
import { connect, ISchema, observer, RecursionField, useField, useFieldSchema } from '@formily/react';
import { SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
import { Table, TableColumnType } from 'antd';
import React from 'react';
const ArrayTable = observer((props: any) => {
const { rowKey } = props;
const field = useField<ArrayField>();
const schema = useFieldSchema();
const columnSchemas = schema.reduceProperties((buf, s) => {
if (s['x-component'] === 'ArrayTable.Column') {
buf.push(s);
}
return buf;
}, []);
const columns = columnSchemas.map((s) => {
return {
render: (value, record) => {
return <RecursionField name={record.__path} schema={s} onlyRenderProperties />;
},
} as TableColumnType<any>;
});
return <Table {...props} rowKey={rowKey} columns={columns} dataSource={field.value} />;
});
const Value = connect((props) => {
return <li>value: {props.value}</li>;
});
const schema: ISchema = {
type: 'object',
properties: {
objArr: {
type: 'array',
default: [
{ __path: '0', id: 1, value: 't1' },
{
__path: '1',
id: 2,
value: 't2',
children: [
{
__path: '1.children.0',
id: 5,
value: 't5',
parentId: 2,
},
],
},
{
__path: '2',
id: 3,
value: 't3',
children: [
{
__path: '2.children.0',
id: 4,
value: 't4',
parentId: 3,
children: [
{
__path: '2.children.0.children.0',
id: 6,
value: 't6',
parentId: 4,
},
{
__path: '2.children.0.children.1',
id: 7,
value: 't7',
parentId: 4,
},
],
},
],
},
],
'x-component': 'ArrayTable',
'x-component-props': {
rowKey: 'id',
rowSelection: {
type: 'checkbox',
},
},
properties: {
c1: {
type: 'void',
'x-component': 'ArrayTable.Column',
properties: {
value: {
type: 'string',
'x-component': 'Value',
},
},
},
},
},
},
};
export default () => {
return (
<SchemaComponentProvider components={{ ArrayTable, Value }}>
<SchemaComponent schema={schema} />
</SchemaComponentProvider>
);
};

View File

@ -9,8 +9,4 @@ group:
## TableBlock ## TableBlock
<code src="./demos/demo1.tsx" /> <code src="./demos/demo3.tsx" />
## TableField
<code src="./demos/demo2.tsx" />

View File

@ -7,3 +7,26 @@ export const isCollectionFieldComponent = (schema: ISchema) => {
export const isColumnComponent = (schema: Schema) => { export const isColumnComponent = (schema: Schema) => {
return schema['x-component']?.endsWith('.Column') > -1; return schema['x-component']?.endsWith('.Column') > -1;
}; };
export function extractIndex(str) {
const numbers = [];
str?.split('.').forEach(function (element) {
if (!isNaN(element)) {
numbers.push(String(Number(element) + 1));
}
});
return numbers.join('.');
}
export function getIdsWithChildren(nodes) {
const ids = [];
if (nodes) {
for (let node of nodes) {
if (node.children && node.children.length > 0) {
ids.push(node.id);
ids.push(...getIdsWithChildren(node.children));
}
}
}
return ids;
}

View File

@ -10,7 +10,7 @@ import {
SchemaInitializerButtonProps, SchemaInitializerButtonProps,
SchemaInitializerItemComponent, SchemaInitializerItemComponent,
SchemaInitializerItemOptions, SchemaInitializerItemOptions,
SchemaInitializerItemProps, SchemaInitializerItemProps
} from './types'; } from './types';
const defaultWrap = (s: ISchema) => s; const defaultWrap = (s: ISchema) => s;
@ -47,7 +47,11 @@ SchemaInitializer.Button = observer((props: SchemaInitializerButtonProps) => {
} }
}; };
const renderItems = (items: any) => { const renderItems = (items: any) => {
return items?.map((item, indexA) => { return items
.filter((v) => {
return v && (v?.visible ? v.visible() : true);
})
?.map((item, indexA) => {
if (item.type === 'divider') { if (item.type === 'divider') {
return <Menu.Divider key={item.key || `item-${indexA}`} />; return <Menu.Divider key={item.key || `item-${indexA}`} />;
} }

View File

@ -4,6 +4,7 @@ import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SchemaInitializer, SchemaSettings } from '../..'; import { SchemaInitializer, SchemaSettings } from '../..';
import { useAPIClient } from '../../api-client'; import { useAPIClient } from '../../api-client';
import { useCollection } from '../../collection-manager';
import { createDesignable, useDesignable } from '../../schema-component'; import { createDesignable, useDesignable } from '../../schema-component';
export const Resizable = (props) => { export const Resizable = (props) => {
@ -49,6 +50,8 @@ export const TableActionColumnInitializers = (props: any) => {
const api = useAPIClient(); const api = useAPIClient();
const { refresh } = useDesignable(); const { refresh } = useDesignable();
const { t } = useTranslation(); const { t } = useTranslation();
const collection = useCollection();
const { treeTable } = fieldSchema?.parent?.parent['x-decorator-props']||{};
return ( return (
<SchemaInitializer.Button <SchemaInitializer.Button
insertPosition={'beforeEnd'} insertPosition={'beforeEnd'}
@ -106,6 +109,17 @@ export const TableActionColumnInitializers = (props: any) => {
'x-decorator': 'ACLActionProvider', 'x-decorator': 'ACLActionProvider',
}, },
}, },
collection.tree &&
treeTable !== false && {
type: 'item',
title: t('Add child'),
component: 'CreateChildInitializer',
schema: {
'x-component': 'Action.Link',
'x-action': 'create',
'x-decorator': 'ACLActionProvider',
},
},
], ],
}, },
{ {

View File

@ -1,4 +1,5 @@
import { Schema } from '@formily/react'; import { Schema, useFieldSchema } from '@formily/react';
import { useCollection } from '../../';
// 表格操作配置 // 表格操作配置
export const TableActionInitializers = { export const TableActionInitializers = {
@ -49,6 +50,20 @@ export const TableActionInitializers = {
'x-align': 'right', 'x-align': 'right',
}, },
}, },
{
type: 'item',
title: "{{t('Expand/Collapse')}}",
component: 'ExpandActionInitializer',
schema: {
'x-align': 'right',
},
visible: () => {
const schema = useFieldSchema();
const collection = useCollection();
const { treeTable } = schema?.parent?.['x-decorator-props']||{};
return collection.tree && treeTable !== false;
},
},
], ],
}, },
{ {

View File

@ -0,0 +1,55 @@
import React from 'react';
import { ActionInitializer } from './ActionInitializer';
export const CreateChildInitializer = (props) => {
const schema = {
type: 'void',
title: '{{ t("Add child") }}',
'x-action': 'create',
'x-designer': 'Action.Designer',
'x-component': 'Action',
'x-visible': '{{treeTable}}',
'x-component-props': {
icon: 'PlusOutlined',
openMode: 'drawer',
type: 'primary',
addChild: true,
},
properties: {
drawer: {
type: 'void',
title: '{{ t("Add record") }}',
'x-component': 'Action.Container',
'x-component-props': {
className: 'nb-action-popup',
},
properties: {
tabs: {
type: 'void',
'x-component': 'Tabs',
'x-component-props': {},
'x-initializer': 'TabPaneInitializersForCreateFormBlock',
properties: {
tab1: {
type: 'void',
title: '{{t("Add new")}}',
'x-component': 'Tabs.TabPane',
'x-designer': 'Tabs.Designer',
'x-component-props': {},
properties: {
grid: {
type: 'void',
'x-component': 'Grid',
'x-initializer': 'CreateFormBlockInitializers',
properties: {},
},
},
},
},
},
},
},
},
};
return <ActionInitializer {...props} schema={schema} />;
};

View File

@ -1,11 +1,11 @@
import React from "react"; import React from 'react';
import { FormOutlined } from '@ant-design/icons'; import { FormOutlined } from '@ant-design/icons';
import { useBlockAssociationContext } from "../../block-provider"; import { useBlockAssociationContext } from '../../block-provider';
import { useCollection } from "../../collection-manager"; import { useCollection } from '../../collection-manager';
import { useSchemaTemplateManager } from "../../schema-templates"; import { useSchemaTemplateManager } from '../../schema-templates';
import { SchemaInitializer } from "../SchemaInitializer"; import { SchemaInitializer } from '../SchemaInitializer';
import { createFormBlockSchema, useRecordCollectionDataSourceItems } from "../utils"; import { createFormBlockSchema, useRecordCollectionDataSourceItems } from '../utils';
export const CreateFormBlockInitializer = (props) => { export const CreateFormBlockInitializer = (props) => {
const { onCreateBlockSchema, componentType, createBlockSchema, insert, ...others } = props; const { onCreateBlockSchema, componentType, createBlockSchema, insert, ...others } = props;
@ -46,4 +46,4 @@ export const CreateFormBlockInitializer = (props) => {
items={useRecordCollectionDataSourceItems('FormItem')} items={useRecordCollectionDataSourceItems('FormItem')}
/> />
); );
}; };

View File

@ -1,6 +1,6 @@
import React from "react"; import React from 'react';
import { useFieldSchema } from '@formily/react';
import { ActionInitializer } from "./ActionInitializer"; import { ActionInitializer } from './ActionInitializer';
export const CreateSubmitActionInitializer = (props) => { export const CreateSubmitActionInitializer = (props) => {
const schema = { const schema = {
@ -15,4 +15,4 @@ export const CreateSubmitActionInitializer = (props) => {
}, },
}; };
return <ActionInitializer {...props} schema={schema} />; return <ActionInitializer {...props} schema={schema} />;
}; };

View File

@ -0,0 +1,89 @@
import React from 'react';
import { Button } from 'antd';
import { css } from '@emotion/css';
import { useTranslation } from 'react-i18next';
import { useFieldSchema } from '@formily/react';
import { ActionInitializer } from './ActionInitializer';
import { useTableBlockContext, useTableSelectorContext } from '../../';
import { NodeCollapseOutlined, NodeExpandOutlined } from '@ant-design/icons';
export const ExpandActionInitializer = (props) => {
const schema = {
'x-action': 'expandAll',
'x-component': 'Action',
'x-designer': 'Action.Designer',
'x-component-props': {
icon: '{{ expandIcon}}',
useProps: '{{ useExpandAllActionProps }}',
component: 'ExpandActionComponent',
useAction: () => {
return {
run() {},
};
},
},
};
return <ActionInitializer {...props} schema={schema} />;
};
const actionDesignerCss = css`
position: relative;
&:hover {
.general-schema-designer {
display: block;
}
}
.general-schema-designer {
position: absolute;
z-index: 999;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: none;
background: rgba(241, 139, 98, 0.06);
border: 0;
top: 0;
bottom: 0;
left: 0;
right: 0;
pointer-events: none;
> .general-schema-designer-icons {
position: absolute;
right: 2px;
top: 2px;
line-height: 16px;
pointer-events: all;
.ant-space-item {
background-color: #f18b62;
color: #fff;
line-height: 16px;
width: 16px;
padding-left: 1px;
}
}
}
`;
export const ExpandActionComponent = (props) => {
const { t } = useTranslation();
const schema = useFieldSchema();
const isTableSelector = schema.parent?.parent?.['x-decorator'] === 'TableSelectorProvider';
const ctx = isTableSelector ? useTableSelectorContext() : useTableBlockContext();
return (
<div className={actionDesignerCss}>
{ctx.params['tree'] && (
<Button
onClick={() => {
ctx?.setExpandFlag();
}}
icon={ctx?.expandFlag ? <NodeCollapseOutlined /> : <NodeExpandOutlined />}
type={props.type}
>
{props.children[1]}
<span style={{ marginLeft: 10 }}>{ctx?.expandFlag ? t('Collapse all') : t('Expand all')}</span>
</Button>
)}
</div>
);
};

View File

@ -42,3 +42,10 @@ export * from './TableSelectorInitializer';
export * from './UpdateActionInitializer'; export * from './UpdateActionInitializer';
export * from './UpdateSubmitActionInitializer'; export * from './UpdateSubmitActionInitializer';
export * from './ViewActionInitializer'; export * from './ViewActionInitializer';
export * from './CreateChildInitializer';
export * from './ExpandActionInitializer';
// 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

@ -76,7 +76,9 @@ export const useTableColumnInitializerFields = () => {
const { name, currentFields = [] } = useCollection(); const { name, currentFields = [] } = useCollection();
const { getInterface } = useCollectionManager(); const { getInterface } = useCollectionManager();
return currentFields return currentFields
.filter((field) => field?.interface && field?.interface !== 'subTable' && !field?.isForeignKey) .filter(
(field) => field?.interface && field?.interface !== 'subTable' && !field?.isForeignKey && !field?.treeChildren,
)
.map((field) => { .map((field) => {
const interfaceConfig = getInterface(field.interface); const interfaceConfig = getInterface(field.interface);
const schema = { const schema = {
@ -113,7 +115,9 @@ export const useAssociatedTableColumnInitializerFields = () => {
const subFields = getCollectionFields(field.target); const subFields = getCollectionFields(field.target);
const items = subFields const items = subFields
// ?.filter((subField) => subField?.interface && !['o2o', 'oho', 'obo', 'o2m', 'm2o', 'subTable', 'linkTo'].includes(subField?.interface)) // ?.filter((subField) => subField?.interface && !['o2o', 'oho', 'obo', 'o2m', 'm2o', 'subTable', 'linkTo'].includes(subField?.interface))
?.filter((subField) => subField?.interface && !['subTable'].includes(subField?.interface)) ?.filter(
(subField) => subField?.interface && !['subTable'].includes(subField?.interface) && !subField?.treeChildren,
)
?.map((subField) => { ?.map((subField) => {
const interfaceConfig = getInterface(subField.interface); const interfaceConfig = getInterface(subField.interface);
const schema = { const schema = {
@ -198,7 +202,7 @@ export const useFormItemInitializerFields = (options?: any) => {
const { snapshot } = useActionContext(); const { snapshot } = useActionContext();
return currentFields return currentFields
?.filter((field) => field?.interface && !field?.isForeignKey) ?.filter((field) => field?.interface && !field?.isForeignKey && !field?.treeChildren)
?.map((field) => { ?.map((field) => {
const interfaceConfig = getInterface(field.interface); const interfaceConfig = getInterface(field.interface);
const schema = { const schema = {
@ -246,7 +250,9 @@ export const useAssociatedFormItemInitializerFields = (options?: any) => {
?.map((field) => { ?.map((field) => {
const subFields = getCollectionFields(field.target); const subFields = getCollectionFields(field.target);
const items = subFields const items = subFields
?.filter((subField) => subField?.interface && !['subTable'].includes(subField?.interface)) ?.filter(
(subField) => subField?.interface && !['subTable'].includes(subField?.interface) && !subField.treeChildren,
)
?.map((subField) => { ?.map((subField) => {
const interfaceConfig = getInterface(subField.interface); const interfaceConfig = getInterface(subField.interface);
const schema = { const schema = {

View File

@ -0,0 +1,217 @@
import { Database } from '../database';
import { mockDatabase } from './';
describe('sort', function () {
let db: Database;
beforeEach(async () => {
db = mockDatabase();
});
afterEach(async () => {
await db.close();
});
it('should be auto completed', () => {
const collection = db.collection({
name: 'categories',
tree: 'adjacency-list',
fields: [
{
type: 'belongsTo',
name: 'parent',
treeParent: true,
},
{
type: 'hasMany',
name: 'children',
treeChildren: true,
},
],
});
expect(collection.treeChildrenField?.name).toBe('children');
expect(collection.treeParentField?.name).toBe('parent');
expect(collection.getField('parent').options.target).toBe('categories');
expect(collection.getField('parent').options.foreignKey).toBe('parentId');
expect(collection.getField('children').options.target).toBe('categories');
expect(collection.getField('children').options.foreignKey).toBe('parentId');
});
it('should be auto completed', () => {
const collection = db.collection({
name: 'categories',
tree: 'adjacency-list',
fields: [
{
type: 'belongsTo',
name: 'parent',
foreignKey: 'cid',
treeParent: true,
},
{
type: 'hasMany',
name: 'children',
foreignKey: 'cid',
treeChildren: true,
},
],
});
expect(collection.treeChildrenField?.name).toBe('children');
expect(collection.treeParentField?.name).toBe('parent');
expect(collection.getField('parent').options.target).toBe('categories');
expect(collection.getField('parent').options.foreignKey).toBe('cid');
expect(collection.getField('children').options.target).toBe('categories');
expect(collection.getField('children').options.foreignKey).toBe('cid');
});
const values = [
{
name: '1',
__index: '0',
children: [
{
name: '1-1',
__index: '0.children.0',
children: [
{
name: '1-1-1',
__index: '0.children.0.children.0',
children: [
{
name: '1-1-1-1',
__index: '0.children.0.children.0.children.0',
},
],
},
],
},
],
},
{
name: '2',
__index: '1',
children: [
{
name: '2-1',
__index: '1.children.0',
children: [
{
name: '2-1-1',
__index: '1.children.0.children.0',
children: [
{
name: '2-1-1-1',
__index: '1.children.0.children.0.children.0',
},
],
},
],
},
],
},
];
it('should be tree', async () => {
const collection = db.collection({
name: 'categories',
tree: 'adjacency-list',
fields: [
{
type: 'string',
name: 'name',
},
{
type: 'string',
name: 'description',
},
{
type: 'belongsTo',
name: 'parent',
treeParent: true,
},
{
type: 'hasMany',
name: 'children',
treeChildren: true,
},
],
});
await db.sync();
await db.getRepository('categories').create({
values,
});
const instances = await db.getRepository('categories').find({
filter: {
parentId: null,
},
tree: true,
fields: ['id', 'name'],
sort: 'id',
});
expect(instances.map((i) => i.toJSON())).toMatchObject(values);
const instance = await db.getRepository('categories').findOne({
filterByTk: 1,
tree: true,
fields: ['id', 'name'],
});
expect(instance.toJSON()).toMatchObject(values[0]);
});
it('should be tree', async () => {
const collection = db.collection({
name: 'categories',
tree: 'adjacency-list',
fields: [
{
type: 'string',
name: 'name',
},
{
type: 'string',
name: 'description',
},
{
type: 'belongsTo',
name: 'parent',
foreignKey: 'cid',
treeParent: true,
},
{
type: 'hasMany',
name: 'children',
foreignKey: 'cid',
treeChildren: true,
},
],
});
await db.sync();
await db.getRepository('categories').create({
values,
});
const instances = await db.getRepository('categories').find({
filter: {
cid: null,
},
tree: true,
fields: ['id', 'name'],
sort: 'id',
});
expect(instances.map((i) => i.toJSON())).toMatchObject(values);
const instance = await db.getRepository('categories').findOne({
filterByTk: 1,
tree: true,
fields: ['id', 'name'],
});
expect(instance.toJSON()).toMatchObject(values[0]);
});
});

View File

@ -7,10 +7,10 @@ import {
QueryInterfaceDropTableOptions, QueryInterfaceDropTableOptions,
SyncOptions, SyncOptions,
Transactionable, Transactionable,
Utils, Utils
} from 'sequelize'; } from 'sequelize';
import { Database } from './database'; import { Database } from './database';
import { Field, FieldOptions } from './fields'; import { BelongsToField, Field, FieldOptions, HasManyField } from './fields';
import { Model } from './model'; import { Model } from './model';
import { Repository } from './repository'; import { Repository } from './repository';
import { checkIdentifier, md5, snakeCase } from './utils'; import { checkIdentifier, md5, snakeCase } from './utils';
@ -48,6 +48,7 @@ export interface CollectionOptions extends Omit<ModelOptions, 'name' | 'hooks'>
*/ */
magicAttribute?: string; magicAttribute?: string;
tree?: string;
[key: string]: any; [key: string]: any;
} }
@ -82,6 +83,22 @@ export class Collection<
return this.context.database; return this.context.database;
} }
get treeParentField(): BelongsToField | null {
for (const [_, field] of this.fields) {
if (field.options.treeParent) {
return field;
}
}
}
get treeChildrenField(): HasManyField | null {
for (const [_, field] of this.fields) {
if (field.options.treeChildren) {
return field;
}
}
}
constructor(options: CollectionOptions, context: CollectionContext) { constructor(options: CollectionOptions, context: CollectionContext) {
super(); super();
this.context = context; this.context = context;
@ -243,6 +260,7 @@ export class Collection<
this.checkFieldType(name, options); this.checkFieldType(name, options);
const { database } = this.context; const { database } = this.context;
this.emit('field.beforeAdd', name, options, { collection: this });
const field = database.buildField( const field = database.buildField(
{ name, ...options }, { name, ...options },

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,11 +58,12 @@ import {
SyncListener, SyncListener,
UpdateListener, UpdateListener,
UpdateWithAssociationsListener, UpdateWithAssociationsListener,
ValidateListener, ValidateListener
} from './types'; } from './types';
import { patchSequelizeQueryInterface, snakeCase } from './utils'; import { patchSequelizeQueryInterface, snakeCase } from './utils';
import DatabaseUtils from './database-utils'; import DatabaseUtils from './database-utils';
import { registerBuiltInListeners } from './listeners';
import { BaseValueParser, registerFieldValueParsers } from './value-parsers'; import { BaseValueParser, registerFieldValueParsers } from './value-parsers';
import buildQueryInterface from './query-interface/query-interface-builder'; import buildQueryInterface from './query-interface/query-interface-builder';
import QueryInterface from './query-interface/query-interface'; import QueryInterface from './query-interface/query-interface';
@ -363,6 +364,8 @@ export class Database extends EventEmitter implements AsyncEmitter {
options.schema = this.options.schema; options.schema = this.options.schema;
} }
}); });
registerBuiltInListeners(this);
} }
addMigration(item: MigrationItem) { addMigration(item: MigrationItem) {

View File

@ -0,0 +1,60 @@
import lodash from 'lodash';
import { Collection, CollectionOptions } from '../collection';
import { Model } from '../model';
export const beforeDefineAdjacencyListCollection = (options: CollectionOptions) => {
if (!options.tree) {
return;
}
(options.fields || []).forEach((field) => {
if (field.treeParent || field.treeChildren) {
if (!field.target) {
field.target = options.name;
}
if (!field.foreignKey) {
field.foreignKey = 'parentId';
}
}
});
};
export const afterDefineAdjacencyListCollection = (collection: Collection) => {
if (!collection.options.tree) {
return;
}
collection.model.afterFind(async (instances, options: any) => {
if (!options.tree) {
return;
}
const foreignKey = collection.treeParentField?.foreignKey ?? 'parentId';
const childrenKey = collection.treeChildrenField?.name ?? 'children';
const arr: Model[] = Array.isArray(instances) ? instances : [instances];
let index = 0;
for (const instance of arr) {
const opts = {
...lodash.pick(options, ['tree', 'fields', 'appends', 'except', 'sort']),
};
let __index = `${index++}`;
if (options.parentIndex) {
__index = `${options.parentIndex}.${__index}`;
}
instance.setDataValue('__index', __index);
const children = await collection.repository.find({
filter: {
[foreignKey]: instance.id,
},
transaction: options.transaction,
...opts,
// @ts-ignore
parentIndex: `${__index}.${childrenKey}`,
context: options.context,
});
if (children?.length > 0) {
instance.setDataValue(
childrenKey,
children.map((r) => r.toJSON()),
);
}
}
});
};

View File

@ -0,0 +1,7 @@
import { Database } from '../database';
import { afterDefineAdjacencyListCollection, beforeDefineAdjacencyListCollection } from './adjacency-list';
export const registerBuiltInListeners = (db: Database) => {
db.on('beforeDefineCollection', beforeDefineAdjacencyListCollection);
db.on('afterDefineCollection', afterDefineAdjacencyListCollection);
};

View File

@ -11,7 +11,7 @@ import {
Op, Op,
Transactionable, Transactionable,
UpdateOptions as SequelizeUpdateOptions, UpdateOptions as SequelizeUpdateOptions,
WhereOperators, WhereOperators
} from 'sequelize'; } from 'sequelize';
import { Collection } from './collection'; import { Collection } from './collection';
import { Database } from './database'; import { Database } from './database';
@ -106,6 +106,7 @@ export interface CommonFindOptions extends Transactionable {
except?: Except; except?: Except;
sort?: Sort; sort?: Sort;
context?: any; context?: any;
tree?: boolean;
} }
export type FindOneOptions = Omit<FindOptions, 'limit'>; export type FindOneOptions = Omit<FindOptions, 'limit'>;

View File

@ -283,4 +283,128 @@ describe('list association action with acl', () => {
expect(data['meta']['allowedActions'].view).toContain(1); expect(data['meta']['allowedActions'].view).toContain(1);
expect(data['meta']['allowedActions'].view).toContain(2); expect(data['meta']['allowedActions'].view).toContain(2);
}); });
it('tree list action allowActions', async () => {
await db.getRepository('roles').create({
values: {
name: 'newRole',
},
});
const user = await db.getRepository('users').create({
values: {
roles: ['newRole'],
},
});
const userPlugin = app.getPlugin('users');
const agent = app
.agent()
.set('X-With-ACL-Meta', true)
.auth(
userPlugin.jwtService.sign({
userId: user.get('id'),
}),
{ type: 'bearer' },
);
app.acl.allow('table_a', ['*']);
app.acl.allow('collections', ['*']);
await agent.resource('collections').create({
values: {
autoGenId: true,
createdBy: false,
updatedBy: false,
createdAt: false,
updatedAt: false,
sortable: false,
name: 'table_a',
template: 'tree',
tree: 'adjacencyList',
fields: [
{
interface: 'integer',
name: 'parentId',
type: 'bigInt',
isForeignKey: true,
uiSchema: {
type: 'number',
title: '{{t("Parent ID")}}',
'x-component': 'InputNumber',
'x-read-pretty': true,
},
target: 'table_a',
},
{
interface: 'm2o',
type: 'belongsTo',
name: 'parent',
foreignKey: 'parentId',
uiSchema: {
title: '{{t("Parent")}}',
'x-component': 'RecordPicker',
'x-component-props': { multiple: false, fieldNames: { label: 'id', value: 'id' } },
},
target: 'table_a',
},
{
interface: 'o2m',
type: 'hasMany',
name: 'children',
foreignKey: 'parentId',
uiSchema: {
title: '{{t("Children")}}',
'x-component': 'RecordPicker',
'x-component-props': { multiple: true, fieldNames: { label: 'id', value: 'id' } },
},
target: 'table_a',
},
{
name: 'id',
type: 'bigInt',
autoIncrement: true,
primaryKey: true,
allowNull: false,
uiSchema: { type: 'number', title: '{{t("ID")}}', 'x-component': 'InputNumber', 'x-read-pretty': true },
interface: 'id',
},
],
title: 'table_a',
},
});
await agent.resource('table_a').create({
values: {},
});
await agent.resource('table_a').create({
values: {
parent: {
id: 1,
},
},
});
await agent.resource('table_a').create({
values: {},
});
await agent.resource('table_a').create({
values: {
parent: {
id: 3,
},
},
});
const res = await agent.resource('table_a').list({
filter: JSON.stringify({
parentId: null,
}),
tree: true,
});
expect(res.body.meta.allowedActions.view.sort()).toMatchObject([1, 2, 3, 4]);
});
}); });

View File

@ -673,7 +673,15 @@ export class PluginACL extends Plugin {
]); ]);
} }
const ids = listData.map((item) => item[primaryKeyField]); const ids = (() => {
if (collection.options.tree) {
if (listData.length == 0) return [];
const getAllNodeIds = (data) => [data[primaryKeyField], ...(data.children || []).flatMap(getAllNodeIds)];
return listData.map((tree) => getAllNodeIds(tree.toJSON())).flat();
}
return listData.map((item) => item[primaryKeyField]);
})();
const conditions = []; const conditions = [];

View File

@ -99,7 +99,7 @@ export function afterCreateForForeignKeyField(db: Database) {
}); });
} }
return async (model, { transaction, context }) => { const hook = async (model, { transaction, context }) => {
// skip if no app context // skip if no app context
if (!context) { if (!context) {
return; return;
@ -174,4 +174,12 @@ export function afterCreateForForeignKeyField(db: Database) {
}); });
} }
}; };
return async (model, options) => {
try {
await hook(model, options);
} catch (error) {
}
};
} }