fix(plugin-ui-layout): backfill admin routes (#10405)

* fix(plugin-ui-layout): backfill admin routes

* test(plugin-ui-layout): verify upgrade backfill
This commit is contained in:
Zeke Zhang
2026-08-24 01:03:04 +08:00
committed by GitHub
parent 916a11c0d4
commit 8cdf59d662
2 changed files with 274 additions and 0 deletions
@@ -0,0 +1,237 @@
/**
* 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 { createMockServer, type MockServer } from '@nocobase/test';
import { DEFAULT_ADMIN_UI_LAYOUT, DEFAULT_MOBILE_UI_LAYOUT } from '../../constants';
import BackfillAdminLayoutDesktopRoutesMigration from '../migrations/20260615090000-backfill-admin-layout-desktop-routes';
import BackfillLateAdminLayoutDesktopRoutesMigration from '../migrations/20260823160000-backfill-late-admin-layout-desktop-routes';
const UI_LAYOUT_MIGRATION_NAMESPACE = '@nocobase/plugin-ui-layout';
const EARLY_ADMIN_ROUTE_MIGRATION_NAME = `20260615090000-backfill-admin-layout-desktop-routes/${UI_LAYOUT_MIGRATION_NAMESPACE}`;
const LATE_ADMIN_ROUTE_MIGRATION_NAME = `20260823160000-backfill-late-admin-layout-desktop-routes/${UI_LAYOUT_MIGRATION_NAMESPACE}`;
describe('plugin-ui-layout migrations', () => {
let app: MockServer | undefined;
afterEach(async () => {
await app?.destroy();
app = undefined;
});
it('should repair AdminLayout routes created after afterSync migrations without changing other layout owners', async () => {
app = await createMockServer({
plugins: [
'error-handler',
'users',
'auth',
'client',
'field-sort',
'acl',
'ui-schema-storage',
'system-settings',
'data-source-main',
'data-source-manager',
'ui-layout',
],
});
const earlyMigration = new BackfillAdminLayoutDesktopRoutesMigration({ db: app.db, app } as never);
expect(earlyMigration.on).toBe('afterSync');
await earlyMigration.up();
const lateGroup = await app.db.getRepository('desktopRoutes').create({
values: {
type: 'group',
title: 'late legacy group',
schemaUid: 'late-legacy-group',
},
});
const latePage = await app.db.getRepository('desktopRoutes').create({
values: {
type: 'page',
title: 'late legacy page',
schemaUid: 'late-legacy-page',
parentId: lateGroup.get('id'),
},
});
const lateTab = await app.db.getRepository('desktopRoutes').create({
values: {
type: 'tabs',
title: 'late legacy tab',
schemaUid: 'late-legacy-tab',
parentId: latePage.get('id'),
},
});
const mobileOwnedRoute = await app.db.getRepository('desktopRoutes').create({
values: {
type: 'page',
title: 'mobile owned route',
schemaUid: 'mobile-owned-route',
},
});
await app.db.getRepository('desktopRoutes.uiLayouts', mobileOwnedRoute.get('id')).set({
tk: [DEFAULT_MOBILE_UI_LAYOUT.uid],
});
await app.db.getRepository('uiLayouts').destroy({
filterByTk: DEFAULT_ADMIN_UI_LAYOUT.uid,
});
const migration = new BackfillLateAdminLayoutDesktopRoutesMigration({ db: app.db, app } as never);
expect(migration.on).toBe('afterLoad');
await migration.up();
await migration.up();
const routes = await Promise.all(
[lateGroup, latePage, lateTab, mobileOwnedRoute].map((route) =>
app.db.getRepository('desktopRoutes').findOne({
filterByTk: route.get('id'),
appends: ['uiLayouts'],
}),
),
);
const layoutUids = routes.map(
(route) => route?.get('uiLayouts').map((layout: { get: (field: string) => unknown }) => layout.get('uid')),
);
const restoredAdminLayout = await app.db.getRepository('uiLayouts').findOne({
filterByTk: DEFAULT_ADMIN_UI_LAYOUT.uid,
});
expect(restoredAdminLayout).not.toBeNull();
expect(layoutUids).toEqual([
[DEFAULT_ADMIN_UI_LAYOUT.uid],
[DEFAULT_ADMIN_UI_LAYOUT.uid],
[DEFAULT_ADMIN_UI_LAYOUT.uid],
[DEFAULT_MOBILE_UI_LAYOUT.uid],
]);
});
it('should reject instead of marking an incomplete schema backfill as successful', async () => {
const migration = new BackfillLateAdminLayoutDesktopRoutesMigration({
db: {
getCollection: () => undefined,
},
app: {},
} as never);
await expect(migration.up()).rejects.toThrow(
'The desktopRoutes collection is required to backfill late AdminLayout routes',
);
});
it.runIf(process.env.DB_DIALECT === 'postgres')(
'should discover and run the late backfill through the real upgrade lifecycle',
async () => {
app = await createMockServer({
registerActions: true,
acl: true,
plugins: [
'error-handler',
'users',
'auth',
'client',
'field-sort',
'acl',
'ui-schema-storage',
'system-settings',
'data-source-main',
'data-source-manager',
'ui-layout',
],
});
const lateRoute = await app.db.getRepository('desktopRoutes').create({
values: {
type: 'page',
title: 'late route repaired by upgrade',
schemaUid: 'late-route-repaired-by-upgrade',
},
});
const routeId = lateRoute.get('id');
await app.db.getRepository('migrations').create({
values: {
name: EARLY_ADMIN_ROUTE_MIGRATION_NAME,
},
});
const rootUserBeforeUpgrade = await app.db.getRepository('users').findOne({
filter: {
'roles.name': 'root',
},
});
const agentBeforeUpgrade = await app.agent().login(rootUserBeforeUpgrade);
const managementResponseBeforeUpgrade = await agentBeforeUpgrade.resource('desktopRoutes').list({
filter: {
id: routeId,
'uiLayouts.uid': DEFAULT_ADMIN_UI_LAYOUT.uid,
},
paginate: false,
sort: ['sort'],
tree: true,
});
const accessibleResponseBeforeUpgrade = await agentBeforeUpgrade.get('/desktopRoutes:listAccessible').query({
layout: DEFAULT_ADMIN_UI_LAYOUT.uid,
filter: {
id: routeId,
},
});
const pendingMigrationBeforeUpgrade = await app.db.getRepository('migrations').findOne({
filterByTk: LATE_ADMIN_ROUTE_MIGRATION_NAME,
});
expect(managementResponseBeforeUpgrade.status).toBe(200);
expect(accessibleResponseBeforeUpgrade.status).toBe(200);
expect(managementResponseBeforeUpgrade.body.data).toEqual([]);
expect(accessibleResponseBeforeUpgrade.body.data).toEqual([]);
expect(pendingMigrationBeforeUpgrade).toBeNull();
await app.upgrade();
const [executedMigration, repairedRoute, rootUserAfterUpgrade] = await Promise.all([
app.db.getRepository('migrations').findOne({
filterByTk: LATE_ADMIN_ROUTE_MIGRATION_NAME,
}),
app.db.getRepository('desktopRoutes').findOne({
filterByTk: routeId,
appends: ['uiLayouts'],
}),
app.db.getRepository('users').findOne({
filter: {
'roles.name': 'root',
},
}),
]);
const agentAfterUpgrade = await app.agent().login(rootUserAfterUpgrade);
const managementResponseAfterUpgrade = await agentAfterUpgrade.resource('desktopRoutes').list({
filter: {
id: routeId,
'uiLayouts.uid': DEFAULT_ADMIN_UI_LAYOUT.uid,
},
paginate: false,
sort: ['sort'],
tree: true,
});
const accessibleResponseAfterUpgrade = await agentAfterUpgrade.get('/desktopRoutes:listAccessible').query({
layout: DEFAULT_ADMIN_UI_LAYOUT.uid,
filter: {
id: routeId,
},
});
expect(executedMigration?.get('name')).toBe(LATE_ADMIN_ROUTE_MIGRATION_NAME);
expect(
repairedRoute?.get('uiLayouts').map((layout: { get: (field: string) => unknown }) => layout.get('uid')),
).toEqual([DEFAULT_ADMIN_UI_LAYOUT.uid]);
expect(managementResponseAfterUpgrade.status).toBe(200);
expect(accessibleResponseAfterUpgrade.status).toBe(200);
expect(managementResponseAfterUpgrade.body.data.map((route: { id: number }) => route.id)).toEqual([routeId]);
expect(accessibleResponseAfterUpgrade.body.data.map((route: { id: number }) => route.id)).toEqual([routeId]);
},
);
});
@@ -0,0 +1,37 @@
/**
* 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 type { Database } from '@nocobase/database';
import { Migration } from '@nocobase/server';
import { ensureDefaultUiLayout } from '../ensureDefaultUiLayout';
import { backfillAdminLayoutDesktopRoutes } from './20260615090000-backfill-admin-layout-desktop-routes';
function assertDesktopRouteLayoutSchema(db: Database) {
const desktopRoutes = db.getCollection('desktopRoutes');
if (!desktopRoutes) {
throw new Error('The desktopRoutes collection is required to backfill late AdminLayout routes');
}
if (!db.getCollection('uiLayouts')) {
throw new Error('The uiLayouts collection is required to backfill late AdminLayout routes');
}
if (!desktopRoutes.getField('uiLayouts')) {
throw new Error('The desktopRoutes.uiLayouts relation is required to backfill late AdminLayout routes');
}
}
export default class extends Migration {
// The legacy menu migration creates desktop routes during afterLoad, after the original afterSync backfill.
on = 'afterLoad';
async up() {
assertDesktopRouteLayoutSchema(this.db);
await ensureDefaultUiLayout(this.db);
await backfillAdminLayoutDesktopRoutes(this.db);
}
}