@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,219 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type {
|
|
3
|
+
WorkflowReviewRequestDecision,
|
|
4
|
+
WorkflowReviewRequestState,
|
|
5
|
+
WorkflowReviewRequestWorkflowDetail,
|
|
6
|
+
WorkflowReviewVersionSnapshot,
|
|
7
|
+
} from '@n8n/api-types';
|
|
8
|
+
import { N8nCallout, N8nText } from '@n8n/design-system';
|
|
9
|
+
import { useI18n } from '@n8n/i18n';
|
|
10
|
+
import isEqual from 'lodash/isEqual';
|
|
11
|
+
import omit from 'lodash/omit';
|
|
12
|
+
import { computed } from 'vue';
|
|
13
|
+
import { componentRegistry } from '@n8n/frontend-module-sdk';
|
|
14
|
+
|
|
15
|
+
import { getVersionLabel } from '@n8n/composables/useWorkflowVersionLabel';
|
|
16
|
+
|
|
17
|
+
const WorkflowDiffView = computed(() => componentRegistry.get('workflow-diff'));
|
|
18
|
+
|
|
19
|
+
const props = defineProps<{
|
|
20
|
+
workflow: WorkflowReviewRequestWorkflowDetail;
|
|
21
|
+
state: WorkflowReviewRequestState;
|
|
22
|
+
decision: WorkflowReviewRequestDecision;
|
|
23
|
+
}>();
|
|
24
|
+
|
|
25
|
+
const i18n = useI18n();
|
|
26
|
+
|
|
27
|
+
// Only approval freezes a baseline, so an approved review diffs against what was
|
|
28
|
+
// published back then; a review closed any other way has nothing to diff against.
|
|
29
|
+
const isApproved = computed(() => props.state === 'closed' && props.decision === 'approved');
|
|
30
|
+
const isClosedWithoutApproval = computed(
|
|
31
|
+
() => props.state === 'closed' && props.decision !== 'approved',
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Label each side the way version history does, falling back to a short id.
|
|
36
|
+
* Falsy rather than nullish: the publish endpoints accept `name: ""`.
|
|
37
|
+
*/
|
|
38
|
+
const versionLabel = (snapshot: WorkflowReviewVersionSnapshot) =>
|
|
39
|
+
getVersionLabel({ workflowHistory: snapshot });
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A snapshot minus its identity and metadata — i.e. everything the diff renders.
|
|
43
|
+
* Derived by omission rather than an explicit field list to avoid drift; renaming
|
|
44
|
+
* a version must not read as a change.
|
|
45
|
+
*/
|
|
46
|
+
function contentOf(snapshot: WorkflowReviewVersionSnapshot) {
|
|
47
|
+
return omit(snapshot, ['versionId', 'name', 'createdAt']);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const hasChanges = computed(() => {
|
|
51
|
+
const { pinnedVersion, baselineVersion } = props.workflow;
|
|
52
|
+
|
|
53
|
+
if (!pinnedVersion) return false;
|
|
54
|
+
// No baseline means first publish: everything is a change.
|
|
55
|
+
if (!baselineVersion) return true;
|
|
56
|
+
if (pinnedVersion.versionId === baselineVersion.versionId) return false;
|
|
57
|
+
|
|
58
|
+
return !isEqual(contentOf(baselineVersion), contentOf(pinnedVersion));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// The dots track publish status, which approval flips: the approved version is
|
|
62
|
+
// the published one now, and the baseline it replaced no longer is.
|
|
63
|
+
const sourceDotClass = computed(() =>
|
|
64
|
+
isApproved.value ? 'statusDotSuperseded' : 'statusDotPublished',
|
|
65
|
+
);
|
|
66
|
+
const targetDotClass = computed(() =>
|
|
67
|
+
isApproved.value ? 'statusDotPublished' : 'statusDotInReview',
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const noChangesText = computed(() =>
|
|
71
|
+
i18n.baseText(
|
|
72
|
+
isApproved.value
|
|
73
|
+
? 'workflowReviews.changes.closed.noChanges'
|
|
74
|
+
: 'workflowReviews.changes.noChanges',
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
const sourceEmptyText = computed(() =>
|
|
78
|
+
i18n.baseText(
|
|
79
|
+
isApproved.value
|
|
80
|
+
? 'workflowReviews.changes.closed.firstPublish.sourceEmpty'
|
|
81
|
+
: 'workflowReviews.changes.firstPublish.sourceEmpty',
|
|
82
|
+
),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const sourceLabel = computed(() =>
|
|
86
|
+
props.workflow.baselineVersion
|
|
87
|
+
? i18n.baseText(
|
|
88
|
+
isApproved.value
|
|
89
|
+
? 'workflowReviews.changes.closed.sourceLabel'
|
|
90
|
+
: 'workflowReviews.changes.sourceLabel',
|
|
91
|
+
{ interpolate: { version: versionLabel(props.workflow.baselineVersion) } },
|
|
92
|
+
)
|
|
93
|
+
: undefined,
|
|
94
|
+
);
|
|
95
|
+
const targetLabel = computed(() =>
|
|
96
|
+
props.workflow.pinnedVersion
|
|
97
|
+
? i18n.baseText(
|
|
98
|
+
isApproved.value
|
|
99
|
+
? 'workflowReviews.changes.closed.targetLabel'
|
|
100
|
+
: 'workflowReviews.changes.targetLabel',
|
|
101
|
+
{ interpolate: { version: versionLabel(props.workflow.pinnedVersion) } },
|
|
102
|
+
)
|
|
103
|
+
: undefined,
|
|
104
|
+
);
|
|
105
|
+
</script>
|
|
106
|
+
|
|
107
|
+
<template>
|
|
108
|
+
<N8nCallout
|
|
109
|
+
v-if="isClosedWithoutApproval"
|
|
110
|
+
theme="info"
|
|
111
|
+
:class="$style.callout"
|
|
112
|
+
data-test-id="workflow-review-changes-closed-without-approval"
|
|
113
|
+
>
|
|
114
|
+
{{ i18n.baseText('workflowReviews.changes.closedWithoutApproval') }}
|
|
115
|
+
</N8nCallout>
|
|
116
|
+
<N8nCallout
|
|
117
|
+
v-else-if="!workflow.pinnedVersion"
|
|
118
|
+
theme="warning"
|
|
119
|
+
:class="$style.callout"
|
|
120
|
+
data-test-id="workflow-review-changes-version-unavailable"
|
|
121
|
+
>
|
|
122
|
+
{{ i18n.baseText('workflowReviews.changes.versionUnavailable.body') }}
|
|
123
|
+
</N8nCallout>
|
|
124
|
+
<N8nCallout
|
|
125
|
+
v-else-if="!hasChanges"
|
|
126
|
+
theme="info"
|
|
127
|
+
:class="$style.callout"
|
|
128
|
+
data-test-id="workflow-review-changes-no-changes"
|
|
129
|
+
>
|
|
130
|
+
{{ noChangesText }}
|
|
131
|
+
</N8nCallout>
|
|
132
|
+
<div v-else :class="$style.diff" data-test-id="workflow-review-changes-diff">
|
|
133
|
+
<component
|
|
134
|
+
:is="WorkflowDiffView"
|
|
135
|
+
:source-snapshot="workflow.baselineVersion ?? undefined"
|
|
136
|
+
:workflow-id="workflow.workflowId"
|
|
137
|
+
:workflow-name="workflow.workflowName"
|
|
138
|
+
:target-snapshot="workflow.pinnedVersion ?? undefined"
|
|
139
|
+
:source-label="sourceLabel"
|
|
140
|
+
:target-label="targetLabel"
|
|
141
|
+
show-fullscreen-button
|
|
142
|
+
>
|
|
143
|
+
<!-- Only when a baseline exists: with no prior published version there is
|
|
144
|
+
no publish status to represent. -->
|
|
145
|
+
<template v-if="workflow.baselineVersion" #sourceLabel>
|
|
146
|
+
<span :class="$style.versionBadge" data-test-id="workflow-review-changes-source-label">
|
|
147
|
+
<span :class="[$style.statusDot, $style[sourceDotClass]]" />
|
|
148
|
+
<N8nText color="text-dark" size="small" compact>{{ sourceLabel }}</N8nText>
|
|
149
|
+
</span>
|
|
150
|
+
</template>
|
|
151
|
+
<template #targetLabel>
|
|
152
|
+
<span :class="$style.versionBadge" data-test-id="workflow-review-changes-target-label">
|
|
153
|
+
<span :class="[$style.statusDot, $style[targetDotClass]]" />
|
|
154
|
+
<N8nText color="text-dark" size="small" compact>{{ targetLabel }}</N8nText>
|
|
155
|
+
</span>
|
|
156
|
+
</template>
|
|
157
|
+
<template #sourceEmptyText>
|
|
158
|
+
<N8nText size="small" color="text-base">{{ sourceEmptyText }}</N8nText>
|
|
159
|
+
</template>
|
|
160
|
+
</component>
|
|
161
|
+
</div>
|
|
162
|
+
</template>
|
|
163
|
+
|
|
164
|
+
<style module lang="scss">
|
|
165
|
+
.callout {
|
|
166
|
+
max-width: var(--review-callout--max-width, 34rem);
|
|
167
|
+
margin-top: var(--spacing--5xs);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.versionBadge {
|
|
171
|
+
/* Keep the existing Reviews badge sizes when the root font changes. */
|
|
172
|
+
--review-version-badge--spacing: 12px;
|
|
173
|
+
--review-version-badge--radius: 4px;
|
|
174
|
+
--review-version-badge--height: 30px;
|
|
175
|
+
--review-version-badge--gap: 8px;
|
|
176
|
+
position: absolute;
|
|
177
|
+
top: var(--review-version-badge--spacing);
|
|
178
|
+
left: var(--review-version-badge--spacing);
|
|
179
|
+
z-index: 1;
|
|
180
|
+
border-radius: var(--review-version-badge--radius);
|
|
181
|
+
border: var(--border-width) solid var(--color--foreground--tint-1);
|
|
182
|
+
background: var(--color--foreground--tint-2);
|
|
183
|
+
display: flex;
|
|
184
|
+
height: var(--review-version-badge--height);
|
|
185
|
+
padding: 0 var(--review-version-badge--spacing);
|
|
186
|
+
align-items: center;
|
|
187
|
+
gap: var(--review-version-badge--gap);
|
|
188
|
+
align-self: stretch;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.statusDot {
|
|
192
|
+
height: var(--spacing--2xs);
|
|
193
|
+
width: var(--spacing--2xs);
|
|
194
|
+
border-radius: 50%;
|
|
195
|
+
display: inline-block;
|
|
196
|
+
flex-shrink: 0;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.statusDotPublished {
|
|
200
|
+
background-color: var(--color--mint-600);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.statusDotInReview {
|
|
204
|
+
background-color: var(--color--yellow-500);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.statusDotSuperseded {
|
|
208
|
+
background-color: var(--color--text--tint-1);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.diff {
|
|
212
|
+
height: 100%;
|
|
213
|
+
min-height: 0;
|
|
214
|
+
border: var(--border);
|
|
215
|
+
border-radius: var(--radius--2xs);
|
|
216
|
+
overflow: hidden;
|
|
217
|
+
margin-top: var(--spacing--5xs);
|
|
218
|
+
}
|
|
219
|
+
</style>
|
|
@@ -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>
|