@n8n/frontend-module-inbox 0.2.3 → 0.3.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/README.md +25 -2
- package/package.json +12 -12
- package/src/components/InboxList.test.ts +53 -1
- package/src/components/InboxList.vue +7 -12
- package/src/components/InboxListItem.vue +54 -22
- package/src/inbox.constants.ts +3 -1
- package/src/inbox.module.ts +6 -1
- package/src/inbox.routes.test.ts +26 -0
- package/src/inbox.routes.ts +15 -2
- package/src/inbox.store.ts +8 -2
- package/src/promotions/PromotionReviewDetail.test.ts +244 -0
- package/src/promotions/PromotionReviewDetail.vue +395 -0
- package/src/promotions/promotionReviews.api.ts +15 -0
- package/src/self-healing/SelfHealingResultActivity.vue +160 -0
- package/src/self-healing/SelfHealingResultContent.test.ts +458 -0
- package/src/self-healing/SelfHealingResultContent.vue +430 -0
- package/src/self-healing/SelfHealingResultDetail.test.ts +804 -0
- package/src/self-healing/SelfHealingResultDetail.vue +340 -8
- package/src/self-healing/SelfHealingResultMetadata.vue +282 -0
- package/src/self-healing/selfHealingResult.store.test.ts +86 -0
- package/src/self-healing/selfHealingResult.store.ts +153 -0
- package/src/self-healing/selfHealingResults.api.test.ts +24 -0
- package/src/self-healing/selfHealingResults.api.ts +38 -0
- package/src/self-healing/selfHealingResults.test.utils.ts +106 -0
- package/src/views/InboxView.test.ts +126 -4
- package/src/views/InboxView.vue +26 -10
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { SelfHealingResultDetail } from '@n8n/api-types';
|
|
2
|
+
import { ResponseError } from '@n8n/rest-api-client';
|
|
3
|
+
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
|
|
4
|
+
|
|
5
|
+
import { useSelfHealingResultStore } from './selfHealingResult.store';
|
|
6
|
+
import * as api from './selfHealingResults.api';
|
|
7
|
+
import { result, resultSelection } from './selfHealingResults.test.utils';
|
|
8
|
+
|
|
9
|
+
vi.mock('./selfHealingResults.api');
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
vi.resetAllMocks();
|
|
13
|
+
vi.mocked(api.fetchSelfHealingResult).mockImplementation(async (_context, selection) =>
|
|
14
|
+
result({ resultId: selection.id }),
|
|
15
|
+
);
|
|
16
|
+
vi.mocked(api.fetchResultWorkflow).mockResolvedValue({
|
|
17
|
+
name: 'Daily report',
|
|
18
|
+
scopes: ['workflow:read', 'workflow:update'],
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('ignores a detail response after selecting another result', async () => {
|
|
23
|
+
const request = createDeferredPromise<SelfHealingResultDetail>();
|
|
24
|
+
vi.mocked(api.fetchSelfHealingResult).mockReturnValueOnce(request.promise);
|
|
25
|
+
const store = useSelfHealingResultStore();
|
|
26
|
+
const first = store.select(resultSelection);
|
|
27
|
+
await store.select({ ...resultSelection, id: 'other' });
|
|
28
|
+
request.resolve(result());
|
|
29
|
+
await first;
|
|
30
|
+
expect(store.detail?.resultId).toBe('other');
|
|
31
|
+
expect(store.loading).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('does not let a pending read replace a completed action', async () => {
|
|
35
|
+
const store = useSelfHealingResultStore();
|
|
36
|
+
await store.select(resultSelection);
|
|
37
|
+
const request = createDeferredPromise<SelfHealingResultDetail>();
|
|
38
|
+
vi.mocked(api.fetchSelfHealingResult).mockReturnValueOnce(request.promise);
|
|
39
|
+
const loading = store.refreshDetail();
|
|
40
|
+
store.acceptResult(result({ reviewState: 'applied' }));
|
|
41
|
+
request.resolve(result());
|
|
42
|
+
await loading;
|
|
43
|
+
expect(store.detail?.reviewState).toBe('applied');
|
|
44
|
+
expect(store.loading).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it.each([403, 404])('clears saved detail when a read returns %s', async (httpStatusCode) => {
|
|
48
|
+
const store = useSelfHealingResultStore();
|
|
49
|
+
await store.select(resultSelection);
|
|
50
|
+
const error = new ResponseError('Unavailable', { httpStatusCode });
|
|
51
|
+
vi.mocked(api.fetchSelfHealingResult).mockRejectedValueOnce(error);
|
|
52
|
+
await store.refreshDetail();
|
|
53
|
+
expect(store.detail).toBeNull();
|
|
54
|
+
expect(store.error).toBe(error);
|
|
55
|
+
expect(store.loading).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('keeps the report available when the workflow metadata request fails', async () => {
|
|
59
|
+
const error = new Error('Request failed');
|
|
60
|
+
vi.mocked(api.fetchResultWorkflow).mockRejectedValueOnce(error);
|
|
61
|
+
const store = useSelfHealingResultStore();
|
|
62
|
+
await store.select(resultSelection);
|
|
63
|
+
expect(store.detail?.report).toBe(result().report);
|
|
64
|
+
expect(store.workflow).toBeNull();
|
|
65
|
+
expect(store.workflowError).toBe(error);
|
|
66
|
+
expect(store.error).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('tracks a pending action for each result', async () => {
|
|
70
|
+
const store = useSelfHealingResultStore();
|
|
71
|
+
const second = { ...resultSelection, id: 'other' };
|
|
72
|
+
await store.select(resultSelection);
|
|
73
|
+
|
|
74
|
+
expect(store.beginAction(resultSelection, 'editor')).toBe(true);
|
|
75
|
+
expect(store.beginAction(resultSelection, 'dismiss')).toBe(false);
|
|
76
|
+
expect(store.pendingAction).toBe('editor');
|
|
77
|
+
|
|
78
|
+
await store.select(second);
|
|
79
|
+
expect(store.pendingAction).toBeNull();
|
|
80
|
+
expect(store.beginAction(second, 'dismiss')).toBe(true);
|
|
81
|
+
|
|
82
|
+
store.endAction(resultSelection);
|
|
83
|
+
expect(store.pendingAction).toBe('dismiss');
|
|
84
|
+
await store.select(resultSelection);
|
|
85
|
+
expect(store.pendingAction).toBeNull();
|
|
86
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { SelfHealingResultDetail } from '@n8n/api-types';
|
|
2
|
+
import { useRootStore } from '@n8n/stores/useRootStore';
|
|
3
|
+
import { defineStore } from 'pinia';
|
|
4
|
+
import { computed, ref } from 'vue';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
fetchResultWorkflow,
|
|
8
|
+
fetchSelfHealingResult,
|
|
9
|
+
type SelfHealingResultAction,
|
|
10
|
+
type SelfHealingResultWorkflowMetadata,
|
|
11
|
+
type SelfHealingSelection,
|
|
12
|
+
} from './selfHealingResults.api';
|
|
13
|
+
|
|
14
|
+
function resultKey({ projectId, workflowId, id }: SelfHealingSelection) {
|
|
15
|
+
return `${projectId}/${workflowId}/${id}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const useSelfHealingResultStore = defineStore('selfHealingResultDetail', () => {
|
|
19
|
+
const rootStore = useRootStore();
|
|
20
|
+
const selection = ref<SelfHealingSelection | null>(null);
|
|
21
|
+
const detail = ref<SelfHealingResultDetail | null>(null);
|
|
22
|
+
const workflow = ref<SelfHealingResultWorkflowMetadata | null>(null);
|
|
23
|
+
const loading = ref(false);
|
|
24
|
+
const error = ref<unknown>(null);
|
|
25
|
+
const workflowError = ref<unknown>(null);
|
|
26
|
+
const pendingActions = ref(new Map<string, SelfHealingResultAction>());
|
|
27
|
+
const pendingAction = computed(() =>
|
|
28
|
+
selection.value ? (pendingActions.value.get(resultKey(selection.value)) ?? null) : null,
|
|
29
|
+
);
|
|
30
|
+
let detailRequestSequence = 0;
|
|
31
|
+
let workflowRequestSequence = 0;
|
|
32
|
+
|
|
33
|
+
function isSelected(target: SelfHealingSelection) {
|
|
34
|
+
return (
|
|
35
|
+
selection.value?.id === target.id &&
|
|
36
|
+
selection.value.projectId === target.projectId &&
|
|
37
|
+
selection.value.workflowId === target.workflowId
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function setError(target: SelfHealingSelection, cause: unknown) {
|
|
42
|
+
if (!isSelected(target)) return;
|
|
43
|
+
|
|
44
|
+
detailRequestSequence++;
|
|
45
|
+
detail.value = null;
|
|
46
|
+
error.value = cause;
|
|
47
|
+
loading.value = false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function acceptResult(result: SelfHealingResultDetail) {
|
|
51
|
+
if (
|
|
52
|
+
selection.value?.id !== result.resultId ||
|
|
53
|
+
selection.value.workflowId !== result.workflowId ||
|
|
54
|
+
selection.value.projectId !== result.projectId
|
|
55
|
+
)
|
|
56
|
+
return;
|
|
57
|
+
|
|
58
|
+
// A read started before this action must not restore the pending state.
|
|
59
|
+
detailRequestSequence++;
|
|
60
|
+
detail.value = result;
|
|
61
|
+
error.value = null;
|
|
62
|
+
loading.value = false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function refreshDetail() {
|
|
66
|
+
const target = selection.value;
|
|
67
|
+
if (!target) return;
|
|
68
|
+
|
|
69
|
+
const requestSequence = ++detailRequestSequence;
|
|
70
|
+
loading.value = true;
|
|
71
|
+
error.value = null;
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const result = await fetchSelfHealingResult(rootStore.restApiContext, target);
|
|
75
|
+
|
|
76
|
+
if (requestSequence === detailRequestSequence) {
|
|
77
|
+
detail.value = result;
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
} catch (cause) {
|
|
81
|
+
if (requestSequence === detailRequestSequence) {
|
|
82
|
+
detail.value = null;
|
|
83
|
+
error.value = cause;
|
|
84
|
+
}
|
|
85
|
+
} finally {
|
|
86
|
+
if (requestSequence === detailRequestSequence) loading.value = false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function refreshWorkflow() {
|
|
93
|
+
const target = selection.value;
|
|
94
|
+
if (!target) return;
|
|
95
|
+
|
|
96
|
+
const requestSequence = ++workflowRequestSequence;
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const response = await fetchResultWorkflow(rootStore.restApiContext, target.workflowId);
|
|
100
|
+
if (requestSequence !== workflowRequestSequence) return;
|
|
101
|
+
|
|
102
|
+
workflow.value = response;
|
|
103
|
+
workflowError.value = null;
|
|
104
|
+
} catch (cause) {
|
|
105
|
+
if (requestSequence !== workflowRequestSequence) return;
|
|
106
|
+
|
|
107
|
+
workflow.value = null;
|
|
108
|
+
workflowError.value = cause;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function beginAction(target: SelfHealingSelection, action: SelfHealingResultAction) {
|
|
113
|
+
const key = resultKey(target);
|
|
114
|
+
if (pendingActions.value.has(key)) return false;
|
|
115
|
+
|
|
116
|
+
pendingActions.value.set(key, action);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function endAction(target: SelfHealingSelection) {
|
|
121
|
+
pendingActions.value.delete(resultKey(target));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function select(target: SelfHealingSelection) {
|
|
125
|
+
detailRequestSequence++;
|
|
126
|
+
workflowRequestSequence++;
|
|
127
|
+
|
|
128
|
+
selection.value = { ...target };
|
|
129
|
+
detail.value = null;
|
|
130
|
+
workflow.value = null;
|
|
131
|
+
error.value = null;
|
|
132
|
+
workflowError.value = null;
|
|
133
|
+
|
|
134
|
+
await Promise.all([refreshDetail(), refreshWorkflow()]);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
detail,
|
|
139
|
+
workflow,
|
|
140
|
+
loading,
|
|
141
|
+
error,
|
|
142
|
+
workflowError,
|
|
143
|
+
pendingAction,
|
|
144
|
+
select,
|
|
145
|
+
refreshDetail,
|
|
146
|
+
refreshWorkflow,
|
|
147
|
+
acceptResult,
|
|
148
|
+
beginAction,
|
|
149
|
+
endAction,
|
|
150
|
+
setError,
|
|
151
|
+
isSelected,
|
|
152
|
+
};
|
|
153
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { makeRestApiRequest } from '@n8n/rest-api-client';
|
|
2
|
+
|
|
3
|
+
import { fetchSelfHealingResult, reviewSelfHealingResult } from './selfHealingResults.api';
|
|
4
|
+
import { resultSelection } from './selfHealingResults.test.utils';
|
|
5
|
+
|
|
6
|
+
vi.mock('@n8n/rest-api-client', () => ({ makeRestApiRequest: vi.fn() }));
|
|
7
|
+
|
|
8
|
+
it('encodes the full result address and forwards the editor context for each action', async () => {
|
|
9
|
+
const context = { baseUrl: '/rest', pushRef: 'editor-client' };
|
|
10
|
+
const selection = {
|
|
11
|
+
...resultSelection,
|
|
12
|
+
projectId: 'project/id',
|
|
13
|
+
workflowId: 'workflow/id',
|
|
14
|
+
id: 'result/id',
|
|
15
|
+
};
|
|
16
|
+
const path = '/projects/project%2Fid/workflows/workflow%2Fid/self-healing-results/result%2Fid';
|
|
17
|
+
await fetchSelfHealingResult(context, selection);
|
|
18
|
+
expect(makeRestApiRequest).toHaveBeenLastCalledWith(context, 'GET', path);
|
|
19
|
+
for (const action of ['apply', 'approve-and-publish', 'dismiss'] as const) {
|
|
20
|
+
await reviewSelfHealingResult(context, selection, action);
|
|
21
|
+
expect(makeRestApiRequest).toHaveBeenLastCalledWith(context, 'POST', `${path}/${action}`);
|
|
22
|
+
}
|
|
23
|
+
expect(makeRestApiRequest).toHaveBeenCalledTimes(4);
|
|
24
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { SelfHealingResultActionResponse, SelfHealingResultDetail } from '@n8n/api-types';
|
|
2
|
+
import { makeRestApiRequest, type IRestApiContext } from '@n8n/rest-api-client';
|
|
3
|
+
|
|
4
|
+
import type { InboxSelection } from '../inbox.constants';
|
|
5
|
+
|
|
6
|
+
export type SelfHealingSelection = Extract<InboxSelection, { type: 'self_healing_result' }>;
|
|
7
|
+
export type SelfHealingReviewAction = 'apply' | 'approve-and-publish' | 'dismiss';
|
|
8
|
+
export type SelfHealingResultAction = Exclude<SelfHealingReviewAction, 'apply'> | 'editor' | 'chat';
|
|
9
|
+
/** Fields the result view reads from the normal workflow endpoint. */
|
|
10
|
+
export type SelfHealingResultWorkflowMetadata = { name: string; scopes: string[] };
|
|
11
|
+
/** Unknown while workflow permissions load or after they fail to load. */
|
|
12
|
+
export type SelfHealingPublishAccess = 'allowed' | 'denied' | 'unknown';
|
|
13
|
+
|
|
14
|
+
function resultPath({ projectId, workflowId, id }: SelfHealingSelection) {
|
|
15
|
+
return `/projects/${encodeURIComponent(projectId)}/workflows/${encodeURIComponent(workflowId)}/self-healing-results/${encodeURIComponent(id)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function fetchSelfHealingResult(
|
|
19
|
+
context: IRestApiContext,
|
|
20
|
+
selection: SelfHealingSelection,
|
|
21
|
+
): Promise<SelfHealingResultDetail> {
|
|
22
|
+
return await makeRestApiRequest(context, 'GET', resultPath(selection));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function reviewSelfHealingResult(
|
|
26
|
+
context: IRestApiContext,
|
|
27
|
+
selection: SelfHealingSelection,
|
|
28
|
+
action: SelfHealingReviewAction,
|
|
29
|
+
): Promise<SelfHealingResultActionResponse> {
|
|
30
|
+
return await makeRestApiRequest(context, 'POST', `${resultPath(selection)}/${action}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function fetchResultWorkflow(
|
|
34
|
+
context: IRestApiContext,
|
|
35
|
+
workflowId: string,
|
|
36
|
+
): Promise<SelfHealingResultWorkflowMetadata> {
|
|
37
|
+
return await makeRestApiRequest(context, 'GET', `/workflows/${encodeURIComponent(workflowId)}`);
|
|
38
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { SelfHealingResultDetail, WorkflowSuggestionProposalDetail } from '@n8n/api-types';
|
|
2
|
+
|
|
3
|
+
import type { SelfHealingSelection } from './selfHealingResults.api';
|
|
4
|
+
|
|
5
|
+
export const resultSelection: SelfHealingSelection = {
|
|
6
|
+
type: 'self_healing_result',
|
|
7
|
+
id: 'result-1',
|
|
8
|
+
workflowId: 'workflow-1',
|
|
9
|
+
projectId: 'project-1',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function suggestion(
|
|
13
|
+
overrides: Partial<WorkflowSuggestionProposalDetail> = {},
|
|
14
|
+
): WorkflowSuggestionProposalDetail {
|
|
15
|
+
const original = {
|
|
16
|
+
name: 'Daily report',
|
|
17
|
+
nodes: [
|
|
18
|
+
{
|
|
19
|
+
id: 'node-1',
|
|
20
|
+
name: 'Start',
|
|
21
|
+
type: 'n8n-nodes-base.manualTrigger',
|
|
22
|
+
typeVersion: 1,
|
|
23
|
+
position: [0, 0] as [number, number],
|
|
24
|
+
parameters: {},
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
connections: {},
|
|
28
|
+
};
|
|
29
|
+
const candidate = {
|
|
30
|
+
nodes: [
|
|
31
|
+
...original.nodes,
|
|
32
|
+
{
|
|
33
|
+
id: 'node-2',
|
|
34
|
+
name: 'Finish',
|
|
35
|
+
type: 'n8n-nodes-base.noOp',
|
|
36
|
+
typeVersion: 1,
|
|
37
|
+
position: [200, 0] as [number, number],
|
|
38
|
+
parameters: {},
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
connections: {},
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
suggestionId: 'suggestion-1',
|
|
45
|
+
workflowId: resultSelection.workflowId,
|
|
46
|
+
projectId: resultSelection.projectId,
|
|
47
|
+
backgroundUserId: 'background-user',
|
|
48
|
+
expectedBaseline: {
|
|
49
|
+
savedVersionId: 'original',
|
|
50
|
+
publishedVersionId: 'original',
|
|
51
|
+
checksum: 'checksum',
|
|
52
|
+
versionCounter: 1,
|
|
53
|
+
latestPublishHistoryEventId: null,
|
|
54
|
+
},
|
|
55
|
+
state: 'pending',
|
|
56
|
+
closedReason: null,
|
|
57
|
+
resultKind: 'fix_ready',
|
|
58
|
+
appliedVersion: null,
|
|
59
|
+
author: 'assistant',
|
|
60
|
+
payload: {
|
|
61
|
+
original,
|
|
62
|
+
candidate,
|
|
63
|
+
proposed: { ...original, ...candidate },
|
|
64
|
+
explanation: 'Add the missing step',
|
|
65
|
+
errorContext: { summary: 'The last step did not run', evidenceReference: null },
|
|
66
|
+
},
|
|
67
|
+
activity: [
|
|
68
|
+
{
|
|
69
|
+
id: 'event-1',
|
|
70
|
+
action: 'submitted',
|
|
71
|
+
author: 'assistant',
|
|
72
|
+
actorId: null,
|
|
73
|
+
createdAt: '2026-10-09T09:00:00.000Z',
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
...overrides,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function result(overrides: Partial<SelfHealingResultDetail> = {}): SelfHealingResultDetail {
|
|
81
|
+
return {
|
|
82
|
+
resultId: resultSelection.id,
|
|
83
|
+
workflowId: resultSelection.workflowId,
|
|
84
|
+
projectId: resultSelection.projectId,
|
|
85
|
+
backgroundUserId: 'background-user',
|
|
86
|
+
outcome: 'fix_ready',
|
|
87
|
+
summary: 'Restore the last step',
|
|
88
|
+
report: 'The workflow stops before the last step. Review the proposed connection.',
|
|
89
|
+
usage: {
|
|
90
|
+
turns: 3,
|
|
91
|
+
durationSeconds: 45,
|
|
92
|
+
promptTokens: 100,
|
|
93
|
+
completionTokens: 50,
|
|
94
|
+
totalTokens: 150,
|
|
95
|
+
},
|
|
96
|
+
completedAt: '2026-10-09T09:00:00.000Z',
|
|
97
|
+
createdAt: '2026-10-09T09:00:00.000Z',
|
|
98
|
+
updatedAt: '2026-10-09T09:00:00.000Z',
|
|
99
|
+
dismissedAt: null,
|
|
100
|
+
dismissedById: null,
|
|
101
|
+
reviewState: 'open',
|
|
102
|
+
suggestion: suggestion(),
|
|
103
|
+
execution: { status: 'available', id: 'execution-1' },
|
|
104
|
+
...overrides,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
DecideWorkflowReviewRequestResponse,
|
|
3
|
+
InboxPromotionReviewItem,
|
|
3
4
|
InboxSelfHealingItem,
|
|
4
5
|
InboxWorkflowReviewItem,
|
|
5
6
|
WorkflowReviewRequestDetail,
|
|
@@ -11,7 +12,7 @@ import { createTestingPinia } from '@pinia/testing';
|
|
|
11
12
|
import { defineComponent, type PropType } from 'vue';
|
|
12
13
|
import { createMemoryHistory, createRouter, RouterView } from 'vue-router';
|
|
13
14
|
|
|
14
|
-
import type { InboxItemChange } from '../inbox.constants';
|
|
15
|
+
import type { InboxItemChange, InboxSelection } from '../inbox.constants';
|
|
15
16
|
import * as inboxApi from '../inbox.api';
|
|
16
17
|
import { InboxModule } from '../inbox.module';
|
|
17
18
|
import { createInboxListSlice, useInboxStore } from '../inbox.store';
|
|
@@ -34,6 +35,7 @@ vi.mock('@n8n/composables/useDocumentTitle', () => ({
|
|
|
34
35
|
}));
|
|
35
36
|
const detailMounted = vi.fn();
|
|
36
37
|
let reportChange: (change: InboxItemChange) => void;
|
|
38
|
+
let reportResultChange: (change: InboxItemChange) => void;
|
|
37
39
|
const router = createRouter({
|
|
38
40
|
history: createMemoryHistory(),
|
|
39
41
|
routes: [
|
|
@@ -81,9 +83,27 @@ const renderOptions = {
|
|
|
81
83
|
<slot v-if="!listItem" />
|
|
82
84
|
</div>`,
|
|
83
85
|
}),
|
|
84
|
-
SelfHealingResultDetail: {
|
|
85
|
-
props:
|
|
86
|
-
|
|
86
|
+
SelfHealingResultDetail: defineComponent({
|
|
87
|
+
props: {
|
|
88
|
+
selection: { type: Object as PropType<InboxSelection>, required: true },
|
|
89
|
+
tab: String,
|
|
90
|
+
onItemChange: {
|
|
91
|
+
type: Function as PropType<(change: InboxItemChange) => void>,
|
|
92
|
+
required: true,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
emits: ['update:tab'],
|
|
96
|
+
setup(props) {
|
|
97
|
+
reportResultChange = props.onItemChange;
|
|
98
|
+
},
|
|
99
|
+
template: `<div data-test-id="result-detail" :data-id="selection.id" :data-tab="tab">
|
|
100
|
+
<button data-test-id="select-result-changes-tab" @click="$emit('update:tab', 'changes')" />
|
|
101
|
+
<button data-test-id="select-result-activity-tab" @click="$emit('update:tab', 'activity')" />
|
|
102
|
+
</div>`,
|
|
103
|
+
}),
|
|
104
|
+
PromotionReviewDetail: {
|
|
105
|
+
props: ['item'],
|
|
106
|
+
template: '<div data-test-id="promotion-detail" :data-id="item.id" />',
|
|
87
107
|
},
|
|
88
108
|
},
|
|
89
109
|
},
|
|
@@ -166,6 +186,21 @@ it('clears source context and keeps the current state and tab', async () => {
|
|
|
166
186
|
expect(router.currentRoute.value.fullPath).toBe('/inbox?state=closed&tab=changes');
|
|
167
187
|
});
|
|
168
188
|
|
|
189
|
+
it('renders a promotion review from its loaded row until its source becomes disabled', async () => {
|
|
190
|
+
await router.replace('/inbox/promotion-reviews/rev-1');
|
|
191
|
+
const { getByTestId, queryByTestId } = renderComponent();
|
|
192
|
+
expect(queryByTestId('promotion-detail')).not.toBeInTheDocument();
|
|
193
|
+
|
|
194
|
+
store.lists.waiting.items = [promotionItem()];
|
|
195
|
+
await waitAllPromises();
|
|
196
|
+
expect(getByTestId('promotion-detail')).toHaveAttribute('data-id', 'rev-1');
|
|
197
|
+
|
|
198
|
+
store.disabledSources = ['promotion_review'];
|
|
199
|
+
await waitAllPromises();
|
|
200
|
+
expect(router.currentRoute.value.fullPath).toBe('/inbox');
|
|
201
|
+
expect(queryByTestId('promotion-detail')).not.toBeInTheDocument();
|
|
202
|
+
});
|
|
203
|
+
|
|
169
204
|
it('clears a disabled-source deep link without mounting its detail entry', async () => {
|
|
170
205
|
await router.replace('/inbox/reviews/req-1');
|
|
171
206
|
store.disabledSources = ['workflow_review'];
|
|
@@ -221,6 +256,71 @@ it('writes detail tabs to the URL and keeps selection and state', async () => {
|
|
|
221
256
|
});
|
|
222
257
|
});
|
|
223
258
|
|
|
259
|
+
it('keeps Assistant selection through detail tabs and reconciles its closure through the Inbox', async () => {
|
|
260
|
+
const inboxSettings = useSettingsStore().settings.inbox;
|
|
261
|
+
createTestingPinia({ stubActions: false });
|
|
262
|
+
useSettingsStore().settings.inbox = inboxSettings;
|
|
263
|
+
const inbox = useInboxStore();
|
|
264
|
+
const closedResult: InboxSelfHealingItem = { ...resultItem(), state: 'closed' };
|
|
265
|
+
const metadata = { partial: false, failedSources: [], disabledSources: [] };
|
|
266
|
+
vi.mocked(inboxApi.fetchInboxSummary).mockResolvedValue({
|
|
267
|
+
...metadata,
|
|
268
|
+
counts: { open: 2, closed: 0 },
|
|
269
|
+
});
|
|
270
|
+
vi.mocked(inboxApi.fetchInbox).mockImplementation(async (_ctx, query) => ({
|
|
271
|
+
...metadata,
|
|
272
|
+
nextCursor: null,
|
|
273
|
+
hasMore: false,
|
|
274
|
+
data:
|
|
275
|
+
query.category === 'authored'
|
|
276
|
+
? []
|
|
277
|
+
: query.state === 'closed'
|
|
278
|
+
? [closedResult]
|
|
279
|
+
: [reviewItem(), resultItem()],
|
|
280
|
+
}));
|
|
281
|
+
await router.replace('/inbox/assistant-results/req-1?projectId=p1&workflowId=w1');
|
|
282
|
+
const { getByTestId, queryByTestId } = renderRoutedComponent();
|
|
283
|
+
await waitAllPromises();
|
|
284
|
+
|
|
285
|
+
getByTestId('select-result-changes-tab').click();
|
|
286
|
+
await waitAllPromises();
|
|
287
|
+
expect(router.currentRoute.value.query).toEqual({
|
|
288
|
+
projectId: 'p1',
|
|
289
|
+
workflowId: 'w1',
|
|
290
|
+
tab: 'changes',
|
|
291
|
+
});
|
|
292
|
+
expect(getByTestId('result-detail')).toHaveAttribute('data-tab', 'changes');
|
|
293
|
+
getByTestId('select-result-activity-tab').click();
|
|
294
|
+
await waitAllPromises();
|
|
295
|
+
expect(router.currentRoute.value.query).toEqual({ projectId: 'p1', workflowId: 'w1' });
|
|
296
|
+
expect(getByTestId('result-detail')).toHaveAttribute('data-tab', 'activity');
|
|
297
|
+
expect(inbox.openCount).toBe(2);
|
|
298
|
+
expect(inbox.closedCount).toBe(0);
|
|
299
|
+
|
|
300
|
+
reportResultChange({ type: 'self_healing_result', id: 'req-1', state: 'closed' });
|
|
301
|
+
await waitAllPromises();
|
|
302
|
+
|
|
303
|
+
expect(inbox.reconcileItemChange).toHaveBeenCalledExactlyOnceWith({
|
|
304
|
+
type: 'self_healing_result',
|
|
305
|
+
id: 'req-1',
|
|
306
|
+
state: 'closed',
|
|
307
|
+
});
|
|
308
|
+
expect(inbox.openCount).toBe(1);
|
|
309
|
+
expect(inbox.closedCount).toBe(1);
|
|
310
|
+
expect(inbox.lists.waiting.items).toEqual([reviewItem()]);
|
|
311
|
+
expect(inbox.lists.closed.items).toEqual([closedResult]);
|
|
312
|
+
expect(inbox.activeTab).toBe('closed');
|
|
313
|
+
expect(router.currentRoute.value.path).toBe('/inbox/assistant-results/req-1');
|
|
314
|
+
expect(router.currentRoute.value.query).toEqual({
|
|
315
|
+
projectId: 'p1',
|
|
316
|
+
workflowId: 'w1',
|
|
317
|
+
state: 'closed',
|
|
318
|
+
});
|
|
319
|
+
expect(getByTestId('result-detail')).toHaveAttribute('data-id', 'req-1');
|
|
320
|
+
expect(queryByTestId('review-detail')).not.toBeInTheDocument();
|
|
321
|
+
expect(inboxApi.fetchInboxSummary).toHaveBeenCalledOnce();
|
|
322
|
+
});
|
|
323
|
+
|
|
224
324
|
it('follows a selected item to Closed after its detail reports the new state', async () => {
|
|
225
325
|
await router.replace('/inbox/reviews/req-1');
|
|
226
326
|
renderComponent();
|
|
@@ -423,6 +523,28 @@ function reviewItem(): InboxWorkflowReviewItem {
|
|
|
423
523
|
updatedAt: '2024-01-01T00:00:00.000Z',
|
|
424
524
|
};
|
|
425
525
|
}
|
|
526
|
+
function promotionItem(): InboxPromotionReviewItem {
|
|
527
|
+
return {
|
|
528
|
+
type: 'promotion_review',
|
|
529
|
+
id: 'rev-1',
|
|
530
|
+
state: 'open',
|
|
531
|
+
reviewState: 'open',
|
|
532
|
+
reviewType: 'gitlab',
|
|
533
|
+
title: 'Promote the thing',
|
|
534
|
+
webUrl: null,
|
|
535
|
+
branchName: 'n8n-promotion/1',
|
|
536
|
+
environmentName: null,
|
|
537
|
+
requester: null,
|
|
538
|
+
approver: null,
|
|
539
|
+
remoteError: null,
|
|
540
|
+
headCommitSha: null,
|
|
541
|
+
runCommitSha: null,
|
|
542
|
+
hasConflicts: false,
|
|
543
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
544
|
+
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
426
548
|
function resultItem(): InboxSelfHealingItem {
|
|
427
549
|
return {
|
|
428
550
|
type: 'self_healing_result',
|
package/src/views/InboxView.vue
CHANGED
|
@@ -15,6 +15,7 @@ import { INBOX_VIEW, type InboxItemChange } from '../inbox.constants';
|
|
|
15
15
|
import { inboxItemLocation, isInboxRoute, selectionFromRoute } from '../inbox.routes';
|
|
16
16
|
import { useInboxStore } from '../inbox.store';
|
|
17
17
|
import WorkflowReviewDetail from '../reviews/WorkflowReviewDetail.vue';
|
|
18
|
+
import PromotionReviewDetail from '../promotions/PromotionReviewDetail.vue';
|
|
18
19
|
import SelfHealingResultDetail from '../self-healing/SelfHealingResultDetail.vue';
|
|
19
20
|
|
|
20
21
|
const store = useInboxStore();
|
|
@@ -53,6 +54,9 @@ const selectedListItem = computed(() =>
|
|
|
53
54
|
const selectedReviewItem = computed(() =>
|
|
54
55
|
selectedListItem.value?.type === 'workflow_review' ? selectedListItem.value : undefined,
|
|
55
56
|
);
|
|
57
|
+
const selectedPromotionItem = computed(() =>
|
|
58
|
+
selectedListItem.value?.type === 'promotion_review' ? selectedListItem.value : undefined,
|
|
59
|
+
);
|
|
56
60
|
const detailTab = computed(() => (route.query.tab === 'changes' ? 'changes' : 'activity'));
|
|
57
61
|
let isMounted = false;
|
|
58
62
|
|
|
@@ -69,6 +73,11 @@ function isSelectedReview(id: string) {
|
|
|
69
73
|
return selected?.type === 'workflow_review' && selected.id === id;
|
|
70
74
|
}
|
|
71
75
|
|
|
76
|
+
function isSelectedResult(id: string) {
|
|
77
|
+
const selected = selectionFromRoute(router.currentRoute.value);
|
|
78
|
+
return selected?.type === 'self_healing_result' && selected.id === id;
|
|
79
|
+
}
|
|
80
|
+
|
|
72
81
|
// Reset on entry. A layout-swap copy can unmount after the next view has mounted.
|
|
73
82
|
store.reset();
|
|
74
83
|
store.activeTab = route.query.state === 'closed' ? 'closed' : 'open';
|
|
@@ -185,17 +194,26 @@ onBeforeUnmount(() => {
|
|
|
185
194
|
>
|
|
186
195
|
<InboxEmptyState />
|
|
187
196
|
</WorkflowReviewDetail>
|
|
197
|
+
<PromotionReviewDetail
|
|
198
|
+
v-else-if="selectedPromotionItem && !store.disabledSources.includes('promotion_review')"
|
|
199
|
+
:item="selectedPromotionItem"
|
|
200
|
+
:on-item-change="onItemChange"
|
|
201
|
+
/>
|
|
202
|
+
<SelfHealingResultDetail
|
|
203
|
+
v-else-if="
|
|
204
|
+
selection?.type === 'self_healing_result' &&
|
|
205
|
+
!store.disabledSources.includes(selection.type)
|
|
206
|
+
"
|
|
207
|
+
:selection="selection"
|
|
208
|
+
:tab="detailTab"
|
|
209
|
+
:on-item-change="onItemChange"
|
|
210
|
+
:is-selected="isSelectedResult"
|
|
211
|
+
@update:tab="onDetailTabChange"
|
|
212
|
+
/>
|
|
188
213
|
<template v-else>
|
|
189
214
|
<div :class="$style.columnTitle" />
|
|
190
215
|
<div :class="$style.mainBody">
|
|
191
|
-
<
|
|
192
|
-
v-if="
|
|
193
|
-
selection?.type === 'self_healing_result' &&
|
|
194
|
-
!store.disabledSources.includes(selection.type)
|
|
195
|
-
"
|
|
196
|
-
:selection="selection"
|
|
197
|
-
/>
|
|
198
|
-
<InboxEmptyState v-else />
|
|
216
|
+
<InboxEmptyState />
|
|
199
217
|
</div>
|
|
200
218
|
</template>
|
|
201
219
|
</div>
|
|
@@ -218,10 +236,8 @@ onBeforeUnmount(() => {
|
|
|
218
236
|
.content {
|
|
219
237
|
--review-callout--max-width: 34rem;
|
|
220
238
|
--review-activity--max-width: 45rem;
|
|
221
|
-
--review-tab-bar--height: var(--height--sm);
|
|
222
239
|
/* Keep the existing Reviews tab alignment at every root font size. */
|
|
223
240
|
--review-tab-bar--indicator-overhang: 15px;
|
|
224
|
-
--review-tab-bar--gap: calc(var(--spacing--sm) + var(--review-tab-bar--indicator-overhang));
|
|
225
241
|
display: flex;
|
|
226
242
|
width: 100%;
|
|
227
243
|
min-height: 0;
|