@n8n/frontend-module-inbox 0.1.0 → 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_EE.md +27 -0
- package/README.md +5 -3
- package/package.json +22 -12
- package/src/components/InboxEmptyState.vue +1 -1
- package/src/components/InboxList.test.ts +76 -16
- package/src/components/InboxListItem.vue +81 -7
- package/src/components/InboxListSection.vue +8 -8
- package/src/inbox.constants.ts +4 -1
- package/src/inbox.module.ts +6 -1
- package/src/inbox.routes.test.ts +55 -26
- package/src/inbox.routes.ts +22 -3
- package/src/inbox.store.test.ts +123 -28
- package/src/inbox.store.ts +3 -3
- package/src/reviews/components/WorkflowReviewChangesList.test.ts +97 -0
- package/src/reviews/components/WorkflowReviewChangesList.vue +143 -0
- package/src/reviews/components/WorkflowReviewChangesSection.test.ts +13 -9
- package/src/reviews/components/WorkflowReviewChangesSection.vue +5 -7
- package/src/reviews/components/WorkflowReviewDetailMetadata.vue +5 -1
- package/src/reviews/components/WorkflowReviewDetailTabs.test.ts +69 -3
- package/src/reviews/components/WorkflowReviewDetailTabs.vue +29 -11
- package/src/self-healing/SelfHealingResultDetail.vue +19 -0
- package/src/views/InboxView.test.ts +53 -10
- package/src/views/InboxView.vue +10 -1
package/LICENSE_EE.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# The n8n Enterprise License (the “Enterprise License”)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022-present n8n GmbH.
|
|
4
|
+
|
|
5
|
+
With regard to the n8n Software:
|
|
6
|
+
|
|
7
|
+
This software and associated documentation files (the "Software") may only be used in production, if
|
|
8
|
+
you (and any entity that you represent) hold a valid n8n Enterprise license corresponding to your
|
|
9
|
+
usage. Subject to the foregoing sentence, you are free to modify this Software and publish patches
|
|
10
|
+
to the Software. You agree that n8n and/or its licensors (as applicable) retain all right, title and
|
|
11
|
+
interest in and to all such modifications and/or patches, and all such modifications and/or patches
|
|
12
|
+
may only be used, copied, modified, displayed, distributed, or otherwise exploited with a valid n8n
|
|
13
|
+
Enterprise license for the corresponding usage. Notwithstanding the foregoing, you may copy and
|
|
14
|
+
modify the Software for development and testing purposes, without requiring a subscription. You
|
|
15
|
+
agree that n8n and/or its licensors (as applicable) retain all right, title and interest in and to
|
|
16
|
+
all such modifications. You are not granted any other rights beyond what is expressly stated herein.
|
|
17
|
+
Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, and/or
|
|
18
|
+
sell the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
|
|
21
|
+
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
22
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
|
|
23
|
+
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
24
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
25
|
+
|
|
26
|
+
For all third party components incorporated into the n8n Software, those components are licensed
|
|
27
|
+
under the original license provided by the owner of the applicable component.
|
package/README.md
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
# @n8n/frontend-module-inbox
|
|
2
2
|
|
|
3
|
-
The shared Inbox lists workflow reviews. Review details, comments, and decisions live in `src/reviews`. The editor supplies only the workflow diff renderer through the SDK component registry. Backend services keep their existing access rules and actions.
|
|
3
|
+
The shared Inbox lists workflow reviews and saved Assistant results. Review details, comments, and decisions live in `src/reviews`. The editor supplies only the workflow diff renderer through the SDK component registry. Backend services keep their existing access rules and actions.
|
|
4
4
|
|
|
5
|
-
Open has two groups: Waiting for review and Authored by you.
|
|
6
|
-
own cursor, Load more button, and retry.
|
|
5
|
+
Open has two groups: Waiting for review and Authored by you. Assistant results go
|
|
6
|
+
in Waiting for review. Each group has its own cursor, Load more button, and retry.
|
|
7
7
|
Closed is a flat list with infinite scroll. Group collapse state persists per user.
|
|
8
8
|
|
|
9
9
|
Review decisions update the loaded rows and counts in place. Approving a selected
|
|
10
10
|
review opens the Closed tab, as before.
|
|
11
11
|
|
|
12
|
+
Self-healing detail UI remains gated by the disabled-by-default self-healing rollout. AST-1518 replaces the explicit unavailable detail state.
|
|
13
|
+
|
|
12
14
|
Frontend feature module. Consumed from source by the editor-ui shell through
|
|
13
15
|
`src/app/modules.manifest.ts`; there is no build step and no `dist`.
|
|
14
16
|
|
package/package.json
CHANGED
|
@@ -1,24 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@n8n/frontend-module-inbox",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.ts"
|
|
8
8
|
},
|
|
9
9
|
"dependencies": {
|
|
10
|
-
"@n8n/api-types": "1.
|
|
11
|
-
"@n8n/composables": "1.
|
|
12
|
-
"@n8n/design-system": "2.
|
|
13
|
-
"@n8n/frontend-module-sdk": "0.
|
|
14
|
-
"@n8n/i18n": "2.
|
|
15
|
-
"@n8n/rest-api-client": "2.
|
|
16
|
-
"@n8n/stores": "2.
|
|
17
|
-
"n8n-workflow": "2.
|
|
10
|
+
"@n8n/api-types": "1.45.0",
|
|
11
|
+
"@n8n/composables": "1.35.0",
|
|
12
|
+
"@n8n/design-system": "2.44.0",
|
|
13
|
+
"@n8n/frontend-module-sdk": "0.15.0",
|
|
14
|
+
"@n8n/i18n": "2.45.0",
|
|
15
|
+
"@n8n/rest-api-client": "2.45.0",
|
|
16
|
+
"@n8n/stores": "2.45.0",
|
|
17
|
+
"n8n-workflow": "2.45.0",
|
|
18
18
|
"pinia": "^2.2.4",
|
|
19
|
+
"reka-ui": "^2.5.0",
|
|
19
20
|
"vue": "^3.5.13",
|
|
20
21
|
"vue-router": "^4.5.0",
|
|
21
|
-
"@n8n/frontend-constants": "0.
|
|
22
|
+
"@n8n/frontend-constants": "0.11.0",
|
|
22
23
|
"@vueuse/core": "^14.3.0",
|
|
23
24
|
"lodash": "4.18.1",
|
|
24
25
|
"vue-i18n": "^11.4.4"
|
|
@@ -46,9 +47,18 @@
|
|
|
46
47
|
"@pinia/testing": "^0.1.6",
|
|
47
48
|
"@testing-library/user-event": "^14.6.1",
|
|
48
49
|
"@types/lodash": "4.17.17",
|
|
49
|
-
"@n8n/utils": "1.
|
|
50
|
+
"@n8n/utils": "1.52.0"
|
|
51
|
+
},
|
|
52
|
+
"license": "SEE LICENSE IN LICENSE.md",
|
|
53
|
+
"homepage": "https://n8n.io",
|
|
54
|
+
"author": {
|
|
55
|
+
"name": "Jan Oberhauser",
|
|
56
|
+
"email": "jan@n8n.io"
|
|
57
|
+
},
|
|
58
|
+
"repository": {
|
|
59
|
+
"type": "git",
|
|
60
|
+
"url": "git+https://github.com/n8n-io/n8n.git"
|
|
50
61
|
},
|
|
51
|
-
"license": "LicenseRef-n8n-sustainable-use",
|
|
52
62
|
"scripts": {
|
|
53
63
|
"clean": "rimraf .turbo",
|
|
54
64
|
"typecheck": "vue-tsc --noEmit",
|
|
@@ -21,7 +21,7 @@ const i18n = useI18n();
|
|
|
21
21
|
const alertIcon = { type: 'icon', value: 'circle-alert' } as const;
|
|
22
22
|
const inboxIcon: EmptyStateIconCards = {
|
|
23
23
|
type: 'cards',
|
|
24
|
-
center: '
|
|
24
|
+
center: 'inbox',
|
|
25
25
|
sides: ['file-diff', 'git-branch', 'circle-check', 'list', 'message-square'],
|
|
26
26
|
};
|
|
27
27
|
const noSelectionHeading = computed(() => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { InboxWorkflowReviewItem } from '@n8n/api-types';
|
|
1
|
+
import type { InboxSelfHealingItem, InboxWorkflowReviewItem } from '@n8n/api-types';
|
|
2
2
|
import { createComponentRenderer } from '@n8n/frontend-test-utils';
|
|
3
3
|
import { useUsersStore } from '@n8n/stores/users.store';
|
|
4
|
+
import userEvent from '@testing-library/user-event';
|
|
4
5
|
import { fireEvent, within } from '@testing-library/vue';
|
|
5
6
|
import { useIntersectionObserver } from '@vueuse/core';
|
|
6
7
|
|
|
@@ -28,6 +29,22 @@ function section(
|
|
|
28
29
|
...props,
|
|
29
30
|
};
|
|
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
|
+
}
|
|
31
48
|
function review(): InboxWorkflowReviewItem {
|
|
32
49
|
return {
|
|
33
50
|
type: 'workflow_review',
|
|
@@ -106,12 +123,12 @@ it('keeps healthy rows visible beside a failed group', () => {
|
|
|
106
123
|
const { queryByRole, getByRole, emitted } = renderComponent({
|
|
107
124
|
props: {
|
|
108
125
|
sections: [
|
|
109
|
-
section('waiting', { items: [
|
|
126
|
+
section('waiting', { items: [result()] }),
|
|
110
127
|
section('authored', { error: new Error('Request failed') }),
|
|
111
128
|
],
|
|
112
129
|
},
|
|
113
130
|
});
|
|
114
|
-
expect(getByRole('
|
|
131
|
+
expect(getByRole('button', { name: /Fix the workflow/ })).toHaveTextContent('Fix the workflow');
|
|
115
132
|
getByRole('button', { name: 'Retry' }).click();
|
|
116
133
|
expect(emitted('retry')).toEqual([['authored']]);
|
|
117
134
|
expect(queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument();
|
|
@@ -130,14 +147,14 @@ it('keeps a partial empty group visible with its own retry', () => {
|
|
|
130
147
|
});
|
|
131
148
|
it('collapses groups without requesting more data', async () => {
|
|
132
149
|
const { getByRole, queryByRole, emitted } = renderComponent({
|
|
133
|
-
props: { sections: [section('waiting', { items: [
|
|
150
|
+
props: { sections: [section('waiting', { items: [result()] })] },
|
|
134
151
|
});
|
|
135
152
|
const header = getByRole('button', { name: 'Waiting for your review' });
|
|
136
153
|
await fireEvent.click(header);
|
|
137
154
|
expect(header).toHaveAttribute('aria-expanded', 'false');
|
|
138
|
-
expect(queryByRole('
|
|
155
|
+
expect(queryByRole('button', { name: /Fix the workflow/ })).not.toBeInTheDocument();
|
|
139
156
|
await fireEvent.click(header);
|
|
140
|
-
expect(getByRole('
|
|
157
|
+
expect(getByRole('button', { name: /Fix the workflow/ })).toHaveTextContent('Fix the workflow');
|
|
141
158
|
expect(emitted('loadMore')).toBeUndefined();
|
|
142
159
|
});
|
|
143
160
|
it('uses the impersonal waiting label for administrators', () => {
|
|
@@ -202,6 +219,34 @@ it('waits for Retry after a failed Closed page and removes the sentinel on Open'
|
|
|
202
219
|
await rerender({ activeTab: 'open', sections: [section('waiting')] });
|
|
203
220
|
expect(target).toHaveProperty('value', null);
|
|
204
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
|
+
|
|
205
250
|
it('separates partial-source recovery from a failed page retry', () => {
|
|
206
251
|
const { getByTestId, emitted } = renderComponent({
|
|
207
252
|
props: { sections: [section('waiting', { partial: true, error: new Error('Page failed') })] },
|
|
@@ -213,17 +258,32 @@ it('separates partial-source recovery from a failed page retry', () => {
|
|
|
213
258
|
expect(emitted('retry')).toEqual([['waiting']]);
|
|
214
259
|
});
|
|
215
260
|
|
|
216
|
-
it('
|
|
217
|
-
const
|
|
218
|
-
const
|
|
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({
|
|
219
266
|
props: {
|
|
220
|
-
selectedKey: `${
|
|
221
|
-
sections: [section('waiting', { items: [
|
|
267
|
+
selectedKey: `${first.type}:${first.id}`,
|
|
268
|
+
sections: [section('waiting', { items: [first, second] })],
|
|
222
269
|
},
|
|
223
270
|
});
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
expect(
|
|
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');
|
|
229
289
|
});
|
|
@@ -1,21 +1,41 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import type { InboxItem } from '@n8n/api-types';
|
|
3
|
-
import {
|
|
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';
|
|
4
12
|
import { useRootStore } from '@n8n/stores/useRootStore';
|
|
5
13
|
|
|
6
14
|
import WorkflowReviewStatusDot from '../reviews/components/WorkflowReviewStatusDot.vue';
|
|
7
15
|
|
|
8
16
|
defineProps<{ item: InboxItem; selected: boolean }>();
|
|
9
17
|
const emit = defineEmits<{ select: [] }>();
|
|
18
|
+
const i18n = useI18n();
|
|
10
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
|
+
}
|
|
11
31
|
</script>
|
|
12
32
|
|
|
13
33
|
<template>
|
|
14
34
|
<N8nCard
|
|
15
35
|
:class="[$style.card, { [$style.selected]: selected }]"
|
|
16
|
-
role="
|
|
36
|
+
role="button"
|
|
17
37
|
tabindex="0"
|
|
18
|
-
:aria-
|
|
38
|
+
:aria-current="selected ? 'true' : undefined"
|
|
19
39
|
:data-source="item.type"
|
|
20
40
|
data-test-id="inbox-row"
|
|
21
41
|
@click="emit('select')"
|
|
@@ -24,8 +44,40 @@ const rootStore = useRootStore();
|
|
|
24
44
|
>
|
|
25
45
|
<div :class="$style.cardContent">
|
|
26
46
|
<div :class="$style.cardHeader">
|
|
27
|
-
<
|
|
28
|
-
|
|
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
|
+
/>
|
|
29
81
|
</div>
|
|
30
82
|
<div :class="$style.meta">
|
|
31
83
|
<N8nBadge v-if="item.workflowName" variant="outline" :class="$style.workflowBadge">
|
|
@@ -35,7 +87,7 @@ const rootStore = useRootStore();
|
|
|
35
87
|
</N8nBadge>
|
|
36
88
|
<div :class="$style.metaActions">
|
|
37
89
|
<N8nText :class="$style.time" size="xsmall" color="text-light">
|
|
38
|
-
<N8nTimeAgo :date="item.
|
|
90
|
+
<N8nTimeAgo :date="item.updatedAt" :locale="rootStore.defaultLocale" />
|
|
39
91
|
</N8nText>
|
|
40
92
|
</div>
|
|
41
93
|
</div>
|
|
@@ -87,6 +139,13 @@ const rootStore = useRootStore();
|
|
|
87
139
|
font-size: var(--font-size--sm);
|
|
88
140
|
}
|
|
89
141
|
|
|
142
|
+
.assistantTitle {
|
|
143
|
+
flex: 1;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.assistantAvatar {
|
|
147
|
+
flex-shrink: 0;
|
|
148
|
+
}
|
|
90
149
|
.meta {
|
|
91
150
|
display: flex;
|
|
92
151
|
align-items: center;
|
|
@@ -131,4 +190,19 @@ const rootStore = useRootStore();
|
|
|
131
190
|
.time {
|
|
132
191
|
white-space: nowrap;
|
|
133
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
|
+
}
|
|
134
208
|
</style>
|
|
@@ -63,20 +63,20 @@ function sectionTitle(key: 'waiting' | 'authored') {
|
|
|
63
63
|
<div
|
|
64
64
|
:id="`inbox-section-${section.key}`"
|
|
65
65
|
:class="$style.group"
|
|
66
|
-
role="
|
|
66
|
+
role="list"
|
|
67
67
|
:aria-labelledby="
|
|
68
68
|
section.key !== 'closed' ? `inbox-section-header-${section.key}` : undefined
|
|
69
69
|
"
|
|
70
70
|
:aria-label="section.key === 'closed' ? i18n.baseText('inbox.tabs.closed') : undefined"
|
|
71
71
|
>
|
|
72
72
|
<template v-if="!collapsed">
|
|
73
|
-
<
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
73
|
+
<div v-for="item in section.items" :key="`${item.type}:${item.id}`" role="listitem">
|
|
74
|
+
<InboxListItem
|
|
75
|
+
:item="item"
|
|
76
|
+
:selected="selectedKey === `${item.type}:${item.id}`"
|
|
77
|
+
@select="emit('select', item)"
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
80
|
</template>
|
|
81
81
|
</div>
|
|
82
82
|
<template v-if="!collapsed">
|
package/src/inbox.constants.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { InboxSourceType, InboxState, WorkflowReviewRequestDecision } from '@n8n/api-types';
|
|
2
2
|
|
|
3
3
|
export const INBOX_VIEW = 'Inbox';
|
|
4
|
+
export const INBOX_ASSISTANT_RESULT_VIEW = 'InboxAssistantResult';
|
|
4
5
|
export const INBOX_PAGE_LIMIT = 15;
|
|
5
6
|
|
|
6
7
|
export type InboxSectionKey = 'waiting' | 'authored' | 'closed';
|
|
7
8
|
|
|
8
|
-
export type InboxSelection =
|
|
9
|
+
export type InboxSelection =
|
|
10
|
+
| { type: 'workflow_review'; id: string }
|
|
11
|
+
| { type: 'self_healing_result'; id: string; projectId: string; workflowId: string };
|
|
9
12
|
|
|
10
13
|
export type InboxItemChange = {
|
|
11
14
|
type: InboxSourceType;
|
package/src/inbox.module.ts
CHANGED
|
@@ -16,7 +16,7 @@ const inboxView = {
|
|
|
16
16
|
export const InboxModule = defineFrontendModule({
|
|
17
17
|
id: 'inbox',
|
|
18
18
|
name: 'Inbox',
|
|
19
|
-
description: '
|
|
19
|
+
description: 'Workflow reviews and saved Assistant results.',
|
|
20
20
|
icon: 'inbox',
|
|
21
21
|
routes: [
|
|
22
22
|
// Keep legacy /reviews links working after the move to Inbox.
|
|
@@ -46,5 +46,10 @@ export const InboxModule = defineFrontendModule({
|
|
|
46
46
|
path: '/inbox/reviews/:reviewId',
|
|
47
47
|
name: 'WorkflowReviewRequestsView',
|
|
48
48
|
},
|
|
49
|
+
{
|
|
50
|
+
...inboxView,
|
|
51
|
+
path: '/inbox/assistant-results/:resultId',
|
|
52
|
+
name: 'InboxAssistantResult',
|
|
53
|
+
},
|
|
49
54
|
],
|
|
50
55
|
});
|
package/src/inbox.routes.test.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { InboxWorkflowReviewItem } from '@n8n/api-types';
|
|
2
1
|
import { createMemoryHistory, createRouter } from 'vue-router';
|
|
3
2
|
|
|
4
3
|
import { InboxModule } from './inbox.module';
|
|
@@ -18,21 +17,19 @@ function createInboxRouter() {
|
|
|
18
17
|
return createRouter({ history: createMemoryHistory(), routes });
|
|
19
18
|
}
|
|
20
19
|
|
|
21
|
-
const
|
|
22
|
-
type: '
|
|
23
|
-
id: '
|
|
20
|
+
const result = {
|
|
21
|
+
type: 'self_healing_result',
|
|
22
|
+
id: 'result-1',
|
|
24
23
|
state: 'open',
|
|
25
24
|
projectId: 'project',
|
|
26
|
-
|
|
25
|
+
workflowId: 'workflow',
|
|
27
26
|
workflowName: 'Workflow',
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
authors: [],
|
|
31
|
-
reviewers: [],
|
|
32
|
-
decision: 'pending',
|
|
27
|
+
summary: 'Summary',
|
|
28
|
+
outcome: 'fix_ready',
|
|
33
29
|
createdAt: '',
|
|
34
30
|
updatedAt: '',
|
|
35
|
-
|
|
31
|
+
completedAt: '',
|
|
32
|
+
} as const;
|
|
36
33
|
|
|
37
34
|
it('redirects to home when Inbox is disabled', async () => {
|
|
38
35
|
settingsStore.settings.inbox.enabled = false;
|
|
@@ -60,18 +57,36 @@ it('redirects the old review list without selecting an item', async () => {
|
|
|
60
57
|
expect(selectionFromRoute(router.currentRoute.value)).toBeNull();
|
|
61
58
|
});
|
|
62
59
|
|
|
63
|
-
it(
|
|
60
|
+
it.each([
|
|
61
|
+
['/inbox/reviews/review-1', { type: 'workflow_review', id: 'review-1' }],
|
|
62
|
+
[
|
|
63
|
+
'/inbox/assistant-results/result-1?projectId=project&workflowId=workflow',
|
|
64
|
+
{
|
|
65
|
+
type: result.type,
|
|
66
|
+
id: result.id,
|
|
67
|
+
projectId: result.projectId,
|
|
68
|
+
workflowId: result.workflowId,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
])('selects the item when opening %s directly', async (path, selection) => {
|
|
64
72
|
const router = createInboxRouter();
|
|
65
|
-
await router.push(
|
|
66
|
-
expect(selectionFromRoute(router.currentRoute.value)).toEqual(
|
|
67
|
-
type: 'workflow_review',
|
|
68
|
-
id: 'review-1',
|
|
69
|
-
});
|
|
73
|
+
await router.push(path);
|
|
74
|
+
expect(selectionFromRoute(router.currentRoute.value)).toEqual(selection);
|
|
70
75
|
});
|
|
71
76
|
|
|
77
|
+
it.each(['', '?projectId=project', '?workflowId=workflow'])(
|
|
78
|
+
'requires Assistant scope: %s',
|
|
79
|
+
async (query) => {
|
|
80
|
+
const router = createInboxRouter();
|
|
81
|
+
await router.push(`/inbox/assistant-results/result-1${query}`);
|
|
82
|
+
expect(selectionFromRoute(router.currentRoute.value)).toBeNull();
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
|
|
72
86
|
it.each([
|
|
73
87
|
['Inbox', true],
|
|
74
88
|
['WorkflowReviewRequestsView', true],
|
|
89
|
+
['InboxAssistantResult', true],
|
|
75
90
|
['Workflow', false],
|
|
76
91
|
[undefined, false],
|
|
77
92
|
])('recognizes the Inbox route %s: %s', (name, expected) => {
|
|
@@ -80,22 +95,34 @@ it.each([
|
|
|
80
95
|
|
|
81
96
|
it('keeps the tab only when selecting the same item', async () => {
|
|
82
97
|
const router = createInboxRouter();
|
|
83
|
-
await router.push(
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
98
|
+
await router.push(
|
|
99
|
+
'/inbox/assistant-results/result-1?projectId=project&workflowId=workflow&state=closed&tab=changes',
|
|
100
|
+
);
|
|
101
|
+
expect(router.resolve(inboxItemLocation(result, router.currentRoute.value))).toMatchObject({
|
|
102
|
+
path: '/inbox/assistant-results/result-1',
|
|
103
|
+
query: { projectId: 'project', workflowId: 'workflow', state: 'closed', tab: 'changes' },
|
|
87
104
|
});
|
|
88
105
|
expect(
|
|
89
|
-
router.resolve(inboxItemLocation({ ...
|
|
106
|
+
router.resolve(inboxItemLocation({ ...result, id: 'result-2' }, router.currentRoute.value))
|
|
90
107
|
.query,
|
|
91
|
-
).toEqual({
|
|
108
|
+
).toEqual({
|
|
109
|
+
projectId: 'project',
|
|
110
|
+
workflowId: 'workflow',
|
|
111
|
+
state: 'closed',
|
|
112
|
+
});
|
|
113
|
+
await router.push('/inbox/reviews/result-1?state=closed&tab=changes');
|
|
114
|
+
expect(router.resolve(inboxItemLocation(result, router.currentRoute.value)).query).toEqual({
|
|
115
|
+
projectId: 'project',
|
|
116
|
+
workflowId: 'workflow',
|
|
117
|
+
state: 'closed',
|
|
118
|
+
});
|
|
92
119
|
});
|
|
93
120
|
|
|
94
121
|
it('restores selection and tabs when navigating back and forward', async () => {
|
|
95
122
|
const router = createInboxRouter();
|
|
96
123
|
await router.push('/inbox');
|
|
97
124
|
await router.push('/inbox/reviews/review-1?tab=changes');
|
|
98
|
-
await router.push(inboxItemLocation(
|
|
125
|
+
await router.push(inboxItemLocation(result, router.currentRoute.value));
|
|
99
126
|
router.back();
|
|
100
127
|
await vi.waitFor(() =>
|
|
101
128
|
expect(router.currentRoute.value.fullPath).toBe('/inbox/reviews/review-1?tab=changes'),
|
|
@@ -105,7 +132,9 @@ it('restores selection and tabs when navigating back and forward', async () => {
|
|
|
105
132
|
id: 'review-1',
|
|
106
133
|
});
|
|
107
134
|
router.forward();
|
|
108
|
-
await vi.waitFor(() =>
|
|
109
|
-
|
|
135
|
+
await vi.waitFor(() =>
|
|
136
|
+
expect(router.currentRoute.value.path).toBe('/inbox/assistant-results/result-1'),
|
|
137
|
+
);
|
|
138
|
+
expect(selectionFromRoute(router.currentRoute.value)?.id).toBe(result.id);
|
|
110
139
|
expect(router.currentRoute.value.query.tab).toBeUndefined();
|
|
111
140
|
});
|
package/src/inbox.routes.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { InboxItem } from '@n8n/api-types';
|
|
2
2
|
import type { LocationQuery, RouteLocationNormalizedLoaded, RouteLocationRaw } from 'vue-router';
|
|
3
3
|
|
|
4
|
-
import { INBOX_VIEW, type InboxSelection } from './inbox.constants';
|
|
4
|
+
import { INBOX_ASSISTANT_RESULT_VIEW, INBOX_VIEW, type InboxSelection } from './inbox.constants';
|
|
5
5
|
import { WORKFLOW_REVIEW_REQUESTS_VIEW } from './reviews/constants';
|
|
6
6
|
|
|
7
7
|
type InboxRoute = Pick<RouteLocationNormalizedLoaded, 'name' | 'params' | 'query'>;
|
|
@@ -11,14 +11,26 @@ function stringParam(value: LocationQuery[string]): string | undefined {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export function isInboxRoute(route: Pick<InboxRoute, 'name'>): boolean {
|
|
14
|
-
return
|
|
14
|
+
return (
|
|
15
|
+
route.name === INBOX_VIEW ||
|
|
16
|
+
route.name === WORKFLOW_REVIEW_REQUESTS_VIEW ||
|
|
17
|
+
route.name === INBOX_ASSISTANT_RESULT_VIEW
|
|
18
|
+
);
|
|
15
19
|
}
|
|
16
20
|
|
|
17
|
-
export function selectionFromRoute({ name, params }: InboxRoute): InboxSelection | null {
|
|
21
|
+
export function selectionFromRoute({ name, params, query }: InboxRoute): InboxSelection | null {
|
|
18
22
|
if (name === WORKFLOW_REVIEW_REQUESTS_VIEW) {
|
|
19
23
|
const id = stringParam(params.reviewId);
|
|
20
24
|
return id ? { type: 'workflow_review', id } : null;
|
|
21
25
|
}
|
|
26
|
+
if (name !== INBOX_ASSISTANT_RESULT_VIEW) return null;
|
|
27
|
+
const id = stringParam(params.resultId);
|
|
28
|
+
if (!id) return null;
|
|
29
|
+
const projectId = stringParam(query.projectId);
|
|
30
|
+
const workflowId = stringParam(query.workflowId);
|
|
31
|
+
if (projectId && workflowId) {
|
|
32
|
+
return { type: 'self_healing_result', id, projectId, workflowId };
|
|
33
|
+
}
|
|
22
34
|
return null;
|
|
23
35
|
}
|
|
24
36
|
|
|
@@ -26,5 +38,12 @@ export function inboxItemLocation(item: InboxItem, route?: InboxRoute): RouteLoc
|
|
|
26
38
|
const next: LocationQuery = { ...route?.query };
|
|
27
39
|
const selected = route ? selectionFromRoute(route) : null;
|
|
28
40
|
if (selected?.type !== item.type || selected.id !== item.id) delete next.tab;
|
|
41
|
+
delete next.projectId;
|
|
42
|
+
delete next.workflowId;
|
|
43
|
+
if (item.type === 'self_healing_result') {
|
|
44
|
+
next.projectId = item.projectId;
|
|
45
|
+
next.workflowId = item.workflowId;
|
|
46
|
+
return { name: INBOX_ASSISTANT_RESULT_VIEW, params: { resultId: item.id }, query: next };
|
|
47
|
+
}
|
|
29
48
|
return { name: WORKFLOW_REVIEW_REQUESTS_VIEW, params: { reviewId: item.id }, query: next };
|
|
30
49
|
}
|