@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,568 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DecideWorkflowReviewRequestResponse,
|
|
3
|
+
InboxSelfHealingItem,
|
|
4
|
+
InboxWorkflowReviewItem,
|
|
5
|
+
WorkflowReviewRequestDetail,
|
|
6
|
+
} from '@n8n/api-types';
|
|
7
|
+
import { createComponentRenderer, mockedStore, waitAllPromises } from '@n8n/frontend-test-utils';
|
|
8
|
+
import { useSettingsStore } from '@n8n/stores/settings.store';
|
|
9
|
+
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
|
|
10
|
+
import { createTestingPinia } from '@pinia/testing';
|
|
11
|
+
import { defineComponent, type PropType } from 'vue';
|
|
12
|
+
import { createMemoryHistory, createRouter, RouterView } from 'vue-router';
|
|
13
|
+
|
|
14
|
+
import type { InboxItemChange } from '../inbox.constants';
|
|
15
|
+
import * as inboxApi from '../inbox.api';
|
|
16
|
+
import { InboxModule } from '../inbox.module';
|
|
17
|
+
import { createInboxListSlice, useInboxStore } from '../inbox.store';
|
|
18
|
+
import { useReviewActivityStore } from '../reviews/reviewActivity.store';
|
|
19
|
+
import { useReviewDetailStore } from '../reviews/reviewDetail.store';
|
|
20
|
+
import * as reviewApi from '../reviews/workflowReviews.api';
|
|
21
|
+
import InboxView from './InboxView.vue';
|
|
22
|
+
|
|
23
|
+
vi.mock('../inbox.api');
|
|
24
|
+
vi.mock('../reviews/workflowReviews.api');
|
|
25
|
+
const { showError, showMessage } = vi.hoisted(() => ({
|
|
26
|
+
showError: vi.fn(),
|
|
27
|
+
showMessage: vi.fn(),
|
|
28
|
+
}));
|
|
29
|
+
vi.mock('@n8n/composables/useToast', () => ({
|
|
30
|
+
useToast: () => ({ showError, showMessage }),
|
|
31
|
+
}));
|
|
32
|
+
vi.mock('@n8n/composables/useDocumentTitle', () => ({
|
|
33
|
+
useDocumentTitle: () => ({ set: vi.fn() }),
|
|
34
|
+
}));
|
|
35
|
+
const detailMounted = vi.fn();
|
|
36
|
+
let reportChange: (change: InboxItemChange) => void;
|
|
37
|
+
const router = createRouter({
|
|
38
|
+
history: createMemoryHistory(),
|
|
39
|
+
routes: [
|
|
40
|
+
...(InboxModule.routes ?? []).map((route) =>
|
|
41
|
+
'component' in route ? { ...route, beforeEnter: undefined, component: InboxView } : route,
|
|
42
|
+
),
|
|
43
|
+
{ path: '/:pathMatch(.*)*', name: 'not-found', component: { template: '<div />' } },
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
const renderOptions = {
|
|
47
|
+
global: {
|
|
48
|
+
plugins: [router],
|
|
49
|
+
stubs: {
|
|
50
|
+
InboxList: {
|
|
51
|
+
props: ['sections', 'activeTab'],
|
|
52
|
+
template: `<div>
|
|
53
|
+
<button data-test-id="load-authored" @click="$emit('loadMore', 'authored')" />
|
|
54
|
+
<button data-test-id="refresh-waiting" @click="$emit('refreshSection', 'waiting')" />
|
|
55
|
+
<button data-test-id="retry-waiting" @click="$emit('retry', 'waiting')" />
|
|
56
|
+
<button data-test-id="select-review" @click="$emit('select', { type: 'workflow_review', id: 'req-1' })" />
|
|
57
|
+
<button data-test-id="select-other-review" @click="$emit('select', { type: 'workflow_review', id: 'req-2' })" />
|
|
58
|
+
<button data-test-id="select-result" @click="$emit('select', { type: 'self_healing_result', id: 'req-1', projectId: 'p1', workflowId: 'w1' })" />
|
|
59
|
+
<button data-test-id="clear-review" @click="$emit('clear')" />
|
|
60
|
+
<button data-test-id="select-closed-tab" @click="$emit('update:active-tab', 'closed')" />
|
|
61
|
+
</div>`,
|
|
62
|
+
},
|
|
63
|
+
WorkflowReviewDetail: defineComponent({
|
|
64
|
+
props: {
|
|
65
|
+
reviewId: { type: String, required: true },
|
|
66
|
+
listItem: { type: Object as PropType<InboxWorkflowReviewItem> },
|
|
67
|
+
tab: String,
|
|
68
|
+
onItemChange: {
|
|
69
|
+
type: Function as PropType<(change: InboxItemChange) => void>,
|
|
70
|
+
required: true,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
emits: ['update:tab'],
|
|
74
|
+
setup(props) {
|
|
75
|
+
detailMounted(props.reviewId);
|
|
76
|
+
reportChange = props.onItemChange;
|
|
77
|
+
},
|
|
78
|
+
template: `<div data-test-id="review-detail" :data-id="reviewId" :data-tab="tab" :data-title="listItem?.title">
|
|
79
|
+
<button data-test-id="select-changes-tab" @click="$emit('update:tab', 'changes')" />
|
|
80
|
+
<button data-test-id="select-activity-tab" @click="$emit('update:tab', 'activity')" />
|
|
81
|
+
<slot v-if="!listItem" />
|
|
82
|
+
</div>`,
|
|
83
|
+
}),
|
|
84
|
+
SelfHealingResultDetail: {
|
|
85
|
+
props: ['selection'],
|
|
86
|
+
template: '<div data-test-id="result-detail" :data-id="selection.id" />',
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
const renderComponent = createComponentRenderer(InboxView, renderOptions);
|
|
92
|
+
const renderRoutedComponent = createComponentRenderer(RouterView, renderOptions);
|
|
93
|
+
|
|
94
|
+
let store: ReturnType<typeof mockedStore<typeof useInboxStore>>;
|
|
95
|
+
beforeEach(async () => {
|
|
96
|
+
createTestingPinia();
|
|
97
|
+
vi.clearAllMocks();
|
|
98
|
+
await router.replace('/inbox');
|
|
99
|
+
await router.isReady();
|
|
100
|
+
mockedStore(useSettingsStore).settings.inbox = {
|
|
101
|
+
enabled: true,
|
|
102
|
+
availableTypes: ['workflow_review', 'self_healing_result'],
|
|
103
|
+
failedTypes: [],
|
|
104
|
+
};
|
|
105
|
+
store = mockedStore(useInboxStore);
|
|
106
|
+
store.refreshListAndSummary.mockResolvedValue(undefined);
|
|
107
|
+
store.fetchActiveTab.mockResolvedValue(undefined);
|
|
108
|
+
store.fetchSummary.mockResolvedValue(undefined);
|
|
109
|
+
store.setActiveTab.mockResolvedValue(undefined);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('refreshes the list and summary on mount', async () => {
|
|
113
|
+
renderComponent();
|
|
114
|
+
await waitAllPromises();
|
|
115
|
+
expect(store.refreshListAndSummary).toHaveBeenCalledTimes(1);
|
|
116
|
+
expect(detailMounted).not.toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('keeps the loaded list when navigating between Inbox routes', async () => {
|
|
120
|
+
const { getByTestId, queryByTestId } = renderRoutedComponent();
|
|
121
|
+
await waitAllPromises();
|
|
122
|
+
store.lists.waiting.items = [reviewItem(), resultItem()];
|
|
123
|
+
store.lists.waiting.nextCursor = 'next-page';
|
|
124
|
+
for (const button of ['select-review', 'select-result', 'clear-review']) {
|
|
125
|
+
getByTestId(button).click();
|
|
126
|
+
await waitAllPromises();
|
|
127
|
+
}
|
|
128
|
+
expect(router.currentRoute.value.fullPath).toBe('/inbox');
|
|
129
|
+
expect(queryByTestId('review-detail')).not.toBeInTheDocument();
|
|
130
|
+
expect(queryByTestId('result-detail')).not.toBeInTheDocument();
|
|
131
|
+
expect(store.reset).toHaveBeenCalledOnce();
|
|
132
|
+
expect(store.refreshListAndSummary).toHaveBeenCalledOnce();
|
|
133
|
+
expect(store.lists.waiting.items).toEqual([reviewItem(), resultItem()]);
|
|
134
|
+
expect(store.lists.waiting.nextCursor).toBe('next-page');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('passes the selected review and list fallback to its detail entry', async () => {
|
|
138
|
+
await router.replace('/inbox/reviews/req-1');
|
|
139
|
+
store.lists.waiting.items = [reviewItem()];
|
|
140
|
+
const { getByTestId } = renderComponent();
|
|
141
|
+
expect(getByTestId('review-detail')).toHaveAttribute('data-id', 'req-1');
|
|
142
|
+
expect(getByTestId('review-detail')).toHaveAttribute('data-title', 'List review');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('selects a result with the same ID without rendering the review entry', async () => {
|
|
146
|
+
await router.replace('/inbox/reviews/req-1');
|
|
147
|
+
const { getByTestId, queryByTestId } = renderComponent();
|
|
148
|
+
getByTestId('select-result').click();
|
|
149
|
+
await waitAllPromises();
|
|
150
|
+
expect(router.currentRoute.value.query).toEqual({
|
|
151
|
+
projectId: 'p1',
|
|
152
|
+
workflowId: 'w1',
|
|
153
|
+
});
|
|
154
|
+
expect(router.currentRoute.value.path).toBe('/inbox/assistant-results/req-1');
|
|
155
|
+
expect(getByTestId('result-detail')).toHaveAttribute('data-id', 'req-1');
|
|
156
|
+
expect(queryByTestId('review-detail')).not.toBeInTheDocument();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('clears source context and keeps the current state and tab', async () => {
|
|
160
|
+
await router.replace(
|
|
161
|
+
'/inbox/assistant-results/r1?projectId=p1&workflowId=w1&state=closed&tab=changes',
|
|
162
|
+
);
|
|
163
|
+
const { getByTestId } = renderComponent();
|
|
164
|
+
getByTestId('clear-review').click();
|
|
165
|
+
await waitAllPromises();
|
|
166
|
+
expect(router.currentRoute.value.fullPath).toBe('/inbox?state=closed&tab=changes');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('clears a disabled-source deep link without mounting its detail entry', async () => {
|
|
170
|
+
await router.replace('/inbox/reviews/req-1');
|
|
171
|
+
store.disabledSources = ['workflow_review'];
|
|
172
|
+
renderComponent();
|
|
173
|
+
await waitAllPromises();
|
|
174
|
+
expect(router.currentRoute.value.fullPath).toBe('/inbox');
|
|
175
|
+
expect(detailMounted).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('unmounts the selected detail when its source becomes disabled', async () => {
|
|
179
|
+
await router.replace('/inbox/reviews/req-1');
|
|
180
|
+
const { queryByTestId } = renderComponent();
|
|
181
|
+
store.disabledSources = ['workflow_review'];
|
|
182
|
+
await waitAllPromises();
|
|
183
|
+
expect(router.currentRoute.value.fullPath).toBe('/inbox');
|
|
184
|
+
expect(queryByTestId('review-detail')).not.toBeInTheDocument();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('clears an already-disabled source when a deep link reuses the Inbox view', async () => {
|
|
188
|
+
store.disabledSources = ['workflow_review'];
|
|
189
|
+
renderRoutedComponent();
|
|
190
|
+
await waitAllPromises();
|
|
191
|
+
await router.replace('/inbox/reviews/req-1?state=closed&tab=changes');
|
|
192
|
+
await waitAllPromises();
|
|
193
|
+
expect(router.currentRoute.value.fullPath).toBe('/inbox?state=closed&tab=changes');
|
|
194
|
+
expect(detailMounted).not.toHaveBeenCalled();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it.each([
|
|
198
|
+
['', 'activity'],
|
|
199
|
+
['changes', 'changes'],
|
|
200
|
+
['bogus', 'activity'],
|
|
201
|
+
])('maps the tab query %s to %s', async (query, tab) => {
|
|
202
|
+
await router.replace(`/inbox/reviews/req-1?tab=${query}`);
|
|
203
|
+
const { getByTestId } = renderComponent();
|
|
204
|
+
expect(getByTestId('review-detail')).toHaveAttribute('data-tab', tab);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('writes detail tabs to the URL and keeps selection and state', async () => {
|
|
208
|
+
await router.replace('/inbox/reviews/req-1?state=closed');
|
|
209
|
+
const { getByTestId } = renderComponent();
|
|
210
|
+
getByTestId('select-changes-tab').click();
|
|
211
|
+
await waitAllPromises();
|
|
212
|
+
expect(router.currentRoute.value.query).toEqual({
|
|
213
|
+
state: 'closed',
|
|
214
|
+
tab: 'changes',
|
|
215
|
+
});
|
|
216
|
+
expect(router.currentRoute.value.path).toBe('/inbox/reviews/req-1');
|
|
217
|
+
getByTestId('select-activity-tab').click();
|
|
218
|
+
await waitAllPromises();
|
|
219
|
+
expect(router.currentRoute.value.query).toEqual({
|
|
220
|
+
state: 'closed',
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('follows a selected item to Closed after its detail reports the new state', async () => {
|
|
225
|
+
await router.replace('/inbox/reviews/req-1');
|
|
226
|
+
renderComponent();
|
|
227
|
+
store.refreshListAndSummary.mockClear();
|
|
228
|
+
reportChange({ type: 'workflow_review', id: 'req-1', state: 'closed' });
|
|
229
|
+
await waitAllPromises();
|
|
230
|
+
expect(store.refreshListAndSummary).not.toHaveBeenCalled();
|
|
231
|
+
expect(store.reconcileItemChange).toHaveBeenCalledWith({
|
|
232
|
+
type: 'workflow_review',
|
|
233
|
+
id: 'req-1',
|
|
234
|
+
state: 'closed',
|
|
235
|
+
});
|
|
236
|
+
expect(router.currentRoute.value.fullPath).toBe('/inbox/reviews/req-1?state=closed');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it.each([undefined, 'open'] as const)(
|
|
240
|
+
'reconciles without navigation for state %s',
|
|
241
|
+
async (state) => {
|
|
242
|
+
await router.replace('/inbox/reviews/req-1');
|
|
243
|
+
renderComponent();
|
|
244
|
+
store.refreshListAndSummary.mockClear();
|
|
245
|
+
reportChange({ type: 'workflow_review', id: 'req-1', state });
|
|
246
|
+
await waitAllPromises();
|
|
247
|
+
expect(store.fetchActiveTab).toHaveBeenCalledTimes(state ? 0 : 1);
|
|
248
|
+
expect(store.refreshListAndSummary).not.toHaveBeenCalled();
|
|
249
|
+
expect(router.currentRoute.value.fullPath).toBe('/inbox/reviews/req-1');
|
|
250
|
+
},
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
it('reloads Closed without replacing the URL when a review closes there', async () => {
|
|
254
|
+
await router.replace('/inbox/reviews/req-1?state=closed');
|
|
255
|
+
renderComponent();
|
|
256
|
+
const replace = vi.spyOn(router, 'replace');
|
|
257
|
+
reportChange({ type: 'workflow_review', id: 'req-1', state: 'closed' });
|
|
258
|
+
await waitAllPromises();
|
|
259
|
+
expect(replace).not.toHaveBeenCalled();
|
|
260
|
+
expect(store.fetchActiveTab).toHaveBeenCalledOnce();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it.each(['select-other-review', 'select-result'])(
|
|
264
|
+
'reconciles a late decision without following it after %s',
|
|
265
|
+
async (target) => {
|
|
266
|
+
await router.replace('/inbox/reviews/req-1');
|
|
267
|
+
const { getByTestId } = renderComponent();
|
|
268
|
+
const finishOldDecision = reportChange;
|
|
269
|
+
getByTestId(target).click();
|
|
270
|
+
await waitAllPromises();
|
|
271
|
+
const path = router.currentRoute.value.fullPath;
|
|
272
|
+
store.refreshListAndSummary.mockClear();
|
|
273
|
+
finishOldDecision({ type: 'workflow_review', id: 'req-1', state: 'closed' });
|
|
274
|
+
await waitAllPromises();
|
|
275
|
+
expect(store.refreshListAndSummary).not.toHaveBeenCalled();
|
|
276
|
+
expect(store.reconcileItemChange).toHaveBeenCalledOnce();
|
|
277
|
+
expect(router.currentRoute.value.fullPath).toBe(path);
|
|
278
|
+
},
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
it('does not refresh a failed decision after the Inbox view unmounts', async () => {
|
|
282
|
+
await router.replace('/inbox/reviews/req-1');
|
|
283
|
+
const { unmount } = renderComponent();
|
|
284
|
+
unmount();
|
|
285
|
+
store.refreshListAndSummary.mockClear();
|
|
286
|
+
reportChange({ type: 'workflow_review', id: 'req-1' });
|
|
287
|
+
expect(store.reconcileItemChange).not.toHaveBeenCalled();
|
|
288
|
+
expect(store.fetchActiveTab).not.toHaveBeenCalled();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('leaves another page unchanged when an old entry reports a tab or item change', async () => {
|
|
292
|
+
await router.replace('/inbox/reviews/req-1');
|
|
293
|
+
const { getByTestId } = renderComponent();
|
|
294
|
+
const oldTab = getByTestId('select-changes-tab');
|
|
295
|
+
await router.replace('/settings/roles?tab=roles');
|
|
296
|
+
oldTab.click();
|
|
297
|
+
reportChange({ type: 'workflow_review', id: 'req-1', state: 'closed' });
|
|
298
|
+
await waitAllPromises();
|
|
299
|
+
expect(router.currentRoute.value.fullPath).toBe('/settings/roles?tab=roles');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it.each([
|
|
303
|
+
['/inbox/reviews/req-1', false],
|
|
304
|
+
['/inbox/reviews/req-1', true],
|
|
305
|
+
['/inbox/reviews/req-2', false],
|
|
306
|
+
['/inbox/assistant-results/result-1?projectId=p1&workflowId=w1', false],
|
|
307
|
+
['/other', false],
|
|
308
|
+
])(
|
|
309
|
+
'reconciles approval after leaving the page for %s (pending detail: %s)',
|
|
310
|
+
async (returnPath, detailPending) => {
|
|
311
|
+
const inboxSettings = useSettingsStore().settings.inbox;
|
|
312
|
+
createTestingPinia({ stubActions: false });
|
|
313
|
+
useSettingsStore().settings.inbox = inboxSettings;
|
|
314
|
+
const inbox = useInboxStore();
|
|
315
|
+
const detail = useReviewDetailStore();
|
|
316
|
+
const activity = useReviewActivityStore();
|
|
317
|
+
const pending = createDeferredPromise<DecideWorkflowReviewRequestResponse>();
|
|
318
|
+
const staleDetail = createDeferredPromise<WorkflowReviewRequestDetail>();
|
|
319
|
+
const openReview = {
|
|
320
|
+
...reviewItem(),
|
|
321
|
+
description: null,
|
|
322
|
+
workflows: [],
|
|
323
|
+
viewerCanDecide: true,
|
|
324
|
+
viewerDecisionIneligibilityReason: null,
|
|
325
|
+
viewerCanComment: true,
|
|
326
|
+
} satisfies WorkflowReviewRequestDetail;
|
|
327
|
+
const approvedReview = {
|
|
328
|
+
...openReview,
|
|
329
|
+
state: 'closed',
|
|
330
|
+
decision: 'approved',
|
|
331
|
+
} satisfies WorkflowReviewRequestDetail;
|
|
332
|
+
vi.mocked(reviewApi.decideWorkflowReviewRequest).mockReturnValueOnce(pending.promise);
|
|
333
|
+
vi.mocked(reviewApi.fetchWorkflowReviewRequestDetail).mockImplementation(async (_ctx, id) => ({
|
|
334
|
+
...openReview,
|
|
335
|
+
id,
|
|
336
|
+
}));
|
|
337
|
+
const page = { data: [], nextCursor: null, hasMore: false };
|
|
338
|
+
vi.mocked(reviewApi.fetchWorkflowReviewActivity).mockResolvedValue(page);
|
|
339
|
+
const metadata = { partial: false, failedSources: [], disabledSources: [] };
|
|
340
|
+
vi.mocked(inboxApi.fetchInboxSummary).mockResolvedValue({
|
|
341
|
+
...metadata,
|
|
342
|
+
counts: { open: 2, closed: 0 },
|
|
343
|
+
});
|
|
344
|
+
vi.mocked(inboxApi.fetchInbox).mockImplementation(async (_ctx, query) => ({
|
|
345
|
+
...page,
|
|
346
|
+
...metadata,
|
|
347
|
+
data:
|
|
348
|
+
query.category === 'authored'
|
|
349
|
+
? []
|
|
350
|
+
: query.state === 'closed'
|
|
351
|
+
? [{ ...approvedReview }]
|
|
352
|
+
: [reviewItem(), { ...reviewItem(), id: 'req-2' }],
|
|
353
|
+
}));
|
|
354
|
+
await router.replace('/inbox/reviews/req-1');
|
|
355
|
+
const { getByTestId } = renderRoutedComponent({
|
|
356
|
+
global: {
|
|
357
|
+
stubs: {
|
|
358
|
+
...renderOptions.global.stubs,
|
|
359
|
+
WorkflowReviewDetail: false,
|
|
360
|
+
WorkflowReviewDetailTabs: {
|
|
361
|
+
template:
|
|
362
|
+
'<button data-test-id="approve" @click="$emit(\'decide\', { decision: \'approved\' })" />',
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
await waitAllPromises();
|
|
368
|
+
getByTestId('approve').click();
|
|
369
|
+
await router.replace('/other');
|
|
370
|
+
await waitAllPromises();
|
|
371
|
+
if (detailPending) {
|
|
372
|
+
vi.mocked(reviewApi.fetchWorkflowReviewRequestDetail).mockReturnValueOnce(
|
|
373
|
+
staleDetail.promise,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
await router.replace(returnPath);
|
|
377
|
+
await waitAllPromises();
|
|
378
|
+
activity.decisionNote = 'New note';
|
|
379
|
+
vi.mocked(reviewApi.fetchWorkflowReviewRequestDetail).mockClear();
|
|
380
|
+
vi.mocked(reviewApi.fetchWorkflowReviewActivity).mockClear();
|
|
381
|
+
vi.mocked(reviewApi.fetchWorkflowReviewRequestDetail).mockResolvedValue({ ...approvedReview });
|
|
382
|
+
pending.resolve({ ...approvedReview, autoPublish: { status: 'published' } });
|
|
383
|
+
await waitAllPromises();
|
|
384
|
+
staleDetail.resolve({ ...openReview });
|
|
385
|
+
await waitAllPromises();
|
|
386
|
+
|
|
387
|
+
expect(inbox.openCount).toBe(1);
|
|
388
|
+
expect(inbox.closedCount).toBe(1);
|
|
389
|
+
expect(inbox.lists.waiting.items.map((item) => item.id)).toEqual(['req-2']);
|
|
390
|
+
if (returnPath === '/inbox/reviews/req-1') {
|
|
391
|
+
expect(detail.detail?.state).toBe('closed');
|
|
392
|
+
expect(reviewApi.fetchWorkflowReviewActivity).toHaveBeenCalledExactlyOnceWith(
|
|
393
|
+
expect.anything(),
|
|
394
|
+
'req-1',
|
|
395
|
+
expect.anything(),
|
|
396
|
+
);
|
|
397
|
+
expect(router.currentRoute.value.fullPath).toBe(`${returnPath}?state=closed`);
|
|
398
|
+
} else {
|
|
399
|
+
expect(router.currentRoute.value.fullPath).toBe(returnPath);
|
|
400
|
+
expect(reviewApi.fetchWorkflowReviewRequestDetail).not.toHaveBeenCalled();
|
|
401
|
+
expect(reviewApi.fetchWorkflowReviewActivity).not.toHaveBeenCalled();
|
|
402
|
+
}
|
|
403
|
+
expect(activity.decisionNote).toBe('New note');
|
|
404
|
+
expect(showMessage).not.toHaveBeenCalled();
|
|
405
|
+
expect(showError).not.toHaveBeenCalled();
|
|
406
|
+
},
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
function reviewItem(): InboxWorkflowReviewItem {
|
|
410
|
+
return {
|
|
411
|
+
type: 'workflow_review',
|
|
412
|
+
id: 'req-1',
|
|
413
|
+
projectId: 'proj-1',
|
|
414
|
+
title: 'List review',
|
|
415
|
+
workflowName: 'My workflow',
|
|
416
|
+
workflowVersionId: null,
|
|
417
|
+
requester: null,
|
|
418
|
+
authors: [],
|
|
419
|
+
reviewers: [],
|
|
420
|
+
decision: 'pending',
|
|
421
|
+
state: 'open',
|
|
422
|
+
createdAt: '2024-01-01T00:00:00.000Z',
|
|
423
|
+
updatedAt: '2024-01-01T00:00:00.000Z',
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
function resultItem(): InboxSelfHealingItem {
|
|
427
|
+
return {
|
|
428
|
+
type: 'self_healing_result',
|
|
429
|
+
id: 'req-1',
|
|
430
|
+
projectId: 'p1',
|
|
431
|
+
workflowId: 'w1',
|
|
432
|
+
workflowName: 'Workflow',
|
|
433
|
+
state: 'open',
|
|
434
|
+
outcome: 'fix_ready',
|
|
435
|
+
summary: 'Fix ready',
|
|
436
|
+
createdAt: '2024-01-01T00:00:00.000Z',
|
|
437
|
+
updatedAt: '2024-01-01T00:00:00.000Z',
|
|
438
|
+
completedAt: '2024-01-01T00:00:00.000Z',
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
it('routes group pagination and retry to the named slice', async () => {
|
|
443
|
+
const loadAuthored = vi.spyOn(store.lists.authored, 'loadMore').mockResolvedValue(undefined);
|
|
444
|
+
const retryWaiting = vi.spyOn(store.lists.waiting, 'retry').mockResolvedValue(undefined);
|
|
445
|
+
const { getByTestId } = renderComponent();
|
|
446
|
+
getByTestId('load-authored').click();
|
|
447
|
+
getByTestId('retry-waiting').click();
|
|
448
|
+
await waitAllPromises();
|
|
449
|
+
expect(loadAuthored).toHaveBeenCalledOnce();
|
|
450
|
+
expect(retryWaiting).toHaveBeenCalledOnce();
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('shows the Open empty state only after both groups load successfully', async () => {
|
|
454
|
+
store.lists.waiting.hasLoaded = true;
|
|
455
|
+
const { queryByTestId } = renderComponent();
|
|
456
|
+
await waitAllPromises();
|
|
457
|
+
expect(queryByTestId('inbox-empty')).not.toBeInTheDocument();
|
|
458
|
+
store.lists.authored.hasLoaded = true;
|
|
459
|
+
await waitAllPromises();
|
|
460
|
+
expect(queryByTestId('inbox-empty')).toBeInTheDocument();
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('keeps the main loading state until both Open groups finish', async () => {
|
|
464
|
+
store.lists.waiting.items = [reviewItem()];
|
|
465
|
+
store.lists.waiting.hasLoaded = true;
|
|
466
|
+
store.lists.authored.loading = true;
|
|
467
|
+
const { queryByText } = renderComponent();
|
|
468
|
+
await waitAllPromises();
|
|
469
|
+
expect(queryByText('Open reviews')).not.toBeInTheDocument();
|
|
470
|
+
|
|
471
|
+
store.lists.authored.loading = false;
|
|
472
|
+
store.lists.authored.hasLoaded = true;
|
|
473
|
+
await waitAllPromises();
|
|
474
|
+
expect(queryByText('Open reviews')).toBeInTheDocument();
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('provides the list fallback when review detail has no data', async () => {
|
|
478
|
+
await router.replace('/inbox/reviews/unlisted');
|
|
479
|
+
store.lists.waiting.items = [reviewItem()];
|
|
480
|
+
store.lists.waiting.hasLoaded = true;
|
|
481
|
+
store.lists.authored.loading = true;
|
|
482
|
+
const { queryByText, queryByRole } = renderComponent();
|
|
483
|
+
await waitAllPromises();
|
|
484
|
+
expect(queryByText('Open reviews')).not.toBeInTheDocument();
|
|
485
|
+
|
|
486
|
+
store.lists.authored.loading = false;
|
|
487
|
+
store.lists.authored.hasLoaded = true;
|
|
488
|
+
await waitAllPromises();
|
|
489
|
+
expect(queryByText('Open reviews')).toBeInTheDocument();
|
|
490
|
+
expect(queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument();
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it('does not show an empty Inbox when one group has rows and the other fails', async () => {
|
|
494
|
+
store.lists.waiting.hasLoaded = true;
|
|
495
|
+
store.lists.waiting.items = [reviewItem()];
|
|
496
|
+
store.lists.authored.error = new Error('timeout');
|
|
497
|
+
const { queryByTestId } = renderComponent();
|
|
498
|
+
await waitAllPromises();
|
|
499
|
+
expect(queryByTestId('inbox-empty')).not.toBeInTheDocument();
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it.each(['list', 'loadMore'] as const)(
|
|
503
|
+
'shows the main Retry only for an initial failure after a failed %s request',
|
|
504
|
+
async (failedRequest) => {
|
|
505
|
+
store.lists.waiting.error = new Error('Request failed');
|
|
506
|
+
store.lists.waiting.failedRequest = failedRequest;
|
|
507
|
+
const { queryByRole } = renderComponent();
|
|
508
|
+
await waitAllPromises();
|
|
509
|
+
expect(queryByRole('button', { name: 'Retry' }) !== null).toBe(failedRequest === 'list');
|
|
510
|
+
},
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
it('restarts a partial group after a page failure while retaining exact page retry', async () => {
|
|
514
|
+
const request = vi
|
|
515
|
+
.fn()
|
|
516
|
+
.mockResolvedValueOnce({
|
|
517
|
+
data: [resultItem()],
|
|
518
|
+
nextCursor: 'partial-next',
|
|
519
|
+
hasMore: true,
|
|
520
|
+
partial: true,
|
|
521
|
+
failedSources: ['workflow_review'],
|
|
522
|
+
disabledSources: [],
|
|
523
|
+
})
|
|
524
|
+
.mockRejectedValueOnce(new Error('Page failed'))
|
|
525
|
+
.mockRejectedValueOnce(new Error('Page still failed'))
|
|
526
|
+
.mockResolvedValueOnce({
|
|
527
|
+
data: [reviewItem(), resultItem()],
|
|
528
|
+
nextCursor: null,
|
|
529
|
+
hasMore: false,
|
|
530
|
+
partial: false,
|
|
531
|
+
failedSources: [],
|
|
532
|
+
disabledSources: [],
|
|
533
|
+
});
|
|
534
|
+
const waiting = createInboxListSlice(request, vi.fn());
|
|
535
|
+
store.lists.waiting = waiting;
|
|
536
|
+
await waiting.fetchList();
|
|
537
|
+
await waiting.loadMore();
|
|
538
|
+
const { getByTestId } = renderComponent();
|
|
539
|
+
getByTestId('retry-waiting').click();
|
|
540
|
+
await waitAllPromises();
|
|
541
|
+
expect(request).toHaveBeenLastCalledWith('partial-next');
|
|
542
|
+
expect(waiting.partial).toBe(true);
|
|
543
|
+
getByTestId('refresh-waiting').click();
|
|
544
|
+
await waitAllPromises();
|
|
545
|
+
expect(request).toHaveBeenLastCalledWith();
|
|
546
|
+
expect(waiting.items.map((item) => item.type)).toEqual([
|
|
547
|
+
'workflow_review',
|
|
548
|
+
'self_healing_result',
|
|
549
|
+
]);
|
|
550
|
+
expect(waiting.partial).toBe(false);
|
|
551
|
+
expect(waiting.error).toBeNull();
|
|
552
|
+
expect(waiting.nextCursor).toBeNull();
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it('does not reload the Inbox on a timer or window focus', async () => {
|
|
556
|
+
vi.useFakeTimers();
|
|
557
|
+
try {
|
|
558
|
+
const { unmount } = renderComponent();
|
|
559
|
+
store.refreshListAndSummary.mockClear();
|
|
560
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
561
|
+
window.dispatchEvent(new Event('focus'));
|
|
562
|
+
document.dispatchEvent(new Event('visibilitychange'));
|
|
563
|
+
expect(store.refreshListAndSummary).not.toHaveBeenCalled();
|
|
564
|
+
unmount();
|
|
565
|
+
} finally {
|
|
566
|
+
vi.useRealTimers();
|
|
567
|
+
}
|
|
568
|
+
});
|