fix styles

This commit is contained in:
Yuliia Pominchuk 2026-08-05 10:57:04 +02:00
parent d400001ec6
commit 19a830363c
No known key found for this signature in database
9 changed files with 307 additions and 77 deletions

View File

@ -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' }],
});

View File

@ -33,7 +33,11 @@ describe('CatalogService', () => {
schemas = mock<WorkflowInputSchemaService>();
executions = mock<ExecutionService>();
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);

View File

@ -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' }],
});
});

View File

@ -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,
};
}),

View File

@ -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 };
}
/**

View File

@ -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",

View File

@ -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<CatalogEntry | null>(null);
const inputs = ref<Record<string, string>>({});
const running = ref(false);
/** Which card is mid-run, so only that card's button spins. */
const runningId = ref<string | null>(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<Record<ExecutionStatus, BadgeTheme>> = {
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<string, unknown>) => {
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 });
};
</script>
<template>
<div :class="$style.page" data-test-id="catalog-view">
<N8nHeading tag="h1" size="xlarge">{{ i18n.baseText('catalog.heading') }}</N8nHeading>
<N8nText color="text-light">{{ i18n.baseText('catalog.subheading') }}</N8nText>
<header :class="$style.header">
<N8nHeading tag="h1" size="xlarge" bold>{{ i18n.baseText('catalog.heading') }}</N8nHeading>
<N8nText color="text-light">{{ i18n.baseText('catalog.subheading') }}</N8nText>
</header>
<N8nSpinner v-if="loading" />
<N8nLoading v-if="loading" :rows="3" :class="$style.grid" />
<template v-else>
<N8nNotice v-if="catalogStore.truncated" theme="warning">
{{ i18n.baseText('catalog.truncated') }}
</N8nNotice>
<N8nText v-if="catalogStore.isEmpty" color="text-light" data-test-id="catalog-empty">
{{ i18n.baseText('catalog.empty') }}
</N8nText>
<N8nEmptyState
v-if="catalogStore.isEmpty"
data-test-id="catalog-empty"
:icon="{ type: 'icon', value: 'play' }"
:heading="i18n.baseText('catalog.empty')"
:description="i18n.baseText('catalog.empty.description')"
/>
<div v-else :class="$style.list">
<div v-else :class="$style.grid">
<N8nCard
v-for="entry in catalogStore.workflows"
:key="entry.id"
:class="$style.card"
data-test-id="catalog-workflow"
@click="select(entry)"
>
<N8nText bold>{{ entry.name }}</N8nText>
<N8nText v-if="entry.description" size="small" color="text-light">
{{ entry.description }}
<template #header>
<N8nText bold :class="$style.name" :title="entry.name">{{ entry.name }}</N8nText>
</template>
<N8nText
size="small"
:color="entry.description ? 'text-base' : 'text-light'"
:class="$style.description"
>
{{ entry.description || i18n.baseText('catalog.card.noDescription') }}
</N8nText>
<template #footer>
<div :class="$style.meta">
<N8nBadge theme="tertiary">
<N8nIcon
:icon="entry.trigger === 'manual-trigger' ? 'mouse-pointer' : 'workflow'"
size="xsmall"
:class="$style.badgeIcon"
/>
{{ triggerLabel(entry) }}
</N8nBadge>
<N8nBadge theme="default">{{ inputsLabel(entry) }}</N8nBadge>
</div>
<N8nButton
size="small"
icon="play"
:label="i18n.baseText('catalog.run')"
:loading="runningId === entry.id"
:disabled="runningId !== null && runningId !== entry.id"
data-test-id="catalog-run-button"
@click="onRunClick(entry)"
/>
</template>
</N8nCard>
</div>
<div v-if="selected" :class="$style.runner" data-test-id="catalog-runner">
<N8nHeading tag="h2" size="medium">{{ selected.name }}</N8nHeading>
<section :class="$style.runs">
<N8nHeading tag="h2" size="medium" bold>
{{ i18n.baseText('catalog.runs.heading') }}
</N8nHeading>
<N8nText v-if="runs.length === 0" color="text-light">
{{ i18n.baseText('catalog.runs.empty') }}
</N8nText>
<ul v-else :class="$style.runList">
<li v-for="item in runs" :key="item.id" :class="$style.run" data-test-id="catalog-run">
<N8nText size="small" :class="$style.name">
{{ item.workflowName ?? i18n.baseText('catalog.runs.unknownWorkflow') }}
</N8nText>
<N8nText size="small" color="text-light">{{ runTime(item) }}</N8nText>
<N8nBadge :theme="runTheme(item.status)">{{ item.status }}</N8nBadge>
</li>
</ul>
</section>
</template>
<N8nDialog
v-if="selected"
:open="true"
size="medium"
:header="selected.name"
:description="i18n.baseText('catalog.form.description')"
@update:open="closeDialog"
>
<form :class="$style.form" data-test-id="catalog-runner" @submit.prevent="submit">
<N8nInputLabel
v-for="field in selected.fields"
:key="field.name"
:input-name="`catalog-input-${field.name}`"
:label="field.name"
:class="$style.field"
>
<N8nInput v-model="inputs[field.name]" :name="field.name" />
<N8nInput
:id="`catalog-input-${field.name}`"
v-model="inputs[field.name]"
:name="field.name"
:placeholder="field.type"
/>
</N8nInputLabel>
<N8nButton
:label="i18n.baseText('catalog.run')"
:loading="running"
data-test-id="catalog-run-button"
@click="run"
/>
</div>
<N8nHeading tag="h2" size="medium">{{ i18n.baseText('catalog.runs.heading') }}</N8nHeading>
<N8nText v-if="runs.length === 0" color="text-light">
{{ i18n.baseText('catalog.runs.empty') }}
</N8nText>
<div v-else :class="$style.list">
<N8nCard v-for="item in runs" :key="item.id" data-test-id="catalog-run">
<N8nText>{{ item.workflowName }}</N8nText>
<N8nBadge>{{ item.status }}</N8nBadge>
</N8nCard>
</div>
</template>
<N8nDialogFooter>
<N8nButton
type="button"
variant="outline"
:disabled="runningId !== null"
@click="closeDialog"
>
{{ i18n.baseText('generic.cancel') }}
</N8nButton>
<N8nButton
type="submit"
:loading="runningId !== null"
data-test-id="catalog-submit-button"
>
{{ i18n.baseText('catalog.run') }}
</N8nButton>
</N8nDialogFooter>
</form>
</N8nDialog>
</div>
</template>
@ -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);
}
</style>

View File

@ -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(() => {

View File

@ -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[];
};