@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,329 @@
1
+ <script setup lang="ts">
2
+ import type { WorkflowReviewInboxItem, WorkflowReviewRequestDetail } from '@n8n/api-types';
3
+ import { N8nCallout, N8nTabs, N8nText } from '@n8n/design-system';
4
+ import { useI18n } from '@n8n/i18n';
5
+ import { computed, provide, ref, useTemplateRef, watch } from 'vue';
6
+
7
+ import { ReviewDetailScrollContainerKey, ReviewLinkedWorkflowsKey } from '../constants';
8
+ import type { WorkflowReviewDecisionInput } from '../workflowReviews.api';
9
+ import WorkflowReviewActivityFeed from './WorkflowReviewActivityFeed.vue';
10
+ import WorkflowReviewChangesList from './WorkflowReviewChangesList.vue';
11
+ import WorkflowReviewCommentComposer from './WorkflowReviewCommentComposer.vue';
12
+ import WorkflowReviewDecisionPopover from './WorkflowReviewDecisionPopover.vue';
13
+ import WorkflowReviewDetailMetadata from './WorkflowReviewDetailMetadata.vue';
14
+
15
+ export type WorkflowReviewDetailTab = 'activity' | 'changes';
16
+
17
+ const props = defineProps<{
18
+ review: WorkflowReviewInboxItem | WorkflowReviewRequestDetail;
19
+ tab: WorkflowReviewDetailTab;
20
+ deciding: boolean;
21
+ }>();
22
+
23
+ const emit = defineEmits<{
24
+ 'update:tab': [tab: WorkflowReviewDetailTab];
25
+ decide: [input: WorkflowReviewDecisionInput];
26
+ }>();
27
+
28
+ const i18n = useI18n();
29
+
30
+ provide(ReviewDetailScrollContainerKey, useTemplateRef<HTMLElement>('detailBody'));
31
+
32
+ const detail = computed<WorkflowReviewRequestDetail | null>(() =>
33
+ 'workflows' in props.review ? props.review : null,
34
+ );
35
+
36
+ // Held here, not in the list: the detail body remounts on every tab switch.
37
+ const expandedWorkflowId = ref<string>();
38
+ // The review that `expandedWorkflowId` belongs to.
39
+ let expandedReviewId: string | undefined;
40
+
41
+ // Open the first workflow for each new review. A refetch keeps the choice while the
42
+ // open workflow is still listed. The detail lists only readable workflows, so it can drop one.
43
+ watch(
44
+ detail,
45
+ (value) => {
46
+ if (!value?.workflows.length) return;
47
+ const open = expandedWorkflowId.value;
48
+ const isListed = value.workflows.some(({ workflowId }) => workflowId === open);
49
+ if (value.id === expandedReviewId && (open === undefined || isListed)) return;
50
+ expandedReviewId = value.id;
51
+ expandedWorkflowId.value = value.workflows[0].workflowId;
52
+ },
53
+ { immediate: true },
54
+ );
55
+
56
+ const viewerCanDecide = computed(() => detail.value?.viewerCanDecide ?? false);
57
+ const viewerCanComment = computed(() => detail.value?.viewerCanComment ?? false);
58
+
59
+ // See the key's doc: read-time names for feed entries, never snapshotted into payloads.
60
+ provide(
61
+ ReviewLinkedWorkflowsKey,
62
+ computed(
63
+ () =>
64
+ new Map(
65
+ (detail.value?.workflows ?? []).map((workflow) => [
66
+ workflow.workflowId,
67
+ {
68
+ workflowName: workflow.workflowName,
69
+ pinnedVersionId: workflow.workflowVersionId,
70
+ pinnedVersionName: workflow.pinnedVersion?.name ?? null,
71
+ },
72
+ ]),
73
+ ),
74
+ ),
75
+ );
76
+
77
+ const ineligibilityHint = computed(() => {
78
+ if (!detail.value || detail.value.viewerCanDecide) return '';
79
+ // Any reason other than 'author' gets the generic permission hint, so new
80
+ // backend reasons degrade gracefully instead of breaking the UI.
81
+ return detail.value.viewerDecisionIneligibilityReason === 'author'
82
+ ? i18n.baseText('workflowReviews.detail.decision.ineligible.author')
83
+ : i18n.baseText('generic.missing.permissions');
84
+ });
85
+
86
+ /**
87
+ * Whether to append the approved-and-published summary below the feed. Derived at read
88
+ * time from the live published pointer, not from a `workflow.published` entry: an entry
89
+ * can sit on an unfetched feed page, and it would keep the summary up after a newer
90
+ * version replaced this one. The pointer cannot claim a publication that isn't live —
91
+ * a failed publish either left the pin unpublished (no summary) or never touched a pin
92
+ * that was already live (summary true) — and it is the signal the canvas banner trusts,
93
+ * so the two cannot disagree. A lifecycle close needs no summary here: its
94
+ * `review.closed` entry renders as a callout.
95
+ */
96
+ const showApprovedAndPublished = computed(() => {
97
+ const review = detail.value;
98
+ if (review?.state !== 'closed' || review.decision !== 'approved') return false;
99
+
100
+ return (
101
+ review.workflows.length > 0 &&
102
+ review.workflows.every(
103
+ (workflow) =>
104
+ workflow.workflowVersionId !== null &&
105
+ workflow.publishedVersionId === workflow.workflowVersionId,
106
+ )
107
+ );
108
+ });
109
+
110
+ const tabOptions = computed(() => [
111
+ {
112
+ label: i18n.baseText('workflowReviews.detail.tabs.activity'),
113
+ value: 'activity' as const,
114
+ },
115
+ {
116
+ label: i18n.baseText('workflowReviews.detail.tabs.changes'),
117
+ value: 'changes' as const,
118
+ },
119
+ ]);
120
+ </script>
121
+
122
+ <template>
123
+ <div :class="$style.container" data-test-id="workflow-review-detail-tabs">
124
+ <div :class="$style.tabRow">
125
+ <N8nTabs
126
+ :model-value="tab"
127
+ :options="tabOptions"
128
+ variant="modern"
129
+ data-test-id="workflow-review-detail-tab-bar"
130
+ @update:model-value="emit('update:tab', $event)"
131
+ />
132
+
133
+ <!-- Gated on `detail`, not `review`: eligibility only arrives with the
134
+ detail payload, so the list item alone can't say who may decide. -->
135
+ <div v-if="detail?.state === 'open'" :class="$style.decisionActions">
136
+ <WorkflowReviewDecisionPopover
137
+ :deciding="deciding"
138
+ :viewer-can-decide="viewerCanDecide"
139
+ :viewer-can-comment="viewerCanComment"
140
+ :ineligibility-hint="ineligibilityHint"
141
+ @decide="emit('decide', $event)"
142
+ @comment-posted="emit('update:tab', 'activity')"
143
+ />
144
+ </div>
145
+ </div>
146
+
147
+ <!-- Keyed so another review or tab starts at the top. -->
148
+ <div :key="`${review.id}:${tab}`" ref="detailBody" :class="$style.detailBody">
149
+ <div
150
+ v-if="tab === 'activity'"
151
+ :class="$style.activityPanel"
152
+ data-test-id="workflow-review-activity-panel"
153
+ >
154
+ <WorkflowReviewActivityFeed :key="review.id">
155
+ <template #header>
156
+ <!-- Carded and labelled so the review's own words are not mistaken for the
157
+ first entry of the feed below it. -->
158
+ <div :class="$style.descriptionCard">
159
+ <N8nText tag="h3" bold color="text-light" size="medium">
160
+ {{ i18n.baseText('workflowReviews.detail.activity.description') }}
161
+ </N8nText>
162
+ <N8nText
163
+ v-if="detail?.description"
164
+ color="text-base"
165
+ size="medium"
166
+ :class="$style.description"
167
+ data-test-id="workflow-review-description"
168
+ >
169
+ {{ detail.description }}
170
+ </N8nText>
171
+ <N8nText
172
+ v-else
173
+ color="text-light"
174
+ size="medium"
175
+ data-test-id="workflow-review-no-description"
176
+ >
177
+ {{ i18n.baseText('workflowReviews.detail.activity.noDescription') }}
178
+ </N8nText>
179
+ </div>
180
+ </template>
181
+ <template v-if="showApprovedAndPublished" #footer>
182
+ <N8nCallout
183
+ theme="success"
184
+ :class="$style.closedCallout"
185
+ data-test-id="workflow-review-closed-callout"
186
+ >
187
+ <div :class="$style.closedCalloutContent">
188
+ <N8nText bold size="medium">
189
+ {{ i18n.baseText('workflowReviews.detail.closedCallout.title') }}
190
+ </N8nText>
191
+ <N8nText size="medium">
192
+ {{ i18n.baseText('workflowReviews.detail.closedCallout.approvedAndPublished') }}
193
+ </N8nText>
194
+ </div>
195
+ </N8nCallout>
196
+ </template>
197
+ <!-- Closed reviews take no new comments (the backend 409s) -->
198
+ <template v-if="review.state === 'open'" #composer>
199
+ <WorkflowReviewCommentComposer :can-comment="viewerCanComment" />
200
+ </template>
201
+ </WorkflowReviewActivityFeed>
202
+ </div>
203
+
204
+ <div v-else :class="$style.panel" data-test-id="workflow-review-changes-panel">
205
+ <N8nCallout
206
+ v-if="!detail"
207
+ theme="warning"
208
+ :class="$style.callout"
209
+ data-test-id="workflow-review-changes-unavailable"
210
+ >
211
+ {{ i18n.baseText('workflowReviews.changes.unavailable') }}
212
+ </N8nCallout>
213
+ <WorkflowReviewChangesList
214
+ v-else-if="detail.workflows.length > 0"
215
+ v-model:expanded="expandedWorkflowId"
216
+ :workflows="detail.workflows"
217
+ :state="detail.state"
218
+ :decision="detail.decision"
219
+ />
220
+ <!-- No rows left: the workflow was deleted, or the requester lost access to it. -->
221
+ <N8nCallout
222
+ v-else
223
+ theme="warning"
224
+ :class="$style.callout"
225
+ data-test-id="workflow-review-changes-workflow-unavailable"
226
+ >
227
+ {{ i18n.baseText('workflowReviews.changes.workflowUnavailable') }}
228
+ </N8nCallout>
229
+ </div>
230
+
231
+ <WorkflowReviewDetailMetadata :review="review" />
232
+ </div>
233
+ </div>
234
+ </template>
235
+
236
+ <style module lang="scss">
237
+ @use './activity-card' as *;
238
+
239
+ .container {
240
+ display: flex;
241
+ flex-direction: column;
242
+ height: 100%;
243
+ min-height: 0;
244
+ container-name: review-detail;
245
+ container-type: inline-size;
246
+ }
247
+
248
+ .tabRow {
249
+ display: flex;
250
+ align-items: center;
251
+ justify-content: space-between;
252
+ gap: var(--spacing--sm);
253
+ margin-right: var(--spacing--md);
254
+ /* Holds the active-tab indicator, so the border sits under it. */
255
+ padding-bottom: var(--review-tab-bar--indicator-overhang);
256
+ border-bottom: var(--border);
257
+
258
+ > :global(.n8n-tabs) {
259
+ transform: translateY(var(--spacing--sm));
260
+ }
261
+ }
262
+
263
+ .detailBody {
264
+ display: flex;
265
+ flex: 1;
266
+ gap: var(--spacing--sm);
267
+ min-height: 0;
268
+ overflow: auto;
269
+ /* Inside the scroll area, so the content keeps clear of the scrollbar. */
270
+ padding: var(--spacing--sm) var(--spacing--md) 0 0;
271
+ }
272
+
273
+ .panel {
274
+ flex: 1;
275
+ min-width: 0;
276
+ }
277
+
278
+ .activityPanel {
279
+ flex: 1;
280
+ min-width: 0;
281
+ max-width: var(--review-activity--max-width, 45rem);
282
+ margin-inline: auto;
283
+ }
284
+
285
+ .descriptionCard {
286
+ @include activity-card;
287
+
288
+ display: flex;
289
+ flex-direction: column;
290
+ gap: var(--spacing--4xs);
291
+ }
292
+
293
+ .callout {
294
+ max-width: var(--review-callout--max-width, 34rem);
295
+ }
296
+
297
+ /* line up the summary's edges with the entry cards above it. */
298
+ .closedCallout {
299
+ margin-inline: calc(-1 * var(--spacing--sm));
300
+ }
301
+
302
+ .closedCalloutContent {
303
+ display: flex;
304
+ flex-direction: column;
305
+ gap: var(--spacing--5xs);
306
+ }
307
+
308
+ /* `pre-wrap` alone does not break a pasted URL, which is the one thing that could scroll the
309
+ card sideways. */
310
+ .description {
311
+ white-space: pre-wrap;
312
+ overflow-wrap: anywhere;
313
+ }
314
+
315
+ .decisionActions {
316
+ flex-shrink: 0;
317
+ }
318
+
319
+ @container review-detail (max-width: 44rem) {
320
+ .detailBody {
321
+ flex-direction: column;
322
+ }
323
+
324
+ /* Auto margins would stop the stacked column from stretching. */
325
+ .activityPanel {
326
+ margin-inline: 0;
327
+ }
328
+ }
329
+ </style>
@@ -0,0 +1,67 @@
1
+ <script lang="ts" setup>
2
+ import type { WorkflowReviewRequestDecision, WorkflowReviewRequestState } from '@n8n/api-types';
3
+ import { useI18n } from '@n8n/i18n';
4
+ import { computed } from 'vue';
5
+
6
+ import { getWorkflowReviewStatusDisplay } from '../workflowReviewStatus.utils';
7
+
8
+ const props = withDefaults(
9
+ defineProps<{
10
+ state: WorkflowReviewRequestState;
11
+ decision: WorkflowReviewRequestDecision;
12
+ size?: 'small' | 'medium';
13
+ /**
14
+ * Set where the parent already renders the status in text. The dot then
15
+ * carries no accessible name, so the status is announced once, not twice.
16
+ */
17
+ decorative?: boolean;
18
+ }>(),
19
+ { size: 'medium', decorative: false },
20
+ );
21
+
22
+ const i18n = useI18n();
23
+
24
+ const status = computed(() => getWorkflowReviewStatusDisplay(i18n, props.state, props.decision));
25
+ </script>
26
+
27
+ <template>
28
+ <div
29
+ :class="[$style.dot, $style[status.colorClass], size === 'small' && $style.small]"
30
+ data-test-id="workflow-review-request-status-dot"
31
+ v-bind="
32
+ decorative
33
+ ? { 'aria-hidden': 'true' }
34
+ : { role: 'img', 'aria-label': `${status.stateLabel} | ${status.decisionLabel}` }
35
+ "
36
+ />
37
+ </template>
38
+
39
+ <style module lang="scss">
40
+ .dot {
41
+ flex-shrink: 0;
42
+ width: var(--font-size--3xs);
43
+ height: var(--font-size--3xs);
44
+ border-radius: 50%;
45
+ }
46
+
47
+ .small {
48
+ width: var(--spacing--2xs);
49
+ height: var(--spacing--2xs);
50
+ }
51
+
52
+ .pending {
53
+ background-color: var(--color--blue-500);
54
+ }
55
+
56
+ .changesRequested {
57
+ background-color: var(--color--yellow-500);
58
+ }
59
+
60
+ .approved {
61
+ background-color: var(--color--green-500);
62
+ }
63
+
64
+ .closed {
65
+ background-color: var(--color--neutral-500);
66
+ }
67
+ </style>
@@ -0,0 +1,36 @@
1
+ // Shared by the feed's entry cards and the description card above them.
2
+
3
+ /* The negative margin cancels the list's inset so every avatar sits in one column, boxed or
4
+ not; the border comes out of the padding so carded text starts on that same column. */
5
+ @mixin activity-card {
6
+ margin-inline: calc(-1 * var(--spacing--sm));
7
+ padding: var(--spacing--xs) calc(var(--spacing--sm) - var(--border-width, 1px));
8
+ border: var(--border);
9
+ border-radius: var(--radius--2xs);
10
+ }
11
+
12
+ @mixin activity-row {
13
+ display: flex;
14
+ align-items: flex-start;
15
+ gap: var(--spacing--2xs);
16
+ }
17
+
18
+ /* An avatar is shorter than a line of text, so the row's `flex-start` hangs it above the name.
19
+ Half the leading puts it back on that line — and only that line, so it stays put when a note
20
+ or a wrapped headline makes the row taller. Every entry kind must include this; without it
21
+ the avatar reads as sitting too high. */
22
+ @mixin activity-avatar {
23
+ flex-shrink: 0;
24
+ margin-block-start: calc(
25
+ (var(--review-activity--line-height) - var(--review-activity--avatar-size)) / 2
26
+ );
27
+ }
28
+
29
+ /* The actor, the sentence and its timestamp read as one line, so every entry kind spaces
30
+ them by a word's worth. */
31
+ @mixin activity-headline {
32
+ display: flex;
33
+ align-items: center;
34
+ gap: var(--spacing--4xs);
35
+ flex-wrap: wrap;
36
+ }
@@ -0,0 +1,50 @@
1
+ <script setup lang="ts">
2
+ import type { WorkflowReviewEligibleReviewer } from '@n8n/api-types';
3
+ import { N8nAvatar, N8nIcon } from '@n8n/design-system';
4
+
5
+ /**
6
+ * The avatar column of a feed entry that names a person: the actor's avatar, or a person
7
+ * silhouette when the actor was deleted.
8
+ */
9
+ defineProps<{
10
+ actor: Pick<WorkflowReviewEligibleReviewer, 'firstName' | 'lastName'> | null;
11
+ }>();
12
+ </script>
13
+
14
+ <template>
15
+ <N8nAvatar
16
+ v-if="actor"
17
+ size="xxsmall"
18
+ :class="$style.avatar"
19
+ :first-name="actor.firstName"
20
+ :last-name="actor.lastName"
21
+ />
22
+ <!-- Decorative: the sentence beside it already says "Deleted user". -->
23
+ <div
24
+ v-else
25
+ :class="$style.deletedActor"
26
+ aria-hidden="true"
27
+ data-test-id="workflow-review-activity-deleted-actor"
28
+ >
29
+ <N8nIcon icon="circle-user-round" :size="16" color="text-light" />
30
+ </div>
31
+ </template>
32
+
33
+ <style lang="scss" module>
34
+ @use '../activity-card' as *;
35
+
36
+ .avatar {
37
+ @include activity-avatar;
38
+ }
39
+
40
+ /* Sized and centred exactly like the avatar, so the two columns cannot drift apart. */
41
+ .deletedActor {
42
+ @include activity-avatar;
43
+
44
+ display: flex;
45
+ width: var(--review-activity--avatar-size);
46
+ height: var(--review-activity--avatar-size);
47
+ align-items: center;
48
+ justify-content: center;
49
+ }
50
+ </style>
@@ -0,0 +1,103 @@
1
+ import type { WorkflowReviewActivityEntry, WorkflowReviewActivityMessage } from '@n8n/api-types';
2
+ import { createTestingPinia } from '@pinia/testing';
3
+ import { createComponentRenderer } from '@n8n/frontend-test-utils';
4
+
5
+ import WorkflowReviewActivityComment from './WorkflowReviewActivityComment.vue';
6
+
7
+ const renderComponent = createComponentRenderer(WorkflowReviewActivityComment);
8
+
9
+ function makeMessage(
10
+ overrides: Partial<WorkflowReviewActivityMessage> = {},
11
+ ): WorkflowReviewActivityMessage {
12
+ return {
13
+ id: 'msg-1',
14
+ body: 'Looks good to me',
15
+ createdBy: {
16
+ id: 'user-1',
17
+ email: 'ada@example.com',
18
+ firstName: 'Ada',
19
+ lastName: 'Lovelace',
20
+ },
21
+ createdAt: '2024-01-01T10:00:00.000Z',
22
+ updatedAt: null,
23
+ deletedAt: null,
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ function makeEntry(
29
+ messages: WorkflowReviewActivityMessage[] = [makeMessage()],
30
+ ): Extract<WorkflowReviewActivityEntry, { type: 'comment.created' }> {
31
+ return {
32
+ id: '1',
33
+ type: 'comment.created',
34
+ typeVersion: 1,
35
+ data: null,
36
+ createdBy: messages[0]?.createdBy ?? null,
37
+ createdAt: '2024-01-01T10:00:00.000Z',
38
+ messages,
39
+ };
40
+ }
41
+
42
+ describe('WorkflowReviewActivityComment', () => {
43
+ beforeEach(() => {
44
+ createTestingPinia();
45
+ });
46
+
47
+ it('renders the body, the author name and a machine-readable timestamp', () => {
48
+ const { getByTestId } = renderComponent({ props: { entry: makeEntry() } });
49
+
50
+ expect(getByTestId('workflow-review-activity-comment-body')).toHaveTextContent(
51
+ 'Looks good to me',
52
+ );
53
+ expect(getByTestId('workflow-review-activity-comment-author')).toHaveTextContent(
54
+ 'Ada Lovelace',
55
+ );
56
+ expect(getByTestId('workflow-review-activity-comment-time')).toHaveAttribute(
57
+ 'datetime',
58
+ '2024-01-01T10:00:00.000Z',
59
+ );
60
+ });
61
+
62
+ it('falls back to the email when the author has no name', () => {
63
+ const { getByTestId } = renderComponent({
64
+ props: {
65
+ entry: makeEntry([
66
+ makeMessage({
67
+ createdBy: { id: 'user-1', email: 'ada@example.com', firstName: null, lastName: null },
68
+ }),
69
+ ]),
70
+ },
71
+ });
72
+
73
+ expect(getByTestId('workflow-review-activity-comment-author')).toHaveTextContent(
74
+ 'ada@example.com',
75
+ );
76
+ });
77
+
78
+ it('names a deleted author and shows a person silhouette where their avatar would be', () => {
79
+ const { getByTestId } = renderComponent({
80
+ props: { entry: makeEntry([makeMessage({ createdBy: null })]) },
81
+ });
82
+
83
+ expect(getByTestId('workflow-review-activity-comment-author')).toHaveTextContent(
84
+ 'Deleted user',
85
+ );
86
+ expect(getByTestId('workflow-review-activity-deleted-actor')).toBeInTheDocument();
87
+ });
88
+
89
+ // Keys on `deletedAt`, not on a null body: a null body from any other writer
90
+ // must not read as a tombstone.
91
+ it('shows that a comment was deleted instead of its text', () => {
92
+ const { getByTestId, queryByTestId } = renderComponent({
93
+ props: {
94
+ entry: makeEntry([makeMessage({ body: null, deletedAt: '2024-01-02T10:00:00.000Z' })]),
95
+ },
96
+ });
97
+
98
+ expect(getByTestId('workflow-review-activity-comment-deleted')).toHaveTextContent(
99
+ 'This comment was deleted.',
100
+ );
101
+ expect(queryByTestId('workflow-review-activity-comment-body')).not.toBeInTheDocument();
102
+ });
103
+ });
@@ -0,0 +1,116 @@
1
+ <script setup lang="ts">
2
+ import type { WorkflowReviewActivityEntry, WorkflowReviewActivityMessage } from '@n8n/api-types';
3
+ import { N8nText, N8nTimeAgo } from '@n8n/design-system';
4
+ import { useI18n } from '@n8n/i18n';
5
+
6
+ import { useRootStore } from '@n8n/stores/useRootStore';
7
+
8
+ import { formatActorName } from '../../workflowReviews.utils';
9
+ import WorkflowReviewActivityActorAvatar from './WorkflowReviewActivityActorAvatar.vue';
10
+
11
+ defineProps<{
12
+ entry: Extract<WorkflowReviewActivityEntry, { type: 'comment.created' }>;
13
+ }>();
14
+
15
+ const i18n = useI18n();
16
+ const rootStore = useRootStore();
17
+
18
+ function authorName(message: WorkflowReviewActivityMessage): string {
19
+ return formatActorName(
20
+ message.createdBy,
21
+ i18n.baseText('workflowReviews.detail.activity.unknownAuthor'),
22
+ );
23
+ }
24
+ </script>
25
+
26
+ <template>
27
+ <div :class="$style.entry">
28
+ <div v-for="message in entry.messages" :key="message.id" :class="$style.message">
29
+ <WorkflowReviewActivityActorAvatar :actor="message.createdBy" />
30
+ <div :class="$style.content">
31
+ <div :class="$style.header">
32
+ <N8nText
33
+ size="medium"
34
+ color="text-base"
35
+ :class="$style.line"
36
+ data-test-id="workflow-review-activity-comment-author"
37
+ >
38
+ {{ authorName(message) }}
39
+ </N8nText>
40
+ <N8nText size="medium" color="text-light">
41
+ <time
42
+ :datetime="message.createdAt"
43
+ data-test-id="workflow-review-activity-comment-time"
44
+ :class="$style.timeStamp"
45
+ >
46
+ <N8nTimeAgo :date="message.createdAt" :locale="rootStore.defaultLocale" />
47
+ </time>
48
+ </N8nText>
49
+ </div>
50
+ <N8nText
51
+ v-if="message.deletedAt"
52
+ size="small"
53
+ color="text-light"
54
+ :class="$style.deleted"
55
+ data-test-id="workflow-review-activity-comment-deleted"
56
+ >
57
+ {{ i18n.baseText('workflowReviews.detail.activity.comment.deleted') }}
58
+ </N8nText>
59
+ <N8nText
60
+ v-else
61
+ size="medium"
62
+ color="text-dark"
63
+ :class="[$style.body, $style.line]"
64
+ data-test-id="workflow-review-activity-comment-body"
65
+ >
66
+ {{ message.body }}
67
+ </N8nText>
68
+ </div>
69
+ </div>
70
+ </div>
71
+ </template>
72
+
73
+ <style lang="scss" module>
74
+ @use '../activity-card' as *;
75
+
76
+ /* A comment is prose, so it gets the same card as a decision that carries a note. */
77
+ .entry {
78
+ @include activity-card;
79
+
80
+ display: flex;
81
+ flex-direction: column;
82
+ gap: var(--spacing--xs);
83
+ }
84
+
85
+ .message {
86
+ @include activity-row;
87
+ }
88
+
89
+ .content {
90
+ display: flex;
91
+ flex-direction: column;
92
+ gap: var(--spacing--3xs);
93
+ min-width: 0;
94
+ }
95
+
96
+ .header {
97
+ @include activity-headline;
98
+ }
99
+
100
+ .line {
101
+ line-height: var(--review-activity--line-height);
102
+ }
103
+
104
+ .timeStamp {
105
+ padding-left: var(--spacing--4xs);
106
+ }
107
+
108
+ .body {
109
+ white-space: pre-wrap;
110
+ overflow-wrap: anywhere;
111
+ }
112
+
113
+ .deleted {
114
+ font-style: italic;
115
+ }
116
+ </style>