@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,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,97 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
WorkflowReviewRequestWorkflowDetail,
|
|
3
|
+
WorkflowReviewVersionSnapshot,
|
|
4
|
+
} from '@n8n/api-types';
|
|
5
|
+
import { createTestingPinia } from '@pinia/testing';
|
|
6
|
+
import userEvent from '@testing-library/user-event';
|
|
7
|
+
import { createComponentRenderer } from '@n8n/frontend-test-utils';
|
|
8
|
+
|
|
9
|
+
import WorkflowReviewChangesList from './WorkflowReviewChangesList.vue';
|
|
10
|
+
|
|
11
|
+
vi.mock('./WorkflowReviewChangesSection.vue', () => ({
|
|
12
|
+
default: {
|
|
13
|
+
name: 'WorkflowReviewChangesSection',
|
|
14
|
+
props: ['workflow', 'state', 'decision'],
|
|
15
|
+
template:
|
|
16
|
+
'<div data-test-id="workflow-review-changes-section" :data-workflow-id="workflow.workflowId" />',
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const renderComponent = createComponentRenderer(WorkflowReviewChangesList);
|
|
21
|
+
|
|
22
|
+
function makeSnapshot(
|
|
23
|
+
overrides: Partial<WorkflowReviewVersionSnapshot> = {},
|
|
24
|
+
): WorkflowReviewVersionSnapshot {
|
|
25
|
+
return {
|
|
26
|
+
versionId: 'version-1',
|
|
27
|
+
name: null,
|
|
28
|
+
nodes: [],
|
|
29
|
+
connections: {},
|
|
30
|
+
nodeGroups: [],
|
|
31
|
+
createdAt: '2024-01-01T00:00:00.000Z',
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeWorkflowDetail(
|
|
37
|
+
overrides: Partial<WorkflowReviewRequestWorkflowDetail> = {},
|
|
38
|
+
): WorkflowReviewRequestWorkflowDetail {
|
|
39
|
+
return {
|
|
40
|
+
workflowId: 'wf-1',
|
|
41
|
+
workflowName: 'My workflow',
|
|
42
|
+
workflowVersionId: 'version-1',
|
|
43
|
+
pinnedVersion: makeSnapshot(),
|
|
44
|
+
publishedVersionId: null,
|
|
45
|
+
baselineVersion: null,
|
|
46
|
+
...overrides,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('WorkflowReviewChangesList', () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
createTestingPinia();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('opens one workflow at a time and closes it on a second click', async () => {
|
|
56
|
+
const { getAllByTestId, queryAllByTestId } = renderComponent({
|
|
57
|
+
props: {
|
|
58
|
+
workflows: [
|
|
59
|
+
makeWorkflowDetail(),
|
|
60
|
+
makeWorkflowDetail({ workflowId: 'wf-2', workflowName: 'Other workflow' }),
|
|
61
|
+
],
|
|
62
|
+
state: 'open',
|
|
63
|
+
decision: 'pending',
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
const [firstTrigger, secondTrigger] = getAllByTestId('workflow-review-changes-item-trigger');
|
|
67
|
+
|
|
68
|
+
await userEvent.click(firstTrigger);
|
|
69
|
+
await userEvent.click(secondTrigger);
|
|
70
|
+
|
|
71
|
+
const sections = getAllByTestId('workflow-review-changes-section');
|
|
72
|
+
expect(sections).toHaveLength(1);
|
|
73
|
+
expect(sections[0]).toHaveAttribute('data-workflow-id', 'wf-2');
|
|
74
|
+
|
|
75
|
+
await userEvent.click(secondTrigger);
|
|
76
|
+
|
|
77
|
+
expect(queryAllByTestId('workflow-review-changes-section')).toHaveLength(0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('shows the version next to the workflow name, and only the name when the version is missing', () => {
|
|
81
|
+
const { getAllByTestId } = renderComponent({
|
|
82
|
+
props: {
|
|
83
|
+
workflows: [
|
|
84
|
+
makeWorkflowDetail({ pinnedVersion: makeSnapshot({ name: 'Fix retries' }) }),
|
|
85
|
+
makeWorkflowDetail({ workflowId: 'wf-2', workflowName: 'Pruned', pinnedVersion: null }),
|
|
86
|
+
],
|
|
87
|
+
state: 'open',
|
|
88
|
+
decision: 'pending',
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
const [withVersion, withoutVersion] = getAllByTestId('workflow-review-changes-item-trigger');
|
|
92
|
+
|
|
93
|
+
expect(withVersion).toHaveTextContent('My workflow');
|
|
94
|
+
expect(withVersion).toHaveTextContent('Fix retries');
|
|
95
|
+
expect(withoutVersion).toHaveTextContent(/^Pruned$/);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type {
|
|
3
|
+
WorkflowReviewRequestDecision,
|
|
4
|
+
WorkflowReviewRequestState,
|
|
5
|
+
WorkflowReviewRequestWorkflowDetail,
|
|
6
|
+
} from '@n8n/api-types';
|
|
7
|
+
import { N8nIcon, N8nText } from '@n8n/design-system';
|
|
8
|
+
import {
|
|
9
|
+
AccordionContent,
|
|
10
|
+
AccordionHeader,
|
|
11
|
+
AccordionItem,
|
|
12
|
+
AccordionRoot,
|
|
13
|
+
AccordionTrigger,
|
|
14
|
+
} from 'reka-ui';
|
|
15
|
+
|
|
16
|
+
import { getVersionLabel } from '@n8n/composables/useWorkflowVersionLabel';
|
|
17
|
+
import WorkflowReviewChangesSection from './WorkflowReviewChangesSection.vue';
|
|
18
|
+
|
|
19
|
+
defineProps<{
|
|
20
|
+
workflows: WorkflowReviewRequestWorkflowDetail[];
|
|
21
|
+
state: WorkflowReviewRequestState;
|
|
22
|
+
decision: WorkflowReviewRequestDecision;
|
|
23
|
+
}>();
|
|
24
|
+
|
|
25
|
+
/** The open workflow's id. The parent owns it, so it survives a tab switch. */
|
|
26
|
+
const expanded = defineModel<string | undefined>('expanded');
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<template>
|
|
30
|
+
<!-- Closed items unmount, so only the open workflow pays for its diff canvases. -->
|
|
31
|
+
<AccordionRoot v-model="expanded" type="single" collapsible unmount-on-hide :class="$style.list">
|
|
32
|
+
<AccordionItem
|
|
33
|
+
v-for="workflow in workflows"
|
|
34
|
+
:key="workflow.workflowId"
|
|
35
|
+
:value="workflow.workflowId"
|
|
36
|
+
:class="$style.item"
|
|
37
|
+
data-test-id="workflow-review-changes-item"
|
|
38
|
+
>
|
|
39
|
+
<AccordionHeader :class="$style.header">
|
|
40
|
+
<AccordionTrigger
|
|
41
|
+
:class="$style.trigger"
|
|
42
|
+
data-test-id="workflow-review-changes-item-trigger"
|
|
43
|
+
>
|
|
44
|
+
<N8nIcon icon="chevron-down" size="small" :class="$style.chevron" />
|
|
45
|
+
<N8nText bold size="medium" color="text-dark" :class="$style.name">
|
|
46
|
+
{{ workflow.workflowName }}
|
|
47
|
+
</N8nText>
|
|
48
|
+
<N8nText
|
|
49
|
+
v-if="workflow.pinnedVersion"
|
|
50
|
+
size="small"
|
|
51
|
+
color="text-light"
|
|
52
|
+
:class="$style.version"
|
|
53
|
+
data-test-id="workflow-review-changes-item-version"
|
|
54
|
+
>
|
|
55
|
+
{{ getVersionLabel({ workflowHistory: workflow.pinnedVersion }) }}
|
|
56
|
+
</N8nText>
|
|
57
|
+
</AccordionTrigger>
|
|
58
|
+
</AccordionHeader>
|
|
59
|
+
<AccordionContent :class="$style.content">
|
|
60
|
+
<WorkflowReviewChangesSection :workflow="workflow" :state="state" :decision="decision" />
|
|
61
|
+
</AccordionContent>
|
|
62
|
+
</AccordionItem>
|
|
63
|
+
</AccordionRoot>
|
|
64
|
+
</template>
|
|
65
|
+
|
|
66
|
+
<style module lang="scss">
|
|
67
|
+
.list {
|
|
68
|
+
display: flex;
|
|
69
|
+
flex-direction: column;
|
|
70
|
+
gap: var(--spacing--2xs);
|
|
71
|
+
/* A definite height, so the diff canvas can size itself with percentages. */
|
|
72
|
+
height: 100%;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.item {
|
|
76
|
+
display: flex;
|
|
77
|
+
flex-direction: column;
|
|
78
|
+
flex-shrink: 0;
|
|
79
|
+
border: var(--border);
|
|
80
|
+
border-radius: var(--radius--2xs);
|
|
81
|
+
overflow: hidden;
|
|
82
|
+
|
|
83
|
+
/* Only a diff takes the free height. A callout keeps its natural size. */
|
|
84
|
+
&[data-state='open']:has([data-review-diff]) {
|
|
85
|
+
flex: 1 0 auto;
|
|
86
|
+
min-height: var(--review-diff--min-height, 28rem);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.header {
|
|
91
|
+
margin: 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.trigger {
|
|
95
|
+
display: flex;
|
|
96
|
+
align-items: center;
|
|
97
|
+
gap: var(--spacing--2xs);
|
|
98
|
+
width: 100%;
|
|
99
|
+
padding: var(--spacing--xs) var(--spacing--sm);
|
|
100
|
+
border: none;
|
|
101
|
+
background: var(--color--background--light-3);
|
|
102
|
+
text-align: left;
|
|
103
|
+
cursor: pointer;
|
|
104
|
+
|
|
105
|
+
&:focus-visible {
|
|
106
|
+
outline: var(--focus--border-width) solid var(--focus--outline-color);
|
|
107
|
+
outline-offset: calc(-1 * var(--focus--border-width));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
&[data-state='open'] {
|
|
111
|
+
border-bottom: var(--border);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.chevron {
|
|
116
|
+
flex-shrink: 0;
|
|
117
|
+
color: var(--color--text--tint-1);
|
|
118
|
+
transition: transform 0.15s ease;
|
|
119
|
+
|
|
120
|
+
[data-state='closed'] > & {
|
|
121
|
+
transform: rotate(-90deg);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.name,
|
|
126
|
+
.version {
|
|
127
|
+
overflow: hidden;
|
|
128
|
+
text-overflow: ellipsis;
|
|
129
|
+
white-space: nowrap;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.version {
|
|
133
|
+
flex-shrink: 0;
|
|
134
|
+
max-width: 40%;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.content {
|
|
138
|
+
display: flex;
|
|
139
|
+
flex-direction: column;
|
|
140
|
+
flex: 1;
|
|
141
|
+
min-height: 0;
|
|
142
|
+
}
|
|
143
|
+
</style>
|