@n8n/frontend-module-inbox 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +88 -0
- package/README.md +59 -2
- package/biome.jsonc +4 -0
- package/eslint.config.mjs +4 -0
- package/package.json +62 -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 +229 -0
- package/src/components/InboxList.vue +195 -0
- package/src/components/InboxListItem.vue +134 -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 +16 -0
- package/src/inbox.module.ts +50 -0
- package/src/inbox.routes.test.ts +111 -0
- package/src/inbox.routes.ts +30 -0
- package/src/inbox.store.test.ts +349 -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/WorkflowReviewChangesSection.test.ts +327 -0
- package/src/reviews/components/WorkflowReviewChangesSection.vue +219 -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 +241 -0
- package/src/reviews/components/WorkflowReviewDetailTabs.decisionNote.test.ts +90 -0
- package/src/reviews/components/WorkflowReviewDetailTabs.test.ts +434 -0
- package/src/reviews/components/WorkflowReviewDetailTabs.vue +311 -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/views/InboxView.test.ts +525 -0
- package/src/views/InboxView.vue +251 -0
- package/stylelint.config.mjs +9 -0
- package/vite.config.ts +52 -0
|
@@ -0,0 +1,241 @@
|
|
|
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
|
+
{{ i18n.baseText('workflowReviews.detail.metadata.workflow') }}
|
|
121
|
+
</N8nText>
|
|
122
|
+
</template>
|
|
123
|
+
<div :class="$style.workflows">
|
|
124
|
+
<N8nLink
|
|
125
|
+
v-for="workflow in detail.workflows"
|
|
126
|
+
:key="workflow.workflowId"
|
|
127
|
+
:to="{ name: VIEWS.WORKFLOW, params: { workflowId: workflow.workflowId } }"
|
|
128
|
+
theme="text"
|
|
129
|
+
size="medium"
|
|
130
|
+
:class="$style.workflow"
|
|
131
|
+
data-test-id="workflow-review-detail-workflow-link"
|
|
132
|
+
>
|
|
133
|
+
<N8nIcon icon="workflow" size="medium" :class="$style.workflowIcon" />
|
|
134
|
+
<span :class="$style.workflowName">{{ workflow.workflowName }}</span>
|
|
135
|
+
</N8nLink>
|
|
136
|
+
</div>
|
|
137
|
+
</N8nCard>
|
|
138
|
+
</aside>
|
|
139
|
+
</template>
|
|
140
|
+
|
|
141
|
+
<style module lang="scss">
|
|
142
|
+
.metadata {
|
|
143
|
+
position: sticky;
|
|
144
|
+
top: 0;
|
|
145
|
+
align-self: flex-start;
|
|
146
|
+
display: flex;
|
|
147
|
+
flex: 0 0 min(18rem, 30%);
|
|
148
|
+
flex-direction: column;
|
|
149
|
+
gap: var(--spacing--2xs);
|
|
150
|
+
min-width: 14rem;
|
|
151
|
+
padding-top: var(--spacing--5xs);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.card {
|
|
155
|
+
--card--padding: var(--spacing--xs);
|
|
156
|
+
--n8n--card-body--gap: var(--spacing--2xs);
|
|
157
|
+
|
|
158
|
+
align-items: stretch;
|
|
159
|
+
border-color: var(--border-color);
|
|
160
|
+
background-color: transparent;
|
|
161
|
+
|
|
162
|
+
> div {
|
|
163
|
+
gap: var(--spacing--4xs);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.status {
|
|
168
|
+
display: flex;
|
|
169
|
+
align-items: center;
|
|
170
|
+
gap: var(--spacing--2xs);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.section,
|
|
174
|
+
.workflows {
|
|
175
|
+
display: flex;
|
|
176
|
+
flex-direction: column;
|
|
177
|
+
gap: var(--spacing--2xs);
|
|
178
|
+
min-width: 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.statusSeparator {
|
|
182
|
+
margin-inline: var(--spacing--3xs);
|
|
183
|
+
color: var(--text-color--subtler);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.peopleCard {
|
|
187
|
+
--n8n--card-body--gap: var(--spacing--sm);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.person {
|
|
191
|
+
display: flex;
|
|
192
|
+
align-items: center;
|
|
193
|
+
gap: var(--spacing--2xs);
|
|
194
|
+
min-width: 0;
|
|
195
|
+
/* Names wrap at spaces; an email breaks mid-word only when it does not fit. */
|
|
196
|
+
overflow-wrap: anywhere;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.workflow {
|
|
200
|
+
display: flex;
|
|
201
|
+
width: 100%;
|
|
202
|
+
min-width: 0;
|
|
203
|
+
|
|
204
|
+
> span,
|
|
205
|
+
> span > span {
|
|
206
|
+
display: flex;
|
|
207
|
+
flex: 1;
|
|
208
|
+
align-items: flex-start;
|
|
209
|
+
min-width: 0;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.workflowName {
|
|
214
|
+
flex: 1;
|
|
215
|
+
min-width: 0;
|
|
216
|
+
/* A name with no spaces still has to break somewhere. */
|
|
217
|
+
overflow-wrap: anywhere;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.workflowIcon {
|
|
221
|
+
flex-shrink: 0;
|
|
222
|
+
margin-right: var(--spacing--4xs);
|
|
223
|
+
/* Optically centre the icon on the first line of text. */
|
|
224
|
+
margin-top: calc((1lh - 1em) / 2);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
@media (max-width: 75rem) {
|
|
228
|
+
.metadata {
|
|
229
|
+
flex-basis: 15rem;
|
|
230
|
+
min-width: 12rem;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
@container review-detail (max-width: 44rem) {
|
|
235
|
+
.metadata {
|
|
236
|
+
flex-basis: auto;
|
|
237
|
+
width: 100%;
|
|
238
|
+
min-width: 0;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
</style>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { WorkflowReviewRequestDetail } from '@n8n/api-types';
|
|
2
|
+
import { createTestingPinia } from '@pinia/testing';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { createComponentRenderer } from '@n8n/frontend-test-utils';
|
|
5
|
+
|
|
6
|
+
import { useReviewActivityStore } from '../reviewActivity.store';
|
|
7
|
+
import WorkflowReviewDetailTabs from './WorkflowReviewDetailTabs.vue';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The real decision popover is kept, so this pins the two facts the draft note depends on:
|
|
11
|
+
* it lives in the store, and its trigger sits outside the tab panel that unmounts.
|
|
12
|
+
* Reka UI popovers do not open in jsdom, so its primitives render inline instead.
|
|
13
|
+
*/
|
|
14
|
+
const rekaStubs = {
|
|
15
|
+
PopoverRoot: { template: '<div><slot /></div>' },
|
|
16
|
+
PopoverTrigger: { template: '<div><slot /></div>' },
|
|
17
|
+
PopoverPortal: { template: '<div><slot /></div>' },
|
|
18
|
+
PopoverContent: { template: '<div><slot /></div>' },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const childStubs = {
|
|
22
|
+
WorkflowReviewActivityFeed: { template: '<div><slot name="header" /></div>' },
|
|
23
|
+
WorkflowReviewChangesSection: { template: '<div />' },
|
|
24
|
+
WorkflowReviewCommentComposer: { template: '<div />' },
|
|
25
|
+
WorkflowReviewDetailMetadata: { template: '<aside />' },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const renderComponent = createComponentRenderer(WorkflowReviewDetailTabs, {
|
|
29
|
+
global: { stubs: { ...rekaStubs, ...childStubs } },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const review: WorkflowReviewRequestDetail = {
|
|
33
|
+
id: 'req-1',
|
|
34
|
+
projectId: 'proj-1',
|
|
35
|
+
title: 'Needs review',
|
|
36
|
+
requester: null,
|
|
37
|
+
authors: [],
|
|
38
|
+
reviewers: [],
|
|
39
|
+
decision: 'pending',
|
|
40
|
+
state: 'open',
|
|
41
|
+
createdAt: '2024-01-01T00:00:00.000Z',
|
|
42
|
+
updatedAt: '2024-01-01T00:00:00.000Z',
|
|
43
|
+
description: null,
|
|
44
|
+
workflows: [],
|
|
45
|
+
viewerCanDecide: true,
|
|
46
|
+
viewerDecisionIneligibilityReason: null,
|
|
47
|
+
viewerCanComment: true,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
describe('WorkflowReviewDetailTabs decision note', () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
createTestingPinia();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('keeps a half-written decision note across a tab switch', async () => {
|
|
56
|
+
const { getByTestId, rerender } = renderComponent({
|
|
57
|
+
props: { review, tab: 'activity', deciding: false },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await userEvent.type(getByTestId('workflow-review-decision-note'), 'Half a thought');
|
|
61
|
+
|
|
62
|
+
await rerender({ tab: 'changes' });
|
|
63
|
+
await rerender({ tab: 'activity' });
|
|
64
|
+
|
|
65
|
+
expect(getByTestId('workflow-review-decision-note')).toHaveValue('Half a thought');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// The two facts that make the note survive: it is typed into the store, and the popover
|
|
69
|
+
// sits outside the panel the tab switch unmounts. Asserted separately because either one
|
|
70
|
+
// alone still reads back the note above.
|
|
71
|
+
it('holds the note in the store rather than in the popover', async () => {
|
|
72
|
+
const { getByTestId } = renderComponent({
|
|
73
|
+
props: { review, tab: 'activity', deciding: false },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await userEvent.type(getByTestId('workflow-review-decision-note'), 'Half a thought');
|
|
77
|
+
|
|
78
|
+
expect(useReviewActivityStore().decisionNote).toBe('Half a thought');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('keeps the decision popover out of the tab panel', () => {
|
|
82
|
+
const { getByTestId } = renderComponent({
|
|
83
|
+
props: { review, tab: 'activity', deciding: false },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(getByTestId('workflow-review-activity-panel')).not.toContainElement(
|
|
87
|
+
getByTestId('workflow-review-decision-note'),
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
});
|