@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,289 @@
1
+ import type { InboxSelfHealingItem, InboxWorkflowReviewItem } from '@n8n/api-types';
2
+ import { createComponentRenderer } from '@n8n/frontend-test-utils';
3
+ import { useUsersStore } from '@n8n/stores/users.store';
4
+ import userEvent from '@testing-library/user-event';
5
+ import { fireEvent, within } from '@testing-library/vue';
6
+ import { useIntersectionObserver } from '@vueuse/core';
7
+
8
+ import InboxList from './InboxList.vue';
9
+ import type { InboxListSection } from './InboxListSection.vue';
10
+
11
+ vi.mock('@vueuse/core', async (importOriginal) => ({
12
+ ...(await importOriginal<typeof import('@vueuse/core')>()),
13
+ useIntersectionObserver: vi.fn(),
14
+ }));
15
+
16
+ function section(
17
+ key: InboxListSection['key'],
18
+ props: Partial<InboxListSection> = {},
19
+ ): InboxListSection {
20
+ return {
21
+ key,
22
+ items: [],
23
+ loading: false,
24
+ loadingMore: false,
25
+ hasMore: false,
26
+ error: null,
27
+ failedRequest: null,
28
+ partial: false,
29
+ ...props,
30
+ };
31
+ }
32
+ function result(props: Partial<InboxSelfHealingItem> = {}): InboxSelfHealingItem {
33
+ return {
34
+ type: 'self_healing_result',
35
+ id: 'result',
36
+ state: 'open',
37
+ projectId: 'project',
38
+ workflowId: 'workflow',
39
+ workflowName: 'My workflow',
40
+ summary: 'Fix the workflow',
41
+ outcome: 'fix_ready',
42
+ createdAt: '2026-01-01T00:00:00.000Z',
43
+ updatedAt: '2026-01-01T00:00:00.000Z',
44
+ completedAt: '2026-01-01T00:00:00.000Z',
45
+ ...props,
46
+ };
47
+ }
48
+ function review(): InboxWorkflowReviewItem {
49
+ return {
50
+ type: 'workflow_review',
51
+ id: 'review',
52
+ state: 'open',
53
+ decision: 'pending',
54
+ projectId: 'project',
55
+ title: 'Review my workflow',
56
+ workflowName: 'My workflow',
57
+ workflowVersionId: null,
58
+ requester: null,
59
+ authors: [],
60
+ reviewers: [],
61
+ createdAt: '2026-01-01T00:00:00.000Z',
62
+ updatedAt: '2026-01-01T00:00:00.000Z',
63
+ };
64
+ }
65
+ const renderComponent = createComponentRenderer(InboxList, {
66
+ props: {
67
+ sections: [section('waiting'), section('authored')],
68
+ activeTab: 'open',
69
+ selectedKey: null,
70
+ openCount: null,
71
+ closedCount: null,
72
+ },
73
+ });
74
+ beforeEach(() => localStorage.clear());
75
+ function intersect(isIntersecting = true) {
76
+ const onIntersect = vi.mocked(useIntersectionObserver).mock.calls.at(-1)![1];
77
+ onIntersect([{ isIntersecting } as IntersectionObserverEntry], {} as IntersectionObserver);
78
+ }
79
+
80
+ it('shows one initial skeleton without empty group headers', () => {
81
+ const { getAllByTestId, queryByTestId } = renderComponent({
82
+ props: {
83
+ sections: [section('waiting', { loading: true }), section('authored', { loading: true })],
84
+ },
85
+ });
86
+ expect(getAllByTestId('inbox-list-skeleton')).toHaveLength(1);
87
+ expect(queryByTestId('inbox-section-header')).not.toBeInTheDocument();
88
+ });
89
+ it('offers one retry when the first load fails without rows', () => {
90
+ const { getAllByRole, queryByTestId, emitted } = renderComponent({
91
+ props: {
92
+ sections: [
93
+ section('waiting', { error: new Error('Request failed'), failedRequest: 'list' }),
94
+ section('authored', { error: new Error('Request failed'), failedRequest: 'list' }),
95
+ ],
96
+ },
97
+ });
98
+ const buttons = getAllByRole('button', { name: 'Retry' });
99
+ expect(buttons).toHaveLength(1);
100
+ buttons[0].click();
101
+ expect(emitted('retryActiveTab')).toEqual([[]]);
102
+ expect(queryByTestId('inbox-section-header')).not.toBeInTheDocument();
103
+ });
104
+ it('retries the failed page after all loaded rows leave the list', () => {
105
+ const { getByRole, emitted } = renderComponent({
106
+ props: {
107
+ sections: [
108
+ section('waiting', {
109
+ hasMore: true,
110
+ error: new Error('Page failed'),
111
+ failedRequest: 'loadMore',
112
+ }),
113
+ section('authored'),
114
+ ],
115
+ },
116
+ });
117
+ getByRole('button', { name: 'Retry' }).click();
118
+ expect(emitted('retry')).toEqual([['waiting']]);
119
+ expect(emitted('retryActiveTab')).toBeUndefined();
120
+ expect(getByRole('button', { name: 'Waiting for your review' })).toBeInTheDocument();
121
+ });
122
+ it('keeps healthy rows visible beside a failed group', () => {
123
+ const { queryByRole, getByRole, emitted } = renderComponent({
124
+ props: {
125
+ sections: [
126
+ section('waiting', { items: [result()] }),
127
+ section('authored', { error: new Error('Request failed') }),
128
+ ],
129
+ },
130
+ });
131
+ expect(getByRole('button', { name: /Fix the workflow/ })).toHaveTextContent('Fix the workflow');
132
+ getByRole('button', { name: 'Retry' }).click();
133
+ expect(emitted('retry')).toEqual([['authored']]);
134
+ expect(queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument();
135
+ });
136
+ it('hides settled empty group headers', () => {
137
+ const { queryByTestId } = renderComponent();
138
+ expect(queryByTestId('inbox-section-header')).not.toBeInTheDocument();
139
+ });
140
+ it('keeps a partial empty group visible with its own retry', () => {
141
+ const { getByRole, emitted } = renderComponent({
142
+ props: { sections: [section('waiting', { partial: true }), section('authored')] },
143
+ });
144
+ expect(getByRole('button', { name: 'Waiting for your review' })).toBeInTheDocument();
145
+ getByRole('button', { name: 'Retry' }).click();
146
+ expect(emitted('refreshSection')).toEqual([['waiting']]);
147
+ });
148
+ it('collapses groups without requesting more data', async () => {
149
+ const { getByRole, queryByRole, emitted } = renderComponent({
150
+ props: { sections: [section('waiting', { items: [result()] })] },
151
+ });
152
+ const header = getByRole('button', { name: 'Waiting for your review' });
153
+ await fireEvent.click(header);
154
+ expect(header).toHaveAttribute('aria-expanded', 'false');
155
+ expect(queryByRole('button', { name: /Fix the workflow/ })).not.toBeInTheDocument();
156
+ await fireEvent.click(header);
157
+ expect(getByRole('button', { name: /Fix the workflow/ })).toHaveTextContent('Fix the workflow');
158
+ expect(emitted('loadMore')).toBeUndefined();
159
+ });
160
+ it('uses the impersonal waiting label for administrators', () => {
161
+ vi.spyOn(useUsersStore(), 'isAdminOrOwner', 'get').mockReturnValue(true);
162
+ const { getByRole } = renderComponent({
163
+ props: { sections: [section('waiting', { items: [review()] })] },
164
+ });
165
+ expect(getByRole('button', { name: 'Waiting for review' })).toBeInTheDocument();
166
+ });
167
+ it('keeps loading open groups explicit and identifies the group', () => {
168
+ const { emitted, getByRole } = renderComponent({
169
+ props: { sections: [section('authored', { hasMore: true })] },
170
+ });
171
+ intersect();
172
+ expect(emitted('loadMore')).toBeUndefined();
173
+ getByRole('button', { name: 'Load more' }).click();
174
+ expect(emitted('loadMore')).toEqual([['authored']]);
175
+ });
176
+ it('loads Closed at the bottom without group headers or a Load more button', () => {
177
+ const { emitted, queryByRole, queryByTestId } = renderComponent({
178
+ props: { activeTab: 'closed', sections: [section('closed', { hasMore: true })] },
179
+ });
180
+ intersect(false);
181
+ expect(emitted('loadMore')).toBeUndefined();
182
+ intersect();
183
+ expect(emitted('loadMore')).toEqual([['closed']]);
184
+ expect(queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument();
185
+ expect(queryByTestId('inbox-section-header')).not.toBeInTheDocument();
186
+ });
187
+ it.each([{ loading: true }, { loadingMore: true }, { hasMore: false }])(
188
+ 'does not automatically load Closed with %o',
189
+ (props) => {
190
+ const { emitted } = renderComponent({
191
+ props: { activeTab: 'closed', sections: [section('closed', { hasMore: true, ...props })] },
192
+ });
193
+ intersect();
194
+ expect(emitted('loadMore')).toBeUndefined();
195
+ },
196
+ );
197
+ it('waits for Retry after a failed Closed page and removes the sentinel on Open', async () => {
198
+ const closed = section('closed', { hasMore: true, items: [{ ...review(), state: 'closed' }] });
199
+ const { rerender, emitted } = renderComponent({
200
+ props: { activeTab: 'closed', sections: [closed] },
201
+ });
202
+ const target = vi.mocked(useIntersectionObserver).mock.calls.at(-1)![0];
203
+ expect(target).not.toHaveProperty('value', null);
204
+ intersect();
205
+ await rerender({ sections: [{ ...closed, loadingMore: true }] });
206
+ expect(target).toHaveProperty('value', null);
207
+ intersect();
208
+ expect(emitted('loadMore')).toHaveLength(1);
209
+ await rerender({
210
+ sections: [{ ...closed, error: new Error('Page failed') }],
211
+ });
212
+ expect(target).toHaveProperty('value', null);
213
+ intersect();
214
+ expect(emitted('loadMore')).toHaveLength(1);
215
+ await rerender({ sections: [closed] });
216
+ expect(target).not.toHaveProperty('value', null);
217
+ intersect();
218
+ expect(emitted('loadMore')).toHaveLength(2);
219
+ await rerender({ activeTab: 'open', sections: [section('waiting')] });
220
+ expect(target).toHaveProperty('value', null);
221
+ });
222
+ it('renders compact rows with an Assistant avatar and no source subtitle', () => {
223
+ const { getByRole, queryByText } = renderComponent({
224
+ props: { sections: [section('waiting', { items: [result(), review()] })] },
225
+ });
226
+ const [assistantRow, reviewRow] = within(getByRole('list')).getAllByRole('button');
227
+ expect(within(assistantRow).getByTestId('inbox-assistant-avatar')).toBeInTheDocument();
228
+ expect(within(reviewRow).queryByTestId('inbox-assistant-avatar')).not.toBeInTheDocument();
229
+ expect(within(assistantRow).getByText('My workflow')).toBeInTheDocument();
230
+ expect(queryByText('Workflow review')).not.toBeInTheDocument();
231
+ expect(queryByText('Fix ready')).not.toBeInTheDocument();
232
+ });
233
+ it.each([
234
+ ['fix_ready', 'open', 'Open | Fix ready', 'fixReady'],
235
+ ['needs_you', 'open', 'Open | Needs attention', 'needsAttention'],
236
+ ['could_not_fix', 'open', 'Open | Could not fix', 'needsAttention'],
237
+ ['fix_ready', 'closed', 'Closed | Fix ready', 'closed'],
238
+ ] as const)(
239
+ 'labels Assistant %s/%s without implying review approval',
240
+ (outcome, state, label, color) => {
241
+ const { getByRole } = renderComponent({
242
+ props: { sections: [section('waiting', { items: [result({ outcome, state })] })] },
243
+ });
244
+ const dot = getByRole('img', { name: label });
245
+ expect(dot.className).toContain(color);
246
+ expect(dot).not.toHaveAccessibleName(/approved/i);
247
+ },
248
+ );
249
+
250
+ it('separates partial-source recovery from a failed page retry', () => {
251
+ const { getByTestId, emitted } = renderComponent({
252
+ props: { sections: [section('waiting', { partial: true, error: new Error('Page failed') })] },
253
+ });
254
+ within(getByTestId('inbox-partial')).getByRole('button', { name: 'Retry' }).click();
255
+ expect(emitted('refreshSection')).toEqual([['waiting']]);
256
+ expect(emitted('retry')).toBeUndefined();
257
+ within(getByTestId('inbox-list-error')).getByRole('button', { name: 'Retry' }).click();
258
+ expect(emitted('retry')).toEqual([['waiting']]);
259
+ });
260
+
261
+ it('uses Tab, Enter, and Space to select rows in an ordinary list', async () => {
262
+ const user = userEvent.setup();
263
+ const first = review();
264
+ const second = result();
265
+ const { getByRole, queryByRole, emitted, rerender } = renderComponent({
266
+ props: {
267
+ selectedKey: `${first.type}:${first.id}`,
268
+ sections: [section('waiting', { items: [first, second] })],
269
+ },
270
+ });
271
+ const list = getByRole('list', { name: 'Waiting for your review' });
272
+ const rows = within(list)
273
+ .getAllByRole('listitem')
274
+ .map((item) => within(item).getByRole('button'));
275
+ expect(queryByRole('listbox')).not.toBeInTheDocument();
276
+ expect(rows[0]).toHaveAttribute('aria-current', 'true');
277
+ expect(rows[1]).not.toHaveAttribute('aria-current');
278
+ getByRole('button', { name: 'Waiting for your review' }).focus();
279
+ await user.tab();
280
+ expect(rows[0]).toHaveFocus();
281
+ await user.keyboard('{Enter}');
282
+ await user.tab();
283
+ expect(rows[1]).toHaveFocus();
284
+ await user.keyboard(' ');
285
+ expect(emitted('select')).toEqual([[first], [second]]);
286
+ await rerender({ selectedKey: `${second.type}:${second.id}` });
287
+ expect(rows[0]).not.toHaveAttribute('aria-current');
288
+ expect(rows[1]).toHaveAttribute('aria-current', 'true');
289
+ });
@@ -0,0 +1,195 @@
1
+ <script setup lang="ts">
2
+ import type { InboxItem, InboxState } from '@n8n/api-types';
3
+ import { N8nButton, N8nHeading, N8nLoading, N8nTabs, N8nText } from '@n8n/design-system';
4
+ import { useI18n } from '@n8n/i18n';
5
+ import { useIntersectionObserver } from '@vueuse/core';
6
+ import { computed, useTemplateRef } from 'vue';
7
+
8
+ import { useReviewInboxSectionCollapse } from '../composables/useReviewInboxSectionCollapse';
9
+ import type { InboxSectionKey } from '../inbox.constants';
10
+ import InboxListSection from './InboxListSection.vue';
11
+ import type { InboxListSection as ListSection } from './InboxListSection.vue';
12
+
13
+ const props = defineProps<{
14
+ sections: ListSection[];
15
+ activeTab: InboxState;
16
+ selectedKey: string | null;
17
+ openCount: number | null;
18
+ closedCount: number | null;
19
+ }>();
20
+ const emit = defineEmits<{
21
+ select: [item: InboxItem];
22
+ clear: [];
23
+ 'update:active-tab': [state: InboxState];
24
+ loadMore: [section: InboxSectionKey];
25
+ retry: [section: InboxSectionKey];
26
+ retryActiveTab: [];
27
+ refreshSection: [section: InboxSectionKey];
28
+ }>();
29
+ const i18n = useI18n();
30
+ const { isCollapsed, toggleSection } = useReviewInboxSectionCollapse();
31
+ const loading = computed(() => props.sections.some((section) => section.loading));
32
+ const hasUsableRows = computed(() => props.sections.some((section) => section.items.length > 0));
33
+ const showInitialLoadError = computed(
34
+ () =>
35
+ !hasUsableRows.value &&
36
+ props.sections.some((section) => section.failedRequest === 'list') &&
37
+ !props.sections.some((section) => section.partial),
38
+ );
39
+ const visibleSections = computed(() =>
40
+ props.sections.filter(
41
+ (section) =>
42
+ !loading.value &&
43
+ !showInitialLoadError.value &&
44
+ (section.error || section.partial || section.items.length > 0 || section.hasMore),
45
+ ),
46
+ );
47
+ const listRef = useTemplateRef<HTMLElement>('list');
48
+ const loadMoreSentinel = useTemplateRef<HTMLElement>('loadMoreSentinel');
49
+ const canAutoLoad = computed(() => {
50
+ const closed = props.sections.find((section) => section.key === 'closed');
51
+ // A failed page waits for Retry. A visible sentinel would otherwise retry in a loop.
52
+ return (
53
+ props.activeTab === 'closed' &&
54
+ closed?.hasMore &&
55
+ !loading.value &&
56
+ !closed.loadingMore &&
57
+ !closed.error
58
+ );
59
+ });
60
+ useIntersectionObserver(
61
+ loadMoreSentinel,
62
+ ([entry]) => {
63
+ if (entry?.isIntersecting && canAutoLoad.value) emit('loadMore', 'closed');
64
+ },
65
+ { root: listRef, threshold: 0.01 },
66
+ );
67
+ const tabs = computed(() => [
68
+ {
69
+ value: 'open',
70
+ label: i18n.baseText('inbox.tabs.open'),
71
+ tag: props.openCount === null ? undefined : String(props.openCount),
72
+ },
73
+ {
74
+ value: 'closed',
75
+ label: i18n.baseText('inbox.tabs.closed'),
76
+ tag: props.closedCount === null ? undefined : String(props.closedCount),
77
+ },
78
+ ]);
79
+ function onTabChange(value: string | number | boolean) {
80
+ if (value === 'open' || value === 'closed') emit('update:active-tab', value);
81
+ }
82
+
83
+ function onListBackgroundClick() {
84
+ if (props.selectedKey) emit('clear');
85
+ }
86
+ </script>
87
+
88
+ <template>
89
+ <aside :class="$style.sidebar" data-test-id="inbox-list">
90
+ <div :class="$style.title">
91
+ <N8nHeading bold tag="h2" size="xlarge">{{ i18n.baseText('inbox.title') }}</N8nHeading>
92
+ </div>
93
+ <div :class="$style.header">
94
+ <N8nTabs
95
+ :model-value="activeTab"
96
+ :options="tabs"
97
+ variant="modern"
98
+ data-test-id="inbox-tabs"
99
+ @update:model-value="onTabChange"
100
+ />
101
+ </div>
102
+ <div ref="list" :class="$style.list" @click.self="onListBackgroundClick">
103
+ <N8nLoading v-if="loading" :loading="true" :rows="3" data-test-id="inbox-list-skeleton" />
104
+ <div
105
+ v-else-if="showInitialLoadError"
106
+ :class="$style.sectionError"
107
+ role="alert"
108
+ data-test-id="inbox-list-error"
109
+ >
110
+ <N8nText color="danger" size="small">{{ i18n.baseText('inbox.loadError') }}</N8nText>
111
+ <N8nButton
112
+ variant="outline"
113
+ size="mini"
114
+ :label="i18n.baseText('generic.retry')"
115
+ @click="emit('retryActiveTab')"
116
+ />
117
+ </div>
118
+ <InboxListSection
119
+ v-for="section in visibleSections"
120
+ :key="section.key"
121
+ :section="section"
122
+ :collapsed="section.key !== 'closed' && isCollapsed(section.key)"
123
+ :selected-key="selectedKey"
124
+ @select="emit('select', $event)"
125
+ @toggle="toggleSection"
126
+ @load-more="emit('loadMore', section.key)"
127
+ @retry="emit('retry', section.key)"
128
+ @refresh-section="emit('refreshSection', section.key)"
129
+ />
130
+ <div v-if="canAutoLoad" ref="loadMoreSentinel" :class="$style.sentinel" />
131
+ <div
132
+ v-else-if="activeTab === 'closed' && !loading && !showInitialLoadError"
133
+ :class="$style.sentinel"
134
+ />
135
+ </div>
136
+ </aside>
137
+ </template>
138
+
139
+ <style module lang="scss">
140
+ .sidebar {
141
+ display: flex;
142
+ flex-direction: column;
143
+ width: 100%;
144
+ min-width: 0;
145
+ height: 100%;
146
+ border-right: var(--border-width) solid var(--border-color);
147
+ }
148
+
149
+ .title {
150
+ display: flex;
151
+ align-items: center;
152
+ min-height: var(--spacing--2xl);
153
+ padding: 0 var(--spacing--md) var(--spacing--sm) 0;
154
+ }
155
+
156
+ .header {
157
+ position: relative;
158
+ display: flex;
159
+ align-items: center;
160
+ height: var(--review-tab-bar--height, var(--height--sm));
161
+ padding-right: var(--spacing--md);
162
+ margin-bottom: var(--review-tab-bar--gap);
163
+
164
+ &::after {
165
+ content: '';
166
+ position: absolute;
167
+ left: 0;
168
+ right: var(--spacing--md);
169
+ bottom: calc(-1 * var(--review-tab-bar--indicator-overhang) - var(--border-width));
170
+ border-bottom: var(--border-width) solid var(--border-color);
171
+ }
172
+ }
173
+
174
+ .list {
175
+ display: flex;
176
+ flex: 1;
177
+ flex-direction: column;
178
+ gap: var(--spacing--sm);
179
+ min-width: 0;
180
+ overflow-y: auto;
181
+ padding: 0 var(--spacing--md) var(--spacing--md) 0;
182
+ }
183
+
184
+ .sectionError {
185
+ display: flex;
186
+ align-items: center;
187
+ justify-content: space-between;
188
+ gap: var(--spacing--2xs);
189
+ padding: 0 var(--spacing--2xs);
190
+ }
191
+
192
+ .sentinel {
193
+ height: var(--border-width);
194
+ }
195
+ </style>
@@ -0,0 +1,208 @@
1
+ <script setup lang="ts">
2
+ import type { InboxItem, InboxSelfHealingItem } from '@n8n/api-types';
3
+ import {
4
+ N8nAssistantAvatar,
5
+ N8nBadge,
6
+ N8nCard,
7
+ N8nIcon,
8
+ N8nText,
9
+ N8nTimeAgo,
10
+ } from '@n8n/design-system';
11
+ import { useI18n } from '@n8n/i18n';
12
+ import { useRootStore } from '@n8n/stores/useRootStore';
13
+
14
+ import WorkflowReviewStatusDot from '../reviews/components/WorkflowReviewStatusDot.vue';
15
+
16
+ defineProps<{ item: InboxItem; selected: boolean }>();
17
+ const emit = defineEmits<{ select: [] }>();
18
+ const i18n = useI18n();
19
+ const rootStore = useRootStore();
20
+
21
+ function outcomeLabel(item: InboxSelfHealingItem) {
22
+ switch (item.outcome) {
23
+ case 'fix_ready':
24
+ return i18n.baseText('inbox.outcome.fixReady');
25
+ case 'needs_you':
26
+ return i18n.baseText('inbox.outcome.needsAttention');
27
+ case 'could_not_fix':
28
+ return i18n.baseText('inbox.outcome.couldNotFix');
29
+ }
30
+ }
31
+ </script>
32
+
33
+ <template>
34
+ <N8nCard
35
+ :class="[$style.card, { [$style.selected]: selected }]"
36
+ role="button"
37
+ tabindex="0"
38
+ :aria-current="selected ? 'true' : undefined"
39
+ :data-source="item.type"
40
+ data-test-id="inbox-row"
41
+ @click="emit('select')"
42
+ @keydown.enter.prevent="emit('select')"
43
+ @keydown.space.prevent="emit('select')"
44
+ >
45
+ <div :class="$style.cardContent">
46
+ <div :class="$style.cardHeader">
47
+ <N8nAssistantAvatar
48
+ v-if="item.type === 'self_healing_result'"
49
+ size="mini"
50
+ :class="$style.assistantAvatar"
51
+ data-test-id="inbox-assistant-avatar"
52
+ />
53
+ <N8nText
54
+ bold
55
+ tag="h3"
56
+ :class="[
57
+ $style.cardTitle,
58
+ { [$style.assistantTitle]: item.type === 'self_healing_result' },
59
+ ]"
60
+ >{{ item.type === 'workflow_review' ? item.title : item.summary }}</N8nText
61
+ >
62
+ <WorkflowReviewStatusDot
63
+ v-if="item.type === 'workflow_review'"
64
+ :state="item.state"
65
+ :decision="item.decision"
66
+ />
67
+ <span
68
+ v-else
69
+ :class="[
70
+ $style.statusDot,
71
+ item.state === 'closed'
72
+ ? $style.closed
73
+ : item.outcome === 'fix_ready'
74
+ ? $style.fixReady
75
+ : $style.needsAttention,
76
+ ]"
77
+ role="img"
78
+ :aria-label="`${i18n.baseText(item.state === 'open' ? 'inbox.tabs.open' : 'inbox.tabs.closed')} | ${outcomeLabel(item)}`"
79
+ data-test-id="inbox-assistant-status"
80
+ />
81
+ </div>
82
+ <div :class="$style.meta">
83
+ <N8nBadge v-if="item.workflowName" variant="outline" :class="$style.workflowBadge">
84
+ <span :class="$style.workflowBadgeText" :title="item.workflowName"
85
+ ><N8nIcon icon="workflow" size="small" /><span>{{ item.workflowName }}</span></span
86
+ >
87
+ </N8nBadge>
88
+ <div :class="$style.metaActions">
89
+ <N8nText :class="$style.time" size="xsmall" color="text-light">
90
+ <N8nTimeAgo :date="item.updatedAt" :locale="rootStore.defaultLocale" />
91
+ </N8nText>
92
+ </div>
93
+ </div>
94
+ </div>
95
+ </N8nCard>
96
+ </template>
97
+
98
+ <style module lang="scss">
99
+ .card {
100
+ cursor: pointer;
101
+ padding: var(--spacing--xs);
102
+ align-items: stretch;
103
+ border: var(--border-width) solid var(--border-color);
104
+ }
105
+
106
+ .card:hover:not(.selected) {
107
+ background-color: var(--background--hover);
108
+ border-color: transparent;
109
+ }
110
+
111
+ .selected {
112
+ background-color: var(--background--active);
113
+ border-color: transparent;
114
+ }
115
+ .card:focus-visible {
116
+ border-color: var(--focus--border-color);
117
+ }
118
+ .cardContent {
119
+ display: flex;
120
+ flex-direction: column;
121
+ align-items: flex-start;
122
+ gap: var(--spacing--2xs);
123
+ min-width: 0;
124
+ width: 100%;
125
+ }
126
+ .cardHeader {
127
+ display: flex;
128
+ align-items: center;
129
+ justify-content: space-between;
130
+ gap: var(--spacing--2xs);
131
+ width: 100%;
132
+ min-width: 0;
133
+ }
134
+ .cardTitle {
135
+ overflow: hidden;
136
+ text-overflow: ellipsis;
137
+ white-space: nowrap;
138
+ min-width: 0;
139
+ font-size: var(--font-size--sm);
140
+ }
141
+
142
+ .assistantTitle {
143
+ flex: 1;
144
+ }
145
+
146
+ .assistantAvatar {
147
+ flex-shrink: 0;
148
+ }
149
+ .meta {
150
+ display: flex;
151
+ align-items: center;
152
+ justify-content: space-between;
153
+ gap: var(--spacing--sm);
154
+ width: 100%;
155
+ min-width: 0;
156
+ }
157
+ .workflowBadge {
158
+ flex: 0 1 auto;
159
+ min-width: 0;
160
+ border: var(--border);
161
+ border-radius: var(--radius);
162
+ padding: var(--spacing--4xs) var(--spacing--2xs);
163
+ color: var(--color--text);
164
+ > span {
165
+ max-width: 100%;
166
+ }
167
+ }
168
+ .workflowBadgeText {
169
+ display: inline-flex;
170
+ align-items: center;
171
+ gap: var(--spacing--3xs);
172
+ max-width: 100%;
173
+ min-width: 0;
174
+ line-height: calc(var(--font-size--sm) + var(--border-width));
175
+ > span {
176
+ overflow: hidden;
177
+ text-overflow: ellipsis;
178
+ white-space: nowrap;
179
+ min-width: 0;
180
+ }
181
+ }
182
+ .metaActions {
183
+ display: flex;
184
+ align-items: center;
185
+ gap: var(--spacing--sm);
186
+ margin-left: auto;
187
+ flex-shrink: 0;
188
+ }
189
+
190
+ .time {
191
+ white-space: nowrap;
192
+ }
193
+ .statusDot {
194
+ flex-shrink: 0;
195
+ width: var(--font-size--3xs);
196
+ height: var(--font-size--3xs);
197
+ border-radius: 50%;
198
+ }
199
+ .fixReady {
200
+ background-color: var(--color--blue-500);
201
+ }
202
+ .needsAttention {
203
+ background-color: var(--color--yellow-500);
204
+ }
205
+ .closed {
206
+ background-color: var(--color--neutral-500);
207
+ }
208
+ </style>