mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-21 20:20:03 +02:00
feat(editor): Add promotion and apply preview pages with a resource tree and change cards (no-changelog) (#41333)
This commit is contained in:
parent
c69581aedf
commit
f830c2b113
|
|
@ -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…",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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']]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue
Block a user