feat(editor): Add promotion and apply preview pages with a resource tree and change cards (no-changelog) (#41333)

This commit is contained in:
Kai 2026-10-21 09:42:11 +00:00 committed by GitHub
parent c69581aedf
commit f830c2b113
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1995 additions and 34 deletions

View File

@ -9885,6 +9885,75 @@
"promotions.bindings.destinationDenied": "You can't create this item in the destination project. Ask a project admin for access.",
"promotions.bindings.editorBusy": "Another editor is open. Close it and try again.",
"promotions.bindings.editorClosed": "The editor closed. Open it again to continue.",
"promotions.preview.title.promote": "Changes to promote",
"promotions.preview.title.apply": "Changes to apply",
"promotions.preview.back": "Back to {project}",
"promotions.preview.tab.changes": "Changes",
"promotions.preview.branch.promote": "Base branch: {branch}",
"promotions.preview.branch.apply": "Branch: {branch}",
"promotions.preview.lastRefreshed": "Last refreshed {time}",
"promotions.preview.refresh": "Refresh",
"promotions.preview.refreshFailed": "Couldn't refresh the preview. It shows the changes from the last refresh. Refresh to try again.",
"promotions.preview.dropped": "{count} workflow left the preview and is no longer selected. | {count} workflows left the preview and are no longer selected.",
"promotions.preview.state.empty.promote.title": "Nothing to promote",
"promotions.preview.state.empty.apply.title": "Nothing to apply",
"promotions.preview.state.empty.description": "Refresh if the project or the branch has changed.",
"promotions.preview.state.disconnected.promote.title": "Promote isn't set up",
"promotions.preview.state.disconnected.apply.title": "Apply isn't set up",
"promotions.preview.state.disconnected.admin": "Set it up in the promotion settings.",
"promotions.preview.state.disconnected.member": "Ask an admin to set it up.",
"promotions.preview.state.disconnected.action": "Open promotion settings",
"promotions.preview.state.connectionFailed": "Couldn't check the instance connection",
"promotions.preview.state.error": "Couldn't load the changes",
"promotions.preview.state.retry": "Try again",
"promotions.preview.change.added": "New",
"promotions.preview.change.archived": "Archived",
"promotions.preview.change.deleted": "Deleted",
"promotions.preview.change.moved": "Moved",
"promotions.preview.change.renamed": "Renamed",
"promotions.preview.change.modified": "Modified",
"promotions.preview.tree.selectAll": "Select all workflows",
"promotions.preview.tree.select": "Select {name}",
"promotions.preview.tree.selectedOfTotal": "{selected} of {total} change | {selected} of {total} changes",
"promotions.preview.tree.section.workflows": "Workflows",
"promotions.preview.tree.group.credential": "Credentials",
"promotions.preview.tree.group.dataTable": "Data tables",
"promotions.preview.tree.group.variable": "Variables",
"promotions.preview.tree.group.tag": "Tags",
"promotions.preview.tree.group.subWorkflow": "Sub-workflows",
"promotions.preview.card.noChanges": "No changes. The operation keeps this resource as it is.",
"promotions.preview.card.loadFailed": "Couldn't load the changes to this resource.",
"promotions.preview.card.stale": "This resource changed after the preview loaded. Refresh the preview to review the current changes.",
"promotions.preview.card.missing": "This resource no longer exists. Refresh the preview.",
"promotions.preview.card.unreadable": "The file in the Git branch has an unexpected format. Ask an admin to check it, then refresh the preview.",
"promotions.preview.card.included": "Included",
"promotions.preview.card.notIncluded": "Not included",
"promotions.preview.card.usedBy": "Used by {names}",
"promotions.preview.card.calledBy": "Called by {names}",
"promotions.preview.card.globalScope": "Global",
"promotions.preview.field.before": "Before",
"promotions.preview.field.after": "After",
"promotions.preview.field.empty": "None",
"promotions.preview.field.name": "Name",
"promotions.preview.field.description": "Description",
"promotions.preview.field.icon": "Icon",
"promotions.preview.field.location": "Location",
"promotions.preview.field.type": "Type",
"promotions.preview.field.data": "Exported expressions",
"promotions.preview.field.value": "Value",
"promotions.preview.variable.valueHidden": "You don't have permission to see variable values.",
"promotions.preview.folder.moveConflict": "Apply can't move a folder that already exists on this instance. Move the folder on this instance to match the branch, then refresh the preview.",
"promotions.preview.dataTable.columns": "Columns",
"promotions.preview.dataTable.addColumn": "Adds column {column} ({type})",
"promotions.preview.dataTable.removeColumn": "Removes column {column}",
"promotions.preview.dataTable.removeColumnWithValues": "Removes column {column} and its values",
"promotions.preview.dataTable.changeColumnType": "Changes the type of column {column} from {from} to {to}",
"promotions.preview.dataTable.renameColumn": "Renames column {from} to {to}",
"promotions.preview.dataTable.reorderColumns": "Changes the column order",
"promotions.preview.dataTable.renameTable": "Renames the table from {from} to {to}",
"promotions.preview.dataTable.rowsNotCopied": "The operation copies the table structure only. It doesn't copy rows.",
"promotions.preview.side.notOnBranch": "Not on the branch",
"promotions.preview.side.notOnInstance": "Not on this instance",
"credentialEdit.typeUnavailable": "This credential type isn't available. Install it and try again.",
"credentialEdit.savedLoadFailed": "The credential was saved, but its details couldn't be loaded. Retry to continue editing.",
"agents.channels.teams.setup.install.checking": "Checking the credential can reach Microsoft…",

View File

@ -1,10 +1,12 @@
import type { Scope } from '@n8n/permissions';
import { useRBACStore } from '@n8n/stores/rbac.store';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { createTestingPinia } from '@pinia/testing';
import { createMemoryHistory, createRouter, type RouteRecordRaw } from 'vue-router';
import { VIEWS } from '@/app/constants';
import { PromotionsModule } from '../module.descriptor';
import { PROMOTIONS_SETTINGS_VIEW } from '../promotions.constants';
import { PROMOTION_PREVIEW_VIEWS, PROMOTIONS_SETTINGS_VIEW } from '../promotions.constants';
const stub = { render: () => null };
@ -16,22 +18,30 @@ const moduleRoutes = (PromotionsModule.routes ?? []).map(
children: route.children?.map((child) => ({ ...child, component: stub })),
}) as RouteRecordRaw,
);
const settingsRoutes = moduleRoutes.filter((route) => !route.meta?.projectRoute);
const projectRoutes = moduleRoutes.filter((route) => route.meta?.projectRoute);
const createTestRouter = () =>
createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/home', name: VIEWS.HOMEPAGE, component: stub },
{ path: '/settings', component: stub, children: moduleRoutes },
{ path: '/settings', component: stub, children: settingsRoutes },
{ path: '/projects/:projectId', component: stub, children: projectRoutes },
],
});
const setup = ({ flag = 'true' } = {}) => {
const setup = ({
flag = 'true',
scopes = ['gitConnection:read', 'gitConnection:push', 'gitConnection:pull'] as Scope[],
} = {}) => {
createTestingPinia({ stubActions: false });
const settingsStore = useSettingsStore();
settingsStore.settings.activeModules = ['promotions'];
settingsStore.settings.envFeatureFlags = { N8N_ENV_FEAT_PROMOTIONS: flag };
// Project scopes stay empty, as in the app, where the frontend does not load them.
useRBACStore().setGlobalScopes(scopes);
};
describe('PromotionsModule settings route', () => {
@ -53,3 +63,38 @@ describe('PromotionsModule settings route', () => {
expect(router.currentRoute.value.name).toBe(VIEWS.HOMEPAGE);
});
});
describe('PromotionsModule preview routes', () => {
it('opens the apply preview of a project with the direction from the route', async () => {
setup();
const router = createTestRouter();
await router.push('/projects/p1/promotions/apply');
const route = router.currentRoute.value;
expect(route.name).toBe(PROMOTION_PREVIEW_VIEWS.apply);
const props = route.matched.at(-1)?.props.default;
expect(typeof props === 'function' ? props(route) : props).toEqual({
projectId: 'p1',
direction: 'apply',
});
});
it('leads users who cannot apply changes home', async () => {
setup({ scopes: ['gitConnection:read', 'gitConnection:push'] });
const router = createTestRouter();
await router.push('/projects/p1/promotions/apply');
expect(router.currentRoute.value.name).toBe(VIEWS.HOMEPAGE);
});
it('redirects to home when the feature flag is off', async () => {
setup({ flag: 'false' });
const router = createTestRouter();
await router.push('/projects/p1/promotions/promote');
expect(router.currentRoute.value.name).toBe(VIEWS.HOMEPAGE);
});
});

View File

@ -0,0 +1,138 @@
<script setup lang="ts">
import type { PromotionDataTableChange } from '@n8n/api-types';
import { N8nIcon, N8nText } from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { I18nT } from 'vue-i18n';
import type { DataTableDescription } from '../../promotionPreview.content';
const props = defineProps<{ description: DataTableDescription }>();
const i18n = useI18n();
type ChangeText = { keypath: BaseTextKey; names: Record<string, string> };
// The data table UI shows the stored type 'date' as 'datetime'.
const typeLabel = (type: string) => (type === 'date' ? 'datetime' : type);
const removesData = (change: PromotionDataTableChange) =>
props.description.warnsAboutDataLoss && change.destructive;
// The names fill slots, so they can stand out from the sentence.
function changeText(change: PromotionDataTableChange): ChangeText {
switch (change.kind) {
case 'add-column':
return {
keypath: 'promotions.preview.dataTable.addColumn',
names: { column: change.column, type: typeLabel(change.type) },
};
case 'remove-column':
return {
keypath: removesData(change)
? 'promotions.preview.dataTable.removeColumnWithValues'
: 'promotions.preview.dataTable.removeColumn',
names: { column: change.column },
};
case 'change-column-type':
return {
keypath: 'promotions.preview.dataTable.changeColumnType',
names: { column: change.column, from: typeLabel(change.from), to: typeLabel(change.to) },
};
case 'rename-column':
return {
keypath: 'promotions.preview.dataTable.renameColumn',
names: { from: change.from, to: change.to },
};
case 'reorder-columns':
return { keypath: 'promotions.preview.dataTable.reorderColumns', names: {} };
case 'rename-table':
return {
keypath: 'promotions.preview.dataTable.renameTable',
names: { from: change.from, to: change.to },
};
}
}
</script>
<template>
<div :class="$style.details" data-test-id="promotion-preview-data-table">
<ul v-if="description.changes.length > 0" :class="$style.list">
<li
v-for="(change, index) in description.changes"
:key="index"
:class="$style.change"
data-test-id="promotion-preview-data-table-change"
>
<N8nIcon
:icon="removesData(change) ? 'triangle-alert' : 'dot'"
size="small"
:class="removesData(change) ? $style.destructive : $style.marker"
/>
<N8nText step="xs" :class="$style.wrap">
<I18nT :keypath="changeText(change).keypath" scope="global">
<template v-for="(name, slot) in changeText(change).names" :key="slot" #[slot]>
<span :class="$style.name">{{ name }}</span>
</template>
</I18nT>
</N8nText>
</li>
</ul>
<template v-if="description.addedColumns.length > 0">
<N8nText step="2xs" color="text-light">
{{ i18n.baseText('promotions.preview.dataTable.columns') }}
</N8nText>
<ul :class="$style.list">
<li v-for="column in description.addedColumns" :key="column.name" :class="$style.change">
<N8nText step="xs" :class="$style.wrap">{{ column.name }}</N8nText>
<N8nText step="2xs" color="text-light">{{ typeLabel(column.type) }}</N8nText>
</li>
</ul>
<!-- Only a new table raises the question. A changed table keeps its rows. -->
<N8nText step="2xs" color="text-light">
{{ i18n.baseText('promotions.preview.dataTable.rowsNotCopied') }}
</N8nText>
</template>
</div>
</template>
<style module lang="scss">
.details {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
}
.list {
display: flex;
flex-direction: column;
gap: var(--spacing--4xs);
margin: 0;
padding: 0;
list-style: none;
}
.change {
display: flex;
align-items: center;
gap: var(--spacing--3xs);
}
.name {
font-weight: var(--font-weight--bold);
}
/* Column names can be long words without spaces. */
.wrap {
min-width: 0;
overflow-wrap: anywhere;
}
.marker {
color: var(--icon-color);
}
.destructive {
color: var(--icon-color--warning);
}
</style>

View File

@ -0,0 +1,244 @@
import type {
PromotionDirection,
PromotionResource,
PromotionResourceDetail,
} from '@n8n/api-types';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import { billingPreview, billingResources, resource } from '../../__tests__/preview.fixtures';
import { buildSelectionGraph } from '../../promotionPreview.selection';
import PromotionChangeCard from './PromotionChangeCard.vue';
const renderComponent = createComponentRenderer(PromotionChangeCard);
type Case = {
name: string;
direction: PromotionDirection;
resource: PromotionResource;
detail?: PromotionResourceDetail;
/** The files changed after the preview loaded, so the revisions differ. */
stale?: boolean;
shows: string[];
};
const revision = { before: 'b', after: 'a' };
const cases: Case[] = [
{
name: 'a renamed project with its old and new name',
direction: 'promote',
resource: resource({ key: 'project:p1', name: 'Billing', change: 'modified' }),
detail: {
key: 'project:p1',
type: 'project',
before: { id: 'p1', name: 'Invoicing' },
after: { id: 'p1', name: 'Billing' },
revision,
},
shows: ['Before', 'Invoicing', 'After', 'Billing'],
},
{
name: 'only the move of a folder, which stops Apply',
direction: 'apply',
resource: resource({
key: 'folder:f3',
name: 'Archive',
parentKey: 'folder:f2',
previousParentKey: 'folder:f1',
change: 'modified',
}),
detail: {
key: 'folder:f3',
type: 'folder',
before: { id: 'f3', name: 'Archive', parentFolderId: 'f1' },
after: { id: 'f3', name: 'Old invoices', parentFolderId: 'f2' },
revision,
},
shows: [
'Location',
'Payments',
'Reports',
"Apply can't move a folder that already exists on this instance",
],
},
{
name: 'the exported expressions of a new credential',
direction: 'promote',
resource: resource({ key: 'credential:c3', name: 'Slack', change: 'added' }),
detail: {
key: 'credential:c3',
type: 'credential',
before: null,
after: { id: 'c3', name: 'Slack', type: 'slackApi', data: { token: '={{ $env.SLACK }}' } },
revision: { before: null, after: 'a' },
},
shows: ['Not on the branch', 'Exported expressions', '$env.SLACK', 'slackApi'],
},
{
name: 'that variable values are hidden from the user',
direction: 'promote',
resource: billingResources.apiUrl,
detail: {
key: 'variable:global:API_URL',
type: 'variable',
before: null,
after: { name: 'API_URL', type: 'string' },
revision: { before: null, after: 'a' },
valueHidden: true,
},
shows: ['Global', "You don't have permission to see variable values."],
},
{
name: 'the column changes of a data table',
direction: 'apply',
resource: billingResources.invoicesTable,
detail: {
key: 'dataTable:t1',
type: 'dataTable',
before: null,
after: null,
revision,
dataTableChanges: [
{ kind: 'remove-column', column: 'notes', type: 'string', destructive: true },
{ kind: 'rename-column', from: 'amount', to: 'total', destructive: false },
],
},
shows: ['Removes column notes and its values', 'Renames column amount to total'],
},
{
name: 'that a file in an unknown format cannot be shown',
direction: 'apply',
resource: resource({ key: 'tag:g2', name: 'ops', change: 'added' }),
detail: {
key: 'tag:g2',
type: 'tag',
before: null,
after: null,
revision: { before: null, after: 'invalid' },
},
shows: ['The file in the Git branch has an unexpected format.'],
},
{
name: 'that an unchanged dependency stays as it is',
direction: 'apply',
resource: billingResources.stripe,
shows: ['No changes. The operation keeps this resource as it is.'],
},
{
name: 'that the content changed after the preview loaded',
direction: 'promote',
resource: resource({ key: 'tag:g1', name: 'finance', change: 'modified' }),
detail: {
key: 'tag:g1',
type: 'tag',
before: { id: 'g1', name: 'fin' },
after: { id: 'g1', name: 'finance' },
revision,
},
stale: true,
shows: ['This resource changed after the preview loaded.'],
},
];
describe('PromotionChangeCard', () => {
it.each(cases)('should show $name', ({ direction, resource: card, detail, stale, shows }) => {
const { getByTestId } = renderComponent({
pinia: createTestingPinia(),
props: {
resource: detail && !stale ? { ...card, revision: detail.revision } : card,
graph: buildSelectionGraph(billingPreview(), 'promote'),
direction,
entry: detail ? { status: 'loaded', detail } : undefined,
included: false,
inspected: false,
},
});
for (const text of shows) {
expect(getByTestId('promotion-preview-card')).toHaveTextContent(text);
}
});
it('should describe a column removal on Promote without a loss of values', () => {
const { getAllByTestId } = renderComponent({
pinia: createTestingPinia(),
props: {
resource: { ...billingResources.invoicesTable, revision },
graph: buildSelectionGraph(billingPreview(), 'promote'),
direction: 'promote',
entry: {
status: 'loaded',
detail: {
key: 'dataTable:t1',
type: 'dataTable',
before: null,
after: null,
revision,
dataTableChanges: [
{ kind: 'remove-column', column: 'notes', type: 'string', destructive: true },
],
},
},
included: false,
inspected: false,
},
});
// Promote changes the branch only. The values go only when the branch is applied.
expect(getAllByTestId('promotion-preview-data-table-change')[0]).toHaveTextContent(
/^Removes column notes$/,
);
});
it('should collapse a card from its header and open it again when the tree inspects it', async () => {
const { getByTestId, findByText, rerender } = renderComponent({
pinia: createTestingPinia(),
props: {
resource: billingResources.stripe,
graph: buildSelectionGraph(billingPreview(), 'promote'),
direction: 'apply',
included: false,
inspected: false,
},
});
const toggle = getByTestId('promotion-preview-card-toggle');
await userEvent.click(toggle);
expect(toggle).toHaveAttribute('aria-expanded', 'false');
await rerender({ inspected: true });
expect(toggle).toHaveAttribute('aria-expanded', 'true');
expect(await findByText(/No changes\./)).toBeInTheDocument();
});
it('should show in the header whether the operation includes a dependency, and which workflows use it', () => {
const props = {
resource: billingResources.stripe,
graph: buildSelectionGraph(billingPreview(), 'promote'),
direction: 'promote' as const,
included: false,
inspected: false,
};
const included = renderComponent({
pinia: createTestingPinia(),
props: { ...props, included: true },
});
expect(included.getByTestId('promotion-preview-card-status')).toHaveAttribute(
'aria-label',
'Included',
);
expect(included.getByTestId('promotion-preview-card-inclusion')).toHaveTextContent(
'Used by Invoice reconciliation',
);
included.unmount();
const notIncluded = renderComponent({ pinia: createTestingPinia(), props });
expect(notIncluded.getByTestId('promotion-preview-card-status')).toHaveAttribute(
'aria-label',
'Not included',
);
});
});

View File

@ -0,0 +1,362 @@
<script setup lang="ts">
import type { PromotionDirection, PromotionResource } from '@n8n/api-types';
import {
N8nButton,
N8nCallout,
N8nIcon,
N8nLoading,
N8nText,
N8nTooltip,
} from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
import { computed, ref, watch } from 'vue';
import { I18nT } from 'vue-i18n';
import type { DetailEntry } from '../../composables/usePromotionResourceDetails';
import { usePromotionPreviewLabels } from '../../composables/usePromotionPreviewLabels';
import {
describeDataTable,
describeFieldChanges,
describeWorkflowFieldChanges,
hasUnreadableSide,
isStaleDetail,
sideState,
type DetailSide,
} from '../../promotionPreview.content';
import type { ResourceKey, SelectionGraph } from '../../promotionPreview.selection';
import { isContainer, resourceIcon } from '../../promotionPreview.tree';
import DataTableChangeDetails from './DataTableChangeDetails.vue';
import PromotionFieldChanges from './PromotionFieldChanges.vue';
const props = defineProps<{
resource: PromotionResource;
graph: SelectionGraph;
direction: PromotionDirection;
included: boolean;
entry?: DetailEntry;
inspected: boolean;
}>();
const emit = defineEmits<{
inspect: [key: ResourceKey];
retry: [];
refresh: [];
}>();
const i18n = useI18n();
const nameOf = (key: string) => props.graph.resources.get(key)?.name ?? key;
const { changeLabel } = usePromotionPreviewLabels();
// Cards start open. Inspecting a resource from the tree opens its card again.
const open = ref(true);
watch(
() => props.inspected,
(inspected) => {
if (inspected) open.value = true;
},
);
const statusLabel = computed(() =>
i18n.baseText(
props.included ? 'promotions.preview.card.included' : 'promotions.preview.card.notIncluded',
),
);
// On a project page, project scope is the default, so only global variables say so.
const scope = computed(() =>
props.resource.key.startsWith('variable:global:')
? i18n.baseText('promotions.preview.card.globalScope')
: undefined,
);
type Footer = { keypath: BaseTextKey; resourceKeys: readonly ResourceKey[] };
// Resources that users cannot select name the workflows that use or call them.
const footer = computed((): Footer | undefined => {
const { resource, graph } = props;
if (resource.selectable || isContainer(resource)) return undefined;
const users = graph.requiredBy.get(resource.key) ?? [];
if (users.length > 0) return { keypath: 'promotions.preview.card.usedBy', resourceKeys: users };
return {
keypath: 'promotions.preview.card.calledBy',
resourceKeys: graph.calledBy.get(resource.key) ?? [],
};
});
// The list format adds the separators and the "and" of the current language.
const footerNames = computed(() =>
new Intl.ListFormat(i18n.locale, { style: 'long', type: 'conjunction' }).formatToParts(
footer.value?.resourceKeys ?? [],
),
);
const detail = computed(() => (props.entry?.status === 'loaded' ? props.entry.detail : undefined));
const stale = computed(() => !!detail.value && isStaleDetail(detail.value, props.resource));
const fieldChanges = computed(() => {
const context = { direction: props.direction, resource: props.resource, containerName: nameOf };
if (props.resource.type === 'workflow') return describeWorkflowFieldChanges(context);
return detail.value ? describeFieldChanges(detail.value, context) : [];
});
const dataTable = computed(() =>
detail.value?.type === 'dataTable'
? describeDataTable(detail.value, props.resource, props.direction)
: undefined,
);
const absentSides = computed((): DetailSide[] => {
const current = detail.value;
if (!current) return [];
return (['before', 'after'] as const).filter(
(side) => sideState(current, side).kind === 'absent',
);
});
const isApplyMove = computed(
() =>
props.direction === 'apply' &&
props.resource.type === 'folder' &&
props.resource.previousParentKey !== undefined,
);
</script>
<template>
<CollapsibleRoot v-model:open="open" as-child>
<article
:class="[$style.card, { [$style.inspected]: inspected }]"
data-test-id="promotion-preview-card"
:data-resource-key="resource.key"
>
<CollapsibleTrigger :class="$style.header" data-test-id="promotion-preview-card-toggle">
<N8nIcon :icon="resourceIcon(resource.type)" size="small" :class="$style.icon" />
<N8nText bold size="medium" color="text-dark" :title="resource.name" :class="$style.title">
{{ resource.name }}
</N8nText>
<N8nText v-if="changeLabel(resource)" step="xs" color="text-light">
{{ changeLabel(resource) }}
</N8nText>
<N8nIcon icon="chevron-down" size="small" :class="$style.chevron" />
<N8nTooltip :content="statusLabel" as-child>
<span
role="img"
:aria-label="statusLabel"
:class="[$style.status, { [$style.included]: included }]"
data-test-id="promotion-preview-card-status"
:data-included="included"
>
<N8nIcon :icon="included ? 'circle-check' : 'circle-minus'" size="medium" />
</span>
</N8nTooltip>
</CollapsibleTrigger>
<CollapsibleContent>
<div :class="$style.notices">
<N8nCallout v-if="entry?.status === 'missing'" theme="warning" slim>
{{ i18n.baseText('promotions.preview.card.missing') }}
<template #trailingContent>
<N8nButton variant="outline" size="small" @click="emit('refresh')">
{{ i18n.baseText('promotions.preview.refresh') }}
</N8nButton>
</template>
</N8nCallout>
<N8nCallout v-else-if="entry?.status === 'error'" theme="danger" slim>
{{ i18n.baseText('promotions.preview.card.loadFailed') }}
<template #trailingContent>
<N8nButton variant="outline" size="small" @click="emit('retry')">
{{ i18n.baseText('promotions.preview.state.retry') }}
</N8nButton>
</template>
</N8nCallout>
<N8nCallout v-if="stale" theme="warning" slim data-test-id="promotion-preview-card-stale">
{{ i18n.baseText('promotions.preview.card.stale') }}
<template #trailingContent>
<N8nButton variant="outline" size="small" @click="emit('refresh')">
{{ i18n.baseText('promotions.preview.refresh') }}
</N8nButton>
</template>
</N8nCallout>
<N8nCallout v-if="detail && hasUnreadableSide(detail)" theme="info" slim>
{{ i18n.baseText('promotions.preview.card.unreadable') }}
</N8nCallout>
</div>
<N8nText
v-if="resource.change === 'unchanged'"
step="xs"
color="text-light"
:class="$style.padded"
>
{{ i18n.baseText('promotions.preview.card.noChanges') }}
</N8nText>
<N8nLoading v-else-if="entry?.status === 'loading'" :rows="2" :class="$style.padded" />
<template v-else>
<DataTableChangeDetails
v-if="dataTable"
:class="$style.padded"
:description="dataTable"
/>
<PromotionFieldChanges
v-else-if="fieldChanges.length > 0"
:changes="fieldChanges"
:direction="direction"
:absent-sides="absentSides"
/>
<N8nText
v-if="detail?.type === 'variable' && detail.valueHidden"
step="2xs"
color="text-light"
:class="$style.padded"
>
{{ i18n.baseText('promotions.preview.variable.valueHidden') }}
</N8nText>
<N8nText v-if="isApplyMove" step="2xs" color="text-light" :class="$style.padded">
{{ i18n.baseText('promotions.preview.folder.moveConflict') }}
</N8nText>
</template>
<footer v-if="footer" :class="$style.footer">
<N8nText v-if="scope" step="2xs" color="text-light">{{ scope }}</N8nText>
<N8nText step="2xs" color="text-light" data-test-id="promotion-preview-card-inclusion">
<I18nT :keypath="footer.keypath" scope="global">
<template #names>
<template v-for="(part, index) in footerNames" :key="index">
<button
v-if="part.type === 'element'"
type="button"
:class="$style.nameLink"
data-test-id="promotion-preview-card-relation"
@click="emit('inspect', part.value)"
>
{{ nameOf(part.value) }}
</button>
<template v-else>{{ part.value }}</template>
</template>
</template>
</I18nT>
</N8nText>
</footer>
</CollapsibleContent>
</article>
</CollapsibleRoot>
</template>
<style module lang="scss">
@use '@n8n/design-system/css/mixins/motion';
.card {
display: flex;
flex-direction: column;
border: var(--border);
border-radius: var(--radius--xs);
background-color: var(--background--surface);
/* The gap between cards, so the card above stays out of view. */
scroll-margin-top: var(--spacing--xs);
overflow: hidden;
}
.inspected {
border-color: var(--border-color--strong);
}
.header {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
width: 100%;
min-height: var(--height--xl);
padding: 0 var(--spacing--sm);
border: none;
background: none;
text-align: left;
cursor: pointer;
&[data-state='open'] {
border-bottom: var(--border);
}
&:focus-visible {
outline: var(--focus--border-width) solid var(--focus--outline-color);
outline-offset: calc(-1 * var(--focus--border-width));
}
}
.chevron {
flex-shrink: 0;
color: var(--icon-color);
transition: transform var(--duration--snappy) var(--easing--ease-out);
@include motion.reduced-motion;
[data-state='closed'] > & {
transform: rotate(-90deg);
}
}
.icon {
flex-shrink: 0;
color: var(--icon-color);
}
.title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notices {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
padding: var(--spacing--xs) var(--spacing--sm) 0;
&:empty {
display: none;
}
}
.padded {
/* N8nText renders a span, which ignores vertical padding. */
display: block;
padding: var(--spacing--xs) var(--spacing--sm);
}
.footer {
display: flex;
flex-wrap: wrap;
align-items: center;
column-gap: var(--spacing--2xs);
padding: var(--spacing--2xs) var(--spacing--sm);
border-top: var(--border);
}
.footer > * + *::before {
content: '·' / '';
margin-right: var(--spacing--2xs);
}
/* N8nLink has no light theme, so the names use the footer text color. */
.nameLink {
padding: 0;
border: none;
background: none;
color: inherit;
font: inherit;
text-decoration: underline;
cursor: pointer;
&:hover {
color: var(--text-color);
}
&:focus-visible {
outline: var(--focus--border-width) solid var(--focus--outline-color);
}
}
.status {
display: flex;
margin-left: auto;
color: var(--icon-color);
}
.included {
color: var(--icon-color--success);
}
</style>

View File

@ -0,0 +1,70 @@
<script setup lang="ts">
import type { PromotionDirection } from '@n8n/api-types';
import { computed, nextTick, ref, watch } from 'vue';
import type { DetailEntry } from '../../composables/usePromotionResourceDetails';
import type { Inclusion, ResourceKey, SelectionGraph } from '../../promotionPreview.selection';
import PromotionChangeCard from './PromotionChangeCard.vue';
const props = defineProps<{
order: ResourceKey[];
graph: SelectionGraph;
direction: PromotionDirection;
inclusion: Inclusion;
entries: ReadonlyMap<ResourceKey, DetailEntry>;
inspectedKey?: ResourceKey;
}>();
const emit = defineEmits<{
retry: [key: ResourceKey];
refresh: [];
inspect: [key: ResourceKey];
}>();
const list = ref<HTMLElement | null>(null);
const resources = computed(() =>
props.order.flatMap((key) => {
const resource = props.graph.resources.get(key);
return resource ? [resource] : [];
}),
);
// Scrolls the card to the top. An instant jump keeps loading cards from pushing it away.
async function scrollToCard(key: ResourceKey | undefined) {
if (!key) return;
await nextTick();
list.value
?.querySelector(`[data-resource-key="${CSS.escape(key)}"]`)
?.scrollIntoView({ block: 'start' });
}
watch(() => props.inspectedKey, scrollToCard, { immediate: true });
defineExpose({ scrollToCard });
</script>
<template>
<div ref="list" :class="$style.list" data-test-id="promotion-preview-cards">
<PromotionChangeCard
v-for="resource in resources"
:key="resource.key"
:resource="resource"
:graph="graph"
:direction="direction"
:included="inclusion.included.has(resource.key)"
:entry="entries.get(resource.key)"
:inspected="inspectedKey === resource.key"
@inspect="emit('inspect', $event)"
@retry="emit('retry', resource.key)"
@refresh="emit('refresh')"
/>
</div>
</template>
<style module lang="scss">
.list {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
}
</style>

View File

@ -0,0 +1,117 @@
<script setup lang="ts">
import type { PromotionDirection } from '@n8n/api-types';
import { N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
import NodeDiff from '@/features/workflows/workflowDiff/NodeDiff.vue';
import type { DetailSide, FieldChange } from '../../promotionPreview.content';
const props = defineProps<{
changes: FieldChange[];
direction: PromotionDirection;
/** Sides without a file. The resource is new or removed there. */
absentSides: DetailSide[];
}>();
const i18n = useI18n();
// Before is the branch for promote, and this instance for apply.
const absentText = (side: DetailSide) =>
i18n.baseText(
side === (props.direction === 'promote' ? 'before' : 'after')
? 'promotions.preview.side.notOnBranch'
: 'promotions.preview.side.notOnInstance',
);
// Exported expressions are JSON, so they show as a line diff.
const columnChanges = computed(() => props.changes.filter((change) => change.field !== 'data'));
const dataChange = computed(() => props.changes.find((change) => change.field === 'data'));
// A single name change needs no field label.
const showFieldLabels = computed(
() => columnChanges.value.length > 1 || columnChanges.value[0]?.field !== 'name',
);
const sides = computed(() =>
(['before', 'after'] as const).map((side) => ({
side,
label: i18n.baseText(`promotions.preview.field.${side}`),
absent: props.absentSides.includes(side),
})),
);
</script>
<template>
<div>
<div
v-if="columnChanges.length > 0 || absentSides.length > 0"
:class="$style.columns"
data-test-id="promotion-preview-field-changes"
>
<div v-for="column in sides" :key="column.side" :class="$style.column">
<N8nText step="2xs" bold color="text-base">{{ column.label }}</N8nText>
<N8nText v-if="column.absent" step="xs" color="text-light">
{{ absentText(column.side) }}
</N8nText>
<template v-else>
<div v-for="change in columnChanges" :key="change.field" :class="$style.field">
<N8nText v-if="showFieldLabels" step="2xs" color="text-light">
{{ i18n.baseText(`promotions.preview.field.${change.field}`) }}
</N8nText>
<N8nText
step="xs"
:color="change[column.side] === undefined ? 'text-light' : 'text-dark'"
:class="$style.value"
>
{{ change[column.side] ?? i18n.baseText('promotions.preview.field.empty') }}
</N8nText>
</div>
</template>
</div>
</div>
<div v-if="dataChange" :class="$style.data" data-test-id="promotion-preview-data-diff">
<N8nText step="2xs" bold color="text-base">
{{ i18n.baseText('promotions.preview.field.data') }}
</N8nText>
<NodeDiff :old-string="dataChange.before ?? ''" :new-string="dataChange.after ?? ''" />
</div>
</div>
</template>
<style module lang="scss">
.columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.column {
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
padding: var(--spacing--xs) var(--spacing--sm);
& + & {
border-left: var(--border);
}
}
.field {
display: flex;
flex-direction: column;
gap: var(--spacing--5xs);
}
.value {
overflow-wrap: anywhere;
}
.data {
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
padding: var(--spacing--xs) var(--spacing--sm);
border-top: var(--border);
}
</style>

View File

@ -0,0 +1,59 @@
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { within } from '@testing-library/vue';
import { createComponentRenderer } from '@/__tests__/render';
import { billingPreview } from '../../__tests__/preview.fixtures';
import { buildSelectionGraph, resolveInclusion } from '../../promotionPreview.selection';
import { buildPreviewTree } from '../../promotionPreview.tree';
import PromotionResourceTree from './PromotionResourceTree.vue';
const graph = buildSelectionGraph(billingPreview(), 'promote');
const tree = buildPreviewTree(billingPreview(), { workflows: 'workflows', group: (id) => id });
const renderComponent = createComponentRenderer(PromotionResourceTree);
function render(selectedKeys: string[]) {
const selected = new Set(selectedKeys);
return renderComponent({
pinia: createTestingPinia(),
props: {
tree,
graph,
selected,
inclusion: resolveInclusion(graph, selected),
includedChanges: 0,
totalChanges: 0,
},
});
}
const row = (getAllByTestId: (id: string) => HTMLElement[], key: string) => {
const match = getAllByTestId('promotion-preview-tree-row').find(
(element) => element.dataset.resourceKey === key,
);
if (!match) throw new Error(`No tree row for ${key}`);
return within(match);
};
describe('PromotionResourceTree', () => {
it('should show required dependencies as checked and read-only, and unused ones as unchecked', () => {
const { getAllByTestId } = render(['workflow:w1']);
const table = row(getAllByTestId, 'dataTable:t1').getByRole('checkbox');
expect(table).toBeChecked();
expect(table).toBeDisabled();
// "Format currency" is a sub-workflow of the selected workflow. Selecting a caller does not include it.
expect(row(getAllByTestId, 'workflow:w4').getByRole('checkbox')).not.toBeChecked();
});
it('should show a folder as partly selected and toggle the workflows inside it', async () => {
const { getAllByTestId, emitted } = render(['workflow:w1']);
const folder = row(getAllByTestId, 'folder:f1').getByRole('checkbox');
expect(folder).toHaveAttribute('data-state', 'indeterminate');
await userEvent.click(folder);
expect(emitted('toggleContainer')).toEqual([['folder:f1']]);
});
});

View File

@ -0,0 +1,225 @@
<script setup lang="ts">
import type { PromotionResource } from '@n8n/api-types';
import { N8nCheckbox, N8nIcon, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
import { usePromotionPreviewLabels } from '../../composables/usePromotionPreviewLabels';
import {
containerCheckState,
selectableDescendants,
selectableKeys,
type Inclusion,
type ResourceKey,
type SelectionGraph,
} from '../../promotionPreview.selection';
import { flattenPreviewTree, isContainer, type PreviewTreeItem } from '../../promotionPreview.tree';
const props = defineProps<{
tree: PreviewTreeItem[];
graph: SelectionGraph;
selected: ReadonlySet<ResourceKey>;
inclusion: Inclusion;
/** Included changes, out of the total in the tab above the tree. */
includedChanges: number;
totalChanges: number;
inspectedKey?: ResourceKey;
}>();
const emit = defineEmits<{
toggle: [key: ResourceKey];
toggleContainer: [key: ResourceKey];
selectAll: [];
clear: [];
inspect: [key: ResourceKey];
}>();
const i18n = useI18n();
const { changeLabel } = usePromotionPreviewLabels();
const rows = computed(() =>
flattenPreviewTree(props.tree).map(({ item, indent }) => {
const resource = props.graph.resources.get(item.id);
return { item, indent, resource, check: resource ? checkbox(resource) : undefined };
}),
);
const selectableCount = computed(() => selectableKeys(props.graph).length);
const allState = computed(() => {
if (props.selected.size === 0) return 'unchecked';
return props.selected.size >= selectableCount.value ? 'checked' : 'indeterminate';
});
/**
* Workflows toggle themselves, projects and folders toggle the workflows inside them, and
* dependencies stay read-only: they are checked while a selected workflow needs them.
*/
function checkbox(resource: PromotionResource) {
if (resource.selectable) {
return { checked: props.selected.has(resource.key), indeterminate: false, disabled: false };
}
if (isContainer(resource)) {
const state = containerCheckState(props.graph, props.selected, resource.key);
return {
checked: state === 'checked',
indeterminate: state === 'indeterminate',
disabled: selectableDescendants(props.graph, resource.key).length === 0,
};
}
return {
checked: props.inclusion.included.has(resource.key),
indeterminate: false,
disabled: true,
};
}
function onToggle(resource: PromotionResource) {
if (isContainer(resource)) emit('toggleContainer', resource.key);
else emit('toggle', resource.key);
}
</script>
<template>
<div :class="$style.tree" data-test-id="promotion-preview-tree">
<div :class="$style.row">
<N8nCheckbox
:model-value="allState === 'checked'"
:indeterminate="allState === 'indeterminate'"
:aria-label="i18n.baseText('promotions.preview.tree.selectAll')"
data-test-id="promotion-preview-select-all"
@update:model-value="allState === 'checked' ? emit('clear') : emit('selectAll')"
/>
<N8nText step="xs" color="text-base" data-test-id="promotion-preview-counts">
{{
i18n.baseText('promotions.preview.tree.selectedOfTotal', {
adjustToNumber: totalChanges,
interpolate: {
selected: String(includedChanges),
total: String(totalChanges),
},
})
}}
</N8nText>
</div>
<ul :class="$style.list" role="list">
<template v-for="{ item, indent, resource, check } in rows" :key="item.id">
<li v-if="item.kind === 'section'" :class="$style.heading">
<N8nText step="2xs" bold color="text-base">
{{ item.label }}
</N8nText>
</li>
<li
v-else-if="resource && check"
:class="[$style.row, $style.resource, { [$style.inspected]: inspectedKey === item.id }]"
data-test-id="promotion-preview-tree-row"
:data-resource-key="item.id"
>
<N8nCheckbox
:model-value="check.checked"
:indeterminate="check.indeterminate"
:disabled="check.disabled"
:aria-label="
i18n.baseText('promotions.preview.tree.select', { interpolate: { name: item.label } })
"
data-test-id="promotion-preview-tree-checkbox"
@update:model-value="onToggle(resource)"
/>
<button
type="button"
:class="$style.name"
:style="{ '--preview-tree--indent': indent }"
:aria-current="inspectedKey === item.id ? 'true' : undefined"
@click="emit('inspect', item.id)"
>
<N8nIcon v-if="item.icon" :icon="item.icon" size="small" :class="$style.icon" />
<N8nText step="xs" color="text-dark" :class="$style.label">{{ item.label }}</N8nText>
<N8nText step="2xs" color="text-light" :class="$style.meta">
{{ changeLabel(resource) }}
</N8nText>
</button>
</li>
</template>
</ul>
</div>
</template>
<style module lang="scss">
.tree {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow-y: auto;
padding: var(--spacing--xs) var(--spacing--2xs);
}
.list {
margin: 0;
padding: 0;
list-style: none;
}
.row {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
min-height: var(--height--md);
padding: 0 var(--spacing--2xs);
border-radius: var(--radius--3xs);
}
/* Same color for hover and selection as the items of the main menu. */
.resource:hover,
.inspected {
background-color: var(--background--subtle);
}
.heading {
display: flex;
align-items: center;
min-height: var(--height--sm);
padding: 0 var(--spacing--2xs);
}
.heading:not(:first-child) {
margin-top: var(--spacing--xs);
}
.name {
display: flex;
flex: 1;
align-items: center;
gap: var(--spacing--3xs);
min-width: 0;
align-self: stretch;
padding: 0;
padding-left: calc(var(--preview-tree--indent) * var(--spacing--xs));
border: none;
background: none;
text-align: left;
cursor: pointer;
&:focus-visible {
outline: var(--focus--border-width) solid var(--focus--outline-color);
outline-offset: calc(-1 * var(--focus--border-width));
}
}
.icon {
flex-shrink: 0;
color: var(--icon-color);
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
flex-shrink: 0;
white-space: nowrap;
}
</style>

View File

@ -0,0 +1,30 @@
import type { PromotionResource } from '@n8n/api-types';
import { useI18n } from '@n8n/i18n';
/** The change label that the tree rows and the change cards share, so both say the same. */
export function usePromotionPreviewLabels() {
const i18n = useI18n();
function changeLabel(resource: PromotionResource): string | undefined {
switch (resource.change) {
case 'added':
return i18n.baseText('promotions.preview.change.added');
case 'archived':
return i18n.baseText('promotions.preview.change.archived');
case 'deleted':
return i18n.baseText('promotions.preview.change.deleted');
case 'modified':
if (resource.previousParentKey !== undefined) {
return i18n.baseText('promotions.preview.change.moved');
}
if (resource.previousName !== undefined) {
return i18n.baseText('promotions.preview.change.renamed');
}
return i18n.baseText('promotions.preview.change.modified');
case 'unchanged':
return undefined;
}
}
return { changeLabel };
}

View File

@ -1,25 +1,50 @@
import type { PromotionDirection } from '@n8n/api-types';
import { defineFrontendModule } from '@n8n/frontend-module-sdk';
import { RouterView } from 'vue-router';
import { useRBACStore } from '@n8n/stores/rbac.store';
import { RouterView, type RouteLocationNormalized, type RouteRecordRaw } from 'vue-router';
import { VIEWS } from '@/app/constants';
import { usePromotionsEnabled } from '@/features/shared/promotions/usePromotionsEnabled';
import { PROMOTIONS_MODALS } from './modals';
import {
PROMOTION_PREVIEW_VIEWS,
PROMOTIONS_ENVIRONMENT_ADD_VIEW,
PROMOTIONS_ENVIRONMENT_EDIT_VIEW,
PROMOTIONS_SETTINGS_SCOPES,
PROMOTIONS_SETTINGS_VIEW,
} from './promotions.constants';
const SettingsView = async () => await import('./views/PromotionsSettingsView.vue');
const EnvironmentView = async () => await import('./views/PromotionEnvironmentView.vue');
const PreviewView = async () => await import('./views/PromotionPreviewView.vue');
const SETTINGS_SCOPES = [
'gitConnection:list',
'gitConnection:read',
'gitConnection:create',
'gitConnection:update',
'gitConnection:delete',
] as const;
// Check the rollout flag here because module routes skip custom middleware.
const requirePromotionsEnabled = () =>
usePromotionsEnabled().isEnabled.value || { name: VIEWS.HOMEPAGE };
/** A project page that shows the changes before the user promotes or applies them. */
const previewRoute = (direction: PromotionDirection): RouteRecordRaw => ({
path: `promotions/${direction}`,
name: PROMOTION_PREVIEW_VIEWS[direction],
component: PreviewView,
props: (route: RouteLocationNormalized) => ({
projectId: String(route.params.projectId),
direction,
}),
meta: {
projectRoute: true,
middleware: ['authenticated', 'custom'],
},
// Users who cannot promote or apply see no promotions UI, so a direct link leads home.
// The rbac middleware needs project scopes, which these routes do not load.
beforeEnter: () => {
const scope = direction === 'promote' ? 'gitConnection:push' : 'gitConnection:pull';
const allowed =
usePromotionsEnabled().isEnabled.value &&
useRBACStore().hasScope(['gitConnection:read', scope], {}, { mode: 'allOf' });
return allowed || { name: VIEWS.HOMEPAGE };
},
});
export const PromotionsModule = defineFrontendModule({
id: 'promotions',
@ -34,15 +59,14 @@ export const PromotionsModule = defineFrontendModule({
layout: 'settings',
middleware: ['authenticated', 'rbac', 'custom'],
middlewareOptions: {
rbac: { scope: [...SETTINGS_SCOPES], options: { mode: 'allOf' } },
rbac: { scope: [...PROMOTIONS_SETTINGS_SCOPES], options: { mode: 'allOf' } },
},
telemetry: {
pageCategory: 'settings',
getProperties: () => ({ feature: 'promotions' }),
},
},
// Check the rollout flag here because module routes skip custom middleware.
beforeEnter: () => usePromotionsEnabled().isEnabled.value || { name: VIEWS.HOMEPAGE },
beforeEnter: requirePromotionsEnabled,
children: [
{ path: '', name: PROMOTIONS_SETTINGS_VIEW, component: SettingsView },
{
@ -59,6 +83,8 @@ export const PromotionsModule = defineFrontendModule({
},
],
},
previewRoute('promote'),
previewRoute('apply'),
],
modals: PROMOTIONS_MODALS,
});

View File

@ -4,6 +4,7 @@ import { billingResources, resource } from './__tests__/preview.fixtures';
import {
describeDataTable,
describeFieldChanges,
describeWorkflowFieldChanges,
sideState,
type FieldContext,
} from './promotionPreview.content';
@ -105,22 +106,39 @@ describe('promotion preview content', () => {
]);
});
it('should flag a removed column as data loss and list the columns of a new table', () => {
const modified = describeDataTable(
{
key: 'dataTable:t1',
type: 'dataTable',
before: null,
after: null,
revision: { before: 'b', after: 'a' },
dataTableChanges: [
{ kind: 'rename-column', from: 'amount', to: 'total', destructive: false },
{ kind: 'remove-column', column: 'notes', type: 'string', destructive: true },
],
},
billingResources.invoicesTable,
);
expect(modified.hasDataLoss).toBe(true);
it('should show the rename and move of a workflow from the preview resource', () => {
// The workflow was renamed and moved on this instance. Apply restores the branch state.
const workflow = resource({
key: 'workflow:w1',
name: 'Invoice sync',
previousName: 'Invoice sync (local)',
parentKey: 'project:p1',
previousParentKey: 'folder:f1',
change: 'modified',
});
expect(
describeWorkflowFieldChanges(context({ direction: 'apply', resource: workflow })),
).toEqual([
{ field: 'name', before: 'Invoice sync (local)', after: 'Invoice sync' },
{ field: 'location', before: 'Payments', after: 'Billing' },
]);
});
const changedTable: Extract<PromotionResourceDetail, { type: 'dataTable' }> = {
key: 'dataTable:t1',
type: 'dataTable',
before: null,
after: null,
revision: { before: 'b', after: 'a' },
dataTableChanges: [
{ kind: 'rename-column', from: 'amount', to: 'total', destructive: false },
{ kind: 'remove-column', column: 'notes', type: 'string', destructive: true },
],
};
it('should list the column changes of a changed table and the columns of a new table', () => {
const modified = describeDataTable(changedTable, billingResources.invoicesTable, 'apply');
expect(modified.changes).toHaveLength(2);
const added = describeDataTable(
@ -139,8 +157,8 @@ describe('promotion preview content', () => {
revision: { before: null, after: 'a' },
},
resource({ key: 'dataTable:t2', name: 'Refunds', change: 'added' }),
'apply',
);
expect(added.addedColumns.map((column) => column.name)).toEqual(['amount', 'reason']);
expect(added.hasDataLoss).toBe(false);
});
});

View File

@ -120,18 +120,37 @@ export function describeFieldChanges(
.map((field) => ({ field, before: before[field], after: after[field] }));
}
/** The page loads no workflow files, so a workflow card shows the rename and move of the resource. */
export function describeWorkflowFieldChanges(context: FieldContext): FieldChange[] {
const { resource } = context;
const changes: FieldChange[] = [];
if (resource.previousName !== undefined) {
changes.push({ field: 'name', before: resource.previousName, after: resource.name });
}
if (resource.previousParentKey !== undefined) {
const before = locationOf(context, 'before').location;
const after = locationOf(context, 'after').location;
changes.push({ field: 'location', before, after });
}
return changes;
}
export type DataTableDescription = {
/** Column changes of a table that exists on both sides. */
changes: PromotionDataTableChange[];
/**
* True when the card warns about changes that remove data. Promote cannot know the table on the
* instance that applies the branch, so only Apply warns.
*/
warnsAboutDataLoss: boolean;
/** The columns of a table that the operation creates. */
addedColumns: SerializedDataTableColumn[];
/** True when a change removes stored values, such as a removed column or a new column type. */
hasDataLoss: boolean;
};
export function describeDataTable(
detail: Extract<PromotionResourceDetail, { type: 'dataTable' }>,
resource: PromotionResource,
direction: PromotionDirection,
): DataTableDescription {
const changes = resource.change === 'modified' ? (detail.dataTableChanges ?? []) : [];
const after = sideState(detail, 'after');
@ -139,7 +158,7 @@ export function describeDataTable(
resource.change === 'added' && after.kind === 'content'
? [...after.content.columns].sort((a, b) => a.index - b.index)
: [];
return { changes, addedColumns, hasDataLoss: changes.some((change) => change.destructive) };
return { changes, warnsAboutDataLoss: direction === 'apply', addedColumns };
}
/** True when the files changed after the preview loaded, so the card can be out of date. */

View File

@ -4,5 +4,19 @@ export const PROMOTIONS_SETTINGS_VIEW = 'PromotionsSettings';
export const PROMOTIONS_ENVIRONMENT_ADD_VIEW = 'PromotionsEnvironmentAdd';
export const PROMOTIONS_ENVIRONMENT_EDIT_VIEW = 'PromotionsEnvironmentEdit';
/** The scopes that the promotion settings page needs. */
export const PROMOTIONS_SETTINGS_SCOPES = [
'gitConnection:list',
'gitConnection:read',
'gitConnection:create',
'gitConnection:update',
'gitConnection:delete',
] as const;
export const PROMOTION_PREVIEW_VIEWS = {
promote: 'PromotionPreviewPromote',
apply: 'PromotionPreviewApply',
} as const;
/** The details endpoint accepts at most this many keys in one request. */
export const PROMOTION_DETAILS_BATCH_SIZE = 50;

View File

@ -0,0 +1,154 @@
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { waitFor, within } from '@testing-library/vue';
import { createMemoryHistory, createRouter } from 'vue-router';
import { createComponentRenderer } from '@/__tests__/render';
import { waitAllPromises } from '@/__tests__/utils';
import { VIEWS } from '@/app/constants';
import { billingPreview } from '../__tests__/preview.fixtures';
import { invalidateInstancePromotionConnection } from '../composables/useInstancePromotionConnection';
import * as api from '../promotions.api';
import * as settingsApi from '../promotionsSettings.api';
import PromotionPreviewView from './PromotionPreviewView.vue';
vi.mock('../promotions.api');
vi.mock('../promotionsSettings.api');
// The projects store loads the project of the route on its own.
vi.mock('@/features/collaboration/projects/projects.api');
const stub = { template: '<div />' };
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/projects/:projectId/workflows', name: VIEWS.PROJECTS_WORKFLOWS, component: stub },
{ path: '/projects/:projectId/promotions/:direction', component: stub },
],
});
const renderComponent = createComponentRenderer(PromotionPreviewView, {
global: { plugins: [router] },
});
const environment = (direction: 'promote' | 'apply') =>
({
id: `environment-${direction}`,
scope: 'instance',
direction,
remote: { schemaVersion: 1, remoteUrl: 'git@github.com:acme/workflows.git' },
}) as unknown as settingsApi.PromotionEnvironmentSummary;
async function render(path = '/projects/p1/promotions/promote', direction = 'promote' as const) {
await router.push(path);
const result = renderComponent({
pinia: createTestingPinia(),
props: { projectId: 'p1', direction },
});
await waitAllPromises();
return result;
}
const treeRow = (getAllByTestId: (id: string) => HTMLElement[], key: string) => {
const row = getAllByTestId('promotion-preview-tree-row').find(
(element) => element.dataset.resourceKey === key,
);
if (!row) throw new Error(`No tree row for ${key}`);
return within(row);
};
describe('PromotionPreviewView', () => {
beforeEach(() => {
invalidateInstancePromotionConnection();
Element.prototype.scrollIntoView = vi.fn();
vi.mocked(settingsApi.fetchPromotionEnvironments).mockResolvedValue([environment('promote')]);
vi.mocked(api.getPromotionPreview).mockResolvedValue(billingPreview());
vi.mocked(api.getPromotionResourceDetails).mockReset();
vi.mocked(api.getPromotionResourceDetails).mockResolvedValue({ details: [], missingKeys: [] });
});
it('should keep the selection while the user inspects cards', async () => {
const { getAllByTestId, getByTestId } = await render();
expect(getByTestId('promotion-preview-counts')).toHaveTextContent('5 of 5');
await userEvent.click(treeRow(getAllByTestId, 'workflow:w1').getByRole('checkbox'));
await userEvent.click(treeRow(getAllByTestId, 'dataTable:t1').getByText('Invoices'));
expect(getByTestId('promotion-preview-counts')).toHaveTextContent('4 of 5');
// The tab shows the total, whatever the selection.
expect(getByTestId('promotion-preview-tabs')).toHaveTextContent(/Changes\s*5/);
expect(treeRow(getAllByTestId, 'workflow:w1').getByRole('checkbox')).not.toBeChecked();
expect(router.currentRoute.value.query.resource).toBe('dataTable:t1');
});
it('should load the content of changed resources other than workflows with the preview', async () => {
await render();
expect(api.getPromotionResourceDetails).toHaveBeenCalledTimes(1);
expect(vi.mocked(api.getPromotionResourceDetails).mock.calls[0][3].keys).toEqual([
'folder:f1',
'dataTable:t1',
'variable:global:API_URL',
]);
});
it('should open the card of a direct link', async () => {
const { getAllByTestId } = await render('/projects/p1/promotions/promote?resource=tag:g1');
const card = getAllByTestId('promotion-preview-card').find(
(element) => element.dataset.resourceKey === 'tag:g1',
);
expect(card?.scrollIntoView).toHaveBeenCalled();
});
it('should scroll to the card again when the same item is inspected twice', async () => {
const { getAllByTestId } = await render();
const scrollIntoView = vi.mocked(Element.prototype.scrollIntoView);
await userEvent.click(treeRow(getAllByTestId, 'tag:g1').getByText('finance'));
await userEvent.click(treeRow(getAllByTestId, 'tag:g1').getByText('finance'));
expect(scrollIntoView).toHaveBeenCalledTimes(2);
});
it('should explain that the direction is not set up when the instance has no environment for it', async () => {
vi.mocked(settingsApi.fetchPromotionEnvironments).mockResolvedValue([environment('apply')]);
const { getByTestId } = await render();
expect(getByTestId('promotion-preview-disconnected')).toHaveTextContent("Promote isn't set up");
});
it('should offer a retry when the preview fails to load', async () => {
vi.mocked(api.getPromotionPreview).mockRejectedValueOnce(new Error('offline'));
const { getByTestId, getByRole } = await render();
expect(getByTestId('promotion-preview-error')).toHaveTextContent("Couldn't load the changes");
await userEvent.click(getByRole('button', { name: 'Try again' }));
await waitFor(() => expect(getByTestId('promotion-preview-tree')).toBeInTheDocument());
});
it('should keep the changes and warn when a refresh fails', async () => {
const { getByTestId, getByText } = await render();
vi.mocked(api.getPromotionPreview).mockRejectedValueOnce(new Error('offline'));
await userEvent.click(getByTestId('promotion-preview-refresh'));
await waitAllPromises();
expect(getByText(/Couldn't refresh the preview/)).toBeInTheDocument();
expect(getByTestId('promotion-preview-tree')).toBeInTheDocument();
});
it('should show the error when the refresh of an empty preview fails', async () => {
vi.mocked(api.getPromotionPreview).mockResolvedValueOnce(billingPreview({ resources: [] }));
const { getByTestId } = await render();
expect(getByTestId('promotion-preview-empty')).toHaveTextContent('Nothing to promote');
vi.mocked(api.getPromotionPreview).mockRejectedValueOnce(new Error('offline'));
await userEvent.click(getByTestId('promotion-preview-refresh'));
await waitAllPromises();
// "Nothing to promote" could be out of date, so the page asks for a retry.
expect(getByTestId('promotion-preview-error')).toHaveTextContent("Couldn't load the changes");
});
});

View File

@ -0,0 +1,371 @@
<script setup lang="ts">
import type { PromotionDirection } from '@n8n/api-types';
import {
N8nButton,
N8nCallout,
N8nEmptyState,
N8nHeading,
N8nIcon,
N8nLink,
N8nLoading,
N8nResizeWrapper,
N8nTabs,
N8nText,
useResizablePanel,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useRBACStore } from '@n8n/stores/rbac.store';
import { computed, ref, toRef, watch } from 'vue';
import { I18nT } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import TimeAgo from '@/app/components/TimeAgo.vue';
import { VIEWS } from '@/app/constants';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import PromotionChangeCards from '../components/preview/PromotionChangeCards.vue';
import PromotionResourceTree from '../components/preview/PromotionResourceTree.vue';
import { usePromotionResourceDetails } from '../composables/usePromotionResourceDetails';
import { usePromotionPreview } from '../composables/usePromotionPreview';
import { usePromotionSelection } from '../composables/usePromotionSelection';
import { PROMOTIONS_SETTINGS_SCOPES, PROMOTIONS_SETTINGS_VIEW } from '../promotions.constants';
import { includedChangeCount, totalChangeCount } from '../promotionPreview.selection';
import { buildPreviewTree, cardOrder } from '../promotionPreview.tree';
const props = defineProps<{ projectId: string; direction: PromotionDirection }>();
const i18n = useI18n();
const route = useRoute();
const router = useRouter();
const projectsStore = useProjectsStore();
const rbacStore = useRBACStore();
const documentTitle = useDocumentTitle();
const projectId = toRef(props, 'projectId');
const direction = toRef(props, 'direction');
const {
state,
preview,
graph,
repositoryName,
refreshing,
refreshFailed,
lastRefreshedAt,
refresh,
} = usePromotionPreview(projectId, direction);
const selection = usePromotionSelection(graph);
const details = usePromotionResourceDetails(projectId, direction, preview, graph);
const title = computed(() => i18n.baseText(`promotions.preview.title.${props.direction}`));
watch(title, (value) => documentTitle.set(value), { immediate: true });
const tree = computed(() =>
preview.value
? buildPreviewTree(preview.value, {
workflows: i18n.baseText('promotions.preview.tree.section.workflows'),
group: (group) => i18n.baseText(`promotions.preview.tree.group.${group}`),
})
: [],
);
const order = computed(() => (graph.value ? cardOrder(tree.value, graph.value.resources) : []));
// The inspected card lives in the URL, so a direct link opens it. Replacing the entry keeps
// browser Back on the page that opened the preview.
const inspectedKey = computed(() =>
typeof route.query.resource === 'string' ? route.query.resource : undefined,
);
const cards = ref<InstanceType<typeof PromotionChangeCards> | null>(null);
async function inspect(key: string) {
// The URL does not change for the same item, so scroll to its card again directly.
if (key === inspectedKey.value) {
await cards.value?.scrollToCard(key);
return;
}
await router.replace({ query: { ...route.query, resource: key } });
}
const canOpenSettings = computed(() =>
rbacStore.hasScope([...PROMOTIONS_SETTINGS_SCOPES], {}, { mode: 'allOf' }),
);
const projectRoute = computed(() => ({
name: VIEWS.PROJECTS_WORKFLOWS,
params: { projectId: props.projectId },
}));
const backLabel = computed(() =>
i18n.baseText('promotions.preview.back', {
interpolate: { project: projectsStore.currentProject?.name ?? '' },
}),
);
const totalChanges = computed(() => (graph.value ? totalChangeCount(graph.value) : 0));
const includedChanges = computed(() =>
graph.value ? includedChangeCount(graph.value, selection.inclusion.value) : 0,
);
const tabs = computed(() => [
{
value: 'changes',
label: i18n.baseText('promotions.preview.tab.changes'),
tag: String(totalChanges.value),
},
]);
// Users can resize the tree to read long names. The browser stores the width.
const panes = ref<HTMLElement | null>(null);
const treeResizer = useResizablePanel({
container: panes,
width: {
localStorageKey: 'N8N_PROMOTION_PREVIEW_TREE_WIDTH',
defaultSize: (size) => Math.min(Math.max(size * 0.28, 280), 420),
minSize: 240,
maxSize: (size) => Math.min(size * 0.5, 640),
snap: true,
},
});
</script>
<template>
<div :class="$style.page" data-test-id="promotion-preview-page">
<header :class="$style.header">
<N8nLink :to="projectRoute" theme="text" size="small" data-test-id="promotion-preview-back">
<span :class="$style.back"><N8nIcon icon="arrow-left" size="small" />{{ backLabel }}</span>
</N8nLink>
<N8nHeading tag="h1" size="xlarge" :class="$style.title">{{ title }}</N8nHeading>
<div :class="$style.tabRow">
<N8nTabs
model-value="changes"
:options="tabs"
:class="$style.tabs"
data-test-id="promotion-preview-tabs"
/>
<div :class="$style.meta">
<div :class="$style.details">
<N8nText v-if="preview && repositoryName" step="xs" color="text-light">
{{ repositoryName }}
</N8nText>
<N8nText v-if="preview" step="xs" color="text-light">
{{
i18n.baseText(`promotions.preview.branch.${direction}`, {
interpolate: { branch: preview.source.branchName },
})
}}
</N8nText>
<N8nText v-if="lastRefreshedAt" step="xs" color="text-light">
<I18nT keypath="promotions.preview.lastRefreshed" scope="global">
<template #time>
<TimeAgo :date="lastRefreshedAt.toISOString()" live />
</template>
</I18nT>
</N8nText>
</div>
<N8nButton
variant="outline"
size="small"
icon="refresh-cw"
:loading="refreshing"
data-test-id="promotion-preview-refresh"
@click="refresh"
>
{{ i18n.baseText('promotions.preview.refresh') }}
</N8nButton>
</div>
</div>
</header>
<div v-if="state === 'loading'" :class="$style.state" data-test-id="promotion-preview-loading">
<N8nLoading :rows="6" />
</div>
<N8nEmptyState
v-else-if="state === 'disconnected'"
:class="$style.state"
:icon="{ type: 'icon', value: 'git-branch' }"
:heading="i18n.baseText(`promotions.preview.state.disconnected.${direction}.title`)"
:description="
i18n.baseText(
canOpenSettings
? 'promotions.preview.state.disconnected.admin'
: 'promotions.preview.state.disconnected.member',
)
"
:button-text="
canOpenSettings ? i18n.baseText('promotions.preview.state.disconnected.action') : undefined
"
data-test-id="promotion-preview-disconnected"
@click:button="router.push({ name: PROMOTIONS_SETTINGS_VIEW })"
/>
<N8nEmptyState
v-else-if="state === 'connectionFailed' || state === 'error'"
:class="$style.state"
:icon="{ type: 'icon', value: 'circle-alert' }"
:heading="
i18n.baseText(
state === 'connectionFailed'
? 'promotions.preview.state.connectionFailed'
: 'promotions.preview.state.error',
)
"
:button-text="i18n.baseText('promotions.preview.state.retry')"
data-test-id="promotion-preview-error"
@click:button="refresh"
/>
<N8nEmptyState
v-else-if="state === 'empty'"
:class="$style.state"
:icon="{ type: 'icon', value: 'check' }"
:heading="i18n.baseText(`promotions.preview.state.empty.${direction}.title`)"
:description="i18n.baseText('promotions.preview.state.empty.description')"
data-test-id="promotion-preview-empty"
/>
<div v-else-if="graph" ref="panes" :class="$style.panes">
<N8nResizeWrapper
:class="$style.tree"
:style="{ width: `${treeResizer.width.value}px` }"
:resizer="treeResizer"
:supported-directions="['right']"
>
<PromotionResourceTree
:tree="tree"
:graph="graph"
:selected="selection.selected.value"
:inclusion="selection.inclusion.value"
:included-changes="includedChanges"
:total-changes="totalChanges"
:inspected-key="inspectedKey"
@toggle="selection.toggle"
@toggle-container="selection.toggleAllInside"
@select-all="selection.selectAll"
@clear="selection.clear"
@inspect="inspect"
/>
</N8nResizeWrapper>
<section :class="$style.cards">
<N8nCallout v-if="refreshFailed" theme="warning" slim>
{{ i18n.baseText('promotions.preview.refreshFailed') }}
</N8nCallout>
<N8nCallout v-if="selection.dropped.value.length > 0" theme="info" slim>
{{
i18n.baseText('promotions.preview.dropped', {
adjustToNumber: selection.dropped.value.length,
interpolate: { count: String(selection.dropped.value.length) },
})
}}
</N8nCallout>
<PromotionChangeCards
ref="cards"
:order="order"
:graph="graph"
:direction="direction"
:inclusion="selection.inclusion.value"
:entries="details.entries.value"
:inspected-key="inspectedKey"
@retry="details.retry"
@refresh="refresh"
@inspect="inspect"
/>
</section>
</div>
</div>
</template>
<style module lang="scss">
.page {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 0;
}
.header {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing--2xs);
/* The same inset as the tree rows and the card panel, so the tab divider lines up with both. */
padding: var(--spacing--sm) var(--spacing--sm) 0;
background-color: var(--background--surface);
}
.title {
margin-bottom: var(--spacing--2xs);
}
.back {
display: inline-flex;
align-items: center;
gap: var(--spacing--4xs);
}
.tabRow {
/* N8nTabs draws its active indicator this far below its own box. Reserving the space puts the
indicator on the divider, as in the inbox. No token matches this value. */
--promotion-preview--tab-overhang: 14px;
display: flex;
align-items: flex-end;
padding-bottom: var(--promotion-preview--tab-overhang);
justify-content: space-between;
gap: var(--spacing--sm);
width: 100%;
border-bottom: var(--border);
}
.tabs {
flex: 1;
min-width: 0;
}
.meta {
flex-shrink: 0;
/* Centers the group on the tab label, which sits half the overhang below the tab box. */
margin-bottom: calc(var(--promotion-preview--tab-overhang) / -2);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing--sm);
}
.details {
display: flex;
flex-wrap: wrap;
align-items: center;
column-gap: var(--spacing--2xs);
}
.details > * + *::before {
content: '·' / '';
margin-right: var(--spacing--2xs);
}
.state {
/* The empty states draw their own border, so the space goes outside them. */
margin: var(--spacing--sm);
}
.panes {
display: flex;
flex: 1;
min-height: 0;
}
.tree {
flex-shrink: 0;
min-height: 0;
border-right: var(--border);
background-color: var(--background--surface);
}
.cards {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--spacing--xs);
min-width: 0;
min-height: 0;
overflow-y: auto;
padding: var(--spacing--sm);
}
</style>