@n8n/frontend-module-inbox 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +88 -0
- package/README.md +59 -2
- package/biome.jsonc +4 -0
- package/eslint.config.mjs +4 -0
- package/package.json +62 -3
- package/src/__tests__/design-system-icons.test.ts +24 -0
- package/src/__tests__/setup.ts +4 -0
- package/src/components/InboxEmptyState.vue +86 -0
- package/src/components/InboxList.test.ts +229 -0
- package/src/components/InboxList.vue +195 -0
- package/src/components/InboxListItem.vue +134 -0
- package/src/components/InboxListSection.vue +168 -0
- package/src/composables/useReviewInboxSectionCollapse.test.ts +66 -0
- package/src/composables/useReviewInboxSectionCollapse.ts +42 -0
- package/src/inbox.api.ts +20 -0
- package/src/inbox.constants.ts +16 -0
- package/src/inbox.module.ts +50 -0
- package/src/inbox.routes.test.ts +111 -0
- package/src/inbox.routes.ts +30 -0
- package/src/inbox.store.test.ts +349 -0
- package/src/inbox.store.ts +305 -0
- package/src/index.ts +5 -0
- package/src/reviews/WorkflowReviewDetail.test.ts +647 -0
- package/src/reviews/WorkflowReviewDetail.vue +257 -0
- package/src/reviews/components/WorkflowReviewActivityFeed.test.ts +923 -0
- package/src/reviews/components/WorkflowReviewActivityFeed.vue +312 -0
- package/src/reviews/components/WorkflowReviewChangesSection.test.ts +327 -0
- package/src/reviews/components/WorkflowReviewChangesSection.vue +219 -0
- package/src/reviews/components/WorkflowReviewCommentComposer.test.ts +175 -0
- package/src/reviews/components/WorkflowReviewCommentComposer.vue +83 -0
- package/src/reviews/components/WorkflowReviewDecisionPopover.test.ts +288 -0
- package/src/reviews/components/WorkflowReviewDecisionPopover.vue +203 -0
- package/src/reviews/components/WorkflowReviewDetailMetadata.test.ts +206 -0
- package/src/reviews/components/WorkflowReviewDetailMetadata.vue +241 -0
- package/src/reviews/components/WorkflowReviewDetailTabs.decisionNote.test.ts +90 -0
- package/src/reviews/components/WorkflowReviewDetailTabs.test.ts +434 -0
- package/src/reviews/components/WorkflowReviewDetailTabs.vue +311 -0
- package/src/reviews/components/WorkflowReviewStatusDot.vue +67 -0
- package/src/reviews/components/_activity-card.scss +36 -0
- package/src/reviews/components/activity-entries/WorkflowReviewActivityActorAvatar.vue +50 -0
- package/src/reviews/components/activity-entries/WorkflowReviewActivityComment.test.ts +103 -0
- package/src/reviews/components/activity-entries/WorkflowReviewActivityComment.vue +116 -0
- package/src/reviews/components/activity-entries/WorkflowReviewActivityEventEntry.vue +361 -0
- package/src/reviews/components/activity-entries/WorkflowReviewActivityFallback.vue +49 -0
- package/src/reviews/components/activity-entries/WorkflowReviewActivityWorkflowLink.vue +36 -0
- package/src/reviews/components/activityEntryRegistry.ts +27 -0
- package/src/reviews/constants.ts +41 -0
- package/src/reviews/reviewActivity.store.test.ts +463 -0
- package/src/reviews/reviewActivity.store.ts +186 -0
- package/src/reviews/reviewDetail.store.test.ts +135 -0
- package/src/reviews/reviewDetail.store.ts +74 -0
- package/src/reviews/workflowReviewStatus.utils.ts +44 -0
- package/src/reviews/workflowReviews.api.ts +61 -0
- package/src/reviews/workflowReviews.utils.ts +18 -0
- package/src/views/InboxView.test.ts +525 -0
- package/src/views/InboxView.vue +251 -0
- package/stylelint.config.mjs +9 -0
- package/vite.config.ts +52 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { useToast } from '@n8n/composables/useToast';
|
|
2
|
+
import { createTestingPinia } from '@pinia/testing';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { createComponentRenderer, mockedStore, waitAllPromises } from '@n8n/frontend-test-utils';
|
|
5
|
+
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
|
|
6
|
+
|
|
7
|
+
import { useReviewActivityStore } from '../reviewActivity.store';
|
|
8
|
+
import WorkflowReviewDecisionPopover from './WorkflowReviewDecisionPopover.vue';
|
|
9
|
+
|
|
10
|
+
vi.mock('@n8n/composables/useToast', () => ({
|
|
11
|
+
useToast: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Reka UI popovers do not open in jsdom, so the four primitives N8nPopover imports are
|
|
16
|
+
* stubbed to render inline. Copied rather than imported: the design system keeps its own
|
|
17
|
+
* set private. Reka's trigger machinery goes with them, hence the open button standing in
|
|
18
|
+
* for it — everything after that is the component's own `v-model:open`.
|
|
19
|
+
*/
|
|
20
|
+
const rekaStubs = {
|
|
21
|
+
PopoverRoot: {
|
|
22
|
+
props: ['open'],
|
|
23
|
+
emits: ['update:open'],
|
|
24
|
+
template: `
|
|
25
|
+
<div :data-open="open" data-test-id="popover-root">
|
|
26
|
+
<button data-test-id="open-popover" @click="$emit('update:open', true)" />
|
|
27
|
+
<slot />
|
|
28
|
+
</div>`,
|
|
29
|
+
},
|
|
30
|
+
PopoverTrigger: { template: '<div><slot /></div>' },
|
|
31
|
+
PopoverPortal: { template: '<div><slot /></div>' },
|
|
32
|
+
PopoverContent: { template: '<div><slot /></div>' },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// The real tooltip renders its content in a portal on hover, which jsdom cannot exercise;
|
|
36
|
+
// expose the bindings as attributes instead.
|
|
37
|
+
const tooltipStub = {
|
|
38
|
+
N8nTooltip: {
|
|
39
|
+
props: ['disabled', 'content'],
|
|
40
|
+
template:
|
|
41
|
+
'<div data-test-id="decision-tooltip" :data-disabled="disabled" :data-content="content"><slot /></div>',
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const renderComponent = createComponentRenderer(WorkflowReviewDecisionPopover, {
|
|
46
|
+
global: { stubs: { ...rekaStubs, ...tooltipStub } },
|
|
47
|
+
props: {
|
|
48
|
+
deciding: false,
|
|
49
|
+
viewerCanDecide: true,
|
|
50
|
+
viewerCanComment: true,
|
|
51
|
+
ineligibilityHint: '',
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function tooltipOf(button: HTMLElement) {
|
|
56
|
+
const tooltip = button.closest('[data-test-id="decision-tooltip"]');
|
|
57
|
+
if (!tooltip) throw new Error('button is not wrapped in a tooltip');
|
|
58
|
+
return tooltip;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const showError = vi.fn();
|
|
62
|
+
|
|
63
|
+
describe('WorkflowReviewDecisionPopover', () => {
|
|
64
|
+
let store: ReturnType<typeof mockedStore<typeof useReviewActivityStore>>;
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
createTestingPinia();
|
|
68
|
+
showError.mockReset();
|
|
69
|
+
vi.mocked(useToast).mockReturnValue({ showError } as unknown as ReturnType<typeof useToast>);
|
|
70
|
+
store = mockedStore(useReviewActivityStore);
|
|
71
|
+
store.decisionNote = '';
|
|
72
|
+
store.posting = false;
|
|
73
|
+
store.postComment.mockResolvedValue(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('needs a note before the reviewer can request changes or comment', async () => {
|
|
77
|
+
const { getByTestId } = renderComponent();
|
|
78
|
+
|
|
79
|
+
const requestChanges = getByTestId('workflow-review-decision-request-changes-button');
|
|
80
|
+
expect(requestChanges).toBeDisabled();
|
|
81
|
+
expect(getByTestId('workflow-review-decision-comment-button')).toBeDisabled();
|
|
82
|
+
expect(tooltipOf(requestChanges)).toHaveAttribute(
|
|
83
|
+
'data-content',
|
|
84
|
+
'Add a note to request changes',
|
|
85
|
+
);
|
|
86
|
+
expect(tooltipOf(requestChanges)).toHaveAttribute('data-disabled', 'false');
|
|
87
|
+
|
|
88
|
+
await userEvent.type(getByTestId('workflow-review-decision-note'), 'Please add retries');
|
|
89
|
+
|
|
90
|
+
expect(requestChanges).not.toBeDisabled();
|
|
91
|
+
expect(getByTestId('workflow-review-decision-comment-button')).not.toBeDisabled();
|
|
92
|
+
expect(tooltipOf(requestChanges)).toHaveAttribute('data-disabled', 'true');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('reports a failed comment after the selected review changes', async () => {
|
|
96
|
+
const pending = createDeferredPromise<boolean>();
|
|
97
|
+
store.postComment.mockReturnValueOnce(pending.promise);
|
|
98
|
+
store.decisionNote = 'Sent comment';
|
|
99
|
+
const { getByTestId, unmount } = renderComponent();
|
|
100
|
+
await userEvent.click(getByTestId('workflow-review-decision-comment-button'));
|
|
101
|
+
unmount();
|
|
102
|
+
store.decisionNote = 'New note';
|
|
103
|
+
const error = new Error('timeout');
|
|
104
|
+
pending.reject(error);
|
|
105
|
+
await waitAllPromises();
|
|
106
|
+
|
|
107
|
+
expect(showError).toHaveBeenCalledWith(error, 'Could not post comment');
|
|
108
|
+
expect(store.decisionNote).toBe('New note');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it.each<[string, { viewerCanComment?: boolean; deciding?: boolean }, boolean]>([
|
|
112
|
+
['a viewer who is not allowed to comment', { viewerCanComment: false }, false],
|
|
113
|
+
['a comment that is already being posted', {}, true],
|
|
114
|
+
['a reviewer whose decision is in flight', { deciding: true }, false],
|
|
115
|
+
])('offers no Comment button to %s, note or not', async (_label, props, posting) => {
|
|
116
|
+
store.posting = posting;
|
|
117
|
+
|
|
118
|
+
const { getByTestId } = renderComponent({ props });
|
|
119
|
+
|
|
120
|
+
await userEvent.type(getByTestId('workflow-review-decision-note'), 'Just a thought');
|
|
121
|
+
|
|
122
|
+
expect(getByTestId('workflow-review-decision-comment-button')).toBeDisabled();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Posting a comment leaves its text in the box on purpose, so a decision taken meanwhile
|
|
126
|
+
// would send the very same text a second time as its note.
|
|
127
|
+
it.each<[string, { posting: boolean; deciding: boolean }]>([
|
|
128
|
+
['a comment is still being posted', { posting: true, deciding: false }],
|
|
129
|
+
['a decision is already in flight', { posting: false, deciding: true }],
|
|
130
|
+
])('offers no decision while %s', async (_label, { posting, deciding }) => {
|
|
131
|
+
store.posting = posting;
|
|
132
|
+
|
|
133
|
+
const { getByTestId } = renderComponent({ props: { deciding } });
|
|
134
|
+
|
|
135
|
+
await userEvent.type(getByTestId('workflow-review-decision-note'), 'Please add retries');
|
|
136
|
+
|
|
137
|
+
expect(getByTestId('workflow-review-decision-request-changes-button')).toBeDisabled();
|
|
138
|
+
expect(getByTestId('workflow-review-decision-approve-button')).toBeDisabled();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('submits a change request with the note', async () => {
|
|
142
|
+
const { getByTestId, emitted } = renderComponent();
|
|
143
|
+
|
|
144
|
+
await userEvent.type(getByTestId('workflow-review-decision-note'), 'Please add retries');
|
|
145
|
+
await userEvent.click(getByTestId('workflow-review-decision-request-changes-button'));
|
|
146
|
+
|
|
147
|
+
expect(emitted('decide')).toEqual([
|
|
148
|
+
[{ decision: 'changes_requested', note: 'Please add retries' }],
|
|
149
|
+
]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('submits an approval with the note', async () => {
|
|
153
|
+
const { getByTestId, emitted } = renderComponent();
|
|
154
|
+
|
|
155
|
+
await userEvent.type(getByTestId('workflow-review-decision-note'), 'Ship it');
|
|
156
|
+
await userEvent.click(getByTestId('workflow-review-decision-approve-button'));
|
|
157
|
+
|
|
158
|
+
expect(emitted('decide')).toEqual([[{ decision: 'approved', note: 'Ship it' }]]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('approves without a note rather than sending an empty one', async () => {
|
|
162
|
+
const { getByTestId, emitted } = renderComponent();
|
|
163
|
+
|
|
164
|
+
await userEvent.click(getByTestId('workflow-review-decision-approve-button'));
|
|
165
|
+
|
|
166
|
+
expect(emitted('decide')).toEqual([[{ decision: 'approved' }]]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('clears its open state once a decision is submitted', async () => {
|
|
170
|
+
const { getByTestId } = renderComponent();
|
|
171
|
+
|
|
172
|
+
await userEvent.click(getByTestId('open-popover'));
|
|
173
|
+
expect(getByTestId('popover-root')).toHaveAttribute('data-open', 'true');
|
|
174
|
+
|
|
175
|
+
await userEvent.click(getByTestId('workflow-review-decision-approve-button'));
|
|
176
|
+
|
|
177
|
+
expect(getByTestId('popover-root')).toHaveAttribute('data-open', 'false');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('posts the note as a comment and shows the viewer where it landed', async () => {
|
|
181
|
+
const { getByTestId, emitted } = renderComponent();
|
|
182
|
+
|
|
183
|
+
await userEvent.type(getByTestId('workflow-review-decision-note'), 'Just a thought');
|
|
184
|
+
await userEvent.click(getByTestId('workflow-review-decision-comment-button'));
|
|
185
|
+
|
|
186
|
+
expect(store.postComment).toHaveBeenCalledWith('Just a thought');
|
|
187
|
+
expect(emitted('comment-posted')).toHaveLength(1);
|
|
188
|
+
expect(store.clearDecisionNote).toHaveBeenCalledWith('Just a thought');
|
|
189
|
+
expect(emitted('decide')).toBeUndefined();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// The note is only dropped after the post comes back, by which time the viewer may be
|
|
193
|
+
// typing the next one. Whether that text is theirs to drop is the store's rule.
|
|
194
|
+
it('drops the note it posted, not whatever the viewer typed meanwhile', async () => {
|
|
195
|
+
let resolvePost!: (landed: boolean) => void;
|
|
196
|
+
store.postComment.mockReturnValue(
|
|
197
|
+
new Promise((resolve) => {
|
|
198
|
+
resolvePost = resolve;
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const { getByTestId, emitted } = renderComponent();
|
|
203
|
+
|
|
204
|
+
await userEvent.type(getByTestId('workflow-review-decision-note'), 'Just a thought');
|
|
205
|
+
await userEvent.click(getByTestId('workflow-review-decision-comment-button'));
|
|
206
|
+
|
|
207
|
+
store.decisionNote = 'and one more thing';
|
|
208
|
+
resolvePost(true);
|
|
209
|
+
await waitAllPromises();
|
|
210
|
+
|
|
211
|
+
expect(store.clearDecisionNote).toHaveBeenCalledWith('Just a thought');
|
|
212
|
+
expect(emitted('comment-posted')).toHaveLength(1);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// The comment is written either way, but it says nothing about the review now on screen.
|
|
216
|
+
it('leaves the review the viewer moved to alone when the comment lands late', async () => {
|
|
217
|
+
store.postComment.mockResolvedValue(false);
|
|
218
|
+
|
|
219
|
+
const { getByTestId, emitted } = renderComponent();
|
|
220
|
+
|
|
221
|
+
await userEvent.type(getByTestId('workflow-review-decision-note'), 'Just a thought');
|
|
222
|
+
await userEvent.click(getByTestId('workflow-review-decision-comment-button'));
|
|
223
|
+
|
|
224
|
+
expect(store.clearDecisionNote).not.toHaveBeenCalled();
|
|
225
|
+
expect(emitted('comment-posted')).toBeUndefined();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('keeps the note when the comment could not be posted', async () => {
|
|
229
|
+
const error = new Error('nope');
|
|
230
|
+
store.postComment.mockRejectedValueOnce(error);
|
|
231
|
+
|
|
232
|
+
const { getByTestId, emitted } = renderComponent();
|
|
233
|
+
|
|
234
|
+
await userEvent.type(getByTestId('workflow-review-decision-note'), 'Just a thought');
|
|
235
|
+
await userEvent.click(getByTestId('workflow-review-decision-comment-button'));
|
|
236
|
+
|
|
237
|
+
expect(showError).toHaveBeenCalledWith(error, 'Could not post comment');
|
|
238
|
+
expect(store.clearDecisionNote).not.toHaveBeenCalled();
|
|
239
|
+
expect(getByTestId('workflow-review-decision-note')).toHaveValue('Just a thought');
|
|
240
|
+
expect(emitted('comment-posted')).toBeUndefined();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// The draft has to outlive both the popover closing and a switch to another review,
|
|
244
|
+
// so the input is bound to the store rather than to a local ref.
|
|
245
|
+
it('shows the note the review already has and writes edits back', async () => {
|
|
246
|
+
store.decisionNote = 'started earlier';
|
|
247
|
+
|
|
248
|
+
const { getByTestId } = renderComponent();
|
|
249
|
+
|
|
250
|
+
expect(getByTestId('workflow-review-decision-note')).toHaveValue('started earlier');
|
|
251
|
+
|
|
252
|
+
await userEvent.type(getByTestId('workflow-review-decision-note'), ' and continued');
|
|
253
|
+
|
|
254
|
+
expect(store.decisionNote).toBe('started earlier and continued');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('says why the viewer cannot decide instead of just blocking them', () => {
|
|
258
|
+
const { getByTestId } = renderComponent({
|
|
259
|
+
props: {
|
|
260
|
+
viewerCanDecide: false,
|
|
261
|
+
ineligibilityHint: 'You contributed a version to this review.',
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const trigger = getByTestId('workflow-review-decision-trigger');
|
|
266
|
+
expect(trigger).toBeDisabled();
|
|
267
|
+
expect(tooltipOf(trigger)).toHaveAttribute(
|
|
268
|
+
'data-content',
|
|
269
|
+
'You contributed a version to this review.',
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// A wrapper around the trigger takes the popover's own trigger props with it: the click,
|
|
274
|
+
// the expanded state a screen reader announces, and focus coming back on close.
|
|
275
|
+
it('hands a reviewer who may decide the trigger itself rather than a wrapper', () => {
|
|
276
|
+
const { getByTestId } = renderComponent();
|
|
277
|
+
|
|
278
|
+
expect(
|
|
279
|
+
getByTestId('workflow-review-decision-trigger').closest('[data-test-id="decision-tooltip"]'),
|
|
280
|
+
).toBeNull();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('keeps the reviewer out while their decision is in flight', () => {
|
|
284
|
+
const { getByTestId } = renderComponent({ props: { deciding: true } });
|
|
285
|
+
|
|
286
|
+
expect(getByTestId('workflow-review-decision-trigger')).toBeDisabled();
|
|
287
|
+
});
|
|
288
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { WORKFLOW_REVIEW_TEXT_MAX_LENGTH } from '@n8n/api-types';
|
|
3
|
+
import { useToast } from '@n8n/composables/useToast';
|
|
4
|
+
import { N8nButton, N8nIcon, N8nInput, N8nPopover, N8nTooltip } from '@n8n/design-system';
|
|
5
|
+
import { useI18n } from '@n8n/i18n';
|
|
6
|
+
import { storeToRefs } from 'pinia';
|
|
7
|
+
import { computed, ref, useId } from 'vue';
|
|
8
|
+
|
|
9
|
+
import { useReviewActivityStore } from '../reviewActivity.store';
|
|
10
|
+
import type { WorkflowReviewDecisionInput } from '../workflowReviews.api';
|
|
11
|
+
|
|
12
|
+
const props = defineProps<{
|
|
13
|
+
deciding: boolean;
|
|
14
|
+
viewerCanDecide: boolean;
|
|
15
|
+
viewerCanComment: boolean;
|
|
16
|
+
/** Why the viewer may not decide; empty when they may. */
|
|
17
|
+
ineligibilityHint: string;
|
|
18
|
+
}>();
|
|
19
|
+
|
|
20
|
+
const emit = defineEmits<{
|
|
21
|
+
decide: [input: WorkflowReviewDecisionInput];
|
|
22
|
+
'comment-posted': [];
|
|
23
|
+
}>();
|
|
24
|
+
|
|
25
|
+
const i18n = useI18n();
|
|
26
|
+
const { showError } = useToast();
|
|
27
|
+
const store = useReviewActivityStore();
|
|
28
|
+
const { posting, decisionNote } = storeToRefs(store);
|
|
29
|
+
|
|
30
|
+
const isOpen = ref(false);
|
|
31
|
+
const noteInputId = useId();
|
|
32
|
+
|
|
33
|
+
const note = computed(() => decisionNote.value.trim());
|
|
34
|
+
|
|
35
|
+
// Every viewer who may decide may also comment, and a viewer who may not decide cannot open
|
|
36
|
+
// the popover at all, so the last term only guards against those two rules drifting apart.
|
|
37
|
+
const commentDisabled = computed(
|
|
38
|
+
() => posting.value || props.deciding || note.value.length === 0 || !props.viewerCanComment,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// A comment in flight leaves its text in the box on purpose, so without this the same text
|
|
42
|
+
// goes out twice: once as the comment, once as the decision note.
|
|
43
|
+
const decisionDisabled = computed(() => props.deciding || posting.value);
|
|
44
|
+
|
|
45
|
+
function submitDecision(input: WorkflowReviewDecisionInput) {
|
|
46
|
+
isOpen.value = false;
|
|
47
|
+
emit('decide', input);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function onRequestChanges() {
|
|
51
|
+
submitDecision({ decision: 'changes_requested', note: note.value });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function onApprove() {
|
|
55
|
+
// The key is omitted rather than sent empty: the DTO rejects `''`.
|
|
56
|
+
submitDecision(
|
|
57
|
+
note.value ? { decision: 'approved', note: note.value } : { decision: 'approved' },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function onComment() {
|
|
62
|
+
const body = note.value;
|
|
63
|
+
|
|
64
|
+
isOpen.value = false;
|
|
65
|
+
try {
|
|
66
|
+
// A comment that landed after the viewer moved on says nothing about the review they
|
|
67
|
+
// are reading now, so neither its note nor its tab may be touched.
|
|
68
|
+
if (!(await store.postComment(body))) return;
|
|
69
|
+
|
|
70
|
+
store.clearDecisionNote(body);
|
|
71
|
+
// The trigger sits outside the tab panel, so a comment posted from the Changes tab
|
|
72
|
+
// would otherwise land nowhere the viewer can see.
|
|
73
|
+
emit('comment-posted');
|
|
74
|
+
} catch (error) {
|
|
75
|
+
showError(error, i18n.baseText('workflowReviews.detail.activity.error.post'));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
</script>
|
|
79
|
+
|
|
80
|
+
<template>
|
|
81
|
+
<!-- 480px deviates from Figma's 440px on purpose: the three icon+label buttons do not fit
|
|
82
|
+
in 440px, and N8nPopover sets a fixed width, so the row would spill rather than wrap. -->
|
|
83
|
+
<N8nPopover
|
|
84
|
+
v-model:open="isOpen"
|
|
85
|
+
side="bottom"
|
|
86
|
+
align="end"
|
|
87
|
+
width="480px"
|
|
88
|
+
:enable-scrolling="false"
|
|
89
|
+
:content-class="$style.popover"
|
|
90
|
+
>
|
|
91
|
+
<template #trigger>
|
|
92
|
+
<!-- Wrapped only while the viewer may not decide: N8nTooltip does not forward attrs
|
|
93
|
+
on this branch, so the trigger props land on the span — which is what makes the
|
|
94
|
+
hint fire over a natively disabled button, and this trigger cannot open anything
|
|
95
|
+
anyway. An enabled trigger has to stay the bare button, or it loses `aria-expanded`
|
|
96
|
+
and the focus it gets back on close. -->
|
|
97
|
+
<span v-if="ineligibilityHint">
|
|
98
|
+
<N8nTooltip :content="ineligibilityHint" :show-after="300">
|
|
99
|
+
<N8nButton
|
|
100
|
+
size="small"
|
|
101
|
+
:disabled="deciding || !viewerCanDecide"
|
|
102
|
+
data-test-id="workflow-review-decision-trigger"
|
|
103
|
+
>
|
|
104
|
+
{{ i18n.baseText('workflowReviews.detail.decision.trigger') }}
|
|
105
|
+
<N8nIcon icon="chevron-down" />
|
|
106
|
+
</N8nButton>
|
|
107
|
+
</N8nTooltip>
|
|
108
|
+
</span>
|
|
109
|
+
<N8nButton
|
|
110
|
+
v-else
|
|
111
|
+
size="small"
|
|
112
|
+
:disabled="deciding || !viewerCanDecide"
|
|
113
|
+
data-test-id="workflow-review-decision-trigger"
|
|
114
|
+
>
|
|
115
|
+
{{ i18n.baseText('workflowReviews.detail.decision.trigger') }}
|
|
116
|
+
<N8nIcon icon="chevron-down" />
|
|
117
|
+
</N8nButton>
|
|
118
|
+
</template>
|
|
119
|
+
<template #content>
|
|
120
|
+
<div :class="$style.content" data-test-id="workflow-review-decision-popover">
|
|
121
|
+
<N8nInput
|
|
122
|
+
:id="noteInputId"
|
|
123
|
+
v-model="decisionNote"
|
|
124
|
+
:aria-label="i18n.baseText('workflowReviews.detail.decision.note.label')"
|
|
125
|
+
type="textarea"
|
|
126
|
+
:rows="3"
|
|
127
|
+
:maxlength="WORKFLOW_REVIEW_TEXT_MAX_LENGTH"
|
|
128
|
+
:placeholder="i18n.baseText('workflowReviews.detail.activity.composer.placeholder')"
|
|
129
|
+
:class="$style.input"
|
|
130
|
+
data-test-id="workflow-review-decision-note"
|
|
131
|
+
/>
|
|
132
|
+
<div :class="$style.actions">
|
|
133
|
+
<N8nButton
|
|
134
|
+
variant="outline"
|
|
135
|
+
size="small"
|
|
136
|
+
:disabled="commentDisabled"
|
|
137
|
+
data-test-id="workflow-review-decision-comment-button"
|
|
138
|
+
@click="onComment"
|
|
139
|
+
>
|
|
140
|
+
<template #icon>
|
|
141
|
+
<N8nIcon icon="message-square" />
|
|
142
|
+
</template>
|
|
143
|
+
{{ i18n.baseText('workflowReviews.detail.decision.comment') }}
|
|
144
|
+
</N8nButton>
|
|
145
|
+
<N8nTooltip
|
|
146
|
+
:disabled="!!note"
|
|
147
|
+
:content="i18n.baseText('workflowReviews.detail.decision.note.required')"
|
|
148
|
+
:show-after="300"
|
|
149
|
+
>
|
|
150
|
+
<N8nButton
|
|
151
|
+
variant="outline"
|
|
152
|
+
size="small"
|
|
153
|
+
:disabled="!note || decisionDisabled"
|
|
154
|
+
data-test-id="workflow-review-decision-request-changes-button"
|
|
155
|
+
@click="onRequestChanges"
|
|
156
|
+
>
|
|
157
|
+
<template #icon>
|
|
158
|
+
<N8nIcon icon="refresh-cw" />
|
|
159
|
+
</template>
|
|
160
|
+
{{ i18n.baseText('workflowReviews.detail.decision.requestChanges') }}
|
|
161
|
+
</N8nButton>
|
|
162
|
+
</N8nTooltip>
|
|
163
|
+
<N8nButton
|
|
164
|
+
variant="outline"
|
|
165
|
+
size="small"
|
|
166
|
+
:disabled="decisionDisabled"
|
|
167
|
+
data-test-id="workflow-review-decision-approve-button"
|
|
168
|
+
@click="onApprove"
|
|
169
|
+
>
|
|
170
|
+
<template #icon>
|
|
171
|
+
<N8nIcon icon="check" />
|
|
172
|
+
</template>
|
|
173
|
+
{{ i18n.baseText('workflowReviews.detail.decision.approveAndPublish') }}
|
|
174
|
+
</N8nButton>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
</template>
|
|
178
|
+
</N8nPopover>
|
|
179
|
+
</template>
|
|
180
|
+
|
|
181
|
+
<style lang="scss" module>
|
|
182
|
+
.popover {
|
|
183
|
+
padding: var(--spacing--xs);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.content {
|
|
187
|
+
display: flex;
|
|
188
|
+
flex-direction: column;
|
|
189
|
+
gap: var(--spacing--xs);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.input {
|
|
193
|
+
:global(textarea) {
|
|
194
|
+
max-height: 50dvh;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.actions {
|
|
199
|
+
display: flex;
|
|
200
|
+
justify-content: flex-end;
|
|
201
|
+
gap: var(--spacing--2xs);
|
|
202
|
+
}
|
|
203
|
+
</style>
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
WorkflowReviewRequestDetail,
|
|
3
|
+
WorkflowReviewRequestWorkflowDetail,
|
|
4
|
+
} from '@n8n/api-types';
|
|
5
|
+
import { createTestingPinia } from '@pinia/testing';
|
|
6
|
+
import { createMemoryHistory, createRouter } from 'vue-router';
|
|
7
|
+
|
|
8
|
+
import { createComponentRenderer } from '@n8n/frontend-test-utils';
|
|
9
|
+
import { VIEWS } from '@n8n/frontend-constants/views';
|
|
10
|
+
|
|
11
|
+
import WorkflowReviewDetailMetadata from './WorkflowReviewDetailMetadata.vue';
|
|
12
|
+
|
|
13
|
+
const router = createRouter({
|
|
14
|
+
history: createMemoryHistory(),
|
|
15
|
+
routes: [
|
|
16
|
+
{
|
|
17
|
+
path: '/workflow/:workflowId',
|
|
18
|
+
name: VIEWS.WORKFLOW,
|
|
19
|
+
component: { template: '<div />' },
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const renderComponent = createComponentRenderer(WorkflowReviewDetailMetadata, {
|
|
25
|
+
global: { plugins: [router], stubs: { RouterLink: false } },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function makeWorkflowDetail(
|
|
29
|
+
overrides: Partial<WorkflowReviewRequestWorkflowDetail> = {},
|
|
30
|
+
): WorkflowReviewRequestWorkflowDetail {
|
|
31
|
+
return {
|
|
32
|
+
workflowId: 'wf-1',
|
|
33
|
+
workflowName: 'Payment Handler',
|
|
34
|
+
workflowVersionId: 'version-1',
|
|
35
|
+
pinnedVersion: null,
|
|
36
|
+
publishedVersionId: null,
|
|
37
|
+
baselineVersion: null,
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const requester = {
|
|
43
|
+
id: 'requester-1',
|
|
44
|
+
email: 'requester@example.com',
|
|
45
|
+
firstName: 'Rita',
|
|
46
|
+
lastName: 'Requester',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const laterAuthor = {
|
|
50
|
+
id: 'author-2',
|
|
51
|
+
email: 'author@example.com',
|
|
52
|
+
firstName: 'Ada',
|
|
53
|
+
lastName: 'Author',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function makeDetail(
|
|
57
|
+
overrides: Partial<WorkflowReviewRequestDetail> = {},
|
|
58
|
+
): WorkflowReviewRequestDetail {
|
|
59
|
+
return {
|
|
60
|
+
id: 'req-1',
|
|
61
|
+
projectId: 'proj-1',
|
|
62
|
+
title: 'Update Payment Handler',
|
|
63
|
+
requester,
|
|
64
|
+
// The backend always carries the requester in `authors` too.
|
|
65
|
+
authors: [{ ...requester }, laterAuthor],
|
|
66
|
+
reviewers: [
|
|
67
|
+
{
|
|
68
|
+
id: 'reviewer-1',
|
|
69
|
+
email: 'reviewer@example.com',
|
|
70
|
+
firstName: 'Riley',
|
|
71
|
+
lastName: 'Reviewer',
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
decision: 'pending',
|
|
75
|
+
state: 'open',
|
|
76
|
+
createdAt: '2024-01-01T00:00:00.000Z',
|
|
77
|
+
updatedAt: '2024-01-01T00:00:00.000Z',
|
|
78
|
+
description: null,
|
|
79
|
+
workflows: [makeWorkflowDetail()],
|
|
80
|
+
viewerCanDecide: true,
|
|
81
|
+
viewerDecisionIneligibilityReason: null,
|
|
82
|
+
viewerCanComment: true,
|
|
83
|
+
...overrides,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('WorkflowReviewDetailMetadata', () => {
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
createTestingPinia();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('renders the status, people, and workflows', () => {
|
|
93
|
+
const { getByTestId, getByText, queryByText } = renderComponent({
|
|
94
|
+
props: { review: makeDetail() },
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(getByTestId('workflow-review-detail-status-card')).toHaveTextContent(
|
|
98
|
+
'Open | Waiting for review',
|
|
99
|
+
);
|
|
100
|
+
expect(getByText('Riley Reviewer')).toBeInTheDocument();
|
|
101
|
+
expect(queryByText('reviewer@example.com')).not.toBeInTheDocument();
|
|
102
|
+
expect(getByText('Payment Handler')).toBeInTheDocument();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('lists requested by, other authors, and reviewers in that order', () => {
|
|
106
|
+
const { getByTestId } = renderComponent({
|
|
107
|
+
props: { review: makeDetail() },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(getByTestId('workflow-review-detail-people-card').textContent).toMatch(
|
|
111
|
+
/^Requested by.*Rita Requester.*Other authors.*Ada Author.*Reviewers.*Riley Reviewer/s,
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('excludes the requester from other authors by id, not object identity', () => {
|
|
116
|
+
const { getByTestId } = renderComponent({
|
|
117
|
+
props: { review: makeDetail() },
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(getByTestId('workflow-review-detail-other-authors')).not.toHaveTextContent(
|
|
121
|
+
'Rita Requester',
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('hides other authors when the requester is the only author', () => {
|
|
126
|
+
const { queryByTestId } = renderComponent({
|
|
127
|
+
props: { review: makeDetail({ authors: [{ ...requester }] }) },
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(queryByTestId('workflow-review-detail-other-authors')).not.toBeInTheDocument();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('falls back to a deleted-user message when the requester no longer exists', () => {
|
|
134
|
+
const { getByTestId } = renderComponent({
|
|
135
|
+
props: { review: makeDetail({ requester: null, authors: [laterAuthor] }) },
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(getByTestId('workflow-review-detail-requester-deleted')).toHaveTextContent(
|
|
139
|
+
'Deleted user',
|
|
140
|
+
);
|
|
141
|
+
expect(getByTestId('workflow-review-detail-other-authors')).toHaveTextContent('Ada Author');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('pairs the closed state with an approval decision', () => {
|
|
145
|
+
const { getByTestId } = renderComponent({
|
|
146
|
+
props: { review: makeDetail({ state: 'closed', decision: 'approved' }) },
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(getByTestId('workflow-review-detail-status-card')).toHaveTextContent(
|
|
150
|
+
'Closed | Approved',
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Nobody is waiting on a closed review, so an undecided close reads "No decision".
|
|
155
|
+
it('pairs the closed state with a pending decision as No decision', () => {
|
|
156
|
+
const { getByTestId } = renderComponent({
|
|
157
|
+
props: { review: makeDetail({ state: 'closed', decision: 'pending' }) },
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(getByTestId('workflow-review-detail-status-card')).toHaveTextContent(
|
|
161
|
+
'Closed | No decision',
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('pairs the closed state with a changes-requested decision', () => {
|
|
166
|
+
const { getByTestId } = renderComponent({
|
|
167
|
+
props: { review: makeDetail({ state: 'closed', decision: 'changes_requested' }) },
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(getByTestId('workflow-review-detail-status-card')).toHaveTextContent(
|
|
171
|
+
'Closed | Changes requested',
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('pairs the open state with a changes-requested decision', () => {
|
|
176
|
+
const { getByTestId } = renderComponent({
|
|
177
|
+
props: { review: makeDetail({ state: 'open', decision: 'changes_requested' }) },
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(getByTestId('workflow-review-detail-status-card')).toHaveTextContent(
|
|
181
|
+
'Open | Changes requested',
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('links each workflow to the editor', () => {
|
|
186
|
+
const { getByTestId } = renderComponent({
|
|
187
|
+
props: { review: makeDetail() },
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
expect(getByTestId('workflow-review-detail-workflow-link')).toHaveAttribute(
|
|
191
|
+
'href',
|
|
192
|
+
'/workflow/wf-1',
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('hides the changes card when there are no workflows', () => {
|
|
197
|
+
const { getByTestId, queryByTestId } = renderComponent({
|
|
198
|
+
props: { review: makeDetail({ reviewers: [], workflows: [] }) },
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
expect(getByTestId('workflow-review-detail-no-reviewers')).toHaveTextContent(
|
|
202
|
+
'No reviewers assigned.',
|
|
203
|
+
);
|
|
204
|
+
expect(queryByTestId('workflow-review-detail-changes-card')).not.toBeInTheDocument();
|
|
205
|
+
});
|
|
206
|
+
});
|