mirror of
https://github.com/nocobase/nocobase.git
synced 2026-09-24 16:02:20 +08:00
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:
+88
@@ -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,
|
||||
|
||||
+12
-1
@@ -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) {
|
||||
|
||||
+48
@@ -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', () => {
|
||||
|
||||
+12
@@ -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, {
|
||||
|
||||
+17
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+552
-1
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+31
-7
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+2
-1
@@ -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
|
||||
: (() => {
|
||||
|
||||
+1
@@ -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',
|
||||
|
||||
+300
-21
@@ -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
Reference in New Issue
Block a user