fix: mobile page can't be created by flow surface api (#9911)

* fix: mobile ui builder api

* fix: mobile action
This commit is contained in:
gchust
2026-06-28 21:18:01 +08:00
committed by GitHub
parent a712defb79
commit 45cf93d7c4
14 changed files with 1125 additions and 42 deletions
@@ -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<AIEmployee> & Pick<AIEmployee, 'username'>;
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 (
<AIEmployeeShortcut
aiEmployee={triggerAiEmployee}
tasks={tasks}
size={style?.size || 40}
mask={style?.mask}
showNotice={showNotice}
onClick={() => triggerTask({ aiEmployee: triggerAiEmployee, tasks, auto })}
onTaskClick={(task) => triggerTask({ aiEmployee: triggerAiEmployee, tasks: [task] })}
/>
);
};
export class AIEmployeeButtonModel extends FlowModel {
declare props: AIEmployeeButtonModelProps;
render() {
return <AIEmployeeButton model={this} />;
}
}
export default AIEmployeeButtonModel;
@@ -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,
@@ -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) {
@@ -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', () => {
@@ -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, {
@@ -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();
});
});
@@ -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');
});
});
@@ -89,7 +89,8 @@ export interface FlowSurfaceAuthoringValidationContext {
applyBlueprintScriptAssets?: Record<string, any>;
getCollection?: (dataSourceKey: string, collectionName: string) => any;
getDefaultFieldGroups?: (dataSourceKey: string, collectionName: string) => any;
findMenuGroupRoutesByTitle?: (title: string, transaction?: any) => Promise<any[]>;
findMenuGroupRoutesByTitle?: (title: string, transaction?: any, layoutUid?: string | string[]) => Promise<any[]>;
getUiLayoutTypeByUid?: (layoutUid: string, transaction?: any) => Promise<string | undefined>;
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;
}
@@ -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,
@@ -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
: (() => {
@@ -30,6 +30,7 @@ export type FlowSurfaceApplyBlueprintNavigationGroup = {
};
export type FlowSurfaceApplyBlueprintNavigation = {
layoutUid?: string;
group?: FlowSurfaceApplyBlueprintNavigationGroup;
item?: {
title?: string;
@@ -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<string, any> = _.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<string, any>, 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<string, any>, 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<string, any>, containerUse?: string) {
if (isCompactRecordActionContainerUse(containerUse)) {
return {
...props,
type: 'link',
@@ -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<string[]> {
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<string[]> {
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<string, any>,
parentRoute: any,
actionName: string,
transaction?: any,
): Promise<string[]> {
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<string, any>, 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<string, any>, 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<string, any>) {
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<FlowSurfaceApplyBlueprintDocument> {
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<FlowSurfaceApplyBlueprintProgram> {
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]) {
File diff suppressed because one or more lines are too long