mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-19 08:46:56 +02:00
feat(core): Add Apps — a top-level App/Page entity with a first UI
Day 1 scaffold for the Webpage Node / App-concept Hackweek project: a project-scoped App entity and its Page tree, decoupled from the workflow canvas, plus enough editor UI to build one end-to-end. Backend (packages/cli/src/modules/apps, packages/@n8n/db migration): - App entity (namespace, theme JSON) and Page entity, pages forming a tree via a self-referencing parentPageId. A page's route can be a static slug, a dynamic :param segment (e.g. :id), or empty (meaning it's the index/home page for its level). - Business rules enforced server-side: a namespace is unique per project; a page's route is unique among its siblings; an index page (empty route) can't have children, since it contributes no path segment of its own and a child would collide with a sibling's URL. - New app:* scopes, an appId branch in userHasScopes, an opt-in backend module (apps) enabled by default alongside data-table. Frontend (packages/frontend/editor-ui/src/features/apps): - Apps list, App detail (page tree), and Page detail views, reachable both from the project view and the home overview tab bar. - Create App / Create Page via modals (matching Data Table's pattern), not inline forms. - Page hierarchy shown as a flat card grid per level with drill-down navigation (mirrors n8n's own Folders feature) instead of a fully expanded indented tree. - AppBreadcrumbs: App > ancestor pages > current page/app, each a link back up except the current one. - The App's and each Page's full served URL shown as a copyable field, live as the route is edited. - Delete (with confirmation) on Apps and Pages throughout. Goal package: goals/webpage-node/ (facts.md, plan.md, plan-week.md).
This commit is contained in:
parent
a0df5b34aa
commit
6fbdfcecdd
23
docs/generated/sqlite-schema/README.md
generated
23
docs/generated/sqlite-schema/README.md
generated
|
|
@ -39,6 +39,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
|
|||
| [agents_threads](agents_threads.md) | 6 | | table |
|
||||
| [ai_builder_temporary_workflow](ai_builder_temporary_workflow.md) | 4 | | table |
|
||||
| [annotation_tag_entity](annotation_tag_entity.md) | 4 | | table |
|
||||
| [app](app.md) | 7 | | table |
|
||||
| [auth_identity](auth_identity.md) | 5 | | table |
|
||||
| [auth_provider_sync_history](auth_provider_sync_history.md) | 11 | | table |
|
||||
| [binary_data](binary_data.md) | 9 | | table |
|
||||
|
|
@ -96,6 +97,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
|
|||
| [oauth_clients](oauth_clients.md) | 10 | | table |
|
||||
| [oauth_refresh_tokens](oauth_refresh_tokens.md) | 8 | | table |
|
||||
| [oauth_user_consents](oauth_user_consents.md) | 5 | | table |
|
||||
| [page](page.md) | 7 | | table |
|
||||
| [poller_state](poller_state.md) | 7 | | table |
|
||||
| [processed_data](processed_data.md) | 5 | | table |
|
||||
| [project](project.md) | 9 | | table |
|
||||
|
|
@ -200,6 +202,7 @@ erDiagram
|
|||
"agents_observations" }o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"ai_builder_temporary_workflow" }o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"ai_builder_temporary_workflow" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"app" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"auth_identity" }o--o| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE NO ACTION MATCH NONE"
|
||||
"chat_hub_agent_tools" |o--|| "chat_hub_tools" : "FOREIGN KEY (toolId) REFERENCES chat_hub_tools (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"chat_hub_agent_tools" |o--|| "chat_hub_agents" : "FOREIGN KEY (agentId) REFERENCES chat_hub_agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
|
|
@ -277,6 +280,8 @@ erDiagram
|
|||
"oauth_refresh_tokens" }o--|| "oauth_clients" : "FOREIGN KEY (clientId) REFERENCES oauth_clients (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"oauth_user_consents" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"oauth_user_consents" }o--|| "oauth_clients" : "FOREIGN KEY (clientId) REFERENCES oauth_clients (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"page" }o--o| "page" : "FOREIGN KEY (parentPageId) REFERENCES page (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"page" }o--|| "app" : "FOREIGN KEY (appId) REFERENCES app (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"poller_state" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"processed_data" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"project" }o--o| "user" : "FOREIGN KEY (creatorId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
|
||||
|
|
@ -698,6 +703,15 @@ erDiagram
|
|||
varchar_24_ name
|
||||
datetime_3_ updatedAt
|
||||
}
|
||||
"app" {
|
||||
datetime_3_ createdAt
|
||||
varchar_36_ id PK
|
||||
varchar_128_ name
|
||||
varchar_128_ namespace
|
||||
varchar_36_ projectId FK
|
||||
TEXT theme
|
||||
datetime_3_ updatedAt
|
||||
}
|
||||
"auth_identity" {
|
||||
timestamp createdAt
|
||||
VARCHAR_64_ providerId PK
|
||||
|
|
@ -1241,6 +1255,15 @@ erDiagram
|
|||
TEXT scope
|
||||
varchar userId FK
|
||||
}
|
||||
"page" {
|
||||
varchar_36_ appId FK
|
||||
TEXT content
|
||||
datetime_3_ createdAt
|
||||
varchar_36_ id PK
|
||||
varchar_36_ parentPageId FK
|
||||
varchar_255_ route
|
||||
datetime_3_ updatedAt
|
||||
}
|
||||
"poller_state" {
|
||||
datetime_3_ backoffUntil
|
||||
INTEGER consecutiveErrors
|
||||
|
|
|
|||
82
docs/generated/sqlite-schema/app.md
generated
Normal file
82
docs/generated/sqlite-schema/app.md
generated
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# app
|
||||
|
||||
## Description
|
||||
|
||||
<details>
|
||||
<summary><strong>Table Definition</strong></summary>
|
||||
|
||||
```sql
|
||||
CREATE TABLE "app" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(128) NOT NULL, "namespace" varchar(128) NOT NULL, "theme" text, "projectId" varchar(36) NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "FK_f84dd7eb539e46e0c233fa09b20" FOREIGN KEY ("projectId") REFERENCES "project" ("id") ON DELETE CASCADE)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Columns
|
||||
|
||||
| Name | Type | Default | Nullable | Children | Parents | Comment |
|
||||
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
|
||||
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
|
||||
| id | varchar(36) | | false | [page](page.md) | | |
|
||||
| name | varchar(128) | | false | | | |
|
||||
| namespace | varchar(128) | | false | | | |
|
||||
| projectId | varchar(36) | | false | | [project](project.md) | |
|
||||
| theme | TEXT | | true | | | |
|
||||
| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
|
||||
|
||||
## Constraints
|
||||
|
||||
| Name | Type | Definition |
|
||||
| ---- | ---- | ---------- |
|
||||
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
|
||||
| id | PRIMARY KEY | PRIMARY KEY (id) |
|
||||
| sqlite_autoindex_app_1 | PRIMARY KEY | PRIMARY KEY (id) |
|
||||
|
||||
## Indexes
|
||||
|
||||
| Name | Definition |
|
||||
| ---- | ---------- |
|
||||
| IDX_app_namespace_projectId | CREATE UNIQUE INDEX "IDX_app_namespace_projectId" ON "app" ("namespace", "projectId") |
|
||||
| sqlite_autoindex_app_1 | PRIMARY KEY (id) |
|
||||
|
||||
## Relations
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
|
||||
"page" }o--|| "app" : "FOREIGN KEY (appId) REFERENCES app (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"app" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
|
||||
"app" {
|
||||
datetime_3_ createdAt
|
||||
varchar_36_ id PK
|
||||
varchar_128_ name
|
||||
varchar_128_ namespace
|
||||
varchar_36_ projectId FK
|
||||
TEXT theme
|
||||
datetime_3_ updatedAt
|
||||
}
|
||||
"page" {
|
||||
varchar_36_ appId FK
|
||||
TEXT content
|
||||
datetime_3_ createdAt
|
||||
varchar_36_ id PK
|
||||
varchar_36_ parentPageId FK
|
||||
varchar_255_ route
|
||||
datetime_3_ updatedAt
|
||||
}
|
||||
"project" {
|
||||
datetime_3_ createdAt
|
||||
varchar creatorId FK
|
||||
TEXT customTelemetryTags
|
||||
varchar_512_ description
|
||||
TEXT icon
|
||||
varchar_36_ id PK
|
||||
varchar_255_ name
|
||||
varchar_36_ type
|
||||
datetime_3_ updatedAt
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> Generated by [tbls](https://github.com/k1LoW/tbls)
|
||||
73
docs/generated/sqlite-schema/page.md
generated
Normal file
73
docs/generated/sqlite-schema/page.md
generated
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# page
|
||||
|
||||
## Description
|
||||
|
||||
<details>
|
||||
<summary><strong>Table Definition</strong></summary>
|
||||
|
||||
```sql
|
||||
CREATE TABLE "page" ("id" varchar(36) PRIMARY KEY NOT NULL, "appId" varchar(36) NOT NULL, "parentPageId" varchar(36), "route" varchar(255) NOT NULL, "content" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "FK_5e3cf5ed8328c4993a910bd239b" FOREIGN KEY ("appId") REFERENCES "app" ("id") ON DELETE CASCADE, CONSTRAINT "FK_b85a31f0bff1e857156d27ea152" FOREIGN KEY ("parentPageId") REFERENCES "page" ("id") ON DELETE CASCADE)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Columns
|
||||
|
||||
| Name | Type | Default | Nullable | Children | Parents | Comment |
|
||||
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
|
||||
| appId | varchar(36) | | false | | [app](app.md) | |
|
||||
| content | TEXT | | true | | | |
|
||||
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
|
||||
| id | varchar(36) | | false | [page](page.md) | | |
|
||||
| parentPageId | varchar(36) | | true | | [page](page.md) | |
|
||||
| route | varchar(255) | | false | | | |
|
||||
| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
|
||||
|
||||
## Constraints
|
||||
|
||||
| Name | Type | Definition |
|
||||
| ---- | ---- | ---------- |
|
||||
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (parentPageId) REFERENCES page (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
|
||||
| - (Foreign key ID: 1) | FOREIGN KEY | FOREIGN KEY (appId) REFERENCES app (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
|
||||
| id | PRIMARY KEY | PRIMARY KEY (id) |
|
||||
| sqlite_autoindex_page_1 | PRIMARY KEY | PRIMARY KEY (id) |
|
||||
|
||||
## Indexes
|
||||
|
||||
| Name | Definition |
|
||||
| ---- | ---------- |
|
||||
| IDX_page_appId | CREATE INDEX "IDX_page_appId" ON "page" ("appId") |
|
||||
| IDX_page_parentPageId | CREATE INDEX "IDX_page_parentPageId" ON "page" ("parentPageId") |
|
||||
| sqlite_autoindex_page_1 | PRIMARY KEY (id) |
|
||||
|
||||
## Relations
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
|
||||
"page" }o--|| "app" : "FOREIGN KEY (appId) REFERENCES app (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"page" }o--o| "page" : "FOREIGN KEY (parentPageId) REFERENCES page (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
|
||||
"page" {
|
||||
varchar_36_ appId FK
|
||||
TEXT content
|
||||
datetime_3_ createdAt
|
||||
varchar_36_ id PK
|
||||
varchar_36_ parentPageId FK
|
||||
varchar_255_ route
|
||||
datetime_3_ updatedAt
|
||||
}
|
||||
"app" {
|
||||
datetime_3_ createdAt
|
||||
varchar_36_ id PK
|
||||
varchar_128_ name
|
||||
varchar_128_ namespace
|
||||
varchar_36_ projectId FK
|
||||
TEXT theme
|
||||
datetime_3_ updatedAt
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> Generated by [tbls](https://github.com/k1LoW/tbls)
|
||||
12
docs/generated/sqlite-schema/project.md
generated
12
docs/generated/sqlite-schema/project.md
generated
|
|
@ -20,7 +20,7 @@ CREATE TABLE "project" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(25
|
|||
| customTelemetryTags | TEXT | '[]' | false | | | |
|
||||
| description | varchar(512) | | true | | | |
|
||||
| icon | TEXT | | true | | | |
|
||||
| id | varchar(36) | | false | [activity_event](activity_event.md) [agent_chat_attachments](agent_chat_attachments.md) [agent_execution_threads](agent_execution_threads.md) [agents](agents.md) [data_table](data_table.md) [folder](folder.md) [git_connection_project](git_connection_project.md) [insights_metadata](insights_metadata.md) [instance_ai_threads](instance_ai_threads.md) [project_pool_settings](project_pool_settings.md) [project_relation](project_relation.md) [project_secrets_provider_access](project_secrets_provider_access.md) [role_mapping_rule_project](role_mapping_rule_project.md) [shared_credentials](shared_credentials.md) [shared_workflow](shared_workflow.md) [type_availability_policy_scope](type_availability_policy_scope.md) [variables](variables.md) [workflow_review_request](workflow_review_request.md) | | |
|
||||
| id | varchar(36) | | false | [activity_event](activity_event.md) [agent_chat_attachments](agent_chat_attachments.md) [agent_execution_threads](agent_execution_threads.md) [agents](agents.md) [app](app.md) [data_table](data_table.md) [folder](folder.md) [git_connection_project](git_connection_project.md) [insights_metadata](insights_metadata.md) [instance_ai_threads](instance_ai_threads.md) [project_pool_settings](project_pool_settings.md) [project_relation](project_relation.md) [project_secrets_provider_access](project_secrets_provider_access.md) [role_mapping_rule_project](role_mapping_rule_project.md) [shared_credentials](shared_credentials.md) [shared_workflow](shared_workflow.md) [type_availability_policy_scope](type_availability_policy_scope.md) [variables](variables.md) [workflow_review_request](workflow_review_request.md) | | |
|
||||
| name | varchar(255) | | false | | | |
|
||||
| type | varchar(36) | | false | | | |
|
||||
| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
|
||||
|
|
@ -49,6 +49,7 @@ erDiagram
|
|||
"agent_chat_attachments" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"agent_execution_threads" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"agents" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"app" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"data_table" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"folder" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"git_connection_project" |o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
|
|
@ -154,6 +155,15 @@ erDiagram
|
|||
datetime_3_ updatedAt
|
||||
varchar_36_ versionId
|
||||
}
|
||||
"app" {
|
||||
datetime_3_ createdAt
|
||||
varchar_36_ id PK
|
||||
varchar_128_ name
|
||||
varchar_128_ namespace
|
||||
varchar_36_ projectId FK
|
||||
TEXT theme
|
||||
datetime_3_ updatedAt
|
||||
}
|
||||
"data_table" {
|
||||
datetime_3_ createdAt
|
||||
varchar_36_ id PK
|
||||
|
|
|
|||
7
packages/@n8n/api-types/src/dto/app/create-app.dto.ts
Normal file
7
packages/@n8n/api-types/src/dto/app/create-app.dto.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { appNameSchema, appNamespaceSchema } from '../../schemas/app.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class CreateAppDto extends Z.class({
|
||||
name: appNameSchema,
|
||||
namespace: appNamespaceSchema,
|
||||
}) {}
|
||||
9
packages/@n8n/api-types/src/dto/app/create-page.dto.ts
Normal file
9
packages/@n8n/api-types/src/dto/app/create-page.dto.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { pageRouteSchema } from '../../schemas/app.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class CreatePageDto extends Z.class({
|
||||
route: pageRouteSchema,
|
||||
parentPageId: z.string().optional(),
|
||||
}) {}
|
||||
7
packages/@n8n/api-types/src/dto/app/update-app.dto.ts
Normal file
7
packages/@n8n/api-types/src/dto/app/update-app.dto.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { appNameSchema, appNamespaceSchema } from '../../schemas/app.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class UpdateAppDto extends Z.class({
|
||||
name: appNameSchema.optional(),
|
||||
namespace: appNamespaceSchema.optional(),
|
||||
}) {}
|
||||
6
packages/@n8n/api-types/src/dto/app/update-page.dto.ts
Normal file
6
packages/@n8n/api-types/src/dto/app/update-page.dto.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { pageRouteSchema } from '../../schemas/app.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class UpdatePageDto extends Z.class({
|
||||
route: pageRouteSchema.optional(),
|
||||
}) {}
|
||||
|
|
@ -281,6 +281,11 @@ export {
|
|||
export { OidcConfigDto, UpdateOidcConfigurationDto, OIDC_PROMPT_VALUES } from './oidc/config.dto';
|
||||
export { TestOidcConfigResponseDto } from './oidc/test-oidc-config-response.dto';
|
||||
|
||||
export { CreateAppDto } from './app/create-app.dto';
|
||||
export { UpdateAppDto } from './app/update-app.dto';
|
||||
export { CreatePageDto } from './app/create-page.dto';
|
||||
export { UpdatePageDto } from './app/update-page.dto';
|
||||
|
||||
export { CreateDataTableDto } from './data-table/create-data-table.dto';
|
||||
export { UpdateDataTableDto } from './data-table/update-data-table.dto';
|
||||
export { PublicApiCreateDataTableDto } from './data-table/public-api-create-data-table.dto';
|
||||
|
|
|
|||
28
packages/@n8n/api-types/src/schemas/app.schema.ts
Normal file
28
packages/@n8n/api-types/src/schemas/app.schema.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const appNameSchema = z.string().trim().min(1).max(128);
|
||||
|
||||
// URL path segment under /apps/<namespace>; kebab-case, matches other n8n slug conventions.
|
||||
const SLUG_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
const SLUG_ERROR_MESSAGE =
|
||||
'Only lowercase letters, numbers, and single hyphens between them are allowed.';
|
||||
|
||||
export const appNamespaceSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.regex(SLUG_REGEX, SLUG_ERROR_MESSAGE);
|
||||
|
||||
// Empty means this page is the index/home page for its level (the app's own
|
||||
// landing page, for a top-level page with no parent). A page's own segment
|
||||
// can also be a dynamic param, e.g. ":id" (as in /clients/:id), matching the
|
||||
// data workflow's input field of the same name.
|
||||
const PAGE_ROUTE_REGEX = /^(:[a-zA-Z_][a-zA-Z0-9_]*|[a-z0-9]+(-[a-z0-9]+)*)?$/;
|
||||
const PAGE_ROUTE_ERROR_MESSAGE = `${SLUG_ERROR_MESSAGE} A dynamic segment starts with ':' followed by a name, e.g. ':id'.`;
|
||||
|
||||
export const pageRouteSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.max(255)
|
||||
.regex(PAGE_ROUTE_REGEX, PAGE_ROUTE_ERROR_MESSAGE);
|
||||
|
|
@ -49,6 +49,7 @@ describe('eligibleModules', () => {
|
|||
'external-secrets',
|
||||
'community-packages',
|
||||
'data-table',
|
||||
'apps',
|
||||
'oauth-server',
|
||||
'mcp',
|
||||
'provisioning',
|
||||
|
|
@ -85,6 +86,7 @@ describe('eligibleModules', () => {
|
|||
'external-secrets',
|
||||
'community-packages',
|
||||
'data-table',
|
||||
'apps',
|
||||
'oauth-server',
|
||||
'mcp',
|
||||
'provisioning',
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export class ModuleRegistry {
|
|||
'external-secrets',
|
||||
'community-packages',
|
||||
'data-table',
|
||||
'apps',
|
||||
// oauth-server precedes mcp: the mcp module registers its protected
|
||||
// resource with the oauth-server module's registry on init.
|
||||
'oauth-server',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { UnknownModuleError } from './errors/unknown-module.error';
|
|||
|
||||
export const MODULE_NAMES = [
|
||||
'agents',
|
||||
'apps',
|
||||
'agent-evals',
|
||||
'insights',
|
||||
'external-secrets',
|
||||
|
|
|
|||
|
|
@ -158,6 +158,8 @@ type EntityName =
|
|||
| 'InsightsMetadata'
|
||||
| 'DataTable'
|
||||
| 'DataTableColumn'
|
||||
| 'App'
|
||||
| 'Page'
|
||||
| 'ChatHubSession'
|
||||
| 'ChatHubMessage'
|
||||
| 'ChatHubAgent'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
const APP_TABLE = 'app';
|
||||
const PAGE_TABLE = 'page';
|
||||
|
||||
/**
|
||||
* Top-level App entity (project-scoped) and its Page tree. A Page's block
|
||||
* content is stored as-is for now (opaque JSON); the block schema and
|
||||
* renderer land separately.
|
||||
*/
|
||||
export class CreateAppTables1788774977417 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { createTable, createIndex, column } }: MigrationContext) {
|
||||
await createTable(APP_TABLE)
|
||||
.withColumns(
|
||||
column('id').varchar(36).primary.notNull,
|
||||
column('name').varchar(128).notNull,
|
||||
column('namespace')
|
||||
.varchar(128)
|
||||
.notNull.comment('URL path segment under /apps/; unique per project'),
|
||||
column('theme').json.comment('Shared colors/spacing for the App, app-defined shape'),
|
||||
column('projectId').varchar(36).notNull,
|
||||
)
|
||||
.withTimestamps.withForeignKey('projectId', {
|
||||
tableName: 'project',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
});
|
||||
|
||||
await createIndex(APP_TABLE, ['namespace', 'projectId'], true);
|
||||
|
||||
await createTable(PAGE_TABLE)
|
||||
.withColumns(
|
||||
column('id').varchar(36).primary.notNull,
|
||||
column('appId').varchar(36).notNull,
|
||||
column('parentPageId').varchar(36).comment('Self-reference; null for a top-level page'),
|
||||
column('route')
|
||||
.varchar(255)
|
||||
.notNull.comment('Path segment under its parent, may contain :params'),
|
||||
column('content').json.comment(
|
||||
'Block tree (Editor.js-shaped); empty until the renderer lands',
|
||||
),
|
||||
)
|
||||
.withTimestamps.withForeignKey('appId', {
|
||||
tableName: APP_TABLE,
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
.withForeignKey('parentPageId', {
|
||||
tableName: PAGE_TABLE,
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
});
|
||||
|
||||
await createIndex(PAGE_TABLE, ['appId']);
|
||||
await createIndex(PAGE_TABLE, ['parentPageId']);
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
|
||||
await dropTable(PAGE_TABLE);
|
||||
await dropTable(APP_TABLE);
|
||||
}
|
||||
}
|
||||
|
|
@ -144,6 +144,12 @@ exports[`Scope Information > ensure scopes are defined correctly 1`] = `
|
|||
"dataTable:readColumn",
|
||||
"dataTable:writeColumn",
|
||||
"dataTable:listProject",
|
||||
"app:create",
|
||||
"app:read",
|
||||
"app:update",
|
||||
"app:delete",
|
||||
"app:list",
|
||||
"app:listProject",
|
||||
"execution:delete",
|
||||
"execution:read",
|
||||
"execution:retry",
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ export const RESOURCES = {
|
|||
'writeColumn',
|
||||
'listProject',
|
||||
] as const,
|
||||
app: [...DEFAULT_OPERATIONS, 'listProject'] as const,
|
||||
execution: ['delete', 'read', 'retry', 'list', 'get', 'reveal'] as const,
|
||||
testRun: ['read', 'list'] as const,
|
||||
workflowTags: ['update', 'list'] as const,
|
||||
|
|
|
|||
|
|
@ -139,6 +139,12 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
|
|||
'dataTable:writeRow',
|
||||
'dataTable:readColumn',
|
||||
'dataTable:writeColumn',
|
||||
'app:create',
|
||||
'app:delete',
|
||||
'app:read',
|
||||
'app:update',
|
||||
'app:list',
|
||||
'app:listProject',
|
||||
'role:manage',
|
||||
'role:read',
|
||||
'role:list',
|
||||
|
|
@ -197,6 +203,7 @@ export const GLOBAL_MEMBER_SCOPES: Scope[] = [
|
|||
'variable:list',
|
||||
'variable:read',
|
||||
'dataTable:list',
|
||||
'app:list',
|
||||
'mcp:oauth',
|
||||
'mcpApiKey:create',
|
||||
'mcpApiKey:rotate',
|
||||
|
|
|
|||
|
|
@ -62,6 +62,11 @@ export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [
|
|||
'dataTable:writeRow',
|
||||
'dataTable:readColumn',
|
||||
'dataTable:writeColumn',
|
||||
'app:create',
|
||||
'app:delete',
|
||||
'app:read',
|
||||
'app:update',
|
||||
'app:listProject',
|
||||
'projectVariable:list',
|
||||
'projectVariable:read',
|
||||
'projectVariable:create',
|
||||
|
|
@ -119,6 +124,11 @@ export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [
|
|||
'dataTable:writeRow',
|
||||
'dataTable:readColumn',
|
||||
'dataTable:writeColumn',
|
||||
'app:create',
|
||||
'app:delete',
|
||||
'app:read',
|
||||
'app:update',
|
||||
'app:listProject',
|
||||
];
|
||||
|
||||
export const PROJECT_EDITOR_SCOPES: Scope[] = [
|
||||
|
|
@ -164,6 +174,11 @@ export const PROJECT_EDITOR_SCOPES: Scope[] = [
|
|||
'dataTable:writeRow',
|
||||
'dataTable:readColumn',
|
||||
'dataTable:writeColumn',
|
||||
'app:create',
|
||||
'app:delete',
|
||||
'app:read',
|
||||
'app:update',
|
||||
'app:listProject',
|
||||
'projectVariable:list',
|
||||
'projectVariable:read',
|
||||
'projectVariable:create',
|
||||
|
|
@ -190,6 +205,8 @@ export const PROJECT_VIEWER_SCOPES: Scope[] = [
|
|||
'dataTable:read',
|
||||
'dataTable:readRow',
|
||||
'dataTable:readColumn',
|
||||
'app:listProject',
|
||||
'app:read',
|
||||
'projectVariable:list',
|
||||
'projectVariable:read',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ describe('permissions', () => {
|
|||
folder: {},
|
||||
insights: {},
|
||||
dataTable: {},
|
||||
app: {},
|
||||
mcp: {},
|
||||
mcpApiKey: {},
|
||||
role: {},
|
||||
|
|
@ -168,6 +169,7 @@ describe('permissions', () => {
|
|||
list: true,
|
||||
},
|
||||
dataTable: {},
|
||||
app: {},
|
||||
execution: {},
|
||||
testRun: {},
|
||||
workflowTags: {},
|
||||
|
|
|
|||
26
packages/cli/src/modules/apps/app.entity.ts
Normal file
26
packages/cli/src/modules/apps/app.entity.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { Project, WithTimestampsAndStringId } from '@n8n/db';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from '@n8n/typeorm';
|
||||
|
||||
@Entity()
|
||||
@Index(['namespace', 'projectId'], { unique: true })
|
||||
export class App extends WithTimestampsAndStringId {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
@Column()
|
||||
name: string;
|
||||
|
||||
@Column()
|
||||
namespace: string;
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
theme: Record<string, unknown> | null;
|
||||
|
||||
@ManyToOne(() => Project)
|
||||
@JoinColumn({ name: 'projectId' })
|
||||
project: Project;
|
||||
|
||||
@Column()
|
||||
projectId: string;
|
||||
}
|
||||
41
packages/cli/src/modules/apps/app.repository.ts
Normal file
41
packages/cli/src/modules/apps/app.repository.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { Service } from '@n8n/di';
|
||||
import { DataSource, Repository } from '@n8n/typeorm';
|
||||
|
||||
import { App } from './app.entity';
|
||||
import { AppNamespaceConflictError } from './errors/app-namespace-conflict.error';
|
||||
|
||||
@Service()
|
||||
export class AppRepository extends Repository<App> {
|
||||
constructor(dataSource: DataSource) {
|
||||
super(App, dataSource.manager);
|
||||
}
|
||||
|
||||
async createApp(projectId: string, name: string, namespace: string) {
|
||||
if (await this.existsBy({ projectId, namespace })) {
|
||||
throw new AppNamespaceConflictError(namespace);
|
||||
}
|
||||
|
||||
const app = this.create({ projectId, name, namespace });
|
||||
return await this.save(app);
|
||||
}
|
||||
|
||||
async findManyByProjectId(projectId: string) {
|
||||
return await this.findBy({ projectId });
|
||||
}
|
||||
|
||||
async updateApp(app: App, updates: Partial<Pick<App, 'name' | 'namespace' | 'theme'>>) {
|
||||
if (
|
||||
updates.namespace !== undefined &&
|
||||
updates.namespace !== app.namespace &&
|
||||
(await this.existsBy({ projectId: app.projectId, namespace: updates.namespace }))
|
||||
) {
|
||||
throw new AppNamespaceConflictError(updates.namespace);
|
||||
}
|
||||
|
||||
return await this.save(Object.assign(app, updates));
|
||||
}
|
||||
|
||||
async deleteApp(id: string) {
|
||||
await this.delete({ id });
|
||||
}
|
||||
}
|
||||
174
packages/cli/src/modules/apps/apps.controller.ts
Normal file
174
packages/cli/src/modules/apps/apps.controller.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { CreateAppDto, CreatePageDto, UpdateAppDto, UpdatePageDto } from '@n8n/api-types';
|
||||
import { AuthenticatedRequest } from '@n8n/db';
|
||||
import {
|
||||
Body,
|
||||
Delete,
|
||||
Get,
|
||||
Middleware,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
ProjectScope,
|
||||
RestController,
|
||||
} from '@n8n/decorators';
|
||||
import { NextFunction, Response } from 'express';
|
||||
|
||||
import { ConflictError } from '@/errors/response-errors/conflict.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { InstanceWriteAccessService } from '@/services/instance-write-access.service';
|
||||
import { ProjectService } from '@/services/project.service.ee';
|
||||
|
||||
import { AppsService } from './apps.service';
|
||||
import { AppNamespaceConflictError } from './errors/app-namespace-conflict.error';
|
||||
import { PageRouteConflictError } from './errors/page-route-conflict.error';
|
||||
|
||||
@RestController('/projects/:projectId/apps')
|
||||
export class AppsController {
|
||||
constructor(
|
||||
private readonly appsService: AppsService,
|
||||
private readonly projectService: ProjectService,
|
||||
private readonly instanceWriteAccess: InstanceWriteAccessService,
|
||||
) {}
|
||||
|
||||
private checkInstanceWriteAccess(): void {
|
||||
if (this.instanceWriteAccess.isReadOnly()) {
|
||||
throw new ForbiddenError(
|
||||
'Cannot modify apps on a protected instance. This instance is in read-only mode.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private handleAppError(e: unknown): never {
|
||||
if (e instanceof AppNamespaceConflictError || e instanceof PageRouteConflictError) {
|
||||
throw new ConflictError(e.message);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
@Middleware()
|
||||
async validateProjectExists(
|
||||
req: AuthenticatedRequest<{ projectId: string }>,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
try {
|
||||
await this.projectService.getProject(req.params.projectId);
|
||||
next();
|
||||
} catch {
|
||||
res.status(404).send('Project not found');
|
||||
}
|
||||
}
|
||||
|
||||
@Post('/')
|
||||
@ProjectScope('app:create')
|
||||
async createApp(
|
||||
req: AuthenticatedRequest<{ projectId: string }>,
|
||||
_res: Response,
|
||||
@Body dto: CreateAppDto,
|
||||
) {
|
||||
this.checkInstanceWriteAccess();
|
||||
try {
|
||||
return await this.appsService.createApp(req.params.projectId, dto);
|
||||
} catch (e: unknown) {
|
||||
this.handleAppError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('/')
|
||||
@ProjectScope('app:listProject')
|
||||
async listApps(req: AuthenticatedRequest<{ projectId: string }>, _res: Response) {
|
||||
return await this.appsService.listApps(req.params.projectId);
|
||||
}
|
||||
|
||||
@Get('/:appId')
|
||||
@ProjectScope('app:read')
|
||||
async getApp(
|
||||
_req: AuthenticatedRequest<{ projectId: string }>,
|
||||
_res: Response,
|
||||
@Param('appId') appId: string,
|
||||
) {
|
||||
return await this.appsService.getApp(appId);
|
||||
}
|
||||
|
||||
@Patch('/:appId')
|
||||
@ProjectScope('app:update')
|
||||
async updateApp(
|
||||
_req: AuthenticatedRequest<{ projectId: string }>,
|
||||
_res: Response,
|
||||
@Param('appId') appId: string,
|
||||
@Body dto: UpdateAppDto,
|
||||
) {
|
||||
this.checkInstanceWriteAccess();
|
||||
try {
|
||||
return await this.appsService.updateApp(appId, dto);
|
||||
} catch (e: unknown) {
|
||||
this.handleAppError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Delete('/:appId')
|
||||
@ProjectScope('app:delete')
|
||||
async deleteApp(
|
||||
_req: AuthenticatedRequest<{ projectId: string }>,
|
||||
_res: Response,
|
||||
@Param('appId') appId: string,
|
||||
) {
|
||||
this.checkInstanceWriteAccess();
|
||||
await this.appsService.deleteApp(appId);
|
||||
}
|
||||
|
||||
@Post('/:appId/pages')
|
||||
@ProjectScope('app:update')
|
||||
async createPage(
|
||||
_req: AuthenticatedRequest<{ projectId: string }>,
|
||||
_res: Response,
|
||||
@Param('appId') appId: string,
|
||||
@Body dto: CreatePageDto,
|
||||
) {
|
||||
this.checkInstanceWriteAccess();
|
||||
try {
|
||||
return await this.appsService.createPage(appId, dto);
|
||||
} catch (e: unknown) {
|
||||
this.handleAppError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('/:appId/pages')
|
||||
@ProjectScope('app:read')
|
||||
async listPages(
|
||||
_req: AuthenticatedRequest<{ projectId: string }>,
|
||||
_res: Response,
|
||||
@Param('appId') appId: string,
|
||||
) {
|
||||
return await this.appsService.listPages(appId);
|
||||
}
|
||||
|
||||
@Patch('/:appId/pages/:pageId')
|
||||
@ProjectScope('app:update')
|
||||
async updatePage(
|
||||
_req: AuthenticatedRequest<{ projectId: string }>,
|
||||
_res: Response,
|
||||
@Param('appId') appId: string,
|
||||
@Param('pageId') pageId: string,
|
||||
@Body dto: UpdatePageDto,
|
||||
) {
|
||||
this.checkInstanceWriteAccess();
|
||||
try {
|
||||
return await this.appsService.updatePage(appId, pageId, dto);
|
||||
} catch (e: unknown) {
|
||||
this.handleAppError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Delete('/:appId/pages/:pageId')
|
||||
@ProjectScope('app:update')
|
||||
async deletePage(
|
||||
_req: AuthenticatedRequest<{ projectId: string }>,
|
||||
_res: Response,
|
||||
@Param('appId') appId: string,
|
||||
@Param('pageId') pageId: string,
|
||||
) {
|
||||
this.checkInstanceWriteAccess();
|
||||
await this.appsService.deletePage(appId, pageId);
|
||||
}
|
||||
}
|
||||
16
packages/cli/src/modules/apps/apps.module.ts
Normal file
16
packages/cli/src/modules/apps/apps.module.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { ModuleInterface } from '@n8n/decorators';
|
||||
import { BackendModule } from '@n8n/decorators';
|
||||
|
||||
@BackendModule({ name: 'apps' })
|
||||
export class AppsModule implements ModuleInterface {
|
||||
async init() {
|
||||
await import('./apps.controller.js');
|
||||
}
|
||||
|
||||
async entities() {
|
||||
const { App } = await import('./app.entity.js');
|
||||
const { Page } = await import('./page.entity.js');
|
||||
|
||||
return [App, Page];
|
||||
}
|
||||
}
|
||||
94
packages/cli/src/modules/apps/apps.service.ts
Normal file
94
packages/cli/src/modules/apps/apps.service.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import type { CreateAppDto, CreatePageDto, UpdateAppDto, UpdatePageDto } from '@n8n/api-types';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import { AppRepository } from './app.repository';
|
||||
import { AppNotFoundError } from './errors/app-not-found.error';
|
||||
import { IndexPageCannotHaveChildrenError } from './errors/index-page-cannot-have-children.error';
|
||||
import { PageNotFoundError } from './errors/page-not-found.error';
|
||||
import { PageRouteConflictError } from './errors/page-route-conflict.error';
|
||||
import { PageRepository } from './page.repository';
|
||||
|
||||
@Service()
|
||||
export class AppsService {
|
||||
constructor(
|
||||
private readonly appRepository: AppRepository,
|
||||
private readonly pageRepository: PageRepository,
|
||||
) {}
|
||||
|
||||
async createApp(projectId: string, dto: CreateAppDto) {
|
||||
return await this.appRepository.createApp(projectId, dto.name, dto.namespace);
|
||||
}
|
||||
|
||||
async listApps(projectId: string) {
|
||||
return await this.appRepository.findManyByProjectId(projectId);
|
||||
}
|
||||
|
||||
async getApp(appId: string) {
|
||||
const app = await this.appRepository.findOneBy({ id: appId });
|
||||
if (!app) throw new AppNotFoundError(appId);
|
||||
return app;
|
||||
}
|
||||
|
||||
async updateApp(appId: string, dto: UpdateAppDto) {
|
||||
const app = await this.getApp(appId);
|
||||
return await this.appRepository.updateApp(app, dto);
|
||||
}
|
||||
|
||||
async deleteApp(appId: string) {
|
||||
await this.getApp(appId);
|
||||
await this.appRepository.deleteApp(appId);
|
||||
}
|
||||
|
||||
async createPage(appId: string, dto: CreatePageDto) {
|
||||
await this.getApp(appId);
|
||||
const parentPageId = dto.parentPageId ?? null;
|
||||
if (parentPageId) {
|
||||
// getPage scopes the lookup to this app, so a parentPageId belonging to
|
||||
// another app/project is rejected the same as one that doesn't exist.
|
||||
const parent = await this.getPage(appId, parentPageId);
|
||||
if (!parent.route) throw new IndexPageCannotHaveChildrenError(parent.id);
|
||||
}
|
||||
if (await this.pageRepository.hasSiblingWithRoute(appId, parentPageId, dto.route)) {
|
||||
throw new PageRouteConflictError(dto.route);
|
||||
}
|
||||
return await this.pageRepository.createPage(appId, parentPageId, dto.route);
|
||||
}
|
||||
|
||||
/** Flat list; callers build the tree from each page's `parentPageId`. */
|
||||
async listPages(appId: string) {
|
||||
await this.getApp(appId);
|
||||
return await this.pageRepository.findManyByAppId(appId);
|
||||
}
|
||||
|
||||
/** 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 });
|
||||
if (!page || page.appId !== appId) throw new PageNotFoundError(pageId);
|
||||
return page;
|
||||
}
|
||||
|
||||
async updatePage(appId: string, pageId: string, dto: UpdatePageDto) {
|
||||
const page = await this.getPage(appId, pageId);
|
||||
if (dto.route !== undefined && dto.route !== page.route) {
|
||||
if (dto.route === '' && (await this.pageRepository.hasChildren(pageId))) {
|
||||
throw new IndexPageCannotHaveChildrenError(pageId);
|
||||
}
|
||||
if (
|
||||
await this.pageRepository.hasSiblingWithRoute(
|
||||
page.appId,
|
||||
page.parentPageId,
|
||||
dto.route,
|
||||
pageId,
|
||||
)
|
||||
) {
|
||||
throw new PageRouteConflictError(dto.route);
|
||||
}
|
||||
}
|
||||
return await this.pageRepository.updatePage(page, dto);
|
||||
}
|
||||
|
||||
async deletePage(appId: string, pageId: string) {
|
||||
await this.getPage(appId, pageId);
|
||||
await this.pageRepository.deletePage(pageId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { UserError } from 'n8n-workflow';
|
||||
|
||||
export class AppNamespaceConflictError extends UserError {
|
||||
constructor(namespace: string) {
|
||||
super(`App with namespace '${namespace}' already exists in this project`, {
|
||||
level: 'warning',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
|
||||
export class AppNotFoundError extends NotFoundError {
|
||||
constructor(appId: string) {
|
||||
super(`Could not find the app: '${appId}'`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { UserError } from 'n8n-workflow';
|
||||
|
||||
export class IndexPageCannotHaveChildrenError extends UserError {
|
||||
constructor(pageId: string) {
|
||||
super(
|
||||
`Page '${pageId}' is an index page (empty route) and can't have sub-pages: a child page would resolve to the exact same URL as a sibling of this page, since the index page itself contributes no path segment.`,
|
||||
{ level: 'warning' },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
|
||||
export class PageNotFoundError extends NotFoundError {
|
||||
constructor(pageId: string) {
|
||||
super(`Could not find the page: '${pageId}'`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { UserError } from 'n8n-workflow';
|
||||
|
||||
export class PageRouteConflictError extends UserError {
|
||||
constructor(route: string) {
|
||||
const label = route || 'the index page';
|
||||
super(`Another page at this level already uses '${label}'`, { level: 'warning' });
|
||||
}
|
||||
}
|
||||
33
packages/cli/src/modules/apps/page.entity.ts
Normal file
33
packages/cli/src/modules/apps/page.entity.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { WithTimestampsAndStringId } from '@n8n/db';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, type Relation } from '@n8n/typeorm';
|
||||
|
||||
import { App } from './app.entity';
|
||||
|
||||
@Entity()
|
||||
export class Page extends WithTimestampsAndStringId {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
@Column()
|
||||
route: string;
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
content: unknown[] | null;
|
||||
|
||||
@ManyToOne(() => App)
|
||||
@JoinColumn({ name: 'appId' })
|
||||
app: Relation<App>;
|
||||
|
||||
@Column()
|
||||
@Index()
|
||||
appId: string;
|
||||
|
||||
@ManyToOne(() => Page, { nullable: true })
|
||||
@JoinColumn({ name: 'parentPageId' })
|
||||
parentPage: Relation<Page> | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
@Index()
|
||||
parentPageId: string | null;
|
||||
}
|
||||
48
packages/cli/src/modules/apps/page.repository.ts
Normal file
48
packages/cli/src/modules/apps/page.repository.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { Service } from '@n8n/di';
|
||||
import { DataSource, IsNull, Not, Repository } from '@n8n/typeorm';
|
||||
|
||||
import { Page } from './page.entity';
|
||||
|
||||
@Service()
|
||||
export class PageRepository extends Repository<Page> {
|
||||
constructor(dataSource: DataSource) {
|
||||
super(Page, dataSource.manager);
|
||||
}
|
||||
|
||||
async createPage(appId: string, parentPageId: string | null, route: string) {
|
||||
const page = this.create({ appId, parentPageId, route });
|
||||
return await this.save(page);
|
||||
}
|
||||
|
||||
/** Flat list of every page under an App; callers build the tree from `parentPageId`. */
|
||||
async findManyByAppId(appId: string) {
|
||||
return await this.findBy({ appId });
|
||||
}
|
||||
|
||||
async hasChildren(pageId: string) {
|
||||
return await this.existsBy({ parentPageId: pageId });
|
||||
}
|
||||
|
||||
/** Whether another page under the same parent already has this route. */
|
||||
async hasSiblingWithRoute(
|
||||
appId: string,
|
||||
parentPageId: string | null,
|
||||
route: string,
|
||||
excludePageId?: string,
|
||||
) {
|
||||
return await this.existsBy({
|
||||
appId,
|
||||
parentPageId: parentPageId ?? IsNull(),
|
||||
route,
|
||||
...(excludePageId ? { id: Not(excludePageId) } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async updatePage(page: Page, updates: Partial<Pick<Page, 'route' | 'content'>>) {
|
||||
return await this.save(Object.assign(page, updates));
|
||||
}
|
||||
|
||||
async deletePage(id: string) {
|
||||
await this.delete({ id });
|
||||
}
|
||||
}
|
||||
|
|
@ -44,6 +44,26 @@ async function hasGlobalCredentialAccess(
|
|||
return isReadOnlyRequest || globalCredential.isResolvable;
|
||||
}
|
||||
|
||||
/** Apps don't have resource-level roles, only project-level access. */
|
||||
async function resolveAppProjectId(appId: string): Promise<string> {
|
||||
const moduleRegistry = Container.get(ModuleRegistry);
|
||||
if (!moduleRegistry.isActive('apps')) {
|
||||
throw new NotFoundError(`App with ID "${appId}" not found.`);
|
||||
}
|
||||
|
||||
const { AppRepository } = await import('@/modules/apps/app.repository.js');
|
||||
const app = await Container.get(AppRepository).findOne({
|
||||
where: { id: appId },
|
||||
relations: ['project'],
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new NotFoundError(`App with ID "${appId}" not found.`);
|
||||
}
|
||||
|
||||
return app.project.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has the required scopes. The check can be:
|
||||
*
|
||||
|
|
@ -61,11 +81,13 @@ export async function userHasScopes(
|
|||
workflowId,
|
||||
projectId,
|
||||
dataTableId,
|
||||
appId,
|
||||
}: {
|
||||
credentialId?: string;
|
||||
workflowId?: string;
|
||||
projectId?: string;
|
||||
dataTableId?: string;
|
||||
appId?: string;
|
||||
} /* only one */,
|
||||
entityManager?: EntityManager,
|
||||
): Promise<boolean> {
|
||||
|
|
@ -177,9 +199,11 @@ export async function userHasScopes(
|
|||
return userProjectIds.includes(dataTable.project.id);
|
||||
}
|
||||
|
||||
if (appId) return userProjectIds.includes(await resolveAppProjectId(appId));
|
||||
|
||||
if (projectId) return userProjectIds.includes(projectId);
|
||||
|
||||
throw new UnexpectedError(
|
||||
"`@ProjectScope` decorator was used but does not have a `credentialId`, `workflowId`, `dataTableId`, or `projectId` in its URL parameters. This is likely an implementation error. If you're a developer, please check your URL is correct or that this should be using `@GlobalScope`.",
|
||||
"`@ProjectScope` decorator was used but does not have a `credentialId`, `workflowId`, `dataTableId`, `appId`, or `projectId` in its URL parameters. This is likely an implementation error. If you're a developer, please check your URL is correct or that this should be using `@GlobalScope`.",
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,11 @@
|
|||
"name": "GitConnectionProject",
|
||||
"path": "packages/cli/src/modules/git-connections.ee/database/entities/git-connection-project.entity.ts",
|
||||
"reason": "Project-scoped git configuration (which connection the project is linked to), not user data. Dropped via the projectId FK cascade when the source project itself is deleted, as in ownership-transfer flows."
|
||||
},
|
||||
{
|
||||
"name": "App",
|
||||
"path": "packages/cli/src/modules/apps/app.entity.ts",
|
||||
"reason": "Hackweek slice: ownership transfer isn't implemented yet (unlike DataTable, which this entity otherwise follows closely). Dropped via the projectId FK cascade when the source project itself is deleted; revisit before this ships beyond the prototype."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,252 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { getPersonalProject, testDb } from '@n8n/backend-test-utils';
|
||||
import type { Project, User } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { AppRepository } from '@/modules/apps/app.repository';
|
||||
import { PageRepository } from '@/modules/apps/page.repository';
|
||||
import { createMember, createOwner } from '@test-integration/db/users';
|
||||
import type { SuperAgentTest } from '@test-integration/types';
|
||||
import * as utils from '@test-integration/utils';
|
||||
|
||||
let owner: User;
|
||||
let member: User;
|
||||
let authOwnerAgent: SuperAgentTest;
|
||||
let authMemberAgent: SuperAgentTest;
|
||||
let ownerProject: Project;
|
||||
|
||||
const testServer = utils.setupTestServer({
|
||||
endpointGroups: ['apps'],
|
||||
modules: ['apps'],
|
||||
});
|
||||
|
||||
let appRepository: AppRepository;
|
||||
let pageRepository: PageRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
appRepository = Container.get(AppRepository);
|
||||
pageRepository = Container.get(PageRepository);
|
||||
|
||||
owner = await createOwner();
|
||||
member = await createMember();
|
||||
|
||||
authOwnerAgent = testServer.authAgentFor(owner);
|
||||
authMemberAgent = testServer.authAgentFor(member);
|
||||
|
||||
ownerProject = await getPersonalProject(owner);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['App', 'Page']);
|
||||
});
|
||||
|
||||
describe('POST /projects/:projectId/apps', () => {
|
||||
test('creates an app for a project member', async () => {
|
||||
const response = await authOwnerAgent
|
||||
.post(`/projects/${ownerProject.id}/apps`)
|
||||
.send({ name: 'My App', namespace: 'my-app' })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.data).toMatchObject({ name: 'My App', namespace: 'my-app' });
|
||||
});
|
||||
|
||||
test('rejects a non-member with 403', async () => {
|
||||
await authMemberAgent
|
||||
.post(`/projects/${ownerProject.id}/apps`)
|
||||
.send({ name: 'My App', namespace: 'my-app' })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
test('rejects a duplicate namespace in the same project with 409', async () => {
|
||||
await appRepository.createApp(ownerProject.id, 'First', 'dup');
|
||||
|
||||
await authOwnerAgent
|
||||
.post(`/projects/${ownerProject.id}/apps`)
|
||||
.send({ name: 'Second', namespace: 'dup' })
|
||||
.expect(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /projects/:projectId/apps', () => {
|
||||
test('lists apps for a project member, 403 for a non-member', async () => {
|
||||
await appRepository.createApp(ownerProject.id, 'My App', 'my-app');
|
||||
|
||||
const response = await authOwnerAgent.get(`/projects/${ownerProject.id}/apps`).expect(200);
|
||||
expect(response.body.data).toHaveLength(1);
|
||||
|
||||
await authMemberAgent.get(`/projects/${ownerProject.id}/apps`).expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('App pages', () => {
|
||||
test('creates a page and a nested child page under it', async () => {
|
||||
const app = await appRepository.createApp(ownerProject.id, 'My App', 'my-app');
|
||||
|
||||
const rootResponse = await authOwnerAgent
|
||||
.post(`/projects/${ownerProject.id}/apps/${app.id}/pages`)
|
||||
.send({ route: 'home' })
|
||||
.expect(200);
|
||||
|
||||
const childResponse = await authOwnerAgent
|
||||
.post(`/projects/${ownerProject.id}/apps/${app.id}/pages`)
|
||||
.send({ route: 'nested', parentPageId: rootResponse.body.data.id })
|
||||
.expect(200);
|
||||
|
||||
expect(childResponse.body.data.parentPageId).toBe(rootResponse.body.data.id);
|
||||
|
||||
const listResponse = await authOwnerAgent
|
||||
.get(`/projects/${ownerProject.id}/apps/${app.id}/pages`)
|
||||
.expect(200);
|
||||
expect(listResponse.body.data).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('accepts an empty route, meaning this page is the index page for its level', async () => {
|
||||
const app = await appRepository.createApp(ownerProject.id, 'My App', 'my-app');
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.post(`/projects/${ownerProject.id}/apps/${app.id}/pages`)
|
||||
.send({ route: '' })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.data.route).toBe('');
|
||||
});
|
||||
|
||||
test('accepts a dynamic param segment, e.g. :id', async () => {
|
||||
const app = await appRepository.createApp(ownerProject.id, 'My App', 'my-app');
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.post(`/projects/${ownerProject.id}/apps/${app.id}/pages`)
|
||||
.send({ route: ':id' })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.data.route).toBe(':id');
|
||||
});
|
||||
|
||||
test('rejects creating a sub-page under an index page', async () => {
|
||||
const app = await appRepository.createApp(ownerProject.id, 'My App', 'my-app');
|
||||
const indexPage = await pageRepository.createPage(app.id, null, '');
|
||||
|
||||
await authOwnerAgent
|
||||
.post(`/projects/${ownerProject.id}/apps/${app.id}/pages`)
|
||||
.send({ route: 'child', parentPageId: indexPage.id })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
test('rejects turning a page with children into an index page', async () => {
|
||||
const app = await appRepository.createApp(ownerProject.id, 'My App', 'my-app');
|
||||
const parent = await pageRepository.createPage(app.id, null, 'parent');
|
||||
await pageRepository.createPage(app.id, parent.id, 'child');
|
||||
|
||||
await authOwnerAgent
|
||||
.patch(`/projects/${ownerProject.id}/apps/${app.id}/pages/${parent.id}`)
|
||||
.send({ route: '' })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
test('rejects creating a page whose route collides with a sibling, at any level', async () => {
|
||||
const app = await appRepository.createApp(ownerProject.id, 'My App', 'my-app');
|
||||
|
||||
await authOwnerAgent
|
||||
.post(`/projects/${ownerProject.id}/apps/${app.id}/pages`)
|
||||
.send({ route: 'foo' })
|
||||
.expect(200);
|
||||
|
||||
await authOwnerAgent
|
||||
.post(`/projects/${ownerProject.id}/apps/${app.id}/pages`)
|
||||
.send({ route: 'foo' })
|
||||
.expect(409);
|
||||
});
|
||||
|
||||
test('rejects creating a second index page at the same level', async () => {
|
||||
const app = await appRepository.createApp(ownerProject.id, 'My App', 'my-app');
|
||||
await pageRepository.createPage(app.id, null, '');
|
||||
|
||||
await authOwnerAgent
|
||||
.post(`/projects/${ownerProject.id}/apps/${app.id}/pages`)
|
||||
.send({ route: '' })
|
||||
.expect(409);
|
||||
});
|
||||
|
||||
test('allows the same route at different levels (different parents)', async () => {
|
||||
const app = await appRepository.createApp(ownerProject.id, 'My App', 'my-app');
|
||||
const parentA = await pageRepository.createPage(app.id, null, 'a');
|
||||
const parentB = await pageRepository.createPage(app.id, null, 'b');
|
||||
|
||||
await authOwnerAgent
|
||||
.post(`/projects/${ownerProject.id}/apps/${app.id}/pages`)
|
||||
.send({ route: 'same', parentPageId: parentA.id })
|
||||
.expect(200);
|
||||
|
||||
await authOwnerAgent
|
||||
.post(`/projects/${ownerProject.id}/apps/${app.id}/pages`)
|
||||
.send({ route: 'same', parentPageId: parentB.id })
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
test('rejects renaming a page to collide with a sibling', async () => {
|
||||
const app = await appRepository.createApp(ownerProject.id, 'My App', 'my-app');
|
||||
await pageRepository.createPage(app.id, null, 'foo');
|
||||
const other = await pageRepository.createPage(app.id, null, 'bar');
|
||||
|
||||
await authOwnerAgent
|
||||
.patch(`/projects/${ownerProject.id}/apps/${app.id}/pages/${other.id}`)
|
||||
.send({ route: 'foo' })
|
||||
.expect(409);
|
||||
});
|
||||
|
||||
test('allows saving a page with its own current route unchanged', async () => {
|
||||
const app = await appRepository.createApp(ownerProject.id, 'My App', 'my-app');
|
||||
const page = await pageRepository.createPage(app.id, null, 'foo');
|
||||
|
||||
await authOwnerAgent
|
||||
.patch(`/projects/${ownerProject.id}/apps/${app.id}/pages/${page.id}`)
|
||||
.send({ route: 'foo' })
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
test('deleting an app deletes its pages', async () => {
|
||||
const app = await appRepository.createApp(ownerProject.id, 'My App', 'my-app');
|
||||
await pageRepository.createPage(app.id, null, 'home');
|
||||
|
||||
await authOwnerAgent.delete(`/projects/${ownerProject.id}/apps/${app.id}`).expect(200);
|
||||
|
||||
const remaining = await pageRepository.findManyByAppId(app.id);
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("rejects updating a page through a different app's URL", async () => {
|
||||
const appA = await appRepository.createApp(ownerProject.id, 'App A', 'app-a');
|
||||
const appB = await appRepository.createApp(ownerProject.id, 'App B', 'app-b');
|
||||
const page = await pageRepository.createPage(appA.id, null, 'foo');
|
||||
|
||||
await authOwnerAgent
|
||||
.patch(`/projects/${ownerProject.id}/apps/${appB.id}/pages/${page.id}`)
|
||||
.send({ route: 'moved' })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
test("rejects deleting a page through a different app's URL, leaving it untouched", async () => {
|
||||
const appA = await appRepository.createApp(ownerProject.id, 'App A', 'app-a');
|
||||
const appB = await appRepository.createApp(ownerProject.id, 'App B', 'app-b');
|
||||
const page = await pageRepository.createPage(appA.id, null, 'foo');
|
||||
|
||||
await authOwnerAgent
|
||||
.delete(`/projects/${ownerProject.id}/apps/${appB.id}/pages/${page.id}`)
|
||||
.expect(404);
|
||||
|
||||
const remaining = await pageRepository.findManyByAppId(appA.id);
|
||||
expect(remaining).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('rejects creating a page whose parentPageId belongs to a different app', async () => {
|
||||
const appA = await appRepository.createApp(ownerProject.id, 'App A', 'app-a');
|
||||
const appB = await appRepository.createApp(ownerProject.id, 'App B', 'app-b');
|
||||
const pageInA = await pageRepository.createPage(appA.id, null, 'foo');
|
||||
|
||||
await authOwnerAgent
|
||||
.post(`/projects/${ownerProject.id}/apps/${appB.id}/pages`)
|
||||
.send({ route: 'child', parentPageId: pageInA.id })
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
|
@ -52,6 +52,7 @@ type EndpointGroup =
|
|||
| 'module-settings'
|
||||
| 'security-settings'
|
||||
| 'data-table'
|
||||
| 'apps'
|
||||
| 'third-party-licenses'
|
||||
| 'mcp'
|
||||
| 'workflowDependencies'
|
||||
|
|
@ -65,6 +66,7 @@ type ModuleName =
|
|||
| 'external-secrets'
|
||||
| 'community-packages'
|
||||
| 'data-table'
|
||||
| 'apps'
|
||||
| 'mcp'
|
||||
| 'oauth-server'
|
||||
| 'dynamic-credentials'
|
||||
|
|
|
|||
|
|
@ -387,6 +387,10 @@ export const setupTestServer = ({
|
|||
await import('@/modules/data-table/data-table.module.js');
|
||||
break;
|
||||
|
||||
case 'apps':
|
||||
await import('@/modules/apps/apps.module.js');
|
||||
break;
|
||||
|
||||
case 'workflow-reviews':
|
||||
await import('@/modules/workflow-reviews.ee/workflow-reviews.module.js');
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,189 @@
|
|||
import {
|
||||
createTestMigrationContext,
|
||||
initDbUpToMigration,
|
||||
runSingleMigration,
|
||||
undoLastSingleMigration,
|
||||
type TestMigrationContext,
|
||||
} from '@n8n/backend-test-utils';
|
||||
import { DbConnection } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { DataSource } from '@n8n/typeorm';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
const MIGRATION_NAME = 'CreateAppTables1788774977417';
|
||||
const APP_TABLE = 'app';
|
||||
const PAGE_TABLE = 'page';
|
||||
|
||||
describe('CreateAppTables migration', () => {
|
||||
let dataSource: DataSource;
|
||||
let projectId: string;
|
||||
|
||||
async function withContext<T>(fn: (context: TestMigrationContext) => Promise<T>): Promise<T> {
|
||||
const context = createTestMigrationContext(dataSource);
|
||||
try {
|
||||
return await fn(context);
|
||||
} finally {
|
||||
await context.queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await Container.get(DbConnection).init();
|
||||
dataSource = Container.get(DataSource);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await withContext(async (context) => await context.queryRunner.clearDatabase());
|
||||
await initDbUpToMigration(MIGRATION_NAME);
|
||||
await runSingleMigration(MIGRATION_NAME);
|
||||
dataSource = Container.get(DataSource);
|
||||
projectId = randomUUID();
|
||||
await withContext(async (context) => await insertProject(context, projectId));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await Container.get(DbConnection).close();
|
||||
});
|
||||
|
||||
async function insertProject(context: TestMigrationContext, id: string) {
|
||||
const table = context.escape.tableName('project');
|
||||
const now = new Date();
|
||||
await context.runQuery(
|
||||
`INSERT INTO ${table} ("id", "name", "type", "customTelemetryTags", "createdAt", "updatedAt")
|
||||
VALUES (:id, :name, :type, :tags, :createdAt, :updatedAt)`,
|
||||
{ id, name: 'Project', type: 'team', tags: '[]', createdAt: now, updatedAt: now },
|
||||
);
|
||||
}
|
||||
|
||||
async function insertApp(
|
||||
context: TestMigrationContext,
|
||||
entry: { id?: string; namespace: string; projectId?: string },
|
||||
) {
|
||||
const table = context.escape.tableName(APP_TABLE);
|
||||
const now = new Date();
|
||||
const id = entry.id ?? randomUUID();
|
||||
await context.runQuery(
|
||||
`INSERT INTO ${table} ("id", "name", "namespace", "projectId", "createdAt", "updatedAt")
|
||||
VALUES (:id, :name, :namespace, :projectId, :createdAt, :updatedAt)`,
|
||||
{
|
||||
id,
|
||||
name: 'My App',
|
||||
namespace: entry.namespace,
|
||||
projectId: entry.projectId ?? projectId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function insertPage(
|
||||
context: TestMigrationContext,
|
||||
entry: { id?: string; appId: string; parentPageId?: string | null; route: string },
|
||||
) {
|
||||
const table = context.escape.tableName(PAGE_TABLE);
|
||||
const now = new Date();
|
||||
const id = entry.id ?? randomUUID();
|
||||
await context.runQuery(
|
||||
`INSERT INTO ${table} ("id", "appId", "parentPageId", "route", "createdAt", "updatedAt")
|
||||
VALUES (:id, :appId, :parentPageId, :route, :createdAt, :updatedAt)`,
|
||||
{
|
||||
id,
|
||||
appId: entry.appId,
|
||||
parentPageId: entry.parentPageId ?? null,
|
||||
route: entry.route,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function getPageIds(context: TestMigrationContext): Promise<string[]> {
|
||||
const table = context.escape.tableName(PAGE_TABLE);
|
||||
const rows = await context.runQuery<Array<{ id: string }>>(`SELECT "id" FROM ${table}`);
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
it('creates an app and a page under it', async () => {
|
||||
const pageId = await withContext(async (context) => {
|
||||
const appId = await insertApp(context, { namespace: 'my-app' });
|
||||
return await insertPage(context, { appId, route: 'home' });
|
||||
});
|
||||
|
||||
expect(pageId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects a second app with the same namespace in the same project', async () => {
|
||||
await expect(
|
||||
withContext(async (context) => {
|
||||
await insertApp(context, { namespace: 'dup' });
|
||||
await insertApp(context, { namespace: 'dup' });
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('allows the same namespace across two different projects', async () => {
|
||||
const otherProjectId = randomUUID();
|
||||
await withContext(async (context) => {
|
||||
await insertProject(context, otherProjectId);
|
||||
await insertApp(context, { namespace: 'shared-name' });
|
||||
await insertApp(context, { namespace: 'shared-name', projectId: otherProjectId });
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes an app's pages when the app is deleted", async () => {
|
||||
const remaining = await withContext(async (context) => {
|
||||
const appId = await insertApp(context, { namespace: 'my-app' });
|
||||
await insertPage(context, { appId, route: 'home' });
|
||||
|
||||
const table = context.escape.tableName(APP_TABLE);
|
||||
await context.runQuery(`DELETE FROM ${table} WHERE "id" = :id`, { id: appId });
|
||||
|
||||
return await getPageIds(context);
|
||||
});
|
||||
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("deletes an app's pages when the owning project is deleted", async () => {
|
||||
const remaining = await withContext(async (context) => {
|
||||
const appId = await insertApp(context, { namespace: 'my-app' });
|
||||
await insertPage(context, { appId, route: 'home' });
|
||||
|
||||
const table = context.escape.tableName('project');
|
||||
await context.runQuery(`DELETE FROM ${table} WHERE "id" = :id`, { id: projectId });
|
||||
|
||||
return await getPageIds(context);
|
||||
});
|
||||
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('deletes a child page when its parent page is deleted', async () => {
|
||||
const remaining = await withContext(async (context) => {
|
||||
const appId = await insertApp(context, { namespace: 'my-app' });
|
||||
const parentId = await insertPage(context, { appId, route: 'parent' });
|
||||
await insertPage(context, { appId, parentPageId: parentId, route: 'child' });
|
||||
|
||||
const table = context.escape.tableName(PAGE_TABLE);
|
||||
await context.runQuery(`DELETE FROM ${table} WHERE "id" = :id`, { id: parentId });
|
||||
|
||||
return await getPageIds(context);
|
||||
});
|
||||
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('drops both tables on revert', async () => {
|
||||
await undoLastSingleMigration();
|
||||
dataSource = Container.get(DataSource);
|
||||
|
||||
await withContext(async (context) => {
|
||||
const appTable = context.escape.tableName(APP_TABLE);
|
||||
const pageTable = context.escape.tableName(PAGE_TABLE);
|
||||
await expect(context.runQuery(`SELECT 1 FROM ${appTable}`)).rejects.toThrow();
|
||||
await expect(context.runQuery(`SELECT 1 FROM ${pageTable}`)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -5138,6 +5138,44 @@
|
|||
"contextual.users.settings.unavailable.button.cloud": "Upgrade now",
|
||||
"contextual.feature.unavailable.title": "Available on the Enterprise Plan",
|
||||
"contextual.feature.unavailable.title.cloud": "Available on the Pro Plan",
|
||||
"apps.apps": "Apps",
|
||||
"apps.empty.heading": "Create your first app",
|
||||
"apps.empty.description": "Build a page backed by your workflows",
|
||||
"apps.add.title": "Create new app",
|
||||
"apps.add.button.label": "Create app",
|
||||
"apps.add.input.name.label": "App name",
|
||||
"apps.add.input.name.placeholder": "Enter app name",
|
||||
"apps.add.input.namespace.label": "Namespace",
|
||||
"apps.add.input.namespace.placeholder": "my-app",
|
||||
"apps.add.input.namespace.hint": "This is the base URL where the app will be available: {path}",
|
||||
"apps.add.error": "Error creating app",
|
||||
"apps.delete.confirm.title": "Delete app",
|
||||
"apps.delete.confirm.message": "Are you sure you want to delete \"{name}\"? This will also delete all of its pages. This can't be undone.",
|
||||
"apps.delete.error": "Error deleting app",
|
||||
"apps.notFound": "App not found",
|
||||
"apps.getDetails.error": "Error loading app",
|
||||
"apps.url.label": "App URL",
|
||||
"apps.pages": "Pages",
|
||||
"apps.page.subPageCount": "{count} sub-page | {count} sub-pages",
|
||||
"apps.page.subPages": "Sub-pages",
|
||||
"apps.pages.empty": "No pages yet",
|
||||
"apps.page.new": "New page",
|
||||
"apps.page.addChild": "Add sub 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.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.url.label": "Page URL",
|
||||
"apps.page.save": "Save",
|
||||
"apps.page.save.error": "Error saving page",
|
||||
"dataTable.dataTables": "Data tables",
|
||||
"dataTables.noResults": "No data tables found",
|
||||
"dataTable.empty.heading": "Create your first data table",
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => {
|
|||
folder: {},
|
||||
insights: {},
|
||||
dataTable: {},
|
||||
app: {},
|
||||
execution: {},
|
||||
testRun: {},
|
||||
workflowTags: {},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { FrontendModuleDescription } from '@n8n/frontend-module-sdk';
|
||||
import { DataTableModule } from '@/features/core/dataTable/module.descriptor';
|
||||
import { AppsModule } from '@/features/apps/module.descriptor';
|
||||
import { InsightsModule } from '@/features/execution/insights/module.descriptor';
|
||||
import { MCPModule } from '@/features/ai/mcpAccess/module.descriptor';
|
||||
import { ChatModule } from '@/features/ai/chatHub/module.descriptor';
|
||||
|
|
@ -16,6 +17,7 @@ import { PromotionsModule } from '@/features/integrations/promotions.ee/module.d
|
|||
export const modules: FrontendModuleDescription[] = [
|
||||
InsightsModule,
|
||||
DataTableModule,
|
||||
AppsModule,
|
||||
MCPModule,
|
||||
ChatModule,
|
||||
InstanceAiModule,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
<script setup lang="ts">
|
||||
import { N8nBreadcrumbs } from '@n8n/design-system';
|
||||
import type { PathItem } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { useAppsStore } from '@/features/apps/apps.store';
|
||||
import { formatRouteSegment, getAncestorPages } from '@/features/apps/pageTree.utils';
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: string;
|
||||
appId: string;
|
||||
appName: string;
|
||||
// Omit when the breadcrumb trail ends at the App itself (no specific page open).
|
||||
currentPageId?: string;
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const router = useRouter();
|
||||
const appsStore = useAppsStore();
|
||||
|
||||
// No leading slash here: the breadcrumb's own separator between items
|
||||
// already renders one, so "route" wouldn't read "Apps / MyApp / /route".
|
||||
const routeLabel = (route: string) => formatRouteSegment(route, i18n.baseText('apps.page.index'));
|
||||
|
||||
const ancestorPages = computed(() =>
|
||||
props.currentPageId ? getAncestorPages(appsStore.pages, props.currentPageId) : [],
|
||||
);
|
||||
|
||||
const items = computed<PathItem[]>(() => {
|
||||
const result: PathItem[] = [
|
||||
{
|
||||
id: 'apps-root',
|
||||
label: i18n.baseText('apps.apps'),
|
||||
href: `/projects/${props.projectId}/apps`,
|
||||
},
|
||||
{
|
||||
id: props.appId,
|
||||
label: props.appName,
|
||||
href: props.currentPageId ? `/projects/${props.projectId}/apps/${props.appId}` : undefined,
|
||||
},
|
||||
];
|
||||
|
||||
for (const page of ancestorPages.value) {
|
||||
result.push({
|
||||
id: page.id,
|
||||
label: routeLabel(page.route),
|
||||
href: `/projects/${props.projectId}/apps/${props.appId}/pages/${page.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (props.currentPageId) {
|
||||
const currentPage = appsStore.pages.find((page) => page.id === props.currentPageId);
|
||||
if (currentPage) {
|
||||
result.push({ id: currentPage.id, label: routeLabel(currentPage.route) });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const onItemSelected = async (item: PathItem) => {
|
||||
if (item.href) await router.push(item.href);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nBreadcrumbs
|
||||
:class="$style.breadcrumbs"
|
||||
:items="items"
|
||||
separator="/"
|
||||
data-test-id="app-breadcrumbs"
|
||||
@item-selected="onItemSelected"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.breadcrumbs {
|
||||
margin-bottom: var(--spacing--lg);
|
||||
}
|
||||
</style>
|
||||
183
packages/frontend/editor-ui/src/features/apps/AppDetailsView.vue
Normal file
183
packages/frontend/editor-ui/src/features/apps/AppDetailsView.vue
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
<script setup lang="ts">
|
||||
import { N8nButton, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
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 { 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 type { App } from '@/features/apps/apps.types';
|
||||
import { getChildCounts } from '@/features/apps/pageTree.utils';
|
||||
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: string;
|
||||
appId: string;
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const toast = useToast();
|
||||
const router = useRouter();
|
||||
const uiStore = useUIStore();
|
||||
const documentTitle = useDocumentTitle();
|
||||
const { confirmAndDeleteApp, confirmAndDeletePage } = useAppDeletion();
|
||||
|
||||
const appsStore = useAppsStore();
|
||||
|
||||
const app = ref<App | null>(null);
|
||||
const loading = ref(false);
|
||||
|
||||
const rootPages = computed(() => appsStore.pages.filter((page) => page.parentPageId === null));
|
||||
const childCounts = computed(() => getChildCounts(appsStore.pages));
|
||||
|
||||
const appUrl = computed(() =>
|
||||
app.value ? `${window.location.origin}/apps/${app.value.namespace}` : '',
|
||||
);
|
||||
|
||||
const showErrorAndGoBack = async (error: unknown) => {
|
||||
toast.showError(error, i18n.baseText('apps.getDetails.error'));
|
||||
await router.push({ name: PROJECT_APPS, params: { projectId: props.projectId } });
|
||||
};
|
||||
|
||||
const initialize = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [result] = await Promise.all([
|
||||
appsStore.getApp(props.projectId, props.appId),
|
||||
appsStore.fetchPages(props.projectId, props.appId),
|
||||
]);
|
||||
app.value = result;
|
||||
documentTitle.set(`${i18n.baseText('apps.apps')} > ${result.name}`);
|
||||
} catch (error) {
|
||||
await showErrorAndGoBack(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openAddPageModal = (parentPageId: string | null) => {
|
||||
uiStore.openModalWithData({
|
||||
name: ADD_PAGE_MODAL_KEY,
|
||||
data: { projectId: props.projectId, appId: props.appId, parentPageId },
|
||||
});
|
||||
};
|
||||
|
||||
const openPage = async (pageId: string) => {
|
||||
await router.push({
|
||||
name: APP_PAGE_DETAILS,
|
||||
params: { projectId: props.projectId, appId: props.appId, pageId },
|
||||
});
|
||||
};
|
||||
|
||||
const onDeleteApp = async () => {
|
||||
if (!app.value) return;
|
||||
const deleted = await confirmAndDeleteApp(props.projectId, app.value);
|
||||
if (deleted) await router.push({ name: PROJECT_APPS, params: { projectId: props.projectId } });
|
||||
};
|
||||
|
||||
const onDeletePage = async (pageId: string) => {
|
||||
await confirmAndDeletePage(props.projectId, props.appId, pageId);
|
||||
};
|
||||
|
||||
onMounted(initialize);
|
||||
|
||||
// Navigating to a different app reuses this same route component instance —
|
||||
// onMounted only fires once, so re-run on an appId change.
|
||||
watch(() => props.appId, initialize);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageViewLayout data-test-id="app-details-view">
|
||||
<template #header>
|
||||
<div :class="$style.breadcrumbsRow">
|
||||
<AppBreadcrumbs v-if="app" :project-id="projectId" :app-id="appId" :app-name="app.name" />
|
||||
<N8nButton
|
||||
v-if="app"
|
||||
icon-only
|
||||
icon="trash-2"
|
||||
variant="subtle"
|
||||
:aria-label="i18n.baseText('generic.delete')"
|
||||
data-test-id="app-delete"
|
||||
@click="onDeleteApp"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div :class="$style.container">
|
||||
<div v-if="app" :class="$style.urlCard">
|
||||
<CopyInput
|
||||
:label="i18n.baseText('apps.url.label')"
|
||||
:value="appUrl"
|
||||
data-test-id="app-url"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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)">
|
||||
{{ i18n.baseText('apps.page.new') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
|
||||
<N8nText v-if="rootPages.length === 0" color="text-light">
|
||||
{{ i18n.baseText('apps.pages.empty') }}
|
||||
</N8nText>
|
||||
|
||||
<div :class="$style.pageGrid">
|
||||
<PageCard
|
||||
v-for="page in rootPages"
|
||||
:key="page.id"
|
||||
:page="page"
|
||||
:child-count="childCounts.get(page.id) ?? 0"
|
||||
@open="openPage"
|
||||
@add-child="openAddPageModal"
|
||||
@delete="onDeletePage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PageViewLayout>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.breadcrumbsRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
width: 100%;
|
||||
padding-bottom: var(--spacing--lg);
|
||||
}
|
||||
|
||||
.urlCard {
|
||||
background-color: var(--background--surface);
|
||||
border-radius: var(--radius--lg);
|
||||
padding: var(--spacing--md);
|
||||
margin-bottom: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.pageGrid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
124
packages/frontend/editor-ui/src/features/apps/AppsView.vue
Normal file
124
packages/frontend/editor-ui/src/features/apps/AppsView.vue
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<script setup lang="ts">
|
||||
import { N8nButton, N8nCard, N8nHeading, N8nIcon, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import PageViewLayout from '@/app/components/layouts/PageViewLayout.vue';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { useProjectPages } from '@/features/collaboration/projects/composables/useProjectPages';
|
||||
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
|
||||
import { useAppsStore } from '@/features/apps/apps.store';
|
||||
import { useAppDeletion } from '@/features/apps/useAppDeletion';
|
||||
import { ADD_APP_MODAL_KEY, APP_DETAILS } from '@/features/apps/apps.constants';
|
||||
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
|
||||
|
||||
const i18n = useI18n();
|
||||
const toast = useToast();
|
||||
const router = useRouter();
|
||||
const uiStore = useUIStore();
|
||||
const documentTitle = useDocumentTitle();
|
||||
const { confirmAndDeleteApp } = useAppDeletion();
|
||||
|
||||
const projectPages = useProjectPages();
|
||||
const projectsStore = useProjectsStore();
|
||||
const appsStore = useAppsStore();
|
||||
|
||||
// On the home overview page there's no :projectId route param — fall back to
|
||||
// the user's personal project, same as Data Tables does.
|
||||
const currentProject = computed(() =>
|
||||
projectPages.isOverviewSubPage ? projectsStore.personalProject : projectsStore.currentProject,
|
||||
);
|
||||
|
||||
const projectId = () => currentProject.value?.id ?? '';
|
||||
|
||||
const fetchApps = async () => {
|
||||
try {
|
||||
await appsStore.fetchApps(projectId());
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('apps.getDetails.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const openAddAppModal = () => {
|
||||
uiStore.openModalWithData({ name: ADD_APP_MODAL_KEY, data: { projectId: projectId() } });
|
||||
};
|
||||
|
||||
const openApp = async (appId: string) => {
|
||||
await router.push({ name: APP_DETAILS, params: { projectId: projectId(), appId } });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
documentTitle.set(i18n.baseText('apps.apps'));
|
||||
void fetchApps();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageViewLayout data-test-id="apps-view">
|
||||
<template #header>
|
||||
<div :class="$style.header">
|
||||
<N8nHeading bold tag="h1" size="2xlarge">{{ i18n.baseText('apps.apps') }}</N8nHeading>
|
||||
<N8nButton data-test-id="apps-new" @click="openAddAppModal">
|
||||
{{ i18n.baseText('apps.add.button.label') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div :class="$style.container">
|
||||
<N8nText v-if="appsStore.apps.length === 0" color="text-light">
|
||||
{{ i18n.baseText('apps.empty.heading') }}
|
||||
</N8nText>
|
||||
|
||||
<N8nCard
|
||||
v-for="app in appsStore.apps"
|
||||
:key="app.id"
|
||||
hoverable
|
||||
:class="$style.appCard"
|
||||
data-test-id="app-card"
|
||||
@click="openApp(app.id)"
|
||||
>
|
||||
<template #prepend>
|
||||
<N8nIcon icon="grid-2x2" />
|
||||
</template>
|
||||
<N8nText bold>{{ app.name }}</N8nText>
|
||||
<N8nText color="text-light" size="small">/{{ app.namespace }}</N8nText>
|
||||
<template #append>
|
||||
<N8nButton
|
||||
icon-only
|
||||
icon="trash-2"
|
||||
variant="subtle"
|
||||
:aria-label="i18n.baseText('generic.delete')"
|
||||
data-test-id="app-delete"
|
||||
@click.stop="confirmAndDeleteApp(projectId(), app)"
|
||||
/>
|
||||
</template>
|
||||
</N8nCard>
|
||||
</div>
|
||||
</PageViewLayout>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing--lg);
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
width: 100%;
|
||||
padding-bottom: var(--spacing--lg);
|
||||
}
|
||||
|
||||
.appCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
77
packages/frontend/editor-ui/src/features/apps/PageCard.vue
Normal file
77
packages/frontend/editor-ui/src/features/apps/PageCard.vue
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<script setup lang="ts">
|
||||
import { N8nButton, N8nCard, N8nIcon, N8nText, N8nTooltip } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import type { Page } from '@/features/apps/apps.types';
|
||||
import { formatRoutePath } from '@/features/apps/pageTree.utils';
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
page: Page;
|
||||
childCount: number;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
open: [pageId: string];
|
||||
addChild: [parentPageId: string];
|
||||
delete: [pageId: string];
|
||||
}>();
|
||||
|
||||
// An empty route means this page is the index page for its level — and,
|
||||
// since it contributes no path segment of its own, it can't have children
|
||||
// (a child of it would resolve to the same URL as a sibling of this page).
|
||||
const routeLabel = computed(() =>
|
||||
formatRoutePath(props.page.route, i18n.baseText('apps.page.index')),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nCard hoverable :class="$style.card" data-test-id="page-card" @click="$emit('open', page.id)">
|
||||
<template #prepend>
|
||||
<N8nIcon icon="file" />
|
||||
</template>
|
||||
<N8nText bold data-test-id="app-page-route">{{ routeLabel }}</N8nText>
|
||||
<N8nText v-if="childCount > 0" color="text-light" size="small">
|
||||
{{ i18n.baseText('apps.page.subPageCount', { adjustToNumber: childCount }) }}
|
||||
</N8nText>
|
||||
<template #append>
|
||||
<div :class="$style.actions" @click.stop>
|
||||
<N8nTooltip v-if="page.route" :content="i18n.baseText('apps.page.addChild')">
|
||||
<N8nButton
|
||||
icon-only
|
||||
icon="plus"
|
||||
size="small"
|
||||
variant="subtle"
|
||||
:aria-label="i18n.baseText('apps.page.addChild')"
|
||||
data-test-id="app-page-add-child"
|
||||
@click="$emit('addChild', page.id)"
|
||||
/>
|
||||
</N8nTooltip>
|
||||
<N8nTooltip :content="i18n.baseText('generic.delete')">
|
||||
<N8nButton
|
||||
icon-only
|
||||
icon="trash-2"
|
||||
size="small"
|
||||
variant="subtle"
|
||||
:aria-label="i18n.baseText('generic.delete')"
|
||||
data-test-id="app-page-delete"
|
||||
@click="$emit('delete', page.id)"
|
||||
/>
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
</template>
|
||||
</N8nCard>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.card {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--spacing--4xs);
|
||||
}
|
||||
</style>
|
||||
278
packages/frontend/editor-ui/src/features/apps/PageView.vue
Normal file
278
packages/frontend/editor-ui/src/features/apps/PageView.vue
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
<script setup lang="ts">
|
||||
import { N8nButton, N8nInput, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
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 { 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 type { App } from '@/features/apps/apps.types';
|
||||
import {
|
||||
formatRoutePath,
|
||||
getAncestorPages,
|
||||
getChildCounts,
|
||||
getPageUrl,
|
||||
} from '@/features/apps/pageTree.utils';
|
||||
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: string;
|
||||
appId: string;
|
||||
pageId: string;
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const toast = useToast();
|
||||
const router = useRouter();
|
||||
const uiStore = useUIStore();
|
||||
const documentTitle = useDocumentTitle();
|
||||
const { confirmAndDeletePage } = useAppDeletion();
|
||||
|
||||
const appsStore = useAppsStore();
|
||||
|
||||
const app = ref<App | null>(null);
|
||||
const route = ref('');
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
const pageUrl = computed(() => {
|
||||
if (!app.value) return '';
|
||||
const ancestors = getAncestorPages(appsStore.pages, props.pageId);
|
||||
return getPageUrl(app.value.namespace, ancestors, route.value);
|
||||
});
|
||||
|
||||
const childPages = computed(() =>
|
||||
appsStore.pages.filter((page) => page.parentPageId === props.pageId),
|
||||
);
|
||||
|
||||
const childCounts = computed(() => getChildCounts(appsStore.pages));
|
||||
|
||||
const showErrorAndGoBack = async (error: unknown) => {
|
||||
toast.showError(error, i18n.baseText('apps.page.getDetails.error'));
|
||||
await router.push({
|
||||
name: APP_DETAILS,
|
||||
params: { projectId: props.projectId, appId: props.appId },
|
||||
});
|
||||
};
|
||||
|
||||
const initialize = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [result] = await Promise.all([
|
||||
appsStore.getApp(props.projectId, props.appId),
|
||||
appsStore.pages.length === 0
|
||||
? appsStore.fetchPages(props.projectId, props.appId)
|
||||
: Promise.resolve(),
|
||||
]);
|
||||
app.value = result;
|
||||
const page = appsStore.pages.find((p) => p.id === props.pageId);
|
||||
if (!page) {
|
||||
await showErrorAndGoBack(new Error(i18n.baseText('apps.page.notFound')));
|
||||
return;
|
||||
}
|
||||
route.value = page.route;
|
||||
documentTitle.set(formatRoutePath(route.value, i18n.baseText('apps.page.index')));
|
||||
} catch (error) {
|
||||
await showErrorAndGoBack(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
saving.value = true;
|
||||
try {
|
||||
await appsStore.updatePage(props.projectId, props.appId, props.pageId, route.value);
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('apps.page.save.error'));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
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 },
|
||||
});
|
||||
};
|
||||
|
||||
const openPage = async (pageId: string) => {
|
||||
await router.push({
|
||||
name: APP_PAGE_DETAILS,
|
||||
params: { projectId: props.projectId, appId: props.appId, pageId },
|
||||
});
|
||||
};
|
||||
|
||||
const onDeleteChildPage = async (pageId: string) => {
|
||||
await confirmAndDeletePage(props.projectId, props.appId, pageId);
|
||||
};
|
||||
|
||||
onMounted(initialize);
|
||||
|
||||
// Navigating between sibling pages (e.g. via the breadcrumb) reuses this same
|
||||
// route component instance — onMounted only fires once, so re-run on a pageId
|
||||
// change or the view keeps showing the previous page.
|
||||
watch(() => props.pageId, initialize);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageViewLayout data-test-id="page-view">
|
||||
<template #header>
|
||||
<div :class="$style.breadcrumbsRow">
|
||||
<AppBreadcrumbs
|
||||
v-if="app"
|
||||
:project-id="projectId"
|
||||
:app-id="appId"
|
||||
:app-name="app.name"
|
||||
:current-page-id="pageId"
|
||||
/>
|
||||
<div v-if="app" :class="$style.headerActions">
|
||||
<N8nButton :loading="saving" data-test-id="page-save" @click="onSave">
|
||||
{{ i18n.baseText('apps.page.save') }}
|
||||
</N8nButton>
|
||||
<N8nButton
|
||||
icon-only
|
||||
icon="trash-2"
|
||||
variant="subtle"
|
||||
:aria-label="i18n.baseText('generic.delete')"
|
||||
data-test-id="page-delete"
|
||||
@click="onDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div :class="$style.container">
|
||||
<div v-if="app" :class="$style.urlCard">
|
||||
<CopyInput
|
||||
:label="i18n.baseText('apps.page.url.label')"
|
||||
:value="pageUrl"
|
||||
data-test-id="page-url"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="$style.routeRow">
|
||||
<N8nText tag="label">{{ i18n.baseText('apps.page.input.route.label') }}</N8nText>
|
||||
<N8nInput
|
||||
v-model="route"
|
||||
:placeholder="i18n.baseText('apps.page.add.input.route.placeholder')"
|
||||
data-test-id="page-route-input"
|
||||
/>
|
||||
</div>
|
||||
<N8nText color="text-light" size="small">
|
||||
{{ i18n.baseText('apps.page.add.input.route.hint') }}
|
||||
</N8nText>
|
||||
|
||||
<div :class="$style.content" data-test-id="page-content-placeholder">
|
||||
<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)"
|
||||
>
|
||||
{{ i18n.baseText('apps.page.new') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
|
||||
<N8nText v-if="childPages.length === 0" color="text-light">
|
||||
{{ i18n.baseText('apps.pages.empty') }}
|
||||
</N8nText>
|
||||
|
||||
<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>
|
||||
</PageViewLayout>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.breadcrumbsRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
width: 100%;
|
||||
padding-bottom: var(--spacing--lg);
|
||||
}
|
||||
|
||||
.urlCard {
|
||||
background-color: var(--background--surface);
|
||||
border-radius: var(--radius--lg);
|
||||
padding: var(--spacing--md);
|
||||
margin-bottom: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.routeRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: var(--radius--sm);
|
||||
padding: var(--spacing--xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
margin-bottom: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.pageGrid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
79
packages/frontend/editor-ui/src/features/apps/apps.api.ts
Normal file
79
packages/frontend/editor-ui/src/features/apps/apps.api.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { makeRestApiRequest } from '@n8n/rest-api-client';
|
||||
import type { IRestApiContext } from '@n8n/rest-api-client';
|
||||
|
||||
import type { App, Page } from '@/features/apps/apps.types';
|
||||
|
||||
export const fetchAppsApi = async (context: IRestApiContext, projectId: string) => {
|
||||
return await makeRestApiRequest<App[]>(context, 'GET', `/projects/${projectId}/apps`);
|
||||
};
|
||||
|
||||
export const getAppApi = async (context: IRestApiContext, projectId: string, appId: string) => {
|
||||
return await makeRestApiRequest<App>(context, 'GET', `/projects/${projectId}/apps/${appId}`);
|
||||
};
|
||||
|
||||
export const createAppApi = async (
|
||||
context: IRestApiContext,
|
||||
projectId: string,
|
||||
name: string,
|
||||
namespace: string,
|
||||
) => {
|
||||
return await makeRestApiRequest<App>(context, 'POST', `/projects/${projectId}/apps`, {
|
||||
name,
|
||||
namespace,
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteAppApi = async (context: IRestApiContext, projectId: string, appId: string) => {
|
||||
return await makeRestApiRequest<void>(context, 'DELETE', `/projects/${projectId}/apps/${appId}`);
|
||||
};
|
||||
|
||||
export const fetchPagesApi = 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,
|
||||
route: string,
|
||||
) => {
|
||||
return await makeRestApiRequest<Page>(
|
||||
context,
|
||||
'PATCH',
|
||||
`/projects/${projectId}/apps/${appId}/pages/${pageId}`,
|
||||
{ route },
|
||||
);
|
||||
};
|
||||
|
||||
export const deletePageApi = async (
|
||||
context: IRestApiContext,
|
||||
projectId: string,
|
||||
appId: string,
|
||||
pageId: string,
|
||||
) => {
|
||||
return await makeRestApiRequest<void>(
|
||||
context,
|
||||
'DELETE',
|
||||
`/projects/${projectId}/apps/${appId}/pages/${pageId}`,
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
export const APPS_VIEW = 'apps';
|
||||
export const PROJECT_APPS = 'project-apps';
|
||||
export const APP_DETAILS = 'app-details';
|
||||
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';
|
||||
90
packages/frontend/editor-ui/src/features/apps/apps.store.ts
Normal file
90
packages/frontend/editor-ui/src/features/apps/apps.store.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import {
|
||||
createAppApi,
|
||||
createPageApi,
|
||||
deleteAppApi,
|
||||
deletePageApi,
|
||||
fetchAppsApi,
|
||||
fetchPagesApi,
|
||||
getAppApi,
|
||||
updatePageApi,
|
||||
} from '@/features/apps/apps.api';
|
||||
import { APPS_STORE } from '@/features/apps/apps.constants';
|
||||
import type { App, Page } from '@/features/apps/apps.types';
|
||||
|
||||
export const useAppsStore = defineStore(APPS_STORE, () => {
|
||||
const rootStore = useRootStore();
|
||||
|
||||
const apps = ref<App[]>([]);
|
||||
const pages = ref<Page[]>([]);
|
||||
|
||||
const fetchApps = async (projectId: string) => {
|
||||
apps.value = await fetchAppsApi(rootStore.restApiContext, projectId);
|
||||
};
|
||||
|
||||
const getApp = async (projectId: string, appId: string) => {
|
||||
return await getAppApi(rootStore.restApiContext, projectId, appId);
|
||||
};
|
||||
|
||||
const createApp = async (projectId: string, name: string, namespace: string) => {
|
||||
const app = await createAppApi(rootStore.restApiContext, projectId, name, namespace);
|
||||
apps.value = [...apps.value, app];
|
||||
return app;
|
||||
};
|
||||
|
||||
const deleteApp = async (projectId: string, appId: string) => {
|
||||
await deleteAppApi(rootStore.restApiContext, projectId, appId);
|
||||
apps.value = apps.value.filter((app) => app.id !== appId);
|
||||
};
|
||||
|
||||
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, route: string) => {
|
||||
const page = await updatePageApi(rootStore.restApiContext, projectId, appId, pageId, route);
|
||||
pages.value = pages.value.map((p) => (p.id === pageId ? page : p));
|
||||
return page;
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
return {
|
||||
apps,
|
||||
pages,
|
||||
fetchApps,
|
||||
getApp,
|
||||
createApp,
|
||||
deleteApp,
|
||||
fetchPages,
|
||||
createPage,
|
||||
updatePage,
|
||||
deletePage,
|
||||
};
|
||||
});
|
||||
19
packages/frontend/editor-ui/src/features/apps/apps.types.ts
Normal file
19
packages/frontend/editor-ui/src/features/apps/apps.types.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export interface App {
|
||||
id: string;
|
||||
name: string;
|
||||
namespace: string;
|
||||
theme: Record<string, unknown> | null;
|
||||
projectId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Page {
|
||||
id: string;
|
||||
appId: string;
|
||||
parentPageId: string | null;
|
||||
route: string;
|
||||
content: unknown[] | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
<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_DETAILS } from '@/features/apps/apps.constants';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
|
||||
type AddAppModalData = { projectId: string };
|
||||
|
||||
const props = defineProps<{
|
||||
modalName: string;
|
||||
data: AddAppModalData;
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const toast = useToast();
|
||||
const router = useRouter();
|
||||
const uiStore = useUIStore();
|
||||
const appsStore = useAppsStore();
|
||||
|
||||
const name = ref('');
|
||||
const namespace = ref('');
|
||||
const isCreating = ref(false);
|
||||
const nameInputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const namespacePreview = computed(() => `/apps/${namespace.value || '…'}`);
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!name.value || !namespace.value || isCreating.value) return;
|
||||
isCreating.value = true;
|
||||
try {
|
||||
const app = await appsStore.createApp(props.data.projectId, name.value, namespace.value);
|
||||
uiStore.closeModal(props.modalName);
|
||||
await router.push({
|
||||
name: APP_DETAILS,
|
||||
params: { projectId: props.data.projectId, appId: app.id },
|
||||
});
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('apps.add.error'));
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => nameInputRef.value?.focus(), 0);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :name="props.modalName" :center="true" width="480px" data-test-id="add-app-modal">
|
||||
<template #header>
|
||||
<h2>{{ i18n.baseText('apps.add.title') }}</h2>
|
||||
</template>
|
||||
<template #content>
|
||||
<div :class="$style.content">
|
||||
<N8nInputLabel
|
||||
:label="i18n.baseText('apps.add.input.name.label')"
|
||||
:required="true"
|
||||
input-name="appName"
|
||||
>
|
||||
<N8nInput
|
||||
ref="nameInputRef"
|
||||
v-model="name"
|
||||
:placeholder="i18n.baseText('apps.add.input.name.placeholder')"
|
||||
name="appName"
|
||||
data-test-id="apps-new-name"
|
||||
@keydown.enter="onSubmit"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
<div :class="$style.field">
|
||||
<N8nInputLabel
|
||||
:label="i18n.baseText('apps.add.input.namespace.label')"
|
||||
:required="true"
|
||||
input-name="appNamespace"
|
||||
>
|
||||
<N8nInput
|
||||
v-model="namespace"
|
||||
:placeholder="i18n.baseText('apps.add.input.namespace.placeholder')"
|
||||
name="appNamespace"
|
||||
data-test-id="apps-new-namespace"
|
||||
@keydown.enter="onSubmit"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
<N8nText color="text-light" size="small">
|
||||
{{
|
||||
i18n.baseText('apps.add.input.namespace.hint', {
|
||||
interpolate: { path: namespacePreview },
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div :class="$style.footer">
|
||||
<N8nButton
|
||||
variant="subtle"
|
||||
size="large"
|
||||
:label="i18n.baseText('generic.cancel')"
|
||||
data-test-id="apps-new-cancel"
|
||||
@click="uiStore.closeModal(props.modalName)"
|
||||
/>
|
||||
<N8nButton
|
||||
:loading="isCreating"
|
||||
:disabled="!name || !namespace"
|
||||
size="large"
|
||||
:label="i18n.baseText('apps.add.button.label')"
|
||||
data-test-id="apps-new-submit"
|
||||
@click="onSubmit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--md);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
gap: var(--spacing--2xs);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
<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 { 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();
|
||||
|
||||
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">
|
||||
{{ i18n.baseText('apps.page.add.input.route.hint') }}
|
||||
</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>
|
||||
16
packages/frontend/editor-ui/src/features/apps/modals.ts
Normal file
16
packages/frontend/editor-ui/src/features/apps/modals.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { ModalDefinition } from '@n8n/frontend-module-sdk';
|
||||
|
||||
import { ADD_APP_MODAL_KEY, ADD_PAGE_MODAL_KEY } from './apps.constants';
|
||||
|
||||
export const APPS_MODALS: ModalDefinition[] = [
|
||||
{
|
||||
key: ADD_APP_MODAL_KEY,
|
||||
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,90 @@
|
|||
import { type FrontendModuleDescription } from '@n8n/frontend-module-sdk';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
||||
import {
|
||||
APP_DETAILS,
|
||||
APP_PAGE_DETAILS,
|
||||
APPS_VIEW,
|
||||
PROJECT_APPS,
|
||||
} from '@/features/apps/apps.constants';
|
||||
import { APPS_MODALS } from '@/features/apps/modals';
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const AppsView = async () => await import('@/features/apps/AppsView.vue');
|
||||
const AppDetailsView = async () => await import('@/features/apps/AppDetailsView.vue');
|
||||
const PageView = async () => await import('@/features/apps/PageView.vue');
|
||||
|
||||
export const AppsModule: FrontendModuleDescription = {
|
||||
id: 'apps',
|
||||
name: 'Apps',
|
||||
description: 'Build pages backed by your workflows.',
|
||||
icon: 'grid-2x2',
|
||||
modals: APPS_MODALS,
|
||||
routes: [
|
||||
{
|
||||
name: APPS_VIEW,
|
||||
path: '/home/apps',
|
||||
component: AppsView,
|
||||
meta: {
|
||||
middleware: ['authenticated', 'custom'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: PROJECT_APPS,
|
||||
path: 'apps',
|
||||
component: AppsView,
|
||||
meta: {
|
||||
projectRoute: true,
|
||||
middleware: ['authenticated', 'custom'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: APP_DETAILS,
|
||||
path: 'apps/:appId',
|
||||
props: true,
|
||||
component: AppDetailsView,
|
||||
meta: {
|
||||
projectRoute: true,
|
||||
middleware: ['authenticated', 'custom'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: APP_PAGE_DETAILS,
|
||||
path: 'apps/:appId/pages/:pageId',
|
||||
props: true,
|
||||
component: PageView,
|
||||
meta: {
|
||||
projectRoute: true,
|
||||
middleware: ['authenticated', 'custom'],
|
||||
},
|
||||
},
|
||||
],
|
||||
projectTabs: {
|
||||
overview: [
|
||||
{
|
||||
label: i18n.baseText('apps.apps'),
|
||||
value: APPS_VIEW,
|
||||
to: {
|
||||
name: APPS_VIEW,
|
||||
},
|
||||
},
|
||||
],
|
||||
project: [
|
||||
{
|
||||
label: i18n.baseText('apps.apps'),
|
||||
value: PROJECT_APPS,
|
||||
dynamicRoute: {
|
||||
name: PROJECT_APPS,
|
||||
includeProjectId: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
resources: [
|
||||
{
|
||||
key: 'app',
|
||||
displayName: 'App',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import type { Page } from '@/features/apps/apps.types';
|
||||
|
||||
/** Ancestors of `pageId`, root-first, excluding the page itself. */
|
||||
export function getAncestorPages(pages: Page[], pageId: string): Page[] {
|
||||
const byId = new Map(pages.map((page) => [page.id, page]));
|
||||
const ancestors: Page[] = [];
|
||||
let parentId = byId.get(pageId)?.parentPageId ?? null;
|
||||
while (parentId) {
|
||||
const parent = byId.get(parentId);
|
||||
if (!parent) break;
|
||||
ancestors.unshift(parent);
|
||||
parentId = parent.parentPageId;
|
||||
}
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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('/');
|
||||
return `${window.location.origin}/${path}`;
|
||||
}
|
||||
|
||||
/** 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>();
|
||||
for (const page of pages) {
|
||||
counts.set(page.parentPageId, (counts.get(page.parentPageId) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** "foo" -> "foo"; "" -> "(index)" — no leading slash, for breadcrumb items. */
|
||||
export function formatRouteSegment(route: string, indexLabel: string): string {
|
||||
return route || `(${indexLabel})`;
|
||||
}
|
||||
|
||||
/** "foo" -> "/foo"; "" -> "/ (index)" — with leading slash, for titles and labels. */
|
||||
export function formatRoutePath(route: string, indexLabel: string): string {
|
||||
return route ? `/${route}` : `/ (${indexLabel})`;
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { useI18n } from '@n8n/i18n';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
|
||||
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. */
|
||||
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'),
|
||||
i18n.baseText('apps.delete.confirm.message', { interpolate: { name: escapeHtml(app.name) } }),
|
||||
);
|
||||
if (!confirmed) return false;
|
||||
|
||||
try {
|
||||
await appsStore.deleteApp(projectId, app.id);
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('apps.delete.error'));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** @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 };
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user