From 37aeb5dd6f53eb5e7f2794d1059e62c690396c90 Mon Sep 17 00:00:00 2001 From: Kevin Powell Noumbissie <10553243+KepoParis@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:05:28 +0200 Subject: [PATCH] feat(client): add deployment value sources editor --- .../client/src/components/DeploymentModal.vue | 70 +++++--- .../src/components/DeploymentRepoOption.vue | 36 ++-- .../src/components/DeploymentRepoSelect.vue | 24 ++- .../DeploymentValueSourceOption.vue | 167 ++++++++++++++++++ .../src/components/DeploymentValueSources.vue | 104 +++++++++++ .../client/src/utils/deployment-draft.spec.ts | 132 ++++++++++++++ apps/client/src/utils/deployment-draft.ts | 62 +++++++ apps/client/src/utils/func.spec.ts | 23 +++ apps/client/src/utils/func.ts | 21 ++- 9 files changed, 574 insertions(+), 65 deletions(-) create mode 100644 apps/client/src/components/DeploymentValueSourceOption.vue create mode 100644 apps/client/src/components/DeploymentValueSources.vue create mode 100644 apps/client/src/utils/deployment-draft.spec.ts create mode 100644 apps/client/src/utils/deployment-draft.ts diff --git a/apps/client/src/components/DeploymentModal.vue b/apps/client/src/components/DeploymentModal.vue index 2323b7cea9..4f974e9a12 100644 --- a/apps/client/src/components/DeploymentModal.vue +++ b/apps/client/src/components/DeploymentModal.vue @@ -1,19 +1,28 @@ diff --git a/apps/client/src/components/DeploymentRepoSelect.vue b/apps/client/src/components/DeploymentRepoSelect.vue index 9af01fb8c6..3cf8c79173 100644 --- a/apps/client/src/components/DeploymentRepoSelect.vue +++ b/apps/client/src/components/DeploymentRepoSelect.vue @@ -1,13 +1,15 @@ @@ -48,7 +46,7 @@ function updateDepot(index: number, value: Partial diff --git a/apps/client/src/components/DeploymentValueSourceOption.vue b/apps/client/src/components/DeploymentValueSourceOption.vue new file mode 100644 index 0000000000..d98781bdae --- /dev/null +++ b/apps/client/src/components/DeploymentValueSourceOption.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/apps/client/src/components/DeploymentValueSources.vue b/apps/client/src/components/DeploymentValueSources.vue new file mode 100644 index 0000000000..2cbe72067f --- /dev/null +++ b/apps/client/src/components/DeploymentValueSources.vue @@ -0,0 +1,104 @@ + + + diff --git a/apps/client/src/utils/deployment-draft.spec.ts b/apps/client/src/utils/deployment-draft.spec.ts new file mode 100644 index 0000000000..940be128d0 --- /dev/null +++ b/apps/client/src/utils/deployment-draft.spec.ts @@ -0,0 +1,132 @@ +import type { Deployment, DeploymentSource, DeploymentValueSource } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { describe, expect, it } from 'vitest' +import { + hasExternalValueSource, + newInternalValueSource, + toDeploymentDraft, + toDeploymentSourceDraft, + toValueSourceDraft, +} from './deployment-draft.js' + +type ReadSource = Pick + +function makeInternalValueSource(order: number, path = `values-${order}.yaml`): DeploymentValueSource { + return { type: 'internal', id: faker.string.uuid(), order, path } +} + +function makeExternalValueSource(order: number): DeploymentValueSource { + return { type: 'external', id: faker.string.uuid(), order, path: 'ext.yaml', ref: 'infra', targetRevision: 'main', repositoryId: faker.string.uuid() } +} + +function makeReadSource(overrides: Partial = {}): ReadSource { + return { + id: faker.string.uuid(), + type: 'git', + repositoryId: faker.string.uuid(), + targetRevision: 'main', + path: '/app', + helmValuesFiles: 'values.yaml', + valueSources: [], + ...overrides, + } +} + +describe('deploymentDraft', () => { + describe('toValueSourceDraft', () => { + it('should drop the persistence `order` from an internal source and keep its id', () => { + const valueSource = makeInternalValueSource(3, 'a.yaml') + + expect(toValueSourceDraft(valueSource)).toEqual({ type: 'internal', id: valueSource.id, path: 'a.yaml' }) + }) + + it('should keep every external field except `order`', () => { + const valueSource = makeExternalValueSource(1) + + expect(toValueSourceDraft(valueSource)).toEqual({ + type: 'external', + id: valueSource.id, + path: 'ext.yaml', + ref: 'infra', + targetRevision: 'main', + repositoryId: valueSource.repositoryId, + }) + }) + }) + + describe('toDeploymentSourceDraft', () => { + it('should map the value sources in order and keep the writable fields', () => { + // The API returns value sources already ordered; the editor keeps that order. + const internal = makeInternalValueSource(0, 'a.yaml') + const external = makeExternalValueSource(1) + const source = makeReadSource({ valueSources: [internal, external] }) + + expect(toDeploymentSourceDraft(source)).toEqual({ + id: source.id, + type: 'git', + repositoryId: source.repositoryId, + targetRevision: 'main', + path: '/app', + helmValuesFiles: 'values.yaml', + valueSources: [ + { type: 'internal', id: internal.id, path: 'a.yaml' }, + { + type: 'external', + id: external.id, + path: 'ext.yaml', + ref: 'infra', + targetRevision: 'main', + repositoryId: external.repositoryId, + }, + ], + }) + }) + }) + + describe('toDeploymentDraft', () => { + it('should map top-level fields and every deployment source', () => { + const deployment = { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + name: 'mydeployment', + environmentId: faker.string.uuid(), + autosync: true, + deploymentSources: [makeReadSource(), makeReadSource()], + } satisfies Pick< + Deployment, + 'id' + | 'projectId' + | 'name' + | 'environmentId' + | 'autosync' + > & { deploymentSources: ReadSource[] } + + const draft = toDeploymentDraft(deployment) + + expect(draft).toMatchObject({ + id: deployment.id, + projectId: deployment.projectId, + name: 'mydeployment', + environmentId: deployment.environmentId, + autosync: true, + }) + expect(draft.deploymentSources).toHaveLength(2) + }) + }) + + describe('newInternalValueSource', () => { + it('should default to an internal source with an empty path', () => { + expect(newInternalValueSource()).toEqual({ type: 'internal', path: '' }) + }) + }) + + describe('hasExternalValueSource', () => { + it('should be true when at least one source is external', () => { + expect(hasExternalValueSource([{ type: 'internal' }, { type: 'external' }])).toBe(true) + }) + + it('should be false when every source is internal', () => { + expect(hasExternalValueSource([{ type: 'internal' }, { type: 'internal' }])).toBe(false) + }) + }) +}) diff --git a/apps/client/src/utils/deployment-draft.ts b/apps/client/src/utils/deployment-draft.ts new file mode 100644 index 0000000000..f2ca07c781 --- /dev/null +++ b/apps/client/src/utils/deployment-draft.ts @@ -0,0 +1,62 @@ +import type { + Deployment, + DeploymentSource, + DeploymentValueSource, + UpdateDeployment, + UpdateDeploymentSource, + UpdateDeploymentValueSource, +} from '@cpn-console/shared' + +type ReadDeploymentSource = Pick< + DeploymentSource, +'id' | 'type' | 'repositoryId' | 'targetRevision' | 'path' | 'helmValuesFiles' | 'valueSources' +> +type ReadDeployment = Pick< + Deployment, +'id' | 'projectId' | 'name' | 'environmentId' | 'autosync' +> & { deploymentSources: ReadDeploymentSource[] } + +export function toValueSourceDraft(valueSource: DeploymentValueSource): UpdateDeploymentValueSource { + return valueSource.type === 'external' + ? { + type: 'external', + id: valueSource.id, + path: valueSource.path, + ref: valueSource.ref, + targetRevision: valueSource.targetRevision, + repositoryId: valueSource.repositoryId, + } + : { type: 'internal', id: valueSource.id, path: valueSource.path } +} + +export function toDeploymentSourceDraft(source: ReadDeploymentSource): UpdateDeploymentSource { + return { + id: source.id, + type: source.type, + repositoryId: source.repositoryId, + targetRevision: source.targetRevision, + path: source.path, + helmValuesFiles: source.helmValuesFiles, + valueSources: source.valueSources.map(toValueSourceDraft), + } +} + +export function toDeploymentDraft(deployment: ReadDeployment): Partial { + return { + id: deployment.id, + projectId: deployment.projectId, + name: deployment.name, + environmentId: deployment.environmentId, + autosync: deployment.autosync, + deploymentSources: deployment.deploymentSources.map(toDeploymentSourceDraft), + } +} + +export function newInternalValueSource(): UpdateDeploymentValueSource { + return { type: 'internal', path: '' } +} + +// At most one external value source is allowed per deployment source. +export function hasExternalValueSource(valueSources: Pick[]): boolean { + return valueSources.some(valueSource => valueSource.type === 'external') +} diff --git a/apps/client/src/utils/func.spec.ts b/apps/client/src/utils/func.spec.ts index 75ef96cead..a0204e6793 100644 --- a/apps/client/src/utils/func.spec.ts +++ b/apps/client/src/utils/func.spec.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { swapItems } from './func' describe('localeParseFloat EN tests', () => { let localeParseFloatEN: (s: string) => number @@ -42,3 +43,25 @@ describe('localeParseFloat FR tests', () => { expect(result).toBe(4.25) }) }) + +describe('swapItems', () => { + it('should swap the item with its neighbour in the given direction', () => { + expect(swapItems(['a', 'b', 'c'], 0, 1)).toEqual(['b', 'a', 'c']) + expect(swapItems(['a', 'b', 'c'], 2, -1)).toEqual(['a', 'c', 'b']) + }) + + it('should return the same array reference when the move falls off either end', () => { + const items = ['a', 'b', 'c'] + + expect(swapItems(items, 0, -1)).toBe(items) + expect(swapItems(items, 2, 1)).toBe(items) + }) + + it('should not mutate the input array', () => { + const items = ['a', 'b', 'c'] + + swapItems(items, 0, 1) + + expect(items).toEqual(['a', 'b', 'c']) + }) +}) diff --git a/apps/client/src/utils/func.ts b/apps/client/src/utils/func.ts index 71f1a61b5e..159a7c8dcf 100644 --- a/apps/client/src/utils/func.ts +++ b/apps/client/src/utils/func.ts @@ -1,5 +1,5 @@ import type { Ref } from 'vue' -import { useSnackbarStore } from '@/stores/snackbar.js' +import { useSnackbarStore } from '@/stores/snackbar' const LOCALE = navigator.language.slice(0, 2) // Get the thousands and decimal separator characters used in the locale. @@ -87,6 +87,25 @@ export function localeParseFloat(s: string): number { return Number.parseFloat(delocalizedInput) } +// DSFR inputs emit `string | number | undefined`; coerce to a plain string for +// string-typed form fields (empty string when the value is cleared). +export function toStringValue(value: string | number | undefined): string { + return value === undefined ? '' : String(value) +} + +// Swaps an item with its neighbour and returns a new array. Out-of-bounds moves are +// a no-op, returning the original array so callers can skip the update. +export function swapItems(items: T[], index: number, direction: -1 | 1): T[] { + const target = index + direction + if (target < 0 || target >= items.length) return items + + const reordered = [...items] + const moved = reordered[index] + reordered[index] = reordered[target] + reordered[target] = moved + return reordered +} + export async function scrollToFirstError(container: Ref) { await nextTick()