@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,90 @@
|
|
|
1
|
+
import type { WorkflowReviewRequestDetail } from '@n8n/api-types';
|
|
2
|
+
import { createTestingPinia } from '@pinia/testing';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { createComponentRenderer } from '@n8n/frontend-test-utils';
|
|
5
|
+
|
|
6
|
+
import { useReviewActivityStore } from '../reviewActivity.store';
|
|
7
|
+
import WorkflowReviewDetailTabs from './WorkflowReviewDetailTabs.vue';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The real decision popover is kept, so this pins the two facts the draft note depends on:
|
|
11
|
+
* it lives in the store, and its trigger sits outside the tab panel that unmounts.
|
|
12
|
+
* Reka UI popovers do not open in jsdom, so its primitives render inline instead.
|
|
13
|
+
*/
|
|
14
|
+
const rekaStubs = {
|
|
15
|
+
PopoverRoot: { template: '<div><slot /></div>' },
|
|
16
|
+
PopoverTrigger: { template: '<div><slot /></div>' },
|
|
17
|
+
PopoverPortal: { template: '<div><slot /></div>' },
|
|
18
|
+
PopoverContent: { template: '<div><slot /></div>' },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const childStubs = {
|
|
22
|
+
WorkflowReviewActivityFeed: { template: '<div><slot name="header" /></div>' },
|
|
23
|
+
WorkflowReviewChangesSection: { template: '<div />' },
|
|
24
|
+
WorkflowReviewCommentComposer: { template: '<div />' },
|
|
25
|
+
WorkflowReviewDetailMetadata: { template: '<aside />' },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const renderComponent = createComponentRenderer(WorkflowReviewDetailTabs, {
|
|
29
|
+
global: { stubs: { ...rekaStubs, ...childStubs } },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const review: WorkflowReviewRequestDetail = {
|
|
33
|
+
id: 'req-1',
|
|
34
|
+
projectId: 'proj-1',
|
|
35
|
+
title: 'Needs review',
|
|
36
|
+
requester: null,
|
|
37
|
+
authors: [],
|
|
38
|
+
reviewers: [],
|
|
39
|
+
decision: 'pending',
|
|
40
|
+
state: 'open',
|
|
41
|
+
createdAt: '2024-01-01T00:00:00.000Z',
|
|
42
|
+
updatedAt: '2024-01-01T00:00:00.000Z',
|
|
43
|
+
description: null,
|
|
44
|
+
workflows: [],
|
|
45
|
+
viewerCanDecide: true,
|
|
46
|
+
viewerDecisionIneligibilityReason: null,
|
|
47
|
+
viewerCanComment: true,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
describe('WorkflowReviewDetailTabs decision note', () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
createTestingPinia();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('keeps a half-written decision note across a tab switch', async () => {
|
|
56
|
+
const { getByTestId, rerender } = renderComponent({
|
|
57
|
+
props: { review, tab: 'activity', deciding: false },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await userEvent.type(getByTestId('workflow-review-decision-note'), 'Half a thought');
|
|
61
|
+
|
|
62
|
+
await rerender({ tab: 'changes' });
|
|
63
|
+
await rerender({ tab: 'activity' });
|
|
64
|
+
|
|
65
|
+
expect(getByTestId('workflow-review-decision-note')).toHaveValue('Half a thought');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// The two facts that make the note survive: it is typed into the store, and the popover
|
|
69
|
+
// sits outside the panel the tab switch unmounts. Asserted separately because either one
|
|
70
|
+
// alone still reads back the note above.
|
|
71
|
+
it('holds the note in the store rather than in the popover', async () => {
|
|
72
|
+
const { getByTestId } = renderComponent({
|
|
73
|
+
props: { review, tab: 'activity', deciding: false },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await userEvent.type(getByTestId('workflow-review-decision-note'), 'Half a thought');
|
|
77
|
+
|
|
78
|
+
expect(useReviewActivityStore().decisionNote).toBe('Half a thought');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('keeps the decision popover out of the tab panel', () => {
|
|
82
|
+
const { getByTestId } = renderComponent({
|
|
83
|
+
props: { review, tab: 'activity', deciding: false },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(getByTestId('workflow-review-activity-panel')).not.toContainElement(
|
|
87
|
+
getByTestId('workflow-review-decision-note'),
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
WorkflowReviewInboxItem,
|
|
3
|
+
WorkflowReviewRequestDetail,
|
|
4
|
+
WorkflowReviewRequestWorkflowDetail,
|
|
5
|
+
} from '@n8n/api-types';
|
|
6
|
+
import { createTestingPinia } from '@pinia/testing';
|
|
7
|
+
import { createComponentRenderer } from '@n8n/frontend-test-utils';
|
|
8
|
+
import userEvent from '@testing-library/user-event';
|
|
9
|
+
|
|
10
|
+
import WorkflowReviewDetailTabs from './WorkflowReviewDetailTabs.vue';
|
|
11
|
+
|
|
12
|
+
vi.mock('./WorkflowReviewChangesSection.vue', () => ({
|
|
13
|
+
default: {
|
|
14
|
+
name: 'WorkflowReviewChangesSection',
|
|
15
|
+
props: ['workflow', 'state', 'decision'],
|
|
16
|
+
template:
|
|
17
|
+
'<div data-test-id="workflow-review-changes-section" :data-workflow-id="workflow.workflowId" :data-state="state" :data-decision="decision" />',
|
|
18
|
+
},
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock('./WorkflowReviewActivityFeed.vue', async () => {
|
|
22
|
+
const { computed, inject } = await import('vue');
|
|
23
|
+
const { ReviewLinkedWorkflowsKey } = await import('../constants');
|
|
24
|
+
return {
|
|
25
|
+
default: {
|
|
26
|
+
name: 'WorkflowReviewActivityFeed',
|
|
27
|
+
setup() {
|
|
28
|
+
const linkedWorkflows = inject(
|
|
29
|
+
ReviewLinkedWorkflowsKey,
|
|
30
|
+
computed(() => new Map()),
|
|
31
|
+
);
|
|
32
|
+
return {
|
|
33
|
+
linkedWorkflowsJson: computed(() =>
|
|
34
|
+
JSON.stringify(Object.fromEntries(linkedWorkflows.value)),
|
|
35
|
+
),
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
template:
|
|
39
|
+
'<div data-test-id="workflow-review-activity-feed" :data-linked-workflows="linkedWorkflowsJson"><slot name="header" /><slot name="footer" /><slot name="composer" /></div>',
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
vi.mock('./WorkflowReviewCommentComposer.vue', () => ({
|
|
45
|
+
default: {
|
|
46
|
+
name: 'WorkflowReviewCommentComposer',
|
|
47
|
+
props: ['canComment'],
|
|
48
|
+
template:
|
|
49
|
+
'<div data-test-id="workflow-review-comment-composer" :data-can-comment="canComment" />',
|
|
50
|
+
},
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
vi.mock('./WorkflowReviewDetailMetadata.vue', () => ({
|
|
54
|
+
default: {
|
|
55
|
+
name: 'WorkflowReviewDetailMetadata',
|
|
56
|
+
template: '<aside data-test-id="workflow-review-detail-metadata" />',
|
|
57
|
+
},
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
// The real popover cannot open in jsdom (Reka UI), so expose what this component passes
|
|
61
|
+
// down as attributes and let a button stand in for the comment it reports back.
|
|
62
|
+
vi.mock('./WorkflowReviewDecisionPopover.vue', () => ({
|
|
63
|
+
default: {
|
|
64
|
+
name: 'WorkflowReviewDecisionPopover',
|
|
65
|
+
props: ['deciding', 'viewerCanDecide', 'viewerCanComment', 'ineligibilityHint'],
|
|
66
|
+
template: `
|
|
67
|
+
<div
|
|
68
|
+
data-test-id="workflow-review-decision-popover"
|
|
69
|
+
:data-can-decide="viewerCanDecide"
|
|
70
|
+
:data-ineligibility-hint="ineligibilityHint"
|
|
71
|
+
>
|
|
72
|
+
<button data-test-id="emit-comment-posted" @click="$emit('comment-posted')" />
|
|
73
|
+
</div>`,
|
|
74
|
+
},
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
const renderComponent = createComponentRenderer(WorkflowReviewDetailTabs);
|
|
78
|
+
|
|
79
|
+
function makeInboxItem(overrides: Partial<WorkflowReviewInboxItem> = {}): WorkflowReviewInboxItem {
|
|
80
|
+
return {
|
|
81
|
+
id: 'req-1',
|
|
82
|
+
projectId: 'proj-1',
|
|
83
|
+
title: 'Needs review',
|
|
84
|
+
workflowName: 'My workflow',
|
|
85
|
+
workflowVersionId: null,
|
|
86
|
+
requester: null,
|
|
87
|
+
authors: [],
|
|
88
|
+
reviewers: [],
|
|
89
|
+
decision: 'pending',
|
|
90
|
+
state: 'open',
|
|
91
|
+
createdAt: '2024-01-01T00:00:00.000Z',
|
|
92
|
+
updatedAt: '2024-01-01T00:00:00.000Z',
|
|
93
|
+
...overrides,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function makeWorkflowDetail(
|
|
98
|
+
overrides: Partial<WorkflowReviewRequestWorkflowDetail> = {},
|
|
99
|
+
): WorkflowReviewRequestWorkflowDetail {
|
|
100
|
+
return {
|
|
101
|
+
workflowId: 'wf-1',
|
|
102
|
+
workflowName: 'My workflow',
|
|
103
|
+
workflowVersionId: 'version-1',
|
|
104
|
+
pinnedVersion: null,
|
|
105
|
+
publishedVersionId: null,
|
|
106
|
+
baselineVersion: null,
|
|
107
|
+
...overrides,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function makeDetail(
|
|
112
|
+
overrides: Partial<WorkflowReviewRequestDetail> = {},
|
|
113
|
+
): WorkflowReviewRequestDetail {
|
|
114
|
+
return {
|
|
115
|
+
...makeInboxItem(),
|
|
116
|
+
description: null,
|
|
117
|
+
workflows: [makeWorkflowDetail()],
|
|
118
|
+
viewerCanDecide: true,
|
|
119
|
+
viewerDecisionIneligibilityReason: null,
|
|
120
|
+
viewerCanComment: true,
|
|
121
|
+
...overrides,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
describe('WorkflowReviewDetailTabs', () => {
|
|
126
|
+
beforeEach(() => {
|
|
127
|
+
createTestingPinia();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('lets the viewer switch between the Activity and Changes tabs', async () => {
|
|
131
|
+
const { getByText, emitted } = renderComponent({
|
|
132
|
+
props: { review: makeDetail(), tab: 'activity', deciding: false },
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
getByText('Changes').click();
|
|
136
|
+
|
|
137
|
+
expect(emitted('update:tab')).toEqual([['changes']]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('activity tab', () => {
|
|
141
|
+
it('renders the description when present', () => {
|
|
142
|
+
const { getByTestId, queryByTestId } = renderComponent({
|
|
143
|
+
props: {
|
|
144
|
+
review: makeDetail({ description: 'Adds retry logic' }),
|
|
145
|
+
tab: 'activity',
|
|
146
|
+
deciding: false,
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(getByTestId('workflow-review-description')).toHaveTextContent('Adds retry logic');
|
|
151
|
+
expect(queryByTestId('workflow-review-no-description')).not.toBeInTheDocument();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('renders a fallback when the review has no description', () => {
|
|
155
|
+
const { getByTestId } = renderComponent({
|
|
156
|
+
props: { review: makeDetail(), tab: 'activity', deciding: false },
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(getByTestId('workflow-review-no-description')).toBeInTheDocument();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Both go inside the feed, so they scroll with the entries.
|
|
163
|
+
it('places the description and the composer inside the feed', () => {
|
|
164
|
+
const { getByTestId } = renderComponent({
|
|
165
|
+
props: {
|
|
166
|
+
review: makeDetail({ description: 'Adds retry logic' }),
|
|
167
|
+
tab: 'activity',
|
|
168
|
+
deciding: false,
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const feed = getByTestId('workflow-review-activity-feed');
|
|
173
|
+
const description = getByTestId('workflow-review-description');
|
|
174
|
+
const composer = getByTestId('workflow-review-comment-composer');
|
|
175
|
+
|
|
176
|
+
expect(feed).toContainElement(description);
|
|
177
|
+
expect(feed).toContainElement(composer);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it.each([
|
|
181
|
+
['lets a viewer who may comment use the composer', true],
|
|
182
|
+
['locks the composer for a viewer who may not', false],
|
|
183
|
+
])('%s', (_label, viewerCanComment) => {
|
|
184
|
+
const { getByTestId } = renderComponent({
|
|
185
|
+
props: { review: makeDetail({ viewerCanComment }), tab: 'activity', deciding: false },
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(getByTestId('workflow-review-comment-composer')).toHaveAttribute(
|
|
189
|
+
'data-can-comment',
|
|
190
|
+
String(viewerCanComment),
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('defaults the composer to read-only on a review whose detail never loaded', () => {
|
|
195
|
+
const { getByTestId } = renderComponent({
|
|
196
|
+
props: { review: makeInboxItem(), tab: 'activity', deciding: false },
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(getByTestId('workflow-review-comment-composer')).toHaveAttribute(
|
|
200
|
+
'data-can-comment',
|
|
201
|
+
'false',
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('provides the linked workflows to the feed', () => {
|
|
206
|
+
const { getByTestId } = renderComponent({
|
|
207
|
+
props: { review: makeDetail(), tab: 'activity', deciding: false },
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
expect(getByTestId('workflow-review-activity-feed')).toHaveAttribute(
|
|
211
|
+
'data-linked-workflows',
|
|
212
|
+
JSON.stringify({
|
|
213
|
+
'wf-1': {
|
|
214
|
+
workflowName: 'My workflow',
|
|
215
|
+
pinnedVersionId: 'version-1',
|
|
216
|
+
pinnedVersionName: null,
|
|
217
|
+
},
|
|
218
|
+
}),
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('closed-state callout', () => {
|
|
223
|
+
it('celebrates a review that was approved and published', () => {
|
|
224
|
+
const { getByTestId } = renderComponent({
|
|
225
|
+
props: {
|
|
226
|
+
review: makeDetail({
|
|
227
|
+
state: 'closed',
|
|
228
|
+
decision: 'approved',
|
|
229
|
+
workflows: [makeWorkflowDetail({ publishedVersionId: 'version-1' })],
|
|
230
|
+
}),
|
|
231
|
+
tab: 'activity',
|
|
232
|
+
deciding: false,
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const callout = getByTestId('workflow-review-closed-callout');
|
|
237
|
+
expect(callout).toHaveTextContent('Submission closed');
|
|
238
|
+
expect(callout).toHaveTextContent(
|
|
239
|
+
'The review was approved and the workflow was published.',
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('shows no callout while the review is open', () => {
|
|
244
|
+
const { queryByTestId } = renderComponent({
|
|
245
|
+
props: { review: makeDetail(), tab: 'activity', deciding: false },
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
expect(queryByTestId('workflow-review-closed-callout')).not.toBeInTheDocument();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('shows no callout when the approved version is not the published one', () => {
|
|
252
|
+
const { queryByTestId } = renderComponent({
|
|
253
|
+
props: {
|
|
254
|
+
review: makeDetail({
|
|
255
|
+
state: 'closed',
|
|
256
|
+
decision: 'approved',
|
|
257
|
+
workflows: [makeWorkflowDetail({ publishedVersionId: 'other-version' })],
|
|
258
|
+
}),
|
|
259
|
+
tab: 'activity',
|
|
260
|
+
deciding: false,
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
expect(queryByTestId('workflow-review-closed-callout')).not.toBeInTheDocument();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// A lifecycle close is already summarized by its `review.closed` feed entry,
|
|
268
|
+
// which renders as a callout; a second one here would say the same thing twice.
|
|
269
|
+
it('adds no summary for a review closed without an approval', () => {
|
|
270
|
+
const { queryByTestId } = renderComponent({
|
|
271
|
+
props: {
|
|
272
|
+
review: makeDetail({ state: 'closed', decision: 'changes_requested' }),
|
|
273
|
+
tab: 'activity',
|
|
274
|
+
deciding: false,
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
expect(queryByTestId('workflow-review-closed-callout')).not.toBeInTheDocument();
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('does not render the composer on closed reviews', () => {
|
|
283
|
+
const { getByTestId, queryByTestId } = renderComponent({
|
|
284
|
+
props: {
|
|
285
|
+
review: makeDetail({ state: 'closed', decision: 'approved' }),
|
|
286
|
+
tab: 'activity',
|
|
287
|
+
deciding: false,
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
expect(getByTestId('workflow-review-activity-feed')).toBeInTheDocument();
|
|
292
|
+
expect(queryByTestId('workflow-review-comment-composer')).not.toBeInTheDocument();
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe('changes tab', () => {
|
|
297
|
+
it('renders one item per workflow and opens only the first', () => {
|
|
298
|
+
const { getAllByTestId } = renderComponent({
|
|
299
|
+
props: {
|
|
300
|
+
review: makeDetail({
|
|
301
|
+
workflows: [
|
|
302
|
+
makeWorkflowDetail(),
|
|
303
|
+
makeWorkflowDetail({ workflowId: 'wf-2', workflowName: 'Other workflow' }),
|
|
304
|
+
],
|
|
305
|
+
}),
|
|
306
|
+
tab: 'changes',
|
|
307
|
+
deciding: false,
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
expect(getAllByTestId('workflow-review-changes-item')).toHaveLength(2);
|
|
312
|
+
expect(getAllByTestId('workflow-review-changes-section')).toHaveLength(1);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('keeps the open workflow when the user switches tabs', async () => {
|
|
316
|
+
const review = makeDetail({
|
|
317
|
+
workflows: [makeWorkflowDetail(), makeWorkflowDetail({ workflowId: 'wf-2' })],
|
|
318
|
+
});
|
|
319
|
+
const { getAllByTestId, getByTestId, rerender } = renderComponent({
|
|
320
|
+
props: { review, tab: 'changes', deciding: false },
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
await userEvent.click(getAllByTestId('workflow-review-changes-item-trigger')[1]);
|
|
324
|
+
await rerender({ review, tab: 'activity', deciding: false });
|
|
325
|
+
await rerender({ review, tab: 'changes', deciding: false });
|
|
326
|
+
|
|
327
|
+
expect(getByTestId('workflow-review-changes-section')).toHaveAttribute(
|
|
328
|
+
'data-workflow-id',
|
|
329
|
+
'wf-2',
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('opens the first workflow again when another review is selected', async () => {
|
|
334
|
+
const workflows = [makeWorkflowDetail(), makeWorkflowDetail({ workflowId: 'wf-2' })];
|
|
335
|
+
const { getAllByTestId, getByTestId, rerender } = renderComponent({
|
|
336
|
+
props: { review: makeDetail({ workflows }), tab: 'changes', deciding: false },
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
await userEvent.click(getAllByTestId('workflow-review-changes-item-trigger')[1]);
|
|
340
|
+
await rerender({
|
|
341
|
+
review: makeDetail({ id: 'req-2', workflows }),
|
|
342
|
+
tab: 'changes',
|
|
343
|
+
deciding: false,
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
expect(getByTestId('workflow-review-changes-section')).toHaveAttribute(
|
|
347
|
+
'data-workflow-id',
|
|
348
|
+
'wf-1',
|
|
349
|
+
);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// The detail lists only the workflows the viewer can read, so a refresh can drop one.
|
|
353
|
+
it('opens the first workflow when a refresh removes the open one', async () => {
|
|
354
|
+
const first = makeWorkflowDetail();
|
|
355
|
+
const third = makeWorkflowDetail({ workflowId: 'wf-3' });
|
|
356
|
+
const { getAllByTestId, findByTestId, rerender } = renderComponent({
|
|
357
|
+
props: {
|
|
358
|
+
review: makeDetail({
|
|
359
|
+
workflows: [first, makeWorkflowDetail({ workflowId: 'wf-2' }), third],
|
|
360
|
+
}),
|
|
361
|
+
tab: 'changes',
|
|
362
|
+
deciding: false,
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
await userEvent.click(getAllByTestId('workflow-review-changes-item-trigger')[1]);
|
|
367
|
+
await rerender({
|
|
368
|
+
review: makeDetail({ workflows: [first, third] }),
|
|
369
|
+
tab: 'changes',
|
|
370
|
+
deciding: false,
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
expect(await findByTestId('workflow-review-changes-section')).toHaveAttribute(
|
|
374
|
+
'data-workflow-id',
|
|
375
|
+
'wf-1',
|
|
376
|
+
);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// A closed review keeps its diff: the backend serves the baseline frozen at
|
|
380
|
+
// approval, and the section needs the lifecycle to phrase the sides.
|
|
381
|
+
it('renders the diff for a closed review and forwards its lifecycle', () => {
|
|
382
|
+
const { getByTestId } = renderComponent({
|
|
383
|
+
props: {
|
|
384
|
+
review: makeDetail({ state: 'closed', decision: 'approved' }),
|
|
385
|
+
tab: 'changes',
|
|
386
|
+
deciding: false,
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
const section = getByTestId('workflow-review-changes-section');
|
|
391
|
+
expect(section).toHaveAttribute('data-state', 'closed');
|
|
392
|
+
expect(section).toHaveAttribute('data-decision', 'approved');
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('shows an unavailable state when the detail has no workflows left', () => {
|
|
396
|
+
const { getByTestId } = renderComponent({
|
|
397
|
+
props: {
|
|
398
|
+
review: makeDetail({ state: 'closed', workflows: [] }),
|
|
399
|
+
tab: 'changes',
|
|
400
|
+
deciding: false,
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
expect(getByTestId('workflow-review-changes-workflow-unavailable')).toBeInTheDocument();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('shows an error state when the detail fetch failed', () => {
|
|
408
|
+
const { getByTestId, queryByTestId } = renderComponent({
|
|
409
|
+
props: { review: makeInboxItem(), tab: 'changes', deciding: false },
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
expect(getByTestId('workflow-review-changes-unavailable')).toBeInTheDocument();
|
|
413
|
+
expect(queryByTestId('workflow-review-changes-workflow-unavailable')).not.toBeInTheDocument();
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
describe('decision actions', () => {
|
|
418
|
+
// The trigger lives outside the tab panel, so a comment posted from the Changes tab
|
|
419
|
+
// would otherwise succeed with nothing to show for it.
|
|
420
|
+
it('switches to the activity tab once a comment was posted from the popover', () => {
|
|
421
|
+
const { getByTestId, emitted } = renderComponent({
|
|
422
|
+
props: { review: makeDetail(), tab: 'changes', deciding: false },
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
getByTestId('emit-comment-posted').click();
|
|
426
|
+
|
|
427
|
+
expect(emitted('update:tab')).toEqual([['activity']]);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('offers no decision on a closed review', () => {
|
|
431
|
+
const { queryByTestId } = renderComponent({
|
|
432
|
+
props: {
|
|
433
|
+
review: makeDetail({ state: 'closed', decision: 'approved' }),
|
|
434
|
+
tab: 'activity',
|
|
435
|
+
deciding: false,
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
expect(queryByTestId('workflow-review-decision-popover')).not.toBeInTheDocument();
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('offers no decision on a review whose detail payload never loaded', () => {
|
|
443
|
+
const { queryByTestId } = renderComponent({
|
|
444
|
+
props: { review: makeInboxItem(), tab: 'activity', deciding: false },
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
expect(queryByTestId('workflow-review-decision-popover')).not.toBeInTheDocument();
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('leaves the decision actions open with no hint when the viewer can decide', () => {
|
|
451
|
+
const { getByTestId } = renderComponent({
|
|
452
|
+
props: { review: makeDetail(), tab: 'activity', deciding: false },
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const popover = getByTestId('workflow-review-decision-popover');
|
|
456
|
+
expect(popover).toHaveAttribute('data-can-decide', 'true');
|
|
457
|
+
expect(popover).toHaveAttribute('data-ineligibility-hint', '');
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('says the viewer contributed a version when that is why they cannot decide', () => {
|
|
461
|
+
const { getByTestId } = renderComponent({
|
|
462
|
+
props: {
|
|
463
|
+
review: makeDetail({
|
|
464
|
+
viewerCanDecide: false,
|
|
465
|
+
viewerDecisionIneligibilityReason: 'author',
|
|
466
|
+
}),
|
|
467
|
+
tab: 'activity',
|
|
468
|
+
deciding: false,
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
const popover = getByTestId('workflow-review-decision-popover');
|
|
473
|
+
expect(popover).toHaveAttribute('data-can-decide', 'false');
|
|
474
|
+
expect(popover).toHaveAttribute(
|
|
475
|
+
'data-ineligibility-hint',
|
|
476
|
+
'You contributed a version to this review.',
|
|
477
|
+
);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('falls back to the generic permission hint for any other reason', () => {
|
|
481
|
+
const { getByTestId } = renderComponent({
|
|
482
|
+
props: {
|
|
483
|
+
review: makeDetail({
|
|
484
|
+
viewerCanDecide: false,
|
|
485
|
+
viewerDecisionIneligibilityReason: 'missing_reviewer_permission',
|
|
486
|
+
}),
|
|
487
|
+
tab: 'activity',
|
|
488
|
+
deciding: false,
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
const popover = getByTestId('workflow-review-decision-popover');
|
|
493
|
+
expect(popover).toHaveAttribute('data-can-decide', 'false');
|
|
494
|
+
expect(popover).toHaveAttribute(
|
|
495
|
+
'data-ineligibility-hint',
|
|
496
|
+
'Missing permissions to perform this action',
|
|
497
|
+
);
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
});
|