feat(editor): Add UI to select workflow changes to promote (no-change… (#37530)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sandra Zollner 2026-09-04 13:06:06 +00:00 committed by GitHub
parent be678fcb69
commit 90d176b969
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1182 additions and 4 deletions

View File

@ -435,3 +435,14 @@ export { UpdateOtelSettingsDto } from './otel/update-otel-settings.dto';
export { TestOtelTraceDto } from './otel/test-otel-trace.dto';
export { InstanceAiExamplesQueryDto } from './instance-ai-examples/instance-ai-examples-query.dto';
export {
promotableResourceSchema,
promotableResourceStatusSchema,
promotableResourceTypeSchema,
promoteRequestSchema,
type PromotableResource,
type PromotableResourceStatus,
type PromotableResourceType,
type PromoteRequest,
} from './promotions/promotable-resource.dto';

View File

@ -0,0 +1,29 @@
import { z } from 'zod';
export const promotableResourceStatusSchema = z.enum(['new', 'modified', 'archived', 'deleted']);
export type PromotableResourceStatus = z.infer<typeof promotableResourceStatusSchema>;
export const promotableResourceTypeSchema = z.enum(['workflow']);
export type PromotableResourceType = z.infer<typeof promotableResourceTypeSchema>;
export const promotableResourceSchema = z.object({
id: z.string(),
name: z.string(),
type: promotableResourceTypeSchema,
status: promotableResourceStatusSchema,
version: z.number().int().nonnegative().nullable(),
updatedAt: z.string(),
updatedBy: z.string().nullable(),
dependencyCount: z.number().int().nonnegative(),
});
export type PromotableResource = z.infer<typeof promotableResourceSchema>;
export const promoteRequestSchema = z.object({
workflowIds: z.array(z.string().min(1)).min(1),
createBranch: z.boolean(),
});
export type PromoteRequest = z.infer<typeof promoteRequestSchema>;

View File

@ -8387,5 +8387,31 @@
"instanceAi.workflowSetup.stepIncompleteTooltip": "To continue, complete the required fields above or skip setup for now",
"agents.chat.askCredential.skipped": "Skipped",
"agents.chat.askQuestions.skipped": "Skipped",
"agents.chat.configureChannel.skipped": "Skipped"
"agents.chat.configureChannel.skipped": "Skipped",
"promotions.modal.title": "Promote changes",
"promotions.modal.search.placeholder": "Search changes...",
"promotions.modal.refresh": "Refresh",
"promotions.modal.status.new": "New",
"promotions.modal.status.modified": "Modified",
"promotions.modal.status.archived": "Will be archived",
"promotions.modal.status.deleted": "Will be deleted",
"promotions.modal.dependencies": "{count} dependencies",
"promotions.modal.dependency": "1 dependency",
"promotions.modal.noDependencies": "No dependencies",
"promotions.modal.changedBy": "Changed by {name}",
"promotions.modal.empty": "Nothing to promote",
"promotions.modal.empty.description": "All workflows in this project are up to date.",
"promotions.modal.noResults": "No changes match your search.",
"promotions.modal.error": "Could not load changes",
"promotions.modal.error.description": "Something went wrong while loading changes.",
"promotions.modal.retry": "Retry",
"promotions.modal.createBranch": "Create branch",
"promotions.modal.close": "Close",
"promotions.modal.promote": "Promote {count} changes",
"promotions.modal.promoteSingle": "Promote 1 change",
"promotions.modal.promoteSuccess": "Changes promoted successfully",
"promotions.modal.promoteError": "Failed to promote changes",
"promotions.banner.changesAvailable": "{count} changes available to promote.",
"promotions.banner.singleChangeAvailable": "1 change available to promote.",
"promotions.banner.viewChanges": "View changes"
}

View File

@ -8,6 +8,7 @@ import { AgentsModule } from '@/features/agents/module.descriptor';
import { WorkflowReviewsModule } from '@/features/workflow-reviews/module.descriptor';
import { InstanceRegistryModule } from '@n8n/frontend-module-instance-registry';
import { OtelModule } from '@n8n/frontend-module-otel';
import { PromotionsModule } from '@/features/integrations/promotions.ee/module.descriptor';
/**
* Hard-coding modules list until we have a dynamic way to load modules.
@ -22,4 +23,5 @@ export const modules: FrontendModuleDescription[] = [
OtelModule,
WorkflowReviewsModule,
InstanceRegistryModule,
PromotionsModule,
];

View File

@ -516,7 +516,7 @@ describe('ProjectHeader', () => {
}),
null,
);
expect(settingsStore.isModuleActive).toHaveBeenCalledTimes(4);
expect(settingsStore.isModuleActive).toHaveBeenCalledTimes(5);
});
it('should pass empty array when no modules are active', () => {

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed, ref, watch, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useElementSize, useResizeObserver } from '@vueuse/core';
import type { TabOptions, UserAction } from '@n8n/design-system';
@ -12,6 +12,7 @@ import { getResourcePermissions } from '@n8n/permissions';
import { EnterpriseEditionFeature, VIEWS } from '@/app/constants';
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
import ProjectCreateResource from './ProjectCreateResource.vue';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { useProjectPages } from '@/features/collaboration/projects/composables/useProjectPages';
import { truncateTextToFitWidth } from '@/app/utils/formatters/textFormatter';
@ -24,8 +25,19 @@ import { instanceAiCreateAgentRoute } from '@/features/ai/instanceAi/createAgent
import { generateNanoId } from '@n8n/utils/generate-nano-id';
import { useAgentPermissions } from '@/features/agents/composables/useAgentPermissions';
import ReadyToRunButton from '@/features/workflows/readyToRun/components/ReadyToRunButton.vue';
import { usePromotionsEnabled } from '@/features/shared/promotions/usePromotionsEnabled';
import { PROMOTION_SELECT_MODAL_KEY } from '@/features/integrations/promotions.ee/promotions.constants';
import { getPromotableChanges } from '@/features/integrations/promotions.ee/promotions.api';
import { N8nButton, N8nHeading, N8nIconButton, N8nText, N8nTooltip } from '@n8n/design-system';
import {
N8nButton,
N8nHeading,
N8nIcon,
N8nIconButton,
N8nLink,
N8nText,
N8nTooltip,
} from '@n8n/design-system';
import { VARIABLE_MODAL_KEY } from '@/features/settings/environments.ee/environments.constants';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { useAgentTelemetry } from '@/features/agents/composables/useAgentTelemetry';
@ -43,11 +55,34 @@ const telemetry = useTelemetry();
const agentTelemetry = useAgentTelemetry();
const usersStore = useUsersStore();
const favoritesStore = useFavoritesStore();
const { isEnabled: isPromotionsEnabled } = usePromotionsEnabled();
const rootStore = useRootStore();
const currentProjectId = computed(() => projectsStore.currentProject?.id);
const isTeamProject = computed(() => projectsStore.currentProject?.type === ProjectTypes.Team);
const promotableChangeCount = ref(0);
const showPromoteButton = computed(() => isPromotionsEnabled.value && isTeamProject.value);
async function fetchPromotableChangeCount() {
// Capture the project this request is for, so a slow response for a project the
// user already navigated away from cannot overwrite the current count.
const requestedProjectId = currentProjectId.value;
promotableChangeCount.value = 0;
if (!showPromoteButton.value || !requestedProjectId) {
return;
}
try {
const changes = await getPromotableChanges(rootStore.restApiContext, requestedProjectId);
if (currentProjectId.value !== requestedProjectId) return;
promotableChangeCount.value = changes.length;
} catch {
if (currentProjectId.value !== requestedProjectId) return;
promotableChangeCount.value = 0;
}
}
const isProjectFavorited = computed(() =>
currentProjectId.value ? favoritesStore.isFavorite(currentProjectId.value, 'project') : false,
);
@ -466,6 +501,30 @@ const projectDescriptionTruncated = computed(() => {
return truncateTextToFitWidth(projectDescription.value, availableTextWidth, fontSizeInPixels);
});
const promotionBannerText = computed(() => {
if (promotableChangeCount.value === 1) {
return i18n.baseText('promotions.banner.singleChangeAvailable');
}
return i18n.baseText('promotions.banner.changesAvailable', {
interpolate: { count: String(promotableChangeCount.value) },
});
});
watch(currentProjectId, () => {
fetchPromotableChangeCount().catch(() => {});
});
onMounted(() => {
fetchPromotableChangeCount().catch(() => {});
});
function onOpenPromotionModal() {
if (!currentProjectId.value) return;
uiStore.openModalWithData({
name: PROMOTION_SELECT_MODAL_KEY,
data: { projectId: currentProjectId.value },
});
}
const onSelect = (action: string, source: CreateSource) => {
const executableAction = actions[action as ActionTypes];
if (!homeProject.value) {
@ -547,6 +606,19 @@ const onSelect = (action: string, source: CreateSource) => {
:additional-tabs="customProjectTabs"
/>
</div>
<div
v-if="showPromoteButton && promotableChangeCount > 0"
:class="$style.promotionBanner"
data-test-id="promotion-banner"
>
<N8nIcon icon="upload" size="small" />
<N8nText size="small">
{{ promotionBannerText }}
</N8nText>
<N8nLink size="small" data-test-id="promotion-banner-link" @click="onOpenPromotionModal">
{{ i18n.baseText('promotions.banner.viewChanges') }}
</N8nLink>
</div>
</div>
</template>
@ -567,6 +639,17 @@ const onSelect = (action: string, source: CreateSource) => {
padding: var(--spacing--2xs) 0 var(--spacing--xs);
}
.promotionBanner {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
padding: var(--spacing--xs) var(--spacing--sm);
background-color: var(--background--hover);
border: var(--border);
border-radius: var(--radius--2xs);
margin-bottom: var(--spacing--xs);
}
.projectDescriptionWrapper {
position: relative;
display: inline-block;

View File

@ -0,0 +1,237 @@
import { createTestingPinia } from '@pinia/testing';
import { createComponentRenderer } from '@/__tests__/render';
import PromotionSelectModal from './PromotionSelectModal.vue';
import { PROMOTION_SELECT_MODAL_KEY } from '../promotions.constants';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import * as promotionsApi from '../promotions.api';
import { useUsersStore } from '@n8n/stores/users.store';
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/vue';
vi.mock('../promotions.api');
const mockChanges = [
{
id: 'wf-001',
name: 'Email summary',
type: 'workflow' as const,
status: 'modified' as const,
version: 14,
updatedAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
updatedBy: 'user-001',
dependencyCount: 7,
},
{
id: 'wf-002',
name: 'Payment Handler',
type: 'workflow' as const,
status: 'new' as const,
version: 1,
updatedAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(),
updatedBy: 'user-002',
dependencyCount: 2,
},
];
const renderComponent = createComponentRenderer(PromotionSelectModal, {
global: {
stubs: {
Modal: {
template: `
<div>
<slot name="content" />
<slot name="footer" />
</div>
`,
},
},
},
});
describe('PromotionSelectModal', () => {
let pinia: ReturnType<typeof createTestingPinia>;
beforeEach(() => {
pinia = createTestingPinia();
vi.clearAllMocks();
vi.mocked(promotionsApi.getPromotableChanges).mockResolvedValue(mockChanges);
vi.mocked(promotionsApi.promoteChanges).mockResolvedValue({ branchName: 'promote/test' });
const usersStore = useUsersStore();
usersStore.usersById = {
'user-001': { id: 'user-001', firstName: 'Fabian', lastName: 'Mueller' },
'user-002': { id: 'user-002', firstName: 'Sandra', lastName: 'Zollner' },
} as unknown as typeof usersStore.usersById;
});
it('should render change list after loading', async () => {
const { getByText } = renderComponent({
pinia,
props: {
modalName: PROMOTION_SELECT_MODAL_KEY,
data: { projectId: 'project-1' },
},
});
await waitFor(() => {
expect(getByText('Email summary')).toBeInTheDocument();
expect(getByText('Payment Handler')).toBeInTheDocument();
});
});
it('should disable promote button when nothing selected', async () => {
const { findByTestId } = renderComponent({
pinia,
props: {
modalName: PROMOTION_SELECT_MODAL_KEY,
data: { projectId: 'project-1' },
},
});
const submitButton = await findByTestId('promotion-submit');
expect(submitButton).toBeDisabled();
});
it('should enable promote button after selecting a change', async () => {
const { findAllByTestId, findByTestId } = renderComponent({
pinia,
props: {
modalName: PROMOTION_SELECT_MODAL_KEY,
data: { projectId: 'project-1' },
},
});
const rows = await findAllByTestId('promotion-change-row');
await userEvent.click(rows[0]);
const submitButton = await findByTestId('promotion-submit');
expect(submitButton).not.toBeDisabled();
});
it('should show changed by name', async () => {
const { getAllByTestId } = renderComponent({
pinia,
props: {
modalName: PROMOTION_SELECT_MODAL_KEY,
data: { projectId: 'project-1' },
},
});
await waitFor(() => {
const rows = getAllByTestId('promotion-change-row');
expect(rows[0].textContent).toContain('Changed by Fabian Mueller');
expect(rows[1].textContent).toContain('Changed by Sandra Zollner');
});
});
it('should show empty state when no changes', async () => {
vi.mocked(promotionsApi.getPromotableChanges).mockResolvedValue([]);
const { getByText } = renderComponent({
pinia,
props: {
modalName: PROMOTION_SELECT_MODAL_KEY,
data: { projectId: 'project-1' },
},
});
await waitFor(() => {
expect(getByText('Nothing to promote')).toBeInTheDocument();
});
});
it('should select every row when clicking select all', async () => {
const { findByTestId } = renderComponent({
pinia,
props: {
modalName: PROMOTION_SELECT_MODAL_KEY,
data: { projectId: 'project-1' },
},
});
const selectAll = await findByTestId('promotion-select-all');
await userEvent.click(selectAll);
const submitButton = await findByTestId('promotion-submit');
expect(submitButton).not.toBeDisabled();
// The label reports every mock row, proving select-all covers the whole list.
expect(submitButton.textContent).toContain('Promote 2 changes');
});
it('should show an error state with a retry action when loading fails', async () => {
vi.mocked(promotionsApi.getPromotableChanges).mockRejectedValueOnce(new Error('Network error'));
const { findByTestId, getByText } = renderComponent({
pinia,
props: {
modalName: PROMOTION_SELECT_MODAL_KEY,
data: { projectId: 'project-1' },
},
});
await findByTestId('promotion-error');
expect(getByText('Could not load changes')).toBeInTheDocument();
// Retry succeeds (default mock) and the list replaces the error state.
await userEvent.click(await findByTestId('promotion-retry'));
await waitFor(() => {
expect(getByText('Email summary')).toBeInTheDocument();
});
});
it('should show a no-results state when the search excludes every change', async () => {
const { findByTestId, getByTestId } = renderComponent({
pinia,
props: {
modalName: PROMOTION_SELECT_MODAL_KEY,
data: { projectId: 'project-1' },
},
});
const search = await findByTestId('promotion-search');
await userEvent.type(search, 'no-such-workflow');
await waitFor(() => {
expect(getByTestId('promotion-no-results')).toBeInTheDocument();
});
});
it('should show distinct labels for archived and deleted workflows', async () => {
vi.mocked(promotionsApi.getPromotableChanges).mockResolvedValue([
{
id: 'wf-archived',
name: 'Archived workflow',
type: 'workflow',
status: 'archived',
version: 3,
updatedAt: new Date().toISOString(),
updatedBy: null,
dependencyCount: 0,
},
{
id: 'wf-deleted',
name: 'Deleted workflow',
type: 'workflow',
status: 'deleted',
version: null,
updatedAt: new Date().toISOString(),
updatedBy: null,
dependencyCount: 0,
},
]);
const { getAllByTestId } = renderComponent({
pinia,
props: {
modalName: PROMOTION_SELECT_MODAL_KEY,
data: { projectId: 'project-1' },
},
});
await waitFor(() => {
const statuses = getAllByTestId('promotion-change-status').map((el) => el.textContent);
expect(statuses).toEqual(['Will be archived', 'Will be deleted']);
});
});
});

View File

@ -0,0 +1,467 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@n8n/composables/useToast';
import { useUIStore } from '@/app/stores/ui.store';
import { useUsersStore } from '@n8n/stores/users.store';
import { createEventBus } from '@n8n/utils/event-bus';
import Modal from '@/app/components/Modal.vue';
import TimeAgo from '@/app/components/TimeAgo.vue';
import { N8nButton, N8nCheckbox, N8nInput, N8nSwitch, N8nText } from '@n8n/design-system';
import type { PromotableResourceStatus } from '@n8n/api-types';
import { usePromotionChanges } from '../composables/usePromotionChanges';
interface Props {
modalName: string;
data: {
projectId: string;
};
}
const props = defineProps<Props>();
const i18n = useI18n();
const toast = useToast();
const uiStore = useUIStore();
const usersStore = useUsersStore();
const modalBus = createEventBus();
const createBranch = ref(false);
const isPromoting = ref(false);
const {
changes,
filteredChanges,
isLoading,
error,
searchQuery,
selectedIds,
selectedCount,
allSelected,
someSelected,
fetchChanges,
toggleSelected,
toggleSelectAll,
promote,
} = usePromotionChanges(props.data.projectId);
// No visible rows despite a loaded, non-empty change set means the search excluded everything.
const hasNoSearchResults = computed(
() =>
!isLoading.value &&
!error.value &&
changes.value.length > 0 &&
filteredChanges.value.length === 0,
);
function getStatusLabel(status: PromotableResourceStatus): string {
return i18n.baseText(`promotions.modal.status.${status}`);
}
function getDependencyLabel(count: number): string {
if (count === 0) return i18n.baseText('promotions.modal.noDependencies');
if (count === 1) return i18n.baseText('promotions.modal.dependency');
return i18n.baseText('promotions.modal.dependencies', { interpolate: { count: String(count) } });
}
function resolveUserName(userId: string | null): string | null {
if (!userId) return null;
const user = usersStore.usersById[userId];
if (!user) return null;
return [user.firstName, user.lastName].filter(Boolean).join(' ') || null;
}
function getChangedByLabel(userId: string | null): string {
const name = resolveUserName(userId);
if (!name) return '';
return i18n.baseText('promotions.modal.changedBy', { interpolate: { name } });
}
function getPromoteButtonLabel(): string {
if (selectedCount.value === 1) {
return i18n.baseText('promotions.modal.promoteSingle');
}
return i18n.baseText('promotions.modal.promote', {
interpolate: { count: String(selectedCount.value) },
});
}
function isSelected(id: string): boolean {
return selectedIds.value.has(id);
}
async function onPromote() {
isPromoting.value = true;
try {
await promote(createBranch.value);
toast.showMessage({
title: i18n.baseText('promotions.modal.promoteSuccess'),
type: 'success',
});
uiStore.closeModal(props.modalName);
} catch (e) {
toast.showError(
e instanceof Error ? e : new Error(String(e)),
i18n.baseText('promotions.modal.promoteError'),
);
} finally {
isPromoting.value = false;
}
}
function onClose() {
uiStore.closeModal(props.modalName);
}
async function onRefresh() {
await fetchChanges();
}
onMounted(async () => {
await fetchChanges();
});
</script>
<template>
<Modal
:name="modalName"
:title="i18n.baseText('promotions.modal.title')"
:event-bus="modalBus"
width="640px"
height="80vh"
max-height="680px"
custom-class="promotion-modal"
>
<template #content>
<div :class="$style.content">
<div :class="$style.toolbar">
<N8nCheckbox
:model-value="allSelected"
:indeterminate="someSelected"
data-test-id="promotion-select-all"
@update:model-value="toggleSelectAll"
/>
<N8nInput
v-model="searchQuery"
:placeholder="i18n.baseText('promotions.modal.search.placeholder')"
size="small"
clearable
data-test-id="promotion-search"
:class="$style.searchInput"
/>
<N8nButton
variant="subtle"
size="small"
icon="refresh-cw"
data-test-id="promotion-refresh"
@click="onRefresh"
>
{{ i18n.baseText('promotions.modal.refresh') }}
</N8nButton>
</div>
<div v-if="isLoading" :class="$style.loading">
<N8nText color="text-light">{{ i18n.baseText('generic.loading') }}</N8nText>
</div>
<template v-else-if="error">
<div :class="$style.empty" data-test-id="promotion-error">
<N8nText size="medium" bold>
{{ i18n.baseText('promotions.modal.error') }}
</N8nText>
<N8nText size="small" color="text-light">
{{ i18n.baseText('promotions.modal.error.description') }}
</N8nText>
<N8nButton
variant="subtle"
size="small"
data-test-id="promotion-retry"
@click="onRefresh"
>
{{ i18n.baseText('promotions.modal.retry') }}
</N8nButton>
</div>
</template>
<template v-else-if="changes.length === 0">
<div :class="$style.empty">
<N8nText size="medium" bold>
{{ i18n.baseText('promotions.modal.empty') }}
</N8nText>
<N8nText size="small" color="text-light">
{{ i18n.baseText('promotions.modal.empty.description') }}
</N8nText>
</div>
</template>
<template v-else-if="hasNoSearchResults">
<div :class="$style.empty" data-test-id="promotion-no-results">
<N8nText size="small" color="text-light">
{{ i18n.baseText('promotions.modal.noResults') }}
</N8nText>
</div>
</template>
<template v-else>
<div :class="$style.listContainer">
<div :class="$style.list">
<div
v-for="(change, index) in filteredChanges"
:key="change.id"
:class="[
$style.row,
isSelected(change.id) && $style.rowSelected,
index === 0 && $style.rowFirst,
index === filteredChanges.length - 1 && $style.rowLast,
]"
data-test-id="promotion-change-row"
@click="toggleSelected(change.id)"
>
<N8nCheckbox
:model-value="isSelected(change.id)"
@update:model-value="toggleSelected(change.id)"
@click.stop
/>
<div :class="$style.rowContent">
<div :class="$style.rowHeader">
<N8nText size="medium" bold>{{ change.name }}</N8nText>
<span
:class="[
$style.statusLabel,
change.status === 'archived' && $style.statusArchived,
change.status === 'deleted' && $style.statusDeleted,
]"
data-test-id="promotion-change-status"
>
{{ getStatusLabel(change.status) }}
</span>
</div>
<div :class="$style.rowMeta">
<N8nText
v-if="getChangedByLabel(change.updatedBy)"
size="small"
color="text-light"
>
{{ getChangedByLabel(change.updatedBy) }}
</N8nText>
<N8nText
v-if="getChangedByLabel(change.updatedBy)"
size="small"
color="text-light"
>·</N8nText
>
<N8nText size="small" color="text-light">
<TimeAgo :date="change.updatedAt" />
</N8nText>
<template v-if="change.dependencyCount > 0">
<N8nText size="small" color="text-light">·</N8nText>
<N8nText size="small" bold>
{{ getDependencyLabel(change.dependencyCount) }}
</N8nText>
</template>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<template #footer>
<div :class="$style.footer">
<div :class="$style.footerLeft">
<N8nSwitch
v-model="createBranch"
:label="i18n.baseText('promotions.modal.createBranch')"
size="small"
data-test-id="promotion-create-branch"
/>
</div>
<div :class="$style.footerRight">
<N8nButton variant="subtle" @click="onClose">
{{ i18n.baseText('promotions.modal.close') }}
</N8nButton>
<N8nButton
:disabled="selectedCount === 0 || isLoading || !!error || isPromoting"
:loading="isPromoting"
data-test-id="promotion-submit"
@click="onPromote"
>
{{ getPromoteButtonLabel() }}
</N8nButton>
</div>
</div>
</template>
</Modal>
</template>
<style lang="scss">
.promotion-modal {
.el-dialog__body {
padding-inline: 0;
padding: 0;
}
.modal-content {
padding-inline: 0;
margin-inline: 0;
}
.modal-content ~ div {
margin-top: 0;
border-top: var(--border);
padding: var(--spacing--sm) var(--spacing--md);
}
}
</style>
<style lang="scss" module>
:global(body) {
--color--bg-promotion-row-selected: var(--color--orange-100);
}
:global(body[data-theme='dark']) {
--color--bg-promotion-row-selected: var(--color--orange-900);
}
@media (prefers-color-scheme: dark) {
:global(body:not([data-theme])) {
--color--bg-promotion-row-selected: var(--color--orange-900);
}
}
.content {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: 100%;
}
.toolbar {
display: flex;
gap: var(--spacing--xs);
align-items: center;
flex-shrink: 0;
position: relative;
z-index: 1;
padding: var(--spacing--sm) var(--spacing--md) var(--spacing--sm) var(--spacing--xl);
}
.searchInput {
flex: 1;
margin-inline: calc(var(--input--padding) * -1) 0 0;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing--xs);
flex: 1;
}
.listContainer {
flex: 1;
overflow-y: auto;
min-height: 0;
background-color: var(--background--subtle);
padding: var(--spacing--sm);
}
.list {
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
border-radius: var(--radius--3xs);
overflow: hidden;
}
.row {
display: flex;
align-items: center;
gap: var(--spacing--xs);
padding: var(--spacing--xs) var(--spacing--sm);
background-color: var(--background--surface);
border-bottom: 1px solid var(--border-color);
cursor: pointer;
transition: background-color var(--duration--snappy) var(--easing--ease-out);
&:hover {
background-color: var(--background--subtle);
}
&:last-child {
border-bottom: none;
}
}
.rowFirst {
border-radius: var(--radius--3xs) var(--radius--3xs) 0 0;
}
.rowLast {
border-radius: 0 0 var(--radius--3xs) var(--radius--3xs);
}
.rowSelected {
background-color: var(--color--bg-promotion-row-selected);
}
.rowContent {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.rowHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing--xs);
}
.statusLabel {
font-size: var(--font-size--2xs);
color: var(--text-color);
}
.statusArchived {
color: var(--text-color--warning);
}
.statusDeleted {
color: var(--text-color--danger);
}
.rowMeta {
display: flex;
align-items: center;
gap: var(--spacing--3xs);
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.footerLeft {
display: flex;
align-items: center;
}
.footerRight {
display: flex;
gap: var(--spacing--xs);
}
</style>

View File

@ -0,0 +1,130 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { usePromotionChanges } from './usePromotionChanges';
import * as promotionsApi from '../promotions.api';
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: () => ({ restApiContext: {} }),
}));
vi.mock('../promotions.api');
const mockChanges = [
{
id: 'wf-001',
name: 'Workflow A',
type: 'workflow' as const,
status: 'modified' as const,
version: 5,
updatedAt: new Date().toISOString(),
updatedBy: 'user-1',
dependencyCount: 3,
},
{
id: 'wf-002',
name: 'Workflow B',
type: 'workflow' as const,
status: 'new' as const,
version: 1,
updatedAt: new Date().toISOString(),
updatedBy: null,
dependencyCount: 0,
},
{
id: 'wf-003',
name: 'Workflow C',
type: 'workflow' as const,
status: 'archived' as const,
version: null,
updatedAt: new Date().toISOString(),
updatedBy: 'user-2',
dependencyCount: 1,
},
{
id: 'wf-004',
name: 'Workflow D',
type: 'workflow' as const,
status: 'deleted' as const,
version: null,
updatedAt: new Date().toISOString(),
updatedBy: null,
dependencyCount: 0,
},
];
describe('usePromotionChanges', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(promotionsApi.getPromotableChanges).mockResolvedValue(mockChanges);
vi.mocked(promotionsApi.promoteChanges).mockResolvedValue({ branchName: 'promote/test' });
});
it('should call promote with correct parameters', async () => {
const { fetchChanges, toggleSelected, promote } = usePromotionChanges('project-1');
await fetchChanges();
toggleSelected('wf-001');
toggleSelected('wf-003');
await promote(true);
expect(promotionsApi.promoteChanges).toHaveBeenCalledWith({}, 'project-1', {
workflowIds: expect.arrayContaining(['wf-001', 'wf-003']),
createBranch: true,
});
});
it('should handle fetch errors', async () => {
vi.mocked(promotionsApi.getPromotableChanges).mockRejectedValue(new Error('Network error'));
const { fetchChanges, error, isLoading } = usePromotionChanges('project-1');
await fetchChanges();
expect(error.value).toBeInstanceOf(Error);
expect(error.value?.message).toBe('Network error');
expect(isLoading.value).toBe(false);
});
it('should drop selections whose resource disappears after a refresh', async () => {
const { fetchChanges, toggleSelected, selectedIds, selectedCount } =
usePromotionChanges('project-1');
await fetchChanges();
toggleSelected('wf-001');
toggleSelected('wf-002');
expect(selectedCount.value).toBe(2);
// wf-001 is gone from the refreshed response (e.g. promoted elsewhere).
vi.mocked(promotionsApi.getPromotableChanges).mockResolvedValueOnce([
mockChanges[1],
mockChanges[2],
]);
await fetchChanges();
expect(selectedIds.value.has('wf-001')).toBe(false);
expect(selectedIds.value.has('wf-002')).toBe(true);
expect(selectedCount.value).toBe(1);
});
it('should select only the visible rows when a search filter is active', async () => {
const {
fetchChanges,
searchQuery,
filteredChanges,
toggleSelectAll,
selectedIds,
allSelected,
} = usePromotionChanges('project-1');
await fetchChanges();
searchQuery.value = 'Workflow A';
expect(filteredChanges.value).toHaveLength(1);
toggleSelectAll();
expect(selectedIds.value.has('wf-001')).toBe(true);
expect(selectedIds.value.has('wf-002')).toBe(false);
expect(selectedIds.value.has('wf-003')).toBe(false);
expect(selectedIds.value.has('wf-004')).toBe(false);
expect(allSelected.value).toBe(true);
});
});

View File

@ -0,0 +1,100 @@
import { ref, computed } from 'vue';
import type { PromotableResource } from '@n8n/api-types';
import { useRootStore } from '@n8n/stores/useRootStore';
import { getPromotableChanges, promoteChanges } from '../promotions.api';
export function usePromotionChanges(projectId: string) {
const rootStore = useRootStore();
const changes = ref<PromotableResource[]>([]);
const isLoading = ref(false);
const error = ref<Error | null>(null);
const searchQuery = ref('');
const selectedIds = ref<Set<string>>(new Set());
// Only the rows the user can currently see are eligible for select-all.
const filteredChanges = computed(() => {
if (!searchQuery.value) return changes.value;
const term = searchQuery.value.toLowerCase();
return changes.value.filter((c) => c.name.toLowerCase().includes(term));
});
const selectedCount = computed(() => selectedIds.value.size);
// Select-all state reflects the visible (filtered) rows, not the full list.
const allSelected = computed(
() =>
filteredChanges.value.length > 0 &&
filteredChanges.value.every((c) => selectedIds.value.has(c.id)),
);
const someSelected = computed(
() => filteredChanges.value.some((c) => selectedIds.value.has(c.id)) && !allSelected.value,
);
// Drop selections whose resource no longer exists in the latest response.
function reconcileSelection() {
const availableIds = new Set(changes.value.map((c) => c.id));
selectedIds.value = new Set([...selectedIds.value].filter((id) => availableIds.has(id)));
}
async function fetchChanges(search?: string) {
isLoading.value = true;
error.value = null;
try {
changes.value = await getPromotableChanges(rootStore.restApiContext, projectId, {
search,
});
reconcileSelection();
} catch (e) {
error.value = e instanceof Error ? e : new Error(String(e));
} finally {
isLoading.value = false;
}
}
function toggleSelected(id: string) {
const next = new Set(selectedIds.value);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
selectedIds.value = next;
}
// Add or remove only the currently visible rows; hidden selections are untouched.
function toggleSelectAll() {
const next = new Set(selectedIds.value);
if (allSelected.value) {
for (const c of filteredChanges.value) next.delete(c.id);
} else {
for (const c of filteredChanges.value) next.add(c.id);
}
selectedIds.value = next;
}
async function promote(createBranch: boolean) {
return await promoteChanges(rootStore.restApiContext, projectId, {
workflowIds: [...selectedIds.value],
createBranch,
});
}
return {
changes,
filteredChanges,
isLoading,
error,
searchQuery,
selectedIds,
selectedCount,
allSelected,
someSelected,
fetchChanges,
toggleSelected,
toggleSelectAll,
promote,
};
}

View File

@ -0,0 +1,15 @@
import type { ModalDefinition } from '@n8n/frontend-module-sdk';
import { PROMOTION_SELECT_MODAL_KEY } from './promotions.constants';
export const PROMOTIONS_MODALS: ModalDefinition[] = [
{
key: PROMOTION_SELECT_MODAL_KEY,
component: async () => await import('./components/PromotionSelectModal.vue'),
initialState: {
open: false,
data: {
projectId: '',
},
},
},
];

View File

@ -0,0 +1,10 @@
import type { FrontendModuleDescription } from '@n8n/frontend-module-sdk';
import { PROMOTIONS_MODALS } from './modals';
export const PromotionsModule: FrontendModuleDescription = {
id: 'promotions',
name: 'Promotions',
description: 'Promote workflow changes between environments',
icon: 'upload',
modals: PROMOTIONS_MODALS,
};

View File

@ -0,0 +1,67 @@
import type { PromotableResource, PromoteRequest } from '@n8n/api-types';
import type { IRestApiContext } from '@n8n/rest-api-client';
import { makeRestApiRequest } from '@n8n/rest-api-client';
const MOCK_DATA: PromotableResource[] = [
{
id: 'wf-001',
name: 'Email summary',
type: 'workflow',
status: 'modified',
version: 14,
updatedAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
updatedBy: 'user-001',
dependencyCount: 7,
},
{
id: 'wf-002',
name: 'Payment handler',
type: 'workflow',
status: 'archived',
version: 3,
updatedAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(),
updatedBy: 'user-002',
dependencyCount: 1,
},
{
id: 'wf-003',
name: 'Legacy invoice sync',
type: 'workflow',
status: 'deleted',
version: null,
updatedAt: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
updatedBy: null,
dependencyCount: 0,
},
];
const USE_MOCK = true;
export async function getPromotableChanges(
context: IRestApiContext,
projectId: string,
options?: { search?: string; sort?: string; order?: string },
): Promise<PromotableResource[]> {
if (USE_MOCK) {
let results = [...MOCK_DATA];
if (options?.search) {
const term = options.search.toLowerCase();
results = results.filter((r) => r.name.toLowerCase().includes(term));
}
return results;
}
return await makeRestApiRequest(context, 'GET', `/promotions/${projectId}/changes`, options);
}
export async function promoteChanges(
context: IRestApiContext,
projectId: string,
request: PromoteRequest,
): Promise<unknown> {
if (USE_MOCK) {
return { branchName: `promote/${new Date().toISOString().slice(0, 16).replace(/[T:]/g, '-')}` };
}
return await makeRestApiRequest(context, 'POST', `/promotions/${projectId}/promote`, request);
}

View File

@ -0,0 +1 @@
export const PROMOTION_SELECT_MODAL_KEY = 'promotionSelect';