@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,257 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { InboxWorkflowReviewItem } from '@n8n/api-types';
|
|
3
|
+
import { useToast } from '@n8n/composables/useToast';
|
|
4
|
+
import { N8nEmptyState, N8nHeading, N8nLoading } from '@n8n/design-system';
|
|
5
|
+
import { useI18n } from '@n8n/i18n';
|
|
6
|
+
import { storeToRefs } from 'pinia';
|
|
7
|
+
import { computed, onBeforeUnmount, onMounted, watch } from 'vue';
|
|
8
|
+
|
|
9
|
+
import type { InboxItemChange } from '../inbox.constants';
|
|
10
|
+
import WorkflowReviewDetailTabs from './components/WorkflowReviewDetailTabs.vue';
|
|
11
|
+
import type { WorkflowReviewDetailTab } from './components/WorkflowReviewDetailTabs.vue';
|
|
12
|
+
import WorkflowReviewStatusDot from './components/WorkflowReviewStatusDot.vue';
|
|
13
|
+
import { useReviewActivityStore } from './reviewActivity.store';
|
|
14
|
+
import { useReviewDetailStore } from './reviewDetail.store';
|
|
15
|
+
import type { WorkflowReviewDecisionInput } from './workflowReviews.api';
|
|
16
|
+
|
|
17
|
+
const props = withDefaults(
|
|
18
|
+
defineProps<{
|
|
19
|
+
reviewId: string;
|
|
20
|
+
listItem?: InboxWorkflowReviewItem;
|
|
21
|
+
tab?: WorkflowReviewDetailTab;
|
|
22
|
+
onItemChange: (change: InboxItemChange) => void;
|
|
23
|
+
isActive: () => boolean;
|
|
24
|
+
isSelected: (reviewId: string) => boolean;
|
|
25
|
+
}>(),
|
|
26
|
+
{ tab: 'activity' },
|
|
27
|
+
);
|
|
28
|
+
const emit = defineEmits<{ 'update:tab': [tab: WorkflowReviewDetailTab] }>();
|
|
29
|
+
|
|
30
|
+
const reviewStore = useReviewDetailStore();
|
|
31
|
+
const activityStore = useReviewActivityStore();
|
|
32
|
+
const { detail, detailLoading, detailNotFound, deciding } = storeToRefs(reviewStore);
|
|
33
|
+
|
|
34
|
+
const i18n = useI18n();
|
|
35
|
+
const { showError, showMessage } = useToast();
|
|
36
|
+
|
|
37
|
+
const alertIcon = { type: 'icon', value: 'circle-alert' } as const;
|
|
38
|
+
|
|
39
|
+
const review = computed(() => {
|
|
40
|
+
if (detail.value?.id === props.reviewId) {
|
|
41
|
+
return detail.value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return props.listItem?.id === props.reviewId ? props.listItem : null;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
let isMounted = false;
|
|
48
|
+
let selectionRevision = 0;
|
|
49
|
+
|
|
50
|
+
function ownsSelection(id: string, revision = selectionRevision) {
|
|
51
|
+
return (
|
|
52
|
+
isMounted &&
|
|
53
|
+
props.reviewId === id &&
|
|
54
|
+
revision === selectionRevision &&
|
|
55
|
+
revision === reviewStore.selectionRevision
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function loadDetail(id: string, revision: number) {
|
|
60
|
+
try {
|
|
61
|
+
await reviewStore.fetchDetail(id);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (props.isActive() && props.isSelected(id) && revision === reviewStore.selectionRevision) {
|
|
64
|
+
showError(error, i18n.baseText('workflowReviews.error.load'));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
watch(
|
|
70
|
+
() => props.reviewId,
|
|
71
|
+
(id) => {
|
|
72
|
+
// A new entry owns these stores. An older layout must not reset them on unmount.
|
|
73
|
+
reviewStore.clearDetail();
|
|
74
|
+
activityStore.reset();
|
|
75
|
+
|
|
76
|
+
selectionRevision = reviewStore.selectionRevision;
|
|
77
|
+
const revision = selectionRevision;
|
|
78
|
+
|
|
79
|
+
void loadDetail(id, revision);
|
|
80
|
+
void activityStore.fetchFeed(id);
|
|
81
|
+
},
|
|
82
|
+
{ immediate: true },
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
function asSentence(message: string) {
|
|
86
|
+
const trimmed = message.trim();
|
|
87
|
+
return /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function onDecide(input: WorkflowReviewDecisionInput) {
|
|
91
|
+
const id = props.reviewId;
|
|
92
|
+
const revision = selectionRevision;
|
|
93
|
+
|
|
94
|
+
if (!ownsSelection(id, revision) || deciding.value) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The Inbox can reconcile this result after the viewer selects another source.
|
|
99
|
+
const onItemChange = props.onItemChange;
|
|
100
|
+
const isActive = props.isActive;
|
|
101
|
+
const isSelected = props.isSelected;
|
|
102
|
+
deciding.value = true;
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const { autoPublish, state, decision, updatedAt } = await reviewStore.decideOnReview(id, input);
|
|
106
|
+
onItemChange({ type: 'workflow_review', id, state, decision, updatedAt });
|
|
107
|
+
|
|
108
|
+
if (ownsSelection(id, revision)) {
|
|
109
|
+
activityStore.clearDecisionNote(input.note ?? '');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (isSelected(id)) {
|
|
113
|
+
void activityStore.fetchFeed(id);
|
|
114
|
+
|
|
115
|
+
if (state === 'closed' || detail.value?.id !== id) {
|
|
116
|
+
void loadDetail(id, reviewStore.selectionRevision);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!isActive()) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (autoPublish?.status === 'published') {
|
|
125
|
+
showMessage({
|
|
126
|
+
type: 'success',
|
|
127
|
+
title: i18n.baseText('workflowReviews.decision.approved.published.title'),
|
|
128
|
+
message: i18n.baseText('workflowReviews.decision.approved.published.message'),
|
|
129
|
+
});
|
|
130
|
+
} else if (autoPublish?.status === 'failed') {
|
|
131
|
+
showMessage({
|
|
132
|
+
type: 'warning',
|
|
133
|
+
duration: 0,
|
|
134
|
+
title: i18n.baseText('workflowReviews.decision.approved.publishFailed.title'),
|
|
135
|
+
message: i18n.baseText('workflowReviews.decision.approved.publishFailed.message', {
|
|
136
|
+
interpolate: { message: asSentence(autoPublish.message) },
|
|
137
|
+
}),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
} catch (error) {
|
|
141
|
+
onItemChange({ type: 'workflow_review', id });
|
|
142
|
+
|
|
143
|
+
if (!isActive()) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
showError(error, i18n.baseText('workflowReviews.decision.error.title'));
|
|
148
|
+
|
|
149
|
+
const selectedId = activityStore.currentReviewId;
|
|
150
|
+
if (selectedId && isSelected(selectedId)) {
|
|
151
|
+
// Another reviewer can decide first. Keep the note while loading the current result.
|
|
152
|
+
await Promise.all([
|
|
153
|
+
loadDetail(selectedId, reviewStore.selectionRevision),
|
|
154
|
+
selectedId === id ? activityStore.fetchFeed(id) : undefined,
|
|
155
|
+
]);
|
|
156
|
+
}
|
|
157
|
+
} finally {
|
|
158
|
+
deciding.value = false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
onMounted(() => {
|
|
163
|
+
isMounted = true;
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
onBeforeUnmount(() => {
|
|
167
|
+
isMounted = false;
|
|
168
|
+
});
|
|
169
|
+
</script>
|
|
170
|
+
|
|
171
|
+
<template>
|
|
172
|
+
<section :class="$style.detail" data-test-id="workflow-review-detail">
|
|
173
|
+
<div :class="$style.columnTitle">
|
|
174
|
+
<div
|
|
175
|
+
v-if="review"
|
|
176
|
+
:class="$style.reviewTitle"
|
|
177
|
+
data-test-id="workflow-review-request-title-row"
|
|
178
|
+
>
|
|
179
|
+
<WorkflowReviewStatusDot :state="review.state" :decision="review.decision" />
|
|
180
|
+
<N8nHeading bold tag="h2" size="xlarge" data-test-id="workflow-review-request-title">{{
|
|
181
|
+
review.title
|
|
182
|
+
}}</N8nHeading>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
<div :class="$style.mainBody">
|
|
186
|
+
<div
|
|
187
|
+
v-if="detailNotFound"
|
|
188
|
+
:class="$style.emptyStateWrapper"
|
|
189
|
+
data-test-id="workflow-review-detail-not-found"
|
|
190
|
+
>
|
|
191
|
+
<N8nEmptyState
|
|
192
|
+
:class="$style.emptyState"
|
|
193
|
+
:icon="alertIcon"
|
|
194
|
+
:heading="i18n.baseText('workflowReviews.detail.notFound.title')"
|
|
195
|
+
:description="i18n.baseText('workflowReviews.detail.notFound.body')"
|
|
196
|
+
/>
|
|
197
|
+
</div>
|
|
198
|
+
<div v-else-if="detailLoading" :class="$style.detailSkeleton">
|
|
199
|
+
<N8nLoading :loading="true" :rows="3" />
|
|
200
|
+
</div>
|
|
201
|
+
<WorkflowReviewDetailTabs
|
|
202
|
+
v-else-if="review"
|
|
203
|
+
:review="review"
|
|
204
|
+
:tab="tab"
|
|
205
|
+
:deciding="deciding"
|
|
206
|
+
@update:tab="emit('update:tab', $event)"
|
|
207
|
+
@decide="onDecide"
|
|
208
|
+
/>
|
|
209
|
+
<slot v-else />
|
|
210
|
+
</div>
|
|
211
|
+
</section>
|
|
212
|
+
</template>
|
|
213
|
+
|
|
214
|
+
<style lang="scss" module>
|
|
215
|
+
.detail {
|
|
216
|
+
display: flex;
|
|
217
|
+
flex: 1;
|
|
218
|
+
flex-direction: column;
|
|
219
|
+
min-height: 0;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.columnTitle {
|
|
223
|
+
display: flex;
|
|
224
|
+
align-items: center;
|
|
225
|
+
min-height: var(--spacing--2xl);
|
|
226
|
+
padding-bottom: var(--spacing--sm);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.reviewTitle {
|
|
230
|
+
display: flex;
|
|
231
|
+
align-items: center;
|
|
232
|
+
gap: var(--spacing--2xs);
|
|
233
|
+
min-width: 0;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.mainBody {
|
|
237
|
+
flex: 1;
|
|
238
|
+
min-height: 0;
|
|
239
|
+
overflow: auto;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.detailSkeleton {
|
|
243
|
+
max-width: var(--review-activity--max-width);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
.emptyStateWrapper {
|
|
247
|
+
display: flex;
|
|
248
|
+
align-items: center;
|
|
249
|
+
justify-content: center;
|
|
250
|
+
height: 100%;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.emptyStateWrapper .emptyState {
|
|
254
|
+
border: none;
|
|
255
|
+
padding: 0;
|
|
256
|
+
}
|
|
257
|
+
</style>
|