@n8n/frontend-module-inbox 0.2.4 → 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.
@@ -0,0 +1,244 @@
1
+ import type { InboxPromotionReviewItem } from '@n8n/api-types';
2
+ import { componentRegistry, type PromotionChangesState } from '@n8n/frontend-module-sdk';
3
+ import { useToast } from '@n8n/composables/useToast';
4
+ import { createComponentRenderer } from '@n8n/frontend-test-utils';
5
+ import { defineComponent } from 'vue';
6
+ import { onTestFinished } from 'vitest';
7
+ import { createTestingPinia } from '@pinia/testing';
8
+ import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
9
+ import { waitFor, within } from '@testing-library/vue';
10
+ import userEvent from '@testing-library/user-event';
11
+
12
+ import PromotionReviewDetail from './PromotionReviewDetail.vue';
13
+ import { approvePromotionReview } from './promotionReviews.api';
14
+
15
+ vi.mock('./promotionReviews.api');
16
+ vi.mock('@n8n/composables/useToast', () => ({ useToast: vi.fn() }));
17
+
18
+ const headCommitSha = 'a'.repeat(40);
19
+
20
+ const item = (props: Partial<InboxPromotionReviewItem> = {}): InboxPromotionReviewItem => ({
21
+ type: 'promotion_review',
22
+ id: 'rev-1',
23
+ state: 'open',
24
+ reviewState: 'open',
25
+ reviewType: 'gitlab',
26
+ title: 'Promote the thing',
27
+ webUrl: 'https://gitlab.example.com/platform/workflows/-/merge_requests/1',
28
+ branchName: 'n8n-promotion/1',
29
+ environmentName: 'To production',
30
+ requester: { id: 'u1', firstName: 'Ada', lastName: 'Lovelace', email: 'ada@example.com' },
31
+ approver: null,
32
+ remoteError: null,
33
+ headCommitSha,
34
+ runCommitSha: 'c'.repeat(40),
35
+ hasConflicts: false,
36
+ createdAt: '2026-01-01T00:00:00.000Z',
37
+ updatedAt: '2026-01-01T00:00:00.000Z',
38
+ ...props,
39
+ });
40
+
41
+ const renderComponent = createComponentRenderer(PromotionReviewDetail);
42
+
43
+ const showError = vi.fn();
44
+ const onItemChange = vi.fn();
45
+
46
+ /** What the Changes tab reports. The editor renders it, so the test uses a stub. */
47
+ let shownChanges: PromotionChangesState;
48
+ const ChangesStub = defineComponent({
49
+ props: { reviewId: { type: String, required: true } },
50
+ emits: ['state'],
51
+ mounted() {
52
+ this.$emit('state', shownChanges);
53
+ },
54
+ template: '<div data-test-id="promotion-changes-stub">{{ reviewId }}</div>',
55
+ });
56
+
57
+ beforeEach(() => {
58
+ vi.clearAllMocks();
59
+ createTestingPinia();
60
+ vi.mocked(useToast).mockReturnValue({ showError } as unknown as ReturnType<typeof useToast>);
61
+ shownChanges = { status: 'ready', headCommitSha };
62
+ componentRegistry.register('promotion-changes', ChangesStub);
63
+ onTestFinished(() => componentRegistry.unregister('promotion-changes'));
64
+ });
65
+
66
+ async function approveInDialog({ getByRole, getByTestId }: ReturnType<typeof renderComponent>) {
67
+ await userEvent.click(getByTestId('inbox-promotion-review-approve'));
68
+ await userEvent.click(
69
+ within(getByRole('dialog')).getByRole('button', { name: 'Approve and merge' }),
70
+ );
71
+ }
72
+
73
+ it('shows the title, state, target, requester, and the link to GitLab', () => {
74
+ const { getByRole, getByText, queryByText } = renderComponent({
75
+ props: { item: item(), onItemChange },
76
+ });
77
+
78
+ expect(getByRole('heading', { name: 'Promote the thing' })).toBeInTheDocument();
79
+ expect(getByText('Open')).toBeInTheDocument();
80
+ expect(getByRole('link', { name: 'Open in GitLab' })).toHaveAttribute(
81
+ 'href',
82
+ 'https://gitlab.example.com/platform/workflows/-/merge_requests/1',
83
+ );
84
+ expect(getByText('To production')).toBeInTheDocument();
85
+ expect(getByText('Ada Lovelace')).toBeInTheDocument();
86
+ expect(queryByText('Reviewer')).not.toBeInTheDocument();
87
+ });
88
+
89
+ it('falls back to the branch name, explains why GitLab did not answer, and shows the approver', () => {
90
+ const { getByRole, getByTestId, getByText, queryByRole, queryByTestId } = renderComponent({
91
+ props: {
92
+ item: item({
93
+ state: 'closed',
94
+ reviewState: 'merged',
95
+ title: null,
96
+ webUrl: null,
97
+ remoteError: 'not_found',
98
+ approver: { id: 'u2', firstName: 'Grace', lastName: 'Hopper', email: 'grace@example.com' },
99
+ }),
100
+ onItemChange,
101
+ },
102
+ });
103
+
104
+ expect(getByRole('heading', { name: 'n8n-promotion/1' })).toBeInTheDocument();
105
+ expect(getByTestId('inbox-promotion-review-remote-error')).toHaveTextContent(
106
+ "GitLab can't find this merge request.",
107
+ );
108
+ expect(queryByRole('link', { name: 'Open in GitLab' })).not.toBeInTheDocument();
109
+ expect(getByText('Merged')).toBeInTheDocument();
110
+ expect(getByText('Grace Hopper')).toBeInTheDocument();
111
+ expect(queryByTestId('inbox-promotion-review-approve')).not.toBeInTheDocument();
112
+ });
113
+
114
+ it('approves the review the admin confirmed, and locks the dialog until GitLab answers', async () => {
115
+ const merged = createDeferredPromise<InboxPromotionReviewItem>();
116
+ vi.mocked(approvePromotionReview).mockReturnValue(merged.promise);
117
+ const { getByRole, getByTestId, queryByTestId, rerender } = renderComponent({
118
+ props: { item: item(), onItemChange },
119
+ });
120
+ const dialogButton = (name: string) => within(getByRole('dialog')).getByRole('button', { name });
121
+ expect(queryByTestId('inbox-promotion-review-approve-reason')).not.toBeInTheDocument();
122
+
123
+ await userEvent.click(getByTestId('inbox-promotion-review-approve'));
124
+ await userEvent.click(dialogButton('Cancel'));
125
+ expect(approvePromotionReview).not.toHaveBeenCalled();
126
+
127
+ await userEvent.click(getByTestId('inbox-promotion-review-approve'));
128
+ await rerender({ item: item({ id: 'rev-2', headCommitSha: 'b'.repeat(40) }), onItemChange });
129
+ await userEvent.click(dialogButton('Approve and merge'));
130
+
131
+ expect(approvePromotionReview).toHaveBeenCalledWith(expect.anything(), 'rev-1', {
132
+ headCommitSha,
133
+ });
134
+ expect(dialogButton('Cancel')).toBeDisabled();
135
+
136
+ merged.resolve(
137
+ item({ state: 'closed', reviewState: 'merged', updatedAt: '2026-01-02T00:00:00.000Z' }),
138
+ );
139
+ await waitFor(() =>
140
+ expect(onItemChange).toHaveBeenCalledWith({
141
+ type: 'promotion_review',
142
+ id: 'rev-1',
143
+ state: 'closed',
144
+ updatedAt: '2026-01-02T00:00:00.000Z',
145
+ }),
146
+ );
147
+ });
148
+
149
+ it.each([
150
+ [
151
+ 'a reviewer requested changes',
152
+ { reviewState: 'changes_requested' as const },
153
+ 'A reviewer requested changes. Resolve them on GitLab, then reload the Inbox.',
154
+ ],
155
+ [
156
+ 'the merge request has conflicts',
157
+ { hasConflicts: true },
158
+ 'The merge request has conflicts. Resolve them on GitLab.',
159
+ ],
160
+ [
161
+ 'GitLab did not answer',
162
+ { remoteError: 'unreachable' as const, headCommitSha: null },
163
+ 'GitLab unreachable',
164
+ ],
165
+ [
166
+ 'GitLab did not report the head commit',
167
+ { headCommitSha: null },
168
+ "GitLab didn't report the latest commit of this merge request. Reload the Inbox and try again.",
169
+ ],
170
+ ])('disables approve with the reason when %s', (_case, props, reason) => {
171
+ const { getByTestId } = renderComponent({ props: { item: item(props), onItemChange } });
172
+
173
+ const button = getByTestId('inbox-promotion-review-approve');
174
+ const text = getByTestId('inbox-promotion-review-approve-reason');
175
+ expect(button).toBeDisabled();
176
+ expect(text).toHaveTextContent(reason);
177
+ expect(button).toHaveAttribute('aria-describedby', text.id);
178
+ });
179
+
180
+ it('shows the error, reloads the list, and enables approve again when the approval fails', async () => {
181
+ const error = new Error('GitLab: 405 refused');
182
+ vi.mocked(approvePromotionReview).mockRejectedValue(error);
183
+ const rendered = renderComponent({ props: { item: item(), onItemChange } });
184
+
185
+ await approveInDialog(rendered);
186
+
187
+ expect(showError).toHaveBeenCalledWith(error, "Couldn't approve and merge");
188
+ expect(onItemChange).toHaveBeenCalledWith({ type: 'promotion_review', id: 'rev-1' });
189
+ expect(rendered.getByTestId('inbox-promotion-review-approve')).toBeEnabled();
190
+ });
191
+
192
+ it('shows the changes of the review from the shell', () => {
193
+ const { getByRole, getByTestId } = renderComponent({ props: { item: item(), onItemChange } });
194
+
195
+ expect(getByRole('tab', { name: 'Changes' })).toBeInTheDocument();
196
+ expect(getByTestId('promotion-changes-stub')).toHaveTextContent('rev-1');
197
+ });
198
+
199
+ it.each([
200
+ ['the changes still load', { status: 'loading' } as const, null],
201
+ [
202
+ 'the changes could not be shown',
203
+ { status: 'unavailable' } as const,
204
+ "n8n couldn't show the changes of this review. Review and merge it on GitLab.",
205
+ ],
206
+ ])('only approves changes that the admin saw: disabled when %s', async (_case, state, reason) => {
207
+ shownChanges = state;
208
+
209
+ const { getByTestId, findByTestId, queryByTestId } = renderComponent({
210
+ props: { item: item(), onItemChange },
211
+ });
212
+
213
+ if (reason) {
214
+ expect(await findByTestId('inbox-promotion-review-approve-reason')).toHaveTextContent(reason);
215
+ } else {
216
+ expect(queryByTestId('inbox-promotion-review-approve-reason')).not.toBeInTheDocument();
217
+ }
218
+ expect(getByTestId('inbox-promotion-review-approve')).toBeDisabled();
219
+ });
220
+
221
+ it('reloads the changes when GitLab reports new commits, then approves the new head', async () => {
222
+ const newHead = 'b'.repeat(40);
223
+ const rendered = renderComponent({
224
+ props: { item: item({ headCommitSha: newHead }), onItemChange },
225
+ });
226
+ const { getByTestId, findByTestId, getByText } = rendered;
227
+
228
+ expect(await findByTestId('inbox-promotion-review-new-commits')).toBeInTheDocument();
229
+ const approve = getByTestId('inbox-promotion-review-approve');
230
+ expect(approve).toBeDisabled();
231
+ // The callout above the changes explains why.
232
+ expect(approve).toHaveAttribute(
233
+ 'aria-describedby',
234
+ getByText('The merge request has new commits since these changes loaded.').id,
235
+ );
236
+
237
+ shownChanges = { status: 'ready', headCommitSha: newHead };
238
+ await userEvent.click(getByTestId('inbox-promotion-review-reload-changes'));
239
+ await approveInDialog(rendered);
240
+
241
+ expect(approvePromotionReview).toHaveBeenCalledWith(expect.anything(), 'rev-1', {
242
+ headCommitSha: newHead,
243
+ });
244
+ });
@@ -0,0 +1,395 @@
1
+ <script setup lang="ts">
2
+ import type { InboxPromotionReviewItem, PromotionReviewState } from '@n8n/api-types';
3
+ import { useToast } from '@n8n/composables/useToast';
4
+ import {
5
+ N8nAvatar,
6
+ N8nBadge,
7
+ N8nButton,
8
+ N8nCallout,
9
+ N8nDialog,
10
+ N8nDialogDescription,
11
+ N8nDialogFooter,
12
+ N8nDialogHeader,
13
+ N8nDialogTitle,
14
+ N8nExternalLink,
15
+ N8nHeading,
16
+ N8nTabs,
17
+ N8nText,
18
+ type BadgeVariant,
19
+ } from '@n8n/design-system';
20
+ import { componentRegistry, type PromotionChangesState } from '@n8n/frontend-module-sdk';
21
+ import { useI18n } from '@n8n/i18n';
22
+ import { useRootStore } from '@n8n/stores/useRootStore';
23
+ import { computed, ref, useId, watch } from 'vue';
24
+
25
+ import type { InboxItemChange } from '../inbox.constants';
26
+ import { formatUserDisplayName, toError } from '../reviews/workflowReviews.utils';
27
+ import { approvePromotionReview } from './promotionReviews.api';
28
+
29
+ const props = defineProps<{
30
+ item: InboxPromotionReviewItem;
31
+ onItemChange: (change: InboxItemChange) => void;
32
+ }>();
33
+ const i18n = useI18n();
34
+ const { showError } = useToast();
35
+ const rootStore = useRootStore();
36
+ const disabledReasonId = useId();
37
+ const newCommitsId = useId();
38
+
39
+ const stateBadge: Record<PromotionReviewState, BadgeVariant> = {
40
+ open: 'info',
41
+ changes_requested: 'warning',
42
+ merged: 'success',
43
+ closed: 'subtle',
44
+ };
45
+
46
+ // The editor renders the changes.
47
+ const PromotionChanges = computed(() => componentRegistry.get('promotion-changes'));
48
+ // One tab for now. The bar matches workflow reviews, which also have an Activity tab.
49
+ const tabOptions = computed(() => [
50
+ { label: i18n.baseText('inbox.promotionReview.detail.tabs.changes'), value: 'changes' as const },
51
+ ]);
52
+
53
+ // Approve sends the head of the changes that the reviewer saw. A list refresh can report a newer
54
+ // head, which the reviewer did not see.
55
+ const changes = ref<PromotionChangesState>({ status: 'loading' });
56
+ watch(
57
+ () => props.item.id,
58
+ () => (changes.value = { status: 'loading' }),
59
+ );
60
+
61
+ const hasNewCommits = computed(
62
+ () =>
63
+ changes.value.status === 'ready' &&
64
+ !!props.item.headCommitSha &&
65
+ changes.value.headCommitSha !== props.item.headCommitSha,
66
+ );
67
+ // A new key mounts the Changes tab again, so it loads the newest head.
68
+ const changesKey = ref(0);
69
+
70
+ /** What the admin confirms. A later change of the selected row does not change it. */
71
+ const pendingApproval = ref<{ id: string; headCommitSha: string } | null>(null);
72
+ const approving = ref(false);
73
+
74
+ const disabledReason = computed(() => {
75
+ const { item } = props;
76
+ if (item.reviewState === 'changes_requested') {
77
+ return i18n.baseText('inbox.promotionReview.approve.disabled.changesRequested');
78
+ }
79
+ if (item.hasConflicts) return i18n.baseText('inbox.promotionReview.approve.disabled.conflicts');
80
+ if (item.remoteError) {
81
+ return i18n.baseText(`inbox.promotionReview.remoteError.${item.remoteError}`);
82
+ }
83
+ if (!item.headCommitSha) {
84
+ return i18n.baseText('inbox.promotionReview.approve.disabled.noHeadCommit');
85
+ }
86
+ if (changes.value.status === 'unavailable') {
87
+ return i18n.baseText('inbox.promotionReview.approve.disabled.changesUnavailable');
88
+ }
89
+ return null;
90
+ });
91
+ // Loading and new commits show no reason under Approve: loading is short, and the new commits
92
+ // callout above the changes explains itself. Screen readers get that callout as the reason.
93
+ const approveDisabled = computed(
94
+ () => !!disabledReason.value || hasNewCommits.value || changes.value.status !== 'ready',
95
+ );
96
+ const approveDescribedBy = computed(() => {
97
+ if (disabledReason.value) return disabledReasonId;
98
+ return hasNewCommits.value ? newCommitsId : undefined;
99
+ });
100
+
101
+ function openApproval() {
102
+ if (changes.value.status !== 'ready') return;
103
+ pendingApproval.value = { id: props.item.id, headCommitSha: changes.value.headCommitSha };
104
+ }
105
+
106
+ // A close cannot stop GitLab, so the dialog stays open until the request ends.
107
+ function closeApproval() {
108
+ if (!approving.value) pendingApproval.value = null;
109
+ }
110
+
111
+ async function onApprove() {
112
+ if (!pendingApproval.value || approving.value) return;
113
+ const { id, headCommitSha } = pendingApproval.value;
114
+ // The Inbox can reconcile this result after the viewer selects another item.
115
+ const onItemChange = props.onItemChange;
116
+ approving.value = true;
117
+ try {
118
+ const { state, updatedAt } = await approvePromotionReview(rootStore.restApiContext, id, {
119
+ headCommitSha,
120
+ });
121
+ onItemChange({ type: 'promotion_review', id, state, updatedAt });
122
+ } catch (error: unknown) {
123
+ showError(toError(error), i18n.baseText('inbox.promotionReview.approve.error.title'));
124
+ // Reload the list, so the next attempt uses the current state from GitLab.
125
+ onItemChange({ type: 'promotion_review', id });
126
+ } finally {
127
+ approving.value = false;
128
+ pendingApproval.value = null;
129
+ }
130
+ }
131
+ </script>
132
+
133
+ <template>
134
+ <section :class="$style.detail" data-test-id="inbox-promotion-review-detail">
135
+ <header :class="[$style.header, $style.gutter]">
136
+ <div :class="$style.titleRow">
137
+ <!-- Inline, so the state tag follows the last word of a title that wraps. -->
138
+ <div :class="$style.title">
139
+ <N8nHeading tag="h2" size="xlarge" bold :class="$style.titleText">
140
+ {{ item.title ?? item.branchName ?? i18n.baseText('inbox.promotionReview.untitled') }}
141
+ </N8nHeading>
142
+ <N8nBadge
143
+ :variant="stateBadge[item.reviewState]"
144
+ size="xxsmall"
145
+ :class="$style.stateBadge"
146
+ >
147
+ {{ i18n.baseText(`inbox.promotionReview.state.${item.reviewState}`) }}
148
+ </N8nBadge>
149
+ </div>
150
+ <N8nExternalLink
151
+ v-if="item.webUrl"
152
+ :class="$style.gitlabLink"
153
+ :href="item.webUrl"
154
+ size="small"
155
+ >
156
+ {{ i18n.baseText('inbox.promotionReview.detail.openInGitLab') }}
157
+ </N8nExternalLink>
158
+ </div>
159
+ <div :class="$style.meta">
160
+ <span v-if="item.environmentName" :class="$style.metaItem">
161
+ <N8nText size="small" color="text-light">
162
+ {{ i18n.baseText('inbox.promotionReview.detail.promoteTo') }}
163
+ </N8nText>
164
+ <N8nText size="small" bold>{{ item.environmentName }}</N8nText>
165
+ </span>
166
+ <span v-if="item.approver" :class="$style.metaItem">
167
+ <N8nText size="small" color="text-light">
168
+ {{ i18n.baseText('inbox.promotionReview.detail.reviewer') }}
169
+ </N8nText>
170
+ <N8nAvatar
171
+ :first-name="item.approver.firstName"
172
+ :last-name="item.approver.lastName"
173
+ size="xsmall"
174
+ />
175
+ <N8nText size="small">{{ formatUserDisplayName(item.approver) }}</N8nText>
176
+ </span>
177
+ <span v-if="item.requester" :class="$style.metaItem">
178
+ <N8nText size="small" color="text-light">
179
+ {{ i18n.baseText('inbox.promotionReview.detail.requestedBy') }}
180
+ </N8nText>
181
+ <N8nAvatar
182
+ :first-name="item.requester.firstName"
183
+ :last-name="item.requester.lastName"
184
+ size="xsmall"
185
+ />
186
+ <N8nText size="small">{{ formatUserDisplayName(item.requester) }}</N8nText>
187
+ </span>
188
+ </div>
189
+ </header>
190
+ <N8nCallout
191
+ v-if="item.remoteError"
192
+ :class="$style.gutter"
193
+ theme="warning"
194
+ data-test-id="inbox-promotion-review-remote-error"
195
+ >
196
+ {{ i18n.baseText(`inbox.promotionReview.detail.remoteError.${item.remoteError}`) }}
197
+ </N8nCallout>
198
+ <div :class="[$style.tabRow, $style.gutter]">
199
+ <N8nTabs
200
+ model-value="changes"
201
+ :options="tabOptions"
202
+ variant="modern"
203
+ data-test-id="inbox-promotion-review-tab-bar"
204
+ />
205
+ <!-- In the tab row, like the Review button of workflow reviews. -->
206
+ <div v-if="item.state === 'open'" :class="$style.approve">
207
+ <!-- Visible text, not a tooltip: a disabled button cannot take keyboard focus. -->
208
+ <N8nText
209
+ v-if="disabledReason"
210
+ :id="disabledReasonId"
211
+ size="small"
212
+ color="text-light"
213
+ data-test-id="inbox-promotion-review-approve-reason"
214
+ >
215
+ {{ disabledReason }}
216
+ </N8nText>
217
+ <N8nButton
218
+ size="small"
219
+ :disabled="approveDisabled"
220
+ :loading="approving"
221
+ :aria-describedby="approveDescribedBy"
222
+ data-test-id="inbox-promotion-review-approve"
223
+ @click="openApproval"
224
+ >
225
+ {{ i18n.baseText('inbox.promotionReview.approve.button') }}
226
+ </N8nButton>
227
+ </div>
228
+ </div>
229
+ <div :class="$style.changesArea">
230
+ <N8nCallout
231
+ v-if="hasNewCommits"
232
+ theme="warning"
233
+ slim
234
+ :class="[$style.newCommits, $style.gutter]"
235
+ data-test-id="inbox-promotion-review-new-commits"
236
+ >
237
+ <span :id="newCommitsId">{{
238
+ i18n.baseText('inbox.promotionReview.changes.newCommits')
239
+ }}</span>
240
+ <template #trailingContent>
241
+ <N8nButton
242
+ variant="outline"
243
+ size="small"
244
+ data-test-id="inbox-promotion-review-reload-changes"
245
+ @click="changesKey++"
246
+ >
247
+ {{ i18n.baseText('inbox.promotionReview.changes.reload') }}
248
+ </N8nButton>
249
+ </template>
250
+ </N8nCallout>
251
+ <component
252
+ :is="PromotionChanges"
253
+ :key="changesKey"
254
+ :class="$style.changes"
255
+ :review-id="item.id"
256
+ @state="changes = $event"
257
+ />
258
+ </div>
259
+ <N8nDialog
260
+ :open="!!pendingApproval"
261
+ size="small"
262
+ :show-close-button="false"
263
+ :close-on-overlay-click="!approving"
264
+ :close-on-escape="!approving"
265
+ @update:open="closeApproval"
266
+ >
267
+ <N8nDialogHeader>
268
+ <N8nDialogTitle>
269
+ {{ i18n.baseText('inbox.promotionReview.approve.confirm.title') }}
270
+ </N8nDialogTitle>
271
+ <N8nDialogDescription>
272
+ {{ i18n.baseText('inbox.promotionReview.approve.confirm.description') }}
273
+ </N8nDialogDescription>
274
+ </N8nDialogHeader>
275
+ <N8nDialogFooter>
276
+ <N8nButton
277
+ variant="outline"
278
+ :label="i18n.baseText('generic.cancel')"
279
+ :disabled="approving"
280
+ @click="closeApproval"
281
+ />
282
+ <N8nButton
283
+ :label="i18n.baseText('inbox.promotionReview.approve.button')"
284
+ :loading="approving"
285
+ @click="onApprove"
286
+ />
287
+ </N8nDialogFooter>
288
+ </N8nDialog>
289
+ </section>
290
+ </template>
291
+
292
+ <style module lang="scss">
293
+ .detail {
294
+ display: flex;
295
+ flex: 1;
296
+ flex-direction: column;
297
+ gap: var(--spacing--md);
298
+ min-height: 0;
299
+ }
300
+
301
+ .tabRow {
302
+ display: flex;
303
+ align-items: center;
304
+ justify-content: space-between;
305
+ gap: var(--spacing--sm);
306
+ /* Holds the active-tab indicator, so the border sits under it. */
307
+ padding-bottom: var(--review-tab-bar--indicator-overhang);
308
+ border-bottom: var(--border);
309
+
310
+ > :global(.n8n-tabs) {
311
+ transform: translateY(var(--spacing--sm));
312
+ }
313
+ }
314
+
315
+ .changesArea {
316
+ display: flex;
317
+ flex: 1;
318
+ flex-direction: column;
319
+ min-height: 0;
320
+ /* The tab row border already separates the changes from the header. */
321
+ margin-top: calc(-1 * var(--spacing--md));
322
+ }
323
+
324
+ .newCommits {
325
+ /* The same space as the warnings of the changes. */
326
+ margin-top: var(--spacing--sm);
327
+ }
328
+
329
+ .changes {
330
+ flex: 1;
331
+ min-height: 0;
332
+ }
333
+
334
+ /* The right gutter of workflow reviews, so Approve sits where their Review button does. */
335
+ .gutter {
336
+ margin-right: var(--spacing--md);
337
+ }
338
+
339
+ .header {
340
+ display: flex;
341
+ flex-direction: column;
342
+ gap: var(--spacing--xs);
343
+ }
344
+
345
+ .titleRow {
346
+ display: flex;
347
+ align-items: center;
348
+ gap: var(--spacing--xs);
349
+ min-width: 0;
350
+ /* Matches the title row of workflow reviews, so both headings sit at the same height. */
351
+ min-height: var(--spacing--xl);
352
+ }
353
+
354
+ .title {
355
+ flex: 1;
356
+ min-width: 0;
357
+ /* The font of the heading, so the tag lines up with the title text. */
358
+ font-size: var(--font-size--xl);
359
+ line-height: var(--line-height--sm);
360
+ }
361
+
362
+ .titleText {
363
+ display: inline;
364
+ }
365
+
366
+ .stateBadge {
367
+ margin-left: var(--spacing--xs);
368
+ vertical-align: middle;
369
+ }
370
+
371
+ .approve {
372
+ display: flex;
373
+ flex-shrink: 0;
374
+ align-items: center;
375
+ gap: var(--spacing--xs);
376
+ }
377
+
378
+ .gitlabLink {
379
+ /* A standalone external link takes the font size around it. The text next to it is smaller. */
380
+ font-size: var(--font-size--sm);
381
+ white-space: nowrap;
382
+ }
383
+
384
+ .meta {
385
+ display: flex;
386
+ flex-wrap: wrap;
387
+ gap: var(--spacing--2xs) var(--spacing--lg);
388
+ }
389
+
390
+ .metaItem {
391
+ display: inline-flex;
392
+ align-items: center;
393
+ gap: var(--spacing--3xs);
394
+ }
395
+ </style>
@@ -0,0 +1,15 @@
1
+ import type { ApprovePromotionReviewDto, InboxPromotionReviewItem } from '@n8n/api-types';
2
+ import { makeRestApiRequest, type IRestApiContext } from '@n8n/rest-api-client';
3
+
4
+ export async function approvePromotionReview(
5
+ context: IRestApiContext,
6
+ reviewId: string,
7
+ payload: ApprovePromotionReviewDto,
8
+ ): Promise<InboxPromotionReviewItem> {
9
+ return await makeRestApiRequest<InboxPromotionReviewItem>(
10
+ context,
11
+ 'POST',
12
+ `/promotions/reviews/${encodeURIComponent(reviewId)}/approve`,
13
+ { ...payload },
14
+ );
15
+ }