@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.
Files changed (62) hide show
  1. package/LICENSE.md +88 -0
  2. package/LICENSE_EE.md +27 -0
  3. package/README.md +61 -2
  4. package/biome.jsonc +4 -0
  5. package/eslint.config.mjs +4 -0
  6. package/package.json +72 -3
  7. package/src/__tests__/design-system-icons.test.ts +24 -0
  8. package/src/__tests__/setup.ts +4 -0
  9. package/src/components/InboxEmptyState.vue +86 -0
  10. package/src/components/InboxList.test.ts +289 -0
  11. package/src/components/InboxList.vue +195 -0
  12. package/src/components/InboxListItem.vue +208 -0
  13. package/src/components/InboxListSection.vue +168 -0
  14. package/src/composables/useReviewInboxSectionCollapse.test.ts +66 -0
  15. package/src/composables/useReviewInboxSectionCollapse.ts +42 -0
  16. package/src/inbox.api.ts +20 -0
  17. package/src/inbox.constants.ts +19 -0
  18. package/src/inbox.module.ts +55 -0
  19. package/src/inbox.routes.test.ts +140 -0
  20. package/src/inbox.routes.ts +49 -0
  21. package/src/inbox.store.test.ts +444 -0
  22. package/src/inbox.store.ts +305 -0
  23. package/src/index.ts +5 -0
  24. package/src/reviews/WorkflowReviewDetail.test.ts +647 -0
  25. package/src/reviews/WorkflowReviewDetail.vue +257 -0
  26. package/src/reviews/components/WorkflowReviewActivityFeed.test.ts +923 -0
  27. package/src/reviews/components/WorkflowReviewActivityFeed.vue +312 -0
  28. package/src/reviews/components/WorkflowReviewChangesList.test.ts +97 -0
  29. package/src/reviews/components/WorkflowReviewChangesList.vue +143 -0
  30. package/src/reviews/components/WorkflowReviewChangesSection.test.ts +331 -0
  31. package/src/reviews/components/WorkflowReviewChangesSection.vue +217 -0
  32. package/src/reviews/components/WorkflowReviewCommentComposer.test.ts +175 -0
  33. package/src/reviews/components/WorkflowReviewCommentComposer.vue +83 -0
  34. package/src/reviews/components/WorkflowReviewDecisionPopover.test.ts +288 -0
  35. package/src/reviews/components/WorkflowReviewDecisionPopover.vue +203 -0
  36. package/src/reviews/components/WorkflowReviewDetailMetadata.test.ts +206 -0
  37. package/src/reviews/components/WorkflowReviewDetailMetadata.vue +245 -0
  38. package/src/reviews/components/WorkflowReviewDetailTabs.decisionNote.test.ts +90 -0
  39. package/src/reviews/components/WorkflowReviewDetailTabs.test.ts +500 -0
  40. package/src/reviews/components/WorkflowReviewDetailTabs.vue +329 -0
  41. package/src/reviews/components/WorkflowReviewStatusDot.vue +67 -0
  42. package/src/reviews/components/_activity-card.scss +36 -0
  43. package/src/reviews/components/activity-entries/WorkflowReviewActivityActorAvatar.vue +50 -0
  44. package/src/reviews/components/activity-entries/WorkflowReviewActivityComment.test.ts +103 -0
  45. package/src/reviews/components/activity-entries/WorkflowReviewActivityComment.vue +116 -0
  46. package/src/reviews/components/activity-entries/WorkflowReviewActivityEventEntry.vue +361 -0
  47. package/src/reviews/components/activity-entries/WorkflowReviewActivityFallback.vue +49 -0
  48. package/src/reviews/components/activity-entries/WorkflowReviewActivityWorkflowLink.vue +36 -0
  49. package/src/reviews/components/activityEntryRegistry.ts +27 -0
  50. package/src/reviews/constants.ts +41 -0
  51. package/src/reviews/reviewActivity.store.test.ts +463 -0
  52. package/src/reviews/reviewActivity.store.ts +186 -0
  53. package/src/reviews/reviewDetail.store.test.ts +135 -0
  54. package/src/reviews/reviewDetail.store.ts +74 -0
  55. package/src/reviews/workflowReviewStatus.utils.ts +44 -0
  56. package/src/reviews/workflowReviews.api.ts +61 -0
  57. package/src/reviews/workflowReviews.utils.ts +18 -0
  58. package/src/self-healing/SelfHealingResultDetail.vue +19 -0
  59. package/src/views/InboxView.test.ts +568 -0
  60. package/src/views/InboxView.vue +260 -0
  61. package/stylelint.config.mjs +9 -0
  62. package/vite.config.ts +52 -0
@@ -0,0 +1,175 @@
1
+ import { WORKFLOW_REVIEW_TEXT_MAX_LENGTH } from '@n8n/api-types';
2
+ import { createTestingPinia } from '@pinia/testing';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { waitFor } from '@testing-library/vue';
5
+ import { createComponentRenderer, mockedStore } from '@n8n/frontend-test-utils';
6
+ import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
7
+
8
+ import { useReviewActivityStore } from '../reviewActivity.store';
9
+ import WorkflowReviewCommentComposer from './WorkflowReviewCommentComposer.vue';
10
+
11
+ const showError = vi.fn();
12
+
13
+ vi.mock('@n8n/composables/useToast', () => ({
14
+ useToast: () => ({ showError }),
15
+ }));
16
+
17
+ /**
18
+ * The real N8nChatInput is mounted on purpose: `submitDisabled` overrides the
19
+ * component's whole internal send gate, which a stub cannot show.
20
+ */
21
+ const renderComponent = createComponentRenderer(WorkflowReviewCommentComposer);
22
+
23
+ describe('WorkflowReviewCommentComposer', () => {
24
+ let store: ReturnType<typeof mockedStore<typeof useReviewActivityStore>>;
25
+
26
+ beforeEach(() => {
27
+ createTestingPinia();
28
+ showError.mockReset();
29
+ store = mockedStore(useReviewActivityStore);
30
+ store.posting = false;
31
+ store.draft = '';
32
+ store.postComment.mockResolvedValue(true);
33
+ });
34
+
35
+ it('keeps a half-typed comment when the composer is unmounted and shown again', async () => {
36
+ // The Changes tab is a `v-if`, so it unmounts this component.
37
+ const first = renderComponent({ props: { canComment: true } });
38
+ await userEvent.type(first.getByRole('textbox'), 'half a thought');
39
+ first.unmount();
40
+
41
+ const second = renderComponent({ props: { canComment: true } });
42
+
43
+ expect(second.getByRole('textbox')).toHaveValue('half a thought');
44
+ });
45
+
46
+ it('clears a sent draft when a tab switch unmounts its composer', async () => {
47
+ const pending = createDeferredPromise<boolean>();
48
+ store.postComment.mockReturnValueOnce(pending.promise);
49
+ store.draft = 'Sent comment';
50
+ const first = renderComponent({ props: { canComment: true } });
51
+ await userEvent.click(first.getByTestId('send-message-button'));
52
+ first.unmount();
53
+ pending.resolve(true);
54
+ await waitFor(() => expect(store.draft).toBe(''));
55
+ const second = renderComponent({ props: { canComment: true } });
56
+ expect(second.getByRole('textbox')).toHaveValue('');
57
+ });
58
+
59
+ it('reports a failed post after a tab switch unmounts the composer', async () => {
60
+ const pending = createDeferredPromise<boolean>();
61
+ store.postComment.mockReturnValueOnce(pending.promise);
62
+ store.draft = 'Sent comment';
63
+ const { getByTestId, unmount } = renderComponent({ props: { canComment: true } });
64
+ await userEvent.click(getByTestId('send-message-button'));
65
+ unmount();
66
+ pending.reject(new Error('timeout'));
67
+ await pending.promise.catch(() => {});
68
+ expect(showError).toHaveBeenCalledWith(expect.any(Error), 'Could not post comment');
69
+ expect(store.draft).toBe('Sent comment');
70
+ });
71
+
72
+ it('disables the send button and the textarea when the viewer cannot comment', () => {
73
+ const { getByTestId, getByRole } = renderComponent({ props: { canComment: false } });
74
+
75
+ expect(getByTestId('send-message-button')).toBeDisabled();
76
+ expect(getByRole('textbox')).toBeDisabled();
77
+ });
78
+
79
+ it('disables only the send button while the draft is empty', () => {
80
+ const { getByTestId, getByRole } = renderComponent({ props: { canComment: true } });
81
+
82
+ expect(getByTestId('send-message-button')).toBeDisabled();
83
+ expect(getByRole('textbox')).not.toBeDisabled();
84
+ });
85
+
86
+ it('enables the send button once the draft has content', async () => {
87
+ const { getByTestId, getByRole } = renderComponent({ props: { canComment: true } });
88
+
89
+ await userEvent.type(getByRole('textbox'), 'Nice work');
90
+
91
+ await waitFor(() => expect(getByTestId('send-message-button')).not.toBeDisabled());
92
+ });
93
+
94
+ it('disables the send button once the draft goes over the length limit', async () => {
95
+ const { getByTestId, getByRole } = renderComponent({ props: { canComment: true } });
96
+ const textarea = getByRole('textbox');
97
+
98
+ textarea.focus();
99
+ await userEvent.paste('x'.repeat(WORKFLOW_REVIEW_TEXT_MAX_LENGTH));
100
+ await waitFor(() => expect(getByTestId('send-message-button')).not.toBeDisabled());
101
+ // Shift+Enter inserts the newline itself, so `maxlength` on the textarea does not stop it
102
+ await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
103
+
104
+ expect(textarea).toHaveValue(`${'x'.repeat(WORKFLOW_REVIEW_TEXT_MAX_LENGTH)}\n`);
105
+ await waitFor(() => expect(getByTestId('send-message-button')).toBeDisabled());
106
+ });
107
+
108
+ it('posts the draft and clears it', async () => {
109
+ const { getByTestId, getByRole } = renderComponent({ props: { canComment: true } });
110
+ const textarea = getByRole('textbox');
111
+
112
+ await userEvent.type(textarea, 'Nice work');
113
+ await userEvent.click(getByTestId('send-message-button'));
114
+
115
+ expect(store.postComment).toHaveBeenCalledWith('Nice work');
116
+ await waitFor(() => expect(textarea).toHaveValue(''));
117
+ });
118
+
119
+ it('keeps text typed while the previous comment was still posting', async () => {
120
+ let resolvePost!: () => void;
121
+ store.postComment.mockImplementation(
122
+ async () =>
123
+ await new Promise<boolean>((resolve) => {
124
+ resolvePost = () => resolve(true);
125
+ }),
126
+ );
127
+ const { getByTestId, getByRole } = renderComponent({ props: { canComment: true } });
128
+ const textarea = getByRole('textbox');
129
+
130
+ await userEvent.type(textarea, 'Nice work');
131
+ await userEvent.click(getByTestId('send-message-button'));
132
+ // The textarea stays enabled during the request, so the user keeps typing
133
+ await userEvent.type(textarea, ' and the next one');
134
+ expect(store.postComment).toHaveBeenCalledWith('Nice work');
135
+ resolvePost();
136
+ await new Promise(setImmediate);
137
+
138
+ expect(textarea).toHaveValue('Nice work and the next one');
139
+ });
140
+
141
+ it('keeps the draft when the post it was waiting on belonged to another review', async () => {
142
+ let resolvePost!: () => void;
143
+ store.postComment.mockImplementation(
144
+ async () =>
145
+ await new Promise<boolean>((resolve) => {
146
+ // Stale: the viewer moved to another review while this was in flight.
147
+ resolvePost = () => resolve(false);
148
+ }),
149
+ );
150
+ const { getByTestId, getByRole } = renderComponent({ props: { canComment: true } });
151
+ const textarea = getByRole('textbox');
152
+
153
+ await userEvent.type(textarea, 'Nice work');
154
+ await userEvent.click(getByTestId('send-message-button'));
155
+ resolvePost();
156
+ await new Promise(setImmediate);
157
+
158
+ // Identical text, so only the stale result can keep it from being cleared.
159
+ expect(textarea).toHaveValue('Nice work');
160
+ });
161
+
162
+ it('keeps the draft and surfaces an error when posting fails', async () => {
163
+ store.postComment.mockRejectedValue(new Error('boom'));
164
+ const { getByTestId, getByRole } = renderComponent({ props: { canComment: true } });
165
+ const textarea = getByRole('textbox');
166
+
167
+ await userEvent.type(textarea, 'Nice work');
168
+ await userEvent.click(getByTestId('send-message-button'));
169
+
170
+ await waitFor(() =>
171
+ expect(showError).toHaveBeenCalledWith(expect.any(Error), 'Could not post comment'),
172
+ );
173
+ expect(textarea).toHaveValue('Nice work');
174
+ });
175
+ });
@@ -0,0 +1,83 @@
1
+ <script setup lang="ts">
2
+ import { WORKFLOW_REVIEW_TEXT_MAX_LENGTH } from '@n8n/api-types';
3
+ import { N8nChatInput } from '@n8n/design-system';
4
+ import { useI18n } from '@n8n/i18n';
5
+ import { useToast } from '@n8n/composables/useToast';
6
+ import { storeToRefs } from 'pinia';
7
+ import { computed } from 'vue';
8
+
9
+ import { useReviewActivityStore } from '../reviewActivity.store';
10
+
11
+ const props = defineProps<{ canComment: boolean }>();
12
+
13
+ const i18n = useI18n();
14
+ const { showError } = useToast();
15
+ const store = useReviewActivityStore();
16
+ const { posting, draft } = storeToRefs(store);
17
+
18
+ // The full condition, not just `posting`: N8nChatInput uses `submitDisabled ?? …`,
19
+ // so a bare `false` would replace its own empty/over-limit/disabled gate.
20
+ const submitDisabled = computed(
21
+ () =>
22
+ posting.value ||
23
+ draft.value.trim().length === 0 ||
24
+ draft.value.length > WORKFLOW_REVIEW_TEXT_MAX_LENGTH ||
25
+ !props.canComment,
26
+ );
27
+
28
+ async function onSubmit() {
29
+ const submitted = draft.value;
30
+ const body = submitted.trim();
31
+ if (!body) return;
32
+
33
+ try {
34
+ // A stale post belongs to the review the viewer left, so its completion must not
35
+ // touch the draft they are typing now, even if the two strings happen to match.
36
+ const posted = await store.postComment(body);
37
+ // Don't clear text typed while the post was in flight.
38
+ if (posted && draft.value === submitted) draft.value = '';
39
+ } catch (error) {
40
+ showError(error, i18n.baseText('workflowReviews.detail.activity.error.post'));
41
+ }
42
+ }
43
+ </script>
44
+
45
+ <template>
46
+ <!-- Implicit label: the textarea has no id to point a `for` at, and
47
+ `inheritAttrs: false` would send an aria-label to the wrapper instead. -->
48
+ <label :class="$style.composer">
49
+ <span :class="$style.srOnly">
50
+ {{ i18n.baseText('workflowReviews.detail.activity.composer.label') }}
51
+ </span>
52
+ <N8nChatInput
53
+ v-model="draft"
54
+ layout="adaptive"
55
+ :max-length="WORKFLOW_REVIEW_TEXT_MAX_LENGTH"
56
+ :placeholder="i18n.baseText('workflowReviews.detail.activity.composer.placeholder')"
57
+ refocus-after-send
58
+ :disabled="!canComment"
59
+ :disabled-tooltip="i18n.baseText('workflowReviews.detail.activity.composer.notAllowed')"
60
+ :submit-disabled="submitDisabled"
61
+ data-test-id="workflow-review-comment-composer"
62
+ @submit="onSubmit"
63
+ />
64
+ </label>
65
+ </template>
66
+
67
+ <style lang="scss" module>
68
+ .composer {
69
+ display: block;
70
+ }
71
+
72
+ .srOnly {
73
+ position: absolute;
74
+ width: 1px;
75
+ height: 1px;
76
+ padding: 0;
77
+ margin: -1px;
78
+ overflow: hidden;
79
+ clip: rect(0, 0, 0, 0);
80
+ white-space: nowrap;
81
+ border: 0;
82
+ }
83
+ </style>
@@ -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
+ });