mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-21 20:20:03 +02:00
feat: Derive app pages from the built app's routes (no-changelog) (#38191)
This commit is contained in:
parent
3ba3ce4056
commit
1f474ffac2
|
|
@ -187,6 +187,8 @@ export {
|
|||
type AppVersion,
|
||||
appThemeSchema,
|
||||
type AppTheme,
|
||||
appRouteSchema,
|
||||
type AppRoute,
|
||||
} from './schemas/app.schema';
|
||||
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
136
packages/cli/src/modules/apps/__tests__/derive-routes.test.ts
Normal file
136
packages/cli/src/modules/apps/__tests__/derive-routes.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
146
packages/cli/src/modules/apps/derive-routes.ts
Normal file
146
packages/cli/src/modules/apps/derive-routes.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
141
packages/frontend/editor-ui/src/features/apps/PageView.test.ts
Normal file
141
packages/frontend/editor-ui/src/features/apps/PageView.test.ts
Normal 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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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})`;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user