@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,311 @@
|
|
|
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, useTemplateRef } from 'vue';
|
|
6
|
+
|
|
7
|
+
import { ReviewDetailScrollContainerKey, ReviewLinkedWorkflowsKey } from '../constants';
|
|
8
|
+
import type { WorkflowReviewDecisionInput } from '../workflowReviews.api';
|
|
9
|
+
import WorkflowReviewActivityFeed from './WorkflowReviewActivityFeed.vue';
|
|
10
|
+
import WorkflowReviewChangesSection from './WorkflowReviewChangesSection.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
|
+
const viewerCanDecide = computed(() => detail.value?.viewerCanDecide ?? false);
|
|
37
|
+
const viewerCanComment = computed(() => detail.value?.viewerCanComment ?? false);
|
|
38
|
+
|
|
39
|
+
// See the key's doc: read-time names for feed entries, never snapshotted into payloads.
|
|
40
|
+
provide(
|
|
41
|
+
ReviewLinkedWorkflowsKey,
|
|
42
|
+
computed(
|
|
43
|
+
() =>
|
|
44
|
+
new Map(
|
|
45
|
+
(detail.value?.workflows ?? []).map((workflow) => [
|
|
46
|
+
workflow.workflowId,
|
|
47
|
+
{
|
|
48
|
+
workflowName: workflow.workflowName,
|
|
49
|
+
pinnedVersionId: workflow.workflowVersionId,
|
|
50
|
+
pinnedVersionName: workflow.pinnedVersion?.name ?? null,
|
|
51
|
+
},
|
|
52
|
+
]),
|
|
53
|
+
),
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const ineligibilityHint = computed(() => {
|
|
58
|
+
if (!detail.value || detail.value.viewerCanDecide) return '';
|
|
59
|
+
// Any reason other than 'author' gets the generic permission hint, so new
|
|
60
|
+
// backend reasons degrade gracefully instead of breaking the UI.
|
|
61
|
+
return detail.value.viewerDecisionIneligibilityReason === 'author'
|
|
62
|
+
? i18n.baseText('workflowReviews.detail.decision.ineligible.author')
|
|
63
|
+
: i18n.baseText('generic.missing.permissions');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether to append the approved-and-published summary below the feed. Derived at read
|
|
68
|
+
* time from the live published pointer, not from a `workflow.published` entry: an entry
|
|
69
|
+
* can sit on an unfetched feed page, and it would keep the summary up after a newer
|
|
70
|
+
* version replaced this one. The pointer cannot claim a publication that isn't live —
|
|
71
|
+
* a failed publish either left the pin unpublished (no summary) or never touched a pin
|
|
72
|
+
* that was already live (summary true) — and it is the signal the canvas banner trusts,
|
|
73
|
+
* so the two cannot disagree. A lifecycle close needs no summary here: its
|
|
74
|
+
* `review.closed` entry renders as a callout.
|
|
75
|
+
*/
|
|
76
|
+
const showApprovedAndPublished = computed(() => {
|
|
77
|
+
const review = detail.value;
|
|
78
|
+
if (review?.state !== 'closed' || review.decision !== 'approved') return false;
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
review.workflows.length > 0 &&
|
|
82
|
+
review.workflows.every(
|
|
83
|
+
(workflow) =>
|
|
84
|
+
workflow.workflowVersionId !== null &&
|
|
85
|
+
workflow.publishedVersionId === workflow.workflowVersionId,
|
|
86
|
+
)
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const tabOptions = computed(() => [
|
|
91
|
+
{
|
|
92
|
+
label: i18n.baseText('workflowReviews.detail.tabs.activity'),
|
|
93
|
+
value: 'activity' as const,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
label: i18n.baseText('workflowReviews.detail.tabs.changes'),
|
|
97
|
+
value: 'changes' as const,
|
|
98
|
+
},
|
|
99
|
+
]);
|
|
100
|
+
</script>
|
|
101
|
+
|
|
102
|
+
<template>
|
|
103
|
+
<div :class="$style.container" data-test-id="workflow-review-detail-tabs">
|
|
104
|
+
<div :class="$style.tabRow">
|
|
105
|
+
<N8nTabs
|
|
106
|
+
:model-value="tab"
|
|
107
|
+
:options="tabOptions"
|
|
108
|
+
variant="modern"
|
|
109
|
+
data-test-id="workflow-review-detail-tab-bar"
|
|
110
|
+
@update:model-value="emit('update:tab', $event)"
|
|
111
|
+
/>
|
|
112
|
+
|
|
113
|
+
<!-- Gated on `detail`, not `review`: eligibility only arrives with the
|
|
114
|
+
detail payload, so the list item alone can't say who may decide. -->
|
|
115
|
+
<div v-if="detail?.state === 'open'" :class="$style.decisionActions">
|
|
116
|
+
<WorkflowReviewDecisionPopover
|
|
117
|
+
:deciding="deciding"
|
|
118
|
+
:viewer-can-decide="viewerCanDecide"
|
|
119
|
+
:viewer-can-comment="viewerCanComment"
|
|
120
|
+
:ineligibility-hint="ineligibilityHint"
|
|
121
|
+
@decide="emit('decide', $event)"
|
|
122
|
+
@comment-posted="emit('update:tab', 'activity')"
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<!-- Keyed so another review or tab starts at the top. -->
|
|
128
|
+
<div :key="`${review.id}:${tab}`" ref="detailBody" :class="$style.detailBody">
|
|
129
|
+
<div
|
|
130
|
+
v-if="tab === 'activity'"
|
|
131
|
+
:class="$style.activityPanel"
|
|
132
|
+
data-test-id="workflow-review-activity-panel"
|
|
133
|
+
>
|
|
134
|
+
<WorkflowReviewActivityFeed :key="review.id">
|
|
135
|
+
<template #header>
|
|
136
|
+
<!-- Carded and labelled so the review's own words are not mistaken for the
|
|
137
|
+
first entry of the feed below it. -->
|
|
138
|
+
<div :class="$style.descriptionCard">
|
|
139
|
+
<N8nText tag="h3" bold color="text-light" size="medium">
|
|
140
|
+
{{ i18n.baseText('workflowReviews.detail.activity.description') }}
|
|
141
|
+
</N8nText>
|
|
142
|
+
<N8nText
|
|
143
|
+
v-if="detail?.description"
|
|
144
|
+
color="text-base"
|
|
145
|
+
size="medium"
|
|
146
|
+
:class="$style.description"
|
|
147
|
+
data-test-id="workflow-review-description"
|
|
148
|
+
>
|
|
149
|
+
{{ detail.description }}
|
|
150
|
+
</N8nText>
|
|
151
|
+
<N8nText
|
|
152
|
+
v-else
|
|
153
|
+
color="text-light"
|
|
154
|
+
size="medium"
|
|
155
|
+
data-test-id="workflow-review-no-description"
|
|
156
|
+
>
|
|
157
|
+
{{ i18n.baseText('workflowReviews.detail.activity.noDescription') }}
|
|
158
|
+
</N8nText>
|
|
159
|
+
</div>
|
|
160
|
+
</template>
|
|
161
|
+
<template v-if="showApprovedAndPublished" #footer>
|
|
162
|
+
<N8nCallout
|
|
163
|
+
theme="success"
|
|
164
|
+
:class="$style.closedCallout"
|
|
165
|
+
data-test-id="workflow-review-closed-callout"
|
|
166
|
+
>
|
|
167
|
+
<div :class="$style.closedCalloutContent">
|
|
168
|
+
<N8nText bold size="medium">
|
|
169
|
+
{{ i18n.baseText('workflowReviews.detail.closedCallout.title') }}
|
|
170
|
+
</N8nText>
|
|
171
|
+
<N8nText size="medium">
|
|
172
|
+
{{ i18n.baseText('workflowReviews.detail.closedCallout.approvedAndPublished') }}
|
|
173
|
+
</N8nText>
|
|
174
|
+
</div>
|
|
175
|
+
</N8nCallout>
|
|
176
|
+
</template>
|
|
177
|
+
<!-- Closed reviews take no new comments (the backend 409s) -->
|
|
178
|
+
<template v-if="review.state === 'open'" #composer>
|
|
179
|
+
<WorkflowReviewCommentComposer :can-comment="viewerCanComment" />
|
|
180
|
+
</template>
|
|
181
|
+
</WorkflowReviewActivityFeed>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<div v-else :class="$style.panel" data-test-id="workflow-review-changes-panel">
|
|
185
|
+
<N8nCallout
|
|
186
|
+
v-if="!detail"
|
|
187
|
+
theme="warning"
|
|
188
|
+
:class="$style.callout"
|
|
189
|
+
data-test-id="workflow-review-changes-unavailable"
|
|
190
|
+
>
|
|
191
|
+
{{ i18n.baseText('workflowReviews.changes.unavailable') }}
|
|
192
|
+
</N8nCallout>
|
|
193
|
+
<template v-else-if="detail.workflows.length > 0">
|
|
194
|
+
<WorkflowReviewChangesSection
|
|
195
|
+
v-for="workflow in detail.workflows"
|
|
196
|
+
:key="workflow.workflowId"
|
|
197
|
+
:workflow="workflow"
|
|
198
|
+
:state="detail.state"
|
|
199
|
+
:decision="detail.decision"
|
|
200
|
+
/>
|
|
201
|
+
</template>
|
|
202
|
+
<!-- No rows left: the workflow was deleted, or the requester lost access to it. -->
|
|
203
|
+
<N8nCallout
|
|
204
|
+
v-else
|
|
205
|
+
theme="warning"
|
|
206
|
+
:class="$style.callout"
|
|
207
|
+
data-test-id="workflow-review-changes-workflow-unavailable"
|
|
208
|
+
>
|
|
209
|
+
{{ i18n.baseText('workflowReviews.changes.workflowUnavailable') }}
|
|
210
|
+
</N8nCallout>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
<WorkflowReviewDetailMetadata :review="review" />
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
</template>
|
|
217
|
+
|
|
218
|
+
<style module lang="scss">
|
|
219
|
+
@use './activity-card' as *;
|
|
220
|
+
|
|
221
|
+
.container {
|
|
222
|
+
display: flex;
|
|
223
|
+
flex-direction: column;
|
|
224
|
+
height: 100%;
|
|
225
|
+
min-height: 0;
|
|
226
|
+
container-name: review-detail;
|
|
227
|
+
container-type: inline-size;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.tabRow {
|
|
231
|
+
display: flex;
|
|
232
|
+
align-items: center;
|
|
233
|
+
justify-content: space-between;
|
|
234
|
+
gap: var(--spacing--sm);
|
|
235
|
+
margin-right: var(--spacing--md);
|
|
236
|
+
/* Holds the active-tab indicator, so the border sits under it. */
|
|
237
|
+
padding-bottom: var(--review-tab-bar--indicator-overhang);
|
|
238
|
+
border-bottom: var(--border);
|
|
239
|
+
|
|
240
|
+
> :global(.n8n-tabs) {
|
|
241
|
+
transform: translateY(var(--spacing--sm));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.detailBody {
|
|
246
|
+
display: flex;
|
|
247
|
+
flex: 1;
|
|
248
|
+
gap: var(--spacing--sm);
|
|
249
|
+
min-height: 0;
|
|
250
|
+
overflow: auto;
|
|
251
|
+
/* Inside the scroll area, so the content keeps clear of the scrollbar. */
|
|
252
|
+
padding: var(--spacing--sm) var(--spacing--md) 0 0;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.panel {
|
|
256
|
+
flex: 1;
|
|
257
|
+
min-width: 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.activityPanel {
|
|
261
|
+
flex: 1;
|
|
262
|
+
min-width: 0;
|
|
263
|
+
max-width: var(--review-activity--max-width, 45rem);
|
|
264
|
+
margin-inline: auto;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.descriptionCard {
|
|
268
|
+
@include activity-card;
|
|
269
|
+
|
|
270
|
+
display: flex;
|
|
271
|
+
flex-direction: column;
|
|
272
|
+
gap: var(--spacing--4xs);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.callout {
|
|
276
|
+
max-width: var(--review-callout--max-width, 34rem);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/* line up the summary's edges with the entry cards above it. */
|
|
280
|
+
.closedCallout {
|
|
281
|
+
margin-inline: calc(-1 * var(--spacing--sm));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.closedCalloutContent {
|
|
285
|
+
display: flex;
|
|
286
|
+
flex-direction: column;
|
|
287
|
+
gap: var(--spacing--5xs);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/* `pre-wrap` alone does not break a pasted URL, which is the one thing that could scroll the
|
|
291
|
+
card sideways. */
|
|
292
|
+
.description {
|
|
293
|
+
white-space: pre-wrap;
|
|
294
|
+
overflow-wrap: anywhere;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.decisionActions {
|
|
298
|
+
flex-shrink: 0;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
@container review-detail (max-width: 44rem) {
|
|
302
|
+
.detailBody {
|
|
303
|
+
flex-direction: column;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/* Auto margins would stop the stacked column from stretching. */
|
|
307
|
+
.activityPanel {
|
|
308
|
+
margin-inline: 0;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
</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>
|