From 19a830363cdca97e75e9e89bdded62307cef3f8d Mon Sep 17 00:00:00 2001 From: Yuliia Pominchuk Date: Wed, 5 Aug 2026 10:57:04 +0200 Subject: [PATCH] fix styles --- .../__tests__/catalog-run.service.test.ts | 1 + .../__tests__/catalog.service.test.ts | 10 +- .../workflow-input-schema.service.test.ts | 9 +- packages/cli/src/workflows/catalog.service.ts | 8 + .../workflow-input-schema.service.ts | 13 +- .../frontend/@n8n/i18n/src/locales/en.json | 11 +- .../src/features/catalog/CatalogView.vue | 319 ++++++++++++++---- .../features/catalog/catalog.store.test.ts | 9 +- .../src/features/catalog/catalog.types.ts | 4 + 9 files changed, 307 insertions(+), 77 deletions(-) diff --git a/packages/cli/src/workflows/__tests__/catalog-run.service.test.ts b/packages/cli/src/workflows/__tests__/catalog-run.service.test.ts index f51b1867dd2..cf325f95193 100644 --- a/packages/cli/src/workflows/__tests__/catalog-run.service.test.ts +++ b/packages/cli/src/workflows/__tests__/catalog-run.service.test.ts @@ -53,6 +53,7 @@ describe('CatalogRunService', () => { executions.runWorkflow.mockResolvedValue('execution-1'); schemas.describe.mockResolvedValue({ eligible: true, + trigger: 'execute-workflow-trigger', fields: [{ name: 'customer', type: 'string' }], }); diff --git a/packages/cli/src/workflows/__tests__/catalog.service.test.ts b/packages/cli/src/workflows/__tests__/catalog.service.test.ts index 29affddc4dc..6ddfe52049d 100644 --- a/packages/cli/src/workflows/__tests__/catalog.service.test.ts +++ b/packages/cli/src/workflows/__tests__/catalog.service.test.ts @@ -33,7 +33,11 @@ describe('CatalogService', () => { schemas = mock(); executions = mock(); - schemas.describe.mockResolvedValue({ eligible: true, fields: [] }); + schemas.describe.mockResolvedValue({ + eligible: true, + trigger: 'manual-trigger', + fields: [], + }); executions.findRangeWithCount.mockResolvedValue({ results: [], count: 0, estimated: false }); service = new CatalogService(logger, finder, schemas, executions); @@ -51,6 +55,7 @@ describe('CatalogService', () => { finder.findAllWorkflowsForUser.mockResolvedValue([entity('a', 'Weekly report')]); schemas.describe.mockResolvedValue({ eligible: true, + trigger: 'execute-workflow-trigger', fields: [{ name: 'customer', type: 'string' }], }); @@ -61,6 +66,7 @@ describe('CatalogService', () => { id: 'a', name: 'Weekly report', description: null, + trigger: 'execute-workflow-trigger', fields: [{ name: 'customer', type: 'string' }], }, ]); @@ -69,7 +75,7 @@ describe('CatalogService', () => { it('should leave out workflows with no readable contract', async () => { finder.findAllWorkflowsForUser.mockResolvedValue([entity('a'), entity('b')]); schemas.describe - .mockResolvedValueOnce({ eligible: true, fields: [] }) + .mockResolvedValueOnce({ eligible: true, trigger: 'manual-trigger', fields: [] }) .mockResolvedValueOnce({ eligible: false, reason: 'own-schedule' }); const result = await service.list(user); diff --git a/packages/cli/src/workflows/__tests__/workflow-input-schema.service.test.ts b/packages/cli/src/workflows/__tests__/workflow-input-schema.service.test.ts index b82e656e8ae..f4d8ec178df 100644 --- a/packages/cli/src/workflows/__tests__/workflow-input-schema.service.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow-input-schema.service.test.ts @@ -80,13 +80,13 @@ describe('WorkflowInputSchemaService', () => { ]), ); - expect(result).toEqual({ eligible: true, fields: [] }); + expect(result).toEqual({ eligible: true, trigger: 'manual-trigger', fields: [] }); }); it('should accept a manual trigger as taking no input', async () => { const result = await service.describe(workflow([node(MANUAL_TRIGGER_NODE_TYPE)])); - expect(result).toEqual({ eligible: true, fields: [] }); + expect(result).toEqual({ eligible: true, trigger: 'manual-trigger', fields: [] }); }); it('should reject a trigger that accepts arbitrary data', async () => { @@ -131,6 +131,7 @@ describe('WorkflowInputSchemaService', () => { expect(result).toEqual({ eligible: true, + trigger: 'execute-workflow-trigger', fields: [ { name: 'customer', type: 'string' }, { name: 'amount', type: 'number' }, @@ -150,6 +151,7 @@ describe('WorkflowInputSchemaService', () => { expect(result).toEqual({ eligible: true, + trigger: 'execute-workflow-trigger', fields: [ { name: 'customer', type: 'string' }, { name: 'amount', type: 'number' }, @@ -162,7 +164,7 @@ describe('WorkflowInputSchemaService', () => { workflow([node(EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, { inputSource: 'workflowInputs' })]), ); - expect(result).toEqual({ eligible: true, fields: [] }); + expect(result).toEqual({ eligible: true, trigger: 'execute-workflow-trigger', fields: [] }); }); it('should prefer the declared contract over a manual trigger', async () => { @@ -178,6 +180,7 @@ describe('WorkflowInputSchemaService', () => { expect(result).toEqual({ eligible: true, + trigger: 'execute-workflow-trigger', fields: [{ name: 'customer', type: 'string' }], }); }); diff --git a/packages/cli/src/workflows/catalog.service.ts b/packages/cli/src/workflows/catalog.service.ts index 45ae5f12f9f..89e4c1c7877 100644 --- a/packages/cli/src/workflows/catalog.service.ts +++ b/packages/cli/src/workflows/catalog.service.ts @@ -6,6 +6,7 @@ import type { ExecutionStatus, FieldValueOption } from 'n8n-workflow'; import { ExecutionService } from '@/executions/execution.service'; import { CATALOG_RUN_USER_KEY } from '@/workflows/catalog-run.service'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; +import type { StartTrigger } from '@/workflows/workflow-input-schema.service'; import { WorkflowInputSchemaService } from '@/workflows/workflow-input-schema.service'; /** @@ -19,6 +20,12 @@ export type CatalogEntry = { id: string; name: string; description: string | null; + /** + * How the workflow is entered. Surfaced rather than kept internal: a + * manual-trigger workflow takes no input at all, which is otherwise + * indistinguishable from one whose builder simply declared no fields yet. + */ + trigger: StartTrigger; fields: FieldValueOption[]; }; @@ -93,6 +100,7 @@ export class CatalogService { id: workflow.id, name: workflow.name, description: workflow.description, + trigger: schema.trigger, fields: schema.fields, }; }), diff --git a/packages/cli/src/workflows/workflow-input-schema.service.ts b/packages/cli/src/workflows/workflow-input-schema.service.ts index cb93efbc058..7cf4a06a51a 100644 --- a/packages/cli/src/workflows/workflow-input-schema.service.ts +++ b/packages/cli/src/workflows/workflow-input-schema.service.ts @@ -25,8 +25,15 @@ export type IneligibleReason = /** Accepts arbitrary data, so there is no contract to build a form from. */ | 'passthrough-input'; +/** The trigger a caller enters the workflow through. */ +export type StartTrigger = + /** Declares named, typed inputs — the run form is built from them. */ + | 'execute-workflow-trigger' + /** Compat path for workflows written before the contract existed: takes no input. */ + | 'manual-trigger'; + export type WorkflowInputSchema = - | { eligible: true; fields: FieldValueOption[] } + | { eligible: true; trigger: StartTrigger; fields: FieldValueOption[] } | { eligible: false; reason: IneligibleReason }; /** @@ -56,7 +63,7 @@ export class WorkflowInputSchemaService { // Execute Workflow Trigger became the way to declare one. const hasManualTrigger = enabled.some((node) => node.type === MANUAL_TRIGGER_NODE_TYPE); return hasManualTrigger - ? { eligible: true, fields: [] } + ? { eligible: true, trigger: 'manual-trigger', fields: [] } : { eligible: false, reason: 'no-start-node' }; } @@ -66,7 +73,7 @@ export class WorkflowInputSchemaService { return { eligible: false, reason: 'passthrough-input' }; } - return { eligible: true, fields }; + return { eligible: true, trigger: 'execute-workflow-trigger', fields }; } /** diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 5fae2595e18..78ee2c6d846 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -5007,14 +5007,23 @@ "contextual.feature.unavailable.title.cloud": "Available on the Pro Plan", "catalog.heading": "Run a workflow", "catalog.subheading": "Workflows you can run without opening the editor", - "catalog.empty": "No workflows have been shared with you to run yet", + "catalog.empty": "Nothing to run yet", + "catalog.empty.description": "Workflows shared with you to run will show up here", "catalog.truncated": "Showing the first workflows only. Ask your admin if one you expect is missing.", "catalog.load.error": "Couldn't load the catalog", + "catalog.card.noDescription": "No description", + "catalog.trigger.declared": "Takes input", + "catalog.trigger.manual": "Runs as is", + "catalog.inputs.none": "No inputs", + "catalog.inputs.count": "{count} input | {count} inputs", + "catalog.form.description": "Fill in what this workflow needs, then run it.", "catalog.run": "Run", "catalog.run.started": "Workflow started", + "catalog.run.started.message": "{name} is running. It'll show up under your runs.", "catalog.run.error": "Couldn't start the workflow", "catalog.runs.heading": "Your runs", "catalog.runs.empty": "You haven't run anything yet", + "catalog.runs.unknownWorkflow": "Unknown workflow", "dataTable.dataTables": "Data tables", "dataTables.noResults": "No data tables found", "dataTable.empty.heading": "Create your first data table", diff --git a/packages/frontend/editor-ui/src/features/catalog/CatalogView.vue b/packages/frontend/editor-ui/src/features/catalog/CatalogView.vue index d3c675034ee..ec5e7d1ef34 100644 --- a/packages/frontend/editor-ui/src/features/catalog/CatalogView.vue +++ b/packages/frontend/editor-ui/src/features/catalog/CatalogView.vue @@ -4,19 +4,26 @@ import { N8nBadge, N8nButton, N8nCard, + N8nDialog, + N8nDialogFooter, + N8nEmptyState, N8nHeading, + N8nIcon, N8nInput, N8nInputLabel, + N8nLoading, N8nNotice, - N8nSpinner, N8nText, + type BadgeTheme, } from '@n8n/design-system'; import { useI18n } from '@n8n/i18n'; +import type { ExecutionStatus } from 'n8n-workflow'; import { computed, onMounted, ref } from 'vue'; import { useDocumentTitle } from '@/app/composables/useDocumentTitle'; +import { convertToDisplayDate } from '@/app/utils/formatters/dateFormatter'; import { useCatalogStore } from '@/features/catalog/catalog.store'; -import type { CatalogEntry } from '@/features/catalog/catalog.types'; +import type { CatalogEntry, CatalogRun } from '@/features/catalog/catalog.types'; const i18n = useI18n(); const toast = useToast(); @@ -24,9 +31,11 @@ const documentTitle = useDocumentTitle(); const catalogStore = useCatalogStore(); const loading = ref(true); +/** The workflow whose input form is open. Null while nothing is being filled in. */ const selected = ref(null); const inputs = ref>({}); -const running = ref(false); +/** Which card is mid-run, so only that card's button spins. */ +const runningId = ref(null); const runs = computed(() => catalogStore.runs); @@ -41,95 +50,210 @@ onMounted(async () => { } }); -const select = (entry: CatalogEntry) => { +const triggerLabel = (entry: CatalogEntry) => + entry.trigger === 'manual-trigger' + ? i18n.baseText('catalog.trigger.manual') + : i18n.baseText('catalog.trigger.declared'); + +const inputsLabel = (entry: CatalogEntry) => + entry.fields.length === 0 + ? i18n.baseText('catalog.inputs.none') + : i18n.baseText('catalog.inputs.count', { + adjustToNumber: entry.fields.length, + interpolate: { count: entry.fields.length }, + }); + +const RUN_STATUS_THEMES: Partial> = { + success: 'success', + error: 'danger', + crashed: 'danger', + canceled: 'default', + running: 'warning', + waiting: 'warning', + new: 'warning', +}; + +const runTheme = (status: ExecutionStatus): BadgeTheme => RUN_STATUS_THEMES[status] ?? 'default'; + +const runTime = (run: CatalogRun) => { + if (!run.startedAt) return ''; + const { date, time } = convertToDisplayDate(run.startedAt); + return `${date} ${time}`; +}; + +const start = async (entry: CatalogEntry, values: Record) => { + runningId.value = entry.id; + try { + await catalogStore.run(entry.id, values); + selected.value = null; + toast.showMessage({ + title: i18n.baseText('catalog.run.started'), + message: i18n.baseText('catalog.run.started.message', { interpolate: { name: entry.name } }), + type: 'success', + }); + } catch (error) { + toast.showError(error, i18n.baseText('catalog.run.error')); + } finally { + runningId.value = null; + } +}; + +/** + * A workflow that declares no fields has nothing to ask for, so the button runs + * it outright rather than opening an empty form. + */ +const onRunClick = async (entry: CatalogEntry) => { + if (entry.fields.length === 0) { + await start(entry, {}); + return; + } + selected.value = entry; // Start each form clean rather than carrying the previous workflow's values. inputs.value = Object.fromEntries(entry.fields.map((field) => [field.name, ''])); }; -const run = async () => { - if (!selected.value) return; +const closeDialog = () => { + if (runningId.value) return; + selected.value = null; +}; - running.value = true; - try { - await catalogStore.run(selected.value.id, { ...inputs.value }); - toast.showMessage({ - title: i18n.baseText('catalog.run.started'), - type: 'success', - }); - selected.value = null; - } catch (error) { - toast.showError(error, i18n.baseText('catalog.run.error')); - } finally { - running.value = false; - } +const submit = async () => { + if (!selected.value) return; + await start(selected.value, { ...inputs.value }); }; + + + {{ i18n.baseText('generic.cancel') }} + + + {{ i18n.baseText('catalog.run') }} + + + + @@ -137,30 +261,91 @@ const run = async () => { .page { display: flex; flex-direction: column; - gap: var(--spacing-s); - padding: var(--spacing-l); + gap: var(--spacing--md); + padding: var(--spacing--lg); + max-width: 1200px; + margin: 0 auto; + width: 100%; } -.list { +.header { display: flex; flex-direction: column; - gap: var(--spacing-2xs); + gap: var(--spacing--4xs); +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: var(--spacing--xs); } .card { - cursor: pointer; + --card--padding: var(--spacing--sm); + + align-items: stretch; + gap: var(--spacing--2xs); } -.runner { +.name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.description { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + margin-bottom: var(--spacing--2xs); +} + +.meta { + display: flex; + flex-wrap: wrap; + gap: var(--spacing--4xs); +} + +.badgeIcon { + margin-right: var(--spacing--5xs); +} + +.runs { display: flex; flex-direction: column; - gap: var(--spacing-2xs); - padding: var(--spacing-s); - border: var(--border-base); - border-radius: var(--border-radius-large); + gap: var(--spacing--2xs); + margin-top: var(--spacing--sm); } -.field { - display: block; +.runList { + display: flex; + flex-direction: column; + list-style: none; + padding: 0; + margin: 0; + border: var(--border); + border-radius: var(--radius--lg); + overflow: hidden; +} + +.run { + display: grid; + grid-template-columns: 1fr auto auto; + align-items: center; + gap: var(--spacing--2xs); + padding: var(--spacing--2xs) var(--spacing--sm); + background-color: var(--background--surface); + + & + & { + border-top: var(--border); + } +} + +.form { + display: flex; + flex-direction: column; + gap: var(--spacing--xs); + margin-top: var(--spacing--xs); } diff --git a/packages/frontend/editor-ui/src/features/catalog/catalog.store.test.ts b/packages/frontend/editor-ui/src/features/catalog/catalog.store.test.ts index 0b3ece8f5e9..3e8bb8370b9 100644 --- a/packages/frontend/editor-ui/src/features/catalog/catalog.store.test.ts +++ b/packages/frontend/editor-ui/src/features/catalog/catalog.store.test.ts @@ -2,10 +2,17 @@ import { createPinia, setActivePinia } from 'pinia'; import * as api from '@/features/catalog/catalog.api'; import { useCatalogStore } from '@/features/catalog/catalog.store'; +import type { CatalogEntry } from '@/features/catalog/catalog.types'; vi.mock('@/features/catalog/catalog.api'); -const entry = (id: string) => ({ id, name: `Workflow ${id}`, description: null, fields: [] }); +const entry = (id: string): CatalogEntry => ({ + id, + name: `Workflow ${id}`, + description: null, + trigger: 'manual-trigger', + fields: [], +}); describe('catalog.store', () => { beforeEach(() => { diff --git a/packages/frontend/editor-ui/src/features/catalog/catalog.types.ts b/packages/frontend/editor-ui/src/features/catalog/catalog.types.ts index c30a52890ab..51e4cc47a95 100644 --- a/packages/frontend/editor-ui/src/features/catalog/catalog.types.ts +++ b/packages/frontend/editor-ui/src/features/catalog/catalog.types.ts @@ -6,10 +6,14 @@ export type CatalogField = { type: string; }; +/** How the workflow is entered, which decides whether it takes input at all. */ +export type CatalogTrigger = 'execute-workflow-trigger' | 'manual-trigger'; + export type CatalogEntry = { id: string; name: string; description: string | null; + trigger: CatalogTrigger; fields: CatalogField[]; };