feat: Derive app pages from the built app's routes (no-changelog) (#38191)

This commit is contained in:
Bernhard Wittmann 2026-09-09 16:34:29 +02:00 committed by GitHub
parent 3ba3ce4056
commit 1f474ffac2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1060 additions and 488 deletions

View File

@ -187,6 +187,8 @@ export {
type AppVersion,
appThemeSchema,
type AppTheme,
appRouteSchema,
type AppRoute,
} from './schemas/app.schema';
export {

View File

@ -58,3 +58,13 @@ export const appVersionSchema = z.object({
});
export type AppVersion = z.infer<typeof appVersionSchema>;
// A page derived from the app's own `src/router.ts`, not a `Page` DB row —
// `id` is synthesized from the route's own full path, since there is no row.
export const appRouteSchema = z.object({
id: z.string(),
parentPageId: z.string().nullable(),
route: z.string(),
});
export type AppRoute = z.infer<typeof appRouteSchema>;

View File

@ -119,7 +119,6 @@
"tsconfig-paths": "^4.2.0",
"@vitest/coverage-v8": "catalog:",
"@typescript/native": "catalog:typescript-tooling",
"typescript": "catalog:typescript-tooling",
"vite": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:"
@ -279,6 +278,7 @@
"swagger-ui-express": "5.0.1",
"tar": "catalog:",
"turndown": "catalog:",
"typescript": "catalog:typescript-tooling",
"uuid": "catalog:",
"validator": "13.15.22",
"ws": "8.20.1",

View File

@ -5,8 +5,10 @@ import { Header } from 'tar';
import { mock } from 'vitest-mock-extended';
import type { AppVersionBlobStore } from '../app-version-blob-store';
import type { AppVersion } from '../app-version.entity';
import type { AppVersionRepository } from '../app-version.repository';
import { AppVersionService } from '../app-version.service';
import type { App } from '../app.entity';
import type { AppRepository } from '../app.repository';
import { AppBlobSizeQuotaExceededError } from '../errors/app-blob-size-quota-exceeded.error';
import { AppVersionQuotaExceededError } from '../errors/app-version-quota-exceeded.error';
@ -161,4 +163,41 @@ describe('AppVersionService', () => {
);
});
});
describe('readRouterSource', () => {
const app = { id: 'app-1', activeVersionId: 'v1' } as App;
const version = {
id: 'v1',
appId: 'app-1',
storedAt: 'db' as const,
sourceStorageKey: 'src-key',
distStorageKey: null,
} as AppVersion;
it("returns the active version's src/router.ts", async () => {
appVersionRepository.findById.mockResolvedValue(version);
blobStore.readAsBuffer.mockResolvedValue(
tgz({ './src/router.ts': 'export const router = createRouter({ routes: [] });' }),
);
const result = await service.readRouterSource(app);
expect(result).toContain('createRouter');
});
it('returns null when the app has no version yet', async () => {
appVersionRepository.listByAppId.mockResolvedValue([]);
expect(
await service.readRouterSource({ id: 'app-1', activeVersionId: null } as App),
).toBeNull();
});
it('returns null when the source has no src/router.ts', async () => {
appVersionRepository.findById.mockResolvedValue(version);
blobStore.readAsBuffer.mockResolvedValue(tgz({ './package.json': '{}' }));
expect(await service.readRouterSource(app)).toBeNull();
});
});
});

View File

@ -0,0 +1,136 @@
import { deriveRoutesFromRouterSource } from '../derive-routes';
/** The id scheme is an implementation detail; tests key off the tree shape instead. */
function byRoute(routes: ReturnType<typeof deriveRoutesFromRouterSource>, route: string) {
const page = routes.find((r) => r.route === route);
if (!page) throw new Error(`No derived page with route "${route}" in ${JSON.stringify(routes)}`);
return page;
}
describe('deriveRoutesFromRouterSource', () => {
it('derives the index route from the template default', () => {
const source = `
import { createRouter, createWebHistory } from 'vue-router';
import Home from './pages/Home.vue';
export const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [{ path: '/', component: Home }],
});
`;
const routes = deriveRoutesFromRouterSource(source);
expect(routes).toHaveLength(1);
expect(byRoute(routes, '').parentPageId).toBeNull();
});
it('gives every page a single-URL-segment id, never empty — vue-router\'s :pageId param can\'t hold "/" or ""', () => {
const source = `
export const router = createRouter({
routes: [
{ path: '/', component: Home },
{
path: '/clients',
component: Clients,
children: [{ path: ':id', component: ClientDetail }],
},
],
});
`;
for (const page of deriveRoutesFromRouterSource(source)) {
expect(page.id).not.toBe('');
expect(page.id).not.toContain('/');
}
});
it('derives flat top-level routes', () => {
const source = `
export const router = createRouter({
routes: [
{ path: '/', component: Home },
{ path: '/clients', component: Clients },
],
});
`;
const routes = deriveRoutesFromRouterSource(source);
expect(routes).toHaveLength(2);
expect(byRoute(routes, '').parentPageId).toBeNull();
expect(byRoute(routes, 'clients').parentPageId).toBeNull();
});
it('derives nested children, including dynamic segments', () => {
const source = `
export const router = createRouter({
routes: [
{
path: '/clients',
component: Clients,
children: [{ path: ':id', component: ClientDetail }],
},
],
});
`;
const routes = deriveRoutesFromRouterSource(source);
const clients = byRoute(routes, 'clients');
const clientDetail = byRoute(routes, ':id');
expect(clients.parentPageId).toBeNull();
expect(clientDetail.parentPageId).toBe(clients.id);
});
it('nests a flat multi-segment path under its prefix, merging with an existing sibling', () => {
// Two independent top-level entries sharing a path prefix — however
// router.ts declares it (flat siblings here, `children` above), a
// path that extends another one is a sub-page of it, not an unrelated peer.
const source = `
export const router = createRouter({
routes: [
{ path: '/success', component: Success },
{ path: '/success/:type', component: SuccessType },
],
});
`;
const routes = deriveRoutesFromRouterSource(source);
expect(routes).toHaveLength(2);
const success = byRoute(routes, 'success');
const successType = byRoute(routes, ':type');
expect(success.parentPageId).toBeNull();
expect(successType.parentPageId).toBe(success.id);
});
it('synthesizes an intermediate page for a multi-segment path with no shorter sibling', () => {
const source = `
export const router = createRouter({
routes: [{ path: '/success/:type', component: SuccessType }],
});
`;
const routes = deriveRoutesFromRouterSource(source);
expect(routes).toHaveLength(2);
const success = byRoute(routes, 'success');
const successType = byRoute(routes, ':type');
expect(success.parentPageId).toBeNull();
expect(successType.parentPageId).toBe(success.id);
});
it('returns an empty list when there is no createRouter call', () => {
expect(deriveRoutesFromRouterSource('export const x = 1;')).toEqual([]);
});
it('returns an empty list when routes is not a plain array literal', () => {
const source = `
const extra = [{ path: '/extra', component: Extra }];
export const router = createRouter({
routes: [{ path: '/', component: Home }, ...extra],
});
`;
// The spread element isn't an object literal with a `path`, so it's skipped;
// the static entry before it is still picked up.
const routes = deriveRoutesFromRouterSource(source);
expect(routes).toHaveLength(1);
expect(byRoute(routes, '')).toBeDefined();
});
});

View File

@ -5,7 +5,7 @@ import { generateNanoId } from '@n8n/utils/generate-nano-id';
import { InstanceSettings } from 'n8n-core';
import { UnexpectedError } from 'n8n-workflow';
import { randomUUID } from 'node:crypto';
import { mkdir, rename, rm, stat } from 'node:fs/promises';
import { mkdir, readFile, rename, rm, stat } from 'node:fs/promises';
import path from 'node:path';
import { extract as extractTar, list as listTar } from 'tar';
@ -125,12 +125,17 @@ export class AppVersionService {
return await this.appVersionRepository.listByAppId(appId);
}
/** Source tarball of the active version, or of the newest version when none is active. */
async readSource(app: App): Promise<{ versionId: string; data: Buffer } | null> {
/** The active version, or the newest version when none is active; `null` when the app has none yet. */
private async resolveVersion(app: App): Promise<AppVersion | null> {
const active = app.activeVersionId
? await this.appVersionRepository.findById(app.activeVersionId)
: null;
const version = active ?? (await this.appVersionRepository.listByAppId(app.id))[0];
return active ?? (await this.appVersionRepository.listByAppId(app.id))[0] ?? null;
}
/** Source tarball of the active version, or of the newest version when none is active. */
async readSource(app: App): Promise<{ versionId: string; data: Buffer } | null> {
const version = await this.resolveVersion(app);
if (!version) return null;
const data = await this.blobStore.readAsBuffer({
@ -140,6 +145,38 @@ export class AppVersionService {
return data ? { versionId: version.id, data } : null;
}
/**
* `src/router.ts` of the same version `readSource` would return, or `null`
* when the app has no version, or that version's source has no
* `src/router.ts` (e.g. an app scaffolded with `template: "none"`).
* Extracted to a throwaway directory and discarded once read — unlike
* `distDir`, nothing here is served repeatedly, so there's nothing worth
* caching.
*/
async readRouterSource(app: App): Promise<string | null> {
const source = await this.readSource(app);
if (!source) return null;
const tempDir = path.join(this.instanceSettings.n8nFolder, 'apps', `${randomUUID()}-src-tmp`);
await mkdir(tempDir, { recursive: true });
try {
// Source tarballs hold arbitrary project files, not just the served
// dist output, so extraction only needs the dist filter's path-safety
// checks — its file-type/size limits are dist-specific and don't apply.
await this.extract(
source.data,
tempDir,
createDistTarFilter({ ...DIST_TAR_LIMITS, maxBytes: MAX_UNPACKED_BYTES.source }),
);
return await readFile(path.join(tempDir, 'src', 'router.ts'), 'utf8');
} catch (error) {
if (isErrnoCode(error, ['ENOENT'])) return null;
throw error;
} finally {
await rm(tempDir, { recursive: true, force: true });
}
}
toResponse(version: AppVersion): AppVersionResponse {
return {
id: version.id,
@ -253,8 +290,12 @@ export class AppVersionService {
return entries;
}
private async extract(tarball: Buffer, cwd: string): Promise<void> {
const unpack = extractTar({ cwd, strip: 0, filter: createDistTarFilter() });
private async extract(
tarball: Buffer,
cwd: string,
filter: ReturnType<typeof createDistTarFilter> = createDistTarFilter(),
): Promise<void> {
const unpack = extractTar({ cwd, strip: 0, filter });
await new Promise<void>((resolve, reject) => {
unpack.on('error', reject);
unpack.on('end', resolve);

View File

@ -248,6 +248,17 @@ export class AppsController {
return await this.appsService.listPages(appId);
}
/** The app's real pages, derived from its source; what the builder UI shows. */
@Get('/:appId/routes')
@ProjectScope('app:read')
async listRoutes(
_req: AuthenticatedRequest<{ projectId: string }>,
_res: Response,
@Param('appId') appId: string,
) {
return await this.appsService.listRoutes(appId);
}
@Patch('/:appId/pages/:pageId')
@ProjectScope('app:update')
async updatePage(

View File

@ -7,6 +7,7 @@ import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { AppVersionService } from './app-version.service';
import { AppRepository } from './app.repository';
import { deriveRoutesFromRouterSource } from './derive-routes';
import { AppNotFoundError } from './errors/app-not-found.error';
import { AppQuotaExceededError } from './errors/app-quota-exceeded.error';
import { DataWorkflowNotFoundError } from './errors/data-workflow-not-found.error';
@ -94,6 +95,17 @@ export class AppsService {
return await this.pageRepository.findManyByAppId(appId);
}
/**
* The app's real pages, derived from its source's `src/router.ts` rather
* than the (unused) `Page` table — `[]` when the app has no version yet,
* or that version's source has no `src/router.ts`.
*/
async listRoutes(appId: string) {
const app = await this.getApp(appId);
const routerSource = await this.appVersionService.readRouterSource(app);
return routerSource ? deriveRoutesFromRouterSource(routerSource) : [];
}
/** Scoped to `appId` so a pageId from a different app is treated as not found, not just unauthorized. */
async getPage(appId: string, pageId: string) {
const page = await this.pageRepository.findOneBy({ id: pageId });

View File

@ -0,0 +1,146 @@
import ts from 'typescript';
/** One page derived from a router route entry. Shaped like `PageNode` so the same tree logic applies. */
export type DerivedRoute = {
id: string;
parentPageId: string | null;
route: string;
};
const stripSlashes = (path: string) => path.replace(/^\/+|\/+$/g, '');
/** "/success/:type" -> ["success", ":type"]; "/" or "" (the index route) -> []. */
function splitSegments(path: string): string[] {
const stripped = stripSlashes(path);
return stripped === '' ? [] : stripped.split('/');
}
/** The index route's stand-in id: base64url of its (empty) path is itself empty. */
const INDEX_ID = '__index__';
/**
* `id` doubles as vue-router's `:pageId` param on the frontend, which can't
* contain '/' — base64url-encoding the node's full path keeps it a single
* URL segment regardless of nesting depth, and is deterministic across
* requests (this is recomputed from source on every read, not stored).
*/
function nodeId(fullSegments: string[]): string {
const path = fullSegments.join('/');
return path === '' ? INDEX_ID : Buffer.from(path).toString('base64url');
}
/**
* Builds the page tree from full URL paths, trie-merged on shared prefixes —
* so "/success" and "/success/:type" end up as parent and child here
* regardless of whether `router.ts` declared the second via `children` or
* as an unrelated flat entry that merely happens to extend the first one's
* path. A prefix with no route of its own (e.g. only "/success/:type" is
* declared, no bare "/success") still gets a synthetic node, since the UI
* needs somewhere to attach the child.
*/
class RouteTree {
private readonly nodes = new Map<string, DerivedRoute>();
add(fullSegments: string[]): void {
this.getOrCreate(fullSegments);
}
toList(): DerivedRoute[] {
return [...this.nodes.values()];
}
private getOrCreate(fullSegments: string[]): DerivedRoute {
const path = fullSegments.join('/');
const existing = this.nodes.get(path);
if (existing) return existing;
const parentSegments = fullSegments.slice(0, -1);
const parentPageId = parentSegments.length > 0 ? this.getOrCreate(parentSegments).id : null;
const node: DerivedRoute = {
id: nodeId(fullSegments),
parentPageId,
route: fullSegments[fullSegments.length - 1] ?? '',
};
this.nodes.set(path, node);
return node;
}
}
const stringLiteralValue = (node: ts.Expression): string | undefined =>
ts.isStringLiteralLike(node) ? node.text : undefined;
function findProperty(object: ts.ObjectLiteralExpression, name: string): ts.Expression | undefined {
for (const property of object.properties) {
if (
ts.isPropertyAssignment(property) &&
ts.isIdentifier(property.name) &&
property.name.text === name
) {
return property.initializer;
}
}
return undefined;
}
function walkRoutes(
elements: ts.NodeArray<ts.Expression>,
parentSegments: string[],
tree: RouteTree,
): void {
for (const element of elements) {
if (!ts.isObjectLiteralExpression(element)) continue;
const pathExpr = findProperty(element, 'path');
const path = pathExpr && stringLiteralValue(pathExpr);
if (path === undefined) continue;
const fullSegments = [...parentSegments, ...splitSegments(path)];
tree.add(fullSegments);
const childrenExpr = findProperty(element, 'children');
if (childrenExpr && ts.isArrayLiteralExpression(childrenExpr)) {
walkRoutes(childrenExpr.elements, fullSegments, tree);
}
}
}
/**
* Parses the `routes` array passed to `createRouter(...)` in an app's
* `src/router.ts` — without executing any code — into the same
* `{ id, parentPageId, route }` tree shape the (unused) DB `Page` model used.
*
* Only understands the literal shape the `app-builder` skill instructs the
* AI to write: a flat array of `{ path, component }` objects, optionally
* nested under `children`. A `router.ts` that isn't in this shape (or isn't
* there at all) yields `[]`, not an error — arbitrary dynamic route
* construction isn't supported, and doesn't need to be.
*/
export function deriveRoutesFromRouterSource(source: string): DerivedRoute[] {
const sourceFile = ts.createSourceFile(
'router.ts',
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
);
const tree = new RouteTree();
function visit(node: ts.Node): void {
if (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === 'createRouter' &&
node.arguments.length > 0 &&
ts.isObjectLiteralExpression(node.arguments[0])
) {
const routesExpr = findProperty(node.arguments[0], 'routes');
if (routesExpr && ts.isArrayLiteralExpression(routesExpr)) {
walkRoutes(routesExpr.elements, [], tree);
}
return;
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return tree.toList();
}

View File

@ -5187,25 +5187,16 @@
"apps.pages.empty": "No pages yet",
"apps.page.new": "New page",
"apps.page.addChild": "Add sub page",
"apps.page.edit": "Edit page",
"apps.page.index": "index",
"apps.page.add.input.route.label": "Route",
"apps.page.add.input.route.placeholder": "my-page",
"apps.page.add.input.route.hint": "Leave blank to make this the index page — the page shown when no further path is given, e.g. the app's own home page at the top level. Use :name (e.g. :id) for a dynamic segment, such as /clients/:id.",
"apps.page.add.input.route.subPage.hint": "Use :name (e.g. :id) for a dynamic segment, such as /clients/:id. A sub-page needs a route of its own.",
"apps.page.add.error": "Error creating page",
"apps.page.delete.confirm.title": "Delete page",
"apps.page.delete.confirm.message": "Are you sure you want to delete \"{name}\"? This will also delete any sub-pages. This can't be undone.",
"apps.page.delete.error": "Error deleting page",
"apps.page.notFound": "Page not found",
"apps.page.getDetails.error": "Error loading page",
"apps.page.content.placeholder": "Page content editor coming soon",
"apps.page.input.route.label": "Route",
"apps.page.input.dataWorkflow.label": "Data workflow",
"apps.page.input.dataWorkflow.placeholder": "Select a workflow",
"apps.page.input.dataWorkflow.hint": "This workflow runs to fetch the page's data. Only workflows that start with an Execute Workflow Trigger show up here.",
"apps.page.content.placeholder": "Ask the AI Assistant to add content to this page",
"apps.page.url.label": "Page URL",
"apps.page.save": "Save",
"apps.page.save.error": "Error saving page",
"apps.page.assistant.add.root": "Add a new page to this app.",
"apps.page.assistant.add.child": "Add a new page nested under \"{path}\" in this app.",
"apps.page.assistant.edit": "Update the page at \"{path}\" in this app.",
"apps.page.assistant.delete": "Remove the page at \"{path}\" from this app.",
"dataTable.dataTables": "Data tables",
"dataTables.noResults": "No data tables found",
"dataTable.empty.heading": "Create your first data table",

View File

@ -1143,6 +1143,17 @@ function handleAgentPreviewAssistantHandoff(params: AgentPreviewHandoffParams) {
}
}
/**
* A page +/edit/delete click inside the embedded app builder (this thread is
* already bound to the app, via `InstanceAiAppPreview`'s own target sync) —
* fill the already-open composer instead of opening another thread.
*/
function handleAppPreviewAssistantHandoff(prompt: string) {
stashPendingComposerDraft(props.threadId, prompt);
pendingComposerDraft.value = prompt;
void nextTick(() => chatInputRef.value?.focus());
}
/**
* Reveal the agent artifact, then hand off to the builder to select its Evals
* tab and generate. Generation deliberately stays in the builder: it already
@ -1579,6 +1590,7 @@ async function dismissComposerContextChip() {
:project-id="preview.activeAppProjectId.value"
:version-id="preview.activeAppVersionId.value ?? undefined"
:building="preview.activeAppBuilding.value"
@assistant-handoff="handleAppPreviewAssistantHandoff"
/>
</div>
</TabsRoot>

View File

@ -17,6 +17,8 @@ const props = defineProps<{
building?: boolean;
}>();
defineEmits<{ 'assistant-handoff': [prompt: string] }>();
const thread = useThread();
const instanceAiStore = useInstanceAiStore();
const i18n = useI18n();
@ -67,6 +69,7 @@ watch(() => props.appId, syncAppTarget, { immediate: true });
:project-id="props.projectId"
:app-id="props.appId"
:artifact-version-id="props.versionId"
@assistant-handoff="$emit('assistant-handoff', $event)"
/>
</div>
</template>

View File

@ -441,6 +441,7 @@ export function useInstanceAiHandoff() {
async function openAppArtifactThread(
attachment: InstanceAiAppAttachment,
launch: InstanceAiThreadLaunch,
options?: { initialDraft?: string },
): Promise<boolean> {
if (!instanceAiReady.value) {
await routeToSetup();
@ -472,6 +473,7 @@ export function useInstanceAiHandoff() {
}
}
stashPendingAppAttachment(threadId, attachment);
if (options?.initialDraft) stashPendingComposerDraft(threadId, options.initialDraft);
try {
const failure = await router.push({
name: INSTANCE_AI_THREAD_VIEW,

View File

@ -244,6 +244,54 @@ describe('AppDetailsView', () => {
expect(getByTestId('app-theme-editor-stub')).toBeInTheDocument();
});
it('hands adding a root page off to the assistant with a pre-filled, unsent prompt', async () => {
const { getByTestId } = await renderApp(makeApp());
await userEvent.click(getByTestId('app-page-add-root'));
expect(openAppArtifactThread).toHaveBeenCalledWith(
{ type: 'app', appId: 'app-1', projectId: 'proj-1', name: 'Greeter' },
{ source: 'app_builder_page', origin: 'internal', sourceContext: { appId: 'app-1' } },
{ initialDraft: 'Add a new page to this app.' },
);
});
it("hands a page card's add/edit/delete actions off to the assistant with the page's route", async () => {
appsStore.pages = [{ id: 'p1', parentPageId: null, route: 'clients' }];
const { getByTestId } = await renderApp(makeApp());
await userEvent.click(getByTestId('app-page-add-child'));
expect(openAppArtifactThread).toHaveBeenLastCalledWith(expect.anything(), expect.anything(), {
initialDraft: 'Add a new page nested under "/clients" in this app.',
});
await userEvent.click(getByTestId('app-page-edit'));
expect(openAppArtifactThread).toHaveBeenLastCalledWith(expect.anything(), expect.anything(), {
initialDraft: 'Update the page at "/clients" in this app.',
});
await userEvent.click(getByTestId('app-page-delete'));
expect(openAppArtifactThread).toHaveBeenLastCalledWith(expect.anything(), expect.anything(), {
initialDraft: 'Remove the page at "/clients" from this app.',
});
});
it("shows a page's direct children indented right beneath it, and acts on their own route", async () => {
appsStore.pages = [
{ id: 'p1', parentPageId: null, route: 'clients' },
{ id: 'p2', parentPageId: 'p1', route: ':id' },
];
const { getAllByTestId } = await renderApp(makeApp());
const routes = getAllByTestId('app-page-route').map((el) => el.textContent);
expect(routes).toEqual(['/clients', '/:id']);
await userEvent.click(getAllByTestId('app-page-edit')[1]);
expect(openAppArtifactThread).toHaveBeenLastCalledWith(expect.anything(), expect.anything(), {
initialDraft: 'Update the page at "/clients/:id" in this app.',
});
});
it('prefers the thread build over the stored version and switches to Preview on the first build', async () => {
const { getByTestId, queryByTestId, rerender } = await renderApp(makeApp(), {
artifactMode: true,

View File

@ -16,16 +16,16 @@ import { useRouter } from 'vue-router';
import CopyInput from '@/app/components/CopyInput.vue';
import PageViewLayout from '@/app/components/layouts/PageViewLayout.vue';
import { useUIStore } from '@/app/stores/ui.store';
import AppBreadcrumbs from '@/features/apps/AppBreadcrumbs.vue';
import PageCard from '@/features/apps/PageCard.vue';
import AppPreviewFrame from '@/features/apps/components/AppPreviewFrame.vue';
import AppThemeEditor from '@/features/apps/components/AppThemeEditor.vue';
import { useAppsStore } from '@/features/apps/apps.store';
import { useAppDeletion } from '@/features/apps/useAppDeletion';
import { ADD_PAGE_MODAL_KEY, APP_PAGE_DETAILS, PROJECT_APPS } from '@/features/apps/apps.constants';
import { useAppPageAssistant } from '@/features/apps/useAppPageAssistant';
import { APP_PAGE_DETAILS, PROJECT_APPS } from '@/features/apps/apps.constants';
import type { App } from '@/features/apps/apps.types';
import { getChildCounts } from '@/features/apps/pageTree.utils';
import { buildPageRows, getChildCounts, getFullRoutePath } from '@/features/apps/pageTree.utils';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { useInstanceAiAvailable } from '@/features/ai/instanceAi/composables/useInstanceAiAvailability';
import { useInstanceAiHandoff } from '@/features/ai/instanceAi/composables/useInstanceAiHandoff';
@ -48,12 +48,14 @@ const props = withDefaults(
{ artifactMode: false, artifactVersionId: undefined },
);
const emit = defineEmits<{ 'assistant-handoff': [prompt: string] }>();
const i18n = useI18n();
const toast = useToast();
const router = useRouter();
const uiStore = useUIStore();
const documentTitle = useDocumentTitle();
const { confirmAndDeleteApp, confirmAndDeletePage } = useAppDeletion();
const { confirmAndDeleteApp } = useAppDeletion();
const { requestPageChange } = useAppPageAssistant();
const instanceAiAvailable = useInstanceAiAvailable();
const { openAppArtifactThread } = useInstanceAiHandoff();
@ -69,6 +71,10 @@ const previewFrame = useTemplateRef<InstanceType<typeof AppPreviewFrame>>('previ
const rootPages = computed(() => appsStore.pages.filter((page) => page.parentPageId === null));
const childCounts = computed(() => getChildCounts(appsStore.pages));
// The tree, indented, down to MAX_INLINE_PAGE_LEVELS deep — deeper pages
// still exist, just require opening their nearest shown ancestor to reach.
const pageRows = computed(() => buildPageRows(appsStore.pages, null));
const appUrl = computed(() =>
app.value ? `${window.location.origin}/apps/${app.value.namespace}/` : '',
);
@ -118,12 +124,11 @@ const initialize = async () => {
}
};
const openAddPageModal = (parentPageId: string | null) => {
uiStore.openModalWithData({
name: ADD_PAGE_MODAL_KEY,
data: { projectId: props.projectId, appId: props.appId, parentPageId },
});
};
// Embedded inside the app's own Instance AI thread: fill its composer
// directly rather than opening another one — see `handleAppPreviewAssistantHandoff`.
const onEmbeddedDraft = props.artifactMode
? (prompt: string) => emit('assistant-handoff', prompt)
: undefined;
const openPage = async (pageId: string) => {
await router.push({
@ -138,8 +143,45 @@ const onDeleteApp = async () => {
if (deleted) await router.push({ name: PROJECT_APPS, params: { projectId: props.projectId } });
};
const onAddRootPage = async () => {
if (!app.value) return;
await requestPageChange('add-root', app.value, '', onEmbeddedDraft);
};
const onAddChildPage = async (parentPageId: string) => {
if (!app.value) return;
const parent = appsStore.pages.find((page) => page.id === parentPageId);
if (!parent) return;
await requestPageChange(
'add-child',
app.value,
getFullRoutePath(appsStore.pages, parent),
onEmbeddedDraft,
);
};
const onEditPage = async (pageId: string) => {
if (!app.value) return;
const page = appsStore.pages.find((p) => p.id === pageId);
if (!page) return;
await requestPageChange(
'edit',
app.value,
getFullRoutePath(appsStore.pages, page),
onEmbeddedDraft,
);
};
const onDeletePage = async (pageId: string) => {
await confirmAndDeletePage(props.projectId, props.appId, pageId);
if (!app.value) return;
const page = appsStore.pages.find((p) => p.id === pageId);
if (!page) return;
await requestPageChange(
'delete',
app.value,
getFullRoutePath(appsStore.pages, page),
onEmbeddedDraft,
);
};
const onDeviceChange = (value: unknown) => {
@ -318,11 +360,7 @@ watch(versionId, (next, previous) => {
<div v-if="buildTab === 'pages'" :class="$style.container">
<div :class="$style.header">
<N8nText tag="h2" size="medium" bold>{{ i18n.baseText('apps.pages') }}</N8nText>
<N8nButton
size="small"
data-test-id="app-page-add-root"
@click="openAddPageModal(null)"
>
<N8nButton size="small" data-test-id="app-page-add-root" @click="onAddRootPage">
{{ i18n.baseText('apps.page.new') }}
</N8nButton>
</div>
@ -333,12 +371,14 @@ watch(versionId, (next, previous) => {
<div :class="$style.pageGrid">
<PageCard
v-for="page in rootPages"
:key="page.id"
:page="page"
:child-count="childCounts.get(page.id) ?? 0"
v-for="row in pageRows"
:key="row.page.id"
:page="row.page"
:indent="row.indent"
:child-count="childCounts.get(row.page.id) ?? 0"
@open="openPage"
@add-child="openAddPageModal"
@add-child="onAddChildPage"
@edit="onEditPage"
@delete="onDeletePage"
/>
</div>

View File

@ -8,14 +8,20 @@ import { formatRoutePath } from '@/features/apps/pageTree.utils';
const i18n = useI18n();
const props = defineProps<{
page: Page;
childCount: number;
}>();
const props = withDefaults(
defineProps<{
page: Page;
childCount: number;
/** How many levels below the top of this list the card sits — 0 for a top-level row. */
indent?: number;
}>(),
{ indent: 0 },
);
defineEmits<{
open: [pageId: string];
addChild: [parentPageId: string];
edit: [pageId: string];
delete: [pageId: string];
}>();
@ -25,12 +31,27 @@ defineEmits<{
const routeLabel = computed(() =>
formatRoutePath(props.page.route, i18n.baseText('apps.page.index')),
);
// A flex-column parent stretches this card to its own full width, so the
// indent has to come out of that width too, not just shift the box right —
// otherwise the right edge overflows the container.
const cardStyle = computed(() => {
if (!props.indent) return undefined;
const indent = `calc(${props.indent} * var(--spacing--lg))`;
return { marginLeft: indent, width: `calc(100% - ${indent})` };
});
</script>
<template>
<N8nCard hoverable :class="$style.card" data-test-id="page-card" @click="$emit('open', page.id)">
<N8nCard
hoverable
:class="$style.card"
:style="cardStyle"
data-test-id="page-card"
@click="$emit('open', page.id)"
>
<template #prepend>
<N8nIcon icon="file" />
<N8nIcon :icon="indent > 0 ? 'corner-down-right' : 'file'" />
</template>
<N8nText bold data-test-id="app-page-route">{{ routeLabel }}</N8nText>
<N8nText v-if="childCount > 0" color="text-light" size="small">
@ -49,6 +70,17 @@ const routeLabel = computed(() =>
@click="$emit('addChild', page.id)"
/>
</N8nTooltip>
<N8nTooltip :content="i18n.baseText('apps.page.edit')">
<N8nButton
icon-only
icon="pencil"
size="small"
variant="subtle"
:aria-label="i18n.baseText('apps.page.edit')"
data-test-id="app-page-edit"
@click="$emit('edit', page.id)"
/>
</N8nTooltip>
<N8nTooltip :content="i18n.baseText('generic.delete')">
<N8nButton
icon-only

View File

@ -0,0 +1,141 @@
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { createMemoryHistory, createRouter } from 'vue-router';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore, waitAllPromises } from '@/__tests__/utils';
import PageView from './PageView.vue';
import { useAppsStore } from './apps.store';
import { APP_DETAILS, APP_PAGE_DETAILS } from './apps.constants';
import type { App } from './apps.types';
const openAppArtifactThread = vi.hoisted(() => vi.fn());
vi.mock('@n8n/composables/useToast', () => ({
useToast: () => ({ showError: vi.fn(), showMessage: vi.fn() }),
}));
vi.mock('@/app/composables/useDocumentTitle', () => ({
useDocumentTitle: () => ({ set: vi.fn() }),
}));
vi.mock('@/features/ai/instanceAi/composables/useInstanceAiHandoff', () => ({
useInstanceAiHandoff: () => ({ openAppArtifactThread }),
}));
const router = createRouter({
history: createMemoryHistory(),
routes: [
{
path: '/projects/:projectId/apps/:appId',
name: APP_DETAILS,
component: { template: '<div />' },
},
{
path: '/projects/:projectId/apps/:appId/pages/:pageId',
name: APP_PAGE_DETAILS,
component: { template: '<div />' },
},
],
});
const renderComponent = createComponentRenderer(PageView, {
global: {
plugins: [router],
stubs: {
PageViewLayout: {
template: '<div data-test-id="page-view-layout"><slot name="header" /><slot /></div>',
},
AppBreadcrumbs: { template: '<nav data-test-id="app-breadcrumbs" />' },
},
},
});
function makeApp(overrides: Partial<App> = {}): App {
return {
id: 'app-1',
name: 'Greeter',
namespace: 'greeter',
theme: null,
projectId: 'proj-1',
activeVersionId: null,
createdAt: '2026-04-01T00:00:00.000Z',
updatedAt: '2026-04-01T00:00:00.000Z',
...overrides,
};
}
describe('PageView', () => {
let appsStore: ReturnType<typeof mockedStore<typeof useAppsStore>>;
beforeEach(async () => {
createTestingPinia();
openAppArtifactThread.mockReset();
await router.push('/projects/proj-1/apps/app-1/pages/p1');
await router.isReady();
appsStore = mockedStore(useAppsStore);
appsStore.fetchPages.mockResolvedValue(undefined);
});
async function renderPage(
app: App,
pages = [{ id: 'p1', parentPageId: null, route: 'clients' }],
) {
appsStore.pages = pages;
appsStore.getApp.mockResolvedValue(app);
const rendered = renderComponent({
props: { projectId: 'proj-1', appId: app.id, pageId: 'p1' },
});
await waitAllPromises();
return rendered;
}
it('opens on Build when the app has no version yet', async () => {
const { getByTestId, queryByTestId } = await renderPage(makeApp());
expect(getByTestId('page-content-placeholder')).toBeInTheDocument();
expect(queryByTestId('instance-ai-app-preview-iframe')).not.toBeInTheDocument();
});
it("opens on Preview and shows the active version scoped to this page's own path", async () => {
const { getByTestId } = await renderPage(makeApp({ activeVersionId: 'v-7' }));
expect(getByTestId('instance-ai-app-preview-iframe')).toHaveAttribute(
'src',
'/apps/greeter/clients?v=v-7',
);
});
it('switches between Build and Preview from the mode control', async () => {
const { getByTestId, queryByTestId } = await renderPage(makeApp({ activeVersionId: 'v-7' }));
await userEvent.click(getByTestId('radio-button-build'));
expect(getByTestId('page-content-placeholder')).toBeInTheDocument();
expect(queryByTestId('instance-ai-app-preview-iframe')).not.toBeInTheDocument();
await userEvent.click(getByTestId('radio-button-preview'));
expect(getByTestId('instance-ai-app-preview-iframe')).toBeInTheDocument();
});
it('hands off to the assistant from the empty preview state', async () => {
const { getByTestId } = await renderPage(makeApp());
await userEvent.click(getByTestId('radio-button-preview'));
expect(getByTestId('page-preview-empty')).toBeInTheDocument();
await userEvent.click(getByTestId('page-preview-empty-edit'));
expect(openAppArtifactThread).toHaveBeenLastCalledWith(expect.anything(), expect.anything(), {
initialDraft: 'Update the page at "/clients" in this app.',
});
});
it('hands off to the assistant from the content placeholder in Build', async () => {
const { getByTestId } = await renderPage(makeApp());
await userEvent.click(getByTestId('page-content-edit'));
expect(openAppArtifactThread).toHaveBeenLastCalledWith(expect.anything(), expect.anything(), {
initialDraft: 'Update the page at "/clients" in this app.',
});
});
});

View File

@ -1,14 +1,5 @@
<script setup lang="ts">
import {
N8nButton,
N8nInput,
N8nOption,
N8nSelect,
N8nSettingsRow,
N8nSettingsRowGroup,
N8nSettingsSection,
N8nText,
} from '@n8n/design-system';
import { N8nButton, N8nSegmentControl, N8nText, N8nTooltip } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@n8n/composables/useToast';
import { computed, onMounted, ref, watch } from 'vue';
@ -16,18 +7,21 @@ import { useRouter } from 'vue-router';
import CopyInput from '@/app/components/CopyInput.vue';
import PageViewLayout from '@/app/components/layouts/PageViewLayout.vue';
import { useUIStore } from '@/app/stores/ui.store';
import AppBreadcrumbs from '@/features/apps/AppBreadcrumbs.vue';
import PageCard from '@/features/apps/PageCard.vue';
import AppPreviewFrame from '@/features/apps/components/AppPreviewFrame.vue';
import { useAppsStore } from '@/features/apps/apps.store';
import { useAppDeletion } from '@/features/apps/useAppDeletion';
import { ADD_PAGE_MODAL_KEY, APP_DETAILS, APP_PAGE_DETAILS } from '@/features/apps/apps.constants';
import { useAppPageAssistant } from '@/features/apps/useAppPageAssistant';
import { APP_DETAILS, APP_PAGE_DETAILS } from '@/features/apps/apps.constants';
import type { App } from '@/features/apps/apps.types';
import {
buildPageRows,
formatRoutePath,
getAncestorPages,
getChildCounts,
getFullRoutePath,
getPageUrl,
joinRouteSegments,
} from '@/features/apps/pageTree.utils';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
@ -40,27 +34,41 @@ const props = defineProps<{
const i18n = useI18n();
const toast = useToast();
const router = useRouter();
const uiStore = useUIStore();
const documentTitle = useDocumentTitle();
const { confirmAndDeletePage } = useAppDeletion();
const { requestPageChange } = useAppPageAssistant();
const appsStore = useAppsStore();
type BuilderMode = 'build' | 'preview';
const app = ref<App | null>(null);
const route = ref('');
const dataWorkflowId = ref<string | null>(null);
const loading = ref(false);
const saving = ref(false);
const mode = ref<BuilderMode>('build');
const versionId = computed(() => app.value?.activeVersionId ?? undefined);
const modeOptions = computed(() => [
{ label: i18n.baseText('apps.builder.build'), value: 'build' as const },
{ label: i18n.baseText('apps.builder.preview'), value: 'preview' as const },
]);
const ancestorPages = computed(() => getAncestorPages(appsStore.pages, props.pageId));
const pageUrl = computed(() => {
if (!app.value) return '';
const ancestors = getAncestorPages(appsStore.pages, props.pageId);
return getPageUrl(app.value.namespace, ancestors, route.value);
return getPageUrl(app.value.namespace, ancestorPages.value, route.value);
});
const childPages = computed(() =>
appsStore.pages.filter((page) => page.parentPageId === props.pageId),
);
// Every ancestor's route plus this page's own — what a +/edit/delete prompt
// names the page as, so the assistant can't mistake it for a different page
// that happens to share a route segment (e.g. two different "loading" pages).
const fullPath = computed(() => joinRouteSegments(ancestorPages.value, route.value));
// The descendants of this page, indented, down to MAX_INLINE_PAGE_LEVELS
// deep — deeper pages still exist, just require opening their nearest
// shown ancestor to reach.
const pageRows = computed(() => buildPageRows(appsStore.pages, props.pageId));
const childCounts = computed(() => getChildCounts(appsStore.pages));
@ -80,9 +88,6 @@ const initialize = async () => {
appsStore.pages.length === 0
? appsStore.fetchPages(props.projectId, props.appId)
: Promise.resolve(),
appsStore.dataWorkflowOptions.length === 0
? appsStore.fetchDataWorkflows(props.projectId)
: Promise.resolve(),
]);
app.value = result;
const page = appsStore.pages.find((p) => p.id === props.pageId);
@ -91,7 +96,7 @@ const initialize = async () => {
return;
}
route.value = page.route;
dataWorkflowId.value = page.dataWorkflowId;
mode.value = versionId.value ? 'preview' : 'build';
documentTitle.set(formatRoutePath(route.value, i18n.baseText('apps.page.index')));
} catch (error) {
await showErrorAndGoBack(error);
@ -100,35 +105,14 @@ const initialize = async () => {
}
};
const onSave = async () => {
saving.value = true;
try {
await appsStore.updatePage(props.projectId, props.appId, props.pageId, {
route: route.value,
dataWorkflowId: dataWorkflowId.value,
});
} catch (error) {
toast.showError(error, i18n.baseText('apps.page.save.error'));
} finally {
saving.value = false;
}
const onEdit = async () => {
if (!app.value) return;
await requestPageChange('edit', app.value, fullPath.value);
};
const onDelete = async () => {
const deleted = await confirmAndDeletePage(props.projectId, props.appId, props.pageId);
if (deleted) {
await router.push({
name: APP_DETAILS,
params: { projectId: props.projectId, appId: props.appId },
});
}
};
const openAddPageModal = (parentPageId: string) => {
uiStore.openModalWithData({
name: ADD_PAGE_MODAL_KEY,
data: { projectId: props.projectId, appId: props.appId, parentPageId },
});
if (!app.value) return;
await requestPageChange('delete', app.value, fullPath.value);
};
const openPage = async (pageId: string) => {
@ -138,8 +122,31 @@ const openPage = async (pageId: string) => {
});
};
const onDeleteChildPage = async (pageId: string) => {
await confirmAndDeletePage(props.projectId, props.appId, pageId);
const onAddChildPage = async () => {
if (!app.value) return;
await requestPageChange('add-child', app.value, fullPath.value);
};
/** From a descendant card's own "+": adds a child of that specific card, not of the current page. */
const onAddChildUnder = async (parentPageId: string) => {
if (!app.value) return;
const page = appsStore.pages.find((p) => p.id === parentPageId);
if (!page) return;
await requestPageChange('add-child', app.value, getFullRoutePath(appsStore.pages, page));
};
const onEditDescendantPage = async (pageId: string) => {
if (!app.value) return;
const page = appsStore.pages.find((p) => p.id === pageId);
if (!page) return;
await requestPageChange('edit', app.value, getFullRoutePath(appsStore.pages, page));
};
const onDeleteDescendantPage = async (pageId: string) => {
if (!app.value) return;
const page = appsStore.pages.find((p) => p.id === pageId);
if (!page) return;
await requestPageChange('delete', app.value, getFullRoutePath(appsStore.pages, page));
};
onMounted(initialize);
@ -161,10 +168,24 @@ watch(() => props.pageId, initialize);
:app-name="app.name"
:current-page-id="pageId"
/>
<N8nSegmentControl
v-if="app"
v-model="mode"
:options="modeOptions"
size="small"
data-test-id="page-builder-mode"
/>
<div v-if="app" :class="$style.headerActions">
<N8nButton :loading="saving" data-test-id="page-save" @click="onSave">
{{ i18n.baseText('apps.page.save') }}
</N8nButton>
<N8nTooltip :content="i18n.baseText('apps.page.edit')">
<N8nButton
icon-only
icon="pencil"
variant="subtle"
:aria-label="i18n.baseText('apps.page.edit')"
data-test-id="page-edit"
@click="onEdit"
/>
</N8nTooltip>
<N8nButton
icon-only
icon="trash-2"
@ -186,77 +207,74 @@ watch(() => props.pageId, initialize);
/>
</div>
<N8nSettingsSection>
<N8nSettingsRowGroup>
<N8nSettingsRow
:title="i18n.baseText('apps.page.input.route.label')"
:description="i18n.baseText('apps.page.add.input.route.hint')"
:max-description-lines="3"
>
<template #action>
<N8nInput
v-model="route"
:placeholder="i18n.baseText('apps.page.add.input.route.placeholder')"
data-test-id="page-route-input"
/>
</template>
</N8nSettingsRow>
<N8nSettingsRow
:title="i18n.baseText('apps.page.input.dataWorkflow.label')"
:description="i18n.baseText('apps.page.input.dataWorkflow.hint')"
:max-description-lines="3"
>
<template #action>
<N8nSelect
v-model="dataWorkflowId"
clearable
filterable
:placeholder="i18n.baseText('apps.page.input.dataWorkflow.placeholder')"
data-test-id="page-data-workflow-select"
>
<N8nOption
v-for="option in appsStore.dataWorkflowOptions"
:key="option.id"
:value="option.id"
:label="option.name"
/>
</N8nSelect>
</template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
</N8nSettingsSection>
<div :class="$style.content" data-test-id="page-content-placeholder">
<AppPreviewFrame
v-if="mode === 'preview' && app && versionId"
:namespace="app.namespace"
:version-id="versionId"
:path="fullPath"
:class="$style.preview"
/>
<div
v-else-if="mode === 'preview' && !loading"
:class="$style.emptyState"
data-test-id="page-preview-empty"
>
<N8nText tag="h2" size="medium" bold>{{
i18n.baseText('apps.builder.empty.title')
}}</N8nText>
<N8nText color="text-light">{{ i18n.baseText('apps.page.content.placeholder') }}</N8nText>
</div>
<div :class="$style.header">
<N8nText tag="h2" size="medium" bold>{{ i18n.baseText('apps.page.subPages') }}</N8nText>
<N8nButton
v-if="route"
size="small"
data-test-id="page-add-child"
@click="openAddPageModal(pageId)"
icon="sparkles"
data-test-id="page-preview-empty-edit"
@click="onEdit"
>
{{ i18n.baseText('apps.page.new') }}
{{ i18n.baseText('apps.builder.openInAssistant') }}
</N8nButton>
</div>
<N8nText v-if="childPages.length === 0" color="text-light">
{{ i18n.baseText('apps.pages.empty') }}
</N8nText>
<template v-else-if="mode === 'build'">
<div :class="$style.content" data-test-id="page-content-placeholder">
<N8nButton
variant="subtle"
icon="sparkles"
data-test-id="page-content-edit"
@click="onEdit"
>
{{ i18n.baseText('apps.page.content.placeholder') }}
</N8nButton>
</div>
<div :class="$style.pageGrid">
<PageCard
v-for="page in childPages"
:key="page.id"
:page="page"
:child-count="childCounts.get(page.id) ?? 0"
@open="openPage"
@add-child="openAddPageModal"
@delete="onDeleteChildPage"
/>
</div>
<div :class="$style.header">
<N8nText tag="h2" size="medium" bold>{{ i18n.baseText('apps.page.subPages') }}</N8nText>
<N8nButton
v-if="route"
size="small"
data-test-id="page-add-child"
@click="onAddChildPage"
>
{{ i18n.baseText('apps.page.new') }}
</N8nButton>
</div>
<N8nText v-if="pageRows.length === 0" color="text-light">
{{ i18n.baseText('apps.pages.empty') }}
</N8nText>
<div :class="$style.pageGrid">
<PageCard
v-for="row in pageRows"
:key="row.page.id"
:page="row.page"
:indent="row.indent"
:child-count="childCounts.get(row.page.id) ?? 0"
@open="openPage"
@add-child="onAddChildUnder"
@edit="onEditDescendantPage"
@delete="onDeleteDescendantPage"
/>
</div>
</template>
</div>
</PageViewLayout>
</template>
@ -307,6 +325,27 @@ watch(() => props.pageId, initialize);
margin-bottom: var(--spacing--xs);
}
.preview {
flex: 1;
min-height: 400px;
border: var(--border);
border-radius: var(--radius--lg);
overflow: hidden;
background: var(--background--surface);
}
.emptyState {
flex: 1;
min-height: 400px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing--2xs);
padding: var(--spacing--lg);
text-align: center;
}
.pageGrid {
display: flex;
flex-direction: column;

View File

@ -1,14 +1,7 @@
import { makeRestApiRequest } from '@n8n/rest-api-client';
import type { IRestApiContext } from '@n8n/rest-api-client';
import type {
App,
AppTheme,
DataWorkflowOption,
Page,
UpdateAppInput,
UpdatePageInput,
} from '@/features/apps/apps.types';
import type { App, AppTheme, Page, UpdateAppInput } from '@/features/apps/apps.types';
export const fetchAppsApi = async (context: IRestApiContext, projectId: string) => {
return await makeRestApiRequest<App[]>(context, 'GET', `/projects/${projectId}/apps`);
@ -60,61 +53,15 @@ export const deleteAppApi = async (context: IRestApiContext, projectId: string,
await makeRestApiRequest(context, 'DELETE', `/projects/${projectId}/apps/${appId}`);
};
export const fetchPagesApi = async (context: IRestApiContext, projectId: string, appId: string) => {
/** The app's real pages, derived from its source — not the (unused) DB `Page` CRUD. */
export const fetchRoutesApi = async (
context: IRestApiContext,
projectId: string,
appId: string,
) => {
return await makeRestApiRequest<Page[]>(
context,
'GET',
`/projects/${projectId}/apps/${appId}/pages`,
);
};
export const createPageApi = async (
context: IRestApiContext,
projectId: string,
appId: string,
route: string,
parentPageId?: string,
) => {
return await makeRestApiRequest<Page>(
context,
'POST',
`/projects/${projectId}/apps/${appId}/pages`,
{ route, ...(parentPageId ? { parentPageId } : {}) },
);
};
export const updatePageApi = async (
context: IRestApiContext,
projectId: string,
appId: string,
pageId: string,
updates: UpdatePageInput,
) => {
return await makeRestApiRequest<Page>(
context,
'PATCH',
`/projects/${projectId}/apps/${appId}/pages/${pageId}`,
{ ...updates },
);
};
export const fetchDataWorkflowsApi = async (context: IRestApiContext, projectId: string) => {
return await makeRestApiRequest<DataWorkflowOption[]>(
context,
'GET',
`/projects/${projectId}/apps/data-workflows`,
);
};
export const deletePageApi = async (
context: IRestApiContext,
projectId: string,
appId: string,
pageId: string,
) => {
await makeRestApiRequest(
context,
'DELETE',
`/projects/${projectId}/apps/${appId}/pages/${pageId}`,
`/projects/${projectId}/apps/${appId}/routes`,
);
};

View File

@ -5,4 +5,3 @@ export const APP_PAGE_DETAILS = 'app-page-details';
export const APPS_STORE = 'appsStore';
export const ADD_APP_MODAL_KEY = 'addAppModal';
export const ADD_PAGE_MODAL_KEY = 'addPageModal';

View File

@ -5,32 +5,20 @@ import { ref } from 'vue';
import {
applyAppThemeApi,
createAppApi,
createPageApi,
deleteAppApi,
deletePageApi,
fetchAppsApi,
fetchDataWorkflowsApi,
fetchPagesApi,
fetchRoutesApi,
getAppApi,
updateAppApi,
updatePageApi,
} from '@/features/apps/apps.api';
import { APPS_STORE } from '@/features/apps/apps.constants';
import type {
App,
AppTheme,
DataWorkflowOption,
Page,
UpdateAppInput,
UpdatePageInput,
} from '@/features/apps/apps.types';
import type { App, AppTheme, Page, UpdateAppInput } from '@/features/apps/apps.types';
export const useAppsStore = defineStore(APPS_STORE, () => {
const rootStore = useRootStore();
const apps = ref<App[]>([]);
const pages = ref<Page[]>([]);
const dataWorkflowOptions = ref<DataWorkflowOption[]>([]);
const fetchApps = async (projectId: string) => {
apps.value = await fetchAppsApi(rootStore.restApiContext, projectId);
@ -64,53 +52,12 @@ export const useAppsStore = defineStore(APPS_STORE, () => {
};
const fetchPages = async (projectId: string, appId: string) => {
pages.value = await fetchPagesApi(rootStore.restApiContext, projectId, appId);
};
const createPage = async (
projectId: string,
appId: string,
route: string,
parentPageId?: string,
) => {
const page = await createPageApi(
rootStore.restApiContext,
projectId,
appId,
route,
parentPageId,
);
pages.value = [...pages.value, page];
return page;
};
const updatePage = async (
projectId: string,
appId: string,
pageId: string,
updates: UpdatePageInput,
) => {
const page = await updatePageApi(rootStore.restApiContext, projectId, appId, pageId, updates);
pages.value = pages.value.map((p) => (p.id === pageId ? page : p));
return page;
};
const fetchDataWorkflows = async (projectId: string) => {
dataWorkflowOptions.value = await fetchDataWorkflowsApi(rootStore.restApiContext, projectId);
};
const deletePage = async (projectId: string, appId: string, pageId: string) => {
await deletePageApi(rootStore.restApiContext, projectId, appId, pageId);
// Refetch rather than filter locally: deleting a page cascades to its
// sub-pages on the backend, and a shallow filter would leave those
// orphaned rows in the local list.
await fetchPages(projectId, appId);
pages.value = await fetchRoutesApi(rootStore.restApiContext, projectId, appId);
};
return {
apps,
pages,
dataWorkflowOptions,
fetchApps,
getApp,
createApp,
@ -118,9 +65,5 @@ export const useAppsStore = defineStore(APPS_STORE, () => {
applyAppTheme,
deleteApp,
fetchPages,
createPage,
updatePage,
deletePage,
fetchDataWorkflows,
};
});

View File

@ -21,26 +21,15 @@ export interface App {
updatedAt: string;
}
/**
* A page of an App, derived from its source's `src/router.ts` — not a DB
* row. `id` is synthesized from the route's own full path, since there is
* no row to key by.
*/
export interface Page {
id: string;
appId: string;
parentPageId: string | null;
route: string;
content: unknown[] | null;
dataWorkflowId: string | null;
createdAt: string;
updatedAt: string;
}
/** A workflow a page can wire up as its `dataWorkflowId`, i.e. it starts with a compatible trigger. */
export interface DataWorkflowOption {
id: string;
name: string;
}
export interface UpdatePageInput {
route?: string;
dataWorkflowId?: string | null;
}
export interface UpdateAppInput {

View File

@ -1,125 +0,0 @@
<script setup lang="ts">
import { N8nButton, N8nInput, N8nInputLabel, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@n8n/composables/useToast';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import Modal from '@/app/components/Modal.vue';
import { useAppsStore } from '@/features/apps/apps.store';
import { APP_PAGE_DETAILS } from '@/features/apps/apps.constants';
import { useUIStore } from '@/app/stores/ui.store';
type AddPageModalData = { projectId: string; appId: string; parentPageId: string | null };
const props = defineProps<{
modalName: string;
data: AddPageModalData;
}>();
const i18n = useI18n();
const toast = useToast();
const router = useRouter();
const uiStore = useUIStore();
const appsStore = useAppsStore();
// Only a top-level page can be the index page, so a sub-page is told it needs
// a route of its own rather than being offered a blank one the API rejects.
const routeHint = computed(() =>
i18n.baseText(
props.data.parentPageId
? 'apps.page.add.input.route.subPage.hint'
: 'apps.page.add.input.route.hint',
),
);
const route = ref('');
const isCreating = ref(false);
const routeInputRef = ref<HTMLInputElement | null>(null);
const onSubmit = async () => {
if (isCreating.value) return;
isCreating.value = true;
try {
const page = await appsStore.createPage(
props.data.projectId,
props.data.appId,
route.value,
props.data.parentPageId ?? undefined,
);
uiStore.closeModal(props.modalName);
await router.push({
name: APP_PAGE_DETAILS,
params: { projectId: props.data.projectId, appId: props.data.appId, pageId: page.id },
});
} catch (error) {
toast.showError(error, i18n.baseText('apps.page.add.error'));
} finally {
isCreating.value = false;
}
};
onMounted(() => {
setTimeout(() => routeInputRef.value?.focus(), 0);
});
</script>
<template>
<Modal :name="props.modalName" :center="true" width="480px" data-test-id="add-page-modal">
<template #header>
<h2>{{ i18n.baseText('apps.page.new') }}</h2>
</template>
<template #content>
<div :class="$style.content">
<N8nInputLabel
:label="i18n.baseText('apps.page.add.input.route.label')"
input-name="pageRoute"
>
<N8nInput
ref="routeInputRef"
v-model="route"
:placeholder="i18n.baseText('apps.page.add.input.route.placeholder')"
name="pageRoute"
data-test-id="app-page-new-route"
@keydown.enter="onSubmit"
/>
</N8nInputLabel>
<N8nText color="text-light" size="small">
{{ routeHint }}
</N8nText>
</div>
</template>
<template #footer>
<div :class="$style.footer">
<N8nButton
variant="subtle"
size="large"
:label="i18n.baseText('generic.cancel')"
data-test-id="app-page-new-cancel"
@click="uiStore.closeModal(props.modalName)"
/>
<N8nButton
:loading="isCreating"
size="large"
:label="i18n.baseText('apps.page.new')"
data-test-id="app-page-new-submit"
@click="onSubmit"
/>
</div>
</template>
</Modal>
</template>
<style lang="scss" module>
.content {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
}
.footer {
display: flex;
gap: var(--spacing--2xs);
justify-content: flex-end;
}
</style>

View File

@ -7,10 +7,12 @@ const props = withDefaults(
namespace: string;
/** Built version to show. The parent renders its own empty state when there is none. */
versionId: string;
/** A specific page's path within the app (e.g. "clients/:id"); the app root when omitted. */
path?: string;
/** CSS width of the document; `390px` mimics a phone. */
width?: string;
}>(),
{ width: '100%' },
{ path: '', width: '100%' },
);
const i18n = useI18n();
@ -28,7 +30,8 @@ watch(
// `v` busts the browser cache on every new build; `r` on every manual refresh.
const iframeSrc = computed(() => {
const base = `/apps/${props.namespace}/?v=${props.versionId}`;
const pathSegments = props.path.split('/').filter(Boolean).map(encodeURIComponent).join('/');
const base = `/apps/${props.namespace}/${pathSegments}?v=${props.versionId}`;
return refreshCount.value > 0 ? `${base}&r=${refreshCount.value}` : base;
});

View File

@ -1,6 +1,6 @@
import type { ModalDefinition } from '@n8n/frontend-module-sdk';
import { ADD_APP_MODAL_KEY, ADD_PAGE_MODAL_KEY } from './apps.constants';
import { ADD_APP_MODAL_KEY } from './apps.constants';
export const APPS_MODALS: ModalDefinition[] = [
{
@ -8,9 +8,4 @@ export const APPS_MODALS: ModalDefinition[] = [
component: async () => await import('./components/AddAppModal.vue'),
initialState: { open: false },
},
{
key: ADD_PAGE_MODAL_KEY,
component: async () => await import('./components/AddPageModal.vue'),
initialState: { open: false },
},
];

View File

@ -0,0 +1,50 @@
import { buildPageRows } from '@/features/apps/pageTree.utils';
import type { Page } from '@/features/apps/apps.types';
const page = (id: string, parentPageId: string | null, route: string): Page => ({
id,
parentPageId,
route,
});
describe('buildPageRows', () => {
// index -> clients -> :id -> edit -> confirm (4 levels deep)
const pages = [
page('index', null, ''),
page('clients', null, 'clients'),
page('client', 'clients', ':id'),
page('edit', 'client', 'edit'),
page('confirm', 'edit', 'confirm'),
];
it('shows up to 3 levels from the tree root, indenting each level', () => {
const rows = buildPageRows(pages, null);
expect(rows.map((row) => [row.page.id, row.indent])).toEqual([
['index', 0],
['clients', 0],
['client', 1],
['edit', 2],
]);
});
it('shows up to 3 levels from any page, not just the tree root', () => {
const rows = buildPageRows(pages, 'clients');
expect(rows.map((row) => [row.page.id, row.indent])).toEqual([
['client', 0],
['edit', 1],
['confirm', 2],
]);
});
it('respects a smaller explicit limit', () => {
const rows = buildPageRows(pages, 'clients', 1);
expect(rows.map((row) => row.page.id)).toEqual(['client']);
});
it('returns nothing for a page with no descendants', () => {
expect(buildPageRows(pages, 'confirm')).toEqual([]);
});
});

View File

@ -14,17 +14,28 @@ export function getAncestorPages(pages: Page[], pageId: string): Page[] {
return ancestors;
}
/** Every ancestor's route segment plus this one's own, joined with `/`. Index segments (empty route) contribute nothing. */
export function joinRouteSegments(ancestors: Page[], ownRoute: string): string {
return [...ancestors.map((p) => p.route), ownRoute].filter(Boolean).join('/');
}
/**
* The full served URL for a page: the app's namespace plus every ancestor's
* route segment plus this page's own route, joined with `/`. Index pages
* (empty route) contribute no segment of their own.
*/
export function getPageUrl(namespace: string, ancestors: Page[], ownRoute: string): string {
const segments = [...ancestors.map((p) => p.route), ownRoute].filter(Boolean);
const path = ['apps', namespace, ...segments].join('/');
const path = ['apps', namespace, joinRouteSegments(ancestors, ownRoute)]
.filter(Boolean)
.join('/');
return `${window.location.origin}/${path}`;
}
/** `page`'s full path within the app — every ancestor's route plus its own, joined with `/`. */
export function getFullRoutePath(pages: Page[], page: Page): string {
return joinRouteSegments(getAncestorPages(pages, page.id), page.route);
}
/** How many pages have each parent (keyed by parentPageId, null for root pages). */
export function getChildCounts(pages: Page[]): Map<string | null, number> {
const counts = new Map<string | null, number>();
@ -34,6 +45,36 @@ export function getChildCounts(pages: Page[]): Map<string | null, number> {
return counts;
}
/** How many levels of the tree a Pages list shows inline before requiring a click into a page. */
export const MAX_INLINE_PAGE_LEVELS = 3;
export type PageRow = { page: Page; indent: number };
/**
* `parentId`'s descendants, depth-first, up to `maxLevels` deep — each row
* carries how many levels below `parentId` it sits, for indenting. A page
* beyond `maxLevels` (and everything under it) is left out; drilling into
* its own PageView is how a user reaches it, same as any page never shown
* inline here.
*/
export function buildPageRows(
pages: Page[],
parentId: string | null,
maxLevels: number = MAX_INLINE_PAGE_LEVELS,
): PageRow[] {
const rows: PageRow[] = [];
const walk = (currentParentId: string | null, indent: number) => {
if (indent >= maxLevels) return;
for (const page of pages) {
if (page.parentPageId !== currentParentId) continue;
rows.push({ page, indent });
walk(page.id, indent + 1);
}
};
walk(parentId, 0);
return rows;
}
/** "foo" -> "foo"; "" -> "(index)" — no leading slash, for breadcrumb items. */
export function formatRouteSegment(route: string, indexLabel: string): string {
return route || `(${indexLabel})`;

View File

@ -5,31 +5,26 @@ import { MODAL_CONFIRM } from '@/app/constants';
import { useMessage } from '@/app/composables/useMessage';
import { escapeHtml } from '@/app/utils/htmlUtils';
import { useAppsStore } from '@/features/apps/apps.store';
import { formatRoutePath } from '@/features/apps/pageTree.utils';
import type { App } from '@/features/apps/apps.types';
/** Confirm-then-delete for Apps and Pages, shared by every view that offers a delete action. */
/** Confirm-then-delete an App. Pages are derived, not stored — deleting one is an Instance AI hand-off, not this. */
export function useAppDeletion() {
const i18n = useI18n();
const toast = useToast();
const message = useMessage();
const appsStore = useAppsStore();
const confirm = async (title: string, text: string) => {
const response = await message.confirm(text, title, {
confirmButtonText: i18n.baseText('generic.delete'),
cancelButtonText: i18n.baseText('generic.cancel'),
});
return response === MODAL_CONFIRM;
};
/** @returns whether the app was deleted (false if cancelled or the request failed) */
const confirmAndDeleteApp = async (projectId: string, app: App): Promise<boolean> => {
const confirmed = await confirm(
i18n.baseText('apps.delete.confirm.title'),
const response = await message.confirm(
i18n.baseText('apps.delete.confirm.message', { interpolate: { name: escapeHtml(app.name) } }),
i18n.baseText('apps.delete.confirm.title'),
{
confirmButtonText: i18n.baseText('generic.delete'),
cancelButtonText: i18n.baseText('generic.cancel'),
},
);
if (!confirmed) return false;
if (response !== MODAL_CONFIRM) return false;
try {
await appsStore.deleteApp(projectId, app.id);
@ -40,33 +35,5 @@ export function useAppDeletion() {
}
};
/** @returns whether the page was deleted (false if cancelled, not found, or the request failed) */
const confirmAndDeletePage = async (
projectId: string,
appId: string,
pageId: string,
): Promise<boolean> => {
const page = appsStore.pages.find((p) => p.id === pageId);
if (!page) return false;
const confirmed = await confirm(
i18n.baseText('apps.page.delete.confirm.title'),
i18n.baseText('apps.page.delete.confirm.message', {
interpolate: {
name: escapeHtml(formatRoutePath(page.route, i18n.baseText('apps.page.index'))),
},
}),
);
if (!confirmed) return false;
try {
await appsStore.deletePage(projectId, appId, pageId);
return true;
} catch (error) {
toast.showError(error, i18n.baseText('apps.page.delete.error'));
return false;
}
};
return { confirmAndDeleteApp, confirmAndDeletePage };
return { confirmAndDeleteApp };
}

View File

@ -0,0 +1,58 @@
import { useI18n } from '@n8n/i18n';
import { useInstanceAiHandoff } from '@/features/ai/instanceAi/composables/useInstanceAiHandoff';
import type { App } from '@/features/apps/apps.types';
import { formatRoutePath } from '@/features/apps/pageTree.utils';
export type PageAssistantAction = 'add-root' | 'add-child' | 'edit' | 'delete';
/**
* Pages are derived from the app's own source, not a DB row: there is
* nothing here to create/update/delete directly. Instead, +/edit/delete
* hand the change off to Instance AI as a pre-filled (unsent) prompt — the
* same mechanism `AddAppModal` already uses for creating an app.
*/
export function useAppPageAssistant() {
const i18n = useI18n();
const { openAppArtifactThread } = useInstanceAiHandoff();
/** `fullPath` is the page's whole path (every ancestor's route plus its own), not just its own segment — otherwise the AI has no way to tell which "loading" page, at which level, is meant. */
function buildPrompt(action: PageAssistantAction, fullPath: string): string {
const path = formatRoutePath(fullPath, i18n.baseText('apps.page.index'));
switch (action) {
case 'add-root':
return i18n.baseText('apps.page.assistant.add.root');
case 'add-child':
return i18n.baseText('apps.page.assistant.add.child', { interpolate: { path } });
case 'edit':
return i18n.baseText('apps.page.assistant.edit', { interpolate: { path } });
case 'delete':
return i18n.baseText('apps.page.assistant.delete', { interpolate: { path } });
}
}
/**
* `onEmbeddedDraft`, when given, fills the prompt into the app's already-open
* Instance AI thread instead of opening a new one — the caller passes this
* only when it is itself rendered inside that thread (`artifactMode`).
*/
async function requestPageChange(
action: PageAssistantAction,
app: App,
fullPath: string,
onEmbeddedDraft?: (prompt: string) => void,
): Promise<void> {
const prompt = buildPrompt(action, fullPath);
if (onEmbeddedDraft) {
onEmbeddedDraft(prompt);
return;
}
await openAppArtifactThread(
{ type: 'app', appId: app.id, projectId: app.projectId, name: app.name },
{ source: 'app_builder_page', origin: 'internal', sourceContext: { appId: app.id } },
{ initialDraft: prompt },
);
}
return { requestPageChange };
}

View File

@ -4618,6 +4618,9 @@ importers:
turndown:
specifier: 'catalog:'
version: 7.2.4
typescript:
specifier: catalog:typescript-tooling
version: '@typescript/typescript6@6.0.2'
uuid:
specifier: 'catalog:'
version: 11.1.1
@ -4811,9 +4814,6 @@ importers:
tsconfig-paths:
specifier: ^4.2.0
version: 4.2.0
typescript:
specifier: catalog:typescript-tooling
version: '@typescript/typescript6@6.0.2'
vite:
specifier: 'catalog:'
version: 8.0.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)