Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions apps/client/src/components/DeleteForm.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<script lang="ts" setup>
const props = withDefaults(defineProps<{
disabled?: boolean
deleteValidationInput?: string
isLoading?: boolean
}>(), {
disabled: false,
deleteValidationInput: 'DELETE',
isLoading: false,
})

defineEmits<{
delete: []
}>()

const isDeleting = ref(false)
const deleteConfirmValue = ref('')
</script>

<template>
<div
class="danger-zone"
>
<div class="danger-zone-btns">
<DsfrButton
v-show="!isDeleting"
label="Supprimer"
danger
:disabled="props.disabled"
icon="ri:delete-bin-7-line"
@click="isDeleting = true"
/>
<DsfrAlert
class="<md:mt-2"
description="La suppression est irréversible."
type="warning"
small
/>
</div>
<div
v-if="isDeleting"
class="fr-mt-4w"
>
<DsfrInput
v-model="deleteConfirmValue"
:label="`Veuillez taper '${deleteValidationInput}' pour confirmer la suppression de l'environnement`"
label-visible
:placeholder="deleteValidationInput"
class="fr-mb-2w"
/>
<div
class="flex justify-between"
>
<div class="flex">
<DsfrButton
label="Supprimer définitivement"
:disabled="deleteConfirmValue !== deleteValidationInput || props.isLoading"
title="Supprimer définitivement"
danger
icon="ri:delete-bin-7-line"
@click="$emit('delete')"
/>
<DsfrButton v-if="props.isLoading" :icon="{ name: 'ri:loader-4-line', animation: 'spin' }" icon-only disabled />
</div>
<DsfrButton
label="Annuler"
primary
@click="isDeleting = false"
/>
</div>
</div>
</div>
</template>
46 changes: 46 additions & 0 deletions apps/client/src/components/DeploymentCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<script lang="ts" setup>
import type { Deployment, Stage } from '@cpn-console/shared'

defineProps<{ deployment: Deployment & { stage?: Stage } }>()
</script>

<template>
<div class="fr-card fr-enlarge-link cursor-pointer w-1/5">
<div class="fr-card__body">
<div class="fr-card__content fr-px-4v fr-pb-4v fr-pt-5v">
<p class="font-bold">
{{ deployment.name }}
</p>
<DsfrBadge
class="fr-mb-0"
:label="`${deployment.environment.name}${deployment.stage?.name ? ` • ${deployment.stage.name}` : ''}`"
no-icon
small
type="info"
/>
<p class="fr-text--sm fr-text-mention--grey uppercase font-bold fr-my-4v">
{{ deployment.deploymentSources.length }}
<template v-if="deployment.deploymentSources.length > 1">
dépôts
</template>
<template v-else>
dépôt
</template>
</p>
<div class="flex flex-wrap items-start gap-2 mb-4">
<div
v-for="source in deployment.deploymentSources"
:key="source.id"
class="px-2 py-1 shadow fr-background-alt--grey flex items-center gap-2 fr-text--sm mb-0"
>
<v-icon name="mdi:git" class="flex-shrink-0" />
{{ source.repository.internalRepoName }}
<span class="text-xs fr-m-0 fr-text-mention--grey font-mono leading-none">
{{ source.targetRevision || 'HEAD' }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
161 changes: 161 additions & 0 deletions apps/client/src/components/DeploymentModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<script lang="ts" setup>
import type { Cluster, Deployment, Environment, Stage, UpdateDeployment, Zone } from '@cpn-console/shared'
import type { DsfrRadioButtonProps } from '@gouvminint/vue-dsfr'
import type { Project } from '@/utils/project-utils.js'
import { CreateDeploymentSchema, DeploymentSchema, longestDeploymentName } from '@cpn-console/shared'
import { useSnackbarStore } from '@/stores/snackbar.js'
import { scrollToFirstError } from '@/utils/func.js'

const props = withDefaults(defineProps<{
opened: boolean
environments: (Environment & { cluster?: Cluster, zone?: Zone, stage?: Stage })[]
repoOptions: { text: string, value: string }[]
deployment?: Deployment
project: Project
disabled?: boolean
}>(), { opened: false, disabled: false })

const emit = defineEmits<{ close: [] }>()

const snackbarStore = useSnackbarStore()

const { opened } = toRefs(props)

const options: ComputedRef<Omit<DsfrRadioButtonProps, 'modelValue'>[]> = computed(
() => props.environments.map(env => ({
hint: env.stage?.name,
label: env.name,
value: env.id,
class: 'fr-p-0',
})),
)

const deployment = ref<Partial<UpdateDeployment & { id: string }>>(
props.deployment ? { ...props.deployment } : { projectId: props.project.id, autosync: true },
)

watch(() => props.deployment, (newValue) => {
deployment.value = newValue ? { ...newValue } : { projectId: props.project.id, autosync: true }
}, { deep: true })

const deploymentSourcesModel = computed({
get: () => deployment.value?.deploymentSources ?? [],
set: (value: UpdateDeployment['deploymentSources']) =>
deployment.value = { ...deployment.value, deploymentSources: value },
})

const isLoading = ref(false)
const isDeleting = ref(false)
const isDirty = ref(false)

const formContainer = ref<HTMLDivElement | null>(null)

const isNew = computed(() => !deployment.value.id)

function upsertDeployment() {
if (isLoading.value) return

const body = CreateDeploymentSchema.safeParse(deployment.value)
if (!body.success) {
isDirty.value = true
scrollToFirstError(formContainer)
return
}

isLoading.value = true
if (deployment.value.id) {
props.project.Deployments.update(deployment.value.id, body.data)
.then(closeModal)
.catch(error => snackbarStore.setMessage(error, 'error'))
.finally(() => isLoading.value = false)
} else {
props.project.Deployments.create(body.data)
.then(closeModal)
.catch(error => snackbarStore.setMessage(error, 'error'))
.finally(() => isLoading.value = false)
}
}

function deleteDeployment() {
if (isDeleting.value || !deployment.value.id) return

isDeleting.value = true
props.project.Deployments.delete(deployment.value.id)
.then(closeModal)
.catch(error => snackbarStore.setMessage(error, 'error'))
.finally(() => isDeleting.value = false)
}

function closeModal() {
isDirty.value = false
deployment.value = { projectId: props.project.id, autosync: true }
emit('close')
}
</script>

<template>
<DsfrModal title="" :opened is-alert @close="closeModal">
<div ref="formContainer" class="w-full">
<h4>
<template v-if="deployment.id">
Modifier le déploiement
</template>
<template v-else>
Ajouter un déploiement au projet
</template>
</h4>

<div class="w-full">
<DsfrInputGroup
v-model="deployment.name"
label="Nom du déploiement"
class="fr-mb-2v"
label-visible
:required="true"
:error-message="!!(deployment.name || isDirty) && !DeploymentSchema.pick({ name: true }).safeParse({ name: deployment.name }).success ? `Le nom du déploiement est requis et ne doit pas contenir d\'espace, doit être unique pour le projet et le cluster sélectionnés, être en minuscules et faire plus de 2 et moins de ${longestDeploymentName} caractères.` : undefined"
placeholder="deploy0"
:disabled="props.disabled"
:hint="`Ne doit pas contenir d'espace ni de trait d'union, doit être unique pour le projet et le cluster sélectionnés, être en minuscules et faire plus de 2 et moins de ${longestDeploymentName} caractères.`"
/>
</div>

<h6 class="fr-mb-0">
Environnement cible
</h6>
<p class="fr-text--sm fr-text-mention--grey fr-mb-0">
Un déploiement est lié à exactement 1 environnement
</p>
<DsfrRadioButtonSet
v-model="deployment.environmentId"
:options="options"
:rich="true"
:required="true"
:disabled="props.disabled"
:error-message="!!(deployment.environmentId || isDirty) && !DeploymentSchema.pick({ environmentId: true }).safeParse({ environmentId: deployment.environmentId }).success ? `L'environnement est requis` : undefined"
/>

<h6 class="fr-mb-0">
Dépôts à inclure
</h6>
<DeploymentRepoSelect v-model="deploymentSourcesModel" :is-dirty :repo-options="repoOptions" :disabled="props.disabled" />
</div>
<div class="w-full flex justify-end gap-4">
<DsfrButton
v-if="!props.disabled"
label="Enregistrer"
primary
size="md"
:icon="isLoading ? { name: 'ri:loader-4-line', animation: 'spin' } : undefined"
:icon-right="isLoading"
@click="upsertDeployment"
/>
<DsfrButton
label="Annuler"
secondary
size="md"
@click="closeModal"
/>
</div>
<DeleteForm v-if="!isNew && !props.disabled" :is-loading="isDeleting" @delete="deleteDeployment" />
</DsfrModal>
</template>
73 changes: 73 additions & 0 deletions apps/client/src/components/DeploymentRepoOption.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<script setup lang="ts">
import type { UpdateDeployment } from '@cpn-console/shared'
import { DeploymentSourceSchema } from '@cpn-console/shared'

withDefaults(defineProps<{
cantDelete: boolean
options: { text: string, value: string }[]
disabled?: boolean
isDirty?: boolean
}>(), {
options: () => [],
cantDelete: false,
disabled: false,
isDirty: false,
})
defineEmits<{ delete: [] }>()

const model = defineModel<Partial<UpdateDeployment['deploymentSources'][0]>>(
{
default: {
id: undefined,
type: 'git',
repositoryId: undefined,
targetRevision: undefined,
path: undefined,
helmValuesFiles: undefined,
},
},
)
</script>

<template>
<div class="w-full">
<div v-if="!$props.cantDelete" class="flex w-full justify-end">
<DsfrButton icon-only icon="ri:delete-bin-7-line" secondary @click="$emit('delete')" />
</div>
<DsfrSelect v-model="model.repositoryId" label="Dépôt" :options="$props.options" required :disabled="$props.disabled" :error-message="$props.isDirty && !DeploymentSourceSchema.pick({ repositoryId: true }).safeParse({ repositoryId: model.repositoryId }).success ? 'Le dépôt est requis' : undefined" />
<DsfrInputGroup
v-model="model.targetRevision"
class="mb-2"
placeholder="HEAD"
label="Nom de la révision à déployer (branche, tag, commit)"
label-visible
:disabled="$props.disabled"
/>
<DsfrInputGroup
v-model="model.path"
class="mb-2"
placeholder="."
label="Chemin du répertoire à déployer"
label-visible
:disabled="$props.disabled"
/>
<DsfrInputGroup
v-model="model.helmValuesFiles"
class="mb-2"
is-textarea
label="Fichiers values (Helm)"
label-visible
hint="Un fichier par ligne, chemin relatif par rapport au répertoire à déployer. L'ordre des fichiers est déterminant pour la surcharge des valeurs communes. Champ optionnel."
placeholder="values/extra.yaml
values-<env>/custom.yaml"
:disabled="$props.disabled"
/>
</div>
</template>

<style lang="css" scoped>
.fr-select-group,
.fr-input-group {
margin-bottom: .75rem;
}
</style>
Loading
Loading