@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,647 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DecideWorkflowReviewRequestResponse,
|
|
3
|
+
InboxWorkflowReviewItem,
|
|
4
|
+
WorkflowReviewRequestDetail,
|
|
5
|
+
} from '@n8n/api-types';
|
|
6
|
+
import { useToast } from '@n8n/composables/useToast';
|
|
7
|
+
import { createComponentRenderer, mockedStore, waitAllPromises } from '@n8n/frontend-test-utils';
|
|
8
|
+
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
|
|
9
|
+
import { createTestingPinia } from '@pinia/testing';
|
|
10
|
+
import userEvent from '@testing-library/user-event';
|
|
11
|
+
|
|
12
|
+
import { useReviewActivityStore } from './reviewActivity.store';
|
|
13
|
+
import { useReviewDetailStore } from './reviewDetail.store';
|
|
14
|
+
import WorkflowReviewDetail from './WorkflowReviewDetail.vue';
|
|
15
|
+
import * as api from './workflowReviews.api';
|
|
16
|
+
|
|
17
|
+
vi.mock('./workflowReviews.api');
|
|
18
|
+
vi.mock('@n8n/composables/useToast', () => ({ useToast: vi.fn() }));
|
|
19
|
+
vi.mock('./components/WorkflowReviewChangesSection.vue', () => ({
|
|
20
|
+
default: { template: '<div data-test-id="workflow-review-changes-section" />' },
|
|
21
|
+
}));
|
|
22
|
+
const showError = vi.fn();
|
|
23
|
+
const showMessage = vi.fn();
|
|
24
|
+
const onItemChange = vi.fn();
|
|
25
|
+
let isInboxActive = true;
|
|
26
|
+
let selectedReviewId: string | null = 'req-1';
|
|
27
|
+
const renderComponent = createComponentRenderer(WorkflowReviewDetail, {
|
|
28
|
+
props: {
|
|
29
|
+
reviewId: 'req-1',
|
|
30
|
+
onItemChange,
|
|
31
|
+
isActive: () => isInboxActive,
|
|
32
|
+
isSelected: (id: string) => selectedReviewId === id,
|
|
33
|
+
},
|
|
34
|
+
global: {
|
|
35
|
+
stubs: {
|
|
36
|
+
WorkflowReviewDecisionPopover: {
|
|
37
|
+
props: ['deciding', 'viewerCanDecide', 'viewerCanComment', 'ineligibilityHint'],
|
|
38
|
+
template: `<div data-test-id="workflow-review-decision-popover" :data-deciding="deciding">
|
|
39
|
+
<button data-test-id="approve-review" @click="$emit('decide', { decision: 'approved' })" />
|
|
40
|
+
<button data-test-id="request-changes" @click="$emit('decide', { decision: 'changes_requested', note: 'needs work' })" />
|
|
41
|
+
<button data-test-id="comment-posted" @click="$emit('comment-posted')" />
|
|
42
|
+
</div>`,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const decisionResponse = (
|
|
49
|
+
overrides: Partial<DecideWorkflowReviewRequestResponse> = {},
|
|
50
|
+
): DecideWorkflowReviewRequestResponse => ({
|
|
51
|
+
id: 'req-1',
|
|
52
|
+
state: 'closed',
|
|
53
|
+
decision: 'approved',
|
|
54
|
+
workflowVersionId: null,
|
|
55
|
+
createdAt: '2024-01-01T00:00:00.000Z',
|
|
56
|
+
updatedAt: '2024-01-02T00:00:00.000Z',
|
|
57
|
+
...overrides,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
let reviewStore: ReturnType<typeof mockedStore<typeof useReviewDetailStore>>;
|
|
61
|
+
let activityStore: ReturnType<typeof mockedStore<typeof useReviewActivityStore>>;
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
createTestingPinia();
|
|
64
|
+
isInboxActive = true;
|
|
65
|
+
selectedReviewId = 'req-1';
|
|
66
|
+
vi.clearAllMocks();
|
|
67
|
+
vi.mocked(useToast).mockReturnValue({ showError, showMessage } as unknown as ReturnType<
|
|
68
|
+
typeof useToast
|
|
69
|
+
>);
|
|
70
|
+
reviewStore = mockedStore(useReviewDetailStore);
|
|
71
|
+
reviewStore.fetchDetail.mockResolvedValue(undefined);
|
|
72
|
+
reviewStore.detail = createDetail();
|
|
73
|
+
reviewStore.decideOnReview.mockResolvedValue(decisionResponse());
|
|
74
|
+
activityStore = mockedStore(useReviewActivityStore);
|
|
75
|
+
activityStore.currentReviewId = 'req-1';
|
|
76
|
+
activityStore.fetchFeed.mockResolvedValue(undefined);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('review decisions', () => {
|
|
80
|
+
it('offers no decision actions for a closed review', async () => {
|
|
81
|
+
reviewStore.detail = createDetail({ state: 'closed', decision: 'approved' });
|
|
82
|
+
|
|
83
|
+
const { queryByTestId } = renderComponent();
|
|
84
|
+
await waitAllPromises();
|
|
85
|
+
|
|
86
|
+
expect(queryByTestId('workflow-review-decision-popover')).not.toBeInTheDocument();
|
|
87
|
+
});
|
|
88
|
+
it('submits an approval for the selected review', async () => {
|
|
89
|
+
const { getByTestId } = renderComponent();
|
|
90
|
+
await waitAllPromises();
|
|
91
|
+
|
|
92
|
+
getByTestId('approve-review').click();
|
|
93
|
+
await waitAllPromises();
|
|
94
|
+
|
|
95
|
+
expect(reviewStore.decideOnReview).toHaveBeenCalledWith('req-1', { decision: 'approved' });
|
|
96
|
+
expect(showError).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
it('refetches the detail after a decision on the selected review', async () => {
|
|
99
|
+
const { getByTestId } = renderComponent();
|
|
100
|
+
await waitAllPromises();
|
|
101
|
+
reviewStore.fetchDetail.mockClear();
|
|
102
|
+
|
|
103
|
+
getByTestId('approve-review').click();
|
|
104
|
+
await waitAllPromises();
|
|
105
|
+
|
|
106
|
+
expect(reviewStore.fetchDetail).toHaveBeenCalledWith('req-1');
|
|
107
|
+
});
|
|
108
|
+
it('submits a change request for the selected review', async () => {
|
|
109
|
+
const { getByTestId } = renderComponent();
|
|
110
|
+
await waitAllPromises();
|
|
111
|
+
|
|
112
|
+
getByTestId('request-changes').click();
|
|
113
|
+
await waitAllPromises();
|
|
114
|
+
|
|
115
|
+
expect(reviewStore.decideOnReview).toHaveBeenCalledWith('req-1', {
|
|
116
|
+
decision: 'changes_requested',
|
|
117
|
+
note: 'needs work',
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
it('shows a success toast when the approval published the workflow', async () => {
|
|
121
|
+
reviewStore.decideOnReview.mockResolvedValueOnce(
|
|
122
|
+
decisionResponse({ autoPublish: { status: 'published' } }),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const { getByTestId } = renderComponent();
|
|
126
|
+
await waitAllPromises();
|
|
127
|
+
|
|
128
|
+
getByTestId('approve-review').click();
|
|
129
|
+
await waitAllPromises();
|
|
130
|
+
|
|
131
|
+
expect(showMessage).toHaveBeenCalledWith({
|
|
132
|
+
type: 'success',
|
|
133
|
+
title: 'Review approved',
|
|
134
|
+
message: 'The reviewed workflow version has been published.',
|
|
135
|
+
});
|
|
136
|
+
expect(showError).not.toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
it('shows a warning toast with the reason when the auto-publish failed', async () => {
|
|
139
|
+
reviewStore.decideOnReview.mockResolvedValueOnce(
|
|
140
|
+
decisionResponse({ autoPublish: { status: 'failed', message: 'Version not found' } }),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const { getByTestId } = renderComponent();
|
|
144
|
+
await waitAllPromises();
|
|
145
|
+
|
|
146
|
+
getByTestId('approve-review').click();
|
|
147
|
+
await waitAllPromises();
|
|
148
|
+
|
|
149
|
+
expect(showMessage).toHaveBeenCalledWith({
|
|
150
|
+
type: 'warning',
|
|
151
|
+
duration: 0,
|
|
152
|
+
title: 'Review approved, but the workflow is not published',
|
|
153
|
+
message: 'Version not found. Publish the workflow manually to retry.',
|
|
154
|
+
});
|
|
155
|
+
expect(showError).not.toHaveBeenCalled();
|
|
156
|
+
});
|
|
157
|
+
it('does not double up punctuation on an already-terminated message', async () => {
|
|
158
|
+
reviewStore.decideOnReview.mockResolvedValueOnce(
|
|
159
|
+
decisionResponse({
|
|
160
|
+
autoPublish: { status: 'failed', message: 'Cannot activate an archived workflow.' },
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const { getByTestId } = renderComponent();
|
|
165
|
+
await waitAllPromises();
|
|
166
|
+
|
|
167
|
+
getByTestId('approve-review').click();
|
|
168
|
+
await waitAllPromises();
|
|
169
|
+
|
|
170
|
+
expect(showMessage).toHaveBeenCalledWith(
|
|
171
|
+
expect.objectContaining({
|
|
172
|
+
message: 'Cannot activate an archived workflow. Publish the workflow manually to retry.',
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
it('shows no publish toast when requesting changes', async () => {
|
|
177
|
+
reviewStore.decideOnReview.mockResolvedValueOnce(
|
|
178
|
+
decisionResponse({ state: 'open', decision: 'changes_requested' }),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const { getByTestId } = renderComponent();
|
|
182
|
+
await waitAllPromises();
|
|
183
|
+
reviewStore.fetchDetail.mockClear();
|
|
184
|
+
|
|
185
|
+
getByTestId('request-changes').click();
|
|
186
|
+
await waitAllPromises();
|
|
187
|
+
|
|
188
|
+
expect(showMessage).not.toHaveBeenCalled();
|
|
189
|
+
expect(reviewStore.fetchDetail).not.toHaveBeenCalled();
|
|
190
|
+
});
|
|
191
|
+
it('shows an error toast when the decision fails', async () => {
|
|
192
|
+
const error = new Error('forbidden');
|
|
193
|
+
reviewStore.decideOnReview.mockRejectedValueOnce(error);
|
|
194
|
+
|
|
195
|
+
const { getByTestId } = renderComponent();
|
|
196
|
+
await waitAllPromises();
|
|
197
|
+
getByTestId('approve-review').click();
|
|
198
|
+
await waitAllPromises();
|
|
199
|
+
|
|
200
|
+
expect(showError).toHaveBeenCalledWith(error, 'Could not submit review decision');
|
|
201
|
+
});
|
|
202
|
+
it('refreshes detail and activity and reports a failed decision', async () => {
|
|
203
|
+
reviewStore.decideOnReview.mockRejectedValueOnce(new Error('conflict'));
|
|
204
|
+
|
|
205
|
+
const { getByTestId } = renderComponent();
|
|
206
|
+
await waitAllPromises();
|
|
207
|
+
reviewStore.fetchDetail.mockClear();
|
|
208
|
+
activityStore.fetchFeed.mockClear();
|
|
209
|
+
getByTestId('approve-review').click();
|
|
210
|
+
await waitAllPromises();
|
|
211
|
+
|
|
212
|
+
expect(onItemChange).toHaveBeenCalledWith({ type: 'workflow_review', id: 'req-1' });
|
|
213
|
+
expect(reviewStore.fetchDetail).toHaveBeenCalledWith('req-1');
|
|
214
|
+
// The other reviewer's decision entry is in the feed, not in the detail payload.
|
|
215
|
+
expect(activityStore.fetchFeed).toHaveBeenCalledWith('req-1');
|
|
216
|
+
// The note stays, so the reviewer can retry with it.
|
|
217
|
+
expect(activityStore.clearDecisionNote).not.toHaveBeenCalled();
|
|
218
|
+
});
|
|
219
|
+
it('shows the submitted decision in the feed and drops the note behind it', async () => {
|
|
220
|
+
reviewStore.decideOnReview.mockResolvedValueOnce(
|
|
221
|
+
decisionResponse({ state: 'open', decision: 'changes_requested' }),
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const { getByTestId } = renderComponent();
|
|
225
|
+
await waitAllPromises();
|
|
226
|
+
activityStore.fetchFeed.mockClear();
|
|
227
|
+
|
|
228
|
+
getByTestId('request-changes').click();
|
|
229
|
+
await waitAllPromises();
|
|
230
|
+
|
|
231
|
+
expect(activityStore.fetchFeed).toHaveBeenCalledWith('req-1');
|
|
232
|
+
// The note it submitted, so a note typed while the decision was in flight survives.
|
|
233
|
+
expect(activityStore.clearDecisionNote).toHaveBeenCalledWith('needs work');
|
|
234
|
+
});
|
|
235
|
+
it('leaves the feed alone when the decision lands after the viewer moved on', async () => {
|
|
236
|
+
let resolveDecision!: () => void;
|
|
237
|
+
reviewStore.decideOnReview.mockImplementationOnce(
|
|
238
|
+
async () =>
|
|
239
|
+
await new Promise<DecideWorkflowReviewRequestResponse>((resolve) => {
|
|
240
|
+
resolveDecision = () =>
|
|
241
|
+
resolve(decisionResponse({ state: 'open', decision: 'changes_requested' }));
|
|
242
|
+
}),
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const { getByTestId, rerender } = renderComponent();
|
|
246
|
+
await waitAllPromises();
|
|
247
|
+
getByTestId('approve-review').click();
|
|
248
|
+
selectedReviewId = 'req-2';
|
|
249
|
+
await rerender({ reviewId: 'req-2' });
|
|
250
|
+
await waitAllPromises();
|
|
251
|
+
activityStore.fetchFeed.mockClear();
|
|
252
|
+
|
|
253
|
+
resolveDecision();
|
|
254
|
+
await waitAllPromises();
|
|
255
|
+
|
|
256
|
+
expect(activityStore.fetchFeed).not.toHaveBeenCalled();
|
|
257
|
+
expect(activityStore.clearDecisionNote).not.toHaveBeenCalled();
|
|
258
|
+
});
|
|
259
|
+
it('shows no publish toast once the viewer has left the page', async () => {
|
|
260
|
+
let resolveDecision!: () => void;
|
|
261
|
+
reviewStore.decideOnReview.mockImplementationOnce(
|
|
262
|
+
async () =>
|
|
263
|
+
await new Promise<DecideWorkflowReviewRequestResponse>((resolve) => {
|
|
264
|
+
resolveDecision = () =>
|
|
265
|
+
resolve(
|
|
266
|
+
decisionResponse({
|
|
267
|
+
autoPublish: { status: 'failed', message: 'Version not found' },
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const { getByTestId, unmount } = renderComponent();
|
|
274
|
+
await waitAllPromises();
|
|
275
|
+
getByTestId('approve-review').click();
|
|
276
|
+
isInboxActive = false;
|
|
277
|
+
unmount();
|
|
278
|
+
|
|
279
|
+
resolveDecision();
|
|
280
|
+
await waitAllPromises();
|
|
281
|
+
|
|
282
|
+
expect(showMessage).not.toHaveBeenCalled();
|
|
283
|
+
});
|
|
284
|
+
it.each(['another review', 'no review'])(
|
|
285
|
+
'reports a publication failure after the viewer selects %s',
|
|
286
|
+
async (selection) => {
|
|
287
|
+
const pending = createDeferredPromise<DecideWorkflowReviewRequestResponse>();
|
|
288
|
+
reviewStore.decideOnReview.mockReturnValueOnce(pending.promise);
|
|
289
|
+
const { getByTestId, rerender, unmount } = renderComponent();
|
|
290
|
+
await waitAllPromises();
|
|
291
|
+
getByTestId('approve-review').click();
|
|
292
|
+
if (selection === 'another review') {
|
|
293
|
+
selectedReviewId = 'req-2';
|
|
294
|
+
await rerender({ reviewId: 'req-2' });
|
|
295
|
+
} else {
|
|
296
|
+
selectedReviewId = null;
|
|
297
|
+
unmount();
|
|
298
|
+
}
|
|
299
|
+
await waitAllPromises();
|
|
300
|
+
activityStore.fetchFeed.mockClear();
|
|
301
|
+
pending.resolve(
|
|
302
|
+
decisionResponse({ autoPublish: { status: 'failed', message: 'Version not found' } }),
|
|
303
|
+
);
|
|
304
|
+
await waitAllPromises();
|
|
305
|
+
|
|
306
|
+
expect(showMessage).toHaveBeenCalledWith(
|
|
307
|
+
expect.objectContaining({
|
|
308
|
+
type: 'warning',
|
|
309
|
+
duration: 0,
|
|
310
|
+
message: 'Version not found. Publish the workflow manually to retry.',
|
|
311
|
+
}),
|
|
312
|
+
);
|
|
313
|
+
expect(activityStore.fetchFeed).not.toHaveBeenCalled();
|
|
314
|
+
},
|
|
315
|
+
);
|
|
316
|
+
it('reports a failed decision after the viewer selects another review', async () => {
|
|
317
|
+
const pending = createDeferredPromise<DecideWorkflowReviewRequestResponse>();
|
|
318
|
+
reviewStore.decideOnReview.mockReturnValueOnce(pending.promise);
|
|
319
|
+
const { getByTestId, rerender } = renderComponent();
|
|
320
|
+
await waitAllPromises();
|
|
321
|
+
getByTestId('approve-review').click();
|
|
322
|
+
selectedReviewId = 'req-2';
|
|
323
|
+
activityStore.currentReviewId = 'req-2';
|
|
324
|
+
await rerender({ reviewId: 'req-2' });
|
|
325
|
+
await waitAllPromises();
|
|
326
|
+
activityStore.fetchFeed.mockClear();
|
|
327
|
+
reviewStore.fetchDetail.mockClear();
|
|
328
|
+
const error = new Error('conflict');
|
|
329
|
+
pending.reject(error);
|
|
330
|
+
await waitAllPromises();
|
|
331
|
+
|
|
332
|
+
expect(showError).toHaveBeenCalledWith(error, 'Could not submit review decision');
|
|
333
|
+
expect(reviewStore.fetchDetail).toHaveBeenCalledWith('req-2');
|
|
334
|
+
expect(activityStore.fetchFeed).not.toHaveBeenCalled();
|
|
335
|
+
});
|
|
336
|
+
it('shows no failure toast once the viewer has left the page', async () => {
|
|
337
|
+
let rejectDecision!: (error: Error) => void;
|
|
338
|
+
reviewStore.decideOnReview.mockImplementationOnce(
|
|
339
|
+
async () =>
|
|
340
|
+
await new Promise<DecideWorkflowReviewRequestResponse>((_resolve, reject) => {
|
|
341
|
+
rejectDecision = reject;
|
|
342
|
+
}),
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
const { getByTestId, unmount } = renderComponent();
|
|
346
|
+
await waitAllPromises();
|
|
347
|
+
getByTestId('approve-review').click();
|
|
348
|
+
isInboxActive = false;
|
|
349
|
+
unmount();
|
|
350
|
+
|
|
351
|
+
rejectDecision(new Error('conflict'));
|
|
352
|
+
await waitAllPromises();
|
|
353
|
+
|
|
354
|
+
expect(showError).not.toHaveBeenCalled();
|
|
355
|
+
expect(onItemChange).toHaveBeenCalledWith({ type: 'workflow_review', id: 'req-1' });
|
|
356
|
+
});
|
|
357
|
+
it('locks the decision actions while a decision is in flight', async () => {
|
|
358
|
+
let resolveDecision!: () => void;
|
|
359
|
+
reviewStore.decideOnReview.mockImplementationOnce(
|
|
360
|
+
async () =>
|
|
361
|
+
await new Promise<DecideWorkflowReviewRequestResponse>((resolve) => {
|
|
362
|
+
resolveDecision = () => resolve(decisionResponse());
|
|
363
|
+
}),
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
const { getByTestId } = renderComponent();
|
|
367
|
+
await waitAllPromises();
|
|
368
|
+
|
|
369
|
+
getByTestId('approve-review').click();
|
|
370
|
+
await vi.waitFor(() => {
|
|
371
|
+
expect(getByTestId('workflow-review-decision-popover')).toHaveAttribute(
|
|
372
|
+
'data-deciding',
|
|
373
|
+
'true',
|
|
374
|
+
);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
resolveDecision();
|
|
378
|
+
await waitAllPromises();
|
|
379
|
+
|
|
380
|
+
expect(getByTestId('workflow-review-decision-popover')).toHaveAttribute(
|
|
381
|
+
'data-deciding',
|
|
382
|
+
'false',
|
|
383
|
+
);
|
|
384
|
+
});
|
|
385
|
+
it('keeps decisions locked after selection changes until the submitted action completes', async () => {
|
|
386
|
+
const pending = createDeferredPromise<DecideWorkflowReviewRequestResponse>();
|
|
387
|
+
reviewStore.decideOnReview.mockReturnValueOnce(pending.promise);
|
|
388
|
+
const { getByTestId, rerender } = renderComponent();
|
|
389
|
+
await waitAllPromises();
|
|
390
|
+
getByTestId('approve-review').click();
|
|
391
|
+
reviewStore.detail = createDetail({ id: 'req-2' });
|
|
392
|
+
selectedReviewId = 'req-2';
|
|
393
|
+
await rerender({ reviewId: 'req-2' });
|
|
394
|
+
await waitAllPromises();
|
|
395
|
+
|
|
396
|
+
expect(getByTestId('workflow-review-decision-popover')).toHaveAttribute(
|
|
397
|
+
'data-deciding',
|
|
398
|
+
'true',
|
|
399
|
+
);
|
|
400
|
+
getByTestId('approve-review').click();
|
|
401
|
+
expect(reviewStore.decideOnReview).toHaveBeenCalledOnce();
|
|
402
|
+
pending.resolve(decisionResponse());
|
|
403
|
+
await waitAllPromises();
|
|
404
|
+
expect(getByTestId('workflow-review-decision-popover')).toHaveAttribute(
|
|
405
|
+
'data-deciding',
|
|
406
|
+
'false',
|
|
407
|
+
);
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
describe('review detail ownership', () => {
|
|
412
|
+
it('loads the selected review and activity on entry', async () => {
|
|
413
|
+
renderComponent();
|
|
414
|
+
await waitAllPromises();
|
|
415
|
+
expect(reviewStore.clearDetail).toHaveBeenCalledOnce();
|
|
416
|
+
expect(activityStore.reset).toHaveBeenCalledOnce();
|
|
417
|
+
expect(reviewStore.fetchDetail).toHaveBeenCalledWith('req-1');
|
|
418
|
+
expect(activityStore.fetchFeed).toHaveBeenCalledWith('req-1');
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('uses the list title while detail is loading', () => {
|
|
422
|
+
reviewStore.detail = null;
|
|
423
|
+
reviewStore.detailLoading = true;
|
|
424
|
+
const { getByTestId } = renderComponent({ props: { listItem: createInboxItem() } });
|
|
425
|
+
expect(getByTestId('workflow-review-request-title')).toHaveTextContent('List review');
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('keeps the list title and row when detail reports a missing review', async () => {
|
|
429
|
+
const { getByTestId } = renderComponent();
|
|
430
|
+
reviewStore.detailNotFound = true;
|
|
431
|
+
await waitAllPromises();
|
|
432
|
+
expect(onItemChange).not.toHaveBeenCalled();
|
|
433
|
+
expect(getByTestId('workflow-review-detail-not-found')).toBeInTheDocument();
|
|
434
|
+
expect(getByTestId('workflow-review-request-title')).toBeInTheDocument();
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('shows the Inbox fallback after a failed detail load without a list item', async () => {
|
|
438
|
+
reviewStore.detail = null;
|
|
439
|
+
const error = new Error('timeout');
|
|
440
|
+
reviewStore.fetchDetail.mockRejectedValueOnce(error);
|
|
441
|
+
const { getByTestId } = renderComponent({
|
|
442
|
+
slots: { default: '<div data-test-id="inbox-fallback">Select an item</div>' },
|
|
443
|
+
});
|
|
444
|
+
await waitAllPromises();
|
|
445
|
+
expect(getByTestId('inbox-fallback')).toBeInTheDocument();
|
|
446
|
+
expect(showError).toHaveBeenCalledWith(error, 'Could not load workflow reviews');
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('refreshes the current review after returning to it and keeps its new note', async () => {
|
|
450
|
+
const pending = createDeferredPromise<DecideWorkflowReviewRequestResponse>();
|
|
451
|
+
reviewStore.clearDetail.mockImplementation(() => {
|
|
452
|
+
reviewStore.selectionRevision++;
|
|
453
|
+
});
|
|
454
|
+
reviewStore.decideOnReview.mockReturnValueOnce(pending.promise);
|
|
455
|
+
const { getByTestId, rerender } = renderComponent();
|
|
456
|
+
await waitAllPromises();
|
|
457
|
+
getByTestId('approve-review').click();
|
|
458
|
+
selectedReviewId = 'req-2';
|
|
459
|
+
await rerender({ reviewId: 'req-2' });
|
|
460
|
+
selectedReviewId = 'req-1';
|
|
461
|
+
await rerender({ reviewId: 'req-1' });
|
|
462
|
+
activityStore.decisionNote = 'New note';
|
|
463
|
+
activityStore.fetchFeed.mockClear();
|
|
464
|
+
pending.resolve(decisionResponse({ autoPublish: { status: 'published' } }));
|
|
465
|
+
await waitAllPromises();
|
|
466
|
+
expect(activityStore.clearDecisionNote).not.toHaveBeenCalled();
|
|
467
|
+
expect(activityStore.fetchFeed).toHaveBeenCalledWith('req-1');
|
|
468
|
+
expect(showMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
|
|
469
|
+
expect(activityStore.decisionNote).toBe('New note');
|
|
470
|
+
expect(onItemChange).toHaveBeenCalledWith({
|
|
471
|
+
type: 'workflow_review',
|
|
472
|
+
id: 'req-1',
|
|
473
|
+
state: 'closed',
|
|
474
|
+
decision: 'approved',
|
|
475
|
+
updatedAt: '2024-01-02T00:00:00.000Z',
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
it.each(['loaded', 'loading'])(
|
|
479
|
+
'shows closed detail and saved activity after returning while approval is pending and detail is %s',
|
|
480
|
+
async (detailState) => {
|
|
481
|
+
createTestingPinia({ stubActions: false });
|
|
482
|
+
reviewStore = mockedStore(useReviewDetailStore);
|
|
483
|
+
activityStore = mockedStore(useReviewActivityStore);
|
|
484
|
+
const pending = createDeferredPromise<DecideWorkflowReviewRequestResponse>();
|
|
485
|
+
const detailRequest = createDeferredPromise<WorkflowReviewRequestDetail>();
|
|
486
|
+
vi.mocked(api.decideWorkflowReviewRequest).mockReturnValueOnce(pending.promise);
|
|
487
|
+
vi.mocked(api.fetchWorkflowReviewRequestDetail).mockImplementation(async (_context, id) =>
|
|
488
|
+
createDetail({ id }),
|
|
489
|
+
);
|
|
490
|
+
vi.mocked(api.fetchWorkflowReviewActivity).mockResolvedValue({
|
|
491
|
+
data: [],
|
|
492
|
+
nextCursor: null,
|
|
493
|
+
hasMore: false,
|
|
494
|
+
});
|
|
495
|
+
const { getByTestId, queryByTestId, rerender } = renderComponent();
|
|
496
|
+
await waitAllPromises();
|
|
497
|
+
getByTestId('approve-review').click();
|
|
498
|
+
selectedReviewId = 'req-2';
|
|
499
|
+
await rerender({ reviewId: 'req-2' });
|
|
500
|
+
await waitAllPromises();
|
|
501
|
+
if (detailState === 'loading') {
|
|
502
|
+
vi.mocked(api.fetchWorkflowReviewRequestDetail).mockReturnValueOnce(detailRequest.promise);
|
|
503
|
+
}
|
|
504
|
+
selectedReviewId = 'req-1';
|
|
505
|
+
await rerender({ reviewId: 'req-1' });
|
|
506
|
+
await waitAllPromises();
|
|
507
|
+
activityStore.decisionNote = 'New note';
|
|
508
|
+
vi.mocked(api.fetchWorkflowReviewRequestDetail).mockResolvedValue(
|
|
509
|
+
createDetail({ state: 'closed', decision: 'approved' }),
|
|
510
|
+
);
|
|
511
|
+
vi.mocked(api.fetchWorkflowReviewActivity).mockResolvedValue({
|
|
512
|
+
data: [
|
|
513
|
+
{
|
|
514
|
+
id: '2',
|
|
515
|
+
type: 'review.approved',
|
|
516
|
+
typeVersion: 1,
|
|
517
|
+
data: null,
|
|
518
|
+
createdBy: null,
|
|
519
|
+
createdAt: '2024-01-02T00:00:00.000Z',
|
|
520
|
+
},
|
|
521
|
+
],
|
|
522
|
+
nextCursor: null,
|
|
523
|
+
hasMore: false,
|
|
524
|
+
});
|
|
525
|
+
pending.resolve(decisionResponse());
|
|
526
|
+
await waitAllPromises();
|
|
527
|
+
detailRequest.resolve(createDetail());
|
|
528
|
+
await waitAllPromises();
|
|
529
|
+
|
|
530
|
+
expect(reviewStore.detail?.state).toBe('closed');
|
|
531
|
+
expect(activityStore.entries).toEqual([expect.objectContaining({ type: 'review.approved' })]);
|
|
532
|
+
expect(queryByTestId('workflow-review-decision-popover')).not.toBeInTheDocument();
|
|
533
|
+
expect(activityStore.decisionNote).toBe('New note');
|
|
534
|
+
},
|
|
535
|
+
);
|
|
536
|
+
it('reports a failed comment after the viewer switches from Activity to Changes', async () => {
|
|
537
|
+
const pending = createDeferredPromise<boolean>();
|
|
538
|
+
activityStore.postComment.mockReturnValueOnce(pending.promise);
|
|
539
|
+
activityStore.draft = 'Sent comment';
|
|
540
|
+
const { getByTestId, queryByTestId, rerender } = renderComponent();
|
|
541
|
+
await waitAllPromises();
|
|
542
|
+
await userEvent.click(getByTestId('send-message-button'));
|
|
543
|
+
await rerender({ tab: 'changes' });
|
|
544
|
+
expect(queryByTestId('workflow-review-comment-composer')).not.toBeInTheDocument();
|
|
545
|
+
const error = new Error('timeout');
|
|
546
|
+
pending.reject(error);
|
|
547
|
+
await waitAllPromises();
|
|
548
|
+
|
|
549
|
+
expect(showError).toHaveBeenCalledWith(error, 'Could not post comment');
|
|
550
|
+
expect(activityStore.draft).toBe('Sent comment');
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it('refreshes a reselected review after its prior detail entry unmounts', async () => {
|
|
554
|
+
const pending = createDeferredPromise<DecideWorkflowReviewRequestResponse>();
|
|
555
|
+
reviewStore.clearDetail.mockImplementation(() => {
|
|
556
|
+
reviewStore.selectionRevision++;
|
|
557
|
+
});
|
|
558
|
+
reviewStore.decideOnReview.mockReturnValueOnce(pending.promise);
|
|
559
|
+
const first = renderComponent();
|
|
560
|
+
await waitAllPromises();
|
|
561
|
+
first.getByTestId('approve-review').click();
|
|
562
|
+
first.unmount();
|
|
563
|
+
renderComponent();
|
|
564
|
+
await waitAllPromises();
|
|
565
|
+
activityStore.decisionNote = 'New note';
|
|
566
|
+
activityStore.fetchFeed.mockClear();
|
|
567
|
+
reviewStore.fetchDetail.mockClear();
|
|
568
|
+
pending.resolve(
|
|
569
|
+
decisionResponse({ autoPublish: { status: 'failed', message: 'Not published' } }),
|
|
570
|
+
);
|
|
571
|
+
await waitAllPromises();
|
|
572
|
+
expect(onItemChange).toHaveBeenCalledWith({
|
|
573
|
+
type: 'workflow_review',
|
|
574
|
+
id: 'req-1',
|
|
575
|
+
state: 'closed',
|
|
576
|
+
decision: 'approved',
|
|
577
|
+
updatedAt: '2024-01-02T00:00:00.000Z',
|
|
578
|
+
});
|
|
579
|
+
expect(activityStore.clearDecisionNote).not.toHaveBeenCalled();
|
|
580
|
+
expect(activityStore.fetchFeed).toHaveBeenCalledWith('req-1');
|
|
581
|
+
expect(reviewStore.fetchDetail).toHaveBeenCalledWith('req-1');
|
|
582
|
+
expect(showMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'warning' }));
|
|
583
|
+
expect(activityStore.decisionNote).toBe('New note');
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it('does not reset shared stores when an older layout unmounts', async () => {
|
|
587
|
+
reviewStore.clearDetail.mockImplementation(() => {
|
|
588
|
+
reviewStore.selectionRevision++;
|
|
589
|
+
});
|
|
590
|
+
const first = renderComponent();
|
|
591
|
+
renderComponent();
|
|
592
|
+
await waitAllPromises();
|
|
593
|
+
activityStore.draft = 'Current draft';
|
|
594
|
+
reviewStore.clearDetail.mockClear();
|
|
595
|
+
activityStore.reset.mockClear();
|
|
596
|
+
first.unmount();
|
|
597
|
+
expect(reviewStore.clearDetail).not.toHaveBeenCalled();
|
|
598
|
+
expect(activityStore.reset).not.toHaveBeenCalled();
|
|
599
|
+
expect(activityStore.draft).toBe('Current draft');
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('ignores an old detail failure after a new layout claims the same review', async () => {
|
|
603
|
+
const pending = createDeferredPromise<undefined>();
|
|
604
|
+
reviewStore.clearDetail.mockImplementation(() => {
|
|
605
|
+
reviewStore.selectionRevision++;
|
|
606
|
+
});
|
|
607
|
+
reviewStore.fetchDetail.mockReturnValueOnce(pending.promise);
|
|
608
|
+
renderComponent();
|
|
609
|
+
renderComponent();
|
|
610
|
+
await waitAllPromises();
|
|
611
|
+
pending.reject(new Error('timeout'));
|
|
612
|
+
await waitAllPromises();
|
|
613
|
+
expect(showError).not.toHaveBeenCalled();
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
function createInboxItem(): InboxWorkflowReviewItem {
|
|
618
|
+
return {
|
|
619
|
+
type: 'workflow_review',
|
|
620
|
+
id: 'req-1',
|
|
621
|
+
projectId: 'proj-1',
|
|
622
|
+
title: 'List review',
|
|
623
|
+
workflowName: 'My workflow',
|
|
624
|
+
workflowVersionId: null,
|
|
625
|
+
requester: null,
|
|
626
|
+
authors: [],
|
|
627
|
+
reviewers: [],
|
|
628
|
+
decision: 'pending',
|
|
629
|
+
state: 'open',
|
|
630
|
+
createdAt: '2024-01-01T00:00:00.000Z',
|
|
631
|
+
updatedAt: '2024-01-01T00:00:00.000Z',
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function createDetail(
|
|
636
|
+
overrides: Partial<WorkflowReviewRequestDetail> = {},
|
|
637
|
+
): WorkflowReviewRequestDetail {
|
|
638
|
+
return {
|
|
639
|
+
...createInboxItem(),
|
|
640
|
+
description: null,
|
|
641
|
+
workflows: [],
|
|
642
|
+
viewerCanDecide: true,
|
|
643
|
+
viewerDecisionIneligibilityReason: null,
|
|
644
|
+
viewerCanComment: true,
|
|
645
|
+
...overrides,
|
|
646
|
+
};
|
|
647
|
+
}
|