mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-29 02:03:53 +08:00
Merge branch 'next' into develop
This commit is contained in:
+5
-11
@@ -428,15 +428,9 @@ describe('flowSurfaces approval blueprint API contract', () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(taskCardManagerCatalog.node.configureOptions.fieldComponent.enum).toEqual([
|
||||
'DisplayTextFieldModel',
|
||||
'DisplaySubItemFieldModel',
|
||||
]);
|
||||
expect(taskCardSkillsCatalog.node.configureOptions.fieldComponent.enum).toEqual([
|
||||
'DisplayTextFieldModel',
|
||||
'DisplaySubListFieldModel',
|
||||
'DisplaySubTableFieldModel',
|
||||
]);
|
||||
expect(taskCardManagerCatalog.node.configureOptions.fieldComponent).toBeUndefined();
|
||||
expect(taskCardManagerCatalog.node.configureOptions.fieldType.enum).toEqual(['text', 'subDetails']);
|
||||
expect(taskCardSkillsCatalog.node.configureOptions.fieldType.enum).toEqual(['text', 'subDetailsList', 'subTable']);
|
||||
|
||||
expect(
|
||||
(
|
||||
@@ -446,7 +440,7 @@ describe('flowSurfaces approval blueprint API contract', () => {
|
||||
uid: taskCardManagerItem.uid,
|
||||
},
|
||||
changes: {
|
||||
fieldComponent: 'DisplaySubItemFieldModel',
|
||||
fieldType: 'subDetails',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -460,7 +454,7 @@ describe('flowSurfaces approval blueprint API contract', () => {
|
||||
uid: taskCardSkillsItem.uid,
|
||||
},
|
||||
changes: {
|
||||
fieldComponent: 'DisplaySubListFieldModel',
|
||||
fieldType: 'subDetailsList',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
+29
-30
@@ -567,7 +567,8 @@ describe('flowSurfaces approval API contract', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(configureRes.status).toBe(200);
|
||||
expect(configureRes.status).toBe(400);
|
||||
expect(readErrorMessage(configureRes)).toContain('fieldComponent');
|
||||
|
||||
const applyFieldWrapperReadback = await getSurface(rootAgent, {
|
||||
uid: applyField.wrapperUid,
|
||||
@@ -576,7 +577,7 @@ describe('flowSurfaces approval API contract', () => {
|
||||
uid: applyField.fieldUid,
|
||||
});
|
||||
|
||||
expect(applyFieldWrapperReadback.tree.stepParams?.editItemSettings?.model?.use).toBe('InputFieldModel');
|
||||
expect(applyFieldWrapperReadback.tree.stepParams?.editItemSettings?.model?.use).toBeUndefined();
|
||||
expect(applyFieldInnerReadback.tree).toMatchObject({
|
||||
use: 'PatternFormFieldModel',
|
||||
stepParams: {
|
||||
@@ -919,7 +920,7 @@ describe('flowSurfaces approval API contract', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should switch approval relation field components and expose dynamic fieldComponent enums through catalog', async () => {
|
||||
it('should switch approval relation fieldTypes and expose dynamic fieldType enums through catalog', async () => {
|
||||
const triggerSurface = await createApprovalSurface(flowRepo, {
|
||||
pageUse: 'TriggerChildPageModel',
|
||||
tabUse: 'TriggerChildPageTabModel',
|
||||
@@ -1038,25 +1039,23 @@ describe('flowSurfaces approval API contract', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(applyManagerCatalog.node.configureOptions.fieldComponent.enum).toEqual([
|
||||
'RecordSelectFieldModel',
|
||||
'RecordPickerFieldModel',
|
||||
'SubFormFieldModel',
|
||||
expect(applyManagerCatalog.node.configureOptions.fieldComponent).toBeUndefined();
|
||||
expect(applyManagerCatalog.node.configureOptions.fieldType.enum).toEqual(['select', 'picker', 'subForm']);
|
||||
expect(applySkillsCatalog.node.configureOptions.fieldType.enum).toEqual([
|
||||
'select',
|
||||
'picker',
|
||||
'subFormList',
|
||||
'subTable',
|
||||
'popupSubTable',
|
||||
]);
|
||||
expect(applySkillsCatalog.node.configureOptions.fieldComponent.enum).toEqual([
|
||||
'RecordSelectFieldModel',
|
||||
'RecordPickerFieldModel',
|
||||
'SubFormListFieldModel',
|
||||
'PatternSubTableFieldModel',
|
||||
]);
|
||||
expect(approvalManagerCatalog.node.configureOptions.fieldComponent.enum).toEqual([
|
||||
'DisplayTextFieldModel',
|
||||
'DisplaySubItemFieldModel',
|
||||
]);
|
||||
expect(approvalSkillsCatalog.node.configureOptions.fieldComponent.enum).toEqual([
|
||||
'DisplayTextFieldModel',
|
||||
'DisplaySubListFieldModel',
|
||||
'DisplaySubTableFieldModel',
|
||||
expect(approvalManagerCatalog.node.configureOptions.fieldType.enum).toEqual(['text', 'subDetails']);
|
||||
expect(approvalSkillsCatalog.node.configureOptions.fieldType.enum).toEqual(['text', 'subDetailsList', 'subTable']);
|
||||
expect(applySkillsCatalog.node.relation?.fieldTypes).toEqual([
|
||||
'select',
|
||||
'picker',
|
||||
'subFormList',
|
||||
'subTable',
|
||||
'popupSubTable',
|
||||
]);
|
||||
|
||||
expect(
|
||||
@@ -1067,7 +1066,7 @@ describe('flowSurfaces approval API contract', () => {
|
||||
uid: applyManagerField.wrapperUid,
|
||||
},
|
||||
changes: {
|
||||
fieldComponent: 'RecordPickerFieldModel',
|
||||
fieldType: 'picker',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1081,7 +1080,7 @@ describe('flowSurfaces approval API contract', () => {
|
||||
uid: applyManagerField.wrapperUid,
|
||||
},
|
||||
changes: {
|
||||
fieldComponent: 'SubFormFieldModel',
|
||||
fieldType: 'subForm',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1095,7 +1094,7 @@ describe('flowSurfaces approval API contract', () => {
|
||||
uid: applySkillsField.wrapperUid,
|
||||
},
|
||||
changes: {
|
||||
fieldComponent: 'SubFormListFieldModel',
|
||||
fieldType: 'subFormList',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1109,7 +1108,7 @@ describe('flowSurfaces approval API contract', () => {
|
||||
uid: applySkillsField.wrapperUid,
|
||||
},
|
||||
changes: {
|
||||
fieldComponent: 'PatternSubTableFieldModel',
|
||||
fieldType: 'subTable',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1123,7 +1122,7 @@ describe('flowSurfaces approval API contract', () => {
|
||||
uid: approvalManagerField.wrapperUid,
|
||||
},
|
||||
changes: {
|
||||
fieldComponent: 'DisplaySubItemFieldModel',
|
||||
fieldType: 'subDetails',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1137,7 +1136,7 @@ describe('flowSurfaces approval API contract', () => {
|
||||
uid: approvalSkillsField.wrapperUid,
|
||||
},
|
||||
changes: {
|
||||
fieldComponent: 'DisplaySubListFieldModel',
|
||||
fieldType: 'subDetailsList',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1151,7 +1150,7 @@ describe('flowSurfaces approval API contract', () => {
|
||||
uid: approvalSkillsField.wrapperUid,
|
||||
},
|
||||
changes: {
|
||||
fieldComponent: 'DisplaySubTableFieldModel',
|
||||
fieldType: 'subTable',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1192,12 +1191,12 @@ describe('flowSurfaces approval API contract', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(applySkillsWrapperReadback.tree.stepParams?.editItemSettings?.model?.use).toBe('PatternSubTableFieldModel');
|
||||
expect(applySkillsWrapperReadback.tree.stepParams?.editItemSettings?.model?.use).toBe('SubTableFieldModel');
|
||||
expect(applySkillsInnerReadback.tree).toMatchObject({
|
||||
use: 'PatternFormFieldModel',
|
||||
stepParams: {
|
||||
fieldBinding: {
|
||||
use: 'PatternSubTableFieldModel',
|
||||
use: 'SubTableFieldModel',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+9
-2
@@ -16,6 +16,7 @@ import {
|
||||
resolveSupportedBlockCatalogItem,
|
||||
} from '../flow-surfaces/catalog';
|
||||
import { getConfigureOptionsForUse } from '../flow-surfaces/configure-options';
|
||||
import { getPublicFieldTypeForUse } from '../flow-surfaces/field-type-resolver';
|
||||
import { buildPlanKeyKind } from '../flow-surfaces/planning/key-kind';
|
||||
import { getReactionKindsForUse } from '../flow-surfaces/reaction/registry';
|
||||
|
||||
@@ -198,7 +199,7 @@ describe('flowSurfaces approval surface', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should expose approval relation fieldComponent sets that match current frontend wrappers', () => {
|
||||
it('should expose approval relation fieldType sets that match current frontend wrappers', () => {
|
||||
const singleAssociationField = {
|
||||
interface: 'm2o',
|
||||
targetCollection: {
|
||||
@@ -223,6 +224,11 @@ describe('flowSurfaces approval surface', () => {
|
||||
getSupportedFieldComponentUseSet({ containerUse: 'PatternFormItemModel', field: singleAssociationField }) || [],
|
||||
),
|
||||
).toEqual(['RecordSelectFieldModel', 'RecordPickerFieldModel', 'SubFormFieldModel']);
|
||||
expect(
|
||||
Array.from(
|
||||
getSupportedFieldComponentUseSet({ containerUse: 'PatternFormItemModel', field: multiAssociationField }) || [],
|
||||
).map((use) => getPublicFieldTypeForUse(use)),
|
||||
).toEqual(['select', 'picker', 'subFormList', 'subTable', 'popupSubTable']);
|
||||
expect(
|
||||
Array.from(
|
||||
getSupportedFieldComponentUseSet({ containerUse: 'PatternFormItemModel', field: multiAssociationField }) || [],
|
||||
@@ -231,7 +237,8 @@ describe('flowSurfaces approval surface', () => {
|
||||
'RecordSelectFieldModel',
|
||||
'RecordPickerFieldModel',
|
||||
'SubFormListFieldModel',
|
||||
'PatternSubTableFieldModel',
|
||||
'SubTableFieldModel',
|
||||
'PopupSubTableFieldModel',
|
||||
]);
|
||||
expect(
|
||||
Array.from(
|
||||
|
||||
+1
-1
@@ -146,7 +146,7 @@ describe('flowSurfaces association contract', () => {
|
||||
}),
|
||||
);
|
||||
expect(rolesWrapperCatalog.node.configureOptions).toMatchObject({
|
||||
fieldComponent: {
|
||||
fieldType: {
|
||||
type: 'string',
|
||||
},
|
||||
});
|
||||
|
||||
+197
@@ -450,6 +450,157 @@ describe('flowSurfaces applyBlueprint contract', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should create flow-model tree blocks through applyBlueprint and reject unsupported tree containers', async () => {
|
||||
const executeRes = await rootAgent.resource('flowSurfaces').applyBlueprint({
|
||||
values: {
|
||||
mode: 'create',
|
||||
navigation: {
|
||||
item: {
|
||||
title: 'Tree blueprint',
|
||||
},
|
||||
},
|
||||
page: {
|
||||
title: 'Tree blueprint',
|
||||
},
|
||||
tabs: [
|
||||
{
|
||||
title: 'Tree',
|
||||
blocks: [
|
||||
{
|
||||
key: 'categoryTree',
|
||||
type: 'tree',
|
||||
resource: {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'categories',
|
||||
},
|
||||
settings: {
|
||||
searchable: false,
|
||||
defaultExpandAll: true,
|
||||
includeDescendants: true,
|
||||
pageSize: 200,
|
||||
titleField: 'title',
|
||||
},
|
||||
},
|
||||
],
|
||||
layout: {
|
||||
rows: [[{ key: 'categoryTree', span: 8 }]],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(executeRes.status, readErrorMessage(executeRes)).toBe(200);
|
||||
const data = getData(executeRes);
|
||||
const treeBlock = collectDescendantNodes(data.surface.tree, (item) => item?.use === 'TreeBlockModel')[0];
|
||||
expect(treeBlock).toMatchObject({
|
||||
use: 'TreeBlockModel',
|
||||
props: {
|
||||
searchable: false,
|
||||
defaultExpandAll: true,
|
||||
includeDescendants: true,
|
||||
pageSize: 200,
|
||||
fieldNames: {
|
||||
title: 'title',
|
||||
},
|
||||
},
|
||||
stepParams: {
|
||||
resourceSettings: {
|
||||
init: {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'categories',
|
||||
},
|
||||
},
|
||||
treeSettings: {
|
||||
searchable: {
|
||||
searchable: false,
|
||||
},
|
||||
defaultExpandAll: {
|
||||
defaultExpandAll: true,
|
||||
},
|
||||
includeDescendants: {
|
||||
includeDescendants: true,
|
||||
},
|
||||
pageSize: {
|
||||
pageSize: 200,
|
||||
},
|
||||
titleField: {
|
||||
titleField: 'title',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(treeBlock.subModels).toBeUndefined();
|
||||
|
||||
const invalidCases = [
|
||||
{
|
||||
key: 'fields',
|
||||
payload: {
|
||||
fields: ['title'],
|
||||
},
|
||||
message: 'fields is not supported on tree blocks',
|
||||
},
|
||||
{
|
||||
key: 'fieldGroups',
|
||||
payload: {
|
||||
fieldGroups: [
|
||||
{
|
||||
title: 'Tree fields',
|
||||
fields: ['title'],
|
||||
},
|
||||
],
|
||||
},
|
||||
message: 'fieldGroups is not supported on tree blocks',
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
payload: {
|
||||
actions: ['refresh'],
|
||||
},
|
||||
message: 'actions is not supported on tree blocks',
|
||||
},
|
||||
{
|
||||
key: 'recordActions',
|
||||
payload: {
|
||||
recordActions: ['view'],
|
||||
},
|
||||
message: 'recordActions is not supported on tree blocks',
|
||||
},
|
||||
];
|
||||
|
||||
for (const item of invalidCases) {
|
||||
const invalidRes = await rootAgent.resource('flowSurfaces').applyBlueprint({
|
||||
values: {
|
||||
mode: 'create',
|
||||
navigation: {
|
||||
item: {
|
||||
title: `Invalid tree blueprint ${item.key}`,
|
||||
},
|
||||
},
|
||||
page: {
|
||||
title: `Invalid tree blueprint ${item.key}`,
|
||||
},
|
||||
tabs: [
|
||||
{
|
||||
title: 'Tree',
|
||||
blocks: [
|
||||
{
|
||||
key: 'categoryTree',
|
||||
type: 'tree',
|
||||
collection: 'categories',
|
||||
...item.payload,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(invalidRes.status).toBe(400);
|
||||
expect(readErrorMessage(invalidRes)).toContain(item.message);
|
||||
}
|
||||
});
|
||||
|
||||
it('should apply block-level defaultFilter in applyBlueprint data blocks and prefer explicit action settings', async () => {
|
||||
const blockDefaultFilter = {
|
||||
logic: '$and',
|
||||
@@ -4299,6 +4450,52 @@ describe('flowSurfaces applyBlueprint contract', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should apply relation fieldType on blueprint field objects without creating standalone table blocks', async () => {
|
||||
const executeRes = await rootAgent.resource('flowSurfaces').applyBlueprint({
|
||||
values: {
|
||||
version: '1',
|
||||
mode: 'create',
|
||||
page: {
|
||||
title: 'Blueprint relation fieldType',
|
||||
},
|
||||
tabs: [
|
||||
{
|
||||
key: 'overview',
|
||||
title: 'Overview',
|
||||
blocks: [
|
||||
{
|
||||
key: 'userForm',
|
||||
type: 'createForm',
|
||||
collection: 'users',
|
||||
fields: [
|
||||
{
|
||||
key: 'rolesField',
|
||||
field: 'roles',
|
||||
fieldType: 'popupSubTable',
|
||||
fields: ['title', 'name'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(executeRes.status).toBe(200);
|
||||
const data = getData(executeRes);
|
||||
const formBlock = _.castArray(data.surface?.tree?.subModels?.tabs || [])[0]?.subModels?.grid?.subModels?.items?.[0];
|
||||
const formItems = _.castArray(formBlock?.subModels?.grid?.subModels?.items || []);
|
||||
expect(formItems).toHaveLength(1);
|
||||
expect(formItems[0]?.use).toBe('FormItemModel');
|
||||
expect(formItems[0]?.subModels?.field?.use).toBe('PopupSubTableFieldModel');
|
||||
expect(
|
||||
_.castArray(formItems[0]?.subModels?.field?.subModels?.subTableColumns || [])
|
||||
.filter((item: any) => item?.use === 'TableColumnModel')
|
||||
.map((item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath),
|
||||
).toEqual(['title', 'name']);
|
||||
});
|
||||
|
||||
it('should reject fieldsLayout on applyBlueprint blocks that do not own a field grid', async () => {
|
||||
const res = await rootAgent.resource('flowSurfaces').applyBlueprint({
|
||||
values: {
|
||||
|
||||
+75
-1
@@ -9,7 +9,12 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildApprovalActionDefaults } from '../flow-surfaces/approval/builder';
|
||||
import { buildActionTree, buildPopupPageTree, buildStandaloneFieldNode } from '../flow-surfaces/builder';
|
||||
import {
|
||||
buildActionTree,
|
||||
buildFieldTree,
|
||||
buildPopupPageTree,
|
||||
buildStandaloneFieldNode,
|
||||
} from '../flow-surfaces/builder';
|
||||
|
||||
describe('flowSurfaces builder translation defaults', () => {
|
||||
it('should persist translatable core default strings', () => {
|
||||
@@ -91,3 +96,72 @@ describe('flowSurfaces builder translation defaults', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('flowSurfaces builder action style defaults', () => {
|
||||
it('should align record action button defaults with their container initializers', () => {
|
||||
const tableEditAction = buildActionTree({
|
||||
use: 'EditActionModel',
|
||||
containerUse: 'TableActionsColumnModel',
|
||||
});
|
||||
const listEditAction = buildActionTree({
|
||||
use: 'EditActionModel',
|
||||
containerUse: 'ListItemModel',
|
||||
});
|
||||
const gridCardEditAction = buildActionTree({
|
||||
use: 'EditActionModel',
|
||||
containerUse: 'GridCardItemModel',
|
||||
});
|
||||
const detailsEditAction = buildActionTree({
|
||||
use: 'EditActionModel',
|
||||
containerUse: 'DetailsBlockModel',
|
||||
});
|
||||
|
||||
expect(tableEditAction.stepParams?.buttonSettings?.general).toMatchObject({
|
||||
type: 'link',
|
||||
icon: null,
|
||||
});
|
||||
expect(listEditAction.stepParams?.buttonSettings?.general).toMatchObject({
|
||||
type: 'link',
|
||||
icon: null,
|
||||
});
|
||||
expect(gridCardEditAction.stepParams?.buttonSettings?.general).toMatchObject({
|
||||
type: 'link',
|
||||
icon: null,
|
||||
});
|
||||
expect(detailsEditAction.stepParams?.buttonSettings?.general).toMatchObject({
|
||||
type: 'default',
|
||||
icon: 'EditOutlined',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('flowSurfaces builder relation field defaults', () => {
|
||||
it('should create PopupSubTableFieldModel with default actions column and edit/remove actions', () => {
|
||||
const fieldTree = buildFieldTree({
|
||||
wrapperUse: 'FormItemModel',
|
||||
fieldUse: 'PopupSubTableFieldModel',
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'users',
|
||||
fieldPath: 'roles',
|
||||
});
|
||||
|
||||
expect(fieldTree.model).toMatchObject({
|
||||
use: 'FormItemModel',
|
||||
subModels: {
|
||||
field: {
|
||||
use: 'PopupSubTableFieldModel',
|
||||
subModels: {
|
||||
subTableColumns: [
|
||||
{
|
||||
use: 'PopupSubTableActionsColumnModel',
|
||||
subModels: {
|
||||
actions: [{ use: 'PopupSubTableEditActionModel' }, { use: 'PopupSubTableRemoveActionModel' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+766
-1
@@ -231,6 +231,7 @@ describe('flowSurfaces catalog + compose contract', () => {
|
||||
);
|
||||
expect(pageCatalog.blocks.find((item: any) => item.use === 'ListBlockModel')).toBeUndefined();
|
||||
expect(pageCatalog.blocks.find((item: any) => item.use === 'GridCardBlockModel')).toBeUndefined();
|
||||
expect(pageCatalog.blocks.find((item: any) => item.use === 'TreeBlockModel')).toBeUndefined();
|
||||
expect(pageCatalog.blocks.find((item: any) => item.use === 'MarkdownBlockModel')).toBeUndefined();
|
||||
expect(pageCatalog.blocks.find((item: any) => item.use === 'IframeBlockModel')).toBeUndefined();
|
||||
expect(pageCatalog.blocks.find((item: any) => item.use === 'MapBlockModel')).toBeUndefined();
|
||||
@@ -283,6 +284,52 @@ describe('flowSurfaces catalog + compose contract', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should expose tree block only when block-tree is enabled', async () => {
|
||||
const page = await createPage(rootAgent, {
|
||||
title: 'Tree catalog page',
|
||||
tabTitle: 'Tree catalog tab',
|
||||
});
|
||||
|
||||
const fullCatalog = getData(
|
||||
await rootAgent.resource('flowSurfaces').catalog({
|
||||
values: {
|
||||
target: {
|
||||
uid: page.tabSchemaUid,
|
||||
},
|
||||
sections: ['blocks'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const treeItem = fullCatalog.blocks.find((item: any) => item.key === 'tree');
|
||||
expect(treeItem).toMatchObject({
|
||||
use: 'TreeBlockModel',
|
||||
requiredInitParams: ['dataSourceKey', 'collectionName'],
|
||||
createSupported: true,
|
||||
});
|
||||
|
||||
await syncFlowSurfacesEnabledPlugins(app, FLOW_SURFACES_MINIMAL_TEST_PLUGINS);
|
||||
try {
|
||||
const minimalRootAgent: any = await loginFlowSurfacesRootAgent(app);
|
||||
const minimalPage = await createPage(minimalRootAgent, {
|
||||
title: 'Minimal tree catalog page',
|
||||
tabTitle: 'Minimal tree catalog tab',
|
||||
});
|
||||
const minimalCatalog = getData(
|
||||
await minimalRootAgent.resource('flowSurfaces').catalog({
|
||||
values: {
|
||||
target: {
|
||||
uid: minimalPage.tabSchemaUid,
|
||||
},
|
||||
sections: ['blocks'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(minimalCatalog.blocks.find((item: any) => item.key === 'tree')).toBeUndefined();
|
||||
} finally {
|
||||
await syncFlowSurfacesEnabledPlugins(app, FLOW_SURFACES_TEST_PLUGINS);
|
||||
}
|
||||
});
|
||||
|
||||
it('should expose public catalog action keys for representative block targets', async () => {
|
||||
const page = await createPage(rootAgent, {
|
||||
title: 'Catalog contract page',
|
||||
@@ -338,6 +385,16 @@ describe('flowSurfaces catalog + compose contract', () => {
|
||||
collectionName: 'calendar_events',
|
||||
},
|
||||
});
|
||||
const tree = await addBlockData(rootAgent, {
|
||||
target: {
|
||||
uid: page.tabSchemaUid,
|
||||
},
|
||||
type: 'tree',
|
||||
resourceInit: {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'categories',
|
||||
},
|
||||
});
|
||||
|
||||
const tableCatalog = getData(
|
||||
await rootAgent.resource('flowSurfaces').catalog({
|
||||
@@ -509,6 +566,37 @@ describe('flowSurfaces catalog + compose contract', () => {
|
||||
calendarCatalog.actions.find((item: any) => item.key === 'turnPages')?.configureOptions?.title,
|
||||
).toBeUndefined();
|
||||
|
||||
const treeCatalog = getData(
|
||||
await rootAgent.resource('flowSurfaces').catalog({
|
||||
values: {
|
||||
target: {
|
||||
uid: tree.uid,
|
||||
},
|
||||
expand: ['item.configureOptions'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(treeCatalog.actions || []).toEqual([]);
|
||||
expect(treeCatalog.recordActions || []).toEqual([]);
|
||||
expect(treeCatalog.fields || []).toEqual([]);
|
||||
expect(treeCatalog.node.configureOptions).toMatchObject({
|
||||
searchable: {
|
||||
type: 'boolean',
|
||||
},
|
||||
defaultExpandAll: {
|
||||
type: 'boolean',
|
||||
},
|
||||
includeDescendants: {
|
||||
type: 'boolean',
|
||||
},
|
||||
titleField: {
|
||||
type: 'string',
|
||||
},
|
||||
pageSize: {
|
||||
type: 'number',
|
||||
},
|
||||
});
|
||||
|
||||
const createFormCatalog = getData(
|
||||
await rootAgent.resource('flowSurfaces').catalog({
|
||||
values: {
|
||||
@@ -843,6 +931,131 @@ describe('flowSurfaces catalog + compose contract', () => {
|
||||
expect(readErrorMessage(invalidCollectionRes)).toContain(`must contain at least one date field`);
|
||||
});
|
||||
|
||||
it('should create configure and batch-add flow-model tree blocks', async () => {
|
||||
const page = await createPage(rootAgent, {
|
||||
title: 'Tree block contract page',
|
||||
tabTitle: 'Tree block contract tab',
|
||||
});
|
||||
|
||||
const tree = await addBlockData(rootAgent, {
|
||||
target: {
|
||||
uid: page.tabSchemaUid,
|
||||
},
|
||||
type: 'tree',
|
||||
resourceInit: {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'categories',
|
||||
},
|
||||
});
|
||||
const treeReadback = await getSurface(rootAgent, {
|
||||
uid: tree.uid,
|
||||
});
|
||||
expect(treeReadback.tree).toMatchObject({
|
||||
use: 'TreeBlockModel',
|
||||
props: {
|
||||
searchable: true,
|
||||
defaultExpandAll: false,
|
||||
includeDescendants: true,
|
||||
},
|
||||
stepParams: {
|
||||
resourceSettings: {
|
||||
init: {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'categories',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(treeReadback.tree.subModels).toBeUndefined();
|
||||
|
||||
const configureRes = await rootAgent.resource('flowSurfaces').configure({
|
||||
values: {
|
||||
target: {
|
||||
uid: tree.uid,
|
||||
},
|
||||
changes: {
|
||||
searchable: false,
|
||||
defaultExpandAll: true,
|
||||
includeDescendants: false,
|
||||
titleField: 'title',
|
||||
pageSize: 200,
|
||||
height: 420,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(configureRes.status, readErrorMessage(configureRes)).toBe(200);
|
||||
const configuredTree = await getSurface(rootAgent, {
|
||||
uid: tree.uid,
|
||||
});
|
||||
expect(configuredTree.tree.props).toMatchObject({
|
||||
searchable: false,
|
||||
defaultExpandAll: true,
|
||||
includeDescendants: false,
|
||||
fieldNames: {
|
||||
title: 'title',
|
||||
},
|
||||
pageSize: 200,
|
||||
});
|
||||
expect(configuredTree.tree.decoratorProps).toMatchObject({
|
||||
height: 420,
|
||||
});
|
||||
expect(configuredTree.tree.stepParams?.treeSettings).toMatchObject({
|
||||
searchable: {
|
||||
searchable: false,
|
||||
},
|
||||
defaultExpandAll: {
|
||||
defaultExpandAll: true,
|
||||
},
|
||||
includeDescendants: {
|
||||
includeDescendants: false,
|
||||
},
|
||||
titleField: {
|
||||
titleField: 'title',
|
||||
},
|
||||
pageSize: {
|
||||
pageSize: 200,
|
||||
},
|
||||
});
|
||||
|
||||
const batchPage = await createPage(rootAgent, {
|
||||
title: 'Batch tree block page',
|
||||
tabTitle: 'Batch tree block tab',
|
||||
});
|
||||
const batchRes = await rootAgent.resource('flowSurfaces').addBlocks({
|
||||
values: {
|
||||
target: {
|
||||
uid: batchPage.tabSchemaUid,
|
||||
},
|
||||
blocks: [
|
||||
{
|
||||
key: 'treeA',
|
||||
type: 'tree',
|
||||
resourceInit: {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'categories',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'treeB',
|
||||
use: 'TreeBlockModel',
|
||||
resourceInit: {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'departments',
|
||||
},
|
||||
settings: {
|
||||
searchable: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(batchRes.status).toBe(200);
|
||||
const batchData = getData(batchRes);
|
||||
expect(batchData.successCount).toBe(2);
|
||||
expect(batchData.errorCount).toBe(0);
|
||||
expect(batchData.blocks.map((item: any) => item.result?.uid).filter(Boolean)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should project missing calendar popup hosts during readback without persisting them', async () => {
|
||||
const page = await createPage(rootAgent, {
|
||||
title: 'Calendar read projection page',
|
||||
@@ -1885,6 +2098,468 @@ describe('flowSurfaces catalog + compose contract', () => {
|
||||
expect(usernameFilterReadback.tree.stepParams?.filterFormItemSettings?.init?.defaultTargetUid).toBe(tableBlock.uid);
|
||||
});
|
||||
|
||||
it('should compose tree blocks with layout and reject unsupported tree content containers', async () => {
|
||||
const page = await createPage(rootAgent, {
|
||||
title: 'Compose tree page',
|
||||
tabTitle: 'Compose tree tab',
|
||||
});
|
||||
|
||||
const composeRes = await rootAgent.resource('flowSurfaces').compose({
|
||||
values: {
|
||||
target: {
|
||||
uid: page.tabSchemaUid,
|
||||
},
|
||||
blocks: [
|
||||
{
|
||||
key: 'categoryTree',
|
||||
type: 'tree',
|
||||
resource: {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'categories',
|
||||
},
|
||||
settings: {
|
||||
searchable: true,
|
||||
defaultExpandAll: true,
|
||||
includeDescendants: true,
|
||||
pageSize: 200,
|
||||
titleField: 'title',
|
||||
},
|
||||
},
|
||||
],
|
||||
layout: {
|
||||
rows: [[{ key: 'categoryTree', span: 8 }]],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(composeRes.status).toBe(200);
|
||||
const composed = getData(composeRes);
|
||||
const treeBlock = getComposeBlock(composed, 'categoryTree');
|
||||
expect(composed.layout.rows.row1).toEqual([[treeBlock.uid]]);
|
||||
expect(composed.layout.sizes.row1).toEqual([24]);
|
||||
|
||||
const treeReadback = await getSurface(rootAgent, {
|
||||
uid: treeBlock.uid,
|
||||
});
|
||||
expect(treeReadback.tree).toMatchObject({
|
||||
use: 'TreeBlockModel',
|
||||
props: {
|
||||
searchable: true,
|
||||
defaultExpandAll: true,
|
||||
includeDescendants: true,
|
||||
pageSize: 200,
|
||||
fieldNames: {
|
||||
title: 'title',
|
||||
},
|
||||
},
|
||||
stepParams: {
|
||||
resourceSettings: {
|
||||
init: {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'categories',
|
||||
},
|
||||
},
|
||||
treeSettings: {
|
||||
defaultExpandAll: {
|
||||
defaultExpandAll: true,
|
||||
},
|
||||
includeDescendants: {
|
||||
includeDescendants: true,
|
||||
},
|
||||
pageSize: {
|
||||
pageSize: 200,
|
||||
},
|
||||
titleField: {
|
||||
titleField: 'title',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(treeReadback.tree.subModels).toBeUndefined();
|
||||
|
||||
const invalidCases = [
|
||||
{
|
||||
key: 'fields',
|
||||
payload: {
|
||||
fields: ['title'],
|
||||
},
|
||||
message: 'tree does not support fields[]',
|
||||
},
|
||||
{
|
||||
key: 'fieldGroups',
|
||||
payload: {
|
||||
fieldGroups: [
|
||||
{
|
||||
title: 'Tree fields',
|
||||
fields: ['title'],
|
||||
},
|
||||
],
|
||||
},
|
||||
message: 'tree does not support fieldGroups[]',
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
payload: {
|
||||
actions: ['refresh'],
|
||||
},
|
||||
message: 'tree does not support actions[]',
|
||||
},
|
||||
{
|
||||
key: 'recordActions',
|
||||
payload: {
|
||||
recordActions: ['view'],
|
||||
},
|
||||
message: 'tree does not support recordActions[]',
|
||||
},
|
||||
];
|
||||
|
||||
for (const item of invalidCases) {
|
||||
const invalidPage = await createPage(rootAgent, {
|
||||
title: `Invalid tree compose ${item.key}`,
|
||||
tabTitle: `Invalid tree compose ${item.key}`,
|
||||
});
|
||||
const invalidRes = await rootAgent.resource('flowSurfaces').compose({
|
||||
values: {
|
||||
target: {
|
||||
uid: invalidPage.tabSchemaUid,
|
||||
},
|
||||
blocks: [
|
||||
{
|
||||
key: 'tree',
|
||||
type: 'tree',
|
||||
resource: {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'categories',
|
||||
},
|
||||
...item.payload,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(invalidRes.status).toBe(400);
|
||||
expect(readErrorMessage(invalidRes)).toContain(item.message);
|
||||
}
|
||||
});
|
||||
|
||||
it('should compose relation fields with fieldType on form blocks', async () => {
|
||||
const page = await createPage(rootAgent, {
|
||||
title: 'Compose relation fieldType page',
|
||||
tabTitle: 'Compose relation fieldType tab',
|
||||
});
|
||||
|
||||
const composeRes = getData(
|
||||
await rootAgent.resource('flowSurfaces').compose({
|
||||
values: {
|
||||
target: {
|
||||
uid: page.tabSchemaUid,
|
||||
},
|
||||
blocks: [
|
||||
{
|
||||
key: 'userForm',
|
||||
type: 'createForm',
|
||||
resource: {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'users',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
key: 'rolesField',
|
||||
fieldPath: 'roles',
|
||||
fieldType: 'popupSubTable',
|
||||
fields: ['title', 'name'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const formBlock = getComposeBlock(composeRes, 'userForm');
|
||||
const formSurface = await getSurface(rootAgent, {
|
||||
uid: formBlock.uid,
|
||||
});
|
||||
const formItems = _.castArray(formSurface.tree?.subModels?.grid?.subModels?.items || []);
|
||||
expect(formItems).toHaveLength(1);
|
||||
expect(formItems[0]?.use).toBe('FormItemModel');
|
||||
expect(formItems[0]?.subModels?.field?.use).toBe('PopupSubTableFieldModel');
|
||||
expect(
|
||||
_.castArray(formItems[0]?.subModels?.field?.subModels?.subTableColumns || [])
|
||||
.filter((item: any) => item?.use === 'TableColumnModel')
|
||||
.map((item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath),
|
||||
).toEqual(['title', 'name']);
|
||||
|
||||
const fieldCatalog = getData(
|
||||
await rootAgent.resource('flowSurfaces').catalog({
|
||||
values: {
|
||||
target: {
|
||||
uid: formItems[0].uid,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(fieldCatalog.node.relation?.fieldTypes).toEqual(
|
||||
expect.arrayContaining(['select', 'picker', 'subFormList', 'popupSubTable']),
|
||||
);
|
||||
expect(fieldCatalog.node.relation?.current).toMatchObject({
|
||||
fieldType: 'popupSubTable',
|
||||
fields: ['title', 'name'],
|
||||
titleField: 'title',
|
||||
});
|
||||
expect(fieldCatalog.node.relation?.candidates).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
fieldType: 'picker',
|
||||
defaults: expect.objectContaining({
|
||||
titleField: 'title',
|
||||
selectorFields: ['title'],
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
fieldType: 'popupSubTable',
|
||||
defaults: expect.objectContaining({
|
||||
titleField: 'title',
|
||||
fields: ['title'],
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should compose inline editable subTable columns with editable fields', async () => {
|
||||
const page = await createPage(rootAgent, {
|
||||
title: 'Compose editable relation subTable page',
|
||||
tabTitle: 'Compose editable relation subTable tab',
|
||||
});
|
||||
|
||||
const composeRes = getData(
|
||||
await rootAgent.resource('flowSurfaces').compose({
|
||||
values: {
|
||||
target: {
|
||||
uid: page.tabSchemaUid,
|
||||
},
|
||||
blocks: [
|
||||
{
|
||||
key: 'userForm',
|
||||
type: 'createForm',
|
||||
resource: {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'users',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
key: 'rolesField',
|
||||
fieldPath: 'roles',
|
||||
fieldType: 'subTable',
|
||||
fields: ['title', 'name'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const formBlock = getComposeBlock(composeRes, 'userForm');
|
||||
const formSurface = await getSurface(rootAgent, {
|
||||
uid: formBlock.uid,
|
||||
});
|
||||
const formItems = _.castArray(formSurface.tree?.subModels?.grid?.subModels?.items || []);
|
||||
expect(formItems).toHaveLength(1);
|
||||
const rolesField = formItems[0]?.subModels?.field;
|
||||
expect(rolesField?.use).toBe('SubTableFieldModel');
|
||||
|
||||
const columns = _.castArray(rolesField?.subModels?.columns || []);
|
||||
expect(columns.map((item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath)).toEqual(['title', 'name']);
|
||||
expect(columns.map((item: any) => item?.subModels?.field?.use)).toEqual(['InputFieldModel', 'InputFieldModel']);
|
||||
});
|
||||
|
||||
it('should preserve explicit empty relation fields in compose fieldType specs', async () => {
|
||||
const page = await createPage(rootAgent, {
|
||||
title: 'Compose empty relation fieldType page',
|
||||
tabTitle: 'Compose empty relation fieldType tab',
|
||||
});
|
||||
|
||||
const composeRes = getData(
|
||||
await rootAgent.resource('flowSurfaces').compose({
|
||||
values: {
|
||||
target: {
|
||||
uid: page.tabSchemaUid,
|
||||
},
|
||||
blocks: [
|
||||
{
|
||||
key: 'userForm',
|
||||
type: 'createForm',
|
||||
resource: {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'users',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
key: 'rolesField',
|
||||
fieldPath: 'roles',
|
||||
fieldType: 'popupSubTable',
|
||||
fields: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const formBlock = getComposeBlock(composeRes, 'userForm');
|
||||
const formSurface = await getSurface(rootAgent, {
|
||||
uid: formBlock.uid,
|
||||
});
|
||||
const formItems = _.castArray(formSurface.tree?.subModels?.grid?.subModels?.items || []);
|
||||
expect(
|
||||
_.castArray(formItems[0]?.subModels?.field?.subModels?.subTableColumns || [])
|
||||
.filter((item: any) => item?.use === 'TableColumnModel')
|
||||
.map((item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('should addBlock with inline fields that use relation fieldType semantics', async () => {
|
||||
const page = await createPage(rootAgent, {
|
||||
title: 'AddBlock inline relation fields page',
|
||||
tabTitle: 'AddBlock inline relation fields tab',
|
||||
});
|
||||
|
||||
const addBlockByTypeRes = getData(
|
||||
await rootAgent.resource('flowSurfaces').addBlock({
|
||||
values: {
|
||||
target: {
|
||||
uid: page.tabSchemaUid,
|
||||
},
|
||||
type: 'createForm',
|
||||
resourceInit: {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'users',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
key: 'rolesField',
|
||||
fieldPath: 'roles',
|
||||
fieldType: 'popupSubTable',
|
||||
fields: ['title'],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const blockByTypeSurface = await getSurface(rootAgent, {
|
||||
uid: addBlockByTypeRes.uid,
|
||||
});
|
||||
const formItemsByType = _.castArray(blockByTypeSurface.tree?.subModels?.grid?.subModels?.items || []);
|
||||
expect(formItemsByType).toHaveLength(1);
|
||||
expect(formItemsByType[0]?.subModels?.field?.use).toBe('PopupSubTableFieldModel');
|
||||
expect(
|
||||
_.castArray(formItemsByType[0]?.subModels?.field?.subModels?.subTableColumns || [])
|
||||
.filter((item: any) => item?.use === 'TableColumnModel')
|
||||
.map((item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath),
|
||||
).toEqual(['title']);
|
||||
|
||||
const addBlockByUseRes = getData(
|
||||
await rootAgent.resource('flowSurfaces').addBlock({
|
||||
values: {
|
||||
target: {
|
||||
uid: page.tabSchemaUid,
|
||||
},
|
||||
use: 'CreateFormModel',
|
||||
resourceInit: {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'users',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
key: 'rolesFieldByUse',
|
||||
fieldPath: 'roles',
|
||||
fieldType: 'popupSubTable',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const blockByUseSurface = await getSurface(rootAgent, {
|
||||
uid: addBlockByUseRes.uid,
|
||||
});
|
||||
const formItemsByUse = _.castArray(blockByUseSurface.tree?.subModels?.grid?.subModels?.items || []);
|
||||
expect(formItemsByUse).toHaveLength(1);
|
||||
expect(formItemsByUse[0]?.subModels?.field?.use).toBe('PopupSubTableFieldModel');
|
||||
|
||||
const addBlocksData = getData(
|
||||
await rootAgent.resource('flowSurfaces').addBlocks({
|
||||
values: {
|
||||
target: {
|
||||
uid: page.tabSchemaUid,
|
||||
},
|
||||
blocks: [
|
||||
{
|
||||
key: 'batchUserForm',
|
||||
type: 'createForm',
|
||||
resourceInit: {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'users',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
key: 'batchRolesField',
|
||||
fieldPath: 'roles',
|
||||
fieldType: 'popupSubTable',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(addBlocksData.successCount).toBe(1);
|
||||
const batchBlockSurface = await getSurface(rootAgent, {
|
||||
uid: addBlocksData.blocks[0].result.uid,
|
||||
});
|
||||
const batchFormItems = _.castArray(batchBlockSurface.tree?.subModels?.grid?.subModels?.items || []);
|
||||
expect(batchFormItems).toHaveLength(1);
|
||||
expect(batchFormItems[0]?.subModels?.field?.use).toBe('PopupSubTableFieldModel');
|
||||
});
|
||||
|
||||
it('should reject internal relation field model keys in public field specs', async () => {
|
||||
const page = await createPage(rootAgent, {
|
||||
title: 'Reject internal relation field keys page',
|
||||
tabTitle: 'Reject internal relation field keys tab',
|
||||
});
|
||||
|
||||
const composeRes = await rootAgent.resource('flowSurfaces').compose({
|
||||
values: {
|
||||
target: {
|
||||
uid: page.tabSchemaUid,
|
||||
},
|
||||
blocks: [
|
||||
{
|
||||
key: 'userForm',
|
||||
type: 'createForm',
|
||||
resource: {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'users',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
key: 'rolesField',
|
||||
fieldPath: 'roles',
|
||||
fieldComponent: 'PopupSubTableFieldModel',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(composeRes.status).toBe(400);
|
||||
expect(readErrorMessage(composeRes)).toContain('does not accept internal field keys');
|
||||
});
|
||||
|
||||
it('should reject fieldsLayout on compose blocks that do not own a field grid', async () => {
|
||||
const page = await createPage(rootAgent, {
|
||||
title: 'Invalid compose fieldsLayout page',
|
||||
@@ -2334,6 +3009,96 @@ describe('flowSurfaces catalog + compose contract', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should create list and grid-card record actions with frontend-aligned button defaults', async () => {
|
||||
const page = await createPage(rootAgent, {
|
||||
title: 'Record action style page',
|
||||
tabTitle: 'Record action style tab',
|
||||
});
|
||||
const list = await addBlockData(rootAgent, {
|
||||
target: {
|
||||
uid: page.tabSchemaUid,
|
||||
},
|
||||
type: 'list',
|
||||
resourceInit: {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'users',
|
||||
},
|
||||
});
|
||||
const gridCard = await addBlockData(rootAgent, {
|
||||
target: {
|
||||
uid: page.tabSchemaUid,
|
||||
},
|
||||
type: 'gridCard',
|
||||
resourceInit: {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'users',
|
||||
},
|
||||
});
|
||||
|
||||
const listEditAction = getData(
|
||||
await rootAgent.resource('flowSurfaces').addRecordAction({
|
||||
values: {
|
||||
target: {
|
||||
uid: list.uid,
|
||||
},
|
||||
type: 'edit',
|
||||
settings: {
|
||||
title: '编辑',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const gridCardViewAction = getData(
|
||||
await rootAgent.resource('flowSurfaces').addRecordAction({
|
||||
values: {
|
||||
target: {
|
||||
uid: gridCard.uid,
|
||||
},
|
||||
type: 'view',
|
||||
},
|
||||
}),
|
||||
);
|
||||
const explicitListEditAction = getData(
|
||||
await rootAgent.resource('flowSurfaces').addRecordAction({
|
||||
values: {
|
||||
target: {
|
||||
uid: list.uid,
|
||||
},
|
||||
type: 'edit',
|
||||
settings: {
|
||||
type: 'primary',
|
||||
icon: 'EditOutlined',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const listEditReadback = await getSurface(rootAgent, {
|
||||
uid: listEditAction.uid,
|
||||
});
|
||||
expect(listEditReadback.tree.stepParams?.buttonSettings?.general).toMatchObject({
|
||||
title: '编辑',
|
||||
type: 'link',
|
||||
icon: null,
|
||||
});
|
||||
|
||||
const gridCardViewReadback = await getSurface(rootAgent, {
|
||||
uid: gridCardViewAction.uid,
|
||||
});
|
||||
expect(gridCardViewReadback.tree.stepParams?.buttonSettings?.general).toMatchObject({
|
||||
type: 'link',
|
||||
icon: null,
|
||||
});
|
||||
|
||||
const explicitListEditReadback = await getSurface(rootAgent, {
|
||||
uid: explicitListEditAction.uid,
|
||||
});
|
||||
expect(explicitListEditReadback.tree.stepParams?.buttonSettings?.general).toMatchObject({
|
||||
type: 'primary',
|
||||
icon: 'EditOutlined',
|
||||
});
|
||||
});
|
||||
|
||||
it('should compose a grid-card block with item fields block actions and record actions', async () => {
|
||||
const page = await createPage(rootAgent, {
|
||||
title: 'Compose grid-card page',
|
||||
@@ -3674,7 +4439,7 @@ describe('flowSurfaces catalog + compose contract', () => {
|
||||
},
|
||||
});
|
||||
expect(rawFieldRes.status).toBe(400);
|
||||
expect(readErrorMessage(rawFieldRes)).toContain('does not accept raw keys');
|
||||
expect(readErrorMessage(rawFieldRes)).toContain('does not accept internal field keys');
|
||||
|
||||
const rawActionRes = await rootAgent.resource('flowSurfaces').addAction({
|
||||
values: {
|
||||
|
||||
+42
@@ -251,4 +251,46 @@ describe('flowSurfaces field binding registry', () => {
|
||||
})?.has('CheckboxGroupFieldModel'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should expose generic relation field component sets that back public fieldType options', () => {
|
||||
const singleAssociationField = {
|
||||
interface: 'm2o',
|
||||
targetCollection: {
|
||||
template: 'general',
|
||||
},
|
||||
};
|
||||
const multiAssociationField = {
|
||||
interface: 'm2m',
|
||||
targetCollection: {
|
||||
template: 'general',
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
Array.from(
|
||||
getSupportedFieldComponentUseSet({ containerUse: 'FormItemModel', field: singleAssociationField }) || [],
|
||||
),
|
||||
).toEqual(['RecordSelectFieldModel', 'RecordPickerFieldModel', 'SubFormFieldModel']);
|
||||
expect(
|
||||
Array.from(
|
||||
getSupportedFieldComponentUseSet({ containerUse: 'FormItemModel', field: multiAssociationField }) || [],
|
||||
),
|
||||
).toEqual([
|
||||
'RecordSelectFieldModel',
|
||||
'RecordPickerFieldModel',
|
||||
'SubFormListFieldModel',
|
||||
'SubTableFieldModel',
|
||||
'PopupSubTableFieldModel',
|
||||
]);
|
||||
expect(
|
||||
Array.from(
|
||||
getSupportedFieldComponentUseSet({ containerUse: 'DetailsItemModel', field: multiAssociationField }) || [],
|
||||
),
|
||||
).toEqual(['DisplayTextFieldModel', 'DisplaySubListFieldModel', 'DisplaySubTableFieldModel']);
|
||||
expect(
|
||||
Array.from(
|
||||
getSupportedFieldComponentUseSet({ containerUse: 'TableColumnModel', field: multiAssociationField }) || [],
|
||||
),
|
||||
).toEqual(['DisplayTextFieldModel', 'DisplaySubListFieldModel', 'DisplaySubTableFieldModel']);
|
||||
});
|
||||
});
|
||||
|
||||
+23
-4
@@ -501,6 +501,7 @@ describe('flowSurfaces swagger', () => {
|
||||
'chart',
|
||||
'actionPanel',
|
||||
'jsBlock',
|
||||
'tree',
|
||||
]);
|
||||
expect(schemas.FlowSurfaceApplyBlueprintBlockSpec.properties.type.description).toContain(
|
||||
'Generic `form` is not supported',
|
||||
@@ -944,6 +945,7 @@ describe('flowSurfaces swagger', () => {
|
||||
expect.arrayContaining([
|
||||
'table',
|
||||
'filterForm',
|
||||
'tree',
|
||||
'actionPanel',
|
||||
'jsBlock',
|
||||
'approvalInitiator',
|
||||
@@ -1217,6 +1219,12 @@ describe('flowSurfaces swagger', () => {
|
||||
expect(schemas.FlowSurfaceAddBlockRequest.properties.resource.$ref).toBe(
|
||||
'#/components/schemas/FlowSurfaceBlockResourceInput',
|
||||
);
|
||||
expect(schemas.FlowSurfaceAddBlockRequest.properties.fields.items.$ref).toBe(
|
||||
'#/components/schemas/FlowSurfaceComposeFieldSpec',
|
||||
);
|
||||
expect(schemas.FlowSurfaceAddBlockRequest.properties.fieldsLayout.$ref).toBe(
|
||||
'#/components/schemas/FlowSurfaceComposeLayout',
|
||||
);
|
||||
expect(schemas.FlowSurfaceAddBlockRequest.properties.settings.type).toBe('object');
|
||||
expect(schemas.FlowSurfaceAddBlockRequest.properties.defaultFilter.allOf).toEqual([
|
||||
{ $ref: '#/components/schemas/FlowSurfaceFilterGroup' },
|
||||
@@ -1338,11 +1346,12 @@ describe('flowSurfaces swagger', () => {
|
||||
expect(swaggerDocument.paths['/flowSurfaces:addBlocks'].post.description).toContain('`kanban`');
|
||||
expect(addBlocksRequest.example.blocks).toHaveLength(2);
|
||||
expect(addBlocksRequest.example.blocks[0].type).toBe('table');
|
||||
expect(addBlocksRequest.example.blocks[1].type).toBe('markdown');
|
||||
expect(addBlocksRequest.example.blocks[1].type).toBe('createForm');
|
||||
expect(addBlocksRequest.example.blocks[0].settings.pageSize).toBe(50);
|
||||
expect(addBlocksRequest.example.blocks[0].defaultFilter.items[0].value).toBe('staff');
|
||||
expect(addBlocksRequest.example.blocks[0].defaultActionSettings.filter.defaultFilter.items).toHaveLength(3);
|
||||
expect(addBlocksRequest.example.blocks[1].settings.content).toContain('Team notes');
|
||||
expect(addBlocksRequest.example.blocks[1].fields[0].fieldType).toBe('popupSubTable');
|
||||
expect(addBlocksRequest.example.blocks[1].fieldsLayout.rows).toEqual([['rolesField']]);
|
||||
expect(schemas.FlowSurfaceAddBlocksRequest.required).toEqual(['target', 'blocks']);
|
||||
expect(schemas.FlowSurfaceAddBlockItem.properties.settings.type).toBe('object');
|
||||
expect(schemas.FlowSurfaceAddBlockItem.properties.defaultFilter.allOf).toEqual([
|
||||
@@ -1354,6 +1363,12 @@ describe('flowSurfaces swagger', () => {
|
||||
expect(schemas.FlowSurfaceAddBlockItem.properties.template.$ref).toBe(
|
||||
'#/components/schemas/FlowSurfaceBlockTemplateRef',
|
||||
);
|
||||
expect(schemas.FlowSurfaceAddBlockItem.properties.fields.items.$ref).toBe(
|
||||
'#/components/schemas/FlowSurfaceComposeFieldSpec',
|
||||
);
|
||||
expect(schemas.FlowSurfaceAddBlockItem.properties.fieldsLayout.$ref).toBe(
|
||||
'#/components/schemas/FlowSurfaceComposeLayout',
|
||||
);
|
||||
expect(schemas.FlowSurfaceComposeBlockSpec.anyOf).toEqual(
|
||||
expect.arrayContaining([{ required: ['type'] }, { required: ['template'] }]),
|
||||
);
|
||||
@@ -1380,9 +1395,9 @@ describe('flowSurfaces swagger', () => {
|
||||
swaggerDocument.paths['/flowSurfaces:addFields'].post.requestBody.content['application/json'];
|
||||
expect(addFieldsRequest.example.fields).toHaveLength(2);
|
||||
expect(addFieldsRequest.example.fields[0].popup.blocks[0].type).toBe('details');
|
||||
expect(addFieldsRequest.example.fields[1].renderer).toBe('js');
|
||||
expect(addFieldsRequest.example.fields[1].fieldType).toBe('popupSubTable');
|
||||
expect(addFieldsRequest.example.fields[0].settings.title).toBe('User name');
|
||||
expect(addFieldsRequest.example.fields[1].settings.version).toBe('1.0.0');
|
||||
expect(addFieldsRequest.example.fields[1].settings.title).toBe('Roles');
|
||||
expect(schemas.FlowSurfaceAddFieldsRequest.required).toEqual(['target']);
|
||||
expect(schemas.FlowSurfaceAddFieldsRequest.oneOf).toHaveLength(2);
|
||||
expect(schemas.FlowSurfaceAddFieldsRequest.properties.template.$ref).toBe(
|
||||
@@ -1395,6 +1410,10 @@ describe('flowSurfaces swagger', () => {
|
||||
expect(schemas.FlowSurfaceAddFieldItem.properties.popup.$ref).toBe(
|
||||
'#/components/schemas/FlowSurfaceComposeFieldPopup',
|
||||
);
|
||||
expect(schemas.FlowSurfaceAddFieldRequest.properties.fieldType.enum).toContain('popupSubTable');
|
||||
expect(schemas.FlowSurfaceAddFieldItem.properties.fieldType.enum).toContain('popupSubTable');
|
||||
expect(schemas.FlowSurfaceComposeFieldSpec.oneOf[1].properties.fieldType.enum).toContain('popupSubTable');
|
||||
expect(schemas.FlowSurfaceApplyBlueprintFieldSpec.oneOf[1].properties.fieldType.enum).toContain('popupSubTable');
|
||||
expect(schemas.FlowSurfaceAddFieldItem.properties.wrapperProps).toBeUndefined();
|
||||
expect(schemas.FlowSurfaceAddFieldItem.properties.fieldProps).toBeUndefined();
|
||||
expect(schemas.FlowSurfaceAddFieldItem.properties.props).toBeUndefined();
|
||||
|
||||
+1
@@ -30,6 +30,7 @@ export const FLOW_SURFACES_TEST_PLUGINS = [
|
||||
'system-settings',
|
||||
'block-list',
|
||||
'block-grid-card',
|
||||
'block-tree',
|
||||
'kanban',
|
||||
'block-markdown',
|
||||
'block-iframe',
|
||||
|
||||
+522
-14
@@ -1160,6 +1160,65 @@ describe('flowSurfaces resource', () => {
|
||||
expect(expandCollapseReadback.tree.stepParams?.buttonSettings?.general?.title).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should ignore button type for actions directly under an action panel', async () => {
|
||||
const page = await createPage(rootAgent, {
|
||||
title: 'Action panel button type page',
|
||||
tabTitle: 'Action panel button type tab',
|
||||
});
|
||||
const actionPanelUid = await addBlock(rootAgent, page.tabSchemaUid, 'actionPanel', {});
|
||||
const createFormUid = await addBlock(rootAgent, page.tabSchemaUid, 'createForm', {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'employees',
|
||||
});
|
||||
|
||||
const panelJsAction = await addAction(rootAgent, actionPanelUid, 'js', {
|
||||
settings: {
|
||||
title: 'Run panel code',
|
||||
type: 'primary',
|
||||
code: "return 'panel';",
|
||||
version: '1.0.0',
|
||||
},
|
||||
});
|
||||
const panelReadback = await getSurface(rootAgent, { uid: panelJsAction.uid });
|
||||
expect(panelReadback.tree.use).toBe('JSActionModel');
|
||||
expect(panelReadback.tree.props?.title).toBe('Run panel code');
|
||||
expect(panelReadback.tree.stepParams?.buttonSettings?.general?.title).toBe('Run panel code');
|
||||
expect(panelReadback.tree.stepParams?.clickSettings?.runJs).toMatchObject({
|
||||
code: "return 'panel';",
|
||||
version: '1.0.0',
|
||||
});
|
||||
expect(panelReadback.tree.props?.type).not.toBe('primary');
|
||||
expect(panelReadback.tree.stepParams?.buttonSettings?.general?.type).not.toBe('primary');
|
||||
|
||||
const configurePanelRes = await rootAgent.resource('flowSurfaces').configure({
|
||||
values: {
|
||||
target: { uid: panelJsAction.uid },
|
||||
changes: {
|
||||
title: 'Run panel code configured',
|
||||
type: 'primary',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(configurePanelRes.status).toBe(200);
|
||||
const configuredPanelReadback = await getSurface(rootAgent, { uid: panelJsAction.uid });
|
||||
expect(configuredPanelReadback.tree.props?.title).toBe('Run panel code configured');
|
||||
expect(configuredPanelReadback.tree.props?.type).not.toBe('primary');
|
||||
expect(configuredPanelReadback.tree.stepParams?.buttonSettings?.general?.type).not.toBe('primary');
|
||||
|
||||
const formJsAction = await addAction(rootAgent, createFormUid, 'js', {
|
||||
settings: {
|
||||
title: 'Run form code',
|
||||
type: 'primary',
|
||||
code: "return 'form';",
|
||||
version: '1.0.0',
|
||||
},
|
||||
});
|
||||
const formReadback = await getSurface(rootAgent, { uid: formJsAction.uid });
|
||||
expect(formReadback.tree.use).toBe('JSFormActionModel');
|
||||
expect(formReadback.tree.props?.type).toBe('primary');
|
||||
expect(formReadback.tree.stepParams?.buttonSettings?.general?.type).toBe('primary');
|
||||
});
|
||||
|
||||
it('should persist canonical block headers through configure for representative block families', async () => {
|
||||
const page = await createPage(rootAgent, {
|
||||
title: 'Canonical block header page',
|
||||
@@ -4154,7 +4213,7 @@ describe('flowSurfaces resource', () => {
|
||||
expect(readErrorMessage(invalidRes)).toContain('must reference an existing node');
|
||||
});
|
||||
|
||||
it('should configure roles table fields with displayStyle tag and switch fieldComponent via wrapper changes', async () => {
|
||||
it('should configure roles table fields with displayStyle tag and switch fieldType via wrapper changes', async () => {
|
||||
const page = await createPage(rootAgent, {
|
||||
title: 'Users roles field configure page',
|
||||
tabTitle: 'Users roles field configure tab',
|
||||
@@ -4191,7 +4250,8 @@ describe('flowSurfaces resource', () => {
|
||||
uid: rolesField.wrapperUid,
|
||||
},
|
||||
changes: {
|
||||
fieldComponent: 'DisplaySubTableFieldModel',
|
||||
fieldType: 'subTable',
|
||||
fields: ['title', 'name'],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -4206,6 +4266,11 @@ describe('flowSurfaces resource', () => {
|
||||
expect(rolesWrapperAfterSwitch.tree.stepParams?.tableColumnSettings?.model?.use).toBe('DisplaySubTableFieldModel');
|
||||
expect(rolesInnerAfterSwitch.tree.use).toBe('DisplaySubTableFieldModel');
|
||||
expect(rolesInnerAfterSwitch.tree.stepParams?.fieldBinding?.use).toBe('DisplaySubTableFieldModel');
|
||||
expect(
|
||||
_.castArray(rolesInnerAfterSwitch.tree.subModels?.columns || []).map(
|
||||
(item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath,
|
||||
),
|
||||
).toEqual(['title', 'name']);
|
||||
expect(rolesInnerAfterSwitch.tree.stepParams?.fieldSettings?.init).toMatchObject({
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'users',
|
||||
@@ -4213,6 +4278,452 @@ describe('flowSurfaces resource', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should create and switch generic relation fields with fieldType semantics', async () => {
|
||||
const page = await createPage(rootAgent, {
|
||||
title: 'Generic relation fieldType page',
|
||||
tabTitle: 'Generic relation fieldType tab',
|
||||
});
|
||||
|
||||
const formUid = await addBlock(rootAgent, page.tabSchemaUid, 'createForm', {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'users',
|
||||
});
|
||||
|
||||
const rolesCatalog = getData(
|
||||
await rootAgent.resource('flowSurfaces').catalog({
|
||||
values: {
|
||||
target: {
|
||||
uid: formUid,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).fields.find((item: any) => item.key === 'roles');
|
||||
expect(rolesCatalog.fieldUse).toBe('RecordSelectFieldModel');
|
||||
|
||||
const createdRolesField = getData(
|
||||
await rootAgent.resource('flowSurfaces').addField({
|
||||
values: {
|
||||
target: {
|
||||
uid: formUid,
|
||||
},
|
||||
fieldPath: 'roles',
|
||||
fieldType: 'popupSubTable',
|
||||
fields: ['title', 'name'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(createdRolesField.fieldUse).toBe('PopupSubTableFieldModel');
|
||||
|
||||
const createdRolesWrapperReadback = await getSurface(rootAgent, {
|
||||
uid: createdRolesField.wrapperUid,
|
||||
});
|
||||
const createdRolesInnerReadback = await getSurface(rootAgent, {
|
||||
uid: createdRolesField.fieldUid,
|
||||
});
|
||||
expect(createdRolesWrapperReadback.tree.stepParams?.editItemSettings?.model?.use).toBeUndefined();
|
||||
expect(createdRolesInnerReadback.tree.use).toBe('PopupSubTableFieldModel');
|
||||
expect(createdRolesInnerReadback.tree.subModels?.subTableColumns?.[0]?.use).toBe('PopupSubTableActionsColumnModel');
|
||||
expect(
|
||||
_.castArray(createdRolesInnerReadback.tree.subModels?.subTableColumns || [])
|
||||
.filter((item: any) => item?.use === 'TableColumnModel')
|
||||
.map((item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath),
|
||||
).toEqual(['title', 'name']);
|
||||
expect(
|
||||
_.castArray(createdRolesInnerReadback.tree.subModels?.subTableColumns?.[0]?.subModels?.actions || []).map(
|
||||
(item: any) => item.use,
|
||||
),
|
||||
).toEqual(['PopupSubTableEditActionModel', 'PopupSubTableRemoveActionModel']);
|
||||
|
||||
const addFieldsData = getData(
|
||||
await rootAgent.resource('flowSurfaces').addFields({
|
||||
values: {
|
||||
target: {
|
||||
uid: formUid,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
key: 'batchRolesField',
|
||||
fieldPath: 'roles',
|
||||
fieldType: 'popupSubTable',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(addFieldsData.successCount).toBe(1);
|
||||
expect(addFieldsData.fields[0].ok).toBe(true);
|
||||
expect(addFieldsData.fields[0].result.fieldUse).toBe('PopupSubTableFieldModel');
|
||||
const batchRolesInnerReadback = await getSurface(rootAgent, {
|
||||
uid: addFieldsData.fields[0].result.fieldUid,
|
||||
});
|
||||
expect(batchRolesInnerReadback.tree.use).toBe('PopupSubTableFieldModel');
|
||||
expect(batchRolesInnerReadback.tree.subModels?.subTableColumns?.[0]?.use).toBe('PopupSubTableActionsColumnModel');
|
||||
|
||||
const defaultedSwitchField = await addField(rootAgent, formUid, 'roles');
|
||||
const defaultedSwitchRes = await rootAgent.resource('flowSurfaces').configure({
|
||||
values: {
|
||||
target: {
|
||||
uid: defaultedSwitchField.wrapperUid,
|
||||
},
|
||||
changes: {
|
||||
fieldType: 'popupSubTable',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(defaultedSwitchRes.status).toBe(200);
|
||||
const defaultedSwitchReadback = await getSurface(rootAgent, {
|
||||
uid: defaultedSwitchField.fieldUid,
|
||||
});
|
||||
expect(
|
||||
_.castArray(defaultedSwitchReadback.tree.subModels?.subTableColumns || [])
|
||||
.filter((item: any) => item?.use === 'TableColumnModel')
|
||||
.map((item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath),
|
||||
).toEqual(['title']);
|
||||
|
||||
const defaultRolesField = await addField(rootAgent, formUid, 'roles');
|
||||
const switchRes = await rootAgent.resource('flowSurfaces').configure({
|
||||
values: {
|
||||
target: {
|
||||
uid: defaultRolesField.wrapperUid,
|
||||
},
|
||||
changes: {
|
||||
fieldType: 'popupSubTable',
|
||||
fields: ['title'],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(switchRes.status).toBe(200);
|
||||
|
||||
const switchedRolesWrapper = await getSurface(rootAgent, {
|
||||
uid: defaultRolesField.wrapperUid,
|
||||
});
|
||||
const switchedRolesInner = await getSurface(rootAgent, {
|
||||
uid: defaultRolesField.fieldUid,
|
||||
});
|
||||
expect(switchedRolesWrapper.tree.stepParams?.editItemSettings?.model?.use).toBe('PopupSubTableFieldModel');
|
||||
expect(switchedRolesInner.tree.stepParams?.fieldBinding?.use).toBe('PopupSubTableFieldModel');
|
||||
expect(switchedRolesInner.tree.subModels?.subTableColumns?.[0]?.use).toBe('PopupSubTableActionsColumnModel');
|
||||
expect(
|
||||
_.castArray(switchedRolesInner.tree.subModels?.subTableColumns || [])
|
||||
.filter((item: any) => item?.use === 'TableColumnModel')
|
||||
.map((item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath),
|
||||
).toEqual(['title']);
|
||||
|
||||
const configureInnerTitleFieldRes = await rootAgent.resource('flowSurfaces').configure({
|
||||
values: {
|
||||
target: {
|
||||
uid: defaultRolesField.fieldUid,
|
||||
},
|
||||
changes: {
|
||||
titleField: 'title',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(configureInnerTitleFieldRes.status).toBe(200);
|
||||
const innerTitleFieldReadback = await getSurface(rootAgent, {
|
||||
uid: defaultRolesField.fieldUid,
|
||||
});
|
||||
expect(innerTitleFieldReadback.tree.props?.titleField).toBe('title');
|
||||
|
||||
const customActionsColumn = innerTitleFieldReadback.tree.subModels?.subTableColumns?.[0];
|
||||
expect(customActionsColumn?.use).toBe('PopupSubTableActionsColumnModel');
|
||||
await flowRepo.patch({
|
||||
uid: customActionsColumn.uid,
|
||||
props: {
|
||||
...(customActionsColumn.props || {}),
|
||||
reviewMarker: 'preserve-on-same-field-component',
|
||||
},
|
||||
});
|
||||
const sameFieldComponentRes = await rootAgent.resource('flowSurfaces').configure({
|
||||
values: {
|
||||
target: {
|
||||
uid: defaultRolesField.wrapperUid,
|
||||
},
|
||||
changes: {
|
||||
fieldType: 'popupSubTable',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(sameFieldComponentRes.status).toBe(200);
|
||||
const sameFieldComponentReadback = await getSurface(rootAgent, {
|
||||
uid: defaultRolesField.fieldUid,
|
||||
});
|
||||
expect(sameFieldComponentReadback.tree.subModels?.subTableColumns?.[0]?.props?.reviewMarker).toBe(
|
||||
'preserve-on-same-field-component',
|
||||
);
|
||||
expect(sameFieldComponentReadback.tree.stepParams?.fieldBinding?.use).toBe('PopupSubTableFieldModel');
|
||||
|
||||
const invalidScalarFieldRes = await rootAgent.resource('flowSurfaces').addField({
|
||||
values: {
|
||||
target: {
|
||||
uid: formUid,
|
||||
},
|
||||
fieldPath: 'nickname',
|
||||
fieldType: 'popupSubTable',
|
||||
},
|
||||
});
|
||||
expect(invalidScalarFieldRes.status).toBe(400);
|
||||
expect(readErrorMessage(invalidScalarFieldRes)).toContain(`fieldType is only supported for relation fields`);
|
||||
});
|
||||
|
||||
it('should validate and configure relation fieldType options consistently', async () => {
|
||||
const page = await createPage(rootAgent, {
|
||||
title: 'Relation fieldType options page',
|
||||
tabTitle: 'Relation fieldType options tab',
|
||||
});
|
||||
|
||||
const formUid = await addBlock(rootAgent, page.tabSchemaUid, 'createForm', {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'users',
|
||||
});
|
||||
const detailsUid = await addBlock(rootAgent, page.tabSchemaUid, 'details', {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'users',
|
||||
});
|
||||
const employeeFormUid = await addBlock(rootAgent, page.tabSchemaUid, 'createForm', {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'employees',
|
||||
});
|
||||
const employeeDetailsUid = await addBlock(rootAgent, page.tabSchemaUid, 'details', {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'employees',
|
||||
});
|
||||
|
||||
const pickerField = await addField(rootAgent, formUid, 'roles', {
|
||||
fieldType: 'picker',
|
||||
selectorFields: ['title'],
|
||||
openMode: 'drawer',
|
||||
popupSize: 'medium',
|
||||
});
|
||||
let pickerReadback = await getSurface(rootAgent, {
|
||||
uid: pickerField.fieldUid,
|
||||
});
|
||||
expect(pickerReadback.tree.use).toBe('RecordPickerFieldModel');
|
||||
expect(pickerReadback.tree.stepParams?.popupSettings?.openView).toMatchObject({
|
||||
mode: 'drawer',
|
||||
size: 'medium',
|
||||
});
|
||||
expect(
|
||||
_.castArray(pickerReadback.tree.subModels?.['grid-block']?.subModels?.items?.[0]?.subModels?.columns || []).map(
|
||||
(item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath,
|
||||
),
|
||||
).toEqual(['title']);
|
||||
|
||||
const configurePickerOnlyRes = await rootAgent.resource('flowSurfaces').configure({
|
||||
values: {
|
||||
target: {
|
||||
uid: pickerField.wrapperUid,
|
||||
},
|
||||
changes: {
|
||||
openMode: 'modal',
|
||||
popupSize: 'large',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(configurePickerOnlyRes.status).toBe(200);
|
||||
pickerReadback = await getSurface(rootAgent, {
|
||||
uid: pickerField.fieldUid,
|
||||
});
|
||||
expect(pickerReadback.tree.stepParams?.popupSettings?.openView).toMatchObject({
|
||||
mode: 'modal',
|
||||
size: 'large',
|
||||
});
|
||||
|
||||
const clearPickerSelectorFieldsRes = await rootAgent.resource('flowSurfaces').configure({
|
||||
values: {
|
||||
target: {
|
||||
uid: pickerField.wrapperUid,
|
||||
},
|
||||
changes: {
|
||||
selectorFields: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(clearPickerSelectorFieldsRes.status).toBe(200);
|
||||
pickerReadback = await getSurface(rootAgent, {
|
||||
uid: pickerField.fieldUid,
|
||||
});
|
||||
expect(
|
||||
_.castArray(pickerReadback.tree.subModels?.['grid-block']?.subModels?.items?.[0]?.subModels?.columns || []).map(
|
||||
(item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath,
|
||||
),
|
||||
).toEqual([]);
|
||||
|
||||
const customPickerField = await addField(rootAgent, employeeFormUid, 'department', {
|
||||
fieldType: 'picker',
|
||||
titleField: 'location',
|
||||
selectorFields: ['location'],
|
||||
});
|
||||
let customPickerReadback = await getSurface(rootAgent, {
|
||||
uid: customPickerField.fieldUid,
|
||||
});
|
||||
expect(customPickerReadback.tree.props?.titleField).toBe('location');
|
||||
const configureCustomPickerOnlyRes = await rootAgent.resource('flowSurfaces').configure({
|
||||
values: {
|
||||
target: {
|
||||
uid: customPickerField.wrapperUid,
|
||||
},
|
||||
changes: {
|
||||
openMode: 'drawer',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(configureCustomPickerOnlyRes.status).toBe(200);
|
||||
customPickerReadback = await getSurface(rootAgent, {
|
||||
uid: customPickerField.fieldUid,
|
||||
});
|
||||
expect(customPickerReadback.tree.props?.titleField).toBe('location');
|
||||
expect(
|
||||
_.castArray(
|
||||
customPickerReadback.tree.subModels?.['grid-block']?.subModels?.items?.[0]?.subModels?.columns || [],
|
||||
).map((item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath),
|
||||
).toEqual(['location']);
|
||||
|
||||
const pickerWithFieldsRes = await rootAgent.resource('flowSurfaces').addField({
|
||||
values: {
|
||||
target: {
|
||||
uid: formUid,
|
||||
},
|
||||
fieldPath: 'roles',
|
||||
fieldType: 'picker',
|
||||
fields: ['title'],
|
||||
},
|
||||
});
|
||||
expect(pickerWithFieldsRes.status).toBe(400);
|
||||
expect(readErrorMessage(pickerWithFieldsRes)).toContain(`fieldType 'picker' does not support fields`);
|
||||
|
||||
const subTableField = await addField(rootAgent, formUid, 'roles', {
|
||||
fieldType: 'popupSubTable',
|
||||
fields: ['title', 'name'],
|
||||
});
|
||||
const configureSubTableOnlyRes = await rootAgent.resource('flowSurfaces').configure({
|
||||
values: {
|
||||
target: {
|
||||
uid: subTableField.wrapperUid,
|
||||
},
|
||||
changes: {
|
||||
pageSize: 7,
|
||||
showIndex: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(configureSubTableOnlyRes.status).toBe(200);
|
||||
let subTableReadback = await getSurface(rootAgent, {
|
||||
uid: subTableField.fieldUid,
|
||||
});
|
||||
expect(subTableReadback.tree.props).toMatchObject({
|
||||
pageSize: 7,
|
||||
showIndex: true,
|
||||
});
|
||||
|
||||
const clearSubTableFieldsRes = await rootAgent.resource('flowSurfaces').configure({
|
||||
values: {
|
||||
target: {
|
||||
uid: subTableField.wrapperUid,
|
||||
},
|
||||
changes: {
|
||||
fields: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(clearSubTableFieldsRes.status).toBe(200);
|
||||
subTableReadback = await getSurface(rootAgent, {
|
||||
uid: subTableField.fieldUid,
|
||||
});
|
||||
expect(
|
||||
_.castArray(subTableReadback.tree.subModels?.subTableColumns || []).filter(
|
||||
(item: any) => item?.use === 'TableColumnModel',
|
||||
),
|
||||
).toEqual([]);
|
||||
|
||||
const subTableWithSelectorFieldsRes = await rootAgent.resource('flowSurfaces').addField({
|
||||
values: {
|
||||
target: {
|
||||
uid: formUid,
|
||||
},
|
||||
fieldPath: 'roles',
|
||||
fieldType: 'popupSubTable',
|
||||
selectorFields: ['title'],
|
||||
},
|
||||
});
|
||||
expect(subTableWithSelectorFieldsRes.status).toBe(400);
|
||||
expect(readErrorMessage(subTableWithSelectorFieldsRes)).toContain(
|
||||
`fieldType 'popupSubTable' does not support selectorFields`,
|
||||
);
|
||||
|
||||
const textWithFieldsRes = await rootAgent.resource('flowSurfaces').addField({
|
||||
values: {
|
||||
target: {
|
||||
uid: detailsUid,
|
||||
},
|
||||
fieldPath: 'roles',
|
||||
fieldType: 'text',
|
||||
fields: ['title'],
|
||||
},
|
||||
});
|
||||
expect(textWithFieldsRes.status).toBe(400);
|
||||
expect(readErrorMessage(textWithFieldsRes)).toContain(`fieldType 'text' does not support fields`);
|
||||
|
||||
const subFormField = await addField(rootAgent, employeeFormUid, 'department', {
|
||||
fieldType: 'subForm',
|
||||
titleField: 'title',
|
||||
fields: ['location'],
|
||||
});
|
||||
const subFormReadback = await getSurface(rootAgent, {
|
||||
uid: subFormField.fieldUid,
|
||||
});
|
||||
expect(subFormReadback.tree.use).toBe('SubFormFieldModel');
|
||||
expect(
|
||||
_.castArray(subFormReadback.tree.subModels?.grid?.subModels?.items || []).map(
|
||||
(item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath,
|
||||
),
|
||||
).toEqual(['location']);
|
||||
|
||||
const detailsSubDetailsField = await addField(rootAgent, employeeDetailsUid, 'department', {
|
||||
fieldType: 'subDetails',
|
||||
titleField: 'title',
|
||||
fields: ['location'],
|
||||
});
|
||||
const subDetailsReadback = await getSurface(rootAgent, {
|
||||
uid: detailsSubDetailsField.fieldUid,
|
||||
});
|
||||
expect(subDetailsReadback.tree.use).toBe('DisplaySubItemFieldModel');
|
||||
|
||||
const detailsSubDetailsListField = await addField(rootAgent, detailsUid, 'roles', {
|
||||
fieldType: 'subDetailsList',
|
||||
fields: ['title'],
|
||||
});
|
||||
const subDetailsListReadback = await getSurface(rootAgent, {
|
||||
uid: detailsSubDetailsListField.fieldUid,
|
||||
});
|
||||
expect(subDetailsListReadback.tree.use).toBe('DisplaySubListFieldModel');
|
||||
|
||||
const formSubFormListField = await addField(rootAgent, formUid, 'roles', {
|
||||
fieldType: 'subFormList',
|
||||
fields: ['title'],
|
||||
});
|
||||
const subFormListReadback = await getSurface(rootAgent, {
|
||||
uid: formSubFormListField.fieldUid,
|
||||
});
|
||||
expect(subFormListReadback.tree.use).toBe('SubFormListFieldModel');
|
||||
|
||||
const detailsTextField = await addField(rootAgent, detailsUid, 'roles', {
|
||||
fieldType: 'text',
|
||||
});
|
||||
const textReadback = await getSurface(rootAgent, {
|
||||
uid: detailsTextField.fieldUid,
|
||||
});
|
||||
expect(textReadback.tree.use).toBe('DisplayTextFieldModel');
|
||||
|
||||
const selectField = await addField(rootAgent, formUid, 'roles', {
|
||||
fieldType: 'select',
|
||||
});
|
||||
const selectReadback = await getSurface(rootAgent, {
|
||||
uid: selectField.fieldUid,
|
||||
});
|
||||
expect(selectReadback.tree.use).toBe('RecordSelectFieldModel');
|
||||
});
|
||||
|
||||
it('should reject invalid openView uid through updateSettings stepParams writes', async () => {
|
||||
const page = await createPage(rootAgent, {
|
||||
title: 'Invalid updateSettings popup uid page',
|
||||
@@ -5670,17 +6181,15 @@ describe('flowSurfaces resource', () => {
|
||||
fieldUse: 'InputFieldModel',
|
||||
});
|
||||
|
||||
const explicitFormField = await addField(rootAgent, createFormUid, 'nickname', {
|
||||
fieldUse: formNicknameField.fieldUse,
|
||||
});
|
||||
expect(explicitFormField.fieldUse).toBe(formNicknameField.fieldUse);
|
||||
const inferredFormField = await addField(rootAgent, createFormUid, 'nickname');
|
||||
expect(inferredFormField.fieldUse).toBe(formNicknameField.fieldUse);
|
||||
|
||||
const explicitFormFieldReadback = await getSurface(rootAgent, {
|
||||
uid: explicitFormField.fieldUid,
|
||||
const inferredFormFieldReadback = await getSurface(rootAgent, {
|
||||
uid: inferredFormField.fieldUid,
|
||||
});
|
||||
expect(explicitFormFieldReadback.tree.use).toBe(formNicknameField.fieldUse);
|
||||
expect(inferredFormFieldReadback.tree.use).toBe(formNicknameField.fieldUse);
|
||||
|
||||
const mismatchedFieldUseRes = await rootAgent.resource('flowSurfaces').addField({
|
||||
const fieldUseRes = await rootAgent.resource('flowSurfaces').addField({
|
||||
values: {
|
||||
target: {
|
||||
uid: createFormUid,
|
||||
@@ -5689,8 +6198,8 @@ describe('flowSurfaces resource', () => {
|
||||
fieldUse: tableNicknameField.fieldUse,
|
||||
},
|
||||
});
|
||||
expect(mismatchedFieldUseRes.status).toBe(400);
|
||||
expect(readErrorMessage(mismatchedFieldUseRes)).toContain(`does not match inferred fieldUse`);
|
||||
expect(fieldUseRes.status).toBe(400);
|
||||
expect(readErrorMessage(fieldUseRes)).toContain(`does not accept internal field keys: fieldUse`);
|
||||
|
||||
const unknownFieldUseRes = await rootAgent.resource('flowSurfaces').addField({
|
||||
values: {
|
||||
@@ -5702,11 +6211,10 @@ describe('flowSurfaces resource', () => {
|
||||
},
|
||||
});
|
||||
expect(unknownFieldUseRes.status).toBe(400);
|
||||
expect(readErrorMessage(unknownFieldUseRes)).toContain(`is not allowed under`);
|
||||
expect(readErrorMessage(unknownFieldUseRes)).toContain(`does not accept internal field keys: fieldUse`);
|
||||
|
||||
const explicitFilterField = await addField(rootAgent, filterFormUid, 'nickname', {
|
||||
defaultTargetUid: tableUid,
|
||||
fieldUse: filterNicknameField.fieldUse,
|
||||
});
|
||||
expect(explicitFilterField.fieldUse).toBe(filterNicknameField.fieldUse);
|
||||
|
||||
|
||||
+3
-1
@@ -28,6 +28,7 @@ import {
|
||||
import { FlowSurfaceBadRequestError } from '../errors';
|
||||
import { CREATABLE_STANDALONE_FIELD_USES, FIELD_WRAPPER_USES } from '../node-use-sets';
|
||||
import type { FlowSurfaceApplySpec, FlowSurfaceMutateOp, FlowSurfaceNodeSpec, FlowSurfaceWriteTarget } from '../types';
|
||||
import { getPublicFieldTypeForUse } from '../field-type-resolver';
|
||||
import {
|
||||
didItemRefsChange,
|
||||
emitLayoutOp,
|
||||
@@ -795,6 +796,7 @@ function createFieldNode(
|
||||
_.get(innerField, ['stepParams', 'fieldSettings', 'init']) ||
|
||||
_.get(desiredNode, ['stepParams', 'fieldSettings', 'init']);
|
||||
const defaultTargetUid = _.get(desiredNode, ['stepParams', 'filterFormItemSettings', 'init', 'defaultTargetUid']);
|
||||
const fieldType = getPublicFieldTypeForUse(innerField?.stepParams?.fieldBinding?.use || innerField?.use);
|
||||
if (!fieldInit?.fieldPath) {
|
||||
throw new FlowSurfaceBadRequestError(
|
||||
`flowSurfaces apply field '${desiredNode.use}' requires stepParams.fieldSettings.init.fieldPath`,
|
||||
@@ -815,7 +817,7 @@ function createFieldNode(
|
||||
dataSourceKey: fieldInit.dataSourceKey,
|
||||
collectionName: fieldInit.collectionName,
|
||||
...(requestedRenderer ? { renderer: requestedRenderer } : {}),
|
||||
...(fieldCapability.fieldUse ? { fieldUse: fieldCapability.fieldUse } : {}),
|
||||
...(fieldType ? { fieldType } : {}),
|
||||
...(defaultTargetUid ? { defaultTargetUid } : {}),
|
||||
...(popup ? { popup } : {}),
|
||||
},
|
||||
|
||||
+7
-7
@@ -83,16 +83,16 @@
|
||||
- `ApplyTaskCardDetailsItemModel`
|
||||
- `ApprovalTaskCardDetailsItemModel`
|
||||
|
||||
### Approval relation fieldComponent switching
|
||||
### Approval relation fieldType switching
|
||||
|
||||
- `PatternFormItemModel`
|
||||
- 单值关系字段: `RecordSelectFieldModel` / `RecordPickerFieldModel` / `SubFormFieldModel`
|
||||
- 多值关系字段: `RecordSelectFieldModel` / `RecordPickerFieldModel` / `SubFormListFieldModel` / `PatternSubTableFieldModel`
|
||||
- 单值关系字段: `text` / `picker` / `subForm`
|
||||
- 多值关系字段: `text` / `picker` / `subFormList` / `subTable` / `popupSubTable`
|
||||
- 指向 file collection 时,继续保留推导出的默认 binding use,不公开 nested subform / subtable 切换
|
||||
- `ApprovalDetailsItemModel` / `ApplyTaskCardDetailsItemModel` / `ApprovalTaskCardDetailsItemModel`
|
||||
- 单值关系字段: `DisplayTextFieldModel` / `DisplaySubItemFieldModel`
|
||||
- 多值关系字段: `DisplayTextFieldModel` / `DisplaySubListFieldModel` / `DisplaySubTableFieldModel`
|
||||
- `catalog.node.configureOptions.fieldComponent.enum` 会按当前 wrapper use 与真实字段 interface 动态收窄,`configure(fieldComponent)` 也按同一套 truth source 校验
|
||||
- 单值关系字段: `text` / `subDetails`
|
||||
- 多值关系字段: `text` / `subDetailsList` / `subTable`
|
||||
- `catalog.node.configureOptions.fieldType.enum` 会按当前 wrapper use 与真实字段 interface 动态收窄,`configure(fieldType)` 也按同一套 truth source 校验
|
||||
|
||||
## 关键约束
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
- approval actions 只应出现在各自 block 的 action 容器中。
|
||||
- approval singleton actions 在当前表单已经存在时,不应继续出现在 catalog 中。
|
||||
- `PatternFormItemModel` 下的 inner field 必须持久化为 `PatternFormFieldModel`,并把真实字段 use 记录到 `stepParams.fieldBinding.use`。
|
||||
- approval relation `fieldComponent` 的公开切换范围必须跟随真实字段 interface 与 wrapper use 动态收窄,不能再靠静态全局枚举推断。
|
||||
- approval relation `fieldType` 的公开切换范围必须跟随真实字段 interface 与 wrapper use 动态收窄,不能再靠静态全局枚举推断。
|
||||
- approval details / task-card details 复用 details 语义,但保留自己的 wrapper use,以便 client 侧继续走审批专属的模型逻辑。
|
||||
|
||||
## 对外 API 策略
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
export const SINGLE_VALUE_ASSOCIATION_INTERFACES = new Set(['m2o', 'o2o', 'oho', 'obo', 'updatedBy', 'createdBy']);
|
||||
|
||||
export const MULTI_VALUE_ASSOCIATION_INTERFACES = new Set(['m2m', 'o2m', 'mbm']);
|
||||
+58
-4
@@ -50,6 +50,11 @@ import {
|
||||
readOptionalString,
|
||||
readString,
|
||||
} from './private-utils';
|
||||
import {
|
||||
assertNoInternalFieldKeys,
|
||||
normalizePublicFieldNameList,
|
||||
normalizePublicFieldType,
|
||||
} from '../field-type-resolver';
|
||||
|
||||
type FlowSurfaceCompiledBlocks = {
|
||||
blocks: any[];
|
||||
@@ -76,6 +81,7 @@ const APPLY_BLUEPRINT_BLOCK_TYPE_ENUM = [
|
||||
'chart',
|
||||
'actionPanel',
|
||||
'jsBlock',
|
||||
'tree',
|
||||
] as const;
|
||||
|
||||
const APPLY_BLUEPRINT_BLOCK_ALLOWED_KEYS = [
|
||||
@@ -106,6 +112,14 @@ const APPLY_BLUEPRINT_FIELD_ALLOWED_KEYS = [
|
||||
'associationPathName',
|
||||
'renderer',
|
||||
'type',
|
||||
'fieldType',
|
||||
'fields',
|
||||
'selectorFields',
|
||||
'titleField',
|
||||
'openMode',
|
||||
'popupSize',
|
||||
'pageSize',
|
||||
'showIndex',
|
||||
'label',
|
||||
'target',
|
||||
'settings',
|
||||
@@ -219,6 +233,24 @@ function assertApplyBlueprintKanbanMainContent(block: Record<string, any>, conte
|
||||
}
|
||||
}
|
||||
|
||||
function assertApplyBlueprintTreeMainContent(block: Record<string, any>, context: string) {
|
||||
if (readOptionalString(block.type) !== 'tree') {
|
||||
return;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(block, 'fields')) {
|
||||
throwBadRequest(`${context}.fields is not supported on tree blocks`);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(block, 'fieldGroups')) {
|
||||
throwBadRequest(`${context}.fieldGroups is not supported on tree blocks`);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(block, 'actions')) {
|
||||
throwBadRequest(`${context}.actions is not supported on tree blocks`);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(block, 'recordActions')) {
|
||||
throwBadRequest(`${context}.recordActions is not supported on tree blocks`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertApplyBlueprintBlockType(type: string | undefined, context: string) {
|
||||
if (!type) {
|
||||
return;
|
||||
@@ -974,8 +1006,16 @@ function compileField(
|
||||
throwBadRequest(`${context}[${index}] must be a string or object`);
|
||||
}
|
||||
assertOnlyAllowedKeys(input, `${context}[${index}]`, APPLY_BLUEPRINT_FIELD_ALLOWED_KEYS);
|
||||
assertNoInternalFieldKeys(input, `${context}[${index}]`);
|
||||
assertNoInternalFieldKeys(input.settings, `${context}[${index}].settings`);
|
||||
const fieldPath = readOptionalString(input.field);
|
||||
const syntheticType = readOptionalString(input.type);
|
||||
const fieldType = normalizePublicFieldType((input as any).fieldType, `${context}[${index}]`);
|
||||
const fields = normalizePublicFieldNameList((input as any).fields, `${context}[${index}].fields`);
|
||||
const selectorFields = normalizePublicFieldNameList(
|
||||
(input as any).selectorFields,
|
||||
`${context}[${index}].selectorFields`,
|
||||
);
|
||||
if (!fieldPath && !syntheticType) {
|
||||
throwBadRequest(`${context}[${index}] requires field or type`);
|
||||
}
|
||||
@@ -1003,6 +1043,14 @@ function compileField(
|
||||
associationPathName: readOptionalString(input.associationPathName),
|
||||
renderer: readOptionalString(input.renderer),
|
||||
type: syntheticType,
|
||||
fieldType,
|
||||
fields,
|
||||
selectorFields,
|
||||
titleField: readOptionalString((input as any).titleField),
|
||||
openMode: readOptionalString((input as any).openMode),
|
||||
popupSize: readOptionalString((input as any).popupSize),
|
||||
pageSize: (input as any).pageSize,
|
||||
showIndex: (input as any).showIndex,
|
||||
target: resolveTargetBlockKey(input.target, localBlockKeys, `${context}[${index}].target`),
|
||||
settings: Object.keys(settings).length ? settings : undefined,
|
||||
popup,
|
||||
@@ -1094,6 +1142,7 @@ function compileBlocks(
|
||||
}
|
||||
assertApplyBlueprintCalendarMainContent(block, `${context}[${index}]`);
|
||||
assertApplyBlueprintKanbanMainContent(block, `${context}[${index}]`);
|
||||
assertApplyBlueprintTreeMainContent(block, `${context}[${index}]`);
|
||||
const fields = resolveBlockFieldInputs(block, `${context}[${index}]`);
|
||||
fields.forEach((field: any, fieldIndex: number) => {
|
||||
if (typeof field?.target !== 'string' || !field.target.trim()) {
|
||||
@@ -1115,6 +1164,7 @@ function compileBlocks(
|
||||
assertApplyBlueprintBlockType(readOptionalString(block.type), `${context}[${index}]`);
|
||||
assertApplyBlueprintCalendarMainContent(block, `${context}[${index}]`);
|
||||
assertApplyBlueprintKanbanMainContent(block, `${context}[${index}]`);
|
||||
assertApplyBlueprintTreeMainContent(block, `${context}[${index}]`);
|
||||
const explicitKey = readString(block.key);
|
||||
const fallback = block.type ? `${block.type}_${index + 1}` : `block_${index + 1}`;
|
||||
const localKey = normalizeBlueprintLocalKey(block.key, fallback, `${context}[${index}].key`);
|
||||
@@ -1217,10 +1267,14 @@ function compileBlocks(
|
||||
resource: buildBlockResource(block, blockContext),
|
||||
template,
|
||||
settings: Object.keys(settings).length ? settings : undefined,
|
||||
fields: blockType === 'calendar' ? undefined : fields,
|
||||
fieldsLayout: blockType === 'calendar' || blockType === 'kanban' ? undefined : fieldsLayout,
|
||||
actions: actionsWithDefaultFilter,
|
||||
recordActions: blockType === 'calendar' || blockType === 'kanban' ? undefined : mergedActions.recordActions,
|
||||
fields: blockType === 'calendar' || blockType === 'tree' ? undefined : fields,
|
||||
fieldsLayout:
|
||||
blockType === 'calendar' || blockType === 'kanban' || blockType === 'tree' ? undefined : fieldsLayout,
|
||||
actions: blockType === 'tree' ? undefined : actionsWithDefaultFilter,
|
||||
recordActions:
|
||||
blockType === 'calendar' || blockType === 'kanban' || blockType === 'tree'
|
||||
? undefined
|
||||
: mergedActions.recordActions,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+10
-1
@@ -109,6 +109,14 @@ export type FlowSurfaceApplyBlueprintFieldObjectSpec = {
|
||||
associationPathName?: string;
|
||||
renderer?: string;
|
||||
type?: string;
|
||||
fieldType?: string;
|
||||
fields?: string[];
|
||||
selectorFields?: string[];
|
||||
titleField?: string;
|
||||
openMode?: string;
|
||||
popupSize?: string;
|
||||
pageSize?: number;
|
||||
showIndex?: boolean;
|
||||
label?: string;
|
||||
target?: string;
|
||||
settings?: Record<string, any>;
|
||||
@@ -181,7 +189,8 @@ export type FlowSurfaceApplyBlueprintBlockType =
|
||||
| 'iframe'
|
||||
| 'chart'
|
||||
| 'actionPanel'
|
||||
| 'jsBlock';
|
||||
| 'jsBlock'
|
||||
| 'tree';
|
||||
|
||||
export type FlowSurfaceApplyBlueprintBlockSpec = {
|
||||
key?: string;
|
||||
|
||||
@@ -656,6 +656,7 @@ export function buildFieldTree(params: BuildFieldParams) {
|
||||
},
|
||||
}),
|
||||
),
|
||||
...(fieldDefaults.subModels ? { subModels: _.cloneDeep(fieldDefaults.subModels) } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1089,7 +1090,7 @@ function inferActionDefaultProps(use: string, scope?: FlowSurfaceCatalogItem['sc
|
||||
}
|
||||
|
||||
function applyContainerActionStyle(props: Record<string, any>, containerUse?: string) {
|
||||
if (containerUse === 'TableActionsColumnModel') {
|
||||
if (['TableActionsColumnModel', 'ListItemModel', 'GridCardItemModel'].includes(String(containerUse || '').trim())) {
|
||||
return {
|
||||
...props,
|
||||
type: 'link',
|
||||
@@ -1145,11 +1146,40 @@ function buildBlockDefaults(use: string): FlowSurfaceNodeDefaults {
|
||||
},
|
||||
};
|
||||
}
|
||||
if (use === 'TreeBlockModel') {
|
||||
return {
|
||||
props: {
|
||||
searchable: true,
|
||||
defaultExpandAll: false,
|
||||
includeDescendants: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function getStandaloneFieldDefaults(use: string): FlowSurfaceNodeDefaults {
|
||||
export function getStandaloneFieldDefaults(use: string): FlowSurfaceNodeDefaults {
|
||||
switch (use) {
|
||||
case 'PopupSubTableFieldModel':
|
||||
return {
|
||||
subModels: {
|
||||
subTableColumns: [
|
||||
{
|
||||
use: 'PopupSubTableActionsColumnModel',
|
||||
subModels: {
|
||||
actions: [
|
||||
{
|
||||
use: 'PopupSubTableEditActionModel',
|
||||
},
|
||||
{
|
||||
use: 'PopupSubTableRemoveActionModel',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
case 'JSFieldModel':
|
||||
return {
|
||||
stepParams: {
|
||||
|
||||
+137
-12
@@ -50,13 +50,10 @@ import {
|
||||
TABLE_ROW_ACTION_CONTAINER_USES,
|
||||
} from './action-scope';
|
||||
import { FlowSurfaceBadRequestError, FlowSurfaceInternalError } from './errors';
|
||||
import {
|
||||
MULTI_VALUE_ASSOCIATION_INTERFACES,
|
||||
normalizeFieldContainerKind,
|
||||
shouldUseAssociationTitleTextDisplay,
|
||||
} from './field-semantics';
|
||||
import { normalizeFieldContainerKind, shouldUseAssociationTitleTextDisplay } from './field-semantics';
|
||||
import { inferSharedFieldDefaultBindingUse } from './core-field-default-bindings';
|
||||
import { getRegisteredFieldUses, resolveRegisteredFieldBinding } from './field-binding-registry';
|
||||
import { MULTI_VALUE_ASSOCIATION_INTERFACES, SINGLE_VALUE_ASSOCIATION_INTERFACES } from './association-interfaces';
|
||||
import { getFieldInterface, resolveFieldTargetCollection } from './service-helpers';
|
||||
import { FLOW_SURFACE_BLOCK_SUPPORT_MATRIX } from './support-matrix';
|
||||
|
||||
@@ -334,6 +331,36 @@ const CALENDAR_SETTINGS_GROUP = {
|
||||
'linkageRules.value': ARRAY_SCHEMA,
|
||||
},
|
||||
};
|
||||
const TREE_BLOCK_PROP_SCHEMAS = {
|
||||
searchable: BOOLEAN_SCHEMA,
|
||||
defaultExpandAll: BOOLEAN_SCHEMA,
|
||||
includeDescendants: BOOLEAN_SCHEMA,
|
||||
fieldNames: OBJECT_SCHEMA,
|
||||
pageSize: NUMBER_SCHEMA,
|
||||
};
|
||||
const TREE_SETTINGS_GROUP = {
|
||||
allowedPaths: [
|
||||
'searchable.searchable',
|
||||
'defaultExpandAll.defaultExpandAll',
|
||||
'includeDescendants.includeDescendants',
|
||||
'titleField.titleField',
|
||||
'pageSize.pageSize',
|
||||
'dataScope.filter',
|
||||
'defaultSorting.sort',
|
||||
],
|
||||
clearable: true,
|
||||
mergeStrategy: 'deep' as const,
|
||||
eventBindingSteps: ['treeSettings', 'dataScope', 'defaultSorting'],
|
||||
pathSchemas: {
|
||||
'searchable.searchable': BOOLEAN_SCHEMA,
|
||||
'defaultExpandAll.defaultExpandAll': BOOLEAN_SCHEMA,
|
||||
'includeDescendants.includeDescendants': BOOLEAN_SCHEMA,
|
||||
'titleField.titleField': STRING_SCHEMA,
|
||||
'pageSize.pageSize': NUMBER_SCHEMA,
|
||||
'dataScope.filter': FILTER_GROUP_SCHEMA,
|
||||
'defaultSorting.sort': ARRAY_SCHEMA,
|
||||
},
|
||||
};
|
||||
const KANBAN_SETTINGS_GROUP = {
|
||||
allowedPaths: [
|
||||
'grouping.groupField',
|
||||
@@ -437,6 +464,7 @@ const REGISTERED_FILTER_FIELD_USE_SET = getRegisteredFieldUses('filter');
|
||||
const EDITABLE_FIELD_USE_SET = new Set([
|
||||
...JS_EDITABLE_FIELD_USE_SET,
|
||||
'RecordSelectFieldModel',
|
||||
'RecordPickerFieldModel',
|
||||
'JsonFieldModel',
|
||||
'TextareaFieldModel',
|
||||
'IconFieldModel',
|
||||
@@ -455,10 +483,14 @@ const EDITABLE_FIELD_USE_SET = new Set([
|
||||
'CollectionSelectorFieldModel',
|
||||
'RichTextFieldModel',
|
||||
'InputFieldModel',
|
||||
'SubFormListFieldModel',
|
||||
'SubTableFieldModel',
|
||||
'PopupSubTableFieldModel',
|
||||
]);
|
||||
const DISPLAY_FIELD_USE_SET = new Set([
|
||||
...JS_DISPLAY_FIELD_USE_SET,
|
||||
'DisplaySubItemFieldModel',
|
||||
'DisplaySubListFieldModel',
|
||||
'DisplaySubTableFieldModel',
|
||||
'DisplayHtmlFieldModel',
|
||||
'DisplayNumberFieldModel',
|
||||
@@ -490,7 +522,6 @@ const APPROVAL_DETAILS_FIELD_COMPONENT_WRAPPER_USE_SET = new Set([
|
||||
'ApplyTaskCardDetailsItemModel',
|
||||
'ApprovalTaskCardDetailsItemModel',
|
||||
]);
|
||||
const SINGLE_VALUE_ASSOCIATION_INTERFACES = new Set(['m2o', 'o2o', 'oho', 'obo', 'updatedBy', 'createdBy']);
|
||||
const KNOWN_FIELD_NODE_USES = new Set<string>([
|
||||
...EDITABLE_FIELD_USE_SET,
|
||||
...DISPLAY_FIELD_USE_SET,
|
||||
@@ -985,6 +1016,28 @@ CALENDAR_BLOCK_CONTRACT.domains.stepParams = groupedDomain({
|
||||
cardSettings: BLOCK_CARD_SETTINGS_GROUP,
|
||||
});
|
||||
|
||||
const TREE_BLOCK_CONTRACT = createContract({
|
||||
editableDomains: ['props', 'decoratorProps', 'stepParams', 'flowRegistry'],
|
||||
props: ['searchable', 'defaultExpandAll', 'includeDescendants', 'fieldNames', 'pageSize'],
|
||||
decoratorProps: ['height', 'heightMode'],
|
||||
stepParams: ['resourceSettings', 'treeSettings', 'cardSettings'],
|
||||
flowRegistry: true,
|
||||
eventCapabilities: {
|
||||
direct: DEFAULT_DIRECT_EVENTS,
|
||||
object: ['click'],
|
||||
},
|
||||
});
|
||||
TREE_BLOCK_CONTRACT.domains.props = keyedDomain(
|
||||
['searchable', 'defaultExpandAll', 'includeDescendants', 'fieldNames', 'pageSize'],
|
||||
'deep',
|
||||
TREE_BLOCK_PROP_SCHEMAS,
|
||||
);
|
||||
TREE_BLOCK_CONTRACT.domains.stepParams = groupedDomain({
|
||||
resourceSettings: RESOURCE_SETTINGS_GROUP,
|
||||
treeSettings: TREE_SETTINGS_GROUP,
|
||||
cardSettings: BLOCK_CARD_SETTINGS_GROUP,
|
||||
});
|
||||
|
||||
const KANBAN_BLOCK_CONTRACT = createContract({
|
||||
editableDomains: ['props', 'decoratorProps', 'stepParams', 'flowRegistry'],
|
||||
props: [
|
||||
@@ -2494,6 +2547,7 @@ const NODE_CONTRACT_ENTRIES: Array<[string, FlowSurfaceNodeContract]> = [
|
||||
['ApprovalBlockGridModel', GRID_NODE_CONTRACT],
|
||||
['TableBlockModel', TABLE_BLOCK_CONTRACT],
|
||||
['CalendarBlockModel', CALENDAR_BLOCK_CONTRACT],
|
||||
['TreeBlockModel', TREE_BLOCK_CONTRACT],
|
||||
['KanbanBlockModel', KANBAN_BLOCK_CONTRACT],
|
||||
['CreateFormModel', CREATE_FORM_BLOCK_CONTRACT],
|
||||
['EditFormModel', EDIT_FORM_BLOCK_CONTRACT],
|
||||
@@ -2670,7 +2724,7 @@ function getAllowedFieldUseSet(containerUse?: string, enabledPackages?: Readonly
|
||||
}
|
||||
}
|
||||
|
||||
function canUseNestedApprovalAssociationFieldComponent(input: {
|
||||
function canUseNestedAssociationFieldComponent(input: {
|
||||
field?: any;
|
||||
dataSourceKey?: string;
|
||||
getCollection?: (dataSourceKey: string, collectionName: string) => any;
|
||||
@@ -2696,7 +2750,10 @@ export function getSupportedFieldComponentUseSet(input: {
|
||||
return baseAllowedFieldUses;
|
||||
}
|
||||
|
||||
const wrapperUse = getApprovalFieldWrapperUse(input.containerUse) || String(input.containerUse || '').trim();
|
||||
const wrapperUse =
|
||||
getApprovalFieldWrapperUse(input.containerUse) ||
|
||||
getFieldWrapperUseForContainer(input.containerUse) ||
|
||||
String(input.containerUse || '').trim();
|
||||
const inferredFieldUse = inferFieldUseByContainer(input.containerUse, input.field, {
|
||||
enabledPackages: input.enabledPackages,
|
||||
dataSourceKey: input.dataSourceKey,
|
||||
@@ -2709,7 +2766,7 @@ export function getSupportedFieldComponentUseSet(input: {
|
||||
[
|
||||
'RecordSelectFieldModel',
|
||||
'RecordPickerFieldModel',
|
||||
canUseNestedApprovalAssociationFieldComponent(input) ? 'SubFormFieldModel' : undefined,
|
||||
canUseNestedAssociationFieldComponent(input) ? 'SubFormFieldModel' : undefined,
|
||||
inferredFieldUse,
|
||||
].filter(Boolean),
|
||||
);
|
||||
@@ -2719,8 +2776,34 @@ export function getSupportedFieldComponentUseSet(input: {
|
||||
[
|
||||
'RecordSelectFieldModel',
|
||||
'RecordPickerFieldModel',
|
||||
canUseNestedApprovalAssociationFieldComponent(input) ? 'SubFormListFieldModel' : undefined,
|
||||
canUseNestedApprovalAssociationFieldComponent(input) ? 'PatternSubTableFieldModel' : undefined,
|
||||
canUseNestedAssociationFieldComponent(input) ? 'SubFormListFieldModel' : undefined,
|
||||
canUseNestedAssociationFieldComponent(input) ? 'SubTableFieldModel' : undefined,
|
||||
canUseNestedAssociationFieldComponent(input) ? 'PopupSubTableFieldModel' : undefined,
|
||||
inferredFieldUse,
|
||||
].filter(Boolean),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (wrapperUse === 'FormItemModel') {
|
||||
if (SINGLE_VALUE_ASSOCIATION_INTERFACES.has(fieldInterface)) {
|
||||
return new Set(
|
||||
[
|
||||
'RecordSelectFieldModel',
|
||||
'RecordPickerFieldModel',
|
||||
canUseNestedAssociationFieldComponent(input) ? 'SubFormFieldModel' : undefined,
|
||||
inferredFieldUse,
|
||||
].filter(Boolean),
|
||||
);
|
||||
}
|
||||
if (MULTI_VALUE_ASSOCIATION_INTERFACES.has(fieldInterface)) {
|
||||
return new Set(
|
||||
[
|
||||
'RecordSelectFieldModel',
|
||||
'RecordPickerFieldModel',
|
||||
canUseNestedAssociationFieldComponent(input) ? 'SubFormListFieldModel' : undefined,
|
||||
canUseNestedAssociationFieldComponent(input) ? 'SubTableFieldModel' : undefined,
|
||||
canUseNestedAssociationFieldComponent(input) ? 'PopupSubTableFieldModel' : undefined,
|
||||
inferredFieldUse,
|
||||
].filter(Boolean),
|
||||
);
|
||||
@@ -2740,6 +2823,36 @@ export function getSupportedFieldComponentUseSet(input: {
|
||||
}
|
||||
}
|
||||
|
||||
if (wrapperUse === 'DetailsItemModel') {
|
||||
if (SINGLE_VALUE_ASSOCIATION_INTERFACES.has(fieldInterface)) {
|
||||
return new Set(['DisplayTextFieldModel', 'DisplaySubItemFieldModel', inferredFieldUse].filter(Boolean));
|
||||
}
|
||||
if (MULTI_VALUE_ASSOCIATION_INTERFACES.has(fieldInterface)) {
|
||||
return new Set(
|
||||
['DisplayTextFieldModel', 'DisplaySubListFieldModel', 'DisplaySubTableFieldModel', inferredFieldUse].filter(
|
||||
Boolean,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (wrapperUse === 'FormAssociationItemModel' || wrapperUse === 'TableColumnModel') {
|
||||
if (SINGLE_VALUE_ASSOCIATION_INTERFACES.has(fieldInterface)) {
|
||||
return new Set(['DisplayTextFieldModel', inferredFieldUse].filter(Boolean));
|
||||
}
|
||||
if (MULTI_VALUE_ASSOCIATION_INTERFACES.has(fieldInterface)) {
|
||||
return new Set(
|
||||
['DisplayTextFieldModel', 'DisplaySubListFieldModel', 'DisplaySubTableFieldModel', inferredFieldUse].filter(
|
||||
Boolean,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (inferredFieldUse) {
|
||||
return new Set([inferredFieldUse]);
|
||||
}
|
||||
|
||||
return baseAllowedFieldUses;
|
||||
}
|
||||
|
||||
@@ -2834,6 +2947,7 @@ export function resolveSupportedFieldCapability(input: {
|
||||
containerUse: string;
|
||||
field?: any;
|
||||
requestedFieldUse?: string;
|
||||
requestedFieldUseMode?: 'fieldUse' | 'fieldType';
|
||||
requestedWrapperUse?: string;
|
||||
allowUnresolvedFieldUse?: boolean;
|
||||
requestedRenderer?: string;
|
||||
@@ -2911,6 +3025,7 @@ export function resolveSupportedFieldCapability(input: {
|
||||
input.requestedFieldUse &&
|
||||
inferredFieldUse &&
|
||||
input.requestedFieldUse !== inferredFieldUse &&
|
||||
input.requestedFieldUseMode !== 'fieldType' &&
|
||||
KNOWN_FIELD_NODE_USES.has(input.requestedFieldUse)
|
||||
) {
|
||||
throw new FlowSurfaceBadRequestError(
|
||||
@@ -2918,7 +3033,16 @@ export function resolveSupportedFieldCapability(input: {
|
||||
);
|
||||
}
|
||||
|
||||
const allowedFieldUses = getAllowedFieldUseSet(input.containerUse, input.enabledPackages);
|
||||
const allowedFieldUses =
|
||||
input.requestedFieldUseMode === 'fieldType' && input.field
|
||||
? getSupportedFieldComponentUseSet({
|
||||
containerUse: input.containerUse,
|
||||
field: input.field,
|
||||
enabledPackages: input.enabledPackages,
|
||||
dataSourceKey: input.dataSourceKey,
|
||||
getCollection: input.getCollection,
|
||||
})
|
||||
: getAllowedFieldUseSet(input.containerUse, input.enabledPackages);
|
||||
if (!allowedFieldUses?.has(fieldUse)) {
|
||||
throw new FlowSurfaceBadRequestError(
|
||||
`flowSurfaces fieldUse '${fieldUse}' is not allowed under '${input.containerUse}'`,
|
||||
@@ -3007,6 +3131,7 @@ export function getAvailableActionCatalogItems(
|
||||
const COLLECTION_RESOURCE_REQUIRED = new Set([
|
||||
'TableBlockModel',
|
||||
'CalendarBlockModel',
|
||||
'TreeBlockModel',
|
||||
'KanbanBlockModel',
|
||||
'CreateFormModel',
|
||||
'EditFormModel',
|
||||
|
||||
+16
@@ -34,6 +34,14 @@ export type FlowSurfaceComposeNormalizedFieldSpec = {
|
||||
associationPathName?: string;
|
||||
renderer?: string;
|
||||
type?: string;
|
||||
fieldType?: string;
|
||||
fields?: string[];
|
||||
selectorFields?: string[];
|
||||
titleField?: string;
|
||||
openMode?: string;
|
||||
popupSize?: string;
|
||||
pageSize?: number;
|
||||
showIndex?: boolean;
|
||||
target?: string | Record<string, unknown>;
|
||||
settings?: FlowSurfaceComposeObject;
|
||||
popup?: FlowSurfaceComposeObject;
|
||||
@@ -243,6 +251,14 @@ function buildComposeFieldCreatePayload(fieldSpec: FlowSurfaceComposeNormalizedF
|
||||
...(fieldSpec.associationPathName ? { associationPathName: fieldSpec.associationPathName } : {}),
|
||||
...(fieldSpec.renderer ? { renderer: fieldSpec.renderer } : {}),
|
||||
...(fieldSpec.type ? { type: fieldSpec.type } : {}),
|
||||
...(fieldSpec.fieldType ? { fieldType: fieldSpec.fieldType } : {}),
|
||||
...(typeof fieldSpec.fields !== 'undefined' ? { fields: fieldSpec.fields } : {}),
|
||||
...(typeof fieldSpec.selectorFields !== 'undefined' ? { selectorFields: fieldSpec.selectorFields } : {}),
|
||||
...(fieldSpec.titleField ? { titleField: fieldSpec.titleField } : {}),
|
||||
...(fieldSpec.openMode ? { openMode: fieldSpec.openMode } : {}),
|
||||
...(fieldSpec.popupSize ? { popupSize: fieldSpec.popupSize } : {}),
|
||||
...(typeof fieldSpec.pageSize !== 'undefined' ? { pageSize: fieldSpec.pageSize } : {}),
|
||||
...(typeof fieldSpec.showIndex !== 'undefined' ? { showIndex: fieldSpec.showIndex } : {}),
|
||||
...(fieldSpec.popup ? { popup: fieldSpec.popup } : {}),
|
||||
...(fieldSpec.__autoPopupForRelationField ? { __autoPopupForRelationField: true } : {}),
|
||||
...(fieldSpec[FLOW_SURFACE_APPLY_BLUEPRINT_POPUP_DEFAULTS_KEY]
|
||||
|
||||
+43
-6
@@ -14,6 +14,7 @@ import type {
|
||||
FlowSurfaceContainerKind,
|
||||
} from './types';
|
||||
import { normalizeApprovalSemanticUse } from './approval';
|
||||
import { FLOW_SURFACE_PUBLIC_RELATION_FIELD_TYPES } from './field-type-resolver';
|
||||
|
||||
const stringOption = (
|
||||
description?: string,
|
||||
@@ -96,10 +97,29 @@ const DISPLAY_STYLE = stringOption('Display style', {
|
||||
example: 'tag',
|
||||
});
|
||||
|
||||
const FIELD_COMPONENT = stringOption('Field component model use', {
|
||||
example: 'DisplayTextFieldModel',
|
||||
const FIELD_TYPE = stringOption('Public relation field presentation type', {
|
||||
enum: [...FLOW_SURFACE_PUBLIC_RELATION_FIELD_TYPES],
|
||||
example: 'popupSubTable',
|
||||
});
|
||||
|
||||
const RELATION_FIELDS = arrayOption('Relation target fields', {
|
||||
example: ['title', 'name'],
|
||||
});
|
||||
|
||||
const SELECTOR_FIELDS = arrayOption('Record picker selector fields', {
|
||||
example: ['title', 'code'],
|
||||
});
|
||||
|
||||
const RELATION_FIELD_TYPE_OPTIONS: FlowSurfaceConfigureOptions = {
|
||||
fieldType: FIELD_TYPE,
|
||||
fields: RELATION_FIELDS,
|
||||
selectorFields: SELECTOR_FIELDS,
|
||||
openMode: stringOption('Popup open mode', { example: 'drawer' }),
|
||||
popupSize: stringOption('Popup size', { example: 'medium' }),
|
||||
pageSize: numberOption('Page size', { example: 10 }),
|
||||
showIndex: booleanOption('Whether to show index', { example: true }),
|
||||
};
|
||||
|
||||
const CONFIRM = objectOption('Confirmation dialog configuration. You can also pass a boolean directly.', {
|
||||
example: {
|
||||
enable: true,
|
||||
@@ -241,6 +261,20 @@ const CALENDAR_OPTIONS: FlowSurfaceConfigureOptions = {
|
||||
eventPopup: OPEN_VIEW,
|
||||
};
|
||||
|
||||
const TREE_OPTIONS: FlowSurfaceConfigureOptions = {
|
||||
...COMMON_BLOCK_HEADER_OPTIONS,
|
||||
...COMMON_HEIGHT_OPTIONS,
|
||||
resource: COMMON_RESOURCE,
|
||||
searchable: booleanOption('Whether search is enabled', { example: true }),
|
||||
defaultExpandAll: booleanOption('Whether all tree nodes are expanded by default', { example: false }),
|
||||
includeDescendants: booleanOption('Whether child nodes are included when filtering', { example: true }),
|
||||
titleField: stringOption('Tree node title field', { example: 'title' }),
|
||||
fieldNames: objectOption('Tree field names', { example: { title: 'title', key: 'id', children: 'children' } }),
|
||||
pageSize: numberOption('Root records per page', { example: 200 }),
|
||||
dataScope: FILTER_GROUP,
|
||||
sorting: SORTING,
|
||||
};
|
||||
|
||||
const KANBAN_OPTIONS: FlowSurfaceConfigureOptions = {
|
||||
...COMMON_BLOCK_HEADER_OPTIONS,
|
||||
...COMMON_HEIGHT_OPTIONS,
|
||||
@@ -418,7 +452,7 @@ const TABLE_FIELD_WRAPPER_OPTIONS: FlowSurfaceConfigureOptions = {
|
||||
editable: booleanOption('Whether editable', { example: false }),
|
||||
dataIndex: stringOption('Data index'),
|
||||
titleField: stringOption('Association display title field', { example: 'title' }),
|
||||
fieldComponent: FIELD_COMPONENT,
|
||||
...RELATION_FIELD_TYPE_OPTIONS,
|
||||
clickToOpen: booleanOption('Whether clicking can open details', { example: true }),
|
||||
openView: OPEN_VIEW,
|
||||
code: JS_CODE,
|
||||
@@ -435,7 +469,7 @@ const DETAILS_FIELD_WRAPPER_OPTIONS: FlowSurfaceConfigureOptions = {
|
||||
disabled: booleanOption('Whether disabled', { example: false }),
|
||||
pattern: stringOption('Display mode'),
|
||||
titleField: stringOption('Association display title field', { example: 'title' }),
|
||||
fieldComponent: FIELD_COMPONENT,
|
||||
...RELATION_FIELD_TYPE_OPTIONS,
|
||||
labelWidth: stringOption('Label width', { example: '120px' }),
|
||||
labelWrap: booleanOption('Whether labels should wrap', { example: false }),
|
||||
clickToOpen: booleanOption('Whether clicking can open details', { example: true }),
|
||||
@@ -456,7 +490,7 @@ const FILTER_FIELD_WRAPPER_OPTIONS: FlowSurfaceConfigureOptions = {
|
||||
allowMultiple: booleanOption('Whether multiple selection is allowed', { example: false }),
|
||||
maxCount: numberOption('Maximum count', { example: 5 }),
|
||||
name: stringOption('Field name override'),
|
||||
fieldComponent: FIELD_COMPONENT,
|
||||
...RELATION_FIELD_TYPE_OPTIONS,
|
||||
labelWidth: stringOption('Label width', { example: '120px' }),
|
||||
labelWrap: booleanOption('Whether labels should wrap', { example: false }),
|
||||
clickToOpen: booleanOption('Whether clicking can open details', { example: false }),
|
||||
@@ -481,7 +515,7 @@ const FORM_FIELD_WRAPPER_OPTIONS: FlowSurfaceConfigureOptions = {
|
||||
pattern: stringOption('Input mode'),
|
||||
titleField: stringOption('Association display title field', { example: 'title' }),
|
||||
name: stringOption('Field name override'),
|
||||
fieldComponent: FIELD_COMPONENT,
|
||||
...RELATION_FIELD_TYPE_OPTIONS,
|
||||
labelWidth: stringOption('Label width', { example: '120px' }),
|
||||
labelWrap: booleanOption('Whether labels should wrap', { example: false }),
|
||||
clickToOpen: booleanOption('Whether clicking can open details', { example: false }),
|
||||
@@ -810,6 +844,9 @@ export function getConfigureOptionsForUse(use?: string): FlowSurfaceConfigureOpt
|
||||
case 'CalendarBlockModel':
|
||||
options = cloneOptions(CALENDAR_OPTIONS);
|
||||
break;
|
||||
case 'TreeBlockModel':
|
||||
options = cloneOptions(TREE_OPTIONS);
|
||||
break;
|
||||
case 'KanbanBlockModel':
|
||||
options = cloneOptions(KANBAN_OPTIONS);
|
||||
break;
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import _ from 'lodash';
|
||||
import { MULTI_VALUE_ASSOCIATION_INTERFACES } from './field-semantics';
|
||||
import { MULTI_VALUE_ASSOCIATION_INTERFACES } from './association-interfaces';
|
||||
import { getFieldInterface, getFieldName, getFieldType } from './service-helpers';
|
||||
|
||||
export type FlowSurfaceDefaultActionPopupType = 'addNew' | 'view' | 'edit';
|
||||
|
||||
+3
@@ -7,9 +7,12 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { MULTI_VALUE_ASSOCIATION_INTERFACES, SINGLE_VALUE_ASSOCIATION_INTERFACES } from './association-interfaces';
|
||||
import { normalizeFieldContainerKind } from './field-semantics';
|
||||
import { getFieldInterface, resolveFieldTargetCollection } from './service-helpers';
|
||||
|
||||
export { MULTI_VALUE_ASSOCIATION_INTERFACES, SINGLE_VALUE_ASSOCIATION_INTERFACES } from './association-interfaces';
|
||||
|
||||
export type FlowSurfaceFieldBindingScope = 'display' | 'editable' | 'filter';
|
||||
|
||||
type FlowSurfaceFieldBindingRule = {
|
||||
|
||||
+3
-2
@@ -8,6 +8,9 @@
|
||||
*/
|
||||
|
||||
import { normalizeApprovalSemanticUse } from './approval';
|
||||
import { MULTI_VALUE_ASSOCIATION_INTERFACES } from './association-interfaces';
|
||||
|
||||
export { MULTI_VALUE_ASSOCIATION_INTERFACES } from './association-interfaces';
|
||||
|
||||
const FORM_FIELD_CONTAINER_USES = new Set([
|
||||
'FormBlockModel',
|
||||
@@ -35,8 +38,6 @@ const FILTER_FIELD_CONTAINER_USES = new Set(['FilterFormBlockModel', 'FilterForm
|
||||
|
||||
const TABLE_FIELD_CONTAINER_USES = new Set(['TableBlockModel', 'TableColumnModel']);
|
||||
|
||||
export const MULTI_VALUE_ASSOCIATION_INTERFACES = new Set(['m2m', 'o2m', 'mbm']);
|
||||
|
||||
export function normalizeFieldContainerKind(containerUse?: string) {
|
||||
const normalized = normalizeApprovalSemanticUse(containerUse);
|
||||
if (FORM_FIELD_CONTAINER_USES.has(normalized)) {
|
||||
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import _ from 'lodash';
|
||||
import { FlowSurfaceBadRequestError, throwBadRequest } from './errors';
|
||||
import { SINGLE_VALUE_ASSOCIATION_INTERFACES, MULTI_VALUE_ASSOCIATION_INTERFACES } from './association-interfaces';
|
||||
import { normalizeFieldContainerKind } from './field-semantics';
|
||||
import {
|
||||
getCollectionFields,
|
||||
getFieldInterface,
|
||||
getFieldName,
|
||||
isAssociationField,
|
||||
resolveFieldFromCollection,
|
||||
resolveFieldTargetCollection,
|
||||
} from './service-helpers';
|
||||
import { resolveCollectionSafeTitleField } from './association-title-field';
|
||||
|
||||
export const FLOW_SURFACE_PUBLIC_RELATION_FIELD_TYPES = [
|
||||
'text',
|
||||
'select',
|
||||
'picker',
|
||||
'subForm',
|
||||
'subFormList',
|
||||
'subDetails',
|
||||
'subDetailsList',
|
||||
'subTable',
|
||||
'popupSubTable',
|
||||
] as const;
|
||||
|
||||
export type FlowSurfacePublicRelationFieldType = (typeof FLOW_SURFACE_PUBLIC_RELATION_FIELD_TYPES)[number];
|
||||
|
||||
export const FLOW_SURFACE_RESERVED_RELATION_FIELD_TYPES = ['cascade', 'upload', 'preview'] as const;
|
||||
|
||||
export const FLOW_SURFACE_PUBLIC_FIELD_TYPE_SET = new Set<string>([
|
||||
...FLOW_SURFACE_PUBLIC_RELATION_FIELD_TYPES,
|
||||
...FLOW_SURFACE_RESERVED_RELATION_FIELD_TYPES,
|
||||
]);
|
||||
|
||||
export const FLOW_SURFACE_INTERNAL_FIELD_KEYS = [
|
||||
'fieldComponent',
|
||||
'fieldModel',
|
||||
'componentFields',
|
||||
'use',
|
||||
'fieldUse',
|
||||
'subModels',
|
||||
'props',
|
||||
'stepParams',
|
||||
];
|
||||
|
||||
const FIELD_USE_TO_PUBLIC_FIELD_TYPE: Record<string, FlowSurfacePublicRelationFieldType> = {
|
||||
DisplayTextFieldModel: 'text',
|
||||
RecordSelectFieldModel: 'select',
|
||||
RecordPickerFieldModel: 'picker',
|
||||
SubFormFieldModel: 'subForm',
|
||||
SubFormListFieldModel: 'subFormList',
|
||||
DisplaySubItemFieldModel: 'subDetails',
|
||||
DisplaySubListFieldModel: 'subDetailsList',
|
||||
DisplaySubTableFieldModel: 'subTable',
|
||||
SubTableFieldModel: 'subTable',
|
||||
PopupSubTableFieldModel: 'popupSubTable',
|
||||
};
|
||||
|
||||
const FIELD_TYPES_WITH_FIELDS = new Set<FlowSurfacePublicRelationFieldType>([
|
||||
'subForm',
|
||||
'subFormList',
|
||||
'subDetails',
|
||||
'subDetailsList',
|
||||
'subTable',
|
||||
'popupSubTable',
|
||||
]);
|
||||
|
||||
const FIELD_TYPES_WITH_SELECTOR_FIELDS = new Set<FlowSurfacePublicRelationFieldType>(['picker']);
|
||||
|
||||
const FIELD_TYPES_WITH_TABLE_PROPS = new Set<FlowSurfacePublicRelationFieldType>(['subTable', 'popupSubTable']);
|
||||
|
||||
export function assertNoInternalFieldKeys(input: Record<string, any> | undefined, context: string) {
|
||||
if (!_.isPlainObject(input)) {
|
||||
return;
|
||||
}
|
||||
const forbidden = FLOW_SURFACE_INTERNAL_FIELD_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(input, key));
|
||||
if (forbidden.length) {
|
||||
throwBadRequest(`${context} does not accept internal field keys: ${forbidden.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePublicFieldType(value: any, context: string): FlowSurfacePublicRelationFieldType | undefined {
|
||||
if (_.isUndefined(value) || _.isNull(value) || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (FLOW_SURFACE_RESERVED_RELATION_FIELD_TYPES.includes(normalized as any)) {
|
||||
throwBadRequest(`flowSurfaces ${context} fieldType '${normalized}' is reserved for plugin-specific field types`);
|
||||
}
|
||||
if (!FLOW_SURFACE_PUBLIC_RELATION_FIELD_TYPES.includes(normalized as any)) {
|
||||
throwBadRequest(
|
||||
`flowSurfaces ${context} fieldType '${normalized}' is not supported; supported fieldTypes: ${FLOW_SURFACE_PUBLIC_RELATION_FIELD_TYPES.join(
|
||||
', ',
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
return normalized as FlowSurfacePublicRelationFieldType;
|
||||
}
|
||||
|
||||
export function normalizePublicFieldNameList(value: any, context: string): string[] | undefined {
|
||||
if (_.isUndefined(value)) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
throwBadRequest(`flowSurfaces ${context} must be an array of field names`);
|
||||
}
|
||||
return value.map((item, index) => {
|
||||
const normalized = String(item || '').trim();
|
||||
if (!normalized) {
|
||||
throwBadRequest(`flowSurfaces ${context}[${index + 1}] cannot be empty`);
|
||||
}
|
||||
return normalized;
|
||||
});
|
||||
}
|
||||
|
||||
export function getPublicFieldTypeForUse(use?: string): FlowSurfacePublicRelationFieldType | undefined {
|
||||
return FIELD_USE_TO_PUBLIC_FIELD_TYPE[String(use || '').trim()];
|
||||
}
|
||||
|
||||
function getAssociationCardinality(field: any): 'single' | 'multi' | null {
|
||||
const fieldInterface = String(getFieldInterface(field) || '').trim();
|
||||
if (SINGLE_VALUE_ASSOCIATION_INTERFACES.has(fieldInterface)) {
|
||||
return 'single';
|
||||
}
|
||||
if (MULTI_VALUE_ASSOCIATION_INTERFACES.has(fieldInterface)) {
|
||||
return 'multi';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickCollectionFallbackFieldName(collection: any) {
|
||||
const safeTitleFieldName = resolveCollectionSafeTitleField(collection)?.fieldName;
|
||||
if (safeTitleFieldName && resolveFieldFromCollection(collection, safeTitleFieldName)) {
|
||||
return safeTitleFieldName;
|
||||
}
|
||||
const primaryField = getCollectionFields(collection).find(
|
||||
(field) => !!field?.primaryKey || !!field?.options?.primaryKey,
|
||||
);
|
||||
return getFieldName(primaryField) || 'id';
|
||||
}
|
||||
|
||||
function assertTargetFieldNamesExist(targetCollection: any, fields: string[], context: string) {
|
||||
fields.forEach((fieldName) => {
|
||||
if (!resolveFieldFromCollection(targetCollection, fieldName)) {
|
||||
throwBadRequest(`flowSurfaces ${context} field '${fieldName}' does not exist on relation target collection`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveRelationFieldType(input: {
|
||||
fieldType: any;
|
||||
containerUse: string;
|
||||
field: any;
|
||||
dataSourceKey: string;
|
||||
getCollection: (dataSourceKey: string, collectionName: string) => any;
|
||||
fields?: any;
|
||||
selectorFields?: any;
|
||||
titleField?: any;
|
||||
openMode?: any;
|
||||
popupSize?: any;
|
||||
pageSize?: any;
|
||||
showIndex?: any;
|
||||
applyDefaults?: boolean;
|
||||
context: string;
|
||||
}) {
|
||||
const fieldType = normalizePublicFieldType(input.fieldType, input.context);
|
||||
if (!fieldType) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isAssociationField(input.field)) {
|
||||
throw new FlowSurfaceBadRequestError('flowSurfaces fieldType is only supported for relation fields');
|
||||
}
|
||||
|
||||
const targetCollection = resolveFieldTargetCollection(
|
||||
input.field,
|
||||
input.dataSourceKey || 'main',
|
||||
input.getCollection,
|
||||
);
|
||||
if (!targetCollection) {
|
||||
throwBadRequest(`flowSurfaces ${input.context} fieldType '${fieldType}' cannot resolve relation target collection`);
|
||||
}
|
||||
|
||||
const cardinality = getAssociationCardinality(input.field);
|
||||
if (!cardinality) {
|
||||
throwBadRequest(`flowSurfaces ${input.context} fieldType '${fieldType}' is not supported for this relation field`);
|
||||
}
|
||||
|
||||
const containerKind = normalizeFieldContainerKind(input.containerUse);
|
||||
let fieldUse: string | undefined;
|
||||
switch (fieldType) {
|
||||
case 'text':
|
||||
fieldUse = containerKind === 'form' ? 'RecordSelectFieldModel' : 'DisplayTextFieldModel';
|
||||
break;
|
||||
case 'select':
|
||||
fieldUse = 'RecordSelectFieldModel';
|
||||
break;
|
||||
case 'picker':
|
||||
fieldUse = 'RecordPickerFieldModel';
|
||||
break;
|
||||
case 'subForm':
|
||||
if (cardinality !== 'single') {
|
||||
throwBadRequest(`flowSurfaces ${input.context} fieldType 'subForm' only supports single relation fields`);
|
||||
}
|
||||
fieldUse = 'SubFormFieldModel';
|
||||
break;
|
||||
case 'subFormList':
|
||||
if (cardinality !== 'multi') {
|
||||
throwBadRequest(`flowSurfaces ${input.context} fieldType 'subFormList' only supports multi relation fields`);
|
||||
}
|
||||
fieldUse = 'SubFormListFieldModel';
|
||||
break;
|
||||
case 'subDetails':
|
||||
if (cardinality !== 'single') {
|
||||
throwBadRequest(`flowSurfaces ${input.context} fieldType 'subDetails' only supports single relation fields`);
|
||||
}
|
||||
fieldUse = 'DisplaySubItemFieldModel';
|
||||
break;
|
||||
case 'subDetailsList':
|
||||
if (cardinality !== 'multi') {
|
||||
throwBadRequest(`flowSurfaces ${input.context} fieldType 'subDetailsList' only supports multi relation fields`);
|
||||
}
|
||||
fieldUse = 'DisplaySubListFieldModel';
|
||||
break;
|
||||
case 'subTable':
|
||||
if (cardinality !== 'multi') {
|
||||
throwBadRequest(`flowSurfaces ${input.context} fieldType 'subTable' only supports multi relation fields`);
|
||||
}
|
||||
fieldUse = containerKind === 'form' ? 'SubTableFieldModel' : 'DisplaySubTableFieldModel';
|
||||
break;
|
||||
case 'popupSubTable':
|
||||
if (cardinality !== 'multi') {
|
||||
throwBadRequest(`flowSurfaces ${input.context} fieldType 'popupSubTable' only supports multi relation fields`);
|
||||
}
|
||||
fieldUse = 'PopupSubTableFieldModel';
|
||||
break;
|
||||
}
|
||||
|
||||
const explicitFields = normalizePublicFieldNameList(input.fields, `${input.context}.fields`);
|
||||
const explicitSelectorFields = normalizePublicFieldNameList(input.selectorFields, `${input.context}.selectorFields`);
|
||||
if (explicitFields && explicitSelectorFields) {
|
||||
throwBadRequest(`flowSurfaces ${input.context} cannot mix fields and selectorFields on the same field`);
|
||||
}
|
||||
if (explicitFields && !FIELD_TYPES_WITH_FIELDS.has(fieldType)) {
|
||||
throwBadRequest(`flowSurfaces ${input.context} fieldType '${fieldType}' does not support fields`);
|
||||
}
|
||||
if (explicitSelectorFields && !FIELD_TYPES_WITH_SELECTOR_FIELDS.has(fieldType)) {
|
||||
throwBadRequest(`flowSurfaces ${input.context} fieldType '${fieldType}' does not support selectorFields`);
|
||||
}
|
||||
if ((!_.isUndefined(input.openMode) || !_.isUndefined(input.popupSize)) && fieldType !== 'picker') {
|
||||
throwBadRequest(`flowSurfaces ${input.context} fieldType '${fieldType}' does not support openMode or popupSize`);
|
||||
}
|
||||
if (
|
||||
(!_.isUndefined(input.pageSize) || !_.isUndefined(input.showIndex)) &&
|
||||
!FIELD_TYPES_WITH_TABLE_PROPS.has(fieldType)
|
||||
) {
|
||||
throwBadRequest(`flowSurfaces ${input.context} fieldType '${fieldType}' does not support pageSize or showIndex`);
|
||||
}
|
||||
const shouldApplyDefaults = input.applyDefaults !== false;
|
||||
const defaultTargetField = shouldApplyDefaults ? pickCollectionFallbackFieldName(targetCollection) : undefined;
|
||||
const fields =
|
||||
explicitFields ?? (shouldApplyDefaults && usesNestedRelationFields(fieldUse) ? [defaultTargetField] : undefined);
|
||||
const selectorFields =
|
||||
explicitSelectorFields ??
|
||||
(shouldApplyDefaults && fieldType === 'picker' && _.isUndefined(input.selectorFields)
|
||||
? [defaultTargetField]
|
||||
: undefined);
|
||||
const titleField = _.isUndefined(input.titleField)
|
||||
? defaultTargetField
|
||||
: String(input.titleField || '').trim() || undefined;
|
||||
|
||||
if (fields?.length) {
|
||||
assertTargetFieldNamesExist(targetCollection, fields, `${input.context}.fields`);
|
||||
}
|
||||
if (selectorFields?.length) {
|
||||
assertTargetFieldNamesExist(targetCollection, selectorFields, `${input.context}.selectorFields`);
|
||||
}
|
||||
if (titleField && !resolveFieldFromCollection(targetCollection, titleField)) {
|
||||
throwBadRequest(
|
||||
`flowSurfaces ${input.context}.titleField '${titleField}' does not exist on relation target collection`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
fieldType,
|
||||
fieldUse,
|
||||
cardinality,
|
||||
targetCollection,
|
||||
fields,
|
||||
selectorFields,
|
||||
titleField,
|
||||
openMode: input.openMode,
|
||||
popupSize: input.popupSize,
|
||||
pageSize: input.pageSize,
|
||||
showIndex: input.showIndex,
|
||||
};
|
||||
}
|
||||
|
||||
export function usesNestedRelationFields(fieldUse?: string) {
|
||||
return [
|
||||
'SubTableFieldModel',
|
||||
'PopupSubTableFieldModel',
|
||||
'DisplaySubTableFieldModel',
|
||||
'SubFormFieldModel',
|
||||
'SubFormListFieldModel',
|
||||
'DisplaySubItemFieldModel',
|
||||
'DisplaySubListFieldModel',
|
||||
].includes(String(fieldUse || '').trim());
|
||||
}
|
||||
+1
@@ -12,6 +12,7 @@ import { APPROVAL_DETAILS_BLOCK_USES, APPROVAL_FIELD_WRAPPER_USES, APPROVAL_FORM
|
||||
export const COLLECTION_BLOCK_USES = new Set([
|
||||
'TableBlockModel',
|
||||
'CalendarBlockModel',
|
||||
'TreeBlockModel',
|
||||
'KanbanBlockModel',
|
||||
'CreateFormModel',
|
||||
'EditFormModel',
|
||||
|
||||
+68
-6
@@ -25,6 +25,12 @@ import {
|
||||
import { getFieldInterface } from './service-helpers';
|
||||
import type { FlowSurfaceNodeSpec, FlowSurfaceNodeSubModel } from './types';
|
||||
import { FLOW_SURFACE_RESERVED_KEYS } from './planning/key-registry';
|
||||
import {
|
||||
assertNoInternalFieldKeys,
|
||||
normalizePublicFieldNameList,
|
||||
normalizePublicFieldType,
|
||||
type FlowSurfacePublicRelationFieldType,
|
||||
} from './field-type-resolver';
|
||||
|
||||
export function buildDefinedPayload(payload: Record<string, any>) {
|
||||
return _.pickBy(payload, (value) => !_.isUndefined(value));
|
||||
@@ -301,7 +307,18 @@ export function hasDefinedValue(input: Record<string, any>, keys: string[]) {
|
||||
}
|
||||
|
||||
export function ensureNoRawSimpleChangeKeys(changes: Record<string, any>) {
|
||||
const rawKeys = ['props', 'decoratorProps', 'stepParams', 'flowRegistry', 'use', 'fieldUse'];
|
||||
const rawKeys = [
|
||||
'props',
|
||||
'decoratorProps',
|
||||
'stepParams',
|
||||
'flowRegistry',
|
||||
'use',
|
||||
'fieldUse',
|
||||
'fieldComponent',
|
||||
'fieldModel',
|
||||
'componentFields',
|
||||
'subModels',
|
||||
];
|
||||
const forbidden = Object.keys(changes).filter((key) => rawKeys.includes(key));
|
||||
if (forbidden.length) {
|
||||
throwBadRequest(
|
||||
@@ -340,6 +357,14 @@ export type NormalizedComposeFieldSpec = {
|
||||
associationPathName?: string;
|
||||
renderer?: string;
|
||||
type?: string;
|
||||
fieldType?: FlowSurfacePublicRelationFieldType;
|
||||
fields?: string[];
|
||||
selectorFields?: string[];
|
||||
titleField?: string;
|
||||
openMode?: string;
|
||||
popupSize?: string;
|
||||
pageSize?: number;
|
||||
showIndex?: boolean;
|
||||
target?: string;
|
||||
settings: Record<string, any>;
|
||||
popup?: Record<string, any>;
|
||||
@@ -365,12 +390,22 @@ export function normalizeComposeFieldSpec(input: any, index: number): Normalized
|
||||
if (!_.isPlainObject(input)) {
|
||||
throwBadRequest(`flowSurfaces compose field #${index + 1} must be a string or object`);
|
||||
}
|
||||
if (input.use || input.fieldUse || input.stepParams || input.props || input.decoratorProps) {
|
||||
throwBadRequest('flowSurfaces compose field only accepts public semantic field fields');
|
||||
}
|
||||
assertNoInternalFieldKeys(input, `flowSurfaces compose field #${index + 1}`);
|
||||
assertNoInternalFieldKeys(input.settings, `flowSurfaces compose field #${index + 1}.settings`);
|
||||
const semanticType = String(input.type || '').trim() || undefined;
|
||||
const fieldPath = String(input.fieldPath || '').trim();
|
||||
const renderer = typeof input.renderer === 'undefined' ? undefined : String(input.renderer || '').trim();
|
||||
const fieldType = normalizePublicFieldType(input.fieldType, `compose field #${index + 1}`);
|
||||
const fields = normalizePublicFieldNameList(input.fields, `compose field #${index + 1}.fields`);
|
||||
const selectorFields = normalizePublicFieldNameList(
|
||||
input.selectorFields,
|
||||
`compose field #${index + 1}.selectorFields`,
|
||||
);
|
||||
const titleField = typeof input.titleField === 'undefined' ? undefined : String(input.titleField || '').trim();
|
||||
const openMode = typeof input.openMode === 'undefined' ? undefined : String(input.openMode || '').trim();
|
||||
const popupSize = typeof input.popupSize === 'undefined' ? undefined : String(input.popupSize || '').trim();
|
||||
const pageSize = typeof input.pageSize === 'undefined' ? undefined : Number(input.pageSize);
|
||||
const showIndex = typeof input.showIndex === 'undefined' ? undefined : !!input.showIndex;
|
||||
if (!_.isUndefined(input.popup) && !_.isPlainObject(input.popup)) {
|
||||
throwBadRequest(`flowSurfaces compose field #${index + 1} popup must be an object`);
|
||||
}
|
||||
@@ -397,6 +432,14 @@ export function normalizeComposeFieldSpec(input: any, index: number): Normalized
|
||||
associationPathName: String(input.associationPathName || '').trim() || undefined,
|
||||
...(renderer ? { renderer } : {}),
|
||||
...(semanticType ? { type: semanticType } : {}),
|
||||
...(fieldType ? { fieldType } : {}),
|
||||
...(!_.isUndefined(fields) ? { fields } : {}),
|
||||
...(!_.isUndefined(selectorFields) ? { selectorFields } : {}),
|
||||
...(titleField ? { titleField } : {}),
|
||||
...(openMode ? { openMode } : {}),
|
||||
...(popupSize ? { popupSize } : {}),
|
||||
...(!_.isUndefined(pageSize) && Number.isFinite(pageSize) ? { pageSize } : {}),
|
||||
...(!_.isUndefined(showIndex) ? { showIndex } : {}),
|
||||
target: typeof input.target === 'string' ? String(input.target || '').trim() || undefined : undefined,
|
||||
settings: _.isPlainObject(input.settings) ? input.settings : {},
|
||||
popup: _.isPlainObject(input.popup) ? input.popup : undefined,
|
||||
@@ -405,6 +448,19 @@ export function normalizeComposeFieldSpec(input: any, index: number): Normalized
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveRequestedFieldComponent(values: Record<string, any> | undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveRequestedFieldUse(values: Record<string, any> | undefined) {
|
||||
return resolveRequestedFieldComponent(values) || resolveRequestedFieldUseAlias(values);
|
||||
}
|
||||
|
||||
export function resolveRequestedFieldUseAlias(values: Record<string, any> | undefined) {
|
||||
const legacyFieldUse = String(values?.fieldUse || '').trim();
|
||||
return legacyFieldUse || undefined;
|
||||
}
|
||||
|
||||
export function normalizeComposeActionSpec(input: any, index: number) {
|
||||
if (typeof input === 'string') {
|
||||
const type = String(input || '').trim();
|
||||
@@ -628,13 +684,19 @@ export function splitComposeFieldChanges(changes: Record<string, any>, wrapperUs
|
||||
'disabled',
|
||||
'maxCount',
|
||||
'pattern',
|
||||
'titleField',
|
||||
'dataIndex',
|
||||
'editable',
|
||||
'labelWidth',
|
||||
'labelWrap',
|
||||
'name',
|
||||
'fieldComponent',
|
||||
'fieldType',
|
||||
'fields',
|
||||
'selectorFields',
|
||||
'titleField',
|
||||
'openMode',
|
||||
'popupSize',
|
||||
'pageSize',
|
||||
'showIndex',
|
||||
...(wrapperUse === 'TableColumnModel' ? ['title'] : []),
|
||||
]);
|
||||
const fieldChanges = _.pick(changes, [
|
||||
|
||||
+950
-63
File diff suppressed because it is too large
Load Diff
+11
@@ -246,6 +246,17 @@ export const FLOW_SURFACE_BLOCK_SUPPORT_MATRIX: FlowSurfaceBlockSupportEntry[] =
|
||||
readbackSupported: true,
|
||||
createSupported: true,
|
||||
},
|
||||
{
|
||||
key: 'tree',
|
||||
label: 'Tree',
|
||||
modelUse: 'TreeBlockModel',
|
||||
ownerPlugin: '@nocobase/plugin-block-tree',
|
||||
topLevelAddable: true,
|
||||
formalBuiltin: false,
|
||||
fixtureCaptured: false,
|
||||
readbackSupported: true,
|
||||
createSupported: true,
|
||||
},
|
||||
];
|
||||
|
||||
function hasFormalFlowSurfaceBlockKey(
|
||||
|
||||
+22
-11
@@ -1488,17 +1488,29 @@ export const flowSurfaceExamples = {
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'teamNotes',
|
||||
type: 'markdown',
|
||||
settings: {
|
||||
content: '# Team notes',
|
||||
key: 'userForm',
|
||||
type: 'createForm',
|
||||
resourceInit: {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'users',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
key: 'rolesField',
|
||||
fieldPath: 'roles',
|
||||
fieldType: 'popupSubTable',
|
||||
fields: ['title', 'name'],
|
||||
},
|
||||
],
|
||||
fieldsLayout: {
|
||||
rows: [['rolesField']],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
addFields: {
|
||||
target: {
|
||||
uid: 'table-block-uid',
|
||||
uid: 'create-form-block-uid',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
@@ -1523,13 +1535,12 @@ export const flowSurfaceExamples = {
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'nickname',
|
||||
fieldPath: 'nickname',
|
||||
renderer: 'js',
|
||||
key: 'rolesField',
|
||||
fieldPath: 'roles',
|
||||
fieldType: 'popupSubTable',
|
||||
fields: ['title', 'name'],
|
||||
settings: {
|
||||
label: 'Nickname (JS)',
|
||||
code: 'return value;',
|
||||
version: '1.0.0',
|
||||
title: 'Roles',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -122,9 +122,36 @@ const APPLY_BLUEPRINT_BLOCK_TYPE_ENUM = [
|
||||
'chart',
|
||||
'actionPanel',
|
||||
'jsBlock',
|
||||
'tree',
|
||||
];
|
||||
const APPROVAL_BLUEPRINT_BLOCK_TYPE_ENUM = [...APPROVAL_BLOCK_PUBLIC_KEYS];
|
||||
const COMPOSE_BLOCK_TYPE_ENUM = [...APPLY_BLUEPRINT_BLOCK_TYPE_ENUM, ...APPROVAL_BLOCK_PUBLIC_KEYS];
|
||||
const RELATION_FIELD_TYPE_ENUM = [
|
||||
'text',
|
||||
'select',
|
||||
'picker',
|
||||
'subForm',
|
||||
'subFormList',
|
||||
'subDetails',
|
||||
'subDetailsList',
|
||||
'subTable',
|
||||
'popupSubTable',
|
||||
];
|
||||
const RELATION_FIELD_TYPE_SCHEMA = {
|
||||
type: 'string',
|
||||
enum: RELATION_FIELD_TYPE_ENUM,
|
||||
description: 'Public relation field UI presentation type. This is not the collection field data type/interface.',
|
||||
};
|
||||
const RELATION_TARGET_FIELDS_SCHEMA = {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Relation target record fields used as sub-table columns or embedded sub-form/detail fields.',
|
||||
};
|
||||
const RELATION_SELECTOR_FIELDS_SCHEMA = {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Record picker selector table fields. Do not mix with fields on the same field object.',
|
||||
};
|
||||
const ADD_CHILD_TREE_TABLE_NOTE =
|
||||
'`addChild` is only valid when the live target `catalog.recordActions` exposes it, which normally means a table bound to a tree collection with `treeTable` enabled.';
|
||||
const APPLY_BLUEPRINT_ADD_CHILD_NOTE =
|
||||
@@ -835,7 +862,7 @@ const actionDocs: Record<string, any> = {
|
||||
tags: [FLOW_SURFACES_TAG],
|
||||
summary: 'Add a field wrapper and inner field under a field container',
|
||||
description: valuesCompatibilityNote(
|
||||
'Automatically derives the wrapper/inner-field combination from the container use and the field interface. It can also import a form template through `template`, using `reference` or `copy` mode for the target form grid. `fieldUse` is only kept as a compatibility check and is no longer an arbitrary creation entry. Direct add does not accept raw `wrapperProps` / `fieldProps` / `props` / `decoratorProps` / `stepParams` / `flowRegistry`. Use `settings` and reuse the public configuration semantics from `configure.changes` plus the catalog item/node `configureOptions`. Popup-capable fields can also pass `popup` directly to append a local popup subtree or `popup.template` to reuse a saved popup template in `reference` / `copy` mode. `popup.tryTemplate=true` asks the backend to auto-select a compatible popup template first, preferring the same relation when one exists and otherwise falling back to a compatible non-relation template. It may be combined with `popup.saveAsTemplate={ name, description }`: a hit reuses the matched template directly, while a miss requires explicit local `popup.blocks` so the fallback popup can be saved as a template reference. When `popup.template` is present, `popup.title` still applies, while local `popup.mode` / `popup.blocks` / `popup.layout` are accepted but ignored. If local openView is enabled but no popup content is provided, the server fills in the popup page/tab/grid shell automatically. Under approval forms, direct field creation preserves the `PatternFormFieldModel` inner node semantics and does not allow standalone `jsItem`.',
|
||||
'Automatically derives the wrapper/inner-field combination from the container use and the field interface. Relation fields can request a public `fieldType` such as `picker`, `subTable`, or `popupSubTable`, with optional flat `fields`, `selectorFields`, and `titleField`. Direct add does not accept raw `wrapperProps` / `fieldProps` / `props` / `decoratorProps` / `stepParams` / `flowRegistry` or internal field model keys. Use `settings` and reuse the public configuration semantics from `configure.changes` plus the catalog item/node `configureOptions`. Popup-capable fields can also pass `popup` directly to append a local popup subtree or `popup.template` to reuse a saved popup template in `reference` / `copy` mode. `popup.tryTemplate=true` asks the backend to auto-select a compatible popup template first, preferring the same relation when one exists and otherwise falling back to a compatible non-relation template. It may be combined with `popup.saveAsTemplate={ name, description }`: a hit reuses the matched template directly, while a miss requires explicit local `popup.blocks` so the fallback popup can be saved as a template reference. When `popup.template` is present, `popup.title` still applies, while local `popup.mode` / `popup.blocks` / `popup.layout` are accepted but ignored. If local openView is enabled but no popup content is provided, the server fills in the popup page/tab/grid shell automatically. Under approval forms, direct field creation preserves the `PatternFormFieldModel` inner node semantics and does not allow standalone `jsItem`.',
|
||||
),
|
||||
requestBody: {
|
||||
required: true,
|
||||
@@ -969,7 +996,7 @@ const actionDocs: Record<string, any> = {
|
||||
tags: [FLOW_SURFACES_TAG],
|
||||
summary: 'Add multiple blocks sequentially under the same target',
|
||||
description: valuesCompatibilityNote(
|
||||
'Creates multiple blocks sequentially under the same target. Each item may include `settings`, `defaultFilter`, `defaultActionSettings`, or `template`, but raw `props` / `decoratorProps` / `stepParams` / `flowRegistry` are not accepted. Direct `table` / `list` / `gridCard` / `calendar` / `kanban` items may use block-level `defaultFilter` to backfill the auto-created default filter action; backend runtime compatibility remains tolerant of omitted or empty values. Legacy `defaultActionSettings.filter.defaultFilter` is still supported for compatibility and wins when both are provided. Partial-success semantics apply: a failure in one item does not roll back the others. Results are returned in input order as `index/key/ok/result/error`, and each `error` always includes `message/type/code/status`.',
|
||||
'Creates multiple blocks sequentially under the same target. Each item may include `settings`, `defaultFilter`, `defaultActionSettings`, `template`, or inline `fields` / `fieldsLayout`, but raw `props` / `decoratorProps` / `stepParams` / `flowRegistry` are not accepted. Inline fields use the same public field semantics as compose/addField, including relation `fieldType`. Direct `table` / `list` / `gridCard` / `calendar` / `kanban` items may use block-level `defaultFilter` to backfill the auto-created default filter action; backend runtime compatibility remains tolerant of omitted or empty values. Legacy `defaultActionSettings.filter.defaultFilter` is still supported for compatibility and wins when both are provided. Partial-success semantics apply: a failure in one item does not roll back the others. Results are returned in input order as `index/key/ok/result/error`, and each `error` always includes `message/type/code/status`.',
|
||||
),
|
||||
requestBody: requestBody('FlowSurfaceAddBlocksRequest', examples.addBlocks),
|
||||
responses: responses('FlowSurfaceAddBlocksResult'),
|
||||
@@ -2159,6 +2186,14 @@ const schemas = {
|
||||
fieldPath: {
|
||||
type: 'string',
|
||||
},
|
||||
fieldType: RELATION_FIELD_TYPE_SCHEMA,
|
||||
fields: RELATION_TARGET_FIELDS_SCHEMA,
|
||||
selectorFields: RELATION_SELECTOR_FIELDS_SCHEMA,
|
||||
titleField: { type: 'string' },
|
||||
openMode: { type: 'string', example: 'drawer' },
|
||||
popupSize: { type: 'string', example: 'medium' },
|
||||
pageSize: { type: 'number', example: 10 },
|
||||
showIndex: { type: 'boolean', example: true },
|
||||
renderer: {
|
||||
type: 'string',
|
||||
enum: ['js'],
|
||||
@@ -3443,6 +3478,14 @@ const schemas = {
|
||||
associationPathName: { type: 'string' },
|
||||
renderer: { type: 'string' },
|
||||
type: { type: 'string' },
|
||||
fieldType: RELATION_FIELD_TYPE_SCHEMA,
|
||||
fields: RELATION_TARGET_FIELDS_SCHEMA,
|
||||
selectorFields: RELATION_SELECTOR_FIELDS_SCHEMA,
|
||||
titleField: { type: 'string' },
|
||||
openMode: { type: 'string', example: 'drawer' },
|
||||
popupSize: { type: 'string', example: 'medium' },
|
||||
pageSize: { type: 'number', example: 10 },
|
||||
showIndex: { type: 'boolean', example: true },
|
||||
label: { type: 'string' },
|
||||
target: {
|
||||
type: 'string',
|
||||
@@ -4361,6 +4404,11 @@ const schemas = {
|
||||
template: ref('FlowSurfaceBlockTemplateRef'),
|
||||
resource: ref('FlowSurfaceBlockResourceInput'),
|
||||
resourceInit: ref('FlowSurfaceResourceInit'),
|
||||
fields: {
|
||||
type: 'array',
|
||||
items: ref('FlowSurfaceComposeFieldSpec'),
|
||||
},
|
||||
fieldsLayout: ref('FlowSurfaceComposeLayout'),
|
||||
settings: ANY_OBJECT_SCHEMA,
|
||||
defaultFilter: {
|
||||
allOf: [ref('FlowSurfaceFilterGroup')],
|
||||
@@ -4452,10 +4500,14 @@ const schemas = {
|
||||
collectionName: {
|
||||
type: 'string',
|
||||
},
|
||||
fieldUse: {
|
||||
type: 'string',
|
||||
description: 'Optional compatibility check. The server infers the actual field use from catalog capabilities.',
|
||||
},
|
||||
fieldType: RELATION_FIELD_TYPE_SCHEMA,
|
||||
fields: RELATION_TARGET_FIELDS_SCHEMA,
|
||||
selectorFields: RELATION_SELECTOR_FIELDS_SCHEMA,
|
||||
titleField: { type: 'string' },
|
||||
openMode: { type: 'string', example: 'drawer' },
|
||||
popupSize: { type: 'string', example: 'medium' },
|
||||
pageSize: { type: 'number', example: 10 },
|
||||
showIndex: { type: 'boolean', example: true },
|
||||
defaultTargetUid: {
|
||||
type: 'string',
|
||||
},
|
||||
@@ -4630,6 +4682,11 @@ const schemas = {
|
||||
template: ref('FlowSurfaceBlockTemplateRef'),
|
||||
resource: ref('FlowSurfaceBlockResourceInput'),
|
||||
resourceInit: ref('FlowSurfaceResourceInit'),
|
||||
fields: {
|
||||
type: 'array',
|
||||
items: ref('FlowSurfaceComposeFieldSpec'),
|
||||
},
|
||||
fieldsLayout: ref('FlowSurfaceComposeLayout'),
|
||||
settings: ANY_OBJECT_SCHEMA,
|
||||
defaultFilter: {
|
||||
allOf: [ref('FlowSurfaceFilterGroup')],
|
||||
@@ -4674,9 +4731,14 @@ const schemas = {
|
||||
collectionName: {
|
||||
type: 'string',
|
||||
},
|
||||
fieldUse: {
|
||||
type: 'string',
|
||||
},
|
||||
fieldType: RELATION_FIELD_TYPE_SCHEMA,
|
||||
fields: RELATION_TARGET_FIELDS_SCHEMA,
|
||||
selectorFields: RELATION_SELECTOR_FIELDS_SCHEMA,
|
||||
titleField: { type: 'string' },
|
||||
openMode: { type: 'string', example: 'drawer' },
|
||||
popupSize: { type: 'string', example: 'medium' },
|
||||
pageSize: { type: 'number', example: 10 },
|
||||
showIndex: { type: 'boolean', example: true },
|
||||
defaultTargetUid: {
|
||||
type: 'string',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user