This commit is contained in:
Kai 2026-10-16 20:37:51 +02:00 committed by GitHub
commit bb75c66b08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 436 additions and 37 deletions

View File

@ -33,4 +33,6 @@ export type SlotWorkflowDiffProps = {
sourceLabel?: string;
targetLabel?: string;
showFullscreenButton?: boolean;
canvasIdPrefix?: string;
hideTitle?: boolean;
};

View File

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

View File

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

View File

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

View File

@ -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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: [] }) },

View File

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

View File

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

View File

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

View File

@ -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)