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:
parent
cd2340ecd0
commit
a9e5e14429
@ -10,9 +10,6 @@
|
||||
"@nocobase/database": "0.9.1-alpha.2",
|
||||
"@nocobase/resourcer": "0.9.1-alpha.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nocobase/test": "0.9.1-alpha.2"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/nocobase/nocobase.git",
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { MockServer, mockServer } from '@nocobase/test';
|
||||
import { registerActions } from '@nocobase/actions';
|
||||
import { MockServer, mockServer } from './';
|
||||
|
||||
describe('get action', () => {
|
||||
let app: MockServer;
|
||||
@ -10,9 +10,7 @@ describe('get action', () => {
|
||||
let Profile;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = mockServer({
|
||||
dataWrapping: false,
|
||||
});
|
||||
app = mockServer();
|
||||
registerActions(app);
|
||||
|
||||
PostTag = app.collection({
|
||||
|
@ -129,3 +129,201 @@ describe('list action', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { ActionParams } from '@nocobase/resourcer';
|
||||
import { assign } from '@nocobase/utils';
|
||||
import { Context } from '..';
|
||||
import { getRepositoryFromParams } from '../utils';
|
||||
|
||||
@ -22,9 +22,20 @@ function totalPage(total, pageSize): number {
|
||||
return Math.ceil(total / pageSize);
|
||||
}
|
||||
|
||||
function findArgs(params: ActionParams) {
|
||||
const { fields, filter, appends, except, sort } = params;
|
||||
return { filter, fields, appends, except, sort };
|
||||
function findArgs(ctx: Context) {
|
||||
const resourceName = ctx.action.resourceName;
|
||||
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) {
|
||||
@ -34,7 +45,7 @@ async function listWithPagination(ctx: Context) {
|
||||
|
||||
const [rows, count] = await repository.findAndCount({
|
||||
context: ctx,
|
||||
...findArgs(ctx.action.params),
|
||||
...findArgs(ctx),
|
||||
...pageArgsToLimitArgs(parseInt(String(page)), parseInt(String(pageSize))),
|
||||
});
|
||||
|
||||
@ -50,7 +61,7 @@ async function listWithPagination(ctx: Context) {
|
||||
async function listWithNonPaged(ctx: Context) {
|
||||
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;
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ import { useCollection } from '../collection-manager';
|
||||
import { RecordProvider, useRecord } from '../record-provider';
|
||||
import { useDesignable } from '../schema-component';
|
||||
import { BlockProvider, useBlockRequestContext } from './BlockProvider';
|
||||
import { useActionContext } from '../schema-component';
|
||||
|
||||
export const FormBlockContext = createContext<any>({});
|
||||
|
||||
@ -88,6 +89,18 @@ export const useFormBlockContext = () => {
|
||||
|
||||
export const useFormBlockProps = () => {
|
||||
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(() => {
|
||||
ctx.form.setInitialValues(ctx.service?.data?.data);
|
||||
}, []);
|
||||
|
@ -1,17 +1,18 @@
|
||||
import { ArrayField, createForm } from '@formily/core';
|
||||
import { FormContext, Schema, useField, useFieldSchema } from '@formily/react';
|
||||
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 { SchemaComponentOptions, useFixedSchema } from '../schema-component';
|
||||
import { BlockProvider, RenderChildrenWithAssociationFilter, useBlockRequestContext } from './BlockProvider';
|
||||
import { useFixedSchema } from '../schema-component';
|
||||
|
||||
export const TableBlockContext = createContext<any>({});
|
||||
|
||||
const InternalTableBlockProvider = (props) => {
|
||||
const { params, showIndex, dragSort, rowKey } = props;
|
||||
const { params, showIndex, dragSort, rowKey, childrenColumnName } = props;
|
||||
const field = useField();
|
||||
const { resource, service } = useBlockRequestContext();
|
||||
const [expandFlag, setExpandFlag] = useState(false);
|
||||
// if (service.loading) {
|
||||
// return <Spin />;
|
||||
// }
|
||||
@ -26,6 +27,9 @@ const InternalTableBlockProvider = (props) => {
|
||||
showIndex,
|
||||
dragSort,
|
||||
rowKey,
|
||||
expandFlag,
|
||||
childrenColumnName,
|
||||
setExpandFlag: () => setExpandFlag(!expandFlag),
|
||||
}}
|
||||
>
|
||||
<RenderChildrenWithAssociationFilter {...props} />
|
||||
@ -81,21 +85,44 @@ export const useAssociationNames = (collection) => {
|
||||
};
|
||||
|
||||
export const TableBlockProvider = (props) => {
|
||||
const resourceName = props.resource;
|
||||
const params = { ...props.params };
|
||||
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) {
|
||||
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')) {
|
||||
params['appends'] = appends;
|
||||
}
|
||||
const form = useMemo(() => createForm(), [treeTable]);
|
||||
return (
|
||||
<FormContext.Provider value={form}>
|
||||
<BlockProvider {...props} params={params}>
|
||||
<InternalTableBlockProvider {...props} params={params} />
|
||||
</BlockProvider>
|
||||
</FormContext.Provider>
|
||||
<SchemaComponentOptions scope={{ treeTable }}>
|
||||
<FormContext.Provider value={form}>
|
||||
<BlockProvider {...props} params={params}>
|
||||
<InternalTableBlockProvider {...props} childrenColumnName={childrenColumnName} params={params} />
|
||||
</BlockProvider>
|
||||
</FormContext.Provider>
|
||||
</SchemaComponentOptions>
|
||||
);
|
||||
};
|
||||
|
||||
@ -120,6 +147,7 @@ export const useTableBlockProps = () => {
|
||||
}
|
||||
}, [ctx?.service?.loading]);
|
||||
return {
|
||||
childrenColumnName: ctx.childrenColumnName,
|
||||
loading: ctx?.service?.loading,
|
||||
showIndex: ctx.showIndex,
|
||||
dragSort: ctx.dragSort,
|
||||
@ -132,6 +160,7 @@ export const useTableBlockProps = () => {
|
||||
}
|
||||
: false,
|
||||
onRowSelectionChange(selectedRowKeys) {
|
||||
console.log(selectedRowKeys);
|
||||
ctx.field.data = ctx?.field?.data || {};
|
||||
ctx.field.data.selectedRowKeys = selectedRowKeys;
|
||||
},
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { ArrayField } from '@formily/core';
|
||||
import { Schema, useField, useFieldSchema } from '@formily/react';
|
||||
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 { RecordProvider, useRecord } from '../record-provider';
|
||||
import { SchemaComponentOptions } from '../schema-component';
|
||||
import { BlockProvider, RenderChildrenWithAssociationFilter, useBlockRequestContext } from './BlockProvider';
|
||||
import { useFormBlockContext } from './FormBlockProvider';
|
||||
|
||||
export const TableSelectorContext = createContext<any>({});
|
||||
|
||||
@ -13,6 +13,7 @@ const InternalTableSelectorProvider = (props) => {
|
||||
const { params, rowKey, extraFilter } = props;
|
||||
const field = useField();
|
||||
const { resource, service } = useBlockRequestContext();
|
||||
const [expandFlag, setExpandFlag] = useState(false);
|
||||
// if (service.loading) {
|
||||
// return <Spin />;
|
||||
// }
|
||||
@ -26,6 +27,10 @@ const InternalTableSelectorProvider = (props) => {
|
||||
params,
|
||||
extraFilter,
|
||||
rowKey,
|
||||
expandFlag,
|
||||
setExpandFlag: () => {
|
||||
setExpandFlag(!expandFlag);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<RenderChildrenWithAssociationFilter {...props} />
|
||||
@ -99,19 +104,29 @@ const useAssociationNames = (collection) => {
|
||||
|
||||
export const TableSelectorProvider = (props) => {
|
||||
const fieldSchema = useFieldSchema();
|
||||
const ctx = useFormBlockContext();
|
||||
const { getCollectionJoinField, getCollectionFields } = useCollectionManager();
|
||||
const record = useRecord();
|
||||
|
||||
const { getCollection } = useCollectionManager();
|
||||
const collection = getCollection(props.collection);
|
||||
const { treeTable } = fieldSchema?.['x-decorator-props'] || {};
|
||||
const collectionFieldSchema = recursiveParent(fieldSchema, 'CollectionField');
|
||||
// const value = ctx.form.query(collectionFieldSchema?.name).value();
|
||||
const collectionField = getCollectionJoinField(collectionFieldSchema?.['x-collection-field']);
|
||||
|
||||
const params = { ...props.params };
|
||||
const appends = useAssociationNames(props.collection);
|
||||
if (props.dragSort) {
|
||||
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')) {
|
||||
params['appends'] = appends;
|
||||
}
|
||||
@ -181,9 +196,11 @@ export const TableSelectorProvider = (props) => {
|
||||
}
|
||||
}
|
||||
return (
|
||||
<BlockProvider {...props} params={params}>
|
||||
<InternalTableSelectorProvider {...props} params={params} extraFilter={extraFilter} />
|
||||
</BlockProvider>
|
||||
<SchemaComponentOptions scope={{ treeTable }}>
|
||||
<BlockProvider {...props} params={params}>
|
||||
<InternalTableSelectorProvider {...props} params={params} extraFilter={extraFilter} />
|
||||
</BlockProvider>
|
||||
</SchemaComponentOptions>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -128,12 +128,12 @@ function getFormValues(filterByTk, field, form, fieldNames, getField, resource)
|
||||
export const useCreateActionProps = () => {
|
||||
const form = useForm();
|
||||
const { field, resource, __parent } = useBlockRequestContext();
|
||||
const { visible, setVisible } = useActionContext();
|
||||
const { visible, setVisible, fieldSchema } = useActionContext();
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
const actionSchema = useFieldSchema();
|
||||
const actionField = useField();
|
||||
const { fields, getField } = useCollection();
|
||||
const { fields, getField, getTreeParentField } = useCollection();
|
||||
const compile = useCompile();
|
||||
const filterByTk = useFilterByTk();
|
||||
const currentRecord = useRecord();
|
||||
@ -148,11 +148,17 @@ export const useCreateActionProps = () => {
|
||||
overwriteValues,
|
||||
skipValidator,
|
||||
} = actionSchema?.['x-action-settings'] ?? {};
|
||||
const { addChild } = fieldSchema?.['x-component-props'];
|
||||
const assignedValues = parse(originalAssignedValues)({ currentTime: new Date(), currentRecord, currentUser });
|
||||
if (!skipValidator) {
|
||||
await form.submit();
|
||||
}
|
||||
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.loading = true;
|
||||
try {
|
||||
|
@ -84,7 +84,7 @@ const getSchema = (schema, category, compile): ISchema => {
|
||||
'x-component': 'Action',
|
||||
'x-component-props': {
|
||||
type: 'primary',
|
||||
useAction: () => useCreateCollection(),
|
||||
useAction: () => useCreateCollection(schema),
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -188,16 +188,19 @@ const useDefaultCollectionFields = (values) => {
|
||||
return defaults;
|
||||
};
|
||||
|
||||
const useCreateCollection = () => {
|
||||
const useCreateCollection = (schema?: any) => {
|
||||
const form = useForm();
|
||||
const { refreshCM } = useCollectionManager();
|
||||
const ctx = useActionContext();
|
||||
const { refresh } = useResourceActionContext();
|
||||
const { resource } = useResourceContext();
|
||||
const { resource, collection } = useResourceContext();
|
||||
return {
|
||||
async run() {
|
||||
await form.submit();
|
||||
const values = cloneDeep(form.values);
|
||||
if (schema?.events?.beforeSubmit) {
|
||||
schema.events.beforeSubmit(values);
|
||||
}
|
||||
const fields = useDefaultCollectionFields(values);
|
||||
if (values.autoCreateReverseField) {
|
||||
} else {
|
||||
|
@ -28,9 +28,13 @@ export const useCollection = () => {
|
||||
...collection,
|
||||
resource,
|
||||
getField(name: SchemaKey): CollectionFieldOptions {
|
||||
const fields = totalFields;
|
||||
const fields = totalFields as any[];
|
||||
return fields?.find((field) => field.name === name);
|
||||
},
|
||||
getTreeParentField() {
|
||||
const fields = totalFields;
|
||||
return fields?.find((field) => field.treeParent);
|
||||
},
|
||||
fields: totalFields,
|
||||
getPrimaryKey: () => {
|
||||
if (collection.targetKey || collection.filterTargetKey) {
|
||||
|
@ -5,7 +5,7 @@ import {
|
||||
recordPickerSelector,
|
||||
recordPickerViewer,
|
||||
relationshipType,
|
||||
reverseFieldProperties,
|
||||
reverseFieldProperties
|
||||
} from './properties';
|
||||
import { IField } from './types';
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
export * from './calendar';
|
||||
export * from './general';
|
||||
export * from './tree';
|
||||
|
||||
|
@ -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'),
|
||||
};
|
@ -9,6 +9,7 @@ export interface ICollectionTemplate {
|
||||
order?: number;
|
||||
/** 默认配置 */
|
||||
default?: CollectionOptions;
|
||||
events?: any;
|
||||
/** UI 可配置的 CollectionOptions 参数(添加或编辑的 Collection 表单的字段) */
|
||||
configurableProperties?: Record<string, ISchema>;
|
||||
/** 当前模板可用的字段类型 */
|
||||
@ -48,4 +49,5 @@ interface CollectionOptions {
|
||||
inherits?: string | string[];
|
||||
/* 字段列表 */
|
||||
fields?: FieldOptions[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
@ -24,6 +24,7 @@ export interface CollectionOptions {
|
||||
sortable?: any;
|
||||
fields?: FieldOptions[];
|
||||
inherits?: string[];
|
||||
tree?: string;
|
||||
}
|
||||
|
||||
export interface ICollectionProviderProps {
|
||||
|
@ -108,6 +108,11 @@ export default {
|
||||
"Add block": "Add block",
|
||||
"Add new": "Add new",
|
||||
"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",
|
||||
"Display fields": "Display collection fields",
|
||||
"Edit record": "Edit record",
|
||||
|
@ -42,6 +42,11 @@ export default {
|
||||
"Category name":"分類名",
|
||||
"Delete category":"分類の削除",
|
||||
"Collection category":"Collection category",
|
||||
'Add child':'サブレコードの追加',
|
||||
'Collapse all':'すべて閉じる',
|
||||
'Expand all':'すべて展開',
|
||||
'Expand/Collapse':'展開と終了',
|
||||
"Tree table":"ツリーテーブル",
|
||||
"Visible":"表示",
|
||||
"Read only":"読み取り専用(編集不可)",
|
||||
"Easy reading":"読み取り専用(読取りモード)",
|
||||
|
@ -58,6 +58,15 @@ export default {
|
||||
"Sort":"排序",
|
||||
"Categories":"数据表类别",
|
||||
"Category name":"分类名称",
|
||||
'Add child':'添加子记录',
|
||||
'Collapse all':'全部收起',
|
||||
'Expand all':'全部展开',
|
||||
'Expand/Collapse':'展开/折叠',
|
||||
'Tree collection': '树结构表',
|
||||
"Tree table":"树表格",
|
||||
'Parent ID': '父记录ID',
|
||||
'Parent': '父记录',
|
||||
'Children': '子记录',
|
||||
"Roles & Permissions": "角色和权限",
|
||||
"Edit profile": "个人资料",
|
||||
"Change password": "修改密码",
|
||||
|
@ -50,6 +50,7 @@ export const ActionDesigner = (props) => {
|
||||
const actionType = fieldSchema['x-action'] ?? '';
|
||||
const isLinkageAction = Object.keys(useFormBlockContext()).length > 0 && Object.keys(useRecord()).length > 0;
|
||||
const isChildCollectionAction = getChildrenCollections(name).length > 0 && fieldSchema['x-action'] === 'create';
|
||||
const isSupportEditButton = fieldSchema['x-action'] !== 'expandAll';
|
||||
useEffect(() => {
|
||||
const schemaUid = uid();
|
||||
const schema: ISchema = {
|
||||
@ -85,6 +86,7 @@ export const ActionDesigner = (props) => {
|
||||
title: t('Button title'),
|
||||
default: fieldSchema.title,
|
||||
'x-component-props': {},
|
||||
'x-visible': isSupportEditButton,
|
||||
// description: `原字段标题:${collectionField?.uiSchema?.title}`,
|
||||
},
|
||||
icon: {
|
||||
@ -93,6 +95,7 @@ export const ActionDesigner = (props) => {
|
||||
title: t('Button icon'),
|
||||
default: fieldSchema?.['x-component-props']?.icon,
|
||||
'x-component-props': {},
|
||||
'x-visible': isSupportEditButton,
|
||||
// description: `原字段标题:${collectionField?.uiSchema?.title}`,
|
||||
},
|
||||
type: {
|
||||
@ -114,27 +117,25 @@ export const ActionDesigner = (props) => {
|
||||
} as ISchema
|
||||
}
|
||||
onSubmit={({ title, icon, type }) => {
|
||||
if (title) {
|
||||
fieldSchema.title = title;
|
||||
field.title = title;
|
||||
field.componentProps.icon = icon;
|
||||
field.componentProps.danger = type === 'danger';
|
||||
field.componentProps.type = type;
|
||||
fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {};
|
||||
fieldSchema['x-component-props'].icon = icon;
|
||||
fieldSchema['x-component-props'].danger = type === 'danger';
|
||||
fieldSchema['x-component-props'].type = type;
|
||||
dn.emit('patch', {
|
||||
schema: {
|
||||
['x-uid']: fieldSchema['x-uid'],
|
||||
title,
|
||||
'x-component-props': {
|
||||
...fieldSchema['x-component-props'],
|
||||
},
|
||||
fieldSchema.title = title;
|
||||
field.title = title;
|
||||
field.componentProps.icon = icon;
|
||||
field.componentProps.danger = type === 'danger';
|
||||
field.componentProps.type = type;
|
||||
fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {};
|
||||
fieldSchema['x-component-props'].icon = icon;
|
||||
fieldSchema['x-component-props'].danger = type === 'danger';
|
||||
fieldSchema['x-component-props'].type = type;
|
||||
dn.emit('patch', {
|
||||
schema: {
|
||||
['x-uid']: fieldSchema['x-uid'],
|
||||
title,
|
||||
'x-component-props': {
|
||||
...fieldSchema['x-component-props'],
|
||||
},
|
||||
});
|
||||
dn.refresh();
|
||||
}
|
||||
},
|
||||
});
|
||||
dn.refresh();
|
||||
}}
|
||||
/>
|
||||
{isLinkageAction && <SchemaSettings.LinkageRules collectionName={name} />}
|
||||
|
@ -12,6 +12,17 @@ import { useFieldNames } from './useFieldNames';
|
||||
import { getLabelFormatValue, useLabelUiSchema } from './util';
|
||||
|
||||
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 field = useField<ArrayField>();
|
||||
@ -28,11 +39,12 @@ const useTableSelectorProps = () => {
|
||||
},
|
||||
onRowSelectionChange(selectedRowKeys, selectedRows) {
|
||||
if (multiple) {
|
||||
const scopeRows = field.value || [];
|
||||
const scopeRows = flatData(field.value) || [];
|
||||
const allSelectedRows = rcSelectRows || [];
|
||||
const otherRows = differenceBy(allSelectedRows, scopeRows, rowKey || 'id');
|
||||
const unionSelectedRows = unionBy(otherRows, selectedRows, rowKey || 'id');
|
||||
const unionSelectedRowKeys = unionSelectedRows.map((item) => item[rowKey || 'id']);
|
||||
console.log(unionSelectedRows, unionSelectedRowKeys);
|
||||
setSelectedRows?.(unionSelectedRows);
|
||||
onRowSelectionChange?.(unionSelectedRowKeys, unionSelectedRows);
|
||||
} else {
|
||||
@ -92,7 +104,7 @@ export const InputRecordPicker: React.FC<any> = (props) => {
|
||||
setOptions(opts);
|
||||
setSelectedRows(opts);
|
||||
}
|
||||
}, [value,fieldNames?.label]);
|
||||
}, [value, fieldNames?.label]);
|
||||
|
||||
const getValue = () => {
|
||||
if (multiple == null) return null;
|
||||
|
@ -10,9 +10,15 @@ import { default as classNames, default as cls } from 'classnames';
|
||||
import React, { RefCallback, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DndContext, useDesignable } from '../..';
|
||||
import { RecordIndexProvider, RecordProvider, useSchemaInitializer } from '../../../';
|
||||
import {
|
||||
RecordIndexProvider,
|
||||
RecordProvider,
|
||||
useSchemaInitializer,
|
||||
useTableBlockContext,
|
||||
useTableSelectorContext
|
||||
} from '../../../';
|
||||
import { useACLFieldWhitelist } from '../../../acl/ACLProvider';
|
||||
import { isCollectionFieldComponent, isColumnComponent } from './utils';
|
||||
import { extractIndex, getIdsWithChildren, isCollectionFieldComponent, isColumnComponent } from './utils';
|
||||
|
||||
const useTableColumns = () => {
|
||||
const field = useField<ArrayField>();
|
||||
@ -106,7 +112,7 @@ const TableIndex = (props) => {
|
||||
const { index } = props;
|
||||
return (
|
||||
<div className={classNames('nb-table-index')} style={{ padding: '0 8px 0 16px' }}>
|
||||
{index + 1}
|
||||
{index}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -162,10 +168,16 @@ export const Table: any = observer((props: any) => {
|
||||
required,
|
||||
...others
|
||||
} = { ...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 paginationProps = usePaginationProps(pagination1, pagination2);
|
||||
const requiredValidator = field.required || required;
|
||||
|
||||
const { treeTable } = schema?.parent?.['x-decorator-props'] || {};
|
||||
const [expandedKeys, setExpandesKeys] = useState([]);
|
||||
const [allIncludesChildren, setAllIncludesChildren] = useState([]);
|
||||
useEffect(() => {
|
||||
field.setValidator((value) => {
|
||||
if (requiredValidator) {
|
||||
@ -174,6 +186,26 @@ export const Table: any = observer((props: any) => {
|
||||
return;
|
||||
});
|
||||
}, [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(() => {
|
||||
return {
|
||||
header: {
|
||||
@ -275,7 +307,10 @@ export const Table: any = observer((props: any) => {
|
||||
const current = props?.pagination?.current;
|
||||
const pageSize = props?.pagination?.pageSize || 20;
|
||||
if (current) {
|
||||
index = index + (current - 1) * pageSize;
|
||||
index = index + (current - 1) * pageSize + 1;
|
||||
}
|
||||
if (record.__index) {
|
||||
index = extractIndex(record.__index);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
@ -284,6 +319,7 @@ export const Table: any = observer((props: any) => {
|
||||
css`
|
||||
position: relative;
|
||||
display: flex;
|
||||
float: left;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
padding-right: 8px;
|
||||
@ -419,6 +455,13 @@ export const Table: any = observer((props: any) => {
|
||||
tableLayout={'auto'}
|
||||
scroll={scroll}
|
||||
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?.()}
|
||||
/>
|
||||
</SortableWrapper>
|
||||
|
@ -4,7 +4,7 @@ import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTableBlockContext } from '../../../block-provider';
|
||||
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 { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings';
|
||||
import { useSchemaTemplate } from '../../../schema-templates';
|
||||
@ -13,6 +13,7 @@ import { useFixedBlockDesignerSetting } from '../page';
|
||||
|
||||
export const TableBlockDesigner = () => {
|
||||
const { name, title, sortable } = useCollection();
|
||||
const { getCollectionField } = useCollectionManager();
|
||||
const field = useField();
|
||||
const fieldSchema = useFieldSchema();
|
||||
const dataSource = useCollectionFilterOptions(name);
|
||||
@ -36,12 +37,33 @@ export const TableBlockDesigner = () => {
|
||||
};
|
||||
});
|
||||
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();
|
||||
|
||||
return (
|
||||
<GeneralSchemaDesigner template={template} title={title || name}>
|
||||
<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 && (
|
||||
<SchemaSettings.SwitchItem
|
||||
title={t('Enable drag and drop sorting')}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { ArrayItems } from '@formily/antd';
|
||||
import { ISchema, useField, useFieldSchema } from '@formily/react';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTableSelectorContext } from '../../../block-provider';
|
||||
@ -8,7 +9,6 @@ import { useCollectionFilterOptions, useSortFields } from '../../../collection-m
|
||||
import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings';
|
||||
import { useSchemaTemplate } from '../../../schema-templates';
|
||||
import { useDesignable } from '../../hooks';
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
export const TableSelectorDesigner = () => {
|
||||
const { name, title } = useCollection();
|
||||
@ -33,6 +33,7 @@ export const TableSelectorDesigner = () => {
|
||||
};
|
||||
});
|
||||
const template = useSchemaTemplate();
|
||||
const collection = useCollection();
|
||||
const { dragSort } = field.decoratorProps;
|
||||
return (
|
||||
<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 && (
|
||||
<SchemaSettings.ModalItem
|
||||
title={t('Set default sorting rules')}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -9,8 +9,4 @@ group:
|
||||
|
||||
## TableBlock
|
||||
|
||||
<code src="./demos/demo1.tsx" />
|
||||
|
||||
## TableField
|
||||
|
||||
<code src="./demos/demo2.tsx" />
|
||||
<code src="./demos/demo3.tsx" />
|
||||
|
@ -7,3 +7,26 @@ export const isCollectionFieldComponent = (schema: ISchema) => {
|
||||
export const isColumnComponent = (schema: Schema) => {
|
||||
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;
|
||||
}
|
||||
|
@ -10,7 +10,7 @@ import {
|
||||
SchemaInitializerButtonProps,
|
||||
SchemaInitializerItemComponent,
|
||||
SchemaInitializerItemOptions,
|
||||
SchemaInitializerItemProps,
|
||||
SchemaInitializerItemProps
|
||||
} from './types';
|
||||
|
||||
const defaultWrap = (s: ISchema) => s;
|
||||
@ -47,59 +47,63 @@ SchemaInitializer.Button = observer((props: SchemaInitializerButtonProps) => {
|
||||
}
|
||||
};
|
||||
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);
|
||||
item.key = `${item.key || item.title}-${indexA}`;
|
||||
return (
|
||||
Component && (
|
||||
<SchemaInitializerItemContext.Provider
|
||||
key={item.key}
|
||||
value={{
|
||||
index: indexA,
|
||||
item,
|
||||
info: item,
|
||||
insert: insertSchema,
|
||||
}}
|
||||
>
|
||||
<Component
|
||||
{...item}
|
||||
item={{
|
||||
...item,
|
||||
title: compile(item.title),
|
||||
return items
|
||||
.filter((v) => {
|
||||
return v && (v?.visible ? v.visible() : true);
|
||||
})
|
||||
?.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);
|
||||
item.key = `${item.key || item.title}-${indexA}`;
|
||||
return (
|
||||
Component && (
|
||||
<SchemaInitializerItemContext.Provider
|
||||
key={item.key}
|
||||
value={{
|
||||
index: indexA,
|
||||
item,
|
||||
info: item,
|
||||
insert: insertSchema,
|
||||
}}
|
||||
insert={insertSchema}
|
||||
/>
|
||||
</SchemaInitializerItemContext.Provider>
|
||||
)
|
||||
);
|
||||
}
|
||||
if (item.type === 'itemGroup') {
|
||||
return (
|
||||
!!item.children?.length && (
|
||||
<Menu.ItemGroup key={item.key || `item-group-${indexA}`} title={compile(item.title)}>
|
||||
{renderItems(item.children)}
|
||||
</Menu.ItemGroup>
|
||||
)
|
||||
);
|
||||
}
|
||||
if (item.type === 'subMenu') {
|
||||
return (
|
||||
!!item.children?.length && (
|
||||
<Menu.SubMenu
|
||||
key={item.key || `item-group-${indexA}`}
|
||||
title={compile(item.title)}
|
||||
popupClassName={menuItemGroupCss}
|
||||
>
|
||||
{renderItems(item.children)}
|
||||
</Menu.SubMenu>
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
>
|
||||
<Component
|
||||
{...item}
|
||||
item={{
|
||||
...item,
|
||||
title: compile(item.title),
|
||||
}}
|
||||
insert={insertSchema}
|
||||
/>
|
||||
</SchemaInitializerItemContext.Provider>
|
||||
)
|
||||
);
|
||||
}
|
||||
if (item.type === 'itemGroup') {
|
||||
return (
|
||||
!!item.children?.length && (
|
||||
<Menu.ItemGroup key={item.key || `item-group-${indexA}`} title={compile(item.title)}>
|
||||
{renderItems(item.children)}
|
||||
</Menu.ItemGroup>
|
||||
)
|
||||
);
|
||||
}
|
||||
if (item.type === 'subMenu') {
|
||||
return (
|
||||
!!item.children?.length && (
|
||||
<Menu.SubMenu
|
||||
key={item.key || `item-group-${indexA}`}
|
||||
title={compile(item.title)}
|
||||
popupClassName={menuItemGroupCss}
|
||||
>
|
||||
{renderItems(item.children)}
|
||||
</Menu.SubMenu>
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
const menu = <Menu style={{ maxHeight: '60vh', overflowY: 'auto' }}>{renderItems(items)}</Menu>;
|
||||
if (!designable && props.designable !== true) {
|
||||
|
@ -4,6 +4,7 @@ import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SchemaInitializer, SchemaSettings } from '../..';
|
||||
import { useAPIClient } from '../../api-client';
|
||||
import { useCollection } from '../../collection-manager';
|
||||
import { createDesignable, useDesignable } from '../../schema-component';
|
||||
|
||||
export const Resizable = (props) => {
|
||||
@ -49,6 +50,8 @@ export const TableActionColumnInitializers = (props: any) => {
|
||||
const api = useAPIClient();
|
||||
const { refresh } = useDesignable();
|
||||
const { t } = useTranslation();
|
||||
const collection = useCollection();
|
||||
const { treeTable } = fieldSchema?.parent?.parent['x-decorator-props']||{};
|
||||
return (
|
||||
<SchemaInitializer.Button
|
||||
insertPosition={'beforeEnd'}
|
||||
@ -106,6 +109,17 @@ export const TableActionColumnInitializers = (props: any) => {
|
||||
'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',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { Schema } from '@formily/react';
|
||||
import { Schema, useFieldSchema } from '@formily/react';
|
||||
import { useCollection } from '../../';
|
||||
|
||||
// 表格操作配置
|
||||
export const TableActionInitializers = {
|
||||
@ -49,6 +50,20 @@ export const TableActionInitializers = {
|
||||
'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;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
@ -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} />;
|
||||
};
|
@ -1,49 +1,49 @@
|
||||
import React from "react";
|
||||
import React from 'react';
|
||||
import { FormOutlined } from '@ant-design/icons';
|
||||
|
||||
import { useBlockAssociationContext } from "../../block-provider";
|
||||
import { useCollection } from "../../collection-manager";
|
||||
import { useSchemaTemplateManager } from "../../schema-templates";
|
||||
import { SchemaInitializer } from "../SchemaInitializer";
|
||||
import { createFormBlockSchema, useRecordCollectionDataSourceItems } from "../utils";
|
||||
import { useBlockAssociationContext } from '../../block-provider';
|
||||
import { useCollection } from '../../collection-manager';
|
||||
import { useSchemaTemplateManager } from '../../schema-templates';
|
||||
import { SchemaInitializer } from '../SchemaInitializer';
|
||||
import { createFormBlockSchema, useRecordCollectionDataSourceItems } from '../utils';
|
||||
|
||||
export const CreateFormBlockInitializer = (props) => {
|
||||
const { onCreateBlockSchema, componentType, createBlockSchema, insert, ...others } = props;
|
||||
const { getTemplateSchemaByMode } = useSchemaTemplateManager();
|
||||
const association = useBlockAssociationContext();
|
||||
const collection = useCollection();
|
||||
return (
|
||||
<SchemaInitializer.Item
|
||||
icon={<FormOutlined />}
|
||||
{...others}
|
||||
onClick={async ({ item }) => {
|
||||
if (item.template) {
|
||||
const s = await getTemplateSchemaByMode(item);
|
||||
if (item.template.componentName === 'FormItem') {
|
||||
const blockSchema = createFormBlockSchema({
|
||||
actionInitializers: 'CreateFormActionInitializers',
|
||||
association,
|
||||
collection: collection.name,
|
||||
template: s,
|
||||
});
|
||||
if (item.mode === 'reference') {
|
||||
blockSchema['x-template-key'] = item.template.key;
|
||||
}
|
||||
insert(blockSchema);
|
||||
} else {
|
||||
insert(s);
|
||||
const { onCreateBlockSchema, componentType, createBlockSchema, insert, ...others } = props;
|
||||
const { getTemplateSchemaByMode } = useSchemaTemplateManager();
|
||||
const association = useBlockAssociationContext();
|
||||
const collection = useCollection();
|
||||
return (
|
||||
<SchemaInitializer.Item
|
||||
icon={<FormOutlined />}
|
||||
{...others}
|
||||
onClick={async ({ item }) => {
|
||||
if (item.template) {
|
||||
const s = await getTemplateSchemaByMode(item);
|
||||
if (item.template.componentName === 'FormItem') {
|
||||
const blockSchema = createFormBlockSchema({
|
||||
actionInitializers: 'CreateFormActionInitializers',
|
||||
association,
|
||||
collection: collection.name,
|
||||
template: s,
|
||||
});
|
||||
if (item.mode === 'reference') {
|
||||
blockSchema['x-template-key'] = item.template.key;
|
||||
}
|
||||
insert(blockSchema);
|
||||
} else {
|
||||
insert(
|
||||
createFormBlockSchema({
|
||||
actionInitializers: 'CreateFormActionInitializers',
|
||||
association,
|
||||
collection: collection.name,
|
||||
}),
|
||||
);
|
||||
insert(s);
|
||||
}
|
||||
}}
|
||||
items={useRecordCollectionDataSourceItems('FormItem')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
} else {
|
||||
insert(
|
||||
createFormBlockSchema({
|
||||
actionInitializers: 'CreateFormActionInitializers',
|
||||
association,
|
||||
collection: collection.name,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
items={useRecordCollectionDataSourceItems('FormItem')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -1,18 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
import { ActionInitializer } from "./ActionInitializer";
|
||||
import React from 'react';
|
||||
import { useFieldSchema } from '@formily/react';
|
||||
import { ActionInitializer } from './ActionInitializer';
|
||||
|
||||
export const CreateSubmitActionInitializer = (props) => {
|
||||
const schema = {
|
||||
title: '{{ t("Submit") }}',
|
||||
'x-action': 'submit',
|
||||
'x-component': 'Action',
|
||||
'x-designer': 'Action.Designer',
|
||||
'x-component-props': {
|
||||
type: 'primary',
|
||||
htmlType: 'submit',
|
||||
useProps: '{{ useCreateActionProps }}',
|
||||
},
|
||||
};
|
||||
return <ActionInitializer {...props} schema={schema} />;
|
||||
const schema = {
|
||||
title: '{{ t("Submit") }}',
|
||||
'x-action': 'submit',
|
||||
'x-component': 'Action',
|
||||
'x-designer': 'Action.Designer',
|
||||
'x-component-props': {
|
||||
type: 'primary',
|
||||
htmlType: 'submit',
|
||||
useProps: '{{ useCreateActionProps }}',
|
||||
},
|
||||
};
|
||||
return <ActionInitializer {...props} schema={schema} />;
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -42,3 +42,10 @@ export * from './TableSelectorInitializer';
|
||||
export * from './UpdateActionInitializer';
|
||||
export * from './UpdateSubmitActionInitializer';
|
||||
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';
|
||||
|
@ -76,7 +76,9 @@ export const useTableColumnInitializerFields = () => {
|
||||
const { name, currentFields = [] } = useCollection();
|
||||
const { getInterface } = useCollectionManager();
|
||||
return currentFields
|
||||
.filter((field) => field?.interface && field?.interface !== 'subTable' && !field?.isForeignKey)
|
||||
.filter(
|
||||
(field) => field?.interface && field?.interface !== 'subTable' && !field?.isForeignKey && !field?.treeChildren,
|
||||
)
|
||||
.map((field) => {
|
||||
const interfaceConfig = getInterface(field.interface);
|
||||
const schema = {
|
||||
@ -113,7 +115,9 @@ export const useAssociatedTableColumnInitializerFields = () => {
|
||||
const subFields = getCollectionFields(field.target);
|
||||
const items = subFields
|
||||
// ?.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) => {
|
||||
const interfaceConfig = getInterface(subField.interface);
|
||||
const schema = {
|
||||
@ -198,7 +202,7 @@ export const useFormItemInitializerFields = (options?: any) => {
|
||||
const { snapshot } = useActionContext();
|
||||
|
||||
return currentFields
|
||||
?.filter((field) => field?.interface && !field?.isForeignKey)
|
||||
?.filter((field) => field?.interface && !field?.isForeignKey && !field?.treeChildren)
|
||||
?.map((field) => {
|
||||
const interfaceConfig = getInterface(field.interface);
|
||||
const schema = {
|
||||
@ -246,7 +250,9 @@ export const useAssociatedFormItemInitializerFields = (options?: any) => {
|
||||
?.map((field) => {
|
||||
const subFields = getCollectionFields(field.target);
|
||||
const items = subFields
|
||||
?.filter((subField) => subField?.interface && !['subTable'].includes(subField?.interface))
|
||||
?.filter(
|
||||
(subField) => subField?.interface && !['subTable'].includes(subField?.interface) && !subField.treeChildren,
|
||||
)
|
||||
?.map((subField) => {
|
||||
const interfaceConfig = getInterface(subField.interface);
|
||||
const schema = {
|
||||
|
217
packages/core/database/src/__tests__/tree.test.ts
Normal file
217
packages/core/database/src/__tests__/tree.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
@ -7,10 +7,10 @@ import {
|
||||
QueryInterfaceDropTableOptions,
|
||||
SyncOptions,
|
||||
Transactionable,
|
||||
Utils,
|
||||
Utils
|
||||
} from 'sequelize';
|
||||
import { Database } from './database';
|
||||
import { Field, FieldOptions } from './fields';
|
||||
import { BelongsToField, Field, FieldOptions, HasManyField } from './fields';
|
||||
import { Model } from './model';
|
||||
import { Repository } from './repository';
|
||||
import { checkIdentifier, md5, snakeCase } from './utils';
|
||||
@ -48,6 +48,7 @@ export interface CollectionOptions extends Omit<ModelOptions, 'name' | 'hooks'>
|
||||
*/
|
||||
magicAttribute?: string;
|
||||
|
||||
tree?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@ -82,6 +83,22 @@ export class Collection<
|
||||
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) {
|
||||
super();
|
||||
this.context = context;
|
||||
@ -243,6 +260,7 @@ export class Collection<
|
||||
this.checkFieldType(name, options);
|
||||
|
||||
const { database } = this.context;
|
||||
this.emit('field.beforeAdd', name, options, { collection: this });
|
||||
|
||||
const field = database.buildField(
|
||||
{ name, ...options },
|
||||
|
@ -15,7 +15,7 @@ import {
|
||||
Sequelize,
|
||||
SyncOptions,
|
||||
Transactionable,
|
||||
Utils,
|
||||
Utils
|
||||
} from 'sequelize';
|
||||
import { SequelizeStorage, Umzug } from 'umzug';
|
||||
import { Collection, CollectionOptions, RepositoryType } from './collection';
|
||||
@ -58,11 +58,12 @@ import {
|
||||
SyncListener,
|
||||
UpdateListener,
|
||||
UpdateWithAssociationsListener,
|
||||
ValidateListener,
|
||||
ValidateListener
|
||||
} from './types';
|
||||
import { patchSequelizeQueryInterface, snakeCase } from './utils';
|
||||
|
||||
import DatabaseUtils from './database-utils';
|
||||
import { registerBuiltInListeners } from './listeners';
|
||||
import { BaseValueParser, registerFieldValueParsers } from './value-parsers';
|
||||
import buildQueryInterface from './query-interface/query-interface-builder';
|
||||
import QueryInterface from './query-interface/query-interface';
|
||||
@ -363,6 +364,8 @@ export class Database extends EventEmitter implements AsyncEmitter {
|
||||
options.schema = this.options.schema;
|
||||
}
|
||||
});
|
||||
|
||||
registerBuiltInListeners(this);
|
||||
}
|
||||
|
||||
addMigration(item: MigrationItem) {
|
||||
|
60
packages/core/database/src/listeners/adjacency-list.ts
Normal file
60
packages/core/database/src/listeners/adjacency-list.ts
Normal 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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
7
packages/core/database/src/listeners/index.ts
Normal file
7
packages/core/database/src/listeners/index.ts
Normal 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);
|
||||
};
|
@ -11,7 +11,7 @@ import {
|
||||
Op,
|
||||
Transactionable,
|
||||
UpdateOptions as SequelizeUpdateOptions,
|
||||
WhereOperators,
|
||||
WhereOperators
|
||||
} from 'sequelize';
|
||||
import { Collection } from './collection';
|
||||
import { Database } from './database';
|
||||
@ -106,6 +106,7 @@ export interface CommonFindOptions extends Transactionable {
|
||||
except?: Except;
|
||||
sort?: Sort;
|
||||
context?: any;
|
||||
tree?: boolean;
|
||||
}
|
||||
|
||||
export type FindOneOptions = Omit<FindOptions, 'limit'>;
|
||||
|
@ -283,4 +283,128 @@ describe('list association action with acl', () => {
|
||||
expect(data['meta']['allowedActions'].view).toContain(1);
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
@ -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 = [];
|
||||
|
||||
@ -823,4 +831,4 @@ export class PluginACL extends Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
export default PluginACL;
|
||||
export default PluginACL;
|
@ -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
|
||||
if (!context) {
|
||||
return;
|
||||
@ -174,4 +174,12 @@ export function afterCreateForForeignKeyField(db: Database) {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return async (model, options) => {
|
||||
try {
|
||||
await hook(model, options);
|
||||
} catch (error) {
|
||||
|
||||
}
|
||||
};
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user