mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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:
parent
be678fcb69
commit
90d176b969
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export const PROMOTION_SELECT_MODAL_KEY = 'promotionSelect';
|
||||
Loading…
Reference in New Issue
Block a user