From 45cf93d7c41148615d26fa2e038bd3ba3892e4fa Mon Sep 17 00:00:00 2001 From: gchust Date: Sun, 28 Jun 2026 21:18:01 +0800 Subject: [PATCH] fix: mobile page can't be created by flow surface api (#9911) * fix: mobile ui builder api * fix: mobile action --- .../flow/models/AIEmployeeButtonModel.tsx | 88 +++ .../plugin-ai/src/client-v2/index.tsx | 7 + ...surfaces.authoring-apply-blueprint.test.ts | 13 +- .../flow-surfaces.builder.unit.test.ts | 48 ++ ...-surfaces.catalog-compose.contract.test.ts | 12 + .../__tests__/flow-surfaces.swagger.test.ts | 17 + .../__tests__/flow-surfaces.ui-layout.test.ts | 553 +++++++++++++++++- .../flow-surfaces/authoring-validation.ts | 38 +- .../flow-surfaces/blueprint/compile-plan.ts | 4 + .../blueprint/normalize-document.ts | 3 +- .../flow-surfaces/blueprint/public-types.ts | 1 + .../src/server/flow-surfaces/builder.ts | 35 +- .../src/server/flow-surfaces/service.ts | 321 +++++++++- .../src/swagger/flow-surfaces.ts | 27 +- 14 files changed, 1125 insertions(+), 42 deletions(-) create mode 100644 packages/plugins/@nocobase/plugin-ai/src/client-v2/ai-employees/flow/models/AIEmployeeButtonModel.tsx diff --git a/packages/plugins/@nocobase/plugin-ai/src/client-v2/ai-employees/flow/models/AIEmployeeButtonModel.tsx b/packages/plugins/@nocobase/plugin-ai/src/client-v2/ai-employees/flow/models/AIEmployeeButtonModel.tsx new file mode 100644 index 00000000000..0e6e8cd84d4 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-ai/src/client-v2/ai-employees/flow/models/AIEmployeeButtonModel.tsx @@ -0,0 +1,88 @@ +/** + * 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 React from 'react'; +import { FlowModel } from '@nocobase/flow-engine'; +import { AIEmployeeShortcut } from '../../AIEmployeeShortcut'; +import { useChatBoxActions } from '../../chatbox/hooks/useChatBoxActions'; +import type { AIEmployee, ContextItem, Task } from '../../types'; + +type PersistedTask = Task & { + prompt?: string; +}; + +type AIEmployeeButtonModelProps = { + aiEmployee?: Partial & Pick; + auto?: boolean; + context?: { + workContext?: ContextItem[]; + }; + showNotice?: boolean; + style?: { + size?: number; + mask?: boolean; + }; + tasks?: PersistedTask[]; +}; + +const normalizeTasks = (tasks: PersistedTask[] | undefined, workContext: ContextItem[] | undefined): Task[] => { + if (!tasks?.length) { + return []; + } + + return tasks.map(({ prompt, ...task }) => { + const message = task.message || {}; + const nextMessage = { + ...message, + ...(message.user || !prompt ? {} : { user: prompt }), + ...(message.workContext?.length || !workContext?.length ? {} : { workContext }), + }; + + return { + ...task, + message: Object.keys(nextMessage).length ? nextMessage : undefined, + }; + }); +}; + +const AIEmployeeButton: React.FC<{ model: AIEmployeeButtonModel }> = ({ model }) => { + const { triggerTask } = useChatBoxActions(); + const { aiEmployee, auto, context, showNotice, style, tasks: propTasks } = model.props || {}; + const stepTasks = model.getStepParams('shortcutSettings', 'editTasks')?.tasks as PersistedTask[] | undefined; + const workContext = context?.workContext; + const tasks = normalizeTasks(stepTasks || propTasks, workContext); + + if (!aiEmployee?.username) { + return null; + } + + const triggerAiEmployee = aiEmployee as AIEmployee; + + return ( + triggerTask({ aiEmployee: triggerAiEmployee, tasks, auto })} + onTaskClick={(task) => triggerTask({ aiEmployee: triggerAiEmployee, tasks: [task] })} + /> + ); +}; + +export class AIEmployeeButtonModel extends FlowModel { + declare props: AIEmployeeButtonModelProps; + + render() { + return ; + } +} + +export default AIEmployeeButtonModel; diff --git a/packages/plugins/@nocobase/plugin-ai/src/client-v2/index.tsx b/packages/plugins/@nocobase/plugin-ai/src/client-v2/index.tsx index 06fb93cc2de..0fbd4c80fdb 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/client-v2/index.tsx +++ b/packages/plugins/@nocobase/plugin-ai/src/client-v2/index.tsx @@ -20,6 +20,12 @@ export class PluginAIClientV2 extends Plugin { features = new AIPluginFeatureManagerImpl(); async load() { + this.app.flowEngine.registerModelLoaders({ + AIEmployeeButtonModel: { + loader: () => import('./ai-employees/flow/models/AIEmployeeButtonModel'), + }, + }); + const context = this.app.flowEngine.context as AIFlowContext; if (!context.aiConfigRepository) { context.defineProperty('aiConfigRepository', { @@ -33,6 +39,7 @@ export default PluginAIClientV2; export { AIEmployeeProfileCard } from './ai-employees/ProfileCard'; export { AIEmployeeShortcut } from './ai-employees/AIEmployeeShortcut'; +export { AIEmployeeButtonModel } from './ai-employees/flow/models/AIEmployeeButtonModel'; export { avatars, avatarsMap } from './ai-employees/avatars'; export type { AIEmployee, diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.authoring-apply-blueprint.test.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.authoring-apply-blueprint.test.ts index 0b005cfacc7..d7a6838cb14 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.authoring-apply-blueprint.test.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.authoring-apply-blueprint.test.ts @@ -1369,6 +1369,13 @@ describe('flowSurfaces backend authoring applyBlueprint compiler', () => { 'EditActionModel', 'DeleteActionModel', ]); + readTableRecordActionNodes(persistedTable).forEach((action) => { + expect(action.stepParams?.buttonSettings?.general).toMatchObject({ + type: 'link', + icon: null, + iconOnly: false, + }); + }); }); it('should auto-complete default table actions when raw applyBlueprint passes empty action arrays', async () => { @@ -4037,10 +4044,14 @@ function collectDescendantNodes(node: any, predicate: (input: any) => boolean, b } function readTableRecordActionUses(node: any) { + return readTableRecordActionNodes(node).map((item: any) => item?.use); +} + +function readTableRecordActionNodes(node: any) { const actionsColumn = _.castArray(node?.subModels?.columns || []).find( (column: any) => column?.use === 'TableActionsColumnModel', ); - return _.castArray(actionsColumn?.subModels?.actions || []).map((item: any) => item?.use); + return _.castArray(actionsColumn?.subModels?.actions || []); } function readTableColumnFieldPaths(node: any) { diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.builder.unit.test.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.builder.unit.test.ts index be8c3d6ccbb..4da5e94154b 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.builder.unit.test.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.builder.unit.test.ts @@ -136,6 +136,10 @@ describe('flowSurfaces builder action style defaults', () => { use: 'EditActionModel', containerUse: 'GridCardItemModel', }); + const commentEditAction = buildActionTree({ + use: 'EditCommentActionModel', + containerUse: 'CommentItemModel', + }); const detailsEditAction = buildActionTree({ use: 'EditActionModel', containerUse: 'DetailsBlockModel', @@ -144,20 +148,64 @@ describe('flowSurfaces builder action style defaults', () => { expect(tableEditAction.stepParams?.buttonSettings?.general).toMatchObject({ type: 'link', icon: null, + iconOnly: false, }); expect(listEditAction.stepParams?.buttonSettings?.general).toMatchObject({ type: 'link', icon: null, + iconOnly: false, }); expect(gridCardEditAction.stepParams?.buttonSettings?.general).toMatchObject({ type: 'link', icon: null, + iconOnly: false, + }); + expect(commentEditAction.stepParams?.buttonSettings?.general).toMatchObject({ + type: 'link', + icon: null, + iconOnly: false, }); expect(detailsEditAction.stepParams?.buttonSettings?.general).toMatchObject({ type: 'default', icon: 'EditOutlined', }); }); + + it('should preserve explicit icon-only compact record actions', () => { + const explicitPropsAction = buildActionTree({ + use: 'UpdateRecordActionModel', + containerUse: 'TableActionsColumnModel', + props: { + icon: 'StarOutlined', + iconOnly: true, + }, + }); + const explicitStepParamsAction = buildActionTree({ + use: 'EditActionModel', + containerUse: 'ListItemModel', + stepParams: { + buttonSettings: { + general: { + iconOnly: true, + }, + }, + }, + }); + + expect(explicitPropsAction.props).toMatchObject({ + icon: 'StarOutlined', + iconOnly: true, + }); + expect(explicitPropsAction.stepParams?.buttonSettings?.general).toMatchObject({ + icon: 'StarOutlined', + iconOnly: true, + }); + expect(explicitPropsAction.stepParams?.buttonSettings?.general?.title).toBeUndefined(); + expect(explicitStepParamsAction.stepParams?.buttonSettings?.general).toMatchObject({ + iconOnly: true, + }); + expect(explicitStepParamsAction.stepParams?.buttonSettings?.general?.title).toBeUndefined(); + }); }); describe('flowSurfaces builder relation field defaults', () => { diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.catalog-compose.contract.test.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.catalog-compose.contract.test.ts index 9043c167fe9..676936f182c 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.catalog-compose.contract.test.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.catalog-compose.contract.test.ts @@ -6295,6 +6295,7 @@ describe('flowSurfaces catalog + compose contract', () => { title: '编辑', type: 'link', icon: null, + iconOnly: false, }); const gridCardViewReadback = await getSurface(rootAgent, { @@ -6303,6 +6304,7 @@ describe('flowSurfaces catalog + compose contract', () => { expect(gridCardViewReadback.tree.stepParams?.buttonSettings?.general).toMatchObject({ type: 'link', icon: null, + iconOnly: false, }); const explicitListEditReadback = await getSurface(rootAgent, { @@ -7727,6 +7729,11 @@ describe('flowSurfaces catalog + compose contract', () => { expect(implicitEditSurface.tree.popup.template).toMatchObject({ mode: 'reference', }); + expect(implicitEditSurface.tree.stepParams?.buttonSettings?.general).toMatchObject({ + type: 'link', + icon: null, + iconOnly: false, + }); expect(implicitEditPopupBlock?.use).toBe('EditFormModel'); expect(implicitEditPopupBlock?.stepParams?.resourceSettings?.init?.collectionName).toBe('users'); expect(_.castArray(implicitEditPopupBlock?.subModels?.actions || []).map((item: any) => item?.use)).toContain( @@ -7738,6 +7745,11 @@ describe('flowSurfaces catalog + compose contract', () => { expect(implicitViewWithLayoutSurface.tree.popup.template).toMatchObject({ mode: 'reference', }); + expect(implicitViewWithLayoutSurface.tree.stepParams?.buttonSettings?.general).toMatchObject({ + type: 'link', + icon: null, + iconOnly: false, + }); expect(implicitViewWithLayoutPopupBlock?.use).toBe('DetailsBlockModel'); const updateRecordReadback = await getSurface(rootAgent, { diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.swagger.test.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.swagger.test.ts index 891a1d1fb23..c483b9b32fe 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.swagger.test.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.swagger.test.ts @@ -828,9 +828,15 @@ describe('flowSurfaces swagger', () => { expect(schemas.FlowSurfaceApplyBlueprintNavigationGroup.properties.routeId.description).toContain( 'Preferred existing menu-group route id', ); + expect(schemas.FlowSurfaceApplyBlueprintNavigationGroup.properties.routeId.description).toContain( + 'Ignored when navigation.layoutUid targets a mobile layout', + ); expect(schemas.FlowSurfaceApplyBlueprintNavigationGroup.properties.title.description).toContain( 'reuses a same-title group if the match is unique', ); + expect(schemas.FlowSurfaceApplyBlueprintNavigationGroup.properties.title.description).toContain( + 'Ignored when navigation.layoutUid targets a mobile layout', + ); expect(schemas.FlowSurfaceApplyBlueprintNavigationGroup.properties.title.description).toContain( 'group metadata is ignored', ); @@ -840,6 +846,15 @@ describe('flowSurfaces swagger', () => { expect(schemas.FlowSurfaceApplyBlueprintNavigationGroup.properties.icon.description).toContain( 'Ignored when routeId is present', ); + expect(schemas.FlowSurfaceApplyBlueprintNavigation.properties.layoutUid.description).toContain( + 'mobile-layout-model', + ); + expect(schemas.FlowSurfaceApplyBlueprintNavigation.properties.layoutUid.description).toContain( + 'create a root-level tab page', + ); + expect(schemas.FlowSurfaceApplyBlueprintRequest.description).toContain('`navigation.layoutUid`'); + expect(schemas.FlowSurfaceApplyBlueprintRequest.description).toContain('target layout'); + expect(schemas.FlowSurfaceApplyBlueprintRequest.description).toContain('`navigation.group` is ignored'); expect(schemas.FlowSurfaceApplyBlueprintResponse.properties.surface.$ref).toBe( '#/components/schemas/FlowSurfaceGetResponse', ); @@ -2123,7 +2138,9 @@ describe('flowSurfaces swagger', () => { ]); expect(swaggerDocument.components?.schemas?.FlowSurfaceCreateMenuRequest.required).toEqual(['title']); expect(swaggerDocument.components?.schemas?.FlowSurfaceUpdateMenuRequest.required).toEqual(['menuRouteId']); + expect(schemas.FlowSurfaceCreateMenuRequest.properties.layoutUid.description).toContain('mobile-layout-model'); expect(schemas.FlowSurfaceCreateMenuRequest.properties.pageUid).toBeUndefined(); + expect(schemas.FlowSurfaceCreatePageRequest.properties.layoutUid.description).toContain('existing route'); expect(schemas.FlowSurfaceCreatePageRequest.properties.menuRouteId).toBeTruthy(); }); }); diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.ui-layout.test.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.ui-layout.test.ts index 98e07b6e390..9761c380735 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.ui-layout.test.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.ui-layout.test.ts @@ -8,11 +8,13 @@ */ import type { MockServer } from '@nocobase/test'; -import { getData } from './flow-surfaces.contract.helpers'; +import { getData, readErrorMessage } from './flow-surfaces.contract.helpers'; import { createFlowSurfacesMockServer, loginFlowSurfacesRootAgent } from './flow-surfaces.mock-server'; import { FLOW_SURFACES_TEST_PLUGIN_INSTALLS, FLOW_SURFACES_TEST_PLUGINS } from './flow-surfaces.test-plugins'; const DEFAULT_ADMIN_UI_LAYOUT_UID = 'admin-layout-model'; +const DEFAULT_MOBILE_UI_LAYOUT_UID = 'mobile-layout-model'; +const DISABLED_UI_LAYOUT_UID = 'flow-surface-disabled-layout-model'; function findRouteBySchemaUid(routes: any[], schemaUid: string): any { for (const route of routes || []) { @@ -26,6 +28,18 @@ function findRouteBySchemaUid(routes: any[], schemaUid: string): any { } } +async function readRouteLayoutUids(app: MockServer, routeId: string | number) { + const route = await app.db.getRepository('desktopRoutes').findOne({ + filterByTk: routeId, + appends: ['uiLayouts'], + }); + return (route?.get('uiLayouts') || []).map((layout: any) => layout.get('uid')); +} + +function isRootRoute(route: any) { + return route?.get('parentId') == null; +} + describe('flowSurfaces UI layout integration', () => { let app: MockServer; let rootAgent: any; @@ -118,4 +132,541 @@ describe('flowSurfaces UI layout integration', () => { ); expect(findRouteBySchemaUid(accessibleRoutes, created.target.pageSchemaUid)?.id).toBe(created.surface.pageRoute.id); }); + + it('should create applyBlueprint pages as root tabs inside an explicit mobile layout', async () => { + const groupTitle = `Flow surface ignored mobile group ${Date.now()}`; + const pageTitle = `Flow surface mobile root page ${Date.now()}`; + const created = getData( + await rootAgent.resource('flowSurfaces').applyBlueprint({ + values: { + version: '1', + mode: 'create', + navigation: { + layoutUid: DEFAULT_MOBILE_UI_LAYOUT_UID, + group: { + title: groupTitle, + }, + item: { + title: pageTitle, + icon: 'FileOutlined', + }, + }, + page: { + title: pageTitle, + }, + tabs: [ + { + title: 'Overview', + blocks: [ + { + type: 'markdown', + settings: { + content: 'Mobile layout-visible page content', + }, + }, + ], + }, + ], + }, + }), + ); + + const pageRoute = await app.db.getRepository('desktopRoutes').findOne({ + filterByTk: created.surface.pageRoute.id, + appends: ['children'], + }); + const tabRoute = pageRoute?.get('children')?.[0]; + + expect(pageRoute?.get('type')).toBe('flowPage'); + expect(isRootRoute(pageRoute)).toBe(true); + expect(await readRouteLayoutUids(app, created.surface.pageRoute.id)).toEqual([DEFAULT_MOBILE_UI_LAYOUT_UID]); + expect(await readRouteLayoutUids(app, tabRoute?.get('id'))).toEqual([DEFAULT_MOBILE_UI_LAYOUT_UID]); + const ignoredGroups = await app.db.getRepository('desktopRoutes').find({ + filter: { + type: 'group', + title: groupTitle, + }, + }); + expect(ignoredGroups).toHaveLength(0); + + const mobileRoutes = getData( + await memberAgent.get('/desktopRoutes:listAccessible').query({ + tree: true, + sort: 'sort', + layout: DEFAULT_MOBILE_UI_LAYOUT_UID, + }), + ); + expect( + mobileRoutes.some( + (route: any) => route?.schemaUid === created.target.pageSchemaUid && route?.id === created.surface.pageRoute.id, + ), + ).toBe(true); + + const adminRoutes = getData( + await memberAgent.get('/desktopRoutes:listAccessible').query({ + tree: true, + sort: 'sort', + layout: DEFAULT_ADMIN_UI_LAYOUT_UID, + }), + ); + expect(findRouteBySchemaUid(adminRoutes, created.target.pageSchemaUid)).toBeUndefined(); + }); + + it('should replace an existing root mobile applyBlueprint page with the same title', async () => { + const pageTitle = `Flow surface mobile duplicate root page ${Date.now()}`; + const first = getData( + await rootAgent.resource('flowSurfaces').applyBlueprint({ + values: { + version: '1', + mode: 'create', + navigation: { + layoutUid: DEFAULT_MOBILE_UI_LAYOUT_UID, + group: { + title: `Ignored duplicate mobile group ${Date.now()}`, + }, + item: { + title: pageTitle, + icon: 'FileOutlined', + }, + }, + page: { + title: pageTitle, + }, + tabs: [ + { + title: 'Overview', + blocks: [ + { + type: 'markdown', + settings: { + content: 'Initial mobile page content', + }, + }, + ], + }, + ], + }, + }), + ); + + const second = getData( + await rootAgent.resource('flowSurfaces').applyBlueprint({ + values: { + version: '1', + mode: 'create', + navigation: { + layoutUid: DEFAULT_MOBILE_UI_LAYOUT_UID, + item: { + title: pageTitle, + icon: 'FileOutlined', + }, + }, + page: { + title: pageTitle, + }, + tabs: [ + { + title: 'Overview', + blocks: [ + { + type: 'markdown', + settings: { + content: 'Updated mobile page content', + }, + }, + ], + }, + ], + }, + }), + ); + + expect(second.mode).toBe('replace'); + expect(second.target.pageSchemaUid).toBe(first.target.pageSchemaUid); + const rootPages = await app.db.getRepository('desktopRoutes').find({ + filter: { + type: 'flowPage', + title: pageTitle, + }, + }); + expect(rootPages.filter((route: any) => isRootRoute(route))).toHaveLength(1); + }); + + it('should create low-level menu and page routes inside an explicit mobile layout', async () => { + const mobileItem = getData( + await rootAgent.resource('flowSurfaces').createMenu({ + values: { + layoutUid: DEFAULT_MOBILE_UI_LAYOUT_UID, + type: 'item', + title: 'Flow surface low-level mobile page', + icon: 'FileOutlined', + }, + }), + ); + + const createdPage = getData( + await rootAgent.resource('flowSurfaces').createPage({ + values: { + layoutUid: DEFAULT_MOBILE_UI_LAYOUT_UID, + menuRouteId: mobileItem.routeId, + title: 'Flow surface low-level mobile page', + }, + }), + ); + const mobileRoute = await app.db.getRepository('desktopRoutes').findOne({ + filterByTk: createdPage.routeId, + appends: ['children'], + }); + const mobileTabRoute = mobileRoute?.get('children')?.[0]; + + expect(await readRouteLayoutUids(app, createdPage.routeId)).toEqual([DEFAULT_MOBILE_UI_LAYOUT_UID]); + expect(await readRouteLayoutUids(app, mobileTabRoute?.get('id'))).toEqual([DEFAULT_MOBILE_UI_LAYOUT_UID]); + + const adminItem = getData( + await rootAgent.resource('flowSurfaces').createMenu({ + values: { + type: 'item', + title: 'Flow surface low-level admin-only page', + icon: 'FileOutlined', + }, + }), + ); + const mismatchResponse = await rootAgent.resource('flowSurfaces').createPage({ + values: { + layoutUid: DEFAULT_MOBILE_UI_LAYOUT_UID, + menuRouteId: adminItem.routeId, + title: 'Flow surface low-level mismatch page', + }, + }); + + expect(mismatchResponse.status, readErrorMessage(mismatchResponse)).toBe(400); + expect(mismatchResponse.body?.errors?.[0]?.ruleId).toBe('navigation-route-layout-mismatch'); + }); + + it('should scope same-title navigation group reuse by layout', async () => { + const sharedGroupTitle = `Flow surface shared layout group ${Date.now()}`; + const adminCreated = getData( + await rootAgent.resource('flowSurfaces').applyBlueprint({ + values: { + version: '1', + mode: 'create', + navigation: { + group: { + title: sharedGroupTitle, + icon: 'AppstoreOutlined', + }, + item: { + title: 'Flow surface shared admin page', + icon: 'FileOutlined', + }, + }, + page: { + title: 'Flow surface shared admin page', + }, + tabs: [ + { + title: 'Overview', + blocks: [ + { + type: 'markdown', + settings: { + content: 'Admin shared group page', + }, + }, + ], + }, + ], + }, + }), + ); + + const mobileCreated = getData( + await rootAgent.resource('flowSurfaces').applyBlueprint({ + values: { + version: '1', + mode: 'create', + navigation: { + layoutUid: DEFAULT_MOBILE_UI_LAYOUT_UID, + group: { + title: sharedGroupTitle, + icon: 'AppstoreOutlined', + }, + item: { + title: 'Flow surface shared mobile page', + icon: 'FileOutlined', + }, + }, + page: { + title: 'Flow surface shared mobile page', + }, + tabs: [ + { + title: 'Overview', + blocks: [ + { + type: 'markdown', + settings: { + content: 'Mobile shared group page', + }, + }, + ], + }, + ], + }, + }), + ); + + const adminPageRoute = await app.db.getRepository('desktopRoutes').findOne({ + filterByTk: adminCreated.surface.pageRoute.id, + }); + const mobilePageRoute = await app.db.getRepository('desktopRoutes').findOne({ + filterByTk: mobileCreated.surface.pageRoute.id, + }); + + expect(isRootRoute(mobilePageRoute)).toBe(true); + expect(await readRouteLayoutUids(app, adminPageRoute?.get('parentId'))).toEqual([DEFAULT_ADMIN_UI_LAYOUT_UID]); + expect(await readRouteLayoutUids(app, mobileCreated.surface.pageRoute.id)).toEqual([DEFAULT_MOBILE_UI_LAYOUT_UID]); + const sharedGroups = await app.db.getRepository('desktopRoutes').find({ + filter: { + type: 'group', + title: sharedGroupTitle, + }, + }); + expect(sharedGroups).toHaveLength(1); + + const secondAdminCreated = getData( + await rootAgent.resource('flowSurfaces').applyBlueprint({ + values: { + version: '1', + mode: 'create', + navigation: { + group: { + title: sharedGroupTitle, + }, + item: { + title: 'Flow surface shared second admin page', + icon: 'FileOutlined', + }, + }, + page: { + title: 'Flow surface shared second admin page', + }, + tabs: [ + { + title: 'Overview', + blocks: [ + { + type: 'markdown', + settings: { + content: 'Second admin shared group page', + }, + }, + ], + }, + ], + }, + }), + ); + const secondAdminPageRoute = await app.db.getRepository('desktopRoutes').findOne({ + filterByTk: secondAdminCreated.surface.pageRoute.id, + }); + + expect(String(secondAdminPageRoute?.get('parentId'))).toBe(String(adminPageRoute?.get('parentId'))); + expect(await readRouteLayoutUids(app, secondAdminCreated.surface.pageRoute.id)).toEqual([ + DEFAULT_ADMIN_UI_LAYOUT_UID, + ]); + }); + + it('should ignore explicit navigation group route ids for mobile applyBlueprint pages', async () => { + const adminGroup = getData( + await rootAgent.resource('flowSurfaces').createMenu({ + values: { + type: 'group', + title: 'Flow surface admin-only route group', + icon: 'AppstoreOutlined', + }, + }), + ); + + const created = getData( + await rootAgent.resource('flowSurfaces').applyBlueprint({ + values: { + version: '1', + mode: 'create', + navigation: { + layoutUid: DEFAULT_MOBILE_UI_LAYOUT_UID, + group: { + routeId: adminGroup.routeId, + }, + item: { + title: 'Flow surface ignored group route page', + icon: 'FileOutlined', + }, + }, + page: { + title: 'Flow surface ignored group route page', + }, + tabs: [ + { + title: 'Overview', + blocks: [ + { + type: 'markdown', + settings: { + content: 'This page should be created at the mobile root', + }, + }, + ], + }, + ], + }, + }), + ); + + const pageRoute = await app.db.getRepository('desktopRoutes').findOne({ + filterByTk: created.surface.pageRoute.id, + }); + expect(isRootRoute(pageRoute)).toBe(true); + expect(await readRouteLayoutUids(app, created.surface.pageRoute.id)).toEqual([DEFAULT_MOBILE_UI_LAYOUT_UID]); + }); + + it('should reject explicit navigation group route ids outside a non-mobile requested layout', async () => { + const mobileGroup = getData( + await rootAgent.resource('flowSurfaces').createMenu({ + values: { + layoutUid: DEFAULT_MOBILE_UI_LAYOUT_UID, + type: 'group', + title: 'Flow surface mobile-only route group', + icon: 'AppstoreOutlined', + }, + }), + ); + + const response = await rootAgent.resource('flowSurfaces').applyBlueprint({ + values: { + version: '1', + mode: 'create', + navigation: { + layoutUid: DEFAULT_ADMIN_UI_LAYOUT_UID, + group: { + routeId: mobileGroup.routeId, + }, + item: { + title: 'Flow surface non-mobile mismatch page', + icon: 'FileOutlined', + }, + }, + page: { + title: 'Flow surface non-mobile mismatch page', + }, + tabs: [ + { + title: 'Overview', + blocks: [ + { + type: 'markdown', + settings: { + content: 'This page should not be created', + }, + }, + ], + }, + ], + }, + }); + + expect(response.status, readErrorMessage(response)).toBe(400); + expect(response.body?.errors?.[0]?.ruleId).toBe('navigation-route-layout-mismatch'); + }); + + it('should reject disabled layoutUid before duplicate page identity can become replace', async () => { + await app.db.getRepository('uiLayouts').create({ + values: { + uid: DISABLED_UI_LAYOUT_UID, + title: 'Flow surface disabled layout', + layoutType: 'mobile', + routeName: 'flow-surface-disabled-layout', + routePath: '/flow-surface-disabled-layout', + authCheck: true, + enabled: true, + }, + }); + + await rootAgent.resource('flowSurfaces').applyBlueprint({ + values: { + version: '1', + mode: 'create', + navigation: { + layoutUid: DISABLED_UI_LAYOUT_UID, + group: { + title: 'Flow surface disabled layout group', + icon: 'AppstoreOutlined', + }, + item: { + title: 'Flow surface disabled layout page', + icon: 'FileOutlined', + }, + }, + page: { + title: 'Flow surface disabled layout page', + }, + tabs: [ + { + title: 'Overview', + blocks: [ + { + type: 'markdown', + settings: { + content: 'Disabled layout page content', + }, + }, + ], + }, + ], + }, + }); + + await app.db.getRepository('uiLayouts').update({ + filterByTk: DISABLED_UI_LAYOUT_UID, + values: { + enabled: false, + }, + }); + + const response = await rootAgent.resource('flowSurfaces').applyBlueprint({ + values: { + version: '1', + mode: 'create', + navigation: { + layoutUid: DISABLED_UI_LAYOUT_UID, + group: { + title: 'Flow surface disabled layout group', + }, + item: { + title: 'Flow surface disabled layout page', + icon: 'FileOutlined', + }, + }, + page: { + title: 'Flow surface disabled layout page', + }, + tabs: [ + { + title: 'Overview', + blocks: [ + { + type: 'markdown', + settings: { + content: 'This page should not replace the disabled layout page', + }, + }, + ], + }, + ], + }, + }); + + expect(response.status, readErrorMessage(response)).toBe(400); + expect(response.body?.errors?.[0]?.ruleId).toBe('navigation-layout-not-found'); + }); }); diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/authoring-validation.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/authoring-validation.ts index b2c85e56bfd..2549df0c620 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/authoring-validation.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/authoring-validation.ts @@ -89,7 +89,8 @@ export interface FlowSurfaceAuthoringValidationContext { applyBlueprintScriptAssets?: Record; getCollection?: (dataSourceKey: string, collectionName: string) => any; getDefaultFieldGroups?: (dataSourceKey: string, collectionName: string) => any; - findMenuGroupRoutesByTitle?: (title: string, transaction?: any) => Promise; + findMenuGroupRoutesByTitle?: (title: string, transaction?: any, layoutUid?: string | string[]) => Promise; + getUiLayoutTypeByUid?: (layoutUid: string, transaction?: any) => Promise; transaction?: any; hostBlockType?: string; hostCollectionName?: string; @@ -636,6 +637,9 @@ async function collectNavigationGroupErrors( if (actionName !== 'applyBlueprint' || values?.mode !== 'create' || !_.isPlainObject(values?.navigation?.group)) { return; } + if (await isApplyBlueprintMobileCreateNavigation(values, context)) { + return; + } if (!_.isUndefined(values.navigation.group.routeId) || !context.findMenuGroupRoutesByTitle) { return; } @@ -643,7 +647,8 @@ async function collectNavigationGroupErrors( if (!groupTitle) { return; } - const matchedRoutes = await context.findMenuGroupRoutesByTitle(groupTitle, context.transaction); + const layoutUid = String(values?.navigation?.layoutUid || '').trim() || undefined; + const matchedRoutes = await context.findMenuGroupRoutesByTitle(groupTitle, context.transaction, layoutUid); const rootMatchedRoutes = filterRootMenuGroupRoutes(matchedRoutes); if (rootMatchedRoutes.length <= 1) { return; @@ -669,11 +674,13 @@ async function collectNavigationIconErrors( if (actionName !== 'applyBlueprint' || values?.mode !== 'create') { return; } + const isMobileCreateNavigation = await isApplyBlueprintMobileCreateNavigation(values, context); const group = _.isPlainObject(values?.navigation?.group) ? values.navigation.group : null; const groupRouteId = String(group?.routeId || '').trim(); - if (group && !groupRouteId && group.hideInMenu !== true) { + const layoutUid = String(values?.navigation?.layoutUid || '').trim() || undefined; + if (!isMobileCreateNavigation && group && !groupRouteId && group.hideInMenu !== true) { const groupIcon = String(group.icon || '').trim(); - if (!groupIcon && (await shouldRequireNewNavigationGroupIcon(group, context))) { + if (!groupIcon && (await shouldRequireNewNavigationGroupIcon(group, context, layoutUid))) { pushAuthoringError(errors, { path: '$.navigation.group.icon', ruleId: 'navigation-icon-required', @@ -693,7 +700,12 @@ async function collectNavigationIconErrors( }, }); } - } else if (group && String(group.icon || '').trim() && !isValidAntDesignIconName(group.icon)) { + } else if ( + !isMobileCreateNavigation && + group && + String(group.icon || '').trim() && + !isValidAntDesignIconName(group.icon) + ) { pushAuthoringError(errors, { path: '$.navigation.group.icon', ruleId: 'navigation-icon-unknown', @@ -730,12 +742,24 @@ async function collectNavigationIconErrors( } } -async function shouldRequireNewNavigationGroupIcon(group: any, context: FlowSurfaceAuthoringValidationContext) { +async function isApplyBlueprintMobileCreateNavigation(values: any, context: FlowSurfaceAuthoringValidationContext) { + const layoutUid = String(values?.navigation?.layoutUid || '').trim(); + if (!layoutUid || !context.getUiLayoutTypeByUid) { + return false; + } + return (await context.getUiLayoutTypeByUid(layoutUid, context.transaction)) === 'mobile'; +} + +async function shouldRequireNewNavigationGroupIcon( + group: any, + context: FlowSurfaceAuthoringValidationContext, + layoutUid?: string, +) { const groupTitle = String(group?.title || '').trim(); if (!groupTitle || !context.findMenuGroupRoutesByTitle) { return true; } - const matchedRoutes = await context.findMenuGroupRoutesByTitle(groupTitle, context.transaction); + const matchedRoutes = await context.findMenuGroupRoutesByTitle(groupTitle, context.transaction, layoutUid); return filterRootMenuGroupRoutes(matchedRoutes).length === 0; } diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/blueprint/compile-plan.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/blueprint/compile-plan.ts index 5ee82120954..6dcdef72001 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/blueprint/compile-plan.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/blueprint/compile-plan.ts @@ -67,12 +67,14 @@ function compileCreatePlan( ): FlowSurfaceApplyBlueprintProgram { const steps: FlowSurfacePlanStep[] = []; const chrome = buildPageChrome(document); + const layoutUid = document.navigation?.layoutUid; if (_.isPlainObject(document.navigation?.group) && _.isUndefined(document.navigation.group.routeId)) { steps.push({ id: CREATE_MENU_GROUP_STEP_ID, action: 'createMenu', values: buildDefinedPayload({ + layoutUid, title: document.navigation?.group?.title, type: 'group', icon: document.navigation?.group?.icon, @@ -86,6 +88,7 @@ function compileCreatePlan( id: CREATE_MENU_ITEM_STEP_ID, action: 'createMenu', values: buildDefinedPayload({ + layoutUid, title: document.navigation?.item?.title || chrome.pageTitle, type: 'item', icon: document.navigation?.item?.icon || document.page?.icon || 'FileOutlined', @@ -103,6 +106,7 @@ function compileCreatePlan( id: CREATE_PAGE_STEP_ID, action: 'createPage', values: buildDefinedPayload({ + layoutUid, menuRouteId: { step: CREATE_MENU_ITEM_STEP_ID, path: 'routeId' }, title: chrome.pageTitle, icon: document.page?.icon, diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/blueprint/normalize-document.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/blueprint/normalize-document.ts index d55b3f5b33a..78e1efc11a3 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/blueprint/normalize-document.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/blueprint/normalize-document.ts @@ -132,8 +132,9 @@ function normalizeNavigation(input: any) { return undefined; } assertPlainObject(input, 'flowSurfaces applyBlueprint navigation'); - assertOnlyAllowedKeys(input, 'flowSurfaces applyBlueprint navigation', ['group', 'item']); + assertOnlyAllowedKeys(input, 'flowSurfaces applyBlueprint navigation', ['layoutUid', 'group', 'item']); const normalized = buildDefinedPayload({ + layoutUid: readOptionalString(input.layoutUid), group: _.isUndefined(input.group) ? undefined : (() => { diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/blueprint/public-types.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/blueprint/public-types.ts index 2d09ec91d54..886b04c6630 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/blueprint/public-types.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/blueprint/public-types.ts @@ -30,6 +30,7 @@ export type FlowSurfaceApplyBlueprintNavigationGroup = { }; export type FlowSurfaceApplyBlueprintNavigation = { + layoutUid?: string; group?: FlowSurfaceApplyBlueprintNavigationGroup; item?: { title?: string; diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/builder.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/builder.ts index 12ad25a7902..04946883fda 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/builder.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/builder.ts @@ -957,6 +957,10 @@ function buildActionDefaults(options: { approvalDefaults?.props || {}, ); const normalizedProps = applyContainerActionStyle(props, options.containerUse); + const buttonGeneralProps = applyCompactRecordActionButtonDefaults( + pickButtonGeneralProps(normalizedProps), + options.containerUse, + ); const stepParams: Record = _.merge( {}, _.cloneDeep(approvalDefaults?.stepParams || {}), @@ -964,7 +968,7 @@ function buildActionDefaults(options: { ? {} : { buttonSettings: { - general: pickButtonGeneralProps(normalizedProps), + general: buttonGeneralProps, }, }, ); @@ -1289,12 +1293,33 @@ function inferActionDefaultProps(use: string, scope?: FlowSurfaceCatalogItem['sc ); } -function applyContainerActionStyle(props: Record, containerUse?: string) { +const COMPACT_RECORD_ACTION_CONTAINER_USES = new Set([ + 'TableActionsColumnModel', + 'ListItemModel', + 'GridCardItemModel', + 'CommentItemModel', +]); + +function isCompactRecordActionContainerUse(containerUse?: string) { + return COMPACT_RECORD_ACTION_CONTAINER_USES.has(String(containerUse || '').trim()); +} + +function applyCompactRecordActionButtonDefaults(props: Record, containerUse?: string) { if ( - ['TableActionsColumnModel', 'ListItemModel', 'GridCardItemModel', 'CommentItemModel'].includes( - String(containerUse || '').trim(), - ) + isCompactRecordActionContainerUse(containerUse) && + props.icon === null && + !Object.prototype.hasOwnProperty.call(props, 'iconOnly') ) { + return { + ...props, + iconOnly: false, + }; + } + return props; +} + +function applyContainerActionStyle(props: Record, containerUse?: string) { + if (isCompactRecordActionContainerUse(containerUse)) { return { ...props, type: 'link', diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/service.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/service.ts index 4fc83659f6e..fa4ec23ce77 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/service.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/service.ts @@ -1292,6 +1292,7 @@ type FlowSurfaceApplyBlueprintResponse = { }; const DEFAULT_ADMIN_UI_LAYOUT_UID = 'admin-layout-model'; +const MOBILE_UI_LAYOUT_TYPE = 'mobile'; export class FlowSurfacesService { constructor(private readonly plugin: Plugin) {} @@ -1553,12 +1554,12 @@ export class FlowSurfacesService { }); } - private async findMenuGroupRoutesByTitle(title: string, transaction?: any) { + private async findMenuGroupRoutesByTitle(title: string, transaction?: any, layoutUid?: string | string[]) { const normalizedTitle = String(title || '').trim(); if (!normalizedTitle) { return []; } - return _.castArray( + const routes = _.castArray( await this.db.getRepository('desktopRoutes').find({ filter: { type: 'group', @@ -1567,6 +1568,19 @@ export class FlowSurfacesService { transaction, }), ); + const layoutUidFilter = await this.resolveDesktopRouteLayoutUidFilter(layoutUid, transaction); + if (!layoutUidFilter.length || !this.hasDesktopRouteUiLayoutsRelation()) { + return routes; + } + + const matchedRoutes: any[] = []; + for (const route of routes) { + const routeLayoutUids = await this.readDesktopRouteUiLayoutUids(this.readRouteField(route, 'id'), transaction); + if (_.intersection(routeLayoutUids, layoutUidFilter).length) { + matchedRoutes.push(route); + } + } + return matchedRoutes; } private routeParentIdMatches(routeParentId: any, parentId: any) { @@ -1580,29 +1594,49 @@ export class FlowSurfacesService { parentId: string | number | null, title: string, transaction?: any, + layoutUid?: string | string[], ) { - const routes = await this.findMenuGroupRoutesByTitle(title, transaction); + const routes = await this.findMenuGroupRoutesByTitle(title, transaction, layoutUid); return routes.filter((route: any) => this.routeParentIdMatches(this.readRouteField(route, 'parentId') ?? null, parentId), ); } - private async findFlowPageRoutesByParentIdAndTitle(parentId: string | number, title: string, transaction?: any) { + private async findFlowPageRoutesByParentIdAndTitle( + parentId: string | number | null, + title: string, + transaction?: any, + layoutUid?: string | string[], + ) { const normalizedTitle = String(title || '').trim(); - const normalizedParentId = String(parentId ?? '').trim(); - if (!normalizedTitle || !normalizedParentId) { + if (!normalizedTitle) { return []; } - return _.castArray( + const routes = _.castArray( await this.db.getRepository('desktopRoutes').find({ - filter: { + filter: buildDefinedPayload({ type: 'flowPage', title: normalizedTitle, - parentId: normalizedParentId, - }, + parentId: _.isNil(parentId) ? undefined : String(parentId), + }), transaction, }), ); + const layoutUidFilter = await this.resolveDesktopRouteLayoutUidFilter(layoutUid, transaction); + const layoutMatchedRoutes: any[] = []; + if (!layoutUidFilter.length || !this.hasDesktopRouteUiLayoutsRelation()) { + layoutMatchedRoutes.push(...routes); + } else { + for (const route of routes) { + const routeLayoutUids = await this.readDesktopRouteUiLayoutUids(this.readRouteField(route, 'id'), transaction); + if (_.intersection(routeLayoutUids, layoutUidFilter).length) { + layoutMatchedRoutes.push(route); + } + } + } + return layoutMatchedRoutes.filter((route: any) => + this.routeParentIdMatches(this.readRouteField(route, 'parentId') ?? null, parentId), + ); } private async assertMenuParentIsGroup(parentMenuRouteId: string | number | null | undefined, transaction?: any) { @@ -1725,6 +1759,31 @@ export class FlowSurfacesService { return defaultLayout ? [DEFAULT_ADMIN_UI_LAYOUT_UID] : []; } + private async findDesktopRouteUiLayoutByUid(layoutUid: string, transaction?: any) { + const normalizedLayoutUid = this.normalizeExplicitDesktopRouteLayoutUid(layoutUid); + if (!normalizedLayoutUid || !this.hasDesktopRouteUiLayoutsRelation()) { + return null; + } + return this.db.getRepository('uiLayouts').findOne({ + filter: { + uid: normalizedLayoutUid, + }, + transaction, + }); + } + + private async getDesktopRouteUiLayoutTypeByUid(layoutUid: string, transaction?: any) { + const uiLayout = await this.findDesktopRouteUiLayoutByUid(layoutUid, transaction); + return String(uiLayout?.get?.('layoutType') || uiLayout?.layoutType || '').trim() || undefined; + } + + private async isMobileDesktopRouteUiLayoutUid(layoutUid: string | undefined, transaction?: any) { + if (!layoutUid) { + return false; + } + return (await this.getDesktopRouteUiLayoutTypeByUid(layoutUid, transaction)) === MOBILE_UI_LAYOUT_TYPE; + } + private async resolveInheritedDesktopRouteUiLayoutUids(parentRoute: any, transaction?: any): Promise { const parentRouteId = this.readRouteField(parentRoute, 'id'); if (!_.isNil(parentRouteId)) { @@ -1737,6 +1796,112 @@ export class FlowSurfacesService { return this.resolveDefaultDesktopRouteUiLayoutUids(transaction); } + private normalizeExplicitDesktopRouteLayoutUid(value: unknown): string | undefined { + const normalized = String(value || '').trim(); + return normalized || undefined; + } + + private normalizeDesktopRouteLayoutUidFilter(value: unknown): string[] { + return _.uniq( + _.castArray(value) + .map((item) => this.normalizeExplicitDesktopRouteLayoutUid(item)) + .filter(Boolean) as string[], + ); + } + + private async resolveDesktopRouteLayoutUidFilter(value: unknown, transaction?: any): Promise { + const explicitLayoutUids = this.normalizeDesktopRouteLayoutUidFilter(value); + if (explicitLayoutUids.length) { + return explicitLayoutUids; + } + return this.resolveDefaultDesktopRouteUiLayoutUids(transaction); + } + + private async assertEnabledDesktopRouteUiLayoutUid( + layoutUid: string, + actionName: string, + path: string, + transaction?: any, + ) { + if (!this.hasDesktopRouteUiLayoutsRelation()) { + throwBadRequest(`flowSurfaces ${actionName} ${path} requires plugin-ui-layout desktop route relation support`, { + ruleId: 'navigation-layout-unsupported', + path, + details: { + layoutUid, + }, + }); + } + + const uiLayout = await this.db.getRepository('uiLayouts').findOne({ + filter: { + uid: layoutUid, + enabled: true, + }, + transaction, + }); + if (!uiLayout) { + throwBadRequest(`flowSurfaces ${actionName} ${path} must reference an enabled ui layout`, { + ruleId: 'navigation-layout-not-found', + path, + details: { + layoutUid, + }, + }); + } + } + + private async assertDesktopRouteBelongsToUiLayout( + actionName: string, + route: any, + layoutUid: string | undefined, + path: string, + transaction?: any, + ) { + const normalizedLayoutUid = this.normalizeExplicitDesktopRouteLayoutUid(layoutUid); + if (!normalizedLayoutUid || !route) { + return; + } + const routeId = this.readRouteField(route, 'id'); + const routeLayoutUids = await this.readDesktopRouteUiLayoutUids(routeId, transaction); + if (routeLayoutUids.includes(normalizedLayoutUid)) { + return; + } + throwBadRequest(`flowSurfaces ${actionName} ${path} does not belong to ui layout '${normalizedLayoutUid}'`, { + ruleId: 'navigation-route-layout-mismatch', + path, + details: { + routeId, + layoutUid: normalizedLayoutUid, + routeLayoutUids, + }, + }); + } + + private async resolveRequestedDesktopRouteUiLayoutUids( + values: Record, + parentRoute: any, + actionName: string, + transaction?: any, + ): Promise { + const layoutUid = this.normalizeExplicitDesktopRouteLayoutUid(values.layoutUid); + if (layoutUid) { + await this.assertEnabledDesktopRouteUiLayoutUid(layoutUid, actionName, 'values.layoutUid', transaction); + if (parentRoute) { + await this.assertDesktopRouteBelongsToUiLayout( + actionName, + parentRoute, + layoutUid, + 'values.parentMenuRouteId', + transaction, + ); + } + return [layoutUid]; + } + + return this.resolveInheritedDesktopRouteUiLayoutUids(parentRoute, transaction); + } + private async setDesktopRouteUiLayouts(routeId: unknown, uiLayoutUids: string[], transaction?: any) { const relationRouteId = this.normalizeDesktopRouteRelationId(routeId); if (_.isUndefined(relationRouteId) || !uiLayoutUids.length || !this.hasDesktopRouteUiLayoutsRelation()) { @@ -1859,8 +2024,19 @@ export class FlowSurfacesService { private async createFlowMenuGroup(values: Record, transaction?: any) { const parentRoute = await this.assertMenuParentIsGroup(values.parentMenuRouteId, transaction); const parentId = this.readRouteField(parentRoute, 'id') ?? null; + const routeUiLayoutUids = await this.resolveRequestedDesktopRouteUiLayoutUids( + values, + parentRoute, + 'createMenu', + transaction, + ); const title = String(values.title || '').trim(); - const existingGroups = await this.findMenuGroupRoutesByParentIdAndTitle(parentId, title, transaction); + const existingGroups = await this.findMenuGroupRoutesByParentIdAndTitle( + parentId, + title, + transaction, + routeUiLayoutUids, + ); if (existingGroups.length === 1) { return this.buildMenuResult(existingGroups[0]); } @@ -1902,7 +2078,7 @@ export class FlowSurfacesService { }); await this.attachDesktopRouteUiLayoutsToRouteAndChildren( this.readRouteField(route, 'id'), - await this.resolveInheritedDesktopRouteUiLayoutUids(parentRoute, transaction), + routeUiLayoutUids, transaction, ); return this.buildMenuResult(route); @@ -1910,6 +2086,12 @@ export class FlowSurfacesService { private async createFlowMenuItem(values: Record, transaction?: any) { const parentRoute = await this.assertMenuParentIsGroup(values.parentMenuRouteId, transaction); + const routeUiLayoutUids = await this.resolveRequestedDesktopRouteUiLayoutUids( + values, + parentRoute, + 'createMenu', + transaction, + ); this.assertVisibleNavigationIcon('createMenu', 'values', values); const pageSchemaUid = values.pageSchemaUid || uid(); const menuSchemaUid = uid(); @@ -1957,7 +2139,7 @@ export class FlowSurfacesService { }); await this.attachDesktopRouteUiLayoutsToRouteAndChildren( this.readRouteField(createdRoute, 'id'), - await this.resolveInheritedDesktopRouteUiLayoutUids(parentRoute, transaction), + routeUiLayoutUids, transaction, ); @@ -1998,7 +2180,7 @@ export class FlowSurfacesService { } private sanitizePublicCreateMenuValues(values: Record) { - return _.pick(values, ['title', 'type', 'icon', 'tooltip', 'hideInMenu', 'parentMenuRouteId']); + return _.pick(values, ['title', 'type', 'layoutUid', 'icon', 'tooltip', 'hideInMenu', 'parentMenuRouteId']); } private async loadRouteBackedPageStructure(route: any, transaction?: any) { @@ -4582,7 +4764,13 @@ export class FlowSurfacesService { return document; } - const matchedRoutes = await this.findMenuGroupRoutesByParentIdAndTitle(null, groupTitle, transaction); + const layoutUid = this.normalizeExplicitDesktopRouteLayoutUid(document.navigation.layoutUid); + const matchedRoutes = await this.findMenuGroupRoutesByParentIdAndTitle( + null, + groupTitle, + transaction, + layoutUid || (await this.resolveDefaultDesktopRouteUiLayoutUids(transaction)), + ); if (!matchedRoutes.length) { return document; } @@ -4610,6 +4798,65 @@ export class FlowSurfacesService { }; } + private async normalizeApplyBlueprintCreateMobileNavigation( + document: FlowSurfaceApplyBlueprintDocument, + transaction?: any, + ): Promise { + if (document.mode !== 'create') { + return document; + } + const layoutUid = this.normalizeExplicitDesktopRouteLayoutUid(document.navigation?.layoutUid); + if (!(await this.isMobileDesktopRouteUiLayoutUid(layoutUid, transaction))) { + return document; + } + + return { + ...document, + navigation: buildDefinedPayload({ + ...document.navigation, + layoutUid, + group: undefined, + }), + }; + } + + private async assertApplyBlueprintCreateNavigationLayout( + document: FlowSurfaceApplyBlueprintDocument, + transaction?: any, + ) { + if (document.mode !== 'create') { + return; + } + const layoutUid = this.normalizeExplicitDesktopRouteLayoutUid(document.navigation?.layoutUid); + if (!layoutUid) { + return; + } + + await this.assertEnabledDesktopRouteUiLayoutUid( + layoutUid, + 'applyBlueprint', + 'values.navigation.layoutUid', + transaction, + ); + + if (await this.isMobileDesktopRouteUiLayoutUid(layoutUid, transaction)) { + return; + } + + const groupRouteId = document.navigation?.group?.routeId; + if (_.isNil(groupRouteId) || groupRouteId === '') { + return; + } + const groupRoute = await this.assertMenuParentIsGroup(groupRouteId, transaction); + await this.assertDesktopRouteBelongsToUiLayout( + 'applyBlueprint', + groupRoute, + layoutUid, + 'values.navigation.group.routeId', + transaction, + ); + } + private async resolveApplyBlueprintCreatePageIdentity( document: FlowSurfaceApplyBlueprintDocument, transaction?: any, @@ -4619,25 +4866,42 @@ export class FlowSurfacesService { } const groupRouteId = document.navigation?.group?.routeId; + const layoutUid = this.normalizeExplicitDesktopRouteLayoutUid(document.navigation?.layoutUid); const pageTitle = String(document.page?.title || document.navigation?.item?.title || '').trim(); - if (_.isNil(groupRouteId) || groupRouteId === '' || !pageTitle) { + const hasGroupRouteId = !_.isNil(groupRouteId) && groupRouteId !== ''; + if (!pageTitle || (!hasGroupRouteId && !layoutUid)) { return document; } + const groupRoute = hasGroupRouteId ? await this.findMenuRouteById(groupRouteId, transaction) : null; + const layoutUidFilter = + layoutUid || + (groupRoute ? await this.resolveInheritedDesktopRouteUiLayoutUids(groupRoute, transaction) : undefined); - const matchedPages = await this.findFlowPageRoutesByParentIdAndTitle(groupRouteId, pageTitle, transaction); + const matchedPages = await this.findFlowPageRoutesByParentIdAndTitle( + hasGroupRouteId ? groupRouteId : null, + pageTitle, + transaction, + layoutUidFilter, + ); if (!matchedPages.length) { return document; } if (matchedPages.length > 1) { + const locationLabel = hasGroupRouteId + ? `under navigation.group.routeId '${groupRouteId}'` + : `at the root of ui layout '${layoutUid}'`; throwBadRequest( - `flowSurfaces applyBlueprint navigation.group.routeId '${groupRouteId}' already has ${matchedPages.length} flow pages titled '${pageTitle}'; pass target.pageSchemaUid explicitly before applyBlueprint`, + `flowSurfaces applyBlueprint ${locationLabel} already has ${matchedPages.length} flow pages titled '${pageTitle}'; pass target.pageSchemaUid explicitly before applyBlueprint`, ); } const pageSchemaUid = String(this.readRouteField(matchedPages[0], 'schemaUid') || '').trim(); if (!pageSchemaUid) { + const locationLabel = hasGroupRouteId + ? `under navigation.group.routeId '${groupRouteId}'` + : `at the root of ui layout '${layoutUid}'`; throwBadRequest( - `flowSurfaces applyBlueprint existing flow page '${pageTitle}' under navigation.group.routeId '${groupRouteId}' is missing schemaUid; pass target.pageSchemaUid explicitly before applyBlueprint`, + `flowSurfaces applyBlueprint existing flow page '${pageTitle}' ${locationLabel} is missing schemaUid; pass target.pageSchemaUid explicitly before applyBlueprint`, ); } @@ -4657,7 +4921,15 @@ export class FlowSurfacesService { createdKanbanSortFields?: FlowSurfaceApplyBlueprintKanbanCreatedSortField[], ): Promise { const initialDocument = prepareFlowSurfaceApplyBlueprintDocument(values); - const groupResolvedDocument = await this.resolveApplyBlueprintCreateNavigationGroup(initialDocument, transaction); + await this.assertApplyBlueprintCreateNavigationLayout(initialDocument, transaction); + const mobileNormalizedDocument = await this.normalizeApplyBlueprintCreateMobileNavigation( + initialDocument, + transaction, + ); + const groupResolvedDocument = await this.resolveApplyBlueprintCreateNavigationGroup( + mobileNormalizedDocument, + transaction, + ); const document = await this.resolveApplyBlueprintCreatePageIdentity(groupResolvedDocument, transaction); await this.prepareApplyBlueprintKanbanBlocks(document, transaction, createdKanbanSortFields); const replaceTarget = @@ -5455,7 +5727,9 @@ export class FlowSurfacesService { await assertFlowSurfaceAuthoringPayload('applyBlueprint', values, { transaction: options.transaction, enabledPackages, - findMenuGroupRoutesByTitle: (title, transaction) => this.findMenuGroupRoutesByTitle(title, transaction), + findMenuGroupRoutesByTitle: (title, transaction, layoutUid) => + this.findMenuGroupRoutesByTitle(title, transaction, layoutUid), + getUiLayoutTypeByUid: (layoutUid, transaction) => this.getDesktopRouteUiLayoutTypeByUid(layoutUid, transaction), getCollection: (dataSourceKey, collectionName) => this.getCollection(dataSourceKey || 'main', collectionName || ''), }); @@ -8064,6 +8338,11 @@ export class FlowSurfacesService { const pageSchemaUid = this.readRouteField(route, 'schemaUid'); const structure = await this.loadRouteBackedPageStructure(route, transaction); let tabRoute = structure.tabRoutes[0]; + const layoutUid = this.normalizeExplicitDesktopRouteLayoutUid(values.layoutUid); + if (layoutUid) { + await this.assertEnabledDesktopRouteUiLayoutUid(layoutUid, 'createPage', 'values.layoutUid', transaction); + await this.assertDesktopRouteBelongsToUiLayout('createPage', route, layoutUid, 'values.menuRouteId', transaction); + } const routeUiLayoutUids = await this.ensureDesktopRouteUiLayouts(route, transaction); const routeOptions = this.readRouteOptions(route); if (!routeOptions[FLOW_SURFACE_MENU_BINDABLE_OPTION_KEY]) { diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/swagger/flow-surfaces.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/swagger/flow-surfaces.ts index d80e83d1a2f..d80eb1ee066 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/swagger/flow-surfaces.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/swagger/flow-surfaces.ts @@ -652,7 +652,7 @@ const actionDocs: Record = { tags: [FLOW_SURFACES_TAG], summary: 'Apply a page blueprint to create or replace one Modern page', description: valuesCompatibilityNote( - `Accepts one simplified JSON page blueprint and compiles it to internal flow-surface operations. The public blueprint describes page structure (\`create\` or \`replace\`, page metadata, ordered tabs, blocks, fields, actions, inline popups, optional reusable assets) and optional top-level \`reaction.items[]\` for whole-page interaction authoring. Each reaction item targets an explicit local key / bind key produced by the same blueprint run. Only explicitly listed reaction items are written. \`rules: []\` clears the targeted slot. Repeating the same \`(type, target)\` reaction slot in one blueprint is invalid. In \`replace\`, reaction targets always bind to the newly produced blueprint result, not historical nodes from the previous page version; if a slot must exist in the resulting surface, include it explicitly instead of relying on omission. Localized reaction edits on an existing surface should use \`getReactionMeta\` + \`set*Rules\` instead of applying a whole page blueprint again. The request body is that page-document JSON object itself and must not be JSON-stringified. Wrong: \`{ "requestBody": "{\\"version\\":\\"1\\"}" }\`. Internal planning details stay hidden. In \`create\`, \`navigation.group.routeId\` has the highest priority when targeting an existing menu group. If \`routeId\` is present, applyBlueprint ignores \`title\`, \`icon\`, \`tooltip\`, and \`hideInMenu\` on \`navigation.group\`; applyBlueprint create mode does not mutate existing group metadata, so callers should use \`updateMenu\` separately when that is required. When \`routeId\` is omitted and \`navigation.group.title\` is provided, applyBlueprint reuses one existing same-title group when it is unique, creates a new group when none exists, and rejects ambiguous multi-match cases. Metadata such as \`icon\`, \`tooltip\`, and \`hideInMenu\` is used only when a new group is created and is ignored when an existing group is reused. \`replace\` uses \`target.pageSchemaUid\`, updates only the explicit page-level fields provided in \`page\`, maps blueprint tabs to existing route-backed tab slots by index, rewrites each slot in order, removes trailing old tabs, and appends extra new tabs when needed. Tab and block keys are optional in the public blueprint; omit them unless custom layout or cross-block targeting needs a stable in-document identifier. \`layout\` is only allowed on tabs and inline popup documents; blocks themselves do not accept a \`layout\` property. Public applyBlueprint blocks do not support generic \`form\`; use \`editForm\` or \`createForm\`. AI employee actions use \`type: "aiEmployee"\` plus public \`settings.username\`, \`workContext\`, \`tasks\`, \`auto\`, and \`style\`; work context may target \`self\` or a same-blueprint block key and is persisted as real Flow Model \`uid\` values. For JS blocks/fields/actions, \`script\` is a non-empty string asset key into \`assets.scripts\`; put inline JS in \`settings.code\` and \`settings.version\`. Direct \`table\` / \`list\` / \`gridCard\` / \`calendar\` / \`kanban\` blocks may omit \`defaultFilter\`; the backend generates one from live metadata with up to 4 scalar/filterable fields. Explicit values must contain at least the smaller of 3 and the collection eligible-field count, and values with more than 4 fields are truncated before persistence. A valid explicit or generated block-level value backfills the default \`filter\` action \`settings.defaultFilter\`; explicit filter-action \`settings.defaultFilter\` still wins. ${TREE_TABLE_RECORD_ACTION_DEFAULTS_NOTE} ${APPLY_BLUEPRINT_TREE_TABLE_TITLE_FIELD_NOTE} Inline popup documents may set \`popup.tryTemplate=true\` to ask the backend for the best compatible popup template before falling back to local popup content. Inline popup documents may also combine \`popup.tryTemplate\` with \`popup.saveAsTemplate={ name, description, local? }\`: a hit binds the matched template immediately and lets later inline popups in the same blueprint reuse that final bound template through \`popup.template={ local, mode }\`, while a miss requires explicit local \`popup.blocks\` so the fallback popup can be saved and reused. Custom \`edit\` popups that provide \`popup.blocks\` must include exactly one \`editForm\` block; that \`editForm\` may omit \`resource\` and then inherits the opener's current-record context. When layout is omitted, applyBlueprint auto-generates a simple top-to-bottom layout. When a \`replace\` run expands a page to multiple tabs while the current page still has \`enableTabs=false\`, callers must set \`page.enableTabs=true\` explicitly. The response hides execution internals and returns only the resolved page target and final surface readback.`, + `Accepts one simplified JSON page blueprint and compiles it to internal flow-surface operations. The public blueprint describes page structure (\`create\` or \`replace\`, page metadata, ordered tabs, blocks, fields, actions, inline popups, optional reusable assets) and optional top-level \`reaction.items[]\` for whole-page interaction authoring. Each reaction item targets an explicit local key / bind key produced by the same blueprint run. Only explicitly listed reaction items are written. \`rules: []\` clears the targeted slot. Repeating the same \`(type, target)\` reaction slot in one blueprint is invalid. In \`replace\`, reaction targets always bind to the newly produced blueprint result, not historical nodes from the previous page version; if a slot must exist in the resulting surface, include it explicitly instead of relying on omission. Localized reaction edits on an existing surface should use \`getReactionMeta\` + \`set*Rules\` instead of applying a whole page blueprint again. The request body is that page-document JSON object itself and must not be JSON-stringified. Wrong: \`{ "requestBody": "{\\"version\\":\\"1\\"}" }\`. Internal planning details stay hidden. In \`create\`, \`navigation.layoutUid\` optionally scopes menu group reuse, duplicate page identity checks, and newly created routes to an enabled UI layout such as \`admin-layout-model\` or \`mobile-layout-model\`; when the target layout type is mobile, applyBlueprint ignores \`navigation.group\` and creates a root-level mobile tab page. When \`layoutUid\` is omitted, routes keep the existing admin/default inheritance behavior. In non-mobile \`create\`, \`navigation.group.routeId\` has the highest priority when targeting an existing menu group. If \`routeId\` is present, applyBlueprint ignores \`title\`, \`icon\`, \`tooltip\`, and \`hideInMenu\` on \`navigation.group\`; applyBlueprint create mode does not mutate existing group metadata, so callers should use \`updateMenu\` separately when that is required. When \`routeId\` is omitted and \`navigation.group.title\` is provided, applyBlueprint reuses one existing same-title group in the target layout when it is unique, creates a new group when none exists, and rejects ambiguous multi-match cases. Metadata such as \`icon\`, \`tooltip\`, and \`hideInMenu\` is used only when a new group is created and is ignored when an existing group is reused. \`replace\` uses \`target.pageSchemaUid\`, updates only the explicit page-level fields provided in \`page\`, maps blueprint tabs to existing route-backed tab slots by index, rewrites each slot in order, removes trailing old tabs, and appends extra new tabs when needed. Tab and block keys are optional in the public blueprint; omit them unless custom layout or cross-block targeting needs a stable in-document identifier. \`layout\` is only allowed on tabs and inline popup documents; blocks themselves do not accept a \`layout\` property. Public applyBlueprint blocks do not support generic \`form\`; use \`editForm\` or \`createForm\`. AI employee actions use \`type: "aiEmployee"\` plus public \`settings.username\`, \`workContext\`, \`tasks\`, \`auto\`, and \`style\`; work context may target \`self\` or a same-blueprint block key and is persisted as real Flow Model \`uid\` values. For JS blocks/fields/actions, \`script\` is a non-empty string asset key into \`assets.scripts\`; put inline JS in \`settings.code\` and \`settings.version\`. Direct \`table\` / \`list\` / \`gridCard\` / \`calendar\` / \`kanban\` blocks may omit \`defaultFilter\`; the backend generates one from live metadata with up to 4 scalar/filterable fields. Explicit values must contain at least the smaller of 3 and the collection eligible-field count, and values with more than 4 fields are truncated before persistence. A valid explicit or generated block-level value backfills the default \`filter\` action \`settings.defaultFilter\`; explicit filter-action \`settings.defaultFilter\` still wins. ${TREE_TABLE_RECORD_ACTION_DEFAULTS_NOTE} ${APPLY_BLUEPRINT_TREE_TABLE_TITLE_FIELD_NOTE} Inline popup documents may set \`popup.tryTemplate=true\` to ask the backend for the best compatible popup template before falling back to local popup content. Inline popup documents may also combine \`popup.tryTemplate\` with \`popup.saveAsTemplate={ name, description, local? }\`: a hit binds the matched template immediately and lets later inline popups in the same blueprint reuse that final bound template through \`popup.template={ local, mode }\`, while a miss requires explicit local \`popup.blocks\` so the fallback popup can be saved and reused. Custom \`edit\` popups that provide \`popup.blocks\` must include exactly one \`editForm\` block; that \`editForm\` may omit \`resource\` and then inherits the opener's current-record context. When layout is omitted, applyBlueprint auto-generates a simple top-to-bottom layout. When a \`replace\` run expands a page to multiple tabs while the current page still has \`enableTabs=false\`, callers must set \`page.enableTabs=true\` explicitly. The response hides execution internals and returns only the resolved page target and final surface readback.`, ), requestBody: { required: true, @@ -841,7 +841,7 @@ const actionDocs: Record = { tags: [FLOW_SURFACES_TAG], summary: 'Create a group menu or a bindable V2 menu item', description: valuesCompatibilityNote( - 'Creates a FlowSurfaces menu node. `type="group"` creates a menu group. `type="item"` creates a menu item that can be bound to a modern page (v2), and automatically fills in the flowPage route, the default hidden tab route, and the RootPageModel anchor.', + 'Creates a FlowSurfaces menu node. `type="group"` creates a menu group. `type="item"` creates a menu item that can be bound to a modern page (v2), and automatically fills in the flowPage route, the default hidden tab route, and the RootPageModel anchor. Optional `layoutUid` scopes the route to an enabled UI layout; omitted values keep the existing parent/default admin layout inheritance.', ), requestBody: requestBody('FlowSurfaceCreateMenuRequest', examples.createMenu), responses: responses('FlowSurfaceCreateMenuResult'), @@ -859,7 +859,7 @@ const actionDocs: Record = { tags: [FLOW_SURFACES_TAG], summary: 'Initialize a modern page for an existing bindable menu item', description: valuesCompatibilityNote( - 'Initializes a modern page (v2) for an existing bindable menu item through `menuRouteId` first, and fills in the default BlockGridModel. In compatibility mode, if `menuRouteId` is omitted, the old behavior still applies and a top-level menu plus page will be created automatically. Before initialization, do not call page/tab lifecycle actions such as `addTab`, `updateTab`, `moveTab`, `removeTab`, or `destroyPage`.', + 'Initializes a modern page (v2) for an existing bindable menu item through `menuRouteId` first, and fills in the default BlockGridModel. Optional `layoutUid` asserts that the existing menu route belongs to the target UI layout; omitted values keep the existing parent/default admin layout inheritance. In compatibility mode, if `menuRouteId` is omitted, the old behavior still applies and a top-level menu plus page will be created automatically. Before initialization, do not call page/tab lifecycle actions such as `addTab`, `updateTab`, `moveTab`, `removeTab`, or `destroyPage`.', ), requestBody: requestBody('FlowSurfaceCreatePageRequest', examples.createPage), responses: responses('FlowSurfaceCreatePageResult'), @@ -4247,12 +4247,12 @@ const schemas = { routeId: { ...STRING_OR_INTEGER_SCHEMA, description: - 'Preferred existing menu-group route id. When present, routeId has the highest priority and title/icon/tooltip/hideInMenu are ignored. applyBlueprint create mode does not mutate existing group metadata; use low-level updateMenu separately when needed.', + 'Preferred existing menu-group route id for non-mobile create mode. When present, routeId has the highest priority and title/icon/tooltip/hideInMenu are ignored. Ignored when navigation.layoutUid targets a mobile layout because mobile pages are created as root tabs. applyBlueprint create mode does not mutate existing group metadata; use low-level updateMenu separately when needed.', }, title: { type: 'string', description: - 'Group title for create mode. When `routeId` is omitted, applyBlueprint reuses a same-title group if the match is unique, creates one when no group exists, and rejects ambiguous multi-match cases. If an existing group is reused, group metadata is ignored; use low-level updateMenu to change existing group metadata.', + 'Group title for non-mobile create mode. When `routeId` is omitted, applyBlueprint reuses a same-title group if the match is unique, creates one when no group exists, and rejects ambiguous multi-match cases. Ignored when navigation.layoutUid targets a mobile layout because mobile pages are created as root tabs. If an existing group is reused, group metadata is ignored; use low-level updateMenu to change existing group metadata.', }, icon: { type: 'string', @@ -4275,6 +4275,11 @@ const schemas = { FlowSurfaceApplyBlueprintNavigation: { type: 'object', properties: { + layoutUid: { + type: 'string', + description: + 'Optional enabled UI layout uid that scopes create-mode route lookup and newly created routes. Use `mobile-layout-model` for mobile pages; mobile layouts ignore navigation.group and create a root-level tab page. When omitted, the server preserves the existing parent/default admin layout behavior.', + }, group: ref('FlowSurfaceApplyBlueprintNavigationGroup'), item: { type: 'object', @@ -4498,7 +4503,7 @@ const schemas = { FlowSurfaceApplyBlueprintRequest: { type: 'object', required: ['mode', 'tabs'], - description: `Simplified page-structure request object for applyBlueprint. \`version\` may be omitted and defaults to '1'. Runtime validation enforces mode-specific rules: create does not accept target, while replace requires target.pageSchemaUid and does not use navigation. For JS blocks/fields/actions, \`script\` is a non-empty string asset key into \`assets.scripts\`, and referenced script assets must provide non-empty \`code\`; put inline JS in \`settings.code\` and \`settings.version\`. ${TREE_TABLE_RECORD_ACTION_DEFAULTS_NOTE} ${APPLY_BLUEPRINT_TREE_TABLE_TITLE_FIELD_NOTE} \`defaults.collections\` may provide main data-source collection-level fieldGroups, popup metadata with required \`name\` and \`description\`, and formBehavior for generated default add/edit popup forms; use \`defaults.dataSources..collections\` for external data sources. v1 does not support \`defaults.blocks\`.`, + description: `Simplified page-structure request object for applyBlueprint. \`version\` may be omitted and defaults to '1'. Runtime validation enforces mode-specific rules: create does not accept target, while replace requires target.pageSchemaUid and does not use navigation. In create mode, \`navigation.layoutUid\` scopes menu group reuse, duplicate page identity, and newly created routes to the target layout; use \`mobile-layout-model\` for mobile pages, where \`navigation.group\` is ignored and the page is created as a root tab, and omit it for default admin behavior. For JS blocks/fields/actions, \`script\` is a non-empty string asset key into \`assets.scripts\`, and referenced script assets must provide non-empty \`code\`; put inline JS in \`settings.code\` and \`settings.version\`. ${TREE_TABLE_RECORD_ACTION_DEFAULTS_NOTE} ${APPLY_BLUEPRINT_TREE_TABLE_TITLE_FIELD_NOTE} \`defaults.collections\` may provide main data-source collection-level fieldGroups, popup metadata with required \`name\` and \`description\`, and formBehavior for generated default add/edit popup forms; use \`defaults.dataSources..collections\` for external data sources. v1 does not support \`defaults.blocks\`.`, properties: { version: { type: 'string', @@ -4671,6 +4676,11 @@ const schemas = { type: 'object', required: ['title'], properties: { + layoutUid: { + type: 'string', + description: + 'Optional enabled UI layout uid for the created menu route, for example `mobile-layout-model`. When omitted, the route inherits its parent layout or the default admin layout.', + }, title: { type: 'string', }, @@ -4767,6 +4777,11 @@ const schemas = { FlowSurfaceCreatePageRequest: { type: 'object', properties: { + layoutUid: { + type: 'string', + description: + 'Optional enabled UI layout uid. With `menuRouteId`, the existing route must already belong to this layout. Without `menuRouteId`, the compatibility create-menu fallback creates the route in this layout.', + }, menuRouteId: STRING_OR_INTEGER_SCHEMA, pageSchemaUid: { type: 'string',