@n8n/frontend-module-inbox 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +88 -0
- package/LICENSE_EE.md +27 -0
- package/README.md +61 -2
- package/biome.jsonc +4 -0
- package/eslint.config.mjs +4 -0
- package/package.json +72 -3
- package/src/__tests__/design-system-icons.test.ts +24 -0
- package/src/__tests__/setup.ts +4 -0
- package/src/components/InboxEmptyState.vue +86 -0
- package/src/components/InboxList.test.ts +289 -0
- package/src/components/InboxList.vue +195 -0
- package/src/components/InboxListItem.vue +208 -0
- package/src/components/InboxListSection.vue +168 -0
- package/src/composables/useReviewInboxSectionCollapse.test.ts +66 -0
- package/src/composables/useReviewInboxSectionCollapse.ts +42 -0
- package/src/inbox.api.ts +20 -0
- package/src/inbox.constants.ts +19 -0
- package/src/inbox.module.ts +55 -0
- package/src/inbox.routes.test.ts +140 -0
- package/src/inbox.routes.ts +49 -0
- package/src/inbox.store.test.ts +444 -0
- package/src/inbox.store.ts +305 -0
- package/src/index.ts +5 -0
- package/src/reviews/WorkflowReviewDetail.test.ts +647 -0
- package/src/reviews/WorkflowReviewDetail.vue +257 -0
- package/src/reviews/components/WorkflowReviewActivityFeed.test.ts +923 -0
- package/src/reviews/components/WorkflowReviewActivityFeed.vue +312 -0
- package/src/reviews/components/WorkflowReviewChangesList.test.ts +97 -0
- package/src/reviews/components/WorkflowReviewChangesList.vue +143 -0
- package/src/reviews/components/WorkflowReviewChangesSection.test.ts +331 -0
- package/src/reviews/components/WorkflowReviewChangesSection.vue +217 -0
- package/src/reviews/components/WorkflowReviewCommentComposer.test.ts +175 -0
- package/src/reviews/components/WorkflowReviewCommentComposer.vue +83 -0
- package/src/reviews/components/WorkflowReviewDecisionPopover.test.ts +288 -0
- package/src/reviews/components/WorkflowReviewDecisionPopover.vue +203 -0
- package/src/reviews/components/WorkflowReviewDetailMetadata.test.ts +206 -0
- package/src/reviews/components/WorkflowReviewDetailMetadata.vue +245 -0
- package/src/reviews/components/WorkflowReviewDetailTabs.decisionNote.test.ts +90 -0
- package/src/reviews/components/WorkflowReviewDetailTabs.test.ts +500 -0
- package/src/reviews/components/WorkflowReviewDetailTabs.vue +329 -0
- package/src/reviews/components/WorkflowReviewStatusDot.vue +67 -0
- package/src/reviews/components/_activity-card.scss +36 -0
- package/src/reviews/components/activity-entries/WorkflowReviewActivityActorAvatar.vue +50 -0
- package/src/reviews/components/activity-entries/WorkflowReviewActivityComment.test.ts +103 -0
- package/src/reviews/components/activity-entries/WorkflowReviewActivityComment.vue +116 -0
- package/src/reviews/components/activity-entries/WorkflowReviewActivityEventEntry.vue +361 -0
- package/src/reviews/components/activity-entries/WorkflowReviewActivityFallback.vue +49 -0
- package/src/reviews/components/activity-entries/WorkflowReviewActivityWorkflowLink.vue +36 -0
- package/src/reviews/components/activityEntryRegistry.ts +27 -0
- package/src/reviews/constants.ts +41 -0
- package/src/reviews/reviewActivity.store.test.ts +463 -0
- package/src/reviews/reviewActivity.store.ts +186 -0
- package/src/reviews/reviewDetail.store.test.ts +135 -0
- package/src/reviews/reviewDetail.store.ts +74 -0
- package/src/reviews/workflowReviewStatus.utils.ts +44 -0
- package/src/reviews/workflowReviews.api.ts +61 -0
- package/src/reviews/workflowReviews.utils.ts +18 -0
- package/src/self-healing/SelfHealingResultDetail.vue +19 -0
- package/src/views/InboxView.test.ts +568 -0
- package/src/views/InboxView.vue +260 -0
- package/stylelint.config.mjs +9 -0
- package/vite.config.ts +52 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { WORKFLOW_REVIEW_TEXT_MAX_LENGTH } from '@n8n/api-types';
|
|
3
|
+
import { useToast } from '@n8n/composables/useToast';
|
|
4
|
+
import { N8nButton, N8nIcon, N8nInput, N8nPopover, N8nTooltip } from '@n8n/design-system';
|
|
5
|
+
import { useI18n } from '@n8n/i18n';
|
|
6
|
+
import { storeToRefs } from 'pinia';
|
|
7
|
+
import { computed, ref, useId } from 'vue';
|
|
8
|
+
|
|
9
|
+
import { useReviewActivityStore } from '../reviewActivity.store';
|
|
10
|
+
import type { WorkflowReviewDecisionInput } from '../workflowReviews.api';
|
|
11
|
+
|
|
12
|
+
const props = defineProps<{
|
|
13
|
+
deciding: boolean;
|
|
14
|
+
viewerCanDecide: boolean;
|
|
15
|
+
viewerCanComment: boolean;
|
|
16
|
+
/** Why the viewer may not decide; empty when they may. */
|
|
17
|
+
ineligibilityHint: string;
|
|
18
|
+
}>();
|
|
19
|
+
|
|
20
|
+
const emit = defineEmits<{
|
|
21
|
+
decide: [input: WorkflowReviewDecisionInput];
|
|
22
|
+
'comment-posted': [];
|
|
23
|
+
}>();
|
|
24
|
+
|
|
25
|
+
const i18n = useI18n();
|
|
26
|
+
const { showError } = useToast();
|
|
27
|
+
const store = useReviewActivityStore();
|
|
28
|
+
const { posting, decisionNote } = storeToRefs(store);
|
|
29
|
+
|
|
30
|
+
const isOpen = ref(false);
|
|
31
|
+
const noteInputId = useId();
|
|
32
|
+
|
|
33
|
+
const note = computed(() => decisionNote.value.trim());
|
|
34
|
+
|
|
35
|
+
// Every viewer who may decide may also comment, and a viewer who may not decide cannot open
|
|
36
|
+
// the popover at all, so the last term only guards against those two rules drifting apart.
|
|
37
|
+
const commentDisabled = computed(
|
|
38
|
+
() => posting.value || props.deciding || note.value.length === 0 || !props.viewerCanComment,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// A comment in flight leaves its text in the box on purpose, so without this the same text
|
|
42
|
+
// goes out twice: once as the comment, once as the decision note.
|
|
43
|
+
const decisionDisabled = computed(() => props.deciding || posting.value);
|
|
44
|
+
|
|
45
|
+
function submitDecision(input: WorkflowReviewDecisionInput) {
|
|
46
|
+
isOpen.value = false;
|
|
47
|
+
emit('decide', input);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function onRequestChanges() {
|
|
51
|
+
submitDecision({ decision: 'changes_requested', note: note.value });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function onApprove() {
|
|
55
|
+
// The key is omitted rather than sent empty: the DTO rejects `''`.
|
|
56
|
+
submitDecision(
|
|
57
|
+
note.value ? { decision: 'approved', note: note.value } : { decision: 'approved' },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function onComment() {
|
|
62
|
+
const body = note.value;
|
|
63
|
+
|
|
64
|
+
isOpen.value = false;
|
|
65
|
+
try {
|
|
66
|
+
// A comment that landed after the viewer moved on says nothing about the review they
|
|
67
|
+
// are reading now, so neither its note nor its tab may be touched.
|
|
68
|
+
if (!(await store.postComment(body))) return;
|
|
69
|
+
|
|
70
|
+
store.clearDecisionNote(body);
|
|
71
|
+
// The trigger sits outside the tab panel, so a comment posted from the Changes tab
|
|
72
|
+
// would otherwise land nowhere the viewer can see.
|
|
73
|
+
emit('comment-posted');
|
|
74
|
+
} catch (error) {
|
|
75
|
+
showError(error, i18n.baseText('workflowReviews.detail.activity.error.post'));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
</script>
|
|
79
|
+
|
|
80
|
+
<template>
|
|
81
|
+
<!-- 480px deviates from Figma's 440px on purpose: the three icon+label buttons do not fit
|
|
82
|
+
in 440px, and N8nPopover sets a fixed width, so the row would spill rather than wrap. -->
|
|
83
|
+
<N8nPopover
|
|
84
|
+
v-model:open="isOpen"
|
|
85
|
+
side="bottom"
|
|
86
|
+
align="end"
|
|
87
|
+
width="480px"
|
|
88
|
+
:enable-scrolling="false"
|
|
89
|
+
:content-class="$style.popover"
|
|
90
|
+
>
|
|
91
|
+
<template #trigger>
|
|
92
|
+
<!-- Wrapped only while the viewer may not decide: N8nTooltip does not forward attrs
|
|
93
|
+
on this branch, so the trigger props land on the span — which is what makes the
|
|
94
|
+
hint fire over a natively disabled button, and this trigger cannot open anything
|
|
95
|
+
anyway. An enabled trigger has to stay the bare button, or it loses `aria-expanded`
|
|
96
|
+
and the focus it gets back on close. -->
|
|
97
|
+
<span v-if="ineligibilityHint">
|
|
98
|
+
<N8nTooltip :content="ineligibilityHint" :show-after="300">
|
|
99
|
+
<N8nButton
|
|
100
|
+
size="small"
|
|
101
|
+
:disabled="deciding || !viewerCanDecide"
|
|
102
|
+
data-test-id="workflow-review-decision-trigger"
|
|
103
|
+
>
|
|
104
|
+
{{ i18n.baseText('workflowReviews.detail.decision.trigger') }}
|
|
105
|
+
<N8nIcon icon="chevron-down" />
|
|
106
|
+
</N8nButton>
|
|
107
|
+
</N8nTooltip>
|
|
108
|
+
</span>
|
|
109
|
+
<N8nButton
|
|
110
|
+
v-else
|
|
111
|
+
size="small"
|
|
112
|
+
:disabled="deciding || !viewerCanDecide"
|
|
113
|
+
data-test-id="workflow-review-decision-trigger"
|
|
114
|
+
>
|
|
115
|
+
{{ i18n.baseText('workflowReviews.detail.decision.trigger') }}
|
|
116
|
+
<N8nIcon icon="chevron-down" />
|
|
117
|
+
</N8nButton>
|
|
118
|
+
</template>
|
|
119
|
+
<template #content>
|
|
120
|
+
<div :class="$style.content" data-test-id="workflow-review-decision-popover">
|
|
121
|
+
<N8nInput
|
|
122
|
+
:id="noteInputId"
|
|
123
|
+
v-model="decisionNote"
|
|
124
|
+
:aria-label="i18n.baseText('workflowReviews.detail.decision.note.label')"
|
|
125
|
+
type="textarea"
|
|
126
|
+
:rows="3"
|
|
127
|
+
:maxlength="WORKFLOW_REVIEW_TEXT_MAX_LENGTH"
|
|
128
|
+
:placeholder="i18n.baseText('workflowReviews.detail.activity.composer.placeholder')"
|
|
129
|
+
:class="$style.input"
|
|
130
|
+
data-test-id="workflow-review-decision-note"
|
|
131
|
+
/>
|
|
132
|
+
<div :class="$style.actions">
|
|
133
|
+
<N8nButton
|
|
134
|
+
variant="outline"
|
|
135
|
+
size="small"
|
|
136
|
+
:disabled="commentDisabled"
|
|
137
|
+
data-test-id="workflow-review-decision-comment-button"
|
|
138
|
+
@click="onComment"
|
|
139
|
+
>
|
|
140
|
+
<template #icon>
|
|
141
|
+
<N8nIcon icon="message-square" />
|
|
142
|
+
</template>
|
|
143
|
+
{{ i18n.baseText('workflowReviews.detail.decision.comment') }}
|
|
144
|
+
</N8nButton>
|
|
145
|
+
<N8nTooltip
|
|
146
|
+
:disabled="!!note"
|
|
147
|
+
:content="i18n.baseText('workflowReviews.detail.decision.note.required')"
|
|
148
|
+
:show-after="300"
|
|
149
|
+
>
|
|
150
|
+
<N8nButton
|
|
151
|
+
variant="outline"
|
|
152
|
+
size="small"
|
|
153
|
+
:disabled="!note || decisionDisabled"
|
|
154
|
+
data-test-id="workflow-review-decision-request-changes-button"
|
|
155
|
+
@click="onRequestChanges"
|
|
156
|
+
>
|
|
157
|
+
<template #icon>
|
|
158
|
+
<N8nIcon icon="refresh-cw" />
|
|
159
|
+
</template>
|
|
160
|
+
{{ i18n.baseText('workflowReviews.detail.decision.requestChanges') }}
|
|
161
|
+
</N8nButton>
|
|
162
|
+
</N8nTooltip>
|
|
163
|
+
<N8nButton
|
|
164
|
+
variant="outline"
|
|
165
|
+
size="small"
|
|
166
|
+
:disabled="decisionDisabled"
|
|
167
|
+
data-test-id="workflow-review-decision-approve-button"
|
|
168
|
+
@click="onApprove"
|
|
169
|
+
>
|
|
170
|
+
<template #icon>
|
|
171
|
+
<N8nIcon icon="check" />
|
|
172
|
+
</template>
|
|
173
|
+
{{ i18n.baseText('workflowReviews.detail.decision.approveAndPublish') }}
|
|
174
|
+
</N8nButton>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
</template>
|
|
178
|
+
</N8nPopover>
|
|
179
|
+
</template>
|
|
180
|
+
|
|
181
|
+
<style lang="scss" module>
|
|
182
|
+
.popover {
|
|
183
|
+
padding: var(--spacing--xs);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.content {
|
|
187
|
+
display: flex;
|
|
188
|
+
flex-direction: column;
|
|
189
|
+
gap: var(--spacing--xs);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.input {
|
|
193
|
+
:global(textarea) {
|
|
194
|
+
max-height: 50dvh;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.actions {
|
|
199
|
+
display: flex;
|
|
200
|
+
justify-content: flex-end;
|
|
201
|
+
gap: var(--spacing--2xs);
|
|
202
|
+
}
|
|
203
|
+
</style>
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
WorkflowReviewRequestDetail,
|
|
3
|
+
WorkflowReviewRequestWorkflowDetail,
|
|
4
|
+
} from '@n8n/api-types';
|
|
5
|
+
import { createTestingPinia } from '@pinia/testing';
|
|
6
|
+
import { createMemoryHistory, createRouter } from 'vue-router';
|
|
7
|
+
|
|
8
|
+
import { createComponentRenderer } from '@n8n/frontend-test-utils';
|
|
9
|
+
import { VIEWS } from '@n8n/frontend-constants/views';
|
|
10
|
+
|
|
11
|
+
import WorkflowReviewDetailMetadata from './WorkflowReviewDetailMetadata.vue';
|
|
12
|
+
|
|
13
|
+
const router = createRouter({
|
|
14
|
+
history: createMemoryHistory(),
|
|
15
|
+
routes: [
|
|
16
|
+
{
|
|
17
|
+
path: '/workflow/:workflowId',
|
|
18
|
+
name: VIEWS.WORKFLOW,
|
|
19
|
+
component: { template: '<div />' },
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const renderComponent = createComponentRenderer(WorkflowReviewDetailMetadata, {
|
|
25
|
+
global: { plugins: [router], stubs: { RouterLink: false } },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function makeWorkflowDetail(
|
|
29
|
+
overrides: Partial<WorkflowReviewRequestWorkflowDetail> = {},
|
|
30
|
+
): WorkflowReviewRequestWorkflowDetail {
|
|
31
|
+
return {
|
|
32
|
+
workflowId: 'wf-1',
|
|
33
|
+
workflowName: 'Payment Handler',
|
|
34
|
+
workflowVersionId: 'version-1',
|
|
35
|
+
pinnedVersion: null,
|
|
36
|
+
publishedVersionId: null,
|
|
37
|
+
baselineVersion: null,
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const requester = {
|
|
43
|
+
id: 'requester-1',
|
|
44
|
+
email: 'requester@example.com',
|
|
45
|
+
firstName: 'Rita',
|
|
46
|
+
lastName: 'Requester',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const laterAuthor = {
|
|
50
|
+
id: 'author-2',
|
|
51
|
+
email: 'author@example.com',
|
|
52
|
+
firstName: 'Ada',
|
|
53
|
+
lastName: 'Author',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function makeDetail(
|
|
57
|
+
overrides: Partial<WorkflowReviewRequestDetail> = {},
|
|
58
|
+
): WorkflowReviewRequestDetail {
|
|
59
|
+
return {
|
|
60
|
+
id: 'req-1',
|
|
61
|
+
projectId: 'proj-1',
|
|
62
|
+
title: 'Update Payment Handler',
|
|
63
|
+
requester,
|
|
64
|
+
// The backend always carries the requester in `authors` too.
|
|
65
|
+
authors: [{ ...requester }, laterAuthor],
|
|
66
|
+
reviewers: [
|
|
67
|
+
{
|
|
68
|
+
id: 'reviewer-1',
|
|
69
|
+
email: 'reviewer@example.com',
|
|
70
|
+
firstName: 'Riley',
|
|
71
|
+
lastName: 'Reviewer',
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
decision: 'pending',
|
|
75
|
+
state: 'open',
|
|
76
|
+
createdAt: '2024-01-01T00:00:00.000Z',
|
|
77
|
+
updatedAt: '2024-01-01T00:00:00.000Z',
|
|
78
|
+
description: null,
|
|
79
|
+
workflows: [makeWorkflowDetail()],
|
|
80
|
+
viewerCanDecide: true,
|
|
81
|
+
viewerDecisionIneligibilityReason: null,
|
|
82
|
+
viewerCanComment: true,
|
|
83
|
+
...overrides,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('WorkflowReviewDetailMetadata', () => {
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
createTestingPinia();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('renders the status, people, and workflows', () => {
|
|
93
|
+
const { getByTestId, getByText, queryByText } = renderComponent({
|
|
94
|
+
props: { review: makeDetail() },
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(getByTestId('workflow-review-detail-status-card')).toHaveTextContent(
|
|
98
|
+
'Open | Waiting for review',
|
|
99
|
+
);
|
|
100
|
+
expect(getByText('Riley Reviewer')).toBeInTheDocument();
|
|
101
|
+
expect(queryByText('reviewer@example.com')).not.toBeInTheDocument();
|
|
102
|
+
expect(getByText('Payment Handler')).toBeInTheDocument();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('lists requested by, other authors, and reviewers in that order', () => {
|
|
106
|
+
const { getByTestId } = renderComponent({
|
|
107
|
+
props: { review: makeDetail() },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(getByTestId('workflow-review-detail-people-card').textContent).toMatch(
|
|
111
|
+
/^Requested by.*Rita Requester.*Other authors.*Ada Author.*Reviewers.*Riley Reviewer/s,
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('excludes the requester from other authors by id, not object identity', () => {
|
|
116
|
+
const { getByTestId } = renderComponent({
|
|
117
|
+
props: { review: makeDetail() },
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(getByTestId('workflow-review-detail-other-authors')).not.toHaveTextContent(
|
|
121
|
+
'Rita Requester',
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('hides other authors when the requester is the only author', () => {
|
|
126
|
+
const { queryByTestId } = renderComponent({
|
|
127
|
+
props: { review: makeDetail({ authors: [{ ...requester }] }) },
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(queryByTestId('workflow-review-detail-other-authors')).not.toBeInTheDocument();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('falls back to a deleted-user message when the requester no longer exists', () => {
|
|
134
|
+
const { getByTestId } = renderComponent({
|
|
135
|
+
props: { review: makeDetail({ requester: null, authors: [laterAuthor] }) },
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(getByTestId('workflow-review-detail-requester-deleted')).toHaveTextContent(
|
|
139
|
+
'Deleted user',
|
|
140
|
+
);
|
|
141
|
+
expect(getByTestId('workflow-review-detail-other-authors')).toHaveTextContent('Ada Author');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('pairs the closed state with an approval decision', () => {
|
|
145
|
+
const { getByTestId } = renderComponent({
|
|
146
|
+
props: { review: makeDetail({ state: 'closed', decision: 'approved' }) },
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(getByTestId('workflow-review-detail-status-card')).toHaveTextContent(
|
|
150
|
+
'Closed | Approved',
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Nobody is waiting on a closed review, so an undecided close reads "No decision".
|
|
155
|
+
it('pairs the closed state with a pending decision as No decision', () => {
|
|
156
|
+
const { getByTestId } = renderComponent({
|
|
157
|
+
props: { review: makeDetail({ state: 'closed', decision: 'pending' }) },
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(getByTestId('workflow-review-detail-status-card')).toHaveTextContent(
|
|
161
|
+
'Closed | No decision',
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('pairs the closed state with a changes-requested decision', () => {
|
|
166
|
+
const { getByTestId } = renderComponent({
|
|
167
|
+
props: { review: makeDetail({ state: 'closed', decision: 'changes_requested' }) },
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(getByTestId('workflow-review-detail-status-card')).toHaveTextContent(
|
|
171
|
+
'Closed | Changes requested',
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('pairs the open state with a changes-requested decision', () => {
|
|
176
|
+
const { getByTestId } = renderComponent({
|
|
177
|
+
props: { review: makeDetail({ state: 'open', decision: 'changes_requested' }) },
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(getByTestId('workflow-review-detail-status-card')).toHaveTextContent(
|
|
181
|
+
'Open | Changes requested',
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('links each workflow to the editor', () => {
|
|
186
|
+
const { getByTestId } = renderComponent({
|
|
187
|
+
props: { review: makeDetail() },
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
expect(getByTestId('workflow-review-detail-workflow-link')).toHaveAttribute(
|
|
191
|
+
'href',
|
|
192
|
+
'/workflow/wf-1',
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('hides the changes card when there are no workflows', () => {
|
|
197
|
+
const { getByTestId, queryByTestId } = renderComponent({
|
|
198
|
+
props: { review: makeDetail({ reviewers: [], workflows: [] }) },
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
expect(getByTestId('workflow-review-detail-no-reviewers')).toHaveTextContent(
|
|
202
|
+
'No reviewers assigned.',
|
|
203
|
+
);
|
|
204
|
+
expect(queryByTestId('workflow-review-detail-changes-card')).not.toBeInTheDocument();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { WorkflowReviewInboxItem, WorkflowReviewRequestDetail } from '@n8n/api-types';
|
|
3
|
+
import { N8nAvatar, N8nCard, N8nIcon, N8nLink, N8nText } from '@n8n/design-system';
|
|
4
|
+
import { useI18n } from '@n8n/i18n';
|
|
5
|
+
import { computed } from 'vue';
|
|
6
|
+
|
|
7
|
+
import { VIEWS } from '@n8n/frontend-constants/views';
|
|
8
|
+
import { formatUserDisplayName } from '../workflowReviews.utils';
|
|
9
|
+
import { getWorkflowReviewStatusDisplay } from '../workflowReviewStatus.utils';
|
|
10
|
+
import WorkflowReviewStatusDot from './WorkflowReviewStatusDot.vue';
|
|
11
|
+
|
|
12
|
+
const props = defineProps<{
|
|
13
|
+
review: WorkflowReviewInboxItem | WorkflowReviewRequestDetail;
|
|
14
|
+
}>();
|
|
15
|
+
|
|
16
|
+
const i18n = useI18n();
|
|
17
|
+
|
|
18
|
+
const detail = computed<WorkflowReviewRequestDetail | null>(() =>
|
|
19
|
+
'workflows' in props.review ? props.review : null,
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
// Authors include the requester, who already has their own section above.
|
|
23
|
+
const otherAuthors = computed(() =>
|
|
24
|
+
props.review.authors.filter((author) => author.id !== props.review.requester?.id),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const statusSummary = computed(() =>
|
|
28
|
+
getWorkflowReviewStatusDisplay(i18n, props.review.state, props.review.decision),
|
|
29
|
+
);
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<template>
|
|
33
|
+
<aside :class="$style.metadata" data-test-id="workflow-review-detail-metadata">
|
|
34
|
+
<N8nCard :class="$style.card" data-test-id="workflow-review-detail-status-card">
|
|
35
|
+
<template #header>
|
|
36
|
+
<N8nText bold color="text-light" size="medium">
|
|
37
|
+
{{ i18n.baseText('workflowReviews.detail.metadata.status') }}
|
|
38
|
+
</N8nText>
|
|
39
|
+
</template>
|
|
40
|
+
<div :class="$style.status">
|
|
41
|
+
<WorkflowReviewStatusDot :state="review.state" :decision="review.decision" decorative />
|
|
42
|
+
<N8nText size="medium">
|
|
43
|
+
{{ statusSummary.stateLabel }}
|
|
44
|
+
<span aria-hidden="true" :class="$style.statusSeparator">|</span>
|
|
45
|
+
{{ statusSummary.decisionLabel }}
|
|
46
|
+
</N8nText>
|
|
47
|
+
</div>
|
|
48
|
+
</N8nCard>
|
|
49
|
+
|
|
50
|
+
<N8nCard
|
|
51
|
+
:class="[$style.card, $style.peopleCard]"
|
|
52
|
+
data-test-id="workflow-review-detail-people-card"
|
|
53
|
+
>
|
|
54
|
+
<div :class="$style.section">
|
|
55
|
+
<N8nText bold color="text-light" size="medium">
|
|
56
|
+
{{ i18n.baseText('workflowReviews.detail.metadata.requestedBy') }}
|
|
57
|
+
</N8nText>
|
|
58
|
+
<div v-if="review.requester" :class="$style.person">
|
|
59
|
+
<N8nAvatar
|
|
60
|
+
:first-name="review.requester.firstName"
|
|
61
|
+
:last-name="review.requester.lastName"
|
|
62
|
+
size="xsmall"
|
|
63
|
+
/>
|
|
64
|
+
<N8nText size="medium">{{ formatUserDisplayName(review.requester) }}</N8nText>
|
|
65
|
+
</div>
|
|
66
|
+
<N8nText
|
|
67
|
+
v-else
|
|
68
|
+
color="text-light"
|
|
69
|
+
size="medium"
|
|
70
|
+
data-test-id="workflow-review-detail-requester-deleted"
|
|
71
|
+
>
|
|
72
|
+
{{ i18n.baseText('workflowReviews.detail.metadata.requesterDeleted') }}
|
|
73
|
+
</N8nText>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div
|
|
77
|
+
v-if="otherAuthors.length > 0"
|
|
78
|
+
:class="$style.section"
|
|
79
|
+
data-test-id="workflow-review-detail-other-authors"
|
|
80
|
+
>
|
|
81
|
+
<N8nText bold color="text-light" size="medium">
|
|
82
|
+
{{ i18n.baseText('workflowReviews.detail.metadata.otherAuthors') }}
|
|
83
|
+
</N8nText>
|
|
84
|
+
<div v-for="author in otherAuthors" :key="author.id" :class="$style.person">
|
|
85
|
+
<N8nAvatar :first-name="author.firstName" :last-name="author.lastName" size="xsmall" />
|
|
86
|
+
<N8nText size="medium">{{ formatUserDisplayName(author) }}</N8nText>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div :class="$style.section">
|
|
91
|
+
<N8nText bold color="text-light" size="medium">
|
|
92
|
+
{{ i18n.baseText('workflowReviews.detail.metadata.reviewers') }}
|
|
93
|
+
</N8nText>
|
|
94
|
+
<div v-for="reviewer in review.reviewers" :key="reviewer.id" :class="$style.person">
|
|
95
|
+
<N8nAvatar
|
|
96
|
+
:first-name="reviewer.firstName"
|
|
97
|
+
:last-name="reviewer.lastName"
|
|
98
|
+
size="xsmall"
|
|
99
|
+
/>
|
|
100
|
+
<N8nText size="medium">{{ formatUserDisplayName(reviewer) }}</N8nText>
|
|
101
|
+
</div>
|
|
102
|
+
<N8nText
|
|
103
|
+
v-if="review.reviewers.length === 0"
|
|
104
|
+
color="text-light"
|
|
105
|
+
size="medium"
|
|
106
|
+
data-test-id="workflow-review-detail-no-reviewers"
|
|
107
|
+
>
|
|
108
|
+
{{ i18n.baseText('workflowReviews.detail.metadata.noReviewers') }}
|
|
109
|
+
</N8nText>
|
|
110
|
+
</div>
|
|
111
|
+
</N8nCard>
|
|
112
|
+
|
|
113
|
+
<N8nCard
|
|
114
|
+
v-if="detail?.workflows.length"
|
|
115
|
+
:class="$style.card"
|
|
116
|
+
data-test-id="workflow-review-detail-changes-card"
|
|
117
|
+
>
|
|
118
|
+
<template #header>
|
|
119
|
+
<N8nText bold color="text-light" size="medium">
|
|
120
|
+
{{
|
|
121
|
+
i18n.baseText('workflowReviews.detail.metadata.workflow', {
|
|
122
|
+
adjustToNumber: detail.workflows.length,
|
|
123
|
+
})
|
|
124
|
+
}}
|
|
125
|
+
</N8nText>
|
|
126
|
+
</template>
|
|
127
|
+
<div :class="$style.workflows">
|
|
128
|
+
<N8nLink
|
|
129
|
+
v-for="workflow in detail.workflows"
|
|
130
|
+
:key="workflow.workflowId"
|
|
131
|
+
:to="{ name: VIEWS.WORKFLOW, params: { workflowId: workflow.workflowId } }"
|
|
132
|
+
theme="text"
|
|
133
|
+
size="medium"
|
|
134
|
+
:class="$style.workflow"
|
|
135
|
+
data-test-id="workflow-review-detail-workflow-link"
|
|
136
|
+
>
|
|
137
|
+
<N8nIcon icon="workflow" size="medium" :class="$style.workflowIcon" />
|
|
138
|
+
<span :class="$style.workflowName">{{ workflow.workflowName }}</span>
|
|
139
|
+
</N8nLink>
|
|
140
|
+
</div>
|
|
141
|
+
</N8nCard>
|
|
142
|
+
</aside>
|
|
143
|
+
</template>
|
|
144
|
+
|
|
145
|
+
<style module lang="scss">
|
|
146
|
+
.metadata {
|
|
147
|
+
position: sticky;
|
|
148
|
+
top: 0;
|
|
149
|
+
align-self: flex-start;
|
|
150
|
+
display: flex;
|
|
151
|
+
flex: 0 0 min(18rem, 30%);
|
|
152
|
+
flex-direction: column;
|
|
153
|
+
gap: var(--spacing--2xs);
|
|
154
|
+
min-width: 14rem;
|
|
155
|
+
padding-top: var(--spacing--5xs);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.card {
|
|
159
|
+
--card--padding: var(--spacing--xs);
|
|
160
|
+
--n8n--card-body--gap: var(--spacing--2xs);
|
|
161
|
+
|
|
162
|
+
align-items: stretch;
|
|
163
|
+
border-color: var(--border-color);
|
|
164
|
+
background-color: transparent;
|
|
165
|
+
|
|
166
|
+
> div {
|
|
167
|
+
gap: var(--spacing--4xs);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.status {
|
|
172
|
+
display: flex;
|
|
173
|
+
align-items: center;
|
|
174
|
+
gap: var(--spacing--2xs);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.section,
|
|
178
|
+
.workflows {
|
|
179
|
+
display: flex;
|
|
180
|
+
flex-direction: column;
|
|
181
|
+
gap: var(--spacing--2xs);
|
|
182
|
+
min-width: 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.statusSeparator {
|
|
186
|
+
margin-inline: var(--spacing--3xs);
|
|
187
|
+
color: var(--text-color--subtler);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.peopleCard {
|
|
191
|
+
--n8n--card-body--gap: var(--spacing--sm);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.person {
|
|
195
|
+
display: flex;
|
|
196
|
+
align-items: center;
|
|
197
|
+
gap: var(--spacing--2xs);
|
|
198
|
+
min-width: 0;
|
|
199
|
+
/* Names wrap at spaces; an email breaks mid-word only when it does not fit. */
|
|
200
|
+
overflow-wrap: anywhere;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.workflow {
|
|
204
|
+
display: flex;
|
|
205
|
+
width: 100%;
|
|
206
|
+
min-width: 0;
|
|
207
|
+
|
|
208
|
+
> span,
|
|
209
|
+
> span > span {
|
|
210
|
+
display: flex;
|
|
211
|
+
flex: 1;
|
|
212
|
+
align-items: flex-start;
|
|
213
|
+
min-width: 0;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.workflowName {
|
|
218
|
+
flex: 1;
|
|
219
|
+
min-width: 0;
|
|
220
|
+
/* A name with no spaces still has to break somewhere. */
|
|
221
|
+
overflow-wrap: anywhere;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.workflowIcon {
|
|
225
|
+
flex-shrink: 0;
|
|
226
|
+
margin-right: var(--spacing--4xs);
|
|
227
|
+
/* Optically centre the icon on the first line of text. */
|
|
228
|
+
margin-top: calc((1lh - 1em) / 2);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
@media (max-width: 75rem) {
|
|
232
|
+
.metadata {
|
|
233
|
+
flex-basis: 15rem;
|
|
234
|
+
min-width: 12rem;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
@container review-detail (max-width: 44rem) {
|
|
239
|
+
.metadata {
|
|
240
|
+
flex-basis: auto;
|
|
241
|
+
width: 100%;
|
|
242
|
+
min-width: 0;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
</style>
|