@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.
- package/LICENSE.md +88 -0
- package/LICENSE_EE.md +27 -0
- package/README.md +61 -2
- package/biome.jsonc +4 -0
- package/eslint.config.mjs +4 -0
- package/package.json +72 -3
- package/src/__tests__/design-system-icons.test.ts +24 -0
- package/src/__tests__/setup.ts +4 -0
- package/src/components/InboxEmptyState.vue +86 -0
- package/src/components/InboxList.test.ts +289 -0
- package/src/components/InboxList.vue +195 -0
- package/src/components/InboxListItem.vue +208 -0
- package/src/components/InboxListSection.vue +168 -0
- package/src/composables/useReviewInboxSectionCollapse.test.ts +66 -0
- package/src/composables/useReviewInboxSectionCollapse.ts +42 -0
- package/src/inbox.api.ts +20 -0
- package/src/inbox.constants.ts +19 -0
- package/src/inbox.module.ts +55 -0
- package/src/inbox.routes.test.ts +140 -0
- package/src/inbox.routes.ts +49 -0
- package/src/inbox.store.test.ts +444 -0
- package/src/inbox.store.ts +305 -0
- package/src/index.ts +5 -0
- package/src/reviews/WorkflowReviewDetail.test.ts +647 -0
- package/src/reviews/WorkflowReviewDetail.vue +257 -0
- package/src/reviews/components/WorkflowReviewActivityFeed.test.ts +923 -0
- package/src/reviews/components/WorkflowReviewActivityFeed.vue +312 -0
- package/src/reviews/components/WorkflowReviewChangesList.test.ts +97 -0
- package/src/reviews/components/WorkflowReviewChangesList.vue +143 -0
- package/src/reviews/components/WorkflowReviewChangesSection.test.ts +331 -0
- package/src/reviews/components/WorkflowReviewChangesSection.vue +217 -0
- package/src/reviews/components/WorkflowReviewCommentComposer.test.ts +175 -0
- package/src/reviews/components/WorkflowReviewCommentComposer.vue +83 -0
- package/src/reviews/components/WorkflowReviewDecisionPopover.test.ts +288 -0
- package/src/reviews/components/WorkflowReviewDecisionPopover.vue +203 -0
- package/src/reviews/components/WorkflowReviewDetailMetadata.test.ts +206 -0
- package/src/reviews/components/WorkflowReviewDetailMetadata.vue +245 -0
- package/src/reviews/components/WorkflowReviewDetailTabs.decisionNote.test.ts +90 -0
- package/src/reviews/components/WorkflowReviewDetailTabs.test.ts +500 -0
- package/src/reviews/components/WorkflowReviewDetailTabs.vue +329 -0
- package/src/reviews/components/WorkflowReviewStatusDot.vue +67 -0
- package/src/reviews/components/_activity-card.scss +36 -0
- package/src/reviews/components/activity-entries/WorkflowReviewActivityActorAvatar.vue +50 -0
- package/src/reviews/components/activity-entries/WorkflowReviewActivityComment.test.ts +103 -0
- package/src/reviews/components/activity-entries/WorkflowReviewActivityComment.vue +116 -0
- package/src/reviews/components/activity-entries/WorkflowReviewActivityEventEntry.vue +361 -0
- package/src/reviews/components/activity-entries/WorkflowReviewActivityFallback.vue +49 -0
- package/src/reviews/components/activity-entries/WorkflowReviewActivityWorkflowLink.vue +36 -0
- package/src/reviews/components/activityEntryRegistry.ts +27 -0
- package/src/reviews/constants.ts +41 -0
- package/src/reviews/reviewActivity.store.test.ts +463 -0
- package/src/reviews/reviewActivity.store.ts +186 -0
- package/src/reviews/reviewDetail.store.test.ts +135 -0
- package/src/reviews/reviewDetail.store.ts +74 -0
- package/src/reviews/workflowReviewStatus.utils.ts +44 -0
- package/src/reviews/workflowReviews.api.ts +61 -0
- package/src/reviews/workflowReviews.utils.ts +18 -0
- package/src/self-healing/SelfHealingResultDetail.vue +19 -0
- package/src/views/InboxView.test.ts +568 -0
- package/src/views/InboxView.vue +260 -0
- package/stylelint.config.mjs +9 -0
- 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>
|