@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.
Files changed (58) hide show
  1. package/LICENSE.md +88 -0
  2. package/README.md +59 -2
  3. package/biome.jsonc +4 -0
  4. package/eslint.config.mjs +4 -0
  5. package/package.json +62 -3
  6. package/src/__tests__/design-system-icons.test.ts +24 -0
  7. package/src/__tests__/setup.ts +4 -0
  8. package/src/components/InboxEmptyState.vue +86 -0
  9. package/src/components/InboxList.test.ts +229 -0
  10. package/src/components/InboxList.vue +195 -0
  11. package/src/components/InboxListItem.vue +134 -0
  12. package/src/components/InboxListSection.vue +168 -0
  13. package/src/composables/useReviewInboxSectionCollapse.test.ts +66 -0
  14. package/src/composables/useReviewInboxSectionCollapse.ts +42 -0
  15. package/src/inbox.api.ts +20 -0
  16. package/src/inbox.constants.ts +16 -0
  17. package/src/inbox.module.ts +50 -0
  18. package/src/inbox.routes.test.ts +111 -0
  19. package/src/inbox.routes.ts +30 -0
  20. package/src/inbox.store.test.ts +349 -0
  21. package/src/inbox.store.ts +305 -0
  22. package/src/index.ts +5 -0
  23. package/src/reviews/WorkflowReviewDetail.test.ts +647 -0
  24. package/src/reviews/WorkflowReviewDetail.vue +257 -0
  25. package/src/reviews/components/WorkflowReviewActivityFeed.test.ts +923 -0
  26. package/src/reviews/components/WorkflowReviewActivityFeed.vue +312 -0
  27. package/src/reviews/components/WorkflowReviewChangesSection.test.ts +327 -0
  28. package/src/reviews/components/WorkflowReviewChangesSection.vue +219 -0
  29. package/src/reviews/components/WorkflowReviewCommentComposer.test.ts +175 -0
  30. package/src/reviews/components/WorkflowReviewCommentComposer.vue +83 -0
  31. package/src/reviews/components/WorkflowReviewDecisionPopover.test.ts +288 -0
  32. package/src/reviews/components/WorkflowReviewDecisionPopover.vue +203 -0
  33. package/src/reviews/components/WorkflowReviewDetailMetadata.test.ts +206 -0
  34. package/src/reviews/components/WorkflowReviewDetailMetadata.vue +241 -0
  35. package/src/reviews/components/WorkflowReviewDetailTabs.decisionNote.test.ts +90 -0
  36. package/src/reviews/components/WorkflowReviewDetailTabs.test.ts +434 -0
  37. package/src/reviews/components/WorkflowReviewDetailTabs.vue +311 -0
  38. package/src/reviews/components/WorkflowReviewStatusDot.vue +67 -0
  39. package/src/reviews/components/_activity-card.scss +36 -0
  40. package/src/reviews/components/activity-entries/WorkflowReviewActivityActorAvatar.vue +50 -0
  41. package/src/reviews/components/activity-entries/WorkflowReviewActivityComment.test.ts +103 -0
  42. package/src/reviews/components/activity-entries/WorkflowReviewActivityComment.vue +116 -0
  43. package/src/reviews/components/activity-entries/WorkflowReviewActivityEventEntry.vue +361 -0
  44. package/src/reviews/components/activity-entries/WorkflowReviewActivityFallback.vue +49 -0
  45. package/src/reviews/components/activity-entries/WorkflowReviewActivityWorkflowLink.vue +36 -0
  46. package/src/reviews/components/activityEntryRegistry.ts +27 -0
  47. package/src/reviews/constants.ts +41 -0
  48. package/src/reviews/reviewActivity.store.test.ts +463 -0
  49. package/src/reviews/reviewActivity.store.ts +186 -0
  50. package/src/reviews/reviewDetail.store.test.ts +135 -0
  51. package/src/reviews/reviewDetail.store.ts +74 -0
  52. package/src/reviews/workflowReviewStatus.utils.ts +44 -0
  53. package/src/reviews/workflowReviews.api.ts +61 -0
  54. package/src/reviews/workflowReviews.utils.ts +18 -0
  55. package/src/views/InboxView.test.ts +525 -0
  56. package/src/views/InboxView.vue +251 -0
  57. package/stylelint.config.mjs +9 -0
  58. 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>