mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
Merge 95f5a39d71 into ec074519d9
This commit is contained in:
commit
bb75c66b08
|
|
@ -33,4 +33,6 @@ export type SlotWorkflowDiffProps = {
|
|||
sourceLabel?: string;
|
||||
targetLabel?: string;
|
||||
showFullscreenButton?: boolean;
|
||||
canvasIdPrefix?: string;
|
||||
hideTitle?: boolean;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6191,7 +6191,7 @@
|
|||
"workflowReviews.detail.metadata.otherAuthors": "Other authors",
|
||||
"workflowReviews.detail.metadata.reviewers": "Reviewers",
|
||||
"workflowReviews.detail.metadata.noReviewers": "No reviewers assigned.",
|
||||
"workflowReviews.detail.metadata.workflow": "Workflow",
|
||||
"workflowReviews.detail.metadata.workflow": "Workflow | Workflows",
|
||||
"workflowReviews.changes.workflowUnavailable": "The workflow in this review is no longer available.",
|
||||
"workflowReviews.changes.unavailable": "Couldn't load the changes for this review. Refresh to try again.",
|
||||
"workflowReviews.changes.versionUnavailable.body": "The submitted workflow version is no longer available.",
|
||||
|
|
@ -7283,6 +7283,7 @@
|
|||
"whatsNew.updateAvailable": "You're currently on version {currentVersion}. Update to {latestVersion} to get {count} versions worth of new features, improvements, and fixes. See what changed",
|
||||
"whatsNew.updateAvailable.changelogLink": "in the full changelog",
|
||||
"workflowDiff.changes": "Changes",
|
||||
"workflowDiff.syncViews": "Sync views",
|
||||
"workflowDiff.nodes": "Nodes",
|
||||
"workflowDiff.connectors": "Connectors",
|
||||
"workflowDiff.compare": "Compare versions",
|
||||
|
|
|
|||
|
|
@ -115,6 +115,15 @@ describe('WorkflowDiffContent', () => {
|
|||
expect(screen.getByTestId('synced-canvas-bottom')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds the prefix to the canvas ids', () => {
|
||||
render(WorkflowDiffContent, {
|
||||
props: { ...defaultProps, canvasIdPrefix: 'wf-1' },
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('synced-canvas-wf-1-top')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('synced-canvas-wf-1-bottom')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display source label', () => {
|
||||
render(WorkflowDiffContent, {
|
||||
props: { ...defaultProps, sourceLabel: 'Remote' },
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import NodeDiff from './NodeDiff.vue';
|
|||
import HighlightedEdge from './HighlightedEdge.vue';
|
||||
import Node from '@/features/workflows/canvas/components/elements/nodes/CanvasNode.vue';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useCssModule } from 'vue';
|
||||
import { computed, useCssModule } from 'vue';
|
||||
import { N8nHeading, N8nText } from '@n8n/design-system';
|
||||
import { NodeDiffStatus } from 'n8n-workflow';
|
||||
|
||||
|
|
@ -29,6 +29,8 @@ const props = defineProps<{
|
|||
applyLayout?: boolean;
|
||||
nodesDiff: Map<string, { status: NodeDiffStatus; node: INodeUi }>;
|
||||
connectionsDiff: Map<string, { status: NodeDiffStatus; connection: unknown }>;
|
||||
/** Prefix for the Vue Flow store ids. Set it when several diffs can be mounted at once. */
|
||||
canvasIdPrefix?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -38,6 +40,14 @@ const emit = defineEmits<{
|
|||
const i18n = useI18n();
|
||||
const $style = useCssModule();
|
||||
|
||||
// Vue Flow shares one store for each id across the page.
|
||||
const sourceCanvasId = computed(() =>
|
||||
props.canvasIdPrefix ? `${props.canvasIdPrefix}-top` : 'top',
|
||||
);
|
||||
const targetCanvasId = computed(() =>
|
||||
props.canvasIdPrefix ? `${props.canvasIdPrefix}-bottom` : 'bottom',
|
||||
);
|
||||
|
||||
function getNodeStatusClass(id: string) {
|
||||
const status = props.nodesDiff?.get(id)?.status ?? 'equal';
|
||||
return $style[status];
|
||||
|
|
@ -61,7 +71,7 @@ function getEdgeStatusClass(id: string) {
|
|||
</slot>
|
||||
<template v-if="sourceExists">
|
||||
<SyncedWorkflowCanvas
|
||||
id="top"
|
||||
:id="sourceCanvasId"
|
||||
:nodes="sourceNodes"
|
||||
:connections="sourceConnections"
|
||||
:render-data="sourceRenderData"
|
||||
|
|
@ -104,7 +114,7 @@ function getEdgeStatusClass(id: string) {
|
|||
</slot>
|
||||
<template v-if="targetExists">
|
||||
<SyncedWorkflowCanvas
|
||||
id="bottom"
|
||||
:id="targetCanvasId"
|
||||
:nodes="targetNodes"
|
||||
:connections="targetConnections"
|
||||
:render-data="targetRenderData"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ type DiffProps = {
|
|||
sourceLabel?: string;
|
||||
targetLabel?: string;
|
||||
showFullscreenButton?: boolean;
|
||||
canvasIdPrefix?: string;
|
||||
hideTitle?: boolean;
|
||||
};
|
||||
const { captured } = vi.hoisted(() => ({ captured: [] as DiffProps[] }));
|
||||
vi.mock('./WorkflowDiffView.vue', () => ({
|
||||
|
|
@ -22,6 +24,8 @@ vi.mock('./WorkflowDiffView.vue', () => ({
|
|||
'sourceLabel',
|
||||
'targetLabel',
|
||||
'showFullscreenButton',
|
||||
'canvasIdPrefix',
|
||||
'hideTitle',
|
||||
],
|
||||
setup(props) {
|
||||
captured.push(props as DiffProps);
|
||||
|
|
@ -89,6 +93,8 @@ it('preserves labels, first-publish text, and the fullscreen control', () => {
|
|||
sourceLabel: 'Published',
|
||||
targetLabel: 'In review',
|
||||
showFullscreenButton: true,
|
||||
canvasIdPrefix: 'workflow',
|
||||
hideTitle: true,
|
||||
},
|
||||
slots: {
|
||||
sourceLabel: '<span>Previous version</span>',
|
||||
|
|
@ -101,6 +107,8 @@ it('preserves labels, first-publish text, and the fullscreen control', () => {
|
|||
sourceLabel: 'Published',
|
||||
targetLabel: 'In review',
|
||||
showFullscreenButton: true,
|
||||
canvasIdPrefix: 'workflow',
|
||||
hideTitle: true,
|
||||
});
|
||||
expect(getByText('Previous version')).toBeInTheDocument();
|
||||
expect(getByText('Proposed version')).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ const targetWorkflow = computed(() => toWorkflow(props.targetSnapshot));
|
|||
:source-label="sourceLabel"
|
||||
:target-label="targetLabel"
|
||||
:show-fullscreen-button="showFullscreenButton"
|
||||
:canvas-id-prefix="canvasIdPrefix"
|
||||
:hide-title="hideTitle"
|
||||
>
|
||||
<template v-if="$slots.sourceLabel" #sourceLabel><slot name="sourceLabel" /></template>
|
||||
<template v-if="$slots.targetLabel" #targetLabel><slot name="targetLabel" /></template>
|
||||
|
|
|
|||
|
|
@ -353,6 +353,18 @@ describe('WorkflowDiffView', () => {
|
|||
expect(getByTestId('workflow-diff-view').parentElement).not.toBe(baseElement);
|
||||
});
|
||||
|
||||
it('hides the workflow name when hideTitle is set, but shows it in fullscreen', async () => {
|
||||
const { getByTestId, queryByText } = renderView({
|
||||
props: { sourceWorkflow, targetWorkflow, showFullscreenButton: true, hideTitle: true },
|
||||
});
|
||||
|
||||
expect(queryByText('Source Workflow')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(getByTestId('workflow-diff-fullscreen-toggle'));
|
||||
|
||||
expect(queryByText('Source Workflow')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leaves fullscreen on Escape', async () => {
|
||||
const { getByTestId, baseElement } = renderView({
|
||||
props: { sourceWorkflow, targetWorkflow, showFullscreenButton: true },
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ const props = withDefaults(
|
|||
tidyUp?: boolean;
|
||||
showBackButton?: boolean;
|
||||
showFullscreenButton?: boolean;
|
||||
/** Hides the workflow name when the parent already shows it. Fullscreen still shows it. */
|
||||
hideTitle?: boolean;
|
||||
canvasIdPrefix?: string;
|
||||
source?: 'version_history' | 'push_pull_modal' | 'unknown';
|
||||
}>(),
|
||||
{
|
||||
|
|
@ -47,6 +50,8 @@ const props = withDefaults(
|
|||
targetLabel: 'After',
|
||||
showBackButton: false,
|
||||
showFullscreenButton: false,
|
||||
hideTitle: false,
|
||||
canvasIdPrefix: undefined,
|
||||
source: 'unknown',
|
||||
},
|
||||
);
|
||||
|
|
@ -164,7 +169,7 @@ const onNodeChangeSelect = (change: { node: INodeUi; status: NodeDiffStatus }) =
|
|||
icon-size="large"
|
||||
@click="emit('back')"
|
||||
/>
|
||||
<N8nHeading tag="h4" size="medium">
|
||||
<N8nHeading v-if="!hideTitle || isFullscreen" tag="h4" size="medium">
|
||||
{{ sourceWorkflow?.name || targetWorkflow?.name }}
|
||||
</N8nHeading>
|
||||
</div>
|
||||
|
|
@ -173,7 +178,7 @@ const onNodeChangeSelect = (change: { node: INodeUi; status: NodeDiffStatus }) =
|
|||
<N8nCheckbox
|
||||
v-model="syncIsEnabled"
|
||||
label-size="small"
|
||||
label="Sync views"
|
||||
:label="i18n.baseText('workflowDiff.syncViews')"
|
||||
class="mb-0 mr-s"
|
||||
/>
|
||||
<ElDropdown
|
||||
|
|
@ -332,6 +337,7 @@ const onNodeChangeSelect = (change: { node: INodeUi; status: NodeDiffStatus }) =
|
|||
:apply-layout="tidyUp"
|
||||
:nodes-diff="nodesDiff"
|
||||
:connections-diff="connectionsDiff"
|
||||
:canvas-id-prefix="canvasIdPrefix"
|
||||
@close-aside="selectedDetailId = undefined"
|
||||
>
|
||||
<template v-if="$slots.sourceLabel" #sourceLabel>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
"@n8n/stores": "workspace:*",
|
||||
"n8n-workflow": "workspace:*",
|
||||
"pinia": "catalog:frontend",
|
||||
"reka-ui": "^2.5.0",
|
||||
"vue": "catalog:frontend",
|
||||
"vue-router": "catalog:frontend",
|
||||
"@n8n/frontend-constants": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
import type {
|
||||
WorkflowReviewRequestWorkflowDetail,
|
||||
WorkflowReviewVersionSnapshot,
|
||||
} from '@n8n/api-types';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createComponentRenderer } from '@n8n/frontend-test-utils';
|
||||
|
||||
import WorkflowReviewChangesList from './WorkflowReviewChangesList.vue';
|
||||
|
||||
vi.mock('./WorkflowReviewChangesSection.vue', () => ({
|
||||
default: {
|
||||
name: 'WorkflowReviewChangesSection',
|
||||
props: ['workflow', 'state', 'decision'],
|
||||
template:
|
||||
'<div data-test-id="workflow-review-changes-section" :data-workflow-id="workflow.workflowId" />',
|
||||
},
|
||||
}));
|
||||
|
||||
const renderComponent = createComponentRenderer(WorkflowReviewChangesList);
|
||||
|
||||
function makeSnapshot(
|
||||
overrides: Partial<WorkflowReviewVersionSnapshot> = {},
|
||||
): WorkflowReviewVersionSnapshot {
|
||||
return {
|
||||
versionId: 'version-1',
|
||||
name: null,
|
||||
nodes: [],
|
||||
connections: {},
|
||||
nodeGroups: [],
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeWorkflowDetail(
|
||||
overrides: Partial<WorkflowReviewRequestWorkflowDetail> = {},
|
||||
): WorkflowReviewRequestWorkflowDetail {
|
||||
return {
|
||||
workflowId: 'wf-1',
|
||||
workflowName: 'My workflow',
|
||||
workflowVersionId: 'version-1',
|
||||
pinnedVersion: makeSnapshot(),
|
||||
publishedVersionId: null,
|
||||
baselineVersion: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('WorkflowReviewChangesList', () => {
|
||||
beforeEach(() => {
|
||||
createTestingPinia();
|
||||
});
|
||||
|
||||
it('opens one workflow at a time and closes it on a second click', async () => {
|
||||
const { getAllByTestId, queryAllByTestId } = renderComponent({
|
||||
props: {
|
||||
workflows: [
|
||||
makeWorkflowDetail(),
|
||||
makeWorkflowDetail({ workflowId: 'wf-2', workflowName: 'Other workflow' }),
|
||||
],
|
||||
state: 'open',
|
||||
decision: 'pending',
|
||||
},
|
||||
});
|
||||
const [firstTrigger, secondTrigger] = getAllByTestId('workflow-review-changes-item-trigger');
|
||||
|
||||
await userEvent.click(firstTrigger);
|
||||
await userEvent.click(secondTrigger);
|
||||
|
||||
const sections = getAllByTestId('workflow-review-changes-section');
|
||||
expect(sections).toHaveLength(1);
|
||||
expect(sections[0]).toHaveAttribute('data-workflow-id', 'wf-2');
|
||||
|
||||
await userEvent.click(secondTrigger);
|
||||
|
||||
expect(queryAllByTestId('workflow-review-changes-section')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shows the version next to the workflow name, and only the name when the version is missing', () => {
|
||||
const { getAllByTestId } = renderComponent({
|
||||
props: {
|
||||
workflows: [
|
||||
makeWorkflowDetail({ pinnedVersion: makeSnapshot({ name: 'Fix retries' }) }),
|
||||
makeWorkflowDetail({ workflowId: 'wf-2', workflowName: 'Pruned', pinnedVersion: null }),
|
||||
],
|
||||
state: 'open',
|
||||
decision: 'pending',
|
||||
},
|
||||
});
|
||||
const [withVersion, withoutVersion] = getAllByTestId('workflow-review-changes-item-trigger');
|
||||
|
||||
expect(withVersion).toHaveTextContent('My workflow');
|
||||
expect(withVersion).toHaveTextContent('Fix retries');
|
||||
expect(withoutVersion).toHaveTextContent(/^Pruned$/);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
<script setup lang="ts">
|
||||
import type {
|
||||
WorkflowReviewRequestDecision,
|
||||
WorkflowReviewRequestState,
|
||||
WorkflowReviewRequestWorkflowDetail,
|
||||
} from '@n8n/api-types';
|
||||
import { N8nIcon, N8nText } from '@n8n/design-system';
|
||||
import {
|
||||
AccordionContent,
|
||||
AccordionHeader,
|
||||
AccordionItem,
|
||||
AccordionRoot,
|
||||
AccordionTrigger,
|
||||
} from 'reka-ui';
|
||||
|
||||
import { getVersionLabel } from '@n8n/composables/useWorkflowVersionLabel';
|
||||
import WorkflowReviewChangesSection from './WorkflowReviewChangesSection.vue';
|
||||
|
||||
defineProps<{
|
||||
workflows: WorkflowReviewRequestWorkflowDetail[];
|
||||
state: WorkflowReviewRequestState;
|
||||
decision: WorkflowReviewRequestDecision;
|
||||
}>();
|
||||
|
||||
/** The open workflow's id. The parent owns it, so it survives a tab switch. */
|
||||
const expanded = defineModel<string | undefined>('expanded');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Closed items unmount, so only the open workflow pays for its diff canvases. -->
|
||||
<AccordionRoot v-model="expanded" type="single" collapsible unmount-on-hide :class="$style.list">
|
||||
<AccordionItem
|
||||
v-for="workflow in workflows"
|
||||
:key="workflow.workflowId"
|
||||
:value="workflow.workflowId"
|
||||
:class="$style.item"
|
||||
data-test-id="workflow-review-changes-item"
|
||||
>
|
||||
<AccordionHeader :class="$style.header">
|
||||
<AccordionTrigger
|
||||
:class="$style.trigger"
|
||||
data-test-id="workflow-review-changes-item-trigger"
|
||||
>
|
||||
<N8nIcon icon="chevron-down" size="small" :class="$style.chevron" />
|
||||
<N8nText bold size="medium" color="text-dark" :class="$style.name">
|
||||
{{ workflow.workflowName }}
|
||||
</N8nText>
|
||||
<N8nText
|
||||
v-if="workflow.pinnedVersion"
|
||||
size="small"
|
||||
color="text-light"
|
||||
:class="$style.version"
|
||||
data-test-id="workflow-review-changes-item-version"
|
||||
>
|
||||
{{ getVersionLabel({ workflowHistory: workflow.pinnedVersion }) }}
|
||||
</N8nText>
|
||||
</AccordionTrigger>
|
||||
</AccordionHeader>
|
||||
<AccordionContent :class="$style.content">
|
||||
<WorkflowReviewChangesSection :workflow="workflow" :state="state" :decision="decision" />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</AccordionRoot>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
/* A definite height, so the diff canvas can size itself with percentages. */
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
border: var(--border);
|
||||
border-radius: var(--radius--2xs);
|
||||
overflow: hidden;
|
||||
|
||||
/* Only a diff takes the free height. A callout keeps its natural size. */
|
||||
&[data-state='open']:has([data-review-diff]) {
|
||||
flex: 1 0 auto;
|
||||
min-height: var(--review-diff--min-height, 28rem);
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
width: 100%;
|
||||
padding: var(--spacing--xs) var(--spacing--sm);
|
||||
border: none;
|
||||
background: var(--color--background--light-3);
|
||||
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));
|
||||
}
|
||||
|
||||
&[data-state='open'] {
|
||||
border-bottom: var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--color--text--tint-1);
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
[data-state='closed'] > & {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.name,
|
||||
.version {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.version {
|
||||
flex-shrink: 0;
|
||||
max-width: 40%;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -20,15 +20,17 @@ const { diffViewProps } = vi.hoisted(() => ({
|
|||
|
||||
const DiffView = defineComponent({
|
||||
name: 'WorkflowDiffView',
|
||||
props: [
|
||||
'sourceSnapshot',
|
||||
'targetSnapshot',
|
||||
'workflowId',
|
||||
'workflowName',
|
||||
'sourceLabel',
|
||||
'targetLabel',
|
||||
'showFullscreenButton',
|
||||
],
|
||||
props: {
|
||||
sourceSnapshot: null,
|
||||
targetSnapshot: null,
|
||||
workflowId: String,
|
||||
workflowName: String,
|
||||
sourceLabel: String,
|
||||
targetLabel: String,
|
||||
showFullscreenButton: Boolean,
|
||||
canvasIdPrefix: String,
|
||||
hideTitle: Boolean,
|
||||
},
|
||||
setup(props) {
|
||||
diffViewProps.push(props as SlotWorkflowDiffProps);
|
||||
},
|
||||
|
|
@ -227,6 +229,8 @@ describe('WorkflowReviewChangesSection', () => {
|
|||
|
||||
expect(diffViewProps[0].workflowId).toBe('wf-1');
|
||||
expect(diffViewProps[0].workflowName).toBe('Payment handler');
|
||||
expect(diffViewProps[0].canvasIdPrefix).toBe('wf-1');
|
||||
expect(diffViewProps[0].hideTitle).toBe(true);
|
||||
expect(sourceSnapshot?.versionId).toBe('0f123890-0000-0000-0000-000000000000');
|
||||
expect(sourceSnapshot?.nodes.map((node) => node.id)).toEqual(['node-removed', 'node-1']);
|
||||
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ const targetLabel = computed(() =>
|
|||
>
|
||||
{{ noChangesText }}
|
||||
</N8nCallout>
|
||||
<div v-else :class="$style.diff" data-test-id="workflow-review-changes-diff">
|
||||
<div v-else :class="$style.diff" data-review-diff data-test-id="workflow-review-changes-diff">
|
||||
<component
|
||||
:is="WorkflowDiffView"
|
||||
:source-snapshot="workflow.baselineVersion ?? undefined"
|
||||
|
|
@ -138,7 +138,9 @@ const targetLabel = computed(() =>
|
|||
:target-snapshot="workflow.pinnedVersion ?? undefined"
|
||||
:source-label="sourceLabel"
|
||||
:target-label="targetLabel"
|
||||
:canvas-id-prefix="workflow.workflowId"
|
||||
show-fullscreen-button
|
||||
hide-title
|
||||
>
|
||||
<!-- Only when a baseline exists: with no prior published version there is
|
||||
no publish status to represent. -->
|
||||
|
|
@ -164,7 +166,7 @@ const targetLabel = computed(() =>
|
|||
<style module lang="scss">
|
||||
.callout {
|
||||
max-width: var(--review-callout--max-width, 34rem);
|
||||
margin-top: var(--spacing--5xs);
|
||||
margin: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.versionBadge {
|
||||
|
|
@ -209,11 +211,7 @@ const targetLabel = computed(() =>
|
|||
}
|
||||
|
||||
.diff {
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: var(--border);
|
||||
border-radius: var(--radius--2xs);
|
||||
overflow: hidden;
|
||||
margin-top: var(--spacing--5xs);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -193,6 +193,21 @@ describe('WorkflowReviewDetailMetadata', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('labels the workflows card in singular or plural to match the count', () => {
|
||||
const single = renderComponent({ props: { review: makeDetail() } });
|
||||
expect(single.getByText('Workflow')).toBeInTheDocument();
|
||||
single.unmount();
|
||||
|
||||
const several = renderComponent({
|
||||
props: {
|
||||
review: makeDetail({
|
||||
workflows: [makeWorkflowDetail(), makeWorkflowDetail({ workflowId: 'wf-2' })],
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(several.getByText('Workflows')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the changes card when there are no workflows', () => {
|
||||
const { getByTestId, queryByTestId } = renderComponent({
|
||||
props: { review: makeDetail({ reviewers: [], workflows: [] }) },
|
||||
|
|
|
|||
|
|
@ -117,7 +117,11 @@ const statusSummary = computed(() =>
|
|||
>
|
||||
<template #header>
|
||||
<N8nText bold color="text-light" size="medium">
|
||||
{{ i18n.baseText('workflowReviews.detail.metadata.workflow') }}
|
||||
{{
|
||||
i18n.baseText('workflowReviews.detail.metadata.workflow', {
|
||||
adjustToNumber: detail.workflows.length,
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
</template>
|
||||
<div :class="$style.workflows">
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
} from '@n8n/api-types';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { createComponentRenderer } from '@n8n/frontend-test-utils';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import WorkflowReviewDetailTabs from './WorkflowReviewDetailTabs.vue';
|
||||
|
||||
|
|
@ -13,7 +14,7 @@ vi.mock('./WorkflowReviewChangesSection.vue', () => ({
|
|||
name: 'WorkflowReviewChangesSection',
|
||||
props: ['workflow', 'state', 'decision'],
|
||||
template:
|
||||
'<div data-test-id="workflow-review-changes-section" :data-state="state" :data-decision="decision" />',
|
||||
'<div data-test-id="workflow-review-changes-section" :data-workflow-id="workflow.workflowId" :data-state="state" :data-decision="decision" />',
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -293,7 +294,7 @@ describe('WorkflowReviewDetailTabs', () => {
|
|||
});
|
||||
|
||||
describe('changes tab', () => {
|
||||
it('renders one section per workflow', () => {
|
||||
it('renders one item per workflow and opens only the first', () => {
|
||||
const { getAllByTestId } = renderComponent({
|
||||
props: {
|
||||
review: makeDetail({
|
||||
|
|
@ -307,7 +308,72 @@ describe('WorkflowReviewDetailTabs', () => {
|
|||
},
|
||||
});
|
||||
|
||||
expect(getAllByTestId('workflow-review-changes-section')).toHaveLength(2);
|
||||
expect(getAllByTestId('workflow-review-changes-item')).toHaveLength(2);
|
||||
expect(getAllByTestId('workflow-review-changes-section')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps the open workflow when the user switches tabs', async () => {
|
||||
const review = makeDetail({
|
||||
workflows: [makeWorkflowDetail(), makeWorkflowDetail({ workflowId: 'wf-2' })],
|
||||
});
|
||||
const { getAllByTestId, getByTestId, rerender } = renderComponent({
|
||||
props: { review, tab: 'changes', deciding: false },
|
||||
});
|
||||
|
||||
await userEvent.click(getAllByTestId('workflow-review-changes-item-trigger')[1]);
|
||||
await rerender({ review, tab: 'activity', deciding: false });
|
||||
await rerender({ review, tab: 'changes', deciding: false });
|
||||
|
||||
expect(getByTestId('workflow-review-changes-section')).toHaveAttribute(
|
||||
'data-workflow-id',
|
||||
'wf-2',
|
||||
);
|
||||
});
|
||||
|
||||
it('opens the first workflow again when another review is selected', async () => {
|
||||
const workflows = [makeWorkflowDetail(), makeWorkflowDetail({ workflowId: 'wf-2' })];
|
||||
const { getAllByTestId, getByTestId, rerender } = renderComponent({
|
||||
props: { review: makeDetail({ workflows }), tab: 'changes', deciding: false },
|
||||
});
|
||||
|
||||
await userEvent.click(getAllByTestId('workflow-review-changes-item-trigger')[1]);
|
||||
await rerender({
|
||||
review: makeDetail({ id: 'req-2', workflows }),
|
||||
tab: 'changes',
|
||||
deciding: false,
|
||||
});
|
||||
|
||||
expect(getByTestId('workflow-review-changes-section')).toHaveAttribute(
|
||||
'data-workflow-id',
|
||||
'wf-1',
|
||||
);
|
||||
});
|
||||
|
||||
// The detail lists only the workflows the viewer can read, so a refresh can drop one.
|
||||
it('opens the first workflow when a refresh removes the open one', async () => {
|
||||
const first = makeWorkflowDetail();
|
||||
const third = makeWorkflowDetail({ workflowId: 'wf-3' });
|
||||
const { getAllByTestId, findByTestId, rerender } = renderComponent({
|
||||
props: {
|
||||
review: makeDetail({
|
||||
workflows: [first, makeWorkflowDetail({ workflowId: 'wf-2' }), third],
|
||||
}),
|
||||
tab: 'changes',
|
||||
deciding: false,
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.click(getAllByTestId('workflow-review-changes-item-trigger')[1]);
|
||||
await rerender({
|
||||
review: makeDetail({ workflows: [first, third] }),
|
||||
tab: 'changes',
|
||||
deciding: false,
|
||||
});
|
||||
|
||||
expect(await findByTestId('workflow-review-changes-section')).toHaveAttribute(
|
||||
'data-workflow-id',
|
||||
'wf-1',
|
||||
);
|
||||
});
|
||||
|
||||
// A closed review keeps its diff: the backend serves the baseline frozen at
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
import type { WorkflowReviewInboxItem, WorkflowReviewRequestDetail } from '@n8n/api-types';
|
||||
import { N8nCallout, N8nTabs, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { computed, provide, useTemplateRef } from 'vue';
|
||||
import { computed, provide, ref, useTemplateRef, watch } from 'vue';
|
||||
|
||||
import { ReviewDetailScrollContainerKey, ReviewLinkedWorkflowsKey } from '../constants';
|
||||
import type { WorkflowReviewDecisionInput } from '../workflowReviews.api';
|
||||
import WorkflowReviewActivityFeed from './WorkflowReviewActivityFeed.vue';
|
||||
import WorkflowReviewChangesSection from './WorkflowReviewChangesSection.vue';
|
||||
import WorkflowReviewChangesList from './WorkflowReviewChangesList.vue';
|
||||
import WorkflowReviewCommentComposer from './WorkflowReviewCommentComposer.vue';
|
||||
import WorkflowReviewDecisionPopover from './WorkflowReviewDecisionPopover.vue';
|
||||
import WorkflowReviewDetailMetadata from './WorkflowReviewDetailMetadata.vue';
|
||||
|
|
@ -33,6 +33,26 @@ const detail = computed<WorkflowReviewRequestDetail | null>(() =>
|
|||
'workflows' in props.review ? props.review : null,
|
||||
);
|
||||
|
||||
// Held here, not in the list: the detail body remounts on every tab switch.
|
||||
const expandedWorkflowId = ref<string>();
|
||||
// The review that `expandedWorkflowId` belongs to.
|
||||
let expandedReviewId: string | undefined;
|
||||
|
||||
// Open the first workflow for each new review. A refetch keeps the choice while the
|
||||
// open workflow is still listed. The detail lists only readable workflows, so it can drop one.
|
||||
watch(
|
||||
detail,
|
||||
(value) => {
|
||||
if (!value?.workflows.length) return;
|
||||
const open = expandedWorkflowId.value;
|
||||
const isListed = value.workflows.some(({ workflowId }) => workflowId === open);
|
||||
if (value.id === expandedReviewId && (open === undefined || isListed)) return;
|
||||
expandedReviewId = value.id;
|
||||
expandedWorkflowId.value = value.workflows[0].workflowId;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const viewerCanDecide = computed(() => detail.value?.viewerCanDecide ?? false);
|
||||
const viewerCanComment = computed(() => detail.value?.viewerCanComment ?? false);
|
||||
|
||||
|
|
@ -190,15 +210,13 @@ const tabOptions = computed(() => [
|
|||
>
|
||||
{{ i18n.baseText('workflowReviews.changes.unavailable') }}
|
||||
</N8nCallout>
|
||||
<template v-else-if="detail.workflows.length > 0">
|
||||
<WorkflowReviewChangesSection
|
||||
v-for="workflow in detail.workflows"
|
||||
:key="workflow.workflowId"
|
||||
:workflow="workflow"
|
||||
:state="detail.state"
|
||||
:decision="detail.decision"
|
||||
/>
|
||||
</template>
|
||||
<WorkflowReviewChangesList
|
||||
v-else-if="detail.workflows.length > 0"
|
||||
v-model:expanded="expandedWorkflowId"
|
||||
:workflows="detail.workflows"
|
||||
:state="detail.state"
|
||||
:decision="detail.decision"
|
||||
/>
|
||||
<!-- No rows left: the workflow was deleted, or the requester lost access to it. -->
|
||||
<N8nCallout
|
||||
v-else
|
||||
|
|
|
|||
|
|
@ -7077,6 +7077,9 @@ importers:
|
|||
pinia:
|
||||
specifier: catalog:frontend
|
||||
version: 2.2.4(typescript@6.0.2)(vue@3.5.26(typescript@6.0.2))
|
||||
reka-ui:
|
||||
specifier: ^2.5.0
|
||||
version: 2.5.0(patch_hash=f1cec654cb01a5751dfc1de06604bb22f9518860ea9c72fb685e770509923a2f)(typescript@6.0.2)(vue@3.5.26(typescript@6.0.2))
|
||||
vue:
|
||||
specifier: catalog:frontend
|
||||
version: 3.5.26(typescript@6.0.2)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user