@n8n/frontend-module-inbox 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE.md +88 -0
  2. package/LICENSE_EE.md +27 -0
  3. package/README.md +61 -2
  4. package/biome.jsonc +4 -0
  5. package/eslint.config.mjs +4 -0
  6. package/package.json +72 -3
  7. package/src/__tests__/design-system-icons.test.ts +24 -0
  8. package/src/__tests__/setup.ts +4 -0
  9. package/src/components/InboxEmptyState.vue +86 -0
  10. package/src/components/InboxList.test.ts +289 -0
  11. package/src/components/InboxList.vue +195 -0
  12. package/src/components/InboxListItem.vue +208 -0
  13. package/src/components/InboxListSection.vue +168 -0
  14. package/src/composables/useReviewInboxSectionCollapse.test.ts +66 -0
  15. package/src/composables/useReviewInboxSectionCollapse.ts +42 -0
  16. package/src/inbox.api.ts +20 -0
  17. package/src/inbox.constants.ts +19 -0
  18. package/src/inbox.module.ts +55 -0
  19. package/src/inbox.routes.test.ts +140 -0
  20. package/src/inbox.routes.ts +49 -0
  21. package/src/inbox.store.test.ts +444 -0
  22. package/src/inbox.store.ts +305 -0
  23. package/src/index.ts +5 -0
  24. package/src/reviews/WorkflowReviewDetail.test.ts +647 -0
  25. package/src/reviews/WorkflowReviewDetail.vue +257 -0
  26. package/src/reviews/components/WorkflowReviewActivityFeed.test.ts +923 -0
  27. package/src/reviews/components/WorkflowReviewActivityFeed.vue +312 -0
  28. package/src/reviews/components/WorkflowReviewChangesList.test.ts +97 -0
  29. package/src/reviews/components/WorkflowReviewChangesList.vue +143 -0
  30. package/src/reviews/components/WorkflowReviewChangesSection.test.ts +331 -0
  31. package/src/reviews/components/WorkflowReviewChangesSection.vue +217 -0
  32. package/src/reviews/components/WorkflowReviewCommentComposer.test.ts +175 -0
  33. package/src/reviews/components/WorkflowReviewCommentComposer.vue +83 -0
  34. package/src/reviews/components/WorkflowReviewDecisionPopover.test.ts +288 -0
  35. package/src/reviews/components/WorkflowReviewDecisionPopover.vue +203 -0
  36. package/src/reviews/components/WorkflowReviewDetailMetadata.test.ts +206 -0
  37. package/src/reviews/components/WorkflowReviewDetailMetadata.vue +245 -0
  38. package/src/reviews/components/WorkflowReviewDetailTabs.decisionNote.test.ts +90 -0
  39. package/src/reviews/components/WorkflowReviewDetailTabs.test.ts +500 -0
  40. package/src/reviews/components/WorkflowReviewDetailTabs.vue +329 -0
  41. package/src/reviews/components/WorkflowReviewStatusDot.vue +67 -0
  42. package/src/reviews/components/_activity-card.scss +36 -0
  43. package/src/reviews/components/activity-entries/WorkflowReviewActivityActorAvatar.vue +50 -0
  44. package/src/reviews/components/activity-entries/WorkflowReviewActivityComment.test.ts +103 -0
  45. package/src/reviews/components/activity-entries/WorkflowReviewActivityComment.vue +116 -0
  46. package/src/reviews/components/activity-entries/WorkflowReviewActivityEventEntry.vue +361 -0
  47. package/src/reviews/components/activity-entries/WorkflowReviewActivityFallback.vue +49 -0
  48. package/src/reviews/components/activity-entries/WorkflowReviewActivityWorkflowLink.vue +36 -0
  49. package/src/reviews/components/activityEntryRegistry.ts +27 -0
  50. package/src/reviews/constants.ts +41 -0
  51. package/src/reviews/reviewActivity.store.test.ts +463 -0
  52. package/src/reviews/reviewActivity.store.ts +186 -0
  53. package/src/reviews/reviewDetail.store.test.ts +135 -0
  54. package/src/reviews/reviewDetail.store.ts +74 -0
  55. package/src/reviews/workflowReviewStatus.utils.ts +44 -0
  56. package/src/reviews/workflowReviews.api.ts +61 -0
  57. package/src/reviews/workflowReviews.utils.ts +18 -0
  58. package/src/self-healing/SelfHealingResultDetail.vue +19 -0
  59. package/src/views/InboxView.test.ts +568 -0
  60. package/src/views/InboxView.vue +260 -0
  61. package/stylelint.config.mjs +9 -0
  62. package/vite.config.ts +52 -0
@@ -0,0 +1,331 @@
1
+ import type {
2
+ WorkflowReviewRequestDecision,
3
+ WorkflowReviewRequestState,
4
+ WorkflowReviewRequestWorkflowDetail,
5
+ WorkflowReviewVersionSnapshot,
6
+ } from '@n8n/api-types';
7
+ import { createTestingPinia } from '@pinia/testing';
8
+ import type { INode } from 'n8n-workflow';
9
+ import { defineComponent } from 'vue';
10
+ import { createComponentRenderer } from '@n8n/frontend-test-utils';
11
+ import { componentRegistry, type SlotWorkflowDiffProps } from '@n8n/frontend-module-sdk';
12
+
13
+ import WorkflowReviewChangesSection from './WorkflowReviewChangesSection.vue';
14
+
15
+ type DiffViewProps = SlotWorkflowDiffProps;
16
+
17
+ const { diffViewProps } = vi.hoisted(() => ({
18
+ diffViewProps: [] as DiffViewProps[],
19
+ }));
20
+
21
+ const DiffView = defineComponent({
22
+ name: 'WorkflowDiffView',
23
+ props: {
24
+ sourceSnapshot: null,
25
+ targetSnapshot: null,
26
+ workflowId: String,
27
+ workflowName: String,
28
+ sourceLabel: String,
29
+ targetLabel: String,
30
+ showFullscreenButton: Boolean,
31
+ canvasIdPrefix: String,
32
+ hideTitle: Boolean,
33
+ },
34
+ setup(props) {
35
+ diffViewProps.push(props as SlotWorkflowDiffProps);
36
+ },
37
+ template:
38
+ '<div data-test-id="workflow-diff-view-stub"><slot name="sourceLabel" /><slot name="targetLabel" /><slot name="sourceEmptyText" /></div>',
39
+ });
40
+ beforeEach(() => {
41
+ componentRegistry.clear();
42
+ componentRegistry.register('workflow-diff', DiffView);
43
+ });
44
+ afterEach(() => componentRegistry.clear());
45
+
46
+ const renderSection = createComponentRenderer(WorkflowReviewChangesSection);
47
+
48
+ /** Defaults to an open review; closed cases pass `state` and `decision` themselves. */
49
+ const renderComponent = (options: {
50
+ props: {
51
+ workflow: WorkflowReviewRequestWorkflowDetail;
52
+ state?: WorkflowReviewRequestState;
53
+ decision?: WorkflowReviewRequestDecision;
54
+ };
55
+ }) => renderSection({ props: { state: 'open', decision: 'pending', ...options.props } });
56
+
57
+ function makeNode(overrides: Partial<INode> = {}): INode {
58
+ return {
59
+ id: 'node-1',
60
+ name: 'Node 1',
61
+ type: 'n8n-nodes-base.noOp',
62
+ typeVersion: 1,
63
+ position: [0, 0],
64
+ parameters: {},
65
+ ...overrides,
66
+ };
67
+ }
68
+
69
+ function makeSnapshot(
70
+ overrides: Partial<WorkflowReviewVersionSnapshot> = {},
71
+ ): WorkflowReviewVersionSnapshot {
72
+ return {
73
+ versionId: '77b70644-0000-0000-0000-000000000000',
74
+ name: null,
75
+ nodes: [makeNode()],
76
+ connections: {},
77
+ nodeGroups: [],
78
+ createdAt: '2024-01-01T00:00:00.000Z',
79
+ ...overrides,
80
+ };
81
+ }
82
+
83
+ function makeWorkflow(
84
+ overrides: Partial<WorkflowReviewRequestWorkflowDetail> = {},
85
+ ): WorkflowReviewRequestWorkflowDetail {
86
+ return {
87
+ workflowId: 'wf-1',
88
+ workflowName: 'Payment handler',
89
+ workflowVersionId: '77b70644-0000-0000-0000-000000000000',
90
+ pinnedVersion: makeSnapshot(),
91
+ publishedVersionId: null,
92
+ baselineVersion: makeSnapshot({
93
+ versionId: '0f123890-0000-0000-0000-000000000000',
94
+ nodes: [makeNode({ id: 'node-removed', name: 'Removed node' }), makeNode()],
95
+ }),
96
+ ...overrides,
97
+ };
98
+ }
99
+
100
+ describe('WorkflowReviewChangesSection', () => {
101
+ beforeEach(() => {
102
+ createTestingPinia();
103
+ diffViewProps.length = 0;
104
+ });
105
+
106
+ it('labels both diff sides with a version status', () => {
107
+ const { getByTestId } = renderComponent({ props: { workflow: makeWorkflow() } });
108
+
109
+ expect(getByTestId('workflow-review-changes-source-label')).toHaveTextContent('0f123890');
110
+ expect(getByTestId('workflow-review-changes-target-label')).toHaveTextContent('77b70644');
111
+ });
112
+
113
+ it('labels a named version by its name instead of its id', () => {
114
+ const { getByTestId } = renderComponent({
115
+ props: {
116
+ workflow: makeWorkflow({
117
+ pinnedVersion: makeSnapshot({ name: 'Version hotpink' }),
118
+ baselineVersion: makeSnapshot({
119
+ versionId: '0f123890-0000-0000-0000-000000000000',
120
+ name: 'Version teal',
121
+ nodes: [makeNode({ id: 'node-removed', name: 'Removed node' }), makeNode()],
122
+ }),
123
+ }),
124
+ },
125
+ });
126
+
127
+ expect(getByTestId('workflow-review-changes-source-label')).toHaveTextContent(
128
+ 'Published: Version teal',
129
+ );
130
+ expect(getByTestId('workflow-review-changes-target-label')).toHaveTextContent(
131
+ 'In review: Version hotpink',
132
+ );
133
+ });
134
+
135
+ // The publish endpoints accept an empty name, so a stored '' must still label.
136
+ it.each([
137
+ ['null', null],
138
+ ['empty', ''],
139
+ ])('falls back to the id-derived label when the name is %s', (_case, name) => {
140
+ const { getByTestId } = renderComponent({
141
+ props: { workflow: makeWorkflow({ pinnedVersion: makeSnapshot({ name }) }) },
142
+ });
143
+
144
+ expect(getByTestId('workflow-review-changes-target-label')).toHaveTextContent(
145
+ 'In review: Version 77b70644',
146
+ );
147
+ });
148
+
149
+ it('shows a no-changes state when versions differ only by name', () => {
150
+ const { getByTestId, queryByTestId } = renderComponent({
151
+ props: {
152
+ workflow: makeWorkflow({
153
+ pinnedVersion: makeSnapshot({ name: 'Version hotpink' }),
154
+ baselineVersion: makeSnapshot({
155
+ versionId: '0f123890-0000-0000-0000-000000000000',
156
+ name: 'Version teal',
157
+ }),
158
+ }),
159
+ },
160
+ });
161
+
162
+ expect(getByTestId('workflow-review-changes-no-changes')).toBeInTheDocument();
163
+ expect(queryByTestId('workflow-diff-view-stub')).not.toBeInTheDocument();
164
+ });
165
+
166
+ it('shows an unavailable state when the pinned version was pruned', () => {
167
+ const { getByTestId, queryByTestId } = renderComponent({
168
+ props: {
169
+ workflow: makeWorkflow({
170
+ workflowVersionId: null,
171
+ pinnedVersion: null,
172
+ }),
173
+ },
174
+ });
175
+
176
+ expect(getByTestId('workflow-review-changes-version-unavailable')).toBeInTheDocument();
177
+ expect(queryByTestId('workflow-diff-view-stub')).not.toBeInTheDocument();
178
+ });
179
+
180
+ it('shows a no-changes state when pinned and baseline are the same version', () => {
181
+ const snapshot = makeSnapshot();
182
+ const { getByTestId, queryByTestId } = renderComponent({
183
+ props: {
184
+ workflow: makeWorkflow({ pinnedVersion: snapshot, baselineVersion: snapshot }),
185
+ },
186
+ });
187
+
188
+ expect(getByTestId('workflow-review-changes-no-changes')).toBeInTheDocument();
189
+ expect(queryByTestId('workflow-diff-view-stub')).not.toBeInTheDocument();
190
+ });
191
+
192
+ it('shows a no-changes state when versions differ only by id', () => {
193
+ const { getByTestId, queryByTestId } = renderComponent({
194
+ props: {
195
+ workflow: makeWorkflow({
196
+ pinnedVersion: makeSnapshot(),
197
+ baselineVersion: makeSnapshot({ versionId: 'other-version-id' }),
198
+ }),
199
+ },
200
+ });
201
+
202
+ expect(getByTestId('workflow-review-changes-no-changes')).toBeInTheDocument();
203
+ expect(queryByTestId('workflow-diff-view-stub')).not.toBeInTheDocument();
204
+ });
205
+
206
+ // First publish: no baseline, so the diff gets no source workflow — and that
207
+ // side carries no publish-status label either, since nothing was published.
208
+ it('renders a sourceless diff with no source label for a first publish', () => {
209
+ const { getByTestId, queryByTestId } = renderComponent({
210
+ props: {
211
+ workflow: makeWorkflow({ baselineVersion: null }),
212
+ },
213
+ });
214
+
215
+ expect(getByTestId('workflow-diff-view-stub')).toBeInTheDocument();
216
+ expect(diffViewProps).toHaveLength(1);
217
+ expect(diffViewProps[0].sourceSnapshot).toBeUndefined();
218
+ expect(diffViewProps[0].workflowId).toBe('wf-1');
219
+ expect(queryByTestId('workflow-review-changes-source-label')).not.toBeInTheDocument();
220
+ expect(getByTestId('workflow-review-changes-target-label')).toBeInTheDocument();
221
+ });
222
+
223
+ it('passes workflow identity, snapshots, and labels to the editor renderer', () => {
224
+ const workflow = makeWorkflow();
225
+ renderComponent({ props: { workflow } });
226
+
227
+ expect(diffViewProps).toHaveLength(1);
228
+ const { sourceSnapshot, targetSnapshot, sourceLabel, targetLabel } = diffViewProps[0];
229
+
230
+ expect(diffViewProps[0].workflowId).toBe('wf-1');
231
+ expect(diffViewProps[0].workflowName).toBe('Payment handler');
232
+ expect(diffViewProps[0].canvasIdPrefix).toBe('wf-1');
233
+ expect(diffViewProps[0].hideTitle).toBe(true);
234
+ expect(sourceSnapshot?.versionId).toBe('0f123890-0000-0000-0000-000000000000');
235
+ expect(sourceSnapshot?.nodes.map((node) => node.id)).toEqual(['node-removed', 'node-1']);
236
+
237
+ expect(targetSnapshot?.versionId).toBe('77b70644-0000-0000-0000-000000000000');
238
+ expect(targetSnapshot?.nodes.map((node) => node.id)).toEqual(['node-1']);
239
+
240
+ expect(sourceLabel).toContain('Published');
241
+ expect(sourceLabel).toContain('0f123890');
242
+ expect(targetLabel).toContain('In review');
243
+ expect(targetLabel).toContain('77b70644');
244
+ });
245
+
246
+ describe('closed reviews', () => {
247
+ const approved = { state: 'closed', decision: 'approved' } as const;
248
+
249
+ it('labels the frozen baseline and the approved version', () => {
250
+ const { getByTestId } = renderComponent({
251
+ props: {
252
+ ...approved,
253
+ workflow: makeWorkflow({
254
+ pinnedVersion: makeSnapshot({ name: 'Version hotpink' }),
255
+ baselineVersion: makeSnapshot({
256
+ versionId: '0f123890-0000-0000-0000-000000000000',
257
+ name: 'Version teal',
258
+ nodes: [makeNode({ id: 'node-removed', name: 'Removed node' }), makeNode()],
259
+ }),
260
+ }),
261
+ },
262
+ });
263
+
264
+ expect(getByTestId('workflow-review-changes-source-label')).toHaveTextContent(
265
+ 'Previously published: Version teal',
266
+ );
267
+ expect(getByTestId('workflow-review-changes-target-label')).toHaveTextContent(
268
+ 'Approved: Version hotpink',
269
+ );
270
+ });
271
+
272
+ // Nothing was published when this was approved, so the baseline is empty by
273
+ // right — not missing. The diff still renders, sourceless.
274
+ it('renders a sourceless diff when nothing was published at approval', () => {
275
+ const { getByTestId, queryByTestId } = renderComponent({
276
+ props: { ...approved, workflow: makeWorkflow({ baselineVersion: null }) },
277
+ });
278
+
279
+ expect(getByTestId('workflow-diff-view-stub')).toBeInTheDocument();
280
+ expect(diffViewProps[0].sourceSnapshot).toBeUndefined();
281
+ expect(
282
+ queryByTestId('workflow-review-changes-closed-without-approval'),
283
+ ).not.toBeInTheDocument();
284
+ });
285
+
286
+ it('phrases the no-changes state in the past tense', () => {
287
+ const snapshot = makeSnapshot();
288
+ const { getByTestId } = renderComponent({
289
+ props: {
290
+ ...approved,
291
+ workflow: makeWorkflow({ pinnedVersion: snapshot, baselineVersion: snapshot }),
292
+ },
293
+ });
294
+
295
+ expect(getByTestId('workflow-review-changes-no-changes')).toHaveTextContent(
296
+ 'No changes compared to the previously published version.',
297
+ );
298
+ });
299
+
300
+ // Auto-closed by archiving, transferring or deleting the workflow: approval
301
+ // is the only thing that freezes a baseline, so there is nothing to diff.
302
+ it.each(['pending', 'changes_requested'] as const)(
303
+ 'explains that a review closed with decision %s has no changes to compare',
304
+ (decision) => {
305
+ const { getByTestId, queryByTestId } = renderComponent({
306
+ props: { state: 'closed', decision, workflow: makeWorkflow() },
307
+ });
308
+
309
+ expect(getByTestId('workflow-review-changes-closed-without-approval')).toBeInTheDocument();
310
+ expect(queryByTestId('workflow-diff-view-stub')).not.toBeInTheDocument();
311
+ },
312
+ );
313
+ });
314
+
315
+ it('renders the diff when versions differ only by an execution flag', () => {
316
+ const { getByTestId, queryByTestId } = renderComponent({
317
+ props: {
318
+ workflow: makeWorkflow({
319
+ pinnedVersion: makeSnapshot({ nodes: [makeNode({ disabled: true })] }),
320
+ baselineVersion: makeSnapshot({
321
+ versionId: '0f123890-0000-0000-0000-000000000000',
322
+ nodes: [makeNode()],
323
+ }),
324
+ }),
325
+ },
326
+ });
327
+
328
+ expect(getByTestId('workflow-diff-view-stub')).toBeInTheDocument();
329
+ expect(queryByTestId('workflow-review-changes-no-changes')).not.toBeInTheDocument();
330
+ });
331
+ });
@@ -0,0 +1,217 @@
1
+ <script setup lang="ts">
2
+ import type {
3
+ WorkflowReviewRequestDecision,
4
+ WorkflowReviewRequestState,
5
+ WorkflowReviewRequestWorkflowDetail,
6
+ WorkflowReviewVersionSnapshot,
7
+ } from '@n8n/api-types';
8
+ import { N8nCallout, N8nText } from '@n8n/design-system';
9
+ import { useI18n } from '@n8n/i18n';
10
+ import isEqual from 'lodash/isEqual';
11
+ import omit from 'lodash/omit';
12
+ import { computed } from 'vue';
13
+ import { componentRegistry } from '@n8n/frontend-module-sdk';
14
+
15
+ import { getVersionLabel } from '@n8n/composables/useWorkflowVersionLabel';
16
+
17
+ const WorkflowDiffView = computed(() => componentRegistry.get('workflow-diff'));
18
+
19
+ const props = defineProps<{
20
+ workflow: WorkflowReviewRequestWorkflowDetail;
21
+ state: WorkflowReviewRequestState;
22
+ decision: WorkflowReviewRequestDecision;
23
+ }>();
24
+
25
+ const i18n = useI18n();
26
+
27
+ // Only approval freezes a baseline, so an approved review diffs against what was
28
+ // published back then; a review closed any other way has nothing to diff against.
29
+ const isApproved = computed(() => props.state === 'closed' && props.decision === 'approved');
30
+ const isClosedWithoutApproval = computed(
31
+ () => props.state === 'closed' && props.decision !== 'approved',
32
+ );
33
+
34
+ /**
35
+ * Label each side the way version history does, falling back to a short id.
36
+ * Falsy rather than nullish: the publish endpoints accept `name: ""`.
37
+ */
38
+ const versionLabel = (snapshot: WorkflowReviewVersionSnapshot) =>
39
+ getVersionLabel({ workflowHistory: snapshot });
40
+
41
+ /**
42
+ * A snapshot minus its identity and metadata — i.e. everything the diff renders.
43
+ * Derived by omission rather than an explicit field list to avoid drift; renaming
44
+ * a version must not read as a change.
45
+ */
46
+ function contentOf(snapshot: WorkflowReviewVersionSnapshot) {
47
+ return omit(snapshot, ['versionId', 'name', 'createdAt']);
48
+ }
49
+
50
+ const hasChanges = computed(() => {
51
+ const { pinnedVersion, baselineVersion } = props.workflow;
52
+
53
+ if (!pinnedVersion) return false;
54
+ // No baseline means first publish: everything is a change.
55
+ if (!baselineVersion) return true;
56
+ if (pinnedVersion.versionId === baselineVersion.versionId) return false;
57
+
58
+ return !isEqual(contentOf(baselineVersion), contentOf(pinnedVersion));
59
+ });
60
+
61
+ // The dots track publish status, which approval flips: the approved version is
62
+ // the published one now, and the baseline it replaced no longer is.
63
+ const sourceDotClass = computed(() =>
64
+ isApproved.value ? 'statusDotSuperseded' : 'statusDotPublished',
65
+ );
66
+ const targetDotClass = computed(() =>
67
+ isApproved.value ? 'statusDotPublished' : 'statusDotInReview',
68
+ );
69
+
70
+ const noChangesText = computed(() =>
71
+ i18n.baseText(
72
+ isApproved.value
73
+ ? 'workflowReviews.changes.closed.noChanges'
74
+ : 'workflowReviews.changes.noChanges',
75
+ ),
76
+ );
77
+ const sourceEmptyText = computed(() =>
78
+ i18n.baseText(
79
+ isApproved.value
80
+ ? 'workflowReviews.changes.closed.firstPublish.sourceEmpty'
81
+ : 'workflowReviews.changes.firstPublish.sourceEmpty',
82
+ ),
83
+ );
84
+
85
+ const sourceLabel = computed(() =>
86
+ props.workflow.baselineVersion
87
+ ? i18n.baseText(
88
+ isApproved.value
89
+ ? 'workflowReviews.changes.closed.sourceLabel'
90
+ : 'workflowReviews.changes.sourceLabel',
91
+ { interpolate: { version: versionLabel(props.workflow.baselineVersion) } },
92
+ )
93
+ : undefined,
94
+ );
95
+ const targetLabel = computed(() =>
96
+ props.workflow.pinnedVersion
97
+ ? i18n.baseText(
98
+ isApproved.value
99
+ ? 'workflowReviews.changes.closed.targetLabel'
100
+ : 'workflowReviews.changes.targetLabel',
101
+ { interpolate: { version: versionLabel(props.workflow.pinnedVersion) } },
102
+ )
103
+ : undefined,
104
+ );
105
+ </script>
106
+
107
+ <template>
108
+ <N8nCallout
109
+ v-if="isClosedWithoutApproval"
110
+ theme="info"
111
+ :class="$style.callout"
112
+ data-test-id="workflow-review-changes-closed-without-approval"
113
+ >
114
+ {{ i18n.baseText('workflowReviews.changes.closedWithoutApproval') }}
115
+ </N8nCallout>
116
+ <N8nCallout
117
+ v-else-if="!workflow.pinnedVersion"
118
+ theme="warning"
119
+ :class="$style.callout"
120
+ data-test-id="workflow-review-changes-version-unavailable"
121
+ >
122
+ {{ i18n.baseText('workflowReviews.changes.versionUnavailable.body') }}
123
+ </N8nCallout>
124
+ <N8nCallout
125
+ v-else-if="!hasChanges"
126
+ theme="info"
127
+ :class="$style.callout"
128
+ data-test-id="workflow-review-changes-no-changes"
129
+ >
130
+ {{ noChangesText }}
131
+ </N8nCallout>
132
+ <div v-else :class="$style.diff" data-review-diff data-test-id="workflow-review-changes-diff">
133
+ <component
134
+ :is="WorkflowDiffView"
135
+ :source-snapshot="workflow.baselineVersion ?? undefined"
136
+ :workflow-id="workflow.workflowId"
137
+ :workflow-name="workflow.workflowName"
138
+ :target-snapshot="workflow.pinnedVersion ?? undefined"
139
+ :source-label="sourceLabel"
140
+ :target-label="targetLabel"
141
+ :canvas-id-prefix="workflow.workflowId"
142
+ show-fullscreen-button
143
+ hide-title
144
+ >
145
+ <!-- Only when a baseline exists: with no prior published version there is
146
+ no publish status to represent. -->
147
+ <template v-if="workflow.baselineVersion" #sourceLabel>
148
+ <span :class="$style.versionBadge" data-test-id="workflow-review-changes-source-label">
149
+ <span :class="[$style.statusDot, $style[sourceDotClass]]" />
150
+ <N8nText color="text-dark" size="small" compact>{{ sourceLabel }}</N8nText>
151
+ </span>
152
+ </template>
153
+ <template #targetLabel>
154
+ <span :class="$style.versionBadge" data-test-id="workflow-review-changes-target-label">
155
+ <span :class="[$style.statusDot, $style[targetDotClass]]" />
156
+ <N8nText color="text-dark" size="small" compact>{{ targetLabel }}</N8nText>
157
+ </span>
158
+ </template>
159
+ <template #sourceEmptyText>
160
+ <N8nText size="small" color="text-base">{{ sourceEmptyText }}</N8nText>
161
+ </template>
162
+ </component>
163
+ </div>
164
+ </template>
165
+
166
+ <style module lang="scss">
167
+ .callout {
168
+ max-width: var(--review-callout--max-width, 34rem);
169
+ margin: var(--spacing--xs);
170
+ }
171
+
172
+ .versionBadge {
173
+ /* Keep the existing Reviews badge sizes when the root font changes. */
174
+ --review-version-badge--spacing: 12px;
175
+ --review-version-badge--radius: 4px;
176
+ --review-version-badge--height: 30px;
177
+ --review-version-badge--gap: 8px;
178
+ position: absolute;
179
+ top: var(--review-version-badge--spacing);
180
+ left: var(--review-version-badge--spacing);
181
+ z-index: 1;
182
+ border-radius: var(--review-version-badge--radius);
183
+ border: var(--border-width) solid var(--color--foreground--tint-1);
184
+ background: var(--color--foreground--tint-2);
185
+ display: flex;
186
+ height: var(--review-version-badge--height);
187
+ padding: 0 var(--review-version-badge--spacing);
188
+ align-items: center;
189
+ gap: var(--review-version-badge--gap);
190
+ align-self: stretch;
191
+ }
192
+
193
+ .statusDot {
194
+ height: var(--spacing--2xs);
195
+ width: var(--spacing--2xs);
196
+ border-radius: 50%;
197
+ display: inline-block;
198
+ flex-shrink: 0;
199
+ }
200
+
201
+ .statusDotPublished {
202
+ background-color: var(--color--mint-600);
203
+ }
204
+
205
+ .statusDotInReview {
206
+ background-color: var(--color--yellow-500);
207
+ }
208
+
209
+ .statusDotSuperseded {
210
+ background-color: var(--color--text--tint-1);
211
+ }
212
+
213
+ .diff {
214
+ flex: 1;
215
+ min-height: 0;
216
+ }
217
+ </style>