mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(editor): Add GitLab promotion connector setup (no-changelog) (#41019)
This commit is contained in:
parent
3c447b42a7
commit
154cd63bbc
|
|
@ -3989,6 +3989,23 @@
|
|||
"settings.promotions.provider.dialog.ariaDescription": "Set the credentials this provider uses to reach Git.",
|
||||
"settings.promotions.provider.dialog.key.ariaDescription": "Copy the deploy key and add it to your Git provider.",
|
||||
"settings.promotions.provider.form.name": "Name",
|
||||
"settings.promotions.provider.form.type": "Provider type",
|
||||
"settings.promotions.providers.type.git": "Git",
|
||||
"settings.promotions.providers.type.gitlab": "GitLab",
|
||||
"settings.promotions.provider.form.baseUrl": "GitLab base URL",
|
||||
"settings.promotions.provider.form.baseUrl.placeholder": "https://gitlab.example.com",
|
||||
"settings.promotions.provider.form.accessToken": "Access token",
|
||||
"settings.promotions.provider.form.accessToken.hint": "Use a personal, group, or project access token with read_api and write_repository scopes",
|
||||
"settings.promotions.provider.form.accessToken.keepHint": "Leave the token empty to keep the saved token",
|
||||
"settings.promotions.provider.form.accessToken.urlChangeHint": "Enter an access token for the new GitLab URL",
|
||||
"settings.promotions.discovery.repository.label": "Repository",
|
||||
"settings.promotions.discovery.repository.placeholder": "Search or select a repository",
|
||||
"settings.promotions.discovery.repository.empty": "No repositories found. Try another search or check the token's access.",
|
||||
"settings.promotions.discovery.repository.selectAgain": "Select a repository again to load its branches",
|
||||
"settings.promotions.discovery.branch.placeholder": "Search or select a branch",
|
||||
"settings.promotions.discovery.branch.empty": "No branches found. Try another search or create a branch in GitLab.",
|
||||
"settings.promotions.discovery.error": "Couldn't load options. Check the provider settings and try again.",
|
||||
"settings.promotions.discovery.searchHint": "Results for “{search}”",
|
||||
"settings.promotions.provider.form.authType": "Authentication",
|
||||
"settings.promotions.provider.form.keyType": "SSH key type",
|
||||
"settings.promotions.provider.form.username": "Username",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import type { PromotionConfigCheckout, PromotionDirection } from '@n8n/api-types';
|
||||
import {
|
||||
isPromotionGitHostType,
|
||||
type PromotionConfigCheckout,
|
||||
type PromotionDirection,
|
||||
type PromotionRepository,
|
||||
} from '@n8n/api-types';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import {
|
||||
N8nButton,
|
||||
|
|
@ -18,7 +23,7 @@ import { useI18n } from '@n8n/i18n';
|
|||
import { getResourcePermissions } from '@n8n/permissions';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { useUsersStore } from '@n8n/stores/users.store';
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { usePromotionConnectionSave } from '../composables/usePromotionConnectionSave';
|
||||
import {
|
||||
|
|
@ -29,6 +34,7 @@ import {
|
|||
type PromotionProviderSummary,
|
||||
} from '../promotionsSettings.api';
|
||||
import PromotionCheckoutStatus from './PromotionCheckoutStatus.vue';
|
||||
import PromotionGitHostSelect from './PromotionGitHostSelect.vue';
|
||||
import {
|
||||
buildConnectionCreatePayload,
|
||||
connectionFormFrom,
|
||||
|
|
@ -36,6 +42,7 @@ import {
|
|||
planConnectionWrites,
|
||||
type ConnectionFormState,
|
||||
type ConnectionWrite,
|
||||
type PromotionProviderChange,
|
||||
} from '../promotionsSettings.utils';
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -45,6 +52,7 @@ const props = defineProps<{
|
|||
|
||||
const emit = defineEmits<{
|
||||
saved: [connection: PromotionConnection];
|
||||
refresh: [];
|
||||
'add-provider': [];
|
||||
}>();
|
||||
|
||||
|
|
@ -66,6 +74,71 @@ const form = reactive<ConnectionFormState>(
|
|||
);
|
||||
const baseline = ref(JSON.stringify(form));
|
||||
const isCreating = ref(false);
|
||||
const discoveryRevision = ref(0);
|
||||
let savedStateRevision = 0;
|
||||
const selectedProvider = computed(
|
||||
() =>
|
||||
props.providers.find(({ id }) => id === form.providerId) ??
|
||||
(current.value?.provider.id === form.providerId ? current.value.provider : undefined),
|
||||
);
|
||||
const hasHostApi = computed(
|
||||
() => selectedProvider.value && isPromotionGitHostType(selectedProvider.value.type),
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.connection,
|
||||
(connection) => {
|
||||
savedStateRevision++;
|
||||
// Refresh saved state without replacing the draft.
|
||||
current.value = connection;
|
||||
// A provider host change keeps the path but changes the derived URL.
|
||||
if (
|
||||
form.repositoryPath &&
|
||||
form.providerId === connection?.provider.id &&
|
||||
form.repositoryPath === connection?.target.repositoryPath
|
||||
) {
|
||||
form.remoteUrl = connection.target.remoteUrl;
|
||||
}
|
||||
form.apply.name = connection?.configs.apply?.name;
|
||||
form.promote.name = connection?.configs.promote?.name;
|
||||
baseline.value = JSON.stringify(
|
||||
connection ? connectionFormFrom(connection) : emptyConnectionForm(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function applyProviderChange(change: PromotionProviderChange) {
|
||||
if (
|
||||
(change.hostChanged || change.credentialsChanged) &&
|
||||
(change.id === form.providerId || change.id === current.value?.provider.id)
|
||||
) {
|
||||
// Pending writes can refer to either provider during reassignment.
|
||||
savedStateRevision++;
|
||||
}
|
||||
if (change.id !== form.providerId) return;
|
||||
// The new host rejects a URL picked from the old host, unless it is the saved repository.
|
||||
if (
|
||||
change.hostChanged &&
|
||||
(form.providerId !== current.value?.provider.id ||
|
||||
form.repositoryPath !== current.value?.target.repositoryPath)
|
||||
) {
|
||||
form.remoteUrl = '';
|
||||
form.repositoryPath = undefined;
|
||||
}
|
||||
if (change.hostChanged || change.credentialsChanged) discoveryRevision.value++;
|
||||
}
|
||||
|
||||
function selectRepository(selected: PromotionRepository) {
|
||||
if (
|
||||
form.remoteUrl !== selected.remoteUrl ||
|
||||
(form.repositoryPath !== undefined && form.repositoryPath !== selected.fullPath)
|
||||
) {
|
||||
form.apply.branchName = '';
|
||||
form.promote.baseBranchName = '';
|
||||
}
|
||||
form.remoteUrl = selected.remoteUrl;
|
||||
form.repositoryPath = selected.fullPath;
|
||||
}
|
||||
|
||||
const resetTo = (connection: PromotionConnection | null) => {
|
||||
current.value = connection;
|
||||
|
|
@ -76,10 +149,18 @@ const resetTo = (connection: PromotionConnection | null) => {
|
|||
|
||||
const isSaving = computed(() => isCreating.value || save.isSaving.value);
|
||||
const isDirty = computed(() => JSON.stringify(form) !== baseline.value);
|
||||
const needsRepositorySelection = computed(
|
||||
() => hasHostApi.value && !!form.remoteUrl && !form.repositoryPath,
|
||||
);
|
||||
|
||||
// Let the API validate remote URLs so frontend rules cannot drift.
|
||||
const saveDisabledReason = computed(() => {
|
||||
if (!form.providerId || !form.name.trim() || !form.remoteUrl.trim())
|
||||
if (
|
||||
!form.providerId ||
|
||||
!form.name.trim() ||
|
||||
!form.remoteUrl.trim() ||
|
||||
(hasHostApi.value && !form.repositoryPath)
|
||||
)
|
||||
return i18n.baseText('settings.promotions.connection.form.incomplete');
|
||||
if (
|
||||
(form.apply.enabled && !form.apply.branchName.trim()) ||
|
||||
|
|
@ -107,6 +188,8 @@ async function submit() {
|
|||
if (saveDisabledReason.value !== undefined || isSaving.value) return;
|
||||
|
||||
const existing = current.value;
|
||||
const revision = discoveryRevision.value;
|
||||
const savedRevision = savedStateRevision;
|
||||
|
||||
if (!existing) {
|
||||
// Creation saves both directions in one request.
|
||||
|
|
@ -116,11 +199,15 @@ async function submit() {
|
|||
rootStore.publicApiContext,
|
||||
buildConnectionCreatePayload(form),
|
||||
);
|
||||
resetTo(created);
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('settings.promotions.connection.toast.created'),
|
||||
type: 'success',
|
||||
});
|
||||
if (revision !== discoveryRevision.value || savedRevision !== savedStateRevision) {
|
||||
emit('refresh');
|
||||
return;
|
||||
}
|
||||
resetTo(created);
|
||||
emit('saved', created);
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('settings.promotions.connection.toast.error.save'));
|
||||
|
|
@ -132,23 +219,31 @@ async function submit() {
|
|||
|
||||
// Build a new plan so retries include only unsaved changes.
|
||||
const result = await save.run(existing, planConnectionWrites(form, existing));
|
||||
current.value = result.connection;
|
||||
emit('saved', result.connection);
|
||||
|
||||
if (result.failed.length > 0) {
|
||||
toast.showError(result.error, i18n.baseText('settings.promotions.connection.toast.error.save'));
|
||||
} else {
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('settings.promotions.connection.toast.updated'),
|
||||
type: 'success',
|
||||
});
|
||||
}
|
||||
if (revision !== discoveryRevision.value || savedRevision !== savedStateRevision) {
|
||||
// Read saved state again after a provider edit or a concurrent refresh.
|
||||
save.reset();
|
||||
emit('refresh');
|
||||
return;
|
||||
}
|
||||
current.value = result.connection;
|
||||
form.apply.name = result.connection.configs.apply?.name;
|
||||
form.promote.name = result.connection.configs.promote?.name;
|
||||
emit('saved', result.connection);
|
||||
|
||||
baseline.value = JSON.stringify(form);
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('settings.promotions.connection.toast.updated'),
|
||||
type: 'success',
|
||||
});
|
||||
if (result.failed.length === 0) resetTo(result.connection);
|
||||
}
|
||||
|
||||
function discard() {
|
||||
resetTo(current.value);
|
||||
discoveryRevision.value++;
|
||||
}
|
||||
|
||||
// A clone reads the saved config, so Connect stays blocked until the direction is
|
||||
|
|
@ -173,6 +268,8 @@ const connectDisabledReason = (direction: PromotionDirection): string | undefine
|
|||
if (!canClone.value) return i18n.baseText('settings.promotions.connection.checkout.noPermission');
|
||||
if (!current.value?.configs[direction])
|
||||
return i18n.baseText('settings.promotions.connection.checkout.saveFirst');
|
||||
if (needsRepositorySelection.value)
|
||||
return i18n.baseText('settings.promotions.discovery.repository.selectAgain');
|
||||
if (isDirty.value)
|
||||
return i18n.baseText('settings.promotions.connection.checkout.saveChangesFirst');
|
||||
return undefined;
|
||||
|
|
@ -194,6 +291,7 @@ function applyCheckout(direction: PromotionDirection, checkout: PromotionConfigC
|
|||
|
||||
async function connect(direction: PromotionDirection) {
|
||||
if (!current.value || connectDisabledReason(direction) !== undefined) return;
|
||||
const revision = savedStateRevision;
|
||||
connecting[direction] = 'connect';
|
||||
try {
|
||||
const result = await clonePromotionCheckout(
|
||||
|
|
@ -201,6 +299,10 @@ async function connect(direction: PromotionDirection) {
|
|||
current.value.id,
|
||||
direction,
|
||||
);
|
||||
if (revision !== savedStateRevision) {
|
||||
emit('refresh');
|
||||
return;
|
||||
}
|
||||
applyCheckout(direction, {
|
||||
hasCheckout: result.hasCheckout,
|
||||
matchesConfig: result.hasCheckout,
|
||||
|
|
@ -221,9 +323,14 @@ async function connect(direction: PromotionDirection) {
|
|||
|
||||
async function disconnect(direction: PromotionDirection) {
|
||||
if (!current.value) return;
|
||||
const revision = savedStateRevision;
|
||||
connecting[direction] = 'disconnect';
|
||||
try {
|
||||
await disconnectPromotionCheckout(rootStore.publicApiContext, current.value.id, direction);
|
||||
if (revision !== savedStateRevision) {
|
||||
emit('refresh');
|
||||
return;
|
||||
}
|
||||
applyCheckout(direction, { hasCheckout: false, matchesConfig: false });
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('settings.promotions.connection.checkout.toast.disconnected'),
|
||||
|
|
@ -240,10 +347,22 @@ async function disconnect(direction: PromotionDirection) {
|
|||
}
|
||||
|
||||
function selectProvider(id: string) {
|
||||
if (id === form.providerId) return;
|
||||
const previous = selectedProvider.value;
|
||||
const next = props.providers.find((provider) => provider.id === id);
|
||||
if (
|
||||
(previous && isPromotionGitHostType(previous.type)) ||
|
||||
(next && isPromotionGitHostType(next.type))
|
||||
) {
|
||||
form.remoteUrl = '';
|
||||
form.apply.branchName = '';
|
||||
form.promote.baseBranchName = '';
|
||||
}
|
||||
form.repositoryPath = undefined;
|
||||
form.providerId = id;
|
||||
}
|
||||
|
||||
defineExpose({ selectProvider });
|
||||
defineExpose({ selectProvider, applyProviderChange });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -256,10 +375,11 @@ defineExpose({ selectProvider });
|
|||
<div :class="$style.providerRow">
|
||||
<N8nSelect
|
||||
id="promotion-connection-provider"
|
||||
v-model="form.providerId"
|
||||
:model-value="form.providerId"
|
||||
:disabled="isSaving"
|
||||
:placeholder="i18n.baseText('settings.promotions.connection.form.provider.placeholder')"
|
||||
data-test-id="promotion-connection-provider-select"
|
||||
@update:model-value="selectProvider"
|
||||
>
|
||||
<N8nOption
|
||||
v-for="provider in providers"
|
||||
|
|
@ -294,6 +414,36 @@ defineExpose({ selectProvider });
|
|||
</N8nInputLabel>
|
||||
|
||||
<N8nInputLabel
|
||||
v-if="hasHostApi"
|
||||
input-name="promotion-connection-repository"
|
||||
:label="i18n.baseText('settings.promotions.discovery.repository.label')"
|
||||
required
|
||||
>
|
||||
<PromotionGitHostSelect
|
||||
:model-value="form.remoteUrl"
|
||||
kind="repository"
|
||||
input-id="promotion-connection-repository"
|
||||
:provider-id="form.providerId"
|
||||
:revision="discoveryRevision"
|
||||
:description-id="
|
||||
needsRepositorySelection ? 'promotion-repository-selection-hint' : undefined
|
||||
"
|
||||
:disabled="isSaving"
|
||||
data-test-id="promotion-connection-repository-select"
|
||||
@selected="selectRepository"
|
||||
/>
|
||||
<N8nText
|
||||
v-if="needsRepositorySelection"
|
||||
id="promotion-repository-selection-hint"
|
||||
size="small"
|
||||
color="text-light"
|
||||
role="status"
|
||||
>
|
||||
{{ i18n.baseText('settings.promotions.discovery.repository.selectAgain') }}
|
||||
</N8nText>
|
||||
</N8nInputLabel>
|
||||
<N8nInputLabel
|
||||
v-else
|
||||
input-name="promotion-connection-remote-url"
|
||||
:label="i18n.baseText('settings.promotions.connection.form.remoteUrl')"
|
||||
required
|
||||
|
|
@ -332,11 +482,23 @@ defineExpose({ selectProvider });
|
|||
required
|
||||
>
|
||||
<N8nInput
|
||||
v-if="!hasHostApi"
|
||||
id="promotion-connection-apply-branch"
|
||||
v-model="form.apply.branchName"
|
||||
:disabled="isSaving"
|
||||
data-test-id="promotion-connection-apply-branch-input"
|
||||
/>
|
||||
<PromotionGitHostSelect
|
||||
v-else
|
||||
v-model="form.apply.branchName"
|
||||
kind="branch"
|
||||
input-id="promotion-connection-apply-branch"
|
||||
:provider-id="form.providerId"
|
||||
:repository-id="form.repositoryPath"
|
||||
:revision="discoveryRevision"
|
||||
:disabled="isSaving"
|
||||
data-test-id="promotion-connection-apply-branch-select"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
<PromotionCheckoutStatus
|
||||
:checkout="savedCheckout('apply')"
|
||||
|
|
@ -373,11 +535,23 @@ defineExpose({ selectProvider });
|
|||
required
|
||||
>
|
||||
<N8nInput
|
||||
v-if="!hasHostApi"
|
||||
id="promotion-connection-promote-branch"
|
||||
v-model="form.promote.baseBranchName"
|
||||
:disabled="isSaving"
|
||||
data-test-id="promotion-connection-promote-branch-input"
|
||||
/>
|
||||
<PromotionGitHostSelect
|
||||
v-else
|
||||
v-model="form.promote.baseBranchName"
|
||||
kind="branch"
|
||||
input-id="promotion-connection-promote-branch"
|
||||
:provider-id="form.providerId"
|
||||
:repository-id="form.repositoryPath"
|
||||
:revision="discoveryRevision"
|
||||
:disabled="isSaving"
|
||||
data-test-id="promotion-connection-promote-branch-select"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
<N8nSwitch
|
||||
v-model="form.promote.createBranchOnPromotion"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,190 @@
|
|||
import type { PromotionRepository, PromotionRepositoryListPublicDto } from '@n8n/api-types';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { flushPromises } from '@vue/test-utils';
|
||||
import { screen, waitFor, within } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import type * as PromotionsApi from '../promotionsSettings.api';
|
||||
import PromotionGitHostSelect from './PromotionGitHostSelect.vue';
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
fetchPromotionRepositories: vi.fn<typeof PromotionsApi.fetchPromotionRepositories>(),
|
||||
fetchPromotionBranches: vi.fn<typeof PromotionsApi.fetchPromotionBranches>(),
|
||||
}));
|
||||
vi.mock('../promotionsSettings.api', () => api);
|
||||
|
||||
const repository: PromotionRepository = {
|
||||
id: '42',
|
||||
name: 'Workflows',
|
||||
fullPath: 'team/workflows',
|
||||
remoteUrl: 'https://gitlab.example.com/team/workflows.git',
|
||||
defaultBranch: 'main',
|
||||
};
|
||||
const otherRepository: PromotionRepository = {
|
||||
...repository,
|
||||
id: '43',
|
||||
name: 'Other',
|
||||
fullPath: 'team/other',
|
||||
remoteUrl: 'https://gitlab.example.com/team/other.git',
|
||||
};
|
||||
|
||||
const renderSelect = createComponentRenderer(PromotionGitHostSelect, {
|
||||
props: { modelValue: '', providerId: 'gitlab-1', kind: 'repository', inputId: 'repository' },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
createTestingPinia();
|
||||
api.fetchPromotionRepositories.mockResolvedValue({ data: [repository], nextCursor: null });
|
||||
api.fetchPromotionBranches.mockResolvedValue({
|
||||
data: [{ name: 'main', isDefault: true }],
|
||||
nextCursor: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores an old provider response while the current provider is still loading', async () => {
|
||||
let resolveOld!: (page: PromotionRepositoryListPublicDto) => void;
|
||||
let resolveCurrent!: (page: PromotionRepositoryListPublicDto) => void;
|
||||
api.fetchPromotionRepositories
|
||||
.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveOld = resolve;
|
||||
}),
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveCurrent = resolve;
|
||||
}),
|
||||
);
|
||||
const { rerender } = renderSelect();
|
||||
await rerender({ providerId: 'gitlab-2' });
|
||||
await waitFor(() => expect(api.fetchPromotionRepositories).toHaveBeenCalledTimes(2));
|
||||
resolveOld({ data: [repository], nextCursor: 'stale-page' });
|
||||
await flushPromises();
|
||||
await userEvent.click(screen.getByRole('combobox'));
|
||||
expect(await screen.findByText('Loading')).toBeVisible();
|
||||
expect(screen.queryByText('team/workflows')).not.toBeInTheDocument();
|
||||
resolveCurrent({ data: [otherRepository], nextCursor: null });
|
||||
expect(await screen.findByText('team/other')).toBeInTheDocument();
|
||||
expect(screen.queryByText('team/workflows')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps a saved selection outside discovery results and clears empty-search feedback on reselection', async () => {
|
||||
api.fetchPromotionRepositories.mockResolvedValue({ data: [otherRepository], nextCursor: 'next' });
|
||||
const { emitted } = renderSelect({ props: { modelValue: repository.remoteUrl } });
|
||||
await flushPromises();
|
||||
expect(screen.getByRole('combobox')).toHaveValue(repository.remoteUrl);
|
||||
expect(api.fetchPromotionRepositories).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.anything(),
|
||||
'gitlab-1',
|
||||
{ search: undefined, limit: 25 },
|
||||
);
|
||||
expect(emitted('selected')).toBeUndefined();
|
||||
expect(emitted('update:modelValue')).toBeUndefined();
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
api.fetchPromotionRepositories.mockResolvedValue({ data: [], nextCursor: null });
|
||||
await userEvent.type(screen.getByRole('combobox'), 'team');
|
||||
await screen.findByText(/No repositories found/, { selector: '[role="status"]' });
|
||||
await userEvent.click(screen.getByRole('option', { name: repository.remoteUrl }));
|
||||
await waitFor(() => expect(api.fetchPromotionRepositories).toHaveBeenCalledTimes(3));
|
||||
expect(screen.getByRole('combobox')).toHaveValue(repository.remoteUrl);
|
||||
expect(screen.queryByText('Results for “team”')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/No repositories found/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe.each(['repository', 'branch'] as const)('filtered %s discovery', (kind) => {
|
||||
const requests = () =>
|
||||
(kind === 'repository'
|
||||
? api.fetchPromotionRepositories.mock.calls
|
||||
: api.fetchPromotionBranches.mock.calls
|
||||
).map((args) => args.at(-1));
|
||||
|
||||
const renderPicker = (onClick: () => void) =>
|
||||
renderSelect({
|
||||
props: { kind, repositoryId: kind === 'branch' ? 'team/workflows' : undefined },
|
||||
attrs: { onClick },
|
||||
});
|
||||
|
||||
it('loads up to 25 matches and keeps search and selection through a retry', async () => {
|
||||
api.fetchPromotionRepositories
|
||||
.mockResolvedValueOnce({ data: [repository], nextCursor: 'unfiltered-next' })
|
||||
.mockRejectedValueOnce(new Error('GitLab is unavailable.'))
|
||||
.mockResolvedValueOnce({
|
||||
data: [repository, otherRepository],
|
||||
nextCursor: 'filtered-next',
|
||||
});
|
||||
api.fetchPromotionBranches
|
||||
.mockResolvedValueOnce({
|
||||
data: [{ name: 'main', isDefault: true }],
|
||||
nextCursor: 'unfiltered-next',
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('GitLab is unavailable.'))
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
{ name: 'team/main', isDefault: false },
|
||||
{ name: 'team/other', isDefault: false },
|
||||
],
|
||||
nextCursor: 'filtered-next',
|
||||
});
|
||||
const onClick = vi.fn();
|
||||
const { emitted } = renderPicker(onClick);
|
||||
await waitFor(() => expect(requests()).toHaveLength(1));
|
||||
const input = screen.getByRole('combobox');
|
||||
expect(input).toHaveAttribute(
|
||||
'placeholder',
|
||||
kind === 'repository' ? 'Search or select a repository' : 'Search or select a branch',
|
||||
);
|
||||
expect(requests()[0]).toEqual({ search: undefined, limit: 25 });
|
||||
await userEvent.type(input, 'team');
|
||||
await waitFor(() => expect(requests()).toHaveLength(2));
|
||||
onClick.mockClear();
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Retry' }));
|
||||
await waitFor(() => expect(requests()).toHaveLength(3));
|
||||
expect(requests()[1]).toEqual({ search: 'team', limit: 25 });
|
||||
expect(requests()[2]).toEqual(requests()[1]);
|
||||
expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument();
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('combobox')).toHaveValue('');
|
||||
expect(screen.getByText('Results for “team”')).toBeVisible();
|
||||
await userEvent.click(screen.getByRole('combobox'));
|
||||
expect(requests()).toHaveLength(3);
|
||||
expect(
|
||||
screen.getAllByRole('option', {
|
||||
name: kind === 'repository' ? 'team/workflows' : 'team/main',
|
||||
}),
|
||||
).toHaveLength(1);
|
||||
await userEvent.click(await screen.findByRole('option', { name: 'team/other' }));
|
||||
expect(emitted('update:modelValue')).toEqual([
|
||||
[kind === 'repository' ? otherRepository.remoteUrl : 'team/other'],
|
||||
]);
|
||||
expect(emitted('selected')).toEqual(kind === 'repository' ? [[otherRepository]] : undefined);
|
||||
expect(screen.queryByText('Results for “team”')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('uses native Tab order to leave the picker without a pagination action', async () => {
|
||||
api.fetchPromotionRepositories.mockResolvedValue({ data: [repository], nextCursor: 'next' });
|
||||
const { container } = renderSelect();
|
||||
container.insertAdjacentHTML('beforeend', '<button type="button">After picker</button>');
|
||||
await flushPromises();
|
||||
await userEvent.click(screen.getByRole('combobox'));
|
||||
await userEvent.tab();
|
||||
expect(screen.getByRole('button', { name: 'After picker' })).toHaveFocus();
|
||||
expect(api.fetchPromotionRepositories).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows one retry action for an initial failure and then an empty result', async () => {
|
||||
api.fetchPromotionRepositories
|
||||
.mockRejectedValueOnce(new Error('GitLab is unavailable.'))
|
||||
.mockResolvedValueOnce({ data: [], nextCursor: null });
|
||||
renderSelect();
|
||||
await screen.findByRole('alert');
|
||||
await userEvent.click(screen.getByRole('combobox'));
|
||||
expect(screen.getAllByRole('button', { name: 'Retry' })).toHaveLength(1);
|
||||
await userEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' }));
|
||||
await waitFor(() => expect(api.fetchPromotionRepositories).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument());
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('No repositories found');
|
||||
});
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
<script setup lang="ts">
|
||||
import type { PromotionRepository } from '@n8n/api-types';
|
||||
import { N8nButton, N8nNotice, N8nOption, N8nSelect, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { usePromotionGitHostDiscovery } from '../composables/usePromotionGitHostDiscovery';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
providerId: string;
|
||||
kind: 'repository' | 'branch';
|
||||
repositoryId?: string;
|
||||
revision?: number;
|
||||
inputId: string;
|
||||
descriptionId?: string;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string];
|
||||
selected: [repository: PromotionRepository];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const { options, search, loading, error, onSearch, cancel, retry } =
|
||||
usePromotionGitHostDiscovery(props);
|
||||
const selectedRepository = ref<PromotionRepository>();
|
||||
watch(
|
||||
() => [props.providerId, props.revision],
|
||||
() => {
|
||||
selectedRepository.value = undefined;
|
||||
},
|
||||
);
|
||||
|
||||
const unavailable = computed(
|
||||
() => props.disabled || (props.kind === 'branch' && !props.repositoryId),
|
||||
);
|
||||
const emptyMessage = computed(() =>
|
||||
i18n.baseText(
|
||||
props.kind === 'repository'
|
||||
? 'settings.promotions.discovery.repository.empty'
|
||||
: 'settings.promotions.discovery.branch.empty',
|
||||
),
|
||||
);
|
||||
const visibleOptions = computed(() => {
|
||||
if (!props.modelValue || options.value.some(({ value }) => value === props.modelValue))
|
||||
return options.value;
|
||||
const selected =
|
||||
selectedRepository.value?.remoteUrl === props.modelValue
|
||||
? {
|
||||
value: props.modelValue,
|
||||
label: selectedRepository.value.fullPath,
|
||||
repository: selectedRepository.value,
|
||||
}
|
||||
: { value: props.modelValue, label: props.modelValue };
|
||||
return [selected, ...options.value];
|
||||
});
|
||||
|
||||
function select(value: string) {
|
||||
const option = visibleOptions.value.find((item) => item.value === value);
|
||||
cancel();
|
||||
onSearch('');
|
||||
emit('update:modelValue', value);
|
||||
if (option?.repository) {
|
||||
selectedRepository.value = option.repository;
|
||||
emit('selected', option.repository);
|
||||
}
|
||||
}
|
||||
|
||||
function onInput(event: Event) {
|
||||
// Dropdown close events must not clear the search used by retry.
|
||||
if (event.target instanceof HTMLInputElement) onSearch(event.target.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.picker" @input="onInput">
|
||||
<N8nSelect
|
||||
:id="inputId"
|
||||
:aria-describedby="descriptionId"
|
||||
:model-value="modelValue"
|
||||
:disabled="unavailable"
|
||||
:loading="loading"
|
||||
:teleported="false"
|
||||
filterable
|
||||
remote
|
||||
:placeholder="
|
||||
i18n.baseText(
|
||||
kind === 'repository'
|
||||
? 'settings.promotions.discovery.repository.placeholder'
|
||||
: 'settings.promotions.discovery.branch.placeholder',
|
||||
)
|
||||
"
|
||||
:no-data-text="emptyMessage"
|
||||
:no-match-text="emptyMessage"
|
||||
@update:model-value="select"
|
||||
>
|
||||
<N8nOption
|
||||
v-for="option in visibleOptions"
|
||||
:key="`${option.value}:${option.label}`"
|
||||
:value="option.value"
|
||||
:label="option.label"
|
||||
/>
|
||||
</N8nSelect>
|
||||
<N8nText v-if="search" size="small" color="text-light" role="status">
|
||||
{{ i18n.baseText('settings.promotions.discovery.searchHint', { interpolate: { search } }) }}
|
||||
</N8nText>
|
||||
<N8nText
|
||||
v-if="!loading && !error && !unavailable && options.length === 0 && (!modelValue || search)"
|
||||
size="small"
|
||||
color="text-light"
|
||||
role="status"
|
||||
>
|
||||
{{ emptyMessage }}
|
||||
</N8nText>
|
||||
<N8nNotice v-if="error" theme="danger" role="alert">
|
||||
{{ error }}
|
||||
<N8nButton
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="unavailable || loading"
|
||||
@click.stop="retry"
|
||||
>
|
||||
{{ i18n.baseText('generic.retry') }}
|
||||
</N8nButton>
|
||||
</N8nNotice>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--xs);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { promotionProviderTypeCapabilities, supportsPromotionAuthType } from '@n8n/api-types';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import {
|
||||
N8nButton,
|
||||
|
|
@ -18,7 +19,7 @@ import {
|
|||
} from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { computed, nextTick, onMounted, reactive, ref, useTemplateRef } from 'vue';
|
||||
import { computed, nextTick, onMounted, reactive, ref, useTemplateRef, watch } from 'vue';
|
||||
|
||||
import { MODAL_CONFIRM } from '@/app/constants';
|
||||
import {
|
||||
|
|
@ -34,7 +35,9 @@ import {
|
|||
buildProviderCreatePayload,
|
||||
buildProviderUpdatePayload,
|
||||
emptyProviderForm,
|
||||
isSamePromotionHostUrl,
|
||||
providerFormFrom,
|
||||
type PromotionProviderChange,
|
||||
type ProviderFormState,
|
||||
} from '../promotionsSettings.utils';
|
||||
|
||||
|
|
@ -45,7 +48,7 @@ const props = defineProps<{
|
|||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean];
|
||||
saved: [id: string];
|
||||
saved: [change: PromotionProviderChange];
|
||||
deleted: [id: string];
|
||||
}>();
|
||||
|
||||
|
|
@ -62,10 +65,31 @@ const usedBy = ref<PromotionConnectionSummary[]>([]);
|
|||
const generatedPublicKey = ref<string | null>(null);
|
||||
const isLoading = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
const saveError = ref<string>();
|
||||
const nameInput = useTemplateRef<InstanceType<typeof N8nInput>>('nameInput');
|
||||
const doneButton = useTemplateRef<{ $el?: HTMLElement }>('doneButton');
|
||||
|
||||
const isEdit = computed(() => props.providerId !== undefined);
|
||||
const capabilities = computed(() => promotionProviderTypeCapabilities[form.type]);
|
||||
|
||||
watch(
|
||||
() => form.type,
|
||||
() => {
|
||||
if (!supportsPromotionAuthType(form.type, form.authType)) {
|
||||
form.authType = capabilities.value.authTypes[0];
|
||||
}
|
||||
form.username = '';
|
||||
form.password = '';
|
||||
saveError.value = undefined;
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [form.baseUrl, form.password],
|
||||
() => {
|
||||
saveError.value = undefined;
|
||||
},
|
||||
);
|
||||
|
||||
const title = computed(() => {
|
||||
if (step.value === 'key') return i18n.baseText('settings.promotions.provider.dialog.title.key');
|
||||
|
|
@ -95,10 +119,18 @@ const usedByNames = computed(() => usedBy.value.map((connection) => connection.n
|
|||
|
||||
const hasUsername = computed(() => form.username.trim().length > 0);
|
||||
const hasPassword = computed(() => form.password.length > 0);
|
||||
const hasHostUrlChange = computed(() => {
|
||||
const config = current.value?.config;
|
||||
return config && 'baseUrl' in config
|
||||
? !isSamePromotionHostUrl(config.baseUrl, form.baseUrl)
|
||||
: false;
|
||||
});
|
||||
|
||||
// Token credentials change as a pair. Empty edit fields keep the stored credentials.
|
||||
const areCredentialsIncomplete = computed(() => {
|
||||
if (form.authType !== 'token') return false;
|
||||
if (capabilities.value.tokenUsername)
|
||||
return (!isEdit.value || hasHostUrlChange.value) && !hasPassword.value;
|
||||
if (!isEdit.value) return !(hasUsername.value && hasPassword.value);
|
||||
if (!hasUsername.value && !hasPassword.value) return false;
|
||||
return !(hasUsername.value && hasPassword.value);
|
||||
|
|
@ -111,6 +143,8 @@ const hasChanges = computed(() => {
|
|||
|
||||
const saveDisabledReason = computed(() => {
|
||||
if (!form.name.trim()) return i18n.baseText('settings.promotions.provider.form.incomplete');
|
||||
if (capabilities.value.hasHostApi && !form.baseUrl.trim())
|
||||
return i18n.baseText('settings.promotions.provider.form.incomplete');
|
||||
if (areCredentialsIncomplete.value)
|
||||
return i18n.baseText('settings.promotions.provider.form.credentials.required');
|
||||
if (!hasChanges.value) return i18n.baseText('settings.promotions.provider.form.noChanges');
|
||||
|
|
@ -177,12 +211,14 @@ async function submit() {
|
|||
|
||||
const existing = current.value;
|
||||
isSubmitting.value = true;
|
||||
saveError.value = undefined;
|
||||
try {
|
||||
if (existing) {
|
||||
const payload = buildProviderUpdatePayload(form, existing);
|
||||
const updated = await updatePromotionProvider(
|
||||
rootStore.publicApiContext,
|
||||
existing.id,
|
||||
buildProviderUpdatePayload(form, existing),
|
||||
payload,
|
||||
);
|
||||
const rotatedKey =
|
||||
'publicKey' in updated.config && updated.config.publicKey !== storedPublicKey.value
|
||||
|
|
@ -193,7 +229,11 @@ async function submit() {
|
|||
title: i18n.baseText('settings.promotions.provider.toast.updated'),
|
||||
type: 'success',
|
||||
});
|
||||
emit('saved', updated.id);
|
||||
emit('saved', {
|
||||
id: updated.id,
|
||||
hostChanged: payload.config !== undefined,
|
||||
credentialsChanged: payload.auth !== undefined,
|
||||
});
|
||||
|
||||
if (rotatedKey) {
|
||||
generatedPublicKey.value = rotatedKey;
|
||||
|
|
@ -211,7 +251,7 @@ async function submit() {
|
|||
title: i18n.baseText('settings.promotions.provider.toast.created'),
|
||||
type: 'success',
|
||||
});
|
||||
emit('saved', created.provider.id);
|
||||
emit('saved', { id: created.provider.id, hostChanged: false, credentialsChanged: false });
|
||||
|
||||
if (created.publicKey) {
|
||||
generatedPublicKey.value = created.publicKey;
|
||||
|
|
@ -222,7 +262,10 @@ async function submit() {
|
|||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('settings.promotions.provider.toast.error.save'));
|
||||
saveError.value =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: i18n.baseText('settings.promotions.provider.toast.error.save');
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
|
|
@ -322,12 +365,47 @@ async function onDelete() {
|
|||
id="promotion-provider-name"
|
||||
ref="nameInput"
|
||||
v-model="form.name"
|
||||
:disabled="isLoading"
|
||||
:disabled="isLoading || isSubmitting"
|
||||
data-test-id="promotion-provider-name-input"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
|
||||
<N8nInputLabel
|
||||
input-name="promotion-provider-type"
|
||||
:label="i18n.baseText('settings.promotions.provider.form.type')"
|
||||
>
|
||||
<N8nSelect
|
||||
id="promotion-provider-type"
|
||||
v-model="form.type"
|
||||
:teleported="false"
|
||||
:disabled="isLoading || isSubmitting || isEdit"
|
||||
data-test-id="promotion-provider-type-select"
|
||||
>
|
||||
<N8nOption value="git" :label="i18n.baseText('settings.promotions.providers.type.git')" />
|
||||
<N8nOption
|
||||
value="gitlab"
|
||||
:label="i18n.baseText('settings.promotions.providers.type.gitlab')"
|
||||
/>
|
||||
</N8nSelect>
|
||||
</N8nInputLabel>
|
||||
|
||||
<N8nInputLabel
|
||||
v-if="capabilities.hasHostApi"
|
||||
input-name="promotion-provider-base-url"
|
||||
:label="i18n.baseText('settings.promotions.provider.form.baseUrl')"
|
||||
required
|
||||
>
|
||||
<N8nInput
|
||||
id="promotion-provider-base-url"
|
||||
v-model="form.baseUrl"
|
||||
:placeholder="i18n.baseText('settings.promotions.provider.form.baseUrl.placeholder')"
|
||||
:disabled="isLoading || isSubmitting"
|
||||
data-test-id="promotion-provider-base-url-input"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
|
||||
<N8nInputLabel
|
||||
v-if="capabilities.authTypes.length > 1"
|
||||
input-name="promotion-provider-auth-type"
|
||||
:label="i18n.baseText('settings.promotions.provider.form.authType')"
|
||||
:tooltip-text="
|
||||
|
|
@ -338,7 +416,7 @@ async function onDelete() {
|
|||
id="promotion-provider-auth-type"
|
||||
v-model="form.authType"
|
||||
:teleported="false"
|
||||
:disabled="isLoading || isEdit"
|
||||
:disabled="isLoading || isSubmitting || isEdit"
|
||||
data-test-id="promotion-provider-auth-type-select"
|
||||
>
|
||||
<N8nOption
|
||||
|
|
@ -362,7 +440,7 @@ async function onDelete() {
|
|||
id="promotion-provider-key-type"
|
||||
v-model="form.keyType"
|
||||
:teleported="false"
|
||||
:disabled="isLoading"
|
||||
:disabled="isLoading || isSubmitting"
|
||||
data-test-id="promotion-provider-key-type-select"
|
||||
>
|
||||
<N8nOption value="ed25519" label="ED25519" />
|
||||
|
|
@ -386,7 +464,7 @@ async function onDelete() {
|
|||
<N8nCheckbox
|
||||
v-model="form.regenerateKey"
|
||||
:label="i18n.baseText('settings.promotions.provider.regenerateKey')"
|
||||
:disabled="isLoading"
|
||||
:disabled="isLoading || isSubmitting"
|
||||
data-test-id="promotion-provider-regenerate-key"
|
||||
/>
|
||||
<N8nText v-if="form.regenerateKey" size="small" color="text-light">
|
||||
|
|
@ -397,6 +475,7 @@ async function onDelete() {
|
|||
|
||||
<template v-else>
|
||||
<N8nInputLabel
|
||||
v-if="!capabilities.tokenUsername"
|
||||
input-name="promotion-provider-username"
|
||||
:label="i18n.baseText('settings.promotions.provider.form.username')"
|
||||
:required="!isEdit"
|
||||
|
|
@ -404,35 +483,69 @@ async function onDelete() {
|
|||
<N8nInput
|
||||
id="promotion-provider-username"
|
||||
v-model="form.username"
|
||||
:disabled="isLoading"
|
||||
:disabled="isLoading || isSubmitting"
|
||||
data-test-id="promotion-provider-username-input"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
|
||||
<N8nInputLabel
|
||||
input-name="promotion-provider-password"
|
||||
:label="i18n.baseText('settings.promotions.provider.form.password')"
|
||||
:required="!isEdit"
|
||||
:label="
|
||||
i18n.baseText(
|
||||
capabilities.hasHostApi
|
||||
? 'settings.promotions.provider.form.accessToken'
|
||||
: 'settings.promotions.provider.form.password',
|
||||
)
|
||||
"
|
||||
:required="!isEdit || hasHostUrlChange"
|
||||
>
|
||||
<N8nInput
|
||||
id="promotion-provider-password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:disabled="isLoading"
|
||||
:disabled="isLoading || isSubmitting"
|
||||
data-test-id="promotion-provider-password-input"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
|
||||
<N8nText v-if="hasUsername !== hasPassword" size="small" color="danger">
|
||||
<N8nText
|
||||
v-if="!capabilities.tokenUsername && hasUsername !== hasPassword"
|
||||
size="small"
|
||||
color="danger"
|
||||
>
|
||||
{{ i18n.baseText('settings.promotions.provider.form.credentials.required') }}
|
||||
</N8nText>
|
||||
|
||||
<N8nText v-if="isEdit" size="small" color="text-light">
|
||||
{{ i18n.baseText('settings.promotions.provider.form.credentials.keepHint') }}
|
||||
<N8nText
|
||||
v-if="isEdit"
|
||||
size="small"
|
||||
:color="capabilities.hasHostApi ? 'text-base' : 'text-light'"
|
||||
>
|
||||
{{
|
||||
i18n.baseText(
|
||||
capabilities.hasHostApi
|
||||
? hasHostUrlChange
|
||||
? 'settings.promotions.provider.form.accessToken.urlChangeHint'
|
||||
: 'settings.promotions.provider.form.accessToken.keepHint'
|
||||
: 'settings.promotions.provider.form.credentials.keepHint',
|
||||
)
|
||||
}}
|
||||
</N8nText>
|
||||
<N8nText v-if="capabilities.hasHostApi" size="small" color="text-base">
|
||||
{{ i18n.baseText('settings.promotions.provider.form.accessToken.hint') }}
|
||||
</N8nText>
|
||||
</template>
|
||||
|
||||
<N8nNotice
|
||||
v-if="saveError"
|
||||
theme="danger"
|
||||
role="alert"
|
||||
data-test-id="promotion-provider-save-error"
|
||||
>
|
||||
{{ saveError }}
|
||||
</N8nNotice>
|
||||
|
||||
<N8nDialogFooter>
|
||||
<N8nButton
|
||||
v-if="isEdit"
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export type ConnectionSaveResult = {
|
|||
error?: unknown;
|
||||
};
|
||||
|
||||
/** Tries every write and returns the successful state and failed writes. */
|
||||
/** Saves independent writes. Target changes must succeed before direction settings. */
|
||||
export function usePromotionConnectionSave() {
|
||||
const rootStore = useRootStore();
|
||||
|
||||
|
|
@ -69,15 +69,26 @@ export function usePromotionConnectionSave() {
|
|||
let connection = current;
|
||||
const failed: ConnectionWrite[] = [];
|
||||
let error: unknown;
|
||||
let targetFailed = false;
|
||||
|
||||
isSaving.value = true;
|
||||
try {
|
||||
for (const write of writes) {
|
||||
if (targetFailed && write.kind !== 'connection') {
|
||||
failed.push(write);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
connection = await runWrite(current.id, write, connection);
|
||||
} catch (writeError) {
|
||||
failed.push(write);
|
||||
error ??= writeError;
|
||||
if (
|
||||
write.kind === 'connection' &&
|
||||
(write.payload.target !== undefined || write.payload.providerId !== undefined)
|
||||
) {
|
||||
targetFailed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { flushPromises } from '@vue/test-utils';
|
||||
import { effectScope, reactive, nextTick } from 'vue';
|
||||
|
||||
import { fetchPromotionBranches, fetchPromotionRepositories } from '../promotionsSettings.api';
|
||||
import { usePromotionGitHostDiscovery } from './usePromotionGitHostDiscovery';
|
||||
|
||||
vi.mock('../promotionsSettings.api', () => ({
|
||||
fetchPromotionRepositories: vi.fn(),
|
||||
fetchPromotionBranches: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
createTestingPinia();
|
||||
vi.mocked(fetchPromotionBranches).mockResolvedValue({
|
||||
data: [{ name: 'main', isDefault: true }],
|
||||
nextCursor: null,
|
||||
});
|
||||
});
|
||||
|
||||
// The disabled UI cannot exercise a search call without a selected repository.
|
||||
it('ignores branch searches until a repository is selected', async () => {
|
||||
const context = reactive<{ providerId: string; kind: 'branch'; repositoryId?: string }>({
|
||||
providerId: 'gitlab-1',
|
||||
kind: 'branch',
|
||||
});
|
||||
const scope = effectScope();
|
||||
try {
|
||||
const discovery = scope.run(() => usePromotionGitHostDiscovery(context));
|
||||
if (!discovery) throw new Error('The discovery scope did not start');
|
||||
discovery.onSearch('main');
|
||||
expect(discovery.loading.value).toBe(false);
|
||||
expect(discovery.search.value).toBe('');
|
||||
expect(fetchPromotionBranches).not.toHaveBeenCalled();
|
||||
expect(fetchPromotionRepositories).not.toHaveBeenCalled();
|
||||
context.repositoryId = '42';
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
expect(discovery.options.value).toEqual([{ value: 'main', label: 'main' }]);
|
||||
expect(discovery.loading.value).toBe(false);
|
||||
} finally {
|
||||
scope.stop();
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import type { PromotionRepository } from '@n8n/api-types';
|
||||
import { getDebounceTime } from '@n8n/composables/useDebounce';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { onScopeDispose, ref, watch } from 'vue';
|
||||
|
||||
import { DEBOUNCE_TIME } from '@/app/constants';
|
||||
import { fetchPromotionBranches, fetchPromotionRepositories } from '../promotionsSettings.api';
|
||||
|
||||
type DiscoveryContext = {
|
||||
providerId: string;
|
||||
kind: 'repository' | 'branch';
|
||||
repositoryId?: string;
|
||||
revision?: number;
|
||||
};
|
||||
|
||||
type DiscoveryOption = { value: string; label: string; repository?: PromotionRepository };
|
||||
|
||||
/** Loads one searchable list. */
|
||||
export function usePromotionGitHostDiscovery(context: DiscoveryContext) {
|
||||
const rootStore = useRootStore();
|
||||
const i18n = useI18n();
|
||||
const options = ref<DiscoveryOption[]>([]);
|
||||
const search = ref('');
|
||||
const loading = ref(false);
|
||||
const error = ref<string>();
|
||||
let generation = 0;
|
||||
|
||||
async function load(requestGeneration: number) {
|
||||
const repositoryId = context.repositoryId;
|
||||
if (requestGeneration !== generation || (context.kind === 'branch' && !repositoryId)) return;
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
const query = { search: search.value || undefined, limit: 25 };
|
||||
let pageOptions: DiscoveryOption[];
|
||||
if (context.kind === 'repository') {
|
||||
const page = await fetchPromotionRepositories(
|
||||
rootStore.publicApiContext,
|
||||
context.providerId,
|
||||
query,
|
||||
);
|
||||
pageOptions = page.data.map((repository) => ({
|
||||
value: repository.remoteUrl,
|
||||
label: repository.fullPath,
|
||||
repository,
|
||||
}));
|
||||
} else if (repositoryId) {
|
||||
const page = await fetchPromotionBranches(
|
||||
rootStore.publicApiContext,
|
||||
context.providerId,
|
||||
repositoryId,
|
||||
query,
|
||||
);
|
||||
pageOptions = page.data.map((branch) => ({ value: branch.name, label: branch.name }));
|
||||
} else return;
|
||||
if (requestGeneration !== generation) return;
|
||||
options.value = pageOptions;
|
||||
} catch (cause) {
|
||||
if (requestGeneration !== generation) return;
|
||||
error.value =
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: i18n.baseText('settings.promotions.discovery.error');
|
||||
} finally {
|
||||
if (requestGeneration === generation) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedLoad = useDebounceFn(load, getDebounceTime(DEBOUNCE_TIME.INPUT.SEARCH));
|
||||
function onSearch(value: string) {
|
||||
if (context.kind === 'branch' && !context.repositoryId) return;
|
||||
const query = value.trim();
|
||||
if (query === search.value) return;
|
||||
search.value = query;
|
||||
options.value = [];
|
||||
error.value = undefined;
|
||||
loading.value = true;
|
||||
void debouncedLoad(++generation);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
generation++;
|
||||
loading.value = false;
|
||||
error.value = undefined;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [context.providerId, context.repositoryId, context.kind, context.revision],
|
||||
() => {
|
||||
cancel();
|
||||
search.value = '';
|
||||
options.value = [];
|
||||
void load(generation);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onScopeDispose(() => {
|
||||
generation++;
|
||||
});
|
||||
|
||||
return {
|
||||
options,
|
||||
search,
|
||||
loading,
|
||||
error,
|
||||
onSearch,
|
||||
cancel,
|
||||
retry: async () => await load(generation),
|
||||
};
|
||||
}
|
||||
|
|
@ -3,11 +3,43 @@ import {
|
|||
applyProjectSelection,
|
||||
continueApplyProjectSelection,
|
||||
continueApplyPromotion,
|
||||
fetchPromotionRepositories,
|
||||
fetchPromotionBranches,
|
||||
} from './promotionsSettings.api';
|
||||
import { blocked } from './__tests__/bindings.fixtures';
|
||||
|
||||
vi.mock('@n8n/rest-api-client', () => ({ request: vi.fn() }));
|
||||
|
||||
it('requests repositories with the encoded provider, query, and public API base URL', async () => {
|
||||
const result = { data: [], nextCursor: 'next' };
|
||||
const context = { baseUrl: '/custom/api/v2' };
|
||||
const query = { search: 'team', cursor: 'page', limit: 50 };
|
||||
vi.mocked(request).mockResolvedValue(result);
|
||||
expect(await fetchPromotionRepositories(context, 'provider/one', query)).toBe(result);
|
||||
expect(request).toHaveBeenLastCalledWith({
|
||||
method: 'GET',
|
||||
baseURL: context.baseUrl,
|
||||
endpoint: '/promotions/providers/provider%2Fone/repositories',
|
||||
data: query,
|
||||
});
|
||||
});
|
||||
|
||||
it('requests branches with the encoded repository path and returns the complete page', async () => {
|
||||
const result = { data: [], nextCursor: 'next' };
|
||||
const context = { baseUrl: '/custom/api/v2' };
|
||||
const query = { search: 'release/', cursor: 'page', limit: 50 };
|
||||
vi.mocked(request).mockResolvedValue(result);
|
||||
expect(await fetchPromotionBranches(context, 'provider/one', 'team/workflows', query)).toBe(
|
||||
result,
|
||||
);
|
||||
expect(request).toHaveBeenLastCalledWith({
|
||||
method: 'GET',
|
||||
baseURL: context.baseUrl,
|
||||
endpoint: '/promotions/providers/provider%2Fone/repositories/team%2Fworkflows/branches',
|
||||
data: query,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the public API context and returns the complete result', async () => {
|
||||
const result = blocked();
|
||||
const context = { baseUrl: '/custom/api/v2' };
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import type {
|
|||
ContinueApplySelectionDto,
|
||||
CreatePromotionConnectionDto,
|
||||
CreatePromotionProviderDto,
|
||||
ListPromotionRepositoriesQueryDto,
|
||||
ListPromotionBranchesQueryDto,
|
||||
PromotionApplyConfigPublicDto,
|
||||
PromotionCheckoutPublicDto,
|
||||
PromotionConnectionPublicDto,
|
||||
|
|
@ -17,6 +19,8 @@ import type {
|
|||
PromotionProviderCreatedPublicDto,
|
||||
PromotionProviderListPublicDto,
|
||||
PromotionProviderPublicDto,
|
||||
PromotionRepositoryListPublicDto,
|
||||
PromotionBranchListPublicDto,
|
||||
PromotionPromoteConfigPublicDto,
|
||||
UpdatePromotionConnectionDto,
|
||||
UpdatePromotionProviderDto,
|
||||
|
|
@ -35,6 +39,31 @@ export type { PromotionConnectionSummary };
|
|||
|
||||
const promotionsApiRoot = '/promotions';
|
||||
|
||||
export const fetchPromotionRepositories = async (
|
||||
context: PublicApiContext,
|
||||
providerId: string,
|
||||
query: Partial<ListPromotionRepositoriesQueryDto> = {},
|
||||
): Promise<PromotionRepositoryListPublicDto> =>
|
||||
await request({
|
||||
method: 'GET',
|
||||
baseURL: context.baseUrl,
|
||||
endpoint: `${promotionsApiRoot}/providers/${encodeURIComponent(providerId)}/repositories`,
|
||||
data: query,
|
||||
});
|
||||
|
||||
export const fetchPromotionBranches = async (
|
||||
context: PublicApiContext,
|
||||
providerId: string,
|
||||
repositoryId: string,
|
||||
query: Partial<ListPromotionBranchesQueryDto> = {},
|
||||
): Promise<PromotionBranchListPublicDto> =>
|
||||
await request({
|
||||
method: 'GET',
|
||||
baseURL: context.baseUrl,
|
||||
endpoint: `${promotionsApiRoot}/providers/${encodeURIComponent(providerId)}/repositories/${encodeURIComponent(repositoryId)}/branches`,
|
||||
data: query,
|
||||
});
|
||||
|
||||
async function fetchAllPages<T>(
|
||||
fetchPage: (cursor?: string) => Promise<{ data: T[]; nextCursor: string | null }>,
|
||||
): Promise<T[]> {
|
||||
|
|
|
|||
|
|
@ -3,17 +3,21 @@ import type {
|
|||
CreatePromotionProviderDto,
|
||||
PromotionDirection,
|
||||
PromotionProviderAuthType,
|
||||
PromotionProviderType,
|
||||
PromotionSshKeyType,
|
||||
UpdatePromotionConnectionDto,
|
||||
UpdatePromotionProviderDto,
|
||||
UpsertPromotionApplyConfigDto,
|
||||
UpsertPromotionPromoteConfigDto,
|
||||
} from '@n8n/api-types';
|
||||
import { promotionProviderTypeCapabilities } from '@n8n/api-types';
|
||||
|
||||
import type { PromotionConnection, PromotionProvider } from './promotionsSettings.api';
|
||||
|
||||
export type ProviderFormState = {
|
||||
name: string;
|
||||
type: PromotionProviderType;
|
||||
baseUrl: string;
|
||||
/** Cannot change after creation. */
|
||||
authType: PromotionProviderAuthType;
|
||||
keyType: PromotionSshKeyType;
|
||||
|
|
@ -22,6 +26,20 @@ export type ProviderFormState = {
|
|||
regenerateKey: boolean;
|
||||
};
|
||||
|
||||
export type PromotionProviderChange = {
|
||||
id: string;
|
||||
hostChanged: boolean;
|
||||
credentialsChanged: boolean;
|
||||
};
|
||||
|
||||
export const isSamePromotionHostUrl = (current: string, next: string): boolean => {
|
||||
try {
|
||||
return new URL(current).href.replace(/\/$/, '') === new URL(next).href.replace(/\/$/, '');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
type DirectionFormState = {
|
||||
enabled: boolean;
|
||||
/** Keep this name when updating the settings. If absent, the API uses the direction label. */
|
||||
|
|
@ -32,6 +50,7 @@ export type ConnectionFormState = {
|
|||
name: string;
|
||||
providerId: string;
|
||||
remoteUrl: string;
|
||||
repositoryPath?: string;
|
||||
apply: DirectionFormState & { branchName: string };
|
||||
promote: DirectionFormState & { baseBranchName: string; createBranchOnPromotion: boolean };
|
||||
};
|
||||
|
|
@ -44,6 +63,8 @@ export type ConnectionWrite =
|
|||
|
||||
export const emptyProviderForm = (): ProviderFormState => ({
|
||||
name: '',
|
||||
type: 'git',
|
||||
baseUrl: '',
|
||||
authType: 'ssh-key',
|
||||
keyType: 'ed25519',
|
||||
username: '',
|
||||
|
|
@ -54,17 +75,20 @@ export const emptyProviderForm = (): ProviderFormState => ({
|
|||
export const providerFormFrom = (provider: PromotionProvider): ProviderFormState => ({
|
||||
...emptyProviderForm(),
|
||||
name: provider.name,
|
||||
type: provider.type,
|
||||
baseUrl: 'baseUrl' in provider.config ? provider.config.baseUrl : '',
|
||||
authType: provider.authType,
|
||||
keyType: 'keyType' in provider.config ? provider.config.keyType : 'ed25519',
|
||||
});
|
||||
|
||||
/** New forms must set whether promotion creates a branch. */
|
||||
/** Keep field order aligned with connectionFormFrom for JSON-based dirty checks. */
|
||||
export const emptyConnectionForm = (): ConnectionFormState => ({
|
||||
name: '',
|
||||
providerId: '',
|
||||
remoteUrl: '',
|
||||
apply: { enabled: false, branchName: '' },
|
||||
promote: { enabled: false, baseBranchName: '', createBranchOnPromotion: false },
|
||||
repositoryPath: undefined,
|
||||
apply: { enabled: false, name: undefined, branchName: '' },
|
||||
promote: { enabled: false, name: undefined, baseBranchName: '', createBranchOnPromotion: false },
|
||||
});
|
||||
|
||||
export const connectionFormFrom = (connection: PromotionConnection): ConnectionFormState => {
|
||||
|
|
@ -74,6 +98,7 @@ export const connectionFormFrom = (connection: PromotionConnection): ConnectionF
|
|||
name: connection.name,
|
||||
providerId: connection.provider.id,
|
||||
remoteUrl: connection.target.remoteUrl,
|
||||
repositoryPath: connection.target.repositoryPath,
|
||||
apply: {
|
||||
enabled: apply !== undefined,
|
||||
name: apply?.name,
|
||||
|
|
@ -106,11 +131,18 @@ export const buildProviderCreatePayload = (
|
|||
form: ProviderFormState,
|
||||
): CreatePromotionProviderDto => ({
|
||||
name: form.name.trim(),
|
||||
type: 'git',
|
||||
type: form.type,
|
||||
...(promotionProviderTypeCapabilities[form.type].hasHostApi && {
|
||||
config: { schemaVersion: 1, baseUrl: form.baseUrl.trim() },
|
||||
}),
|
||||
auth:
|
||||
form.authType === 'ssh-key'
|
||||
? { authType: 'ssh-key', keyType: form.keyType }
|
||||
: { authType: 'token', username: form.username.trim(), password: form.password },
|
||||
: {
|
||||
authType: 'token',
|
||||
...(form.type === 'git' && { username: form.username.trim() }),
|
||||
password: form.password,
|
||||
},
|
||||
});
|
||||
|
||||
/** If credentials are absent, the API keeps them. SSH rotation keeps the key type. */
|
||||
|
|
@ -122,13 +154,20 @@ export const buildProviderUpdatePayload = (
|
|||
|
||||
const name = form.name.trim();
|
||||
if (name !== current.name) payload.name = name;
|
||||
if (
|
||||
'baseUrl' in current.config &&
|
||||
!isSamePromotionHostUrl(current.config.baseUrl, form.baseUrl)
|
||||
) {
|
||||
payload.config = { schemaVersion: 1, baseUrl: form.baseUrl.trim() };
|
||||
}
|
||||
const username = form.username.trim();
|
||||
|
||||
if (form.authType === 'ssh-key' && form.regenerateKey) {
|
||||
payload.auth = { authType: 'ssh-key' };
|
||||
} else if (form.authType === 'token' && form.username.trim() && form.password) {
|
||||
} else if (form.authType === 'token' && form.password && (current.type !== 'git' || username)) {
|
||||
payload.auth = {
|
||||
authType: 'token',
|
||||
username: form.username.trim(),
|
||||
...(current.type === 'git' && { username }),
|
||||
password: form.password,
|
||||
};
|
||||
}
|
||||
|
|
@ -161,7 +200,11 @@ export const planConnectionWrites = (
|
|||
if (name !== current.name) payload.name = name;
|
||||
|
||||
const remoteUrl = form.remoteUrl.trim();
|
||||
if (remoteUrl !== current.target.remoteUrl) {
|
||||
// A matching URL still needs a write when it converts a saved URL target to a path.
|
||||
if (
|
||||
remoteUrl !== current.target.remoteUrl ||
|
||||
form.repositoryPath !== current.target.repositoryPath
|
||||
) {
|
||||
payload.target = { schemaVersion: 1, remoteUrl };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,28 @@
|
|||
import type { PromotionBranchListPublicDto, PromotionRepository } from '@n8n/api-types';
|
||||
import { ResponseError, type IUser } from '@n8n/rest-api-client';
|
||||
import { useUsersStore } from '@n8n/stores/users.store';
|
||||
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { flushPromises } from '@vue/test-utils';
|
||||
import { screen, waitFor, within } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { getDropdownItems } from '@/__tests__/utils';
|
||||
import { getDropdownItems, mockedStore } from '@/__tests__/utils';
|
||||
import { MODAL_CANCEL, MODAL_CONFIRM } from '@/app/constants';
|
||||
import {
|
||||
invalidateInstancePromotionConnection,
|
||||
useInstancePromotionConnection,
|
||||
} from '../composables/useInstancePromotionConnection';
|
||||
import type * as PromotionsApi from '../promotionsSettings.api';
|
||||
import type { PromotionConnection, PromotionProvider } from '../promotionsSettings.api';
|
||||
import PromotionsSettingsView from './PromotionsSettingsView.vue';
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
fetchPromotionProviders: vi.fn<typeof PromotionsApi.fetchPromotionProviders>(),
|
||||
fetchPromotionRepositories: vi.fn<typeof PromotionsApi.fetchPromotionRepositories>(),
|
||||
fetchPromotionBranches: vi.fn<typeof PromotionsApi.fetchPromotionBranches>(),
|
||||
fetchPromotionProvider: vi.fn<typeof PromotionsApi.fetchPromotionProvider>(),
|
||||
createPromotionProvider: vi.fn<typeof PromotionsApi.createPromotionProvider>(),
|
||||
updatePromotionProvider: vi.fn<typeof PromotionsApi.updatePromotionProvider>(),
|
||||
|
|
@ -22,6 +34,8 @@ const api = vi.hoisted(() => ({
|
|||
upsertPromotionApplyConfig: vi.fn<typeof PromotionsApi.upsertPromotionApplyConfig>(),
|
||||
upsertPromotionPromoteConfig: vi.fn<typeof PromotionsApi.upsertPromotionPromoteConfig>(),
|
||||
deletePromotionConfig: vi.fn<typeof PromotionsApi.deletePromotionConfig>(),
|
||||
clonePromotionCheckout: vi.fn<typeof PromotionsApi.clonePromotionCheckout>(),
|
||||
disconnectPromotionCheckout: vi.fn<typeof PromotionsApi.disconnectPromotionCheckout>(),
|
||||
}));
|
||||
|
||||
vi.mock('../promotionsSettings.api', () => api);
|
||||
|
|
@ -88,6 +102,52 @@ const promoteConfig = (baseBranchName: string, createBranchOnPromotion = false)
|
|||
...timestamps,
|
||||
});
|
||||
|
||||
const gitlabProvider = (overrides: Partial<PromotionProvider> = {}) =>
|
||||
sshProvider({
|
||||
id: 'provider-gitlab',
|
||||
name: 'GitLab',
|
||||
type: 'gitlab',
|
||||
authType: 'token',
|
||||
config: { schemaVersion: 1, baseUrl: 'https://gitlab.example.com' },
|
||||
...overrides,
|
||||
});
|
||||
const repository: PromotionRepository = {
|
||||
id: '42',
|
||||
name: 'Workflows',
|
||||
fullPath: 'team/workflows',
|
||||
remoteUrl: 'https://gitlab.example.com/team/workflows.git',
|
||||
defaultBranch: 'main',
|
||||
};
|
||||
const otherRepository = {
|
||||
...repository,
|
||||
id: '43',
|
||||
fullPath: 'team/other',
|
||||
remoteUrl: 'https://gitlab.example.com/team/other.git',
|
||||
};
|
||||
const requestTargetOf = (repo: PromotionRepository) => ({
|
||||
schemaVersion: 1 as const,
|
||||
remoteUrl: repo.remoteUrl,
|
||||
});
|
||||
const targetOf = (repo: PromotionRepository) => ({
|
||||
...requestTargetOf(repo),
|
||||
repositoryPath: repo.fullPath,
|
||||
});
|
||||
const gitlabConnection = (overrides: Partial<PromotionConnection> = {}) =>
|
||||
instanceConnection({
|
||||
provider: summaryOf(gitlabProvider()),
|
||||
target: targetOf(repository),
|
||||
configs: { apply: applyConfig('main') },
|
||||
...overrides,
|
||||
});
|
||||
const mockGitlabConnection = (saved = gitlabConnection()) => {
|
||||
api.fetchPromotionProviders.mockResolvedValue([saved.provider]);
|
||||
api.fetchPromotionProvider.mockResolvedValue(gitlabProvider());
|
||||
api.updatePromotionProvider.mockResolvedValue(gitlabProvider());
|
||||
api.fetchPromotionConnections.mockResolvedValue([saved]);
|
||||
api.fetchPromotionConnection.mockResolvedValue(saved);
|
||||
return saved;
|
||||
};
|
||||
|
||||
const renderView = createComponentRenderer(PromotionsSettingsView);
|
||||
|
||||
const renderReadyView = async () => {
|
||||
|
|
@ -122,9 +182,50 @@ const save = async () => {
|
|||
await userEvent.click(within(bar).getByRole('button', { name: /save settings|retry/i }));
|
||||
};
|
||||
|
||||
const selectedValue = (field: string) =>
|
||||
within(screen.getByTestId(`promotion-connection-${field}-select`)).getByRole('combobox');
|
||||
const discard = async () =>
|
||||
userEvent.click(
|
||||
within(screen.getByTestId('promotion-connection-save-bar')).getByRole('button', {
|
||||
name: /discard/i,
|
||||
}),
|
||||
);
|
||||
const selectRepository = async (repo = otherRepository) =>
|
||||
selectInDialog(screen.getByTestId('promotion-connection-repository-select'), repo.fullPath);
|
||||
const selectBranches = async (apply = 'main', promote?: string) => {
|
||||
await selectInDialog(screen.getByTestId('promotion-connection-apply-branch-select'), apply);
|
||||
if (promote)
|
||||
await selectInDialog(screen.getByTestId('promotion-connection-promote-branch-select'), promote);
|
||||
};
|
||||
const fillGitlabProvider = async (fields: { name?: string; baseUrl?: string; token?: string }) => {
|
||||
for (const [field, value] of [
|
||||
['name', fields.name],
|
||||
['base-url', fields.baseUrl],
|
||||
['password', fields.token],
|
||||
]) {
|
||||
if (value === undefined) continue;
|
||||
const input = screen.getByTestId(`promotion-provider-${field}-input`);
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, value);
|
||||
}
|
||||
};
|
||||
const saveProvider = async () => {
|
||||
await userEvent.click(screen.getByTestId('promotion-provider-save-button'));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('promotion-provider-dialog')).not.toBeInTheDocument(),
|
||||
);
|
||||
};
|
||||
const editGitlabProvider = async (fields: Parameters<typeof fillGitlabProvider>[0]) => {
|
||||
await openProviderDialog(screen.getAllByTestId('promotion-provider-row')[0]);
|
||||
await screen.findByDisplayValue('https://gitlab.example.com');
|
||||
await fillGitlabProvider(fields);
|
||||
await saveProvider();
|
||||
};
|
||||
|
||||
describe('PromotionsSettingsView', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
invalidateInstancePromotionConnection();
|
||||
createTestingPinia();
|
||||
api.fetchPromotionProviders.mockResolvedValue([]);
|
||||
api.fetchPromotionProvider.mockResolvedValue(sshProvider());
|
||||
|
|
@ -132,9 +233,518 @@ describe('PromotionsSettingsView', () => {
|
|||
api.fetchPromotionConnections.mockResolvedValue([]);
|
||||
api.deletePromotionProvider.mockResolvedValue(undefined);
|
||||
api.deletePromotionConfig.mockResolvedValue(undefined);
|
||||
api.fetchPromotionRepositories.mockResolvedValue({
|
||||
data: [repository, otherRepository],
|
||||
nextCursor: null,
|
||||
});
|
||||
api.fetchPromotionBranches.mockResolvedValue({
|
||||
data: [
|
||||
{ name: 'main', isDefault: true },
|
||||
{ name: 'release/test', isDefault: false },
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitLab connections', () => {
|
||||
it('creates a provider and a connection without losing the draft', async () => {
|
||||
const cachedConnection = useInstancePromotionConnection();
|
||||
await cachedConnection.load();
|
||||
const created = gitlabConnection({
|
||||
configs: { apply: applyConfig('main'), promote: promoteConfig('release/test', true) },
|
||||
});
|
||||
const pending = createDeferredPromise<PromotionConnection>();
|
||||
api.createPromotionProvider.mockResolvedValue({
|
||||
provider: gitlabProvider(),
|
||||
publicKey: null,
|
||||
});
|
||||
api.fetchPromotionProviders
|
||||
.mockResolvedValueOnce([])
|
||||
.mockRejectedValueOnce(new Error('The provider refresh failed.'))
|
||||
.mockResolvedValue([summaryOf(gitlabProvider())]);
|
||||
api.createPromotionConnection.mockReturnValueOnce(pending.promise);
|
||||
await renderReadyView();
|
||||
await userEvent.type(screen.getByTestId('promotion-connection-name-input'), 'Production');
|
||||
await userEvent.click(screen.getByTestId('promotion-connection-add-provider'));
|
||||
await selectInDialog(screen.getByTestId('promotion-provider-type-select'), 'GitLab');
|
||||
await fillGitlabProvider({
|
||||
name: 'GitLab',
|
||||
baseUrl: 'https://gitlab.example.com',
|
||||
token: 'test-token',
|
||||
});
|
||||
await saveProvider();
|
||||
const refreshError = await screen.findByTestId('promotion-providers-load-error');
|
||||
expect(selectedValue('provider')).toHaveValue('');
|
||||
await userEvent.click(within(refreshError).getByRole('button', { name: 'Retry' }));
|
||||
await screen.findByTestId('promotion-provider-row');
|
||||
await selectProvider('GitLab');
|
||||
expect(screen.getByTestId('promotion-connection-name-input')).toHaveValue('Production');
|
||||
expect(screen.queryByTestId('promotion-connection-remote-url-input')).not.toBeInTheDocument();
|
||||
await selectRepository(repository);
|
||||
await userEvent.click(screen.getByTestId('promotion-connection-apply-toggle'));
|
||||
await userEvent.click(screen.getByTestId('promotion-connection-promote-toggle'));
|
||||
await selectBranches('main', 'release/test');
|
||||
expect(selectedValue('repository')).toHaveAccessibleName(/^Repository/);
|
||||
expect(selectedValue('apply-branch')).toHaveAccessibleName(/^Apply branch/);
|
||||
expect(selectedValue('promote-branch')).toHaveAccessibleName(/^Promote base branch/);
|
||||
await userEvent.click(screen.getByTestId('promotion-connection-create-branch-toggle'));
|
||||
await save();
|
||||
expect(api.createPromotionConnection).toHaveBeenCalledWith(expect.anything(), {
|
||||
name: 'Production',
|
||||
scope: 'instance',
|
||||
providerId: 'provider-gitlab',
|
||||
target: requestTargetOf(repository),
|
||||
configs: {
|
||||
apply: { settings: { schemaVersion: 1, branchName: 'main' } },
|
||||
promote: {
|
||||
settings: {
|
||||
schemaVersion: 1,
|
||||
baseBranchName: 'release/test',
|
||||
createBranchOnPromotion: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
api.fetchPromotionConnections.mockResolvedValue([created]);
|
||||
api.fetchPromotionConnection.mockResolvedValue(created);
|
||||
api.fetchPromotionProvider.mockResolvedValue(gitlabProvider());
|
||||
api.updatePromotionProvider.mockResolvedValue(gitlabProvider());
|
||||
await editGitlabProvider({ name: 'GitLab renamed' });
|
||||
pending.resolve(created);
|
||||
await flushPromises();
|
||||
expect(mockShowMessage).toHaveBeenCalledWith({
|
||||
title: 'Connection created',
|
||||
type: 'success',
|
||||
});
|
||||
expect(
|
||||
within(screen.getByTestId('promotion-connection-save-bar')).queryByRole('button', {
|
||||
name: /save settings/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
await cachedConnection.load();
|
||||
expect(cachedConnection.hasPromoteConfig.value).toBe(true);
|
||||
});
|
||||
|
||||
it('clears dependent branches, supports discard, and converts a saved URL target to a path', async () => {
|
||||
const saved = mockGitlabConnection(
|
||||
gitlabConnection({
|
||||
target: { schemaVersion: 1, remoteUrl: repository.remoteUrl },
|
||||
configs: { apply: applyConfig('main'), promote: promoteConfig('release/test', true) },
|
||||
}),
|
||||
);
|
||||
api.fetchPromotionProviders.mockResolvedValue([saved.provider, summaryOf(sshProvider())]);
|
||||
api.updatePromotionConnection
|
||||
.mockResolvedValueOnce({ ...saved, target: targetOf(repository) })
|
||||
.mockResolvedValue({ ...saved, target: targetOf(otherRepository) });
|
||||
await renderReadyView();
|
||||
const hint = screen.getByText('Select a repository again to load its branches', {
|
||||
selector: '[role="status"]',
|
||||
});
|
||||
expect(selectedValue('repository')).toHaveAttribute('aria-describedby', hint.id);
|
||||
await selectRepository(repository);
|
||||
expect(selectedValue('apply-branch')).toHaveValue('main');
|
||||
expect(selectedValue('promote-branch')).toHaveValue('release/test');
|
||||
await save();
|
||||
expect(api.updatePromotionConnection).toHaveBeenCalledWith(expect.anything(), saved.id, {
|
||||
target: requestTargetOf(repository),
|
||||
});
|
||||
await selectRepository();
|
||||
expect(selectedValue('apply-branch')).toHaveValue('');
|
||||
expect(selectedValue('promote-branch')).toHaveValue('');
|
||||
expect(
|
||||
within(screen.getByTestId('promotion-connection-save-bar')).getByRole('button', {
|
||||
name: /save settings/i,
|
||||
}),
|
||||
).toBeDisabled();
|
||||
await discard();
|
||||
expect(selectedValue('repository')).toHaveValue(repository.fullPath);
|
||||
expect(selectedValue('promote-branch')).toHaveValue('release/test');
|
||||
await selectRepository();
|
||||
await selectBranches('main', 'release/test');
|
||||
await save();
|
||||
expect(api.updatePromotionConnection).toHaveBeenCalledWith(expect.anything(), saved.id, {
|
||||
target: requestTargetOf(otherRepository),
|
||||
});
|
||||
expect(api.upsertPromotionApplyConfig).not.toHaveBeenCalled();
|
||||
expect(api.upsertPromotionPromoteConfig).not.toHaveBeenCalled();
|
||||
await selectProvider('Production key');
|
||||
expect(screen.getByTestId('promotion-connection-remote-url-input')).toHaveValue('');
|
||||
expect(screen.getByTestId('promotion-connection-apply-branch-input')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('loads saved branches by path without resolving the repository browse page', async () => {
|
||||
const saved = mockGitlabConnection();
|
||||
api.fetchPromotionRepositories.mockRejectedValueOnce(new Error('GitLab is unavailable.'));
|
||||
await renderReadyView();
|
||||
await screen.findByRole('alert');
|
||||
expect(selectedValue('apply-branch')).toBeEnabled();
|
||||
expect(api.fetchPromotionBranches).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
saved.provider.id,
|
||||
repository.fullPath,
|
||||
expect.anything(),
|
||||
);
|
||||
expect(api.fetchPromotionRepositories).toHaveBeenCalledTimes(1);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Retry' }));
|
||||
await flushPromises();
|
||||
await userEvent.type(screen.getByTestId('promotion-connection-name-input'), ' renamed');
|
||||
api.updatePromotionConnection.mockResolvedValue({ ...saved, name: 'Production renamed' });
|
||||
await save();
|
||||
expect(api.updatePromotionConnection).toHaveBeenCalledWith(expect.anything(), saved.id, {
|
||||
name: 'Production renamed',
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks dependent writes after target failure and retries only unsaved writes', async () => {
|
||||
const saved = mockGitlabConnection(
|
||||
gitlabConnection({
|
||||
configs: { apply: applyConfig('main'), promote: promoteConfig('main') },
|
||||
}),
|
||||
);
|
||||
api.updatePromotionConnection
|
||||
.mockRejectedValueOnce(new Error('Target update failed.'))
|
||||
.mockResolvedValue({ ...saved, target: targetOf(otherRepository) });
|
||||
api.upsertPromotionApplyConfig.mockResolvedValue(applyConfig('release/test'));
|
||||
api.upsertPromotionPromoteConfig
|
||||
.mockRejectedValueOnce(new Error('Settings update failed.'))
|
||||
.mockResolvedValue(promoteConfig('release/test'));
|
||||
await renderReadyView();
|
||||
await selectRepository();
|
||||
await selectBranches('release/test', 'release/test');
|
||||
await save();
|
||||
const failure = await screen.findByTestId('promotion-connection-partial-save');
|
||||
expect(failure).toHaveTextContent('Apply settings');
|
||||
expect(failure).toHaveTextContent('Promote settings');
|
||||
expect(api.upsertPromotionApplyConfig).not.toHaveBeenCalled();
|
||||
expect(api.upsertPromotionPromoteConfig).not.toHaveBeenCalled();
|
||||
await save();
|
||||
await waitFor(() => expect(api.upsertPromotionPromoteConfig).toHaveBeenCalledTimes(1));
|
||||
await save();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('promotion-connection-partial-save')).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(api.updatePromotionConnection).toHaveBeenCalledTimes(2);
|
||||
expect(api.upsertPromotionApplyConfig).toHaveBeenCalledTimes(1);
|
||||
expect(api.upsertPromotionPromoteConfig).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps the saved repository and its branches after a host edit', async () => {
|
||||
const saved = mockGitlabConnection();
|
||||
const movedRepository = {
|
||||
...repository,
|
||||
remoteUrl: 'https://other.example.com/team/workflows.git',
|
||||
};
|
||||
api.fetchPromotionConnection
|
||||
.mockResolvedValueOnce(saved)
|
||||
.mockResolvedValue({ ...saved, target: targetOf(movedRepository) });
|
||||
await renderReadyView();
|
||||
await userEvent.type(screen.getByTestId('promotion-connection-name-input'), ' draft');
|
||||
api.fetchPromotionRepositories.mockResolvedValue({
|
||||
data: [movedRepository],
|
||||
nextCursor: null,
|
||||
});
|
||||
await editGitlabProvider({ baseUrl: 'https://other.example.com', token: 'test-token' });
|
||||
await waitFor(() => expect(selectedValue('repository')).toHaveValue(repository.fullPath));
|
||||
expect(selectedValue('apply-branch')).toBeEnabled();
|
||||
expect(selectedValue('apply-branch')).toHaveValue('main');
|
||||
api.updatePromotionConnection.mockResolvedValue({ ...saved, name: 'Production draft' });
|
||||
await save();
|
||||
await waitFor(() =>
|
||||
expect(api.updatePromotionConnection).toHaveBeenCalledWith(expect.anything(), saved.id, {
|
||||
name: 'Production draft',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps same-path repository drafts scoped to their provider during host edits', async () => {
|
||||
const saved = mockGitlabConnection();
|
||||
const providerB = gitlabProvider({ id: 'provider-b', name: 'GitLab B' });
|
||||
const movedRepository = {
|
||||
...repository,
|
||||
remoteUrl: 'https://other.example.com/team/workflows.git',
|
||||
};
|
||||
api.fetchPromotionProviders
|
||||
.mockResolvedValueOnce([saved.provider, summaryOf(providerB)])
|
||||
.mockResolvedValue([summaryOf(providerB), saved.provider]);
|
||||
api.fetchPromotionProvider
|
||||
.mockResolvedValueOnce(gitlabProvider())
|
||||
.mockResolvedValue(providerB);
|
||||
api.updatePromotionProvider
|
||||
.mockResolvedValueOnce(
|
||||
gitlabProvider({ config: { schemaVersion: 1, baseUrl: 'https://other.example.com' } }),
|
||||
)
|
||||
.mockResolvedValue({
|
||||
...providerB,
|
||||
config: { schemaVersion: 1, baseUrl: 'https://new.example.com' },
|
||||
});
|
||||
api.fetchPromotionConnection
|
||||
.mockResolvedValueOnce(saved)
|
||||
.mockResolvedValue({ ...saved, target: targetOf(movedRepository) });
|
||||
await renderReadyView();
|
||||
await selectProvider('GitLab B');
|
||||
await selectRepository(repository);
|
||||
await selectBranches();
|
||||
await editGitlabProvider({ baseUrl: 'https://other.example.com', token: 'test-token' });
|
||||
expect(selectedValue('provider')).toHaveValue('GitLab B');
|
||||
expect(selectedValue('repository')).toHaveValue(repository.fullPath);
|
||||
await editGitlabProvider({ baseUrl: 'https://new.example.com', token: 'test-token' });
|
||||
expect(selectedValue('repository')).toHaveValue('');
|
||||
expect(selectedValue('apply-branch')).toBeDisabled();
|
||||
expect(
|
||||
within(screen.getByTestId('promotion-connection-save-bar')).getByRole('button', {
|
||||
name: /save settings/i,
|
||||
}),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it.each(['saved', 'new'] as const)(
|
||||
'clears a repository picked from the old host after a host edit on a %s connection',
|
||||
async (state) => {
|
||||
const saved = mockGitlabConnection();
|
||||
api.fetchPromotionConnections.mockResolvedValue(state === 'saved' ? [saved] : []);
|
||||
await renderReadyView();
|
||||
if (state === 'new') {
|
||||
await selectProvider('GitLab');
|
||||
await userEvent.type(screen.getByTestId('promotion-connection-name-input'), 'Production');
|
||||
await userEvent.click(screen.getByTestId('promotion-connection-apply-toggle'));
|
||||
}
|
||||
await selectRepository();
|
||||
await selectBranches();
|
||||
await userEvent.type(screen.getByTestId('promotion-connection-name-input'), ' draft');
|
||||
await editGitlabProvider({ baseUrl: 'https://other.example.com', token: 'test-token' });
|
||||
expect(selectedValue('repository')).toHaveValue('');
|
||||
expect(selectedValue('apply-branch')).toBeDisabled();
|
||||
expect(screen.getByTestId('promotion-connection-name-input')).toHaveValue(
|
||||
'Production draft',
|
||||
);
|
||||
expect(
|
||||
within(screen.getByTestId('promotion-connection-save-bar')).getByRole('button', {
|
||||
name: /save settings/i,
|
||||
}),
|
||||
).toBeDisabled();
|
||||
},
|
||||
);
|
||||
|
||||
it('refreshes token access and ignores pending discovery from the previous token', async () => {
|
||||
mockGitlabConnection();
|
||||
const old = createDeferredPromise<PromotionBranchListPublicDto>();
|
||||
api.fetchPromotionBranches
|
||||
.mockReturnValueOnce(old.promise)
|
||||
.mockRejectedValueOnce(new Error('Repository access changed.'));
|
||||
await renderReadyView();
|
||||
await editGitlabProvider({ token: 'replacement-token' });
|
||||
expect(api.updatePromotionProvider).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'provider-gitlab',
|
||||
{ auth: { authType: 'token', password: 'replacement-token' } },
|
||||
);
|
||||
expect(api.fetchPromotionRepositories).toHaveBeenCalledTimes(2);
|
||||
expect(api.fetchPromotionBranches).toHaveBeenCalledTimes(2);
|
||||
old.resolve({ data: [{ name: 'old-only', isDefault: false }], nextCursor: 'old-page' });
|
||||
await flushPromises();
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Repository access changed.');
|
||||
expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument();
|
||||
expect(selectedValue('apply-branch')).toHaveValue('main');
|
||||
});
|
||||
|
||||
it.each(['selected', 'original'] as const)(
|
||||
'does not restore cleared IDs after editing the %s provider during a failed save',
|
||||
async (editedProvider) => {
|
||||
const saved = mockGitlabConnection();
|
||||
const providerB = gitlabProvider({
|
||||
id: 'provider-b',
|
||||
name: 'GitLab B',
|
||||
});
|
||||
api.fetchPromotionProviders.mockResolvedValue([saved.provider, summaryOf(providerB)]);
|
||||
api.fetchPromotionConnection.mockResolvedValueOnce(saved).mockResolvedValue({
|
||||
...saved,
|
||||
target: { schemaVersion: 1, remoteUrl: saved.target.remoteUrl },
|
||||
});
|
||||
const pending = createDeferredPromise<PromotionConnection>();
|
||||
api.updatePromotionConnection.mockReturnValueOnce(pending.promise);
|
||||
await renderReadyView();
|
||||
if (editedProvider === 'original') await selectProvider('GitLab B');
|
||||
await selectRepository();
|
||||
await selectBranches();
|
||||
await save();
|
||||
await editGitlabProvider({ baseUrl: 'https://other.example.com', token: 'test-token' });
|
||||
const failure = new Error('The provider changed during the update.');
|
||||
pending.reject(failure);
|
||||
await waitFor(() => expect(api.fetchPromotionConnection).toHaveBeenCalledTimes(3));
|
||||
expect(mockShowError).toHaveBeenCalledWith(failure, "Couldn't save the connection");
|
||||
if (editedProvider === 'original') {
|
||||
expect(selectedValue('provider')).toHaveValue('GitLab B');
|
||||
expect(selectedValue('apply-branch')).toBeEnabled();
|
||||
}
|
||||
await discard();
|
||||
expect(selectedValue('apply-branch')).toBeDisabled();
|
||||
expect(api.upsertPromotionApplyConfig).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['success', 'failure'] as const)(
|
||||
'ignores an older refresh %s after a successful save',
|
||||
async (outcome) => {
|
||||
const saved = mockGitlabConnection();
|
||||
const pending = createDeferredPromise<PromotionConnection>();
|
||||
api.fetchPromotionConnection
|
||||
.mockResolvedValueOnce(saved)
|
||||
.mockReturnValueOnce(pending.promise);
|
||||
api.updatePromotionConnection.mockResolvedValue({
|
||||
...saved,
|
||||
target: targetOf(otherRepository),
|
||||
});
|
||||
await renderReadyView();
|
||||
await editGitlabProvider({ name: 'GitLab renamed' });
|
||||
await selectRepository();
|
||||
await selectBranches();
|
||||
await save();
|
||||
await flushPromises();
|
||||
if (outcome === 'success') pending.resolve(saved);
|
||||
else pending.reject(new Error('The older refresh failed.'));
|
||||
await flushPromises();
|
||||
await userEvent.type(screen.getByTestId('promotion-connection-name-input'), ' draft');
|
||||
await discard();
|
||||
expect(selectedValue('repository')).toHaveValue(otherRepository.fullPath);
|
||||
expect(screen.queryByTestId('promotion-providers-load-error')).not.toBeInTheDocument();
|
||||
expect(mockShowError).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['transient', 'forbidden'] as const)(
|
||||
'handles a %s refresh failure without treating it as an initial load',
|
||||
async (failure) => {
|
||||
mockGitlabConnection();
|
||||
api.fetchPromotionProviders
|
||||
.mockResolvedValueOnce([summaryOf(gitlabProvider())])
|
||||
.mockRejectedValueOnce(
|
||||
failure === 'transient'
|
||||
? new Error('The refresh failed.')
|
||||
: new ResponseError('Access denied.', { httpStatusCode: 403 }),
|
||||
);
|
||||
await renderReadyView();
|
||||
await userEvent.type(screen.getByTestId('promotion-connection-name-input'), ' draft');
|
||||
await editGitlabProvider({ name: 'GitLab renamed' });
|
||||
const loadError = await screen.findByTestId('promotion-providers-load-error');
|
||||
if (failure === 'transient')
|
||||
expect(screen.getByTestId('promotion-connection-name-input')).toHaveValue(
|
||||
'Production draft',
|
||||
);
|
||||
else expect(screen.queryByTestId('promotion-connection-form')).not.toBeInTheDocument();
|
||||
await userEvent.click(within(loadError).getByRole('button', { name: 'Retry' }));
|
||||
expect(await screen.findByTestId('promotion-connection-name-input')).toHaveValue(
|
||||
failure === 'transient' ? 'Production draft' : 'Production',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['unchanged', 'replaced'] as const)(
|
||||
'keeps checkout state correct when the repository is %s during a clone',
|
||||
async (state) => {
|
||||
mockedStore(useUsersStore).currentUser = mock<IUser>({
|
||||
globalScopes: ['gitConnection:clone', 'gitConnection:pull'],
|
||||
});
|
||||
const saved = mockGitlabConnection();
|
||||
const updated = { ...saved, target: targetOf(otherRepository) };
|
||||
api.fetchPromotionConnection.mockResolvedValueOnce(saved).mockResolvedValue(updated);
|
||||
api.updatePromotionConnection.mockResolvedValue(updated);
|
||||
const pending =
|
||||
createDeferredPromise<Awaited<ReturnType<typeof PromotionsApi.clonePromotionCheckout>>>();
|
||||
api.clonePromotionCheckout.mockReturnValueOnce(pending.promise);
|
||||
await renderReadyView();
|
||||
await userEvent.click(screen.getByTestId('promotion-checkout-connect'));
|
||||
if (state === 'replaced') {
|
||||
await selectRepository();
|
||||
await selectBranches();
|
||||
await save();
|
||||
await flushPromises();
|
||||
}
|
||||
pending.resolve({
|
||||
connectionId: saved.id,
|
||||
configId: 'config-apply',
|
||||
direction: 'apply',
|
||||
branchName: 'main',
|
||||
hasCheckout: true,
|
||||
});
|
||||
await flushPromises();
|
||||
if (state === 'replaced')
|
||||
expect(screen.getByTestId('apply-instance-button')).toBeDisabled();
|
||||
else expect(screen.getByTestId('apply-instance-button')).toBeEnabled();
|
||||
expect(api.fetchPromotionConnection).toHaveBeenCalledTimes(state === 'replaced' ? 2 : 1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('providers', () => {
|
||||
it('validates GitLab URL and token input and retries creation without closing', async () => {
|
||||
api.createPromotionProvider
|
||||
.mockRejectedValueOnce(new Error('GitLab authentication failed.'))
|
||||
.mockResolvedValue({ provider: gitlabProvider(), publicKey: null });
|
||||
await renderReadyView();
|
||||
await openProviderDialog();
|
||||
await selectInDialog(screen.getByTestId('promotion-provider-type-select'), 'GitLab');
|
||||
expect(screen.queryByTestId('promotion-provider-auth-type-select')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('promotion-provider-username-input')).not.toBeInTheDocument();
|
||||
await fillGitlabProvider({ name: 'GitLab', baseUrl: 'https://gitlab.example.com/gitlab' });
|
||||
expect(screen.getByTestId('promotion-provider-save-button')).toBeDisabled();
|
||||
await fillGitlabProvider({ token: 'test-token' });
|
||||
await userEvent.click(screen.getByTestId('promotion-provider-save-button'));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('GitLab authentication failed.');
|
||||
expect(screen.getByTestId('promotion-provider-password-input')).toHaveValue('test-token');
|
||||
expect(mockShowError).not.toHaveBeenCalled();
|
||||
await saveProvider();
|
||||
expect(api.createPromotionProvider).toHaveBeenLastCalledWith(expect.anything(), {
|
||||
name: 'GitLab',
|
||||
type: 'gitlab',
|
||||
config: { schemaVersion: 1, baseUrl: 'https://gitlab.example.com/gitlab' },
|
||||
auth: { authType: 'token', password: 'test-token' },
|
||||
});
|
||||
});
|
||||
|
||||
it('renames a GitLab provider without changing its token, equivalent URL, or discovery', async () => {
|
||||
mockGitlabConnection();
|
||||
await renderReadyView();
|
||||
await editGitlabProvider({
|
||||
name: 'GitLab renamed',
|
||||
baseUrl: 'https://GITLAB.example.com:443/',
|
||||
});
|
||||
expect(api.updatePromotionProvider).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'provider-gitlab',
|
||||
{ name: 'GitLab renamed' },
|
||||
);
|
||||
expect(api.fetchPromotionRepositories).toHaveBeenCalledTimes(1);
|
||||
expect(api.fetchPromotionBranches).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('requires a new token for a host edit and clears failed validation after editing', async () => {
|
||||
mockGitlabConnection();
|
||||
api.updatePromotionProvider
|
||||
.mockRejectedValueOnce(new Error('GitLab authentication failed.'))
|
||||
.mockResolvedValue(gitlabProvider());
|
||||
await renderReadyView();
|
||||
await openProviderDialog(screen.getByTestId('promotion-provider-row'));
|
||||
await screen.findByDisplayValue('https://gitlab.example.com');
|
||||
await fillGitlabProvider({ baseUrl: 'https://gitlab.example.com/gitlab' });
|
||||
expect(screen.getByTestId('promotion-provider-save-button')).toBeDisabled();
|
||||
await fillGitlabProvider({ token: 'expired-token' });
|
||||
await userEvent.click(screen.getByTestId('promotion-provider-save-button'));
|
||||
expect(await screen.findByTestId('promotion-provider-save-error')).toHaveTextContent(
|
||||
'GitLab authentication failed.',
|
||||
);
|
||||
await fillGitlabProvider({ token: 'replacement-token' });
|
||||
expect(screen.queryByTestId('promotion-provider-save-error')).not.toBeInTheDocument();
|
||||
await saveProvider();
|
||||
expect(api.updatePromotionProvider).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
'provider-gitlab',
|
||||
{
|
||||
config: { schemaVersion: 1, baseUrl: 'https://gitlab.example.com/gitlab' },
|
||||
auth: { authType: 'token', password: 'replacement-token' },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('adds an SSH provider and shows its deploy key', async () => {
|
||||
api.createPromotionProvider.mockResolvedValue({
|
||||
provider: sshProvider(),
|
||||
|
|
@ -477,6 +1087,32 @@ describe('PromotionsSettingsView', () => {
|
|||
expect(mockShowMessage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('saves independent branches after a name failure and retries only the name', async () => {
|
||||
const saved = instanceConnection({ configs: { apply: applyConfig('main') } });
|
||||
api.fetchPromotionProviders.mockResolvedValue([saved.provider]);
|
||||
api.fetchPromotionConnections.mockResolvedValue([saved]);
|
||||
api.fetchPromotionConnection.mockResolvedValue(saved);
|
||||
api.updatePromotionConnection
|
||||
.mockRejectedValueOnce(new Error('Name update failed.'))
|
||||
.mockResolvedValue({
|
||||
...saved,
|
||||
name: 'Production renamed',
|
||||
configs: { apply: applyConfig('main-next') },
|
||||
});
|
||||
api.upsertPromotionApplyConfig.mockResolvedValue(applyConfig('main-next'));
|
||||
await renderReadyView();
|
||||
await userEvent.type(screen.getByTestId('promotion-connection-name-input'), ' renamed');
|
||||
await userEvent.type(screen.getByTestId('promotion-connection-apply-branch-input'), '-next');
|
||||
await save();
|
||||
await screen.findByTestId('promotion-connection-partial-save');
|
||||
await save();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('promotion-connection-partial-save')).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(api.updatePromotionConnection).toHaveBeenCalledTimes(2);
|
||||
expect(api.upsertPromotionApplyConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('deletes Apply settings when Apply is turned off', async () => {
|
||||
api.fetchPromotionProviders.mockResolvedValue([summaryOf(sshProvider())]);
|
||||
const saved = instanceConnection({ configs: { apply: applyConfig('main') } });
|
||||
|
|
|
|||
|
|
@ -12,10 +12,12 @@ import {
|
|||
N8nSettingsSection,
|
||||
} from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { ResponseError } from '@n8n/rest-api-client';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { nextTick, onMounted, ref, useTemplateRef } from 'vue';
|
||||
|
||||
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
|
||||
import { useLatestFetch } from '@/app/composables/useLatestFetch';
|
||||
import PromoteApplyInstanceSection from '../components/PromoteApplyInstanceSection.vue';
|
||||
import PromotionConnectionForm from '../components/PromotionConnectionForm.vue';
|
||||
import PromotionProviderDialog from '../components/PromotionProviderDialog.vue';
|
||||
|
|
@ -27,16 +29,19 @@ import {
|
|||
type PromotionConnection,
|
||||
type PromotionProviderSummary,
|
||||
} from '../promotionsSettings.api';
|
||||
import type { PromotionProviderChange } from '../promotionsSettings.utils';
|
||||
|
||||
const i18n = useI18n();
|
||||
const toast = useToast();
|
||||
const rootStore = useRootStore();
|
||||
const documentTitle = useDocumentTitle();
|
||||
const { next: nextLoad } = useLatestFetch();
|
||||
|
||||
const providers = ref<PromotionProviderSummary[]>([]);
|
||||
const connection = ref<PromotionConnection | null>(null);
|
||||
const isInitialLoading = ref(true);
|
||||
const loadError = ref(false);
|
||||
const hasLoaded = ref(false);
|
||||
const dialogOpen = ref(false);
|
||||
const editingId = ref<string | undefined>(undefined);
|
||||
const providerToFocus = ref<string | undefined>(undefined);
|
||||
|
|
@ -48,32 +53,44 @@ const page = useTemplateRef<{ $el?: HTMLElement }>('page');
|
|||
const connectionForm =
|
||||
useTemplateRef<InstanceType<typeof PromotionConnectionForm>>('connectionForm');
|
||||
|
||||
let hasLoaded = false;
|
||||
let connectionRevision = 0;
|
||||
let pendingLoad: Promise<void> = Promise.resolve();
|
||||
|
||||
async function load() {
|
||||
const isLatestLoad = nextLoad();
|
||||
const revision = connectionRevision;
|
||||
// Keep the list mounted during refresh so focus can return to a row.
|
||||
// A retry after an error must show loading, so the form mounts with loaded data.
|
||||
isInitialLoading.value = !hasLoaded || loadError.value;
|
||||
isInitialLoading.value = !hasLoaded.value;
|
||||
loadError.value = false;
|
||||
try {
|
||||
const [loadedProviders, connections] = await Promise.all([
|
||||
fetchPromotionProviders(rootStore.publicApiContext),
|
||||
fetchPromotionConnections(rootStore.publicApiContext, { scope: 'instance' }),
|
||||
]);
|
||||
providers.value = loadedProviders;
|
||||
if (!isLatestLoad()) return;
|
||||
const summary = connections[0];
|
||||
connection.value = summary
|
||||
const loadedConnection = summary
|
||||
? await fetchPromotionConnection(rootStore.publicApiContext, summary.id)
|
||||
: null;
|
||||
if (!isLatestLoad()) return;
|
||||
providers.value = loadedProviders;
|
||||
// A save can finish while connection detail is loading.
|
||||
if (revision === connectionRevision) connection.value = loadedConnection;
|
||||
hasLoaded.value = true;
|
||||
} catch (error) {
|
||||
if (!isLatestLoad() || revision !== connectionRevision) return;
|
||||
loadError.value = true;
|
||||
providers.value = [];
|
||||
connection.value = null;
|
||||
if (
|
||||
error instanceof ResponseError &&
|
||||
(error.httpStatusCode === 401 || error.httpStatusCode === 403)
|
||||
) {
|
||||
hasLoaded.value = false;
|
||||
providers.value = [];
|
||||
connection.value = null;
|
||||
}
|
||||
toast.showError(error, i18n.baseText('settings.promotions.providers.error.title'));
|
||||
} finally {
|
||||
isInitialLoading.value = false;
|
||||
hasLoaded = true;
|
||||
if (isLatestLoad()) isInitialLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -83,6 +100,7 @@ onMounted(async () => {
|
|||
});
|
||||
|
||||
function authTypeLabel(provider: PromotionProviderSummary) {
|
||||
if (provider.type === 'gitlab') return i18n.baseText('settings.promotions.providers.type.gitlab');
|
||||
return i18n.baseText(
|
||||
provider.authType === 'ssh-key'
|
||||
? 'settings.promotions.providers.authType.sshKey'
|
||||
|
|
@ -104,11 +122,18 @@ function openEditDialog(id: string) {
|
|||
dialogOpen.value = true;
|
||||
}
|
||||
|
||||
function onProviderSaved(id: string) {
|
||||
function onProviderSaved(change: PromotionProviderChange) {
|
||||
const { id } = change;
|
||||
providerToFocus.value = id;
|
||||
connectionForm.value?.applyProviderChange(change);
|
||||
if (selectAfterCreate.value) {
|
||||
connectionForm.value?.selectProvider(id);
|
||||
selectAfterCreate.value = false;
|
||||
pendingLoad = load().then(() => {
|
||||
if (providers.value.some((provider) => provider.id === id)) {
|
||||
connectionForm.value?.selectProvider(id);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
pendingLoad = load();
|
||||
}
|
||||
|
|
@ -119,11 +144,17 @@ function onProviderDeleted() {
|
|||
}
|
||||
|
||||
function onConnectionSaved(saved: PromotionConnection) {
|
||||
connectionRevision++;
|
||||
connection.value = saved;
|
||||
// The project header caches the instance connection per page load.
|
||||
invalidateInstancePromotionConnection();
|
||||
}
|
||||
|
||||
function onConnectionRefresh() {
|
||||
invalidateInstancePromotionConnection();
|
||||
void load();
|
||||
}
|
||||
|
||||
async function focusProvider(id: string | undefined) {
|
||||
await nextTick();
|
||||
const row = id ? list.value?.querySelector<HTMLElement>(`[data-provider-id="${id}"]`) : undefined;
|
||||
|
|
@ -163,7 +194,7 @@ async function onDialogOpenChange(open: boolean) {
|
|||
data-test-id="promotion-providers-load-error"
|
||||
@click:button="load"
|
||||
/>
|
||||
<template v-else>
|
||||
<template v-if="!isInitialLoading && hasLoaded">
|
||||
<div ref="list" :class="$style.list">
|
||||
<N8nSettingsRowGroup v-for="provider in providers" :key="provider.id">
|
||||
<N8nSettingsRow
|
||||
|
|
@ -210,11 +241,12 @@ async function onDialogOpenChange(open: boolean) {
|
|||
>
|
||||
<N8nLoading2 v-if="isInitialLoading" :rows="3" :shrink-last="false" />
|
||||
<PromotionConnectionForm
|
||||
v-else-if="!loadError"
|
||||
v-else-if="hasLoaded"
|
||||
ref="connectionForm"
|
||||
:providers="providers"
|
||||
:connection="connection"
|
||||
@saved="onConnectionSaved"
|
||||
@refresh="onConnectionRefresh"
|
||||
@add-provider="openCreateDialog(true)"
|
||||
/>
|
||||
</N8nSettingsSection>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user