@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.
- package/LICENSE.md +88 -0
- package/LICENSE_EE.md +27 -0
- package/README.md +61 -2
- package/biome.jsonc +4 -0
- package/eslint.config.mjs +4 -0
- package/package.json +72 -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 +289 -0
- package/src/components/InboxList.vue +195 -0
- package/src/components/InboxListItem.vue +208 -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 +19 -0
- package/src/inbox.module.ts +55 -0
- package/src/inbox.routes.test.ts +140 -0
- package/src/inbox.routes.ts +49 -0
- package/src/inbox.store.test.ts +444 -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/WorkflowReviewChangesList.test.ts +97 -0
- package/src/reviews/components/WorkflowReviewChangesList.vue +143 -0
- package/src/reviews/components/WorkflowReviewChangesSection.test.ts +331 -0
- package/src/reviews/components/WorkflowReviewChangesSection.vue +217 -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 +245 -0
- package/src/reviews/components/WorkflowReviewDetailTabs.decisionNote.test.ts +90 -0
- package/src/reviews/components/WorkflowReviewDetailTabs.test.ts +500 -0
- package/src/reviews/components/WorkflowReviewDetailTabs.vue +329 -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/self-healing/SelfHealingResultDetail.vue +19 -0
- package/src/views/InboxView.test.ts +568 -0
- package/src/views/InboxView.vue +260 -0
- package/stylelint.config.mjs +9 -0
- package/vite.config.ts +52 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { WorkflowReviewActivityEntry, WorkflowReviewClosedReason } from '@n8n/api-types';
|
|
3
|
+
import { N8nCallout, N8nIcon, N8nText, N8nTimeAgo } from '@n8n/design-system';
|
|
4
|
+
import { type BaseTextKey, useI18n } from '@n8n/i18n';
|
|
5
|
+
import { computed, inject } from 'vue';
|
|
6
|
+
import { I18nT } from 'vue-i18n';
|
|
7
|
+
|
|
8
|
+
import { useRootStore } from '@n8n/stores/useRootStore';
|
|
9
|
+
import { getVersionLabel } from '@n8n/composables/useWorkflowVersionLabel';
|
|
10
|
+
|
|
11
|
+
import { ReviewLinkedWorkflowsKey, type ReviewLinkedWorkflowContext } from '../../constants';
|
|
12
|
+
import { formatActorName } from '../../workflowReviews.utils';
|
|
13
|
+
import WorkflowReviewActivityActorAvatar from './WorkflowReviewActivityActorAvatar.vue';
|
|
14
|
+
import WorkflowReviewActivityFallback from './WorkflowReviewActivityFallback.vue';
|
|
15
|
+
import WorkflowReviewActivityWorkflowLink from './WorkflowReviewActivityWorkflowLink.vue';
|
|
16
|
+
|
|
17
|
+
const props = defineProps<{
|
|
18
|
+
entry: Extract<
|
|
19
|
+
WorkflowReviewActivityEntry,
|
|
20
|
+
{
|
|
21
|
+
type:
|
|
22
|
+
| 'review.opened'
|
|
23
|
+
| 'review.changes_requested'
|
|
24
|
+
| 'review.approved'
|
|
25
|
+
| 'review.version_updated'
|
|
26
|
+
| 'review.closed'
|
|
27
|
+
| 'workflow.archived'
|
|
28
|
+
| 'workflow.deleted'
|
|
29
|
+
| 'workflow.moved'
|
|
30
|
+
| 'workflow.published';
|
|
31
|
+
}
|
|
32
|
+
>;
|
|
33
|
+
}>();
|
|
34
|
+
|
|
35
|
+
const i18n = useI18n();
|
|
36
|
+
const rootStore = useRootStore();
|
|
37
|
+
|
|
38
|
+
const linkedWorkflows = inject(
|
|
39
|
+
ReviewLinkedWorkflowsKey,
|
|
40
|
+
computed(() => new Map<string, ReviewLinkedWorkflowContext>()),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const closedReasonKeys: Record<WorkflowReviewClosedReason, BaseTextKey> = {
|
|
44
|
+
'no-reviewable-workflows': 'workflowReviews.detail.closedCallout.noReviewableWorkflows',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A lifecycle close concludes the feed, so it renders as a callout rather than one more
|
|
49
|
+
* sentence. The body is a keypath, and the stored reason, so there is nothing to fall back to.
|
|
50
|
+
*/
|
|
51
|
+
const closedCallout = computed<{ title: string; bodyKey: BaseTextKey } | null>(() => {
|
|
52
|
+
const entry = props.entry;
|
|
53
|
+
if (entry.type !== 'review.closed' || !entry.data) return null;
|
|
54
|
+
return {
|
|
55
|
+
title: i18n.baseText('workflowReviews.detail.closedCallout.title'),
|
|
56
|
+
bodyKey: closedReasonKeys[entry.data.reason],
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// A cause event's sentence names the actor when a user acted, or says the system did it.
|
|
61
|
+
const causeKeys: Record<
|
|
62
|
+
'workflow.archived' | 'workflow.deleted' | 'workflow.moved',
|
|
63
|
+
{
|
|
64
|
+
user: BaseTextKey;
|
|
65
|
+
userNamed: BaseTextKey;
|
|
66
|
+
system: BaseTextKey;
|
|
67
|
+
systemNamed: BaseTextKey;
|
|
68
|
+
testId: string;
|
|
69
|
+
}
|
|
70
|
+
> = {
|
|
71
|
+
'workflow.archived': {
|
|
72
|
+
user: 'workflowReviews.detail.activity.workflowArchived.user',
|
|
73
|
+
userNamed: 'workflowReviews.detail.activity.workflowArchived.user.named',
|
|
74
|
+
system: 'workflowReviews.detail.activity.workflowArchived.system',
|
|
75
|
+
systemNamed: 'workflowReviews.detail.activity.workflowArchived.system.named',
|
|
76
|
+
testId: 'workflow-review-activity-workflow-archived',
|
|
77
|
+
},
|
|
78
|
+
'workflow.deleted': {
|
|
79
|
+
user: 'workflowReviews.detail.activity.workflowDeleted.user',
|
|
80
|
+
userNamed: 'workflowReviews.detail.activity.workflowDeleted.user.named',
|
|
81
|
+
system: 'workflowReviews.detail.activity.workflowDeleted.system',
|
|
82
|
+
systemNamed: 'workflowReviews.detail.activity.workflowDeleted.system.named',
|
|
83
|
+
testId: 'workflow-review-activity-workflow-deleted',
|
|
84
|
+
},
|
|
85
|
+
'workflow.moved': {
|
|
86
|
+
user: 'workflowReviews.detail.activity.workflowMoved.user',
|
|
87
|
+
userNamed: 'workflowReviews.detail.activity.workflowMoved.user.named',
|
|
88
|
+
system: 'workflowReviews.detail.activity.workflowMoved.system',
|
|
89
|
+
systemNamed: 'workflowReviews.detail.activity.workflowMoved.system.named',
|
|
90
|
+
testId: 'workflow-review-activity-workflow-moved',
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The sentence for one entry: an i18n key plus its placeholder values, one case per type.
|
|
96
|
+
* `null` means "no sentence" — `review.closed` renders as a callout instead, and a
|
|
97
|
+
* `workflow.published` entry with an unreadable payload has nothing to say. Other types
|
|
98
|
+
* survive a bad payload with a reduced sentence: a decision loses its note, a cause event
|
|
99
|
+
* its actor.
|
|
100
|
+
*
|
|
101
|
+
* `workflow` is set only when the workflow still exists.
|
|
102
|
+
*
|
|
103
|
+
* `namesActor` comes from the entry itself, never from `createdBy` — that is also null for
|
|
104
|
+
* deleted users, whose actions must still read as a person's.
|
|
105
|
+
*/
|
|
106
|
+
const content = computed<{
|
|
107
|
+
key: BaseTextKey;
|
|
108
|
+
workflow?: { id: string; name: string };
|
|
109
|
+
version?: string;
|
|
110
|
+
note: string | null;
|
|
111
|
+
testId: string;
|
|
112
|
+
namesActor: boolean;
|
|
113
|
+
} | null>(() => {
|
|
114
|
+
const entry = props.entry;
|
|
115
|
+
// Read-time lookup shared by every sentence that may name a workflow
|
|
116
|
+
const workflowId = entry.data && 'workflowId' in entry.data ? entry.data.workflowId : null;
|
|
117
|
+
const linked = workflowId === null ? undefined : linkedWorkflows.value.get(workflowId);
|
|
118
|
+
const workflow =
|
|
119
|
+
workflowId !== null && linked ? { id: workflowId, name: linked.workflowName } : undefined;
|
|
120
|
+
|
|
121
|
+
switch (entry.type) {
|
|
122
|
+
case 'review.opened':
|
|
123
|
+
return {
|
|
124
|
+
key: 'workflowReviews.detail.activity.opened',
|
|
125
|
+
note: null,
|
|
126
|
+
testId: 'workflow-review-activity-opened',
|
|
127
|
+
namesActor: true,
|
|
128
|
+
};
|
|
129
|
+
case 'review.changes_requested':
|
|
130
|
+
return {
|
|
131
|
+
key: 'workflowReviews.detail.activity.changesRequested',
|
|
132
|
+
note: entry.data?.note ?? null,
|
|
133
|
+
testId: 'workflow-review-activity-changes-requested',
|
|
134
|
+
namesActor: true,
|
|
135
|
+
};
|
|
136
|
+
case 'review.approved':
|
|
137
|
+
return {
|
|
138
|
+
key: 'workflowReviews.detail.activity.approved',
|
|
139
|
+
note: entry.data?.note ?? null,
|
|
140
|
+
testId: 'workflow-review-activity-approved',
|
|
141
|
+
namesActor: true,
|
|
142
|
+
};
|
|
143
|
+
case 'review.version_updated':
|
|
144
|
+
return {
|
|
145
|
+
key: workflow
|
|
146
|
+
? 'workflowReviews.detail.activity.versionUpdated.named'
|
|
147
|
+
: 'workflowReviews.detail.activity.versionUpdated',
|
|
148
|
+
workflow,
|
|
149
|
+
note: null,
|
|
150
|
+
testId: 'workflow-review-activity-version-updated',
|
|
151
|
+
namesActor: true,
|
|
152
|
+
};
|
|
153
|
+
case 'review.closed':
|
|
154
|
+
// Rendered as {@link closedCallout}, never as a sentence.
|
|
155
|
+
return null;
|
|
156
|
+
case 'workflow.archived':
|
|
157
|
+
case 'workflow.deleted':
|
|
158
|
+
case 'workflow.moved': {
|
|
159
|
+
const keys = causeKeys[entry.type];
|
|
160
|
+
const namesActor = entry.data?.actorKind === 'user';
|
|
161
|
+
return {
|
|
162
|
+
key: namesActor
|
|
163
|
+
? workflow
|
|
164
|
+
? keys.userNamed
|
|
165
|
+
: keys.user
|
|
166
|
+
: workflow
|
|
167
|
+
? keys.systemNamed
|
|
168
|
+
: keys.system,
|
|
169
|
+
workflow,
|
|
170
|
+
note: null,
|
|
171
|
+
testId: keys.testId,
|
|
172
|
+
namesActor,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
case 'workflow.published': {
|
|
176
|
+
if (!entry.data) return null;
|
|
177
|
+
const versionName =
|
|
178
|
+
linked?.pinnedVersionId === entry.data.workflowVersionId ? linked.pinnedVersionName : null;
|
|
179
|
+
return {
|
|
180
|
+
key: 'workflowReviews.detail.activity.workflowPublished',
|
|
181
|
+
workflow,
|
|
182
|
+
// The shared version-label convention: user-given name, id-derived fallback.
|
|
183
|
+
version: getVersionLabel({
|
|
184
|
+
workflowHistory: { versionId: entry.data.workflowVersionId, name: versionName },
|
|
185
|
+
}),
|
|
186
|
+
note: null,
|
|
187
|
+
testId: 'workflow-review-activity-workflow-published',
|
|
188
|
+
namesActor: true,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const actorName = computed(() =>
|
|
196
|
+
formatActorName(
|
|
197
|
+
props.entry.createdBy,
|
|
198
|
+
i18n.baseText('workflowReviews.detail.activity.unknownAuthor'),
|
|
199
|
+
),
|
|
200
|
+
);
|
|
201
|
+
</script>
|
|
202
|
+
|
|
203
|
+
<template>
|
|
204
|
+
<N8nCallout
|
|
205
|
+
v-if="closedCallout"
|
|
206
|
+
theme="secondary"
|
|
207
|
+
:class="$style.closedCallout"
|
|
208
|
+
data-test-id="workflow-review-activity-closed"
|
|
209
|
+
>
|
|
210
|
+
<div :class="$style.closedCalloutContent">
|
|
211
|
+
<N8nText bold size="medium">{{ closedCallout.title }}</N8nText>
|
|
212
|
+
<N8nText size="medium">
|
|
213
|
+
<I18nT :keypath="closedCallout.bodyKey" scope="global">
|
|
214
|
+
<template #timestamp>
|
|
215
|
+
<time :datetime="entry.createdAt">
|
|
216
|
+
<N8nTimeAgo :date="entry.createdAt" :locale="rootStore.defaultLocale" />
|
|
217
|
+
</time>
|
|
218
|
+
</template>
|
|
219
|
+
</I18nT>
|
|
220
|
+
</N8nText>
|
|
221
|
+
</div>
|
|
222
|
+
</N8nCallout>
|
|
223
|
+
<div v-else-if="content" :class="[$style.entry, content.note && $style.boxed]">
|
|
224
|
+
<WorkflowReviewActivityActorAvatar v-if="content.namesActor" :actor="entry.createdBy" />
|
|
225
|
+
<!-- Fills the avatar's column, so an actorless (system) sentence stays in line -->
|
|
226
|
+
<div
|
|
227
|
+
v-else
|
|
228
|
+
:class="$style.systemIcon"
|
|
229
|
+
aria-hidden="true"
|
|
230
|
+
data-test-id="workflow-review-activity-system-icon"
|
|
231
|
+
>
|
|
232
|
+
<N8nIcon icon="info" :size="16" color="text-light" />
|
|
233
|
+
</div>
|
|
234
|
+
<!-- Column beside the avatar, as in the comment entry, so a note lines up with the
|
|
235
|
+
sentence above it instead of starting back at the avatar. -->
|
|
236
|
+
<div :class="$style.content">
|
|
237
|
+
<div :class="$style.headline">
|
|
238
|
+
<N8nText
|
|
239
|
+
v-if="content.namesActor"
|
|
240
|
+
size="medium"
|
|
241
|
+
color="text-dark"
|
|
242
|
+
:class="$style.line"
|
|
243
|
+
data-test-id="workflow-review-activity-actor"
|
|
244
|
+
>
|
|
245
|
+
{{ actorName }}
|
|
246
|
+
<!-- Inside the name so it disappears with it, rather than leaving a dangling
|
|
247
|
+
separator on the actorless entries. -->
|
|
248
|
+
<span aria-hidden="true" :class="$style.separator">|</span>
|
|
249
|
+
</N8nText>
|
|
250
|
+
<N8nText
|
|
251
|
+
size="medium"
|
|
252
|
+
color="text-light"
|
|
253
|
+
:class="$style.line"
|
|
254
|
+
:data-test-id="content.testId"
|
|
255
|
+
>
|
|
256
|
+
<I18nT :keypath="content.key" scope="global">
|
|
257
|
+
<template v-if="content.workflow" #workflowName>
|
|
258
|
+
<WorkflowReviewActivityWorkflowLink
|
|
259
|
+
:workflow-id="content.workflow.id"
|
|
260
|
+
:workflow-name="content.workflow.name"
|
|
261
|
+
:version="content.version"
|
|
262
|
+
/>
|
|
263
|
+
</template>
|
|
264
|
+
<!-- `content.version` is only ever set for workflow.published, so this is reached
|
|
265
|
+
only when that entry's workflow has since been deleted. -->
|
|
266
|
+
<template v-else-if="content.version" #workflowName
|
|
267
|
+
><i :class="$style.unknownWorkflow">{{
|
|
268
|
+
i18n.baseText('workflowReviews.detail.activity.unknownWorkflow')
|
|
269
|
+
}}</i>
|
|
270
|
+
{{ content.version }}</template
|
|
271
|
+
>
|
|
272
|
+
</I18nT>
|
|
273
|
+
</N8nText>
|
|
274
|
+
<N8nText size="medium" color="text-light" :class="$style.timeStamp">
|
|
275
|
+
<time :datetime="entry.createdAt">
|
|
276
|
+
<N8nTimeAgo :date="entry.createdAt" :locale="rootStore.defaultLocale" />
|
|
277
|
+
</time>
|
|
278
|
+
</N8nText>
|
|
279
|
+
</div>
|
|
280
|
+
<N8nText
|
|
281
|
+
v-if="content.note"
|
|
282
|
+
size="medium"
|
|
283
|
+
color="text-dark"
|
|
284
|
+
:class="[$style.body, $style.line]"
|
|
285
|
+
data-test-id="workflow-review-activity-note"
|
|
286
|
+
>
|
|
287
|
+
{{ content.note }}
|
|
288
|
+
</N8nText>
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
<WorkflowReviewActivityFallback v-else />
|
|
292
|
+
</template>
|
|
293
|
+
|
|
294
|
+
<style lang="scss" module>
|
|
295
|
+
@use '../activity-card' as *;
|
|
296
|
+
|
|
297
|
+
.entry {
|
|
298
|
+
@include activity-row;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.systemIcon {
|
|
302
|
+
@include activity-avatar;
|
|
303
|
+
|
|
304
|
+
display: flex;
|
|
305
|
+
width: var(--review-activity--avatar-size);
|
|
306
|
+
height: var(--review-activity--avatar-size);
|
|
307
|
+
align-items: center;
|
|
308
|
+
justify-content: center;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
.content {
|
|
312
|
+
display: flex;
|
|
313
|
+
flex-direction: column;
|
|
314
|
+
gap: var(--spacing--3xs);
|
|
315
|
+
min-width: 0;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/* The separator carries its own wider spacing. */
|
|
319
|
+
.headline {
|
|
320
|
+
@include activity-headline;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.separator {
|
|
324
|
+
margin-inline: var(--spacing--3xs);
|
|
325
|
+
color: var(--text-color--disabled);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
.unknownWorkflow {
|
|
329
|
+
font-style: italic;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
.timeStamp {
|
|
333
|
+
padding-left: var(--spacing--4xs);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/* A decision that carries a note sits in a card, as a comment always does. */
|
|
337
|
+
.boxed {
|
|
338
|
+
@include activity-card;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/* line up the callout's edges line up with the comment cards */
|
|
342
|
+
.closedCallout {
|
|
343
|
+
margin-inline: calc(-1 * var(--spacing--sm));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
.closedCalloutContent {
|
|
347
|
+
display: flex;
|
|
348
|
+
flex-direction: column;
|
|
349
|
+
gap: var(--spacing--5xs);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/* The same line the avatar beside it is centred on, so the two cannot drift apart. */
|
|
353
|
+
.line {
|
|
354
|
+
line-height: var(--review-activity--line-height);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
.body {
|
|
358
|
+
white-space: pre-wrap;
|
|
359
|
+
overflow-wrap: anywhere;
|
|
360
|
+
}
|
|
361
|
+
</style>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { N8nIcon, N8nText } from '@n8n/design-system';
|
|
3
|
+
import { useI18n } from '@n8n/i18n';
|
|
4
|
+
|
|
5
|
+
// The registry binds `:entry` to every activity component; without this it would
|
|
6
|
+
// fall through to the root element as `entry="[object Object]"`.
|
|
7
|
+
defineOptions({ inheritAttrs: false });
|
|
8
|
+
|
|
9
|
+
const i18n = useI18n();
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<div :class="$style.entry">
|
|
14
|
+
<!-- Fills the avatar's column like other entries, so the timeline stays on its grid. -->
|
|
15
|
+
<div :class="$style.icon" aria-hidden="true">
|
|
16
|
+
<N8nIcon icon="circle-help" :size="16" color="text-light" />
|
|
17
|
+
</div>
|
|
18
|
+
<N8nText
|
|
19
|
+
size="medium"
|
|
20
|
+
color="text-light"
|
|
21
|
+
:class="$style.line"
|
|
22
|
+
data-test-id="workflow-review-activity-unknown"
|
|
23
|
+
>
|
|
24
|
+
{{ i18n.baseText('workflowReviews.detail.activity.unknownEntry') }}
|
|
25
|
+
</N8nText>
|
|
26
|
+
</div>
|
|
27
|
+
</template>
|
|
28
|
+
|
|
29
|
+
<style lang="scss" module>
|
|
30
|
+
@use '../activity-card' as *;
|
|
31
|
+
|
|
32
|
+
.entry {
|
|
33
|
+
@include activity-row;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.icon {
|
|
37
|
+
@include activity-avatar;
|
|
38
|
+
|
|
39
|
+
display: flex;
|
|
40
|
+
width: var(--review-activity--avatar-size);
|
|
41
|
+
height: var(--review-activity--avatar-size);
|
|
42
|
+
align-items: center;
|
|
43
|
+
justify-content: center;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.line {
|
|
47
|
+
line-height: var(--review-activity--line-height);
|
|
48
|
+
}
|
|
49
|
+
</style>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { N8nIcon, N8nLink } from '@n8n/design-system';
|
|
3
|
+
import { computed } from 'vue';
|
|
4
|
+
|
|
5
|
+
import { VIEWS } from '@n8n/frontend-constants/views';
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{
|
|
8
|
+
workflowId: string;
|
|
9
|
+
workflowName: string;
|
|
10
|
+
version?: string;
|
|
11
|
+
}>();
|
|
12
|
+
|
|
13
|
+
const label = computed(() =>
|
|
14
|
+
props.version ? `${props.workflowName} ${props.version}` : props.workflowName,
|
|
15
|
+
);
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<N8nLink
|
|
20
|
+
:to="{ name: VIEWS.WORKFLOW, params: { workflowId } }"
|
|
21
|
+
theme="text"
|
|
22
|
+
size="medium"
|
|
23
|
+
data-test-id="workflow-review-activity-workflow-link"
|
|
24
|
+
>
|
|
25
|
+
<N8nIcon icon="workflow" size="medium" :class="$style.icon" />{{ label }}
|
|
26
|
+
</N8nLink>
|
|
27
|
+
</template>
|
|
28
|
+
|
|
29
|
+
<style lang="scss" module>
|
|
30
|
+
.icon {
|
|
31
|
+
margin-left: var(--spacing--4xs);
|
|
32
|
+
margin-right: var(--spacing--4xs);
|
|
33
|
+
/* Inline with the sentence; the offset optically centres the icon on the text. */
|
|
34
|
+
vertical-align: -0.15em;
|
|
35
|
+
}
|
|
36
|
+
</style>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { WorkflowReviewActivityEntry, WorkflowReviewActivityType } from '@n8n/api-types';
|
|
2
|
+
import type { Component } from 'vue';
|
|
3
|
+
|
|
4
|
+
import WorkflowReviewActivityComment from './activity-entries/WorkflowReviewActivityComment.vue';
|
|
5
|
+
import WorkflowReviewActivityFallback from './activity-entries/WorkflowReviewActivityFallback.vue';
|
|
6
|
+
import WorkflowReviewActivityEventEntry from './activity-entries/WorkflowReviewActivityEventEntry.vue';
|
|
7
|
+
|
|
8
|
+
// Total over the union so a new type cannot ship without a renderer; the runtime lookup still
|
|
9
|
+
// falls back for a stored type outside the union (e.g. after a version downgrade).
|
|
10
|
+
type ActivityEntryRegistry = Record<WorkflowReviewActivityType, Record<number, Component>>;
|
|
11
|
+
|
|
12
|
+
const registry: ActivityEntryRegistry = {
|
|
13
|
+
'comment.created': { 1: WorkflowReviewActivityComment },
|
|
14
|
+
'review.opened': { 1: WorkflowReviewActivityEventEntry },
|
|
15
|
+
'review.changes_requested': { 1: WorkflowReviewActivityEventEntry },
|
|
16
|
+
'review.approved': { 1: WorkflowReviewActivityEventEntry },
|
|
17
|
+
'review.version_updated': { 1: WorkflowReviewActivityEventEntry },
|
|
18
|
+
'review.closed': { 1: WorkflowReviewActivityEventEntry },
|
|
19
|
+
'workflow.archived': { 1: WorkflowReviewActivityEventEntry },
|
|
20
|
+
'workflow.deleted': { 1: WorkflowReviewActivityEventEntry },
|
|
21
|
+
'workflow.moved': { 1: WorkflowReviewActivityEventEntry },
|
|
22
|
+
'workflow.published': { 1: WorkflowReviewActivityEventEntry },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function resolveActivityComponent(entry: WorkflowReviewActivityEntry): Component {
|
|
26
|
+
return registry[entry.type]?.[entry.typeVersion] ?? WorkflowReviewActivityFallback;
|
|
27
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ComputedRef, InjectionKey, Ref } from 'vue';
|
|
2
|
+
|
|
3
|
+
export const WORKFLOW_REVIEW_REQUESTS_VIEW = 'WorkflowReviewRequestsView';
|
|
4
|
+
|
|
5
|
+
/** What a feed entry may say about a linked workflow: its live name and its pinned version. */
|
|
6
|
+
export type ReviewLinkedWorkflowContext = {
|
|
7
|
+
workflowName: string;
|
|
8
|
+
pinnedVersionId: string | null;
|
|
9
|
+
/** The user-given version name, e.g. "Release candidate"; null when never named or pruned. */
|
|
10
|
+
pinnedVersionName: string | null;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The review's linked workflows, keyed by workflow id. Provided by the detail surface, read
|
|
15
|
+
* by feed entries that mention a workflow. Resolved at read time on purpose: feed payloads
|
|
16
|
+
* are immutable and never snapshot names, so a rename shows up everywhere at once. A workflow
|
|
17
|
+
* deleted since the entry was written has no row here; the entry copy degrades.
|
|
18
|
+
*/
|
|
19
|
+
export const ReviewLinkedWorkflowsKey: InjectionKey<
|
|
20
|
+
ComputedRef<Map<string, ReviewLinkedWorkflowContext>>
|
|
21
|
+
> = Symbol('reviewLinkedWorkflows');
|
|
22
|
+
|
|
23
|
+
/** The detail pane's scroll area, which the activity feed scrolls in. */
|
|
24
|
+
export const ReviewDetailScrollContainerKey: InjectionKey<Readonly<Ref<HTMLElement | null>>> =
|
|
25
|
+
Symbol('reviewDetailScrollContainer');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Routing contract for the review inbox.
|
|
29
|
+
*
|
|
30
|
+
* Path: /inbox/reviews/:reviewId. Use /inbox with nothing selected.
|
|
31
|
+
* Selection always navigates with router.replace.
|
|
32
|
+
*
|
|
33
|
+
* Query params hold filter state; adding a filter must not require a routing
|
|
34
|
+
* change — reserve the key here, hydrate it in the view, and write it with
|
|
35
|
+
* router.replace({ query }) preserving params.
|
|
36
|
+
* - `state`: 'open' | 'closed'. Default 'open' is omitted from the URL.
|
|
37
|
+
* - `tab`: 'activity' | 'changes' — detail-pane tab. Default 'activity' is
|
|
38
|
+
* omitted from the URL.
|
|
39
|
+
* - Reserved for later filters: `q`, `projectId`, `author`, `reviewer`.
|
|
40
|
+
*/
|
|
41
|
+
export const REVIEW_INBOX_QUERY_PARAM = { state: 'state', tab: 'tab' } as const;
|