Merge branch 'next' into develop

This commit is contained in:
nocobase[bot]
2026-04-27 05:05:58 +00:00
30 changed files with 3439 additions and 189 deletions
@@ -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',
},
},
})
@@ -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',
},
},
});
@@ -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(
@@ -146,7 +146,7 @@ describe('flowSurfaces association contract', () => {
}),
);
expect(rolesWrapperCatalog.node.configureOptions).toMatchObject({
fieldComponent: {
fieldType: {
type: 'string',
},
});
@@ -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: {
@@ -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' }],
},
},
],
},
},
},
});
});
});
@@ -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: {
@@ -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']);
});
});
@@ -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();
@@ -30,6 +30,7 @@ export const FLOW_SURFACES_TEST_PLUGINS = [
'system-settings',
'block-list',
'block-grid-card',
'block-tree',
'kanban',
'block-markdown',
'block-iframe',
@@ -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);
@@ -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 } : {}),
},
@@ -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 策略
@@ -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']);
@@ -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,
});
});
@@ -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: {
@@ -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',
@@ -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]
@@ -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;
@@ -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';
@@ -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 = {
@@ -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)) {
@@ -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());
}
@@ -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',
@@ -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, [
File diff suppressed because it is too large Load Diff
@@ -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(
@@ -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',
},