@n8n/frontend-module-inbox 0.0.0-stage → 0.1.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/README.md +59 -2
- package/biome.jsonc +4 -0
- package/eslint.config.mjs +4 -0
- package/package.json +62 -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 +229 -0
- package/src/components/InboxList.vue +195 -0
- package/src/components/InboxListItem.vue +134 -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 +16 -0
- package/src/inbox.module.ts +50 -0
- package/src/inbox.routes.test.ts +111 -0
- package/src/inbox.routes.ts +30 -0
- package/src/inbox.store.test.ts +349 -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/WorkflowReviewChangesSection.test.ts +327 -0
- package/src/reviews/components/WorkflowReviewChangesSection.vue +219 -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 +241 -0
- package/src/reviews/components/WorkflowReviewDetailTabs.decisionNote.test.ts +90 -0
- package/src/reviews/components/WorkflowReviewDetailTabs.test.ts +434 -0
- package/src/reviews/components/WorkflowReviewDetailTabs.vue +311 -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/views/InboxView.test.ts +525 -0
- package/src/views/InboxView.vue +251 -0
- package/stylelint.config.mjs +9 -0
- package/vite.config.ts +52 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { N8nButton, N8nLoading, N8nText } from '@n8n/design-system';
|
|
3
|
+
import { useI18n } from '@n8n/i18n';
|
|
4
|
+
import { useResizeObserver } from '@vueuse/core';
|
|
5
|
+
import { storeToRefs } from 'pinia';
|
|
6
|
+
import { inject, onMounted, ref, watch } from 'vue';
|
|
7
|
+
|
|
8
|
+
import { useIntersectionObserver } from '@n8n/composables/useIntersectionObserver';
|
|
9
|
+
|
|
10
|
+
import { ReviewDetailScrollContainerKey } from '../constants';
|
|
11
|
+
import { useReviewActivityStore } from '../reviewActivity.store';
|
|
12
|
+
import { resolveActivityComponent } from './activityEntryRegistry';
|
|
13
|
+
|
|
14
|
+
const i18n = useI18n();
|
|
15
|
+
const store = useReviewActivityStore();
|
|
16
|
+
const { entries, loading, loadingMore, hasMore, error } = storeToRefs(store);
|
|
17
|
+
|
|
18
|
+
const scrollContainer = inject(ReviewDetailScrollContainerKey, ref(null));
|
|
19
|
+
const feed = ref<HTMLElement | null>(null);
|
|
20
|
+
const list = ref<HTMLElement | null>(null);
|
|
21
|
+
const sentinel = ref<HTMLElement | null>(null);
|
|
22
|
+
const composer = ref<HTMLElement | null>(null);
|
|
23
|
+
// Held back until the initial scroll position is applied, so the sentinel cannot
|
|
24
|
+
// intersect at scrollTop 0 and pull in the whole feed before the user sees it.
|
|
25
|
+
const initialScrollApplied = ref(false);
|
|
26
|
+
|
|
27
|
+
let prependAnchor: { element: Element; top: number } | null = null;
|
|
28
|
+
|
|
29
|
+
const enteringIds = ref<ReadonlySet<string>>(new Set());
|
|
30
|
+
|
|
31
|
+
function scrollToBottom() {
|
|
32
|
+
const container = scrollContainer.value;
|
|
33
|
+
if (container) container.scrollTop = container.scrollHeight;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { observe } = useIntersectionObserver({
|
|
37
|
+
root: scrollContainer,
|
|
38
|
+
threshold: 0.01,
|
|
39
|
+
onIntersect: () => {
|
|
40
|
+
// Captured here, not in the post-flush watcher: by then the older page is
|
|
41
|
+
// already in the DOM and the difference would be zero.
|
|
42
|
+
const element = list.value?.firstElementChild ?? null;
|
|
43
|
+
prependAnchor = element ? { element, top: element.getBoundingClientRect().top } : null;
|
|
44
|
+
void store.loadMore();
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
watch(
|
|
49
|
+
[sentinel, hasMore, loadingMore, () => entries.value.length],
|
|
50
|
+
([sentinelElement, moreToLoad, isLoadingMore]) => {
|
|
51
|
+
if (sentinelElement && moreToLoad && !isLoadingMore) {
|
|
52
|
+
observe(sentinelElement);
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
{ immediate: true, flush: 'post' },
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
watch(
|
|
59
|
+
entries,
|
|
60
|
+
(next, previous) => {
|
|
61
|
+
// Good for this update only, and only while its element is still in the list: `loadMore`
|
|
62
|
+
// bails out on a cursor it has already spent, leaving an anchor behind, and a refetch
|
|
63
|
+
// drops the entries an older anchor points at.
|
|
64
|
+
const anchor = prependAnchor?.element.isConnected === true ? prependAnchor : null;
|
|
65
|
+
prependAnchor = null;
|
|
66
|
+
|
|
67
|
+
if (next.length === 0) return;
|
|
68
|
+
if (!previous || previous.length === 0) {
|
|
69
|
+
scrollToBottom();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (next[0]?.id !== previous[0]?.id) {
|
|
73
|
+
const container = scrollContainer.value;
|
|
74
|
+
// With no anchor the list was replaced rather than prepended to — a refetch keeps
|
|
75
|
+
// only what is newer than the page it got — so the newest entry is what to show.
|
|
76
|
+
if (!container || !anchor) {
|
|
77
|
+
scrollToBottom();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
container.scrollTop += anchor.element.getBoundingClientRect().top - anchor.top;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (next.at(-1)?.id !== previous.at(-1)?.id) scrollToBottom();
|
|
84
|
+
},
|
|
85
|
+
{ flush: 'post' },
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// Ids, not positions: a refetch can replace the first entries and still add new ones.
|
|
89
|
+
watch(
|
|
90
|
+
entries,
|
|
91
|
+
(next, previous) => {
|
|
92
|
+
if (next.length === 0) {
|
|
93
|
+
enteringIds.value = new Set();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const lastShown = previous?.at(-1);
|
|
97
|
+
if (!lastShown) return;
|
|
98
|
+
|
|
99
|
+
const newer = next.filter((entry) => Number(entry.id) > Number(lastShown.id));
|
|
100
|
+
// Only replaced on new entries, so a prepend does not cut a running fade.
|
|
101
|
+
if (newer.length > 0) enteringIds.value = new Set(newer.map((entry) => entry.id));
|
|
102
|
+
},
|
|
103
|
+
{ flush: 'post' },
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// Keeps the feed at the bottom while the composer grows, unless the viewer scrolled up.
|
|
107
|
+
// The first size counts too: a restored draft grows the input after the initial scroll.
|
|
108
|
+
let composerHeight = 0;
|
|
109
|
+
useResizeObserver(composer, ([entry]) => {
|
|
110
|
+
const height = entry?.borderBoxSize[0]?.blockSize ?? 0;
|
|
111
|
+
const growth = height - composerHeight;
|
|
112
|
+
composerHeight = height;
|
|
113
|
+
|
|
114
|
+
feed.value?.style.setProperty('--review-activity--composer-height', `${height}px`);
|
|
115
|
+
|
|
116
|
+
const container = scrollContainer.value;
|
|
117
|
+
if (!container || growth <= 0) return;
|
|
118
|
+
|
|
119
|
+
// Was at the bottom before this growth. 1px covers rounding.
|
|
120
|
+
const distanceToBottom = container.scrollHeight - container.clientHeight - container.scrollTop;
|
|
121
|
+
if (distanceToBottom <= growth + 1) scrollToBottom();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// `loadMore` is a no-op with no cursor, so a failed first page has to refetch. Shared by both
|
|
125
|
+
// error rows: posting onto a failed feed moves the viewer from the first to the second, which
|
|
126
|
+
// would otherwise hit that dead end and leave the earlier activity unreachable.
|
|
127
|
+
function retry() {
|
|
128
|
+
if (!store.nextCursor) {
|
|
129
|
+
if (store.currentReviewId) void store.fetchFeed(store.currentReviewId);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
void store.loadMore();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Entries may already be loaded on mount (Changes -> Activity round trip).
|
|
136
|
+
onMounted(() => {
|
|
137
|
+
if (entries.value.length > 0) scrollToBottom();
|
|
138
|
+
initialScrollApplied.value = true;
|
|
139
|
+
});
|
|
140
|
+
</script>
|
|
141
|
+
|
|
142
|
+
<template>
|
|
143
|
+
<div
|
|
144
|
+
ref="feed"
|
|
145
|
+
:class="[$style.feed, { [$style.feedWithComposer]: $slots.composer }]"
|
|
146
|
+
data-test-id="workflow-review-activity-feed"
|
|
147
|
+
>
|
|
148
|
+
<div v-if="$slots.header" :class="$style.header">
|
|
149
|
+
<slot name="header" />
|
|
150
|
+
</div>
|
|
151
|
+
<N8nLoading v-if="loading && entries.length === 0" :loading="true" :rows="3" />
|
|
152
|
+
<div
|
|
153
|
+
v-else-if="error && entries.length === 0"
|
|
154
|
+
:class="$style.errorRow"
|
|
155
|
+
data-test-id="workflow-review-activity-error"
|
|
156
|
+
>
|
|
157
|
+
<N8nText color="danger" size="small">
|
|
158
|
+
{{ i18n.baseText('workflowReviews.detail.activity.error.load') }}
|
|
159
|
+
</N8nText>
|
|
160
|
+
<N8nButton
|
|
161
|
+
size="mini"
|
|
162
|
+
variant="outline"
|
|
163
|
+
data-test-id="workflow-review-activity-retry"
|
|
164
|
+
@click="retry()"
|
|
165
|
+
>
|
|
166
|
+
{{ i18n.baseText('generic.retry') }}
|
|
167
|
+
</N8nButton>
|
|
168
|
+
</div>
|
|
169
|
+
<template v-else>
|
|
170
|
+
<div
|
|
171
|
+
v-if="initialScrollApplied && hasMore && !error"
|
|
172
|
+
ref="sentinel"
|
|
173
|
+
:class="$style.sentinel"
|
|
174
|
+
data-test-id="workflow-review-activity-load-more-sentinel"
|
|
175
|
+
/>
|
|
176
|
+
<!-- `loading` too: a retry that keeps a posted comment on screen leaves this the
|
|
177
|
+
only place a refetch can show progress. -->
|
|
178
|
+
<N8nLoading v-if="loadingMore || loading" :loading="true" :rows="1" />
|
|
179
|
+
<div v-if="error" :class="$style.errorRow">
|
|
180
|
+
<N8nText color="danger" size="small">
|
|
181
|
+
{{ i18n.baseText('workflowReviews.detail.activity.error.load') }}
|
|
182
|
+
</N8nText>
|
|
183
|
+
<N8nButton
|
|
184
|
+
size="mini"
|
|
185
|
+
variant="outline"
|
|
186
|
+
data-test-id="workflow-review-activity-load-more-retry"
|
|
187
|
+
@click="retry()"
|
|
188
|
+
>
|
|
189
|
+
{{ i18n.baseText('generic.retry') }}
|
|
190
|
+
</N8nButton>
|
|
191
|
+
</div>
|
|
192
|
+
<N8nText bold color="text-light" size="medium" :class="$style.header">{{
|
|
193
|
+
i18n.baseText('workflowReviews.detail.tabs.activity')
|
|
194
|
+
}}</N8nText>
|
|
195
|
+
<div
|
|
196
|
+
ref="list"
|
|
197
|
+
role="list"
|
|
198
|
+
:aria-label="i18n.baseText('workflowReviews.detail.activity.listLabel')"
|
|
199
|
+
:class="$style.list"
|
|
200
|
+
>
|
|
201
|
+
<div
|
|
202
|
+
v-for="entry in entries"
|
|
203
|
+
:key="entry.id"
|
|
204
|
+
role="listitem"
|
|
205
|
+
:class="[$style.item, { [$style.itemEntering]: enteringIds.has(entry.id) }]"
|
|
206
|
+
data-test-id="workflow-review-activity-entry"
|
|
207
|
+
>
|
|
208
|
+
<component :is="resolveActivityComponent(entry)" :entry="entry" />
|
|
209
|
+
</div>
|
|
210
|
+
<div v-if="$slots.footer" role="listitem" :class="$style.item">
|
|
211
|
+
<slot name="footer" />
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
</template>
|
|
215
|
+
<!-- Outside the loading and error states, so the composer stays mounted through them. -->
|
|
216
|
+
<div v-if="$slots.composer" ref="composer" :class="$style.composer">
|
|
217
|
+
<slot name="composer" />
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
</template>
|
|
221
|
+
|
|
222
|
+
<style lang="scss" module>
|
|
223
|
+
@use '@n8n/design-system/css/mixins/motion';
|
|
224
|
+
|
|
225
|
+
.feed {
|
|
226
|
+
/* Set here so the list, its rail and the composer share them. */
|
|
227
|
+
--review-activity--gap: var(--spacing--md);
|
|
228
|
+
/* Every entry leads with an `xxsmall` avatar (`N8nAvatar/avatarSizes.ts`), and a boxed
|
|
229
|
+
entry's negative margin and padding cancel out, so all avatars share this column. The
|
|
230
|
+
rail below is centred on it. */
|
|
231
|
+
--review-activity--avatar-size: 16px;
|
|
232
|
+
--review-activity--line-height: 20px;
|
|
233
|
+
|
|
234
|
+
display: flex;
|
|
235
|
+
flex-direction: column;
|
|
236
|
+
padding-block: var(--spacing--5xs) var(--spacing--sm);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/* Same inset the list gives its entries, so a card here starts on the avatar column. */
|
|
240
|
+
.header {
|
|
241
|
+
padding-inline: var(--spacing--sm);
|
|
242
|
+
padding-bottom: var(--spacing--sm);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.list {
|
|
246
|
+
display: flex;
|
|
247
|
+
flex-direction: column;
|
|
248
|
+
gap: var(--review-activity--gap);
|
|
249
|
+
/* Entries sit inset; a boxed entry cancels this to reach the panel edge. */
|
|
250
|
+
padding-inline: var(--spacing--sm);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.item {
|
|
254
|
+
position: relative;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/* Keeps focused entry links above the composer. Not scroll padding on the feed: that also
|
|
258
|
+
scrolls the feed for the caret while typing. */
|
|
259
|
+
.list * {
|
|
260
|
+
scroll-margin-bottom: var(--review-activity--composer-height, 0);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.itemEntering {
|
|
264
|
+
--animation--fade-in-up--translate: var(--spacing--2xs);
|
|
265
|
+
--animation--fade-in-up--easing: var(--easing--ease-out-quint);
|
|
266
|
+
|
|
267
|
+
@include motion.fade-in-up;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/* Threads the entries into one timeline. Drawn in the gap above each entry rather than
|
|
271
|
+
inside it, because the line belongs to the space between two entries and only the list
|
|
272
|
+
knows that gap. The first entry has nothing above it to join. */
|
|
273
|
+
.item:not(:first-child)::before {
|
|
274
|
+
content: '';
|
|
275
|
+
position: absolute;
|
|
276
|
+
/* Floats clear of both neighbours instead of butting into them, as the design does. The
|
|
277
|
+
row is taller than its avatar, so the same inset reads as a wider gap at the top. */
|
|
278
|
+
bottom: calc(100% + var(--spacing--5xs));
|
|
279
|
+
height: calc(var(--review-activity--gap) - 2 * var(--spacing--5xs));
|
|
280
|
+
left: calc(var(--review-activity--avatar-size) / 2);
|
|
281
|
+
border-left: var(--border);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/* The composer holds the bottom space, so it sticks flush to the edge. */
|
|
285
|
+
.feedWithComposer {
|
|
286
|
+
padding-block-end: 0;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/* Sticks to the bottom when the entries overflow. The background hides entries under it. */
|
|
290
|
+
.composer {
|
|
291
|
+
position: sticky;
|
|
292
|
+
bottom: 0;
|
|
293
|
+
z-index: 1;
|
|
294
|
+
flex-shrink: 0;
|
|
295
|
+
padding-block: var(--review-activity--gap) var(--spacing--sm);
|
|
296
|
+
/* Room for the input's focus ring, which the scroll container would clip. */
|
|
297
|
+
padding-inline: var(--focus--border-width);
|
|
298
|
+
background-color: var(--color--background--light-2);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.errorRow {
|
|
302
|
+
display: flex;
|
|
303
|
+
align-items: center;
|
|
304
|
+
gap: var(--spacing--md);
|
|
305
|
+
padding-block: var(--spacing--xs);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.sentinel {
|
|
309
|
+
height: var(--spacing--4xs);
|
|
310
|
+
flex-shrink: 0;
|
|
311
|
+
}
|
|
312
|
+
</style>
|
|
@@ -0,0 +1,327 @@
|
|
|
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',
|
|
25
|
+
'targetSnapshot',
|
|
26
|
+
'workflowId',
|
|
27
|
+
'workflowName',
|
|
28
|
+
'sourceLabel',
|
|
29
|
+
'targetLabel',
|
|
30
|
+
'showFullscreenButton',
|
|
31
|
+
],
|
|
32
|
+
setup(props) {
|
|
33
|
+
diffViewProps.push(props as SlotWorkflowDiffProps);
|
|
34
|
+
},
|
|
35
|
+
template:
|
|
36
|
+
'<div data-test-id="workflow-diff-view-stub"><slot name="sourceLabel" /><slot name="targetLabel" /><slot name="sourceEmptyText" /></div>',
|
|
37
|
+
});
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
componentRegistry.clear();
|
|
40
|
+
componentRegistry.register('workflow-diff', DiffView);
|
|
41
|
+
});
|
|
42
|
+
afterEach(() => componentRegistry.clear());
|
|
43
|
+
|
|
44
|
+
const renderSection = createComponentRenderer(WorkflowReviewChangesSection);
|
|
45
|
+
|
|
46
|
+
/** Defaults to an open review; closed cases pass `state` and `decision` themselves. */
|
|
47
|
+
const renderComponent = (options: {
|
|
48
|
+
props: {
|
|
49
|
+
workflow: WorkflowReviewRequestWorkflowDetail;
|
|
50
|
+
state?: WorkflowReviewRequestState;
|
|
51
|
+
decision?: WorkflowReviewRequestDecision;
|
|
52
|
+
};
|
|
53
|
+
}) => renderSection({ props: { state: 'open', decision: 'pending', ...options.props } });
|
|
54
|
+
|
|
55
|
+
function makeNode(overrides: Partial<INode> = {}): INode {
|
|
56
|
+
return {
|
|
57
|
+
id: 'node-1',
|
|
58
|
+
name: 'Node 1',
|
|
59
|
+
type: 'n8n-nodes-base.noOp',
|
|
60
|
+
typeVersion: 1,
|
|
61
|
+
position: [0, 0],
|
|
62
|
+
parameters: {},
|
|
63
|
+
...overrides,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function makeSnapshot(
|
|
68
|
+
overrides: Partial<WorkflowReviewVersionSnapshot> = {},
|
|
69
|
+
): WorkflowReviewVersionSnapshot {
|
|
70
|
+
return {
|
|
71
|
+
versionId: '77b70644-0000-0000-0000-000000000000',
|
|
72
|
+
name: null,
|
|
73
|
+
nodes: [makeNode()],
|
|
74
|
+
connections: {},
|
|
75
|
+
nodeGroups: [],
|
|
76
|
+
createdAt: '2024-01-01T00:00:00.000Z',
|
|
77
|
+
...overrides,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function makeWorkflow(
|
|
82
|
+
overrides: Partial<WorkflowReviewRequestWorkflowDetail> = {},
|
|
83
|
+
): WorkflowReviewRequestWorkflowDetail {
|
|
84
|
+
return {
|
|
85
|
+
workflowId: 'wf-1',
|
|
86
|
+
workflowName: 'Payment handler',
|
|
87
|
+
workflowVersionId: '77b70644-0000-0000-0000-000000000000',
|
|
88
|
+
pinnedVersion: makeSnapshot(),
|
|
89
|
+
publishedVersionId: null,
|
|
90
|
+
baselineVersion: makeSnapshot({
|
|
91
|
+
versionId: '0f123890-0000-0000-0000-000000000000',
|
|
92
|
+
nodes: [makeNode({ id: 'node-removed', name: 'Removed node' }), makeNode()],
|
|
93
|
+
}),
|
|
94
|
+
...overrides,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe('WorkflowReviewChangesSection', () => {
|
|
99
|
+
beforeEach(() => {
|
|
100
|
+
createTestingPinia();
|
|
101
|
+
diffViewProps.length = 0;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('labels both diff sides with a version status', () => {
|
|
105
|
+
const { getByTestId } = renderComponent({ props: { workflow: makeWorkflow() } });
|
|
106
|
+
|
|
107
|
+
expect(getByTestId('workflow-review-changes-source-label')).toHaveTextContent('0f123890');
|
|
108
|
+
expect(getByTestId('workflow-review-changes-target-label')).toHaveTextContent('77b70644');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('labels a named version by its name instead of its id', () => {
|
|
112
|
+
const { getByTestId } = renderComponent({
|
|
113
|
+
props: {
|
|
114
|
+
workflow: makeWorkflow({
|
|
115
|
+
pinnedVersion: makeSnapshot({ name: 'Version hotpink' }),
|
|
116
|
+
baselineVersion: makeSnapshot({
|
|
117
|
+
versionId: '0f123890-0000-0000-0000-000000000000',
|
|
118
|
+
name: 'Version teal',
|
|
119
|
+
nodes: [makeNode({ id: 'node-removed', name: 'Removed node' }), makeNode()],
|
|
120
|
+
}),
|
|
121
|
+
}),
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(getByTestId('workflow-review-changes-source-label')).toHaveTextContent(
|
|
126
|
+
'Published: Version teal',
|
|
127
|
+
);
|
|
128
|
+
expect(getByTestId('workflow-review-changes-target-label')).toHaveTextContent(
|
|
129
|
+
'In review: Version hotpink',
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// The publish endpoints accept an empty name, so a stored '' must still label.
|
|
134
|
+
it.each([
|
|
135
|
+
['null', null],
|
|
136
|
+
['empty', ''],
|
|
137
|
+
])('falls back to the id-derived label when the name is %s', (_case, name) => {
|
|
138
|
+
const { getByTestId } = renderComponent({
|
|
139
|
+
props: { workflow: makeWorkflow({ pinnedVersion: makeSnapshot({ name }) }) },
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(getByTestId('workflow-review-changes-target-label')).toHaveTextContent(
|
|
143
|
+
'In review: Version 77b70644',
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('shows a no-changes state when versions differ only by name', () => {
|
|
148
|
+
const { getByTestId, queryByTestId } = renderComponent({
|
|
149
|
+
props: {
|
|
150
|
+
workflow: makeWorkflow({
|
|
151
|
+
pinnedVersion: makeSnapshot({ name: 'Version hotpink' }),
|
|
152
|
+
baselineVersion: makeSnapshot({
|
|
153
|
+
versionId: '0f123890-0000-0000-0000-000000000000',
|
|
154
|
+
name: 'Version teal',
|
|
155
|
+
}),
|
|
156
|
+
}),
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(getByTestId('workflow-review-changes-no-changes')).toBeInTheDocument();
|
|
161
|
+
expect(queryByTestId('workflow-diff-view-stub')).not.toBeInTheDocument();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('shows an unavailable state when the pinned version was pruned', () => {
|
|
165
|
+
const { getByTestId, queryByTestId } = renderComponent({
|
|
166
|
+
props: {
|
|
167
|
+
workflow: makeWorkflow({
|
|
168
|
+
workflowVersionId: null,
|
|
169
|
+
pinnedVersion: null,
|
|
170
|
+
}),
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(getByTestId('workflow-review-changes-version-unavailable')).toBeInTheDocument();
|
|
175
|
+
expect(queryByTestId('workflow-diff-view-stub')).not.toBeInTheDocument();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('shows a no-changes state when pinned and baseline are the same version', () => {
|
|
179
|
+
const snapshot = makeSnapshot();
|
|
180
|
+
const { getByTestId, queryByTestId } = renderComponent({
|
|
181
|
+
props: {
|
|
182
|
+
workflow: makeWorkflow({ pinnedVersion: snapshot, baselineVersion: snapshot }),
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
expect(getByTestId('workflow-review-changes-no-changes')).toBeInTheDocument();
|
|
187
|
+
expect(queryByTestId('workflow-diff-view-stub')).not.toBeInTheDocument();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('shows a no-changes state when versions differ only by id', () => {
|
|
191
|
+
const { getByTestId, queryByTestId } = renderComponent({
|
|
192
|
+
props: {
|
|
193
|
+
workflow: makeWorkflow({
|
|
194
|
+
pinnedVersion: makeSnapshot(),
|
|
195
|
+
baselineVersion: makeSnapshot({ versionId: 'other-version-id' }),
|
|
196
|
+
}),
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(getByTestId('workflow-review-changes-no-changes')).toBeInTheDocument();
|
|
201
|
+
expect(queryByTestId('workflow-diff-view-stub')).not.toBeInTheDocument();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// First publish: no baseline, so the diff gets no source workflow — and that
|
|
205
|
+
// side carries no publish-status label either, since nothing was published.
|
|
206
|
+
it('renders a sourceless diff with no source label for a first publish', () => {
|
|
207
|
+
const { getByTestId, queryByTestId } = renderComponent({
|
|
208
|
+
props: {
|
|
209
|
+
workflow: makeWorkflow({ baselineVersion: null }),
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(getByTestId('workflow-diff-view-stub')).toBeInTheDocument();
|
|
214
|
+
expect(diffViewProps).toHaveLength(1);
|
|
215
|
+
expect(diffViewProps[0].sourceSnapshot).toBeUndefined();
|
|
216
|
+
expect(diffViewProps[0].workflowId).toBe('wf-1');
|
|
217
|
+
expect(queryByTestId('workflow-review-changes-source-label')).not.toBeInTheDocument();
|
|
218
|
+
expect(getByTestId('workflow-review-changes-target-label')).toBeInTheDocument();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('passes workflow identity, snapshots, and labels to the editor renderer', () => {
|
|
222
|
+
const workflow = makeWorkflow();
|
|
223
|
+
renderComponent({ props: { workflow } });
|
|
224
|
+
|
|
225
|
+
expect(diffViewProps).toHaveLength(1);
|
|
226
|
+
const { sourceSnapshot, targetSnapshot, sourceLabel, targetLabel } = diffViewProps[0];
|
|
227
|
+
|
|
228
|
+
expect(diffViewProps[0].workflowId).toBe('wf-1');
|
|
229
|
+
expect(diffViewProps[0].workflowName).toBe('Payment handler');
|
|
230
|
+
expect(sourceSnapshot?.versionId).toBe('0f123890-0000-0000-0000-000000000000');
|
|
231
|
+
expect(sourceSnapshot?.nodes.map((node) => node.id)).toEqual(['node-removed', 'node-1']);
|
|
232
|
+
|
|
233
|
+
expect(targetSnapshot?.versionId).toBe('77b70644-0000-0000-0000-000000000000');
|
|
234
|
+
expect(targetSnapshot?.nodes.map((node) => node.id)).toEqual(['node-1']);
|
|
235
|
+
|
|
236
|
+
expect(sourceLabel).toContain('Published');
|
|
237
|
+
expect(sourceLabel).toContain('0f123890');
|
|
238
|
+
expect(targetLabel).toContain('In review');
|
|
239
|
+
expect(targetLabel).toContain('77b70644');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe('closed reviews', () => {
|
|
243
|
+
const approved = { state: 'closed', decision: 'approved' } as const;
|
|
244
|
+
|
|
245
|
+
it('labels the frozen baseline and the approved version', () => {
|
|
246
|
+
const { getByTestId } = renderComponent({
|
|
247
|
+
props: {
|
|
248
|
+
...approved,
|
|
249
|
+
workflow: makeWorkflow({
|
|
250
|
+
pinnedVersion: makeSnapshot({ name: 'Version hotpink' }),
|
|
251
|
+
baselineVersion: makeSnapshot({
|
|
252
|
+
versionId: '0f123890-0000-0000-0000-000000000000',
|
|
253
|
+
name: 'Version teal',
|
|
254
|
+
nodes: [makeNode({ id: 'node-removed', name: 'Removed node' }), makeNode()],
|
|
255
|
+
}),
|
|
256
|
+
}),
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
expect(getByTestId('workflow-review-changes-source-label')).toHaveTextContent(
|
|
261
|
+
'Previously published: Version teal',
|
|
262
|
+
);
|
|
263
|
+
expect(getByTestId('workflow-review-changes-target-label')).toHaveTextContent(
|
|
264
|
+
'Approved: Version hotpink',
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// Nothing was published when this was approved, so the baseline is empty by
|
|
269
|
+
// right — not missing. The diff still renders, sourceless.
|
|
270
|
+
it('renders a sourceless diff when nothing was published at approval', () => {
|
|
271
|
+
const { getByTestId, queryByTestId } = renderComponent({
|
|
272
|
+
props: { ...approved, workflow: makeWorkflow({ baselineVersion: null }) },
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
expect(getByTestId('workflow-diff-view-stub')).toBeInTheDocument();
|
|
276
|
+
expect(diffViewProps[0].sourceSnapshot).toBeUndefined();
|
|
277
|
+
expect(
|
|
278
|
+
queryByTestId('workflow-review-changes-closed-without-approval'),
|
|
279
|
+
).not.toBeInTheDocument();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('phrases the no-changes state in the past tense', () => {
|
|
283
|
+
const snapshot = makeSnapshot();
|
|
284
|
+
const { getByTestId } = renderComponent({
|
|
285
|
+
props: {
|
|
286
|
+
...approved,
|
|
287
|
+
workflow: makeWorkflow({ pinnedVersion: snapshot, baselineVersion: snapshot }),
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
expect(getByTestId('workflow-review-changes-no-changes')).toHaveTextContent(
|
|
292
|
+
'No changes compared to the previously published version.',
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// Auto-closed by archiving, transferring or deleting the workflow: approval
|
|
297
|
+
// is the only thing that freezes a baseline, so there is nothing to diff.
|
|
298
|
+
it.each(['pending', 'changes_requested'] as const)(
|
|
299
|
+
'explains that a review closed with decision %s has no changes to compare',
|
|
300
|
+
(decision) => {
|
|
301
|
+
const { getByTestId, queryByTestId } = renderComponent({
|
|
302
|
+
props: { state: 'closed', decision, workflow: makeWorkflow() },
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
expect(getByTestId('workflow-review-changes-closed-without-approval')).toBeInTheDocument();
|
|
306
|
+
expect(queryByTestId('workflow-diff-view-stub')).not.toBeInTheDocument();
|
|
307
|
+
},
|
|
308
|
+
);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('renders the diff when versions differ only by an execution flag', () => {
|
|
312
|
+
const { getByTestId, queryByTestId } = renderComponent({
|
|
313
|
+
props: {
|
|
314
|
+
workflow: makeWorkflow({
|
|
315
|
+
pinnedVersion: makeSnapshot({ nodes: [makeNode({ disabled: true })] }),
|
|
316
|
+
baselineVersion: makeSnapshot({
|
|
317
|
+
versionId: '0f123890-0000-0000-0000-000000000000',
|
|
318
|
+
nodes: [makeNode()],
|
|
319
|
+
}),
|
|
320
|
+
}),
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
expect(getByTestId('workflow-diff-view-stub')).toBeInTheDocument();
|
|
325
|
+
expect(queryByTestId('workflow-review-changes-no-changes')).not.toBeInTheDocument();
|
|
326
|
+
});
|
|
327
|
+
});
|