Compare commits

..

5 Commits

Author SHA1 Message Date
Bastien Nollet
cf2bea4651 fix(xo-server): sort subtasks even for failed task 2026-08-13 16:28:55 +02:00
Pierre Brunet
5014409baa feat: serializeError during task edit and add tests 2026-08-06 17:11:57 +02:00
Pierre Brunet
435d660c84 feat: rename function 2026-08-06 11:13:37 +02:00
Pierre Brunet
dbb6e252e6 feat: update node in xo-server and use AggregateError from node 2026-08-06 11:10:30 +02:00
Pierre Brunet
6ebbd2c968 fix(tasks): add result if status is overrided to 'failed' 2026-08-05 15:46:50 +02:00
192 changed files with 4278 additions and 3078 deletions

View File

@@ -1,13 +1,10 @@
import { asyncMap } from '@xen-orchestra/async-map'
import { createLogger } from '@xen-orchestra/log'
import { Task } from '@vates/task'
import { DIR_XO_POOL_METADATA_BACKUPS } from '../RemoteAdapter.mjs'
import { forkStreamUnpipe } from './_forkStreamUnpipe.mjs'
import { formatFilenameDate } from '../_filenameDate.mjs'
const { debug } = createLogger('xo:backups:PoolMetadataBackup')
export const PATH_DB_DUMP = '/pool/xmldbdump'
export class PoolMetadataBackup {
@@ -34,22 +31,7 @@ export class PoolMetadataBackup {
const poolDir = `${DIR_XO_POOL_METADATA_BACKUPS}/${schedule.id}/${pool.$id}`
const dir = `${poolDir}/${formatFilenameDate(timestamp)}`
const tasks = Object.values(pool.$xapi.objects.indexes.type.task ?? {})
const pendingTasks = tasks.filter(task => task.status === 'pending')
debug('pool tasks before export', {
total: tasks.length,
pending: pendingTasks.length,
oldestPendingCreated: pendingTasks.map(task => task.created).sort()[0],
})
const stream = (await this._exportPoolMetadata()).body
const startedAt = Date.now()
let bytes = 0
stream.once('data', () => debug('first byte received', { afterMs: Date.now() - startedAt }))
stream.on('data', chunk => (bytes += chunk.length))
stream.once('end', () => debug('stream ended', { bytes, durationMs: Date.now() - startedAt }))
stream.once('error', error => debug('stream errored', { error, bytes, durationMs: Date.now() - startedAt }))
const fileName = `${dir}/data`
const metadata = JSON.stringify(

View File

@@ -11,7 +11,8 @@
</main>
</div>
</div>
<VtsOverlayList />
<VtsModalList />
<VtsDrawerList />
<VtsTooltipList />
</template>
@@ -22,7 +23,8 @@ import AppNavigation from '@/components/AppNavigation.vue'
import { useUnreachableHosts } from '@/composables/unreachable-hosts.composable'
import { usePoolStore } from '@/stores/xen-api/pool.store'
import { useXenApiStore } from '@/stores/xen-api.store'
import VtsOverlayList from '@core/components/overlay/VtsOverlayList.vue'
import VtsDrawerList from '@core/components/drawer/VtsDrawerList.vue'
import VtsModalList from '@core/components/modal/VtsModalList.vue'
import VtsTooltipList from '@core/components/tooltip-list/VtsTooltipList.vue'
import { useChartTheme } from '@core/composables/chart-theme.composable'
import { useUiStore } from '@core/stores/ui.store'

View File

@@ -20,7 +20,8 @@ import UiActionButton from '@/components/ui/UiActionButton.vue'
import UiFilter from '@/components/ui/UiFilter.vue'
import UiFilterGroup from '@/components/ui/UiFilterGroup.vue'
import type { Filters } from '@/types/filter'
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { useModal } from '@core/packages/modal/use-modal.ts'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { availableFilters } = defineProps<{
@@ -35,25 +36,18 @@ const emit = defineEmits<{
const { t } = useI18n()
const { open } = useOverlay({
component: () => import('@/components/modals/CollectionFilterModal.vue'),
events: {
onCancel: true,
const openFilterModal = useModal((editedFilter?: string) => ({
component: import('@/components/modals/CollectionFilterModal.vue'),
props: {
editedFilter,
availableFilters: computed(() => availableFilters),
},
})
onConfirm: async newFilter => {
if (editedFilter !== undefined) {
emit('removeFilter', editedFilter)
}
function openFilterModal(editedFilter?: string) {
return open({
props: { editedFilter, availableFilters },
events: {
onConfirm: newFilter => {
if (editedFilter !== undefined) {
emit('removeFilter', editedFilter)
}
emit('addFilter', newFilter)
},
},
})
}
emit('addFilter', newFilter)
},
}))
</script>

View File

@@ -24,7 +24,8 @@ import UiFilter from '@/components/ui/UiFilter.vue'
import UiFilterGroup from '@/components/ui/UiFilterGroup.vue'
import type { ActiveSorts, Sorts } from '@/types/sort'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { useModal } from '@core/packages/modal/use-modal.ts'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { availableSorts } = defineProps<{
@@ -40,17 +41,11 @@ const emit = defineEmits<{
const { t } = useI18n()
const { open } = useOverlay({
component: () => import('@/components/modals/CollectionSorterModal.vue'),
events: {
onConfirm: ({ property, isAscending }) => emit('addSort', property as TProperty, isAscending),
onCancel: true,
},
const openFormModal = useModal({
component: import('@/components/modals/CollectionSorterModal.vue'),
props: { availableSorts: computed(() => availableSorts) },
onConfirm: ({ property, isAscending }) => emit('addSort', property as TProperty, isAscending),
})
function openFormModal() {
return open({ props: { availableSorts } })
}
</script>
<style lang="postcss" scoped>

View File

@@ -87,7 +87,7 @@ import useSortedCollection from '@/composables/sorted-collection.composable'
import type { PropParam } from '@/libs/story/story-param'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import { vTooltip } from '@core/directives/tooltip.directive'
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { useModal } from '@core/packages/modal/use-modal.ts'
import { useVModel } from '@vueuse/core'
import { toRef } from 'vue'
@@ -111,16 +111,10 @@ const params = useSortedCollection(toRef(props, 'params'), (p1, p2) => {
const model = useVModel(props, 'modelValue', emit)
const { open } = useOverlay({
component: () => import('@/components/modals/CodeHighlightModal.vue'),
events: {
onClose: true,
},
})
function openRawValueModal(code: string) {
return open({ props: { code } })
}
const openRawValueModal = useModal((code: string) => ({
component: import('@/components/modals/CodeHighlightModal.vue'),
props: { code },
}))
</script>
<style lang="postcss" scoped>

View File

@@ -4,7 +4,7 @@
<script lang="ts" setup>
import FormInput from '@/components/form/FormInput.vue'
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { useModal } from '@core/packages/modal/use-modal.ts'
import { useVModel } from '@vueuse/core'
import { computed } from 'vue'
@@ -20,17 +20,11 @@ const model = useVModel(props, 'modelValue', emit)
const jsonValue = computed(() => JSON.stringify(model.value, undefined, 2))
const { open } = useOverlay({
component: () => import('@/components/modals/JsonEditorModal.vue'),
events: {
onConfirm: (newValue: string) => {
model.value = JSON.parse(newValue)
},
onCancel: true,
const openJsonModal = useModal({
component: import('@/components/modals/JsonEditorModal.vue'),
props: { initialValue: computed(() => jsonValue.value) },
onConfirm: newValue => {
model.value = JSON.parse(newValue)
},
})
function openJsonModal() {
return open({ props: { initialValue: jsonValue.value } })
}
</script>

View File

@@ -1,20 +1,16 @@
<template>
<UiModal accent="info" @dismiss="emit('close')">
<VtsModal accent="info" dismissible>
<template #content>
<CodeHighlight :code />
</template>
</UiModal>
</VtsModal>
</template>
<script lang="ts" setup>
import CodeHighlight from '@/components/CodeHighlight.vue'
import UiModal from '@core/components/ui/modal/UiModal.vue'
import VtsModal from '@core/components/modal/VtsModal.vue'
defineProps<{
code: any
}>()
const emit = defineEmits<{
close: []
}>()
</script>

View File

@@ -1,5 +1,5 @@
<template>
<UiModal accent="info" @dismiss="emit('cancel')" @confirm="emit('confirm', generatedFilter)">
<VtsModal accent="info" dismissible @confirm="emit('confirm', generatedFilter)">
<template #content>
<div class="rows">
<CollectionFilterRow
@@ -10,9 +10,9 @@
@remove="removeNewFilter($event)"
/>
<div>
<VtsOverlayButton variant="tertiary" @click="addNewFilter()">
<VtsModalButton variant="tertiary" @click="addNewFilter()">
{{ t('add-or') }}
</VtsOverlayButton>
</VtsModalButton>
</div>
</div>
<div v-if="newFilters.some(filter => filter.isAdvanced)" class="available-properties">
@@ -25,12 +25,12 @@
</div>
</template>
<template #buttons>
<VtsOverlayCancelButton @click="emit('cancel')" />
<VtsOverlayConfirmButton :disabled="!isFilterValid">
<VtsModalCancelButton />
<VtsModalConfirmButton :disabled="!isFilterValid">
{{ editedFilter ? t('action:update') : t('action:add') }}
</VtsOverlayConfirmButton>
</VtsModalConfirmButton>
</template>
</UiModal>
</VtsModal>
</template>
<script lang="ts" setup>
@@ -38,10 +38,10 @@ import CollectionFilterRow from '@/components/CollectionFilterRow.vue'
import UiBadge from '@/components/ui/UiBadge.vue'
import { getFilterIcon } from '@/libs/utils'
import type { Filters, NewFilter } from '@/types/filter'
import VtsOverlayButton from '@core/components/overlay/VtsOverlayButton.vue'
import VtsOverlayCancelButton from '@core/components/overlay/VtsOverlayCancelButton.vue'
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
import UiModal from '@core/components/ui/modal/UiModal.vue'
import VtsModal from '@core/components/modal/VtsModal.vue'
import VtsModalButton from '@core/components/modal/VtsModalButton.vue'
import VtsModalCancelButton from '@core/components/modal/VtsModalCancelButton.vue'
import VtsModalConfirmButton from '@core/components/modal/VtsModalConfirmButton.vue'
import { Or, parse } from 'complex-matcher'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -53,7 +53,6 @@ const { editedFilter } = defineProps<{
const emit = defineEmits<{
confirm: [filter: string]
cancel: []
}>()
const { t } = useI18n()

View File

@@ -1,5 +1,5 @@
<template>
<UiModal accent="info" @dismiss="emit('cancel')" @confirm="handleSubmit()">
<VtsModal accent="info" dismissible @confirm="handleSubmit()">
<template #content>
<div class="form-selects">
<VtsSelect :id="sortPropertySelectId" accent="brand" />
@@ -8,18 +8,18 @@
</template>
<template #buttons>
<VtsOverlayCancelButton @click="emit('cancel')" />
<VtsOverlayConfirmButton :disabled="!newSortProperty">{{ t('action:add') }}</VtsOverlayConfirmButton>
<VtsModalCancelButton />
<VtsModalConfirmButton :disabled="!newSortProperty">{{ t('action:add') }}</VtsModalConfirmButton>
</template>
</UiModal>
</VtsModal>
</template>
<script lang="ts" setup>
import type { NewSort, Sorts } from '@/types/sort'
import VtsOverlayCancelButton from '@core/components/overlay/VtsOverlayCancelButton.vue'
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
import VtsModal from '@core/components/modal/VtsModal.vue'
import VtsModalCancelButton from '@core/components/modal/VtsModalCancelButton.vue'
import VtsModalConfirmButton from '@core/components/modal/VtsModalConfirmButton.vue'
import VtsSelect from '@core/components/select/VtsSelect.vue'
import UiModal from '@core/components/ui/modal/UiModal.vue'
import { useFormSelect } from '@core/packages/form-select'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -30,7 +30,6 @@ const { availableSorts } = defineProps<{
const emit = defineEmits<{
confirm: [sort: NewSort]
cancel: []
}>()
const { t } = useI18n()

View File

@@ -1,27 +1,23 @@
<template>
<UiModal accent="danger" icon="fa:exclamation-circle" @dismiss="emit('close')" @confirm="emit('close')">
<VtsModal accent="danger" dismissible icon="fa:exclamation-circle">
<template #title>{{ t('invalid-field') }}</template>
<template #content>{{ message }}</template>
<template #buttons>
<VtsOverlayConfirmButton>
<VtsModalConfirmButton>
{{ t('ok') }}
</VtsOverlayConfirmButton>
</VtsModalConfirmButton>
</template>
</UiModal>
</VtsModal>
</template>
<script lang="ts" setup>
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
import UiModal from '@core/components/ui/modal/UiModal.vue'
import VtsModal from '@core/components/modal/VtsModal.vue'
import VtsModalConfirmButton from '@core/components/modal/VtsModalConfirmButton.vue'
import { useI18n } from 'vue-i18n'
defineProps<{
message: string
}>()
const emit = defineEmits<{
close: []
}>()
const { t } = useI18n()
</script>

View File

@@ -1,32 +1,32 @@
<template>
<UiModal
<VtsModal
:accent="isJsonValid ? 'info' : 'danger'"
class="json-editor-modal"
icon="fa:code"
dismissible
@confirm="handleSubmit()"
@dismiss="emit('cancel')"
>
<template #content>
<UiTextarea v-model="editedJson" accent="brand" class="modal-textarea" />
<VtsOverlayButton variant="tertiary" @click="formatJson()">
<VtsModalButton variant="tertiary" @click="formatJson()">
{{ t('action:reformat') }}
</VtsOverlayButton>
</VtsModalButton>
</template>
<template #buttons>
<VtsOverlayCancelButton @click="emit('cancel')" />
<VtsOverlayConfirmButton :disabled="!isJsonValid">
<VtsModalCancelButton />
<VtsModalConfirmButton :disabled="!isJsonValid">
{{ t('action:save') }}
</VtsOverlayConfirmButton>
</VtsModalConfirmButton>
</template>
</UiModal>
</VtsModal>
</template>
<script lang="ts" setup>
import VtsOverlayButton from '@core/components/overlay/VtsOverlayButton.vue'
import VtsOverlayCancelButton from '@core/components/overlay/VtsOverlayCancelButton.vue'
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
import UiModal from '@core/components/ui/modal/UiModal.vue'
import VtsModal from '@core/components/modal/VtsModal.vue'
import VtsModalButton from '@core/components/modal/VtsModalButton.vue'
import VtsModalCancelButton from '@core/components/modal/VtsModalCancelButton.vue'
import VtsModalConfirmButton from '@core/components/modal/VtsModalConfirmButton.vue'
import UiTextarea from '@core/components/ui/text-area/UiTextarea.vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -37,7 +37,6 @@ const { initialValue } = defineProps<{
const emit = defineEmits<{
confirm: [editedJson: string]
cancel: []
}>()
const { t } = useI18n()

View File

@@ -1,5 +1,5 @@
<template>
<UiModal accent="danger" icon="object:host" @confirm="emit('confirm')">
<VtsModal accent="danger" icon="object:host">
<template #title>{{ t('unreachable-hosts') }}</template>
<template #content>
<div class="description">
@@ -13,30 +13,25 @@
</div>
</template>
<template #buttons>
<VtsOverlayCancelButton @click="emit('cancel')" />
<VtsOverlayConfirmButton>
<VtsModalCancelButton />
<VtsModalConfirmButton>
{{ t('unreachable-hosts-reload-page') }}
</VtsOverlayConfirmButton>
</VtsModalConfirmButton>
</template>
</UiModal>
</VtsModal>
</template>
<script lang="ts" setup>
import VtsOverlayCancelButton from '@core/components/overlay/VtsOverlayCancelButton.vue'
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
import VtsModal from '@core/components/modal/VtsModal.vue'
import VtsModalCancelButton from '@core/components/modal/VtsModalCancelButton.vue'
import VtsModalConfirmButton from '@core/components/modal/VtsModalConfirmButton.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import UiModal from '@core/components/ui/modal/UiModal.vue'
import { useI18n } from 'vue-i18n'
defineProps<{
urls: string[]
}>()
const emit = defineEmits<{
confirm: []
cancel: []
}>()
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,22 @@
<template>
<VtsDeleteModal>
<template #title>
{{ t('n-vms', { n: count }) }}
</template>
<template #confirm>
{{ t(`action:delete-n-vms`, { n: count }) }}
</template>
</VtsDeleteModal>
</template>
<script lang="ts" setup>
import VtsDeleteModal from '@core/components/modal/VtsDeleteModal.vue'
import { useI18n } from 'vue-i18n'
defineProps<{
count: number
}>()
const { t } = useI18n()
</script>

View File

@@ -1,5 +1,5 @@
<template>
<UiModal accent="info" icon="object:vm" @confirm="emit('close')" @dismiss="emit('close')">
<VtsModal accent="info" icon="object:vm" dismissible>
<template #title>
{{ t('action:export-n-vms-manually', { n: labelWithUrl.length }) }}
</template>
@@ -18,16 +18,16 @@
</template>
<template #buttons>
<VtsOverlayConfirmButton>{{ t('action:close') }}</VtsOverlayConfirmButton>
<VtsModalConfirmButton>{{ t('action:close') }}</VtsModalConfirmButton>
</template>
</UiModal>
</VtsModal>
</template>
<script lang="ts" setup>
import type { XenApiVm } from '@/libs/xen-api/xen-api.types'
import { useVmStore } from '@/stores/xen-api/vm.store'
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
import UiModal from '@core/components/ui/modal/UiModal.vue'
import VtsModal from '@core/components/modal/VtsModal.vue'
import VtsModalConfirmButton from '@core/components/modal/VtsModalConfirmButton.vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -35,10 +35,6 @@ const { blockedUrls } = defineProps<{
blockedUrls: URL[]
}>()
const emit = defineEmits<{
close: []
}>()
const { t } = useI18n()
const { getByOpaqueRef } = useVmStore().subscribe()

View File

@@ -1,5 +1,5 @@
<template>
<UiModal accent="info" icon="object:vm" @confirm="emit('confirm', compressionType)" @dismiss="emit('cancel')">
<VtsModal accent="info" icon="object:vm" dismissible @confirm="emit('confirm', compressionType)">
<template #title>
{{ t('action:export-n-vms', { n: count }) }}
</template>
@@ -12,21 +12,21 @@
</VtsInputWrapper>
</template>
<template #buttons>
<VtsOverlayCancelButton @click="emit('cancel')" />
<VtsOverlayConfirmButton>
<VtsModalCancelButton />
<VtsModalConfirmButton>
{{ t('action:export-n-vms', { n: count }) }}
</VtsOverlayConfirmButton>
</VtsModalConfirmButton>
</template>
</UiModal>
</VtsModal>
</template>
<script lang="ts" setup>
import { VM_COMPRESSION_TYPE } from '@/libs/xen-api/xen-api.enums'
import VtsInputWrapper from '@core/components/input-wrapper/VtsInputWrapper.vue'
import VtsOverlayCancelButton from '@core/components/overlay/VtsOverlayCancelButton.vue'
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
import VtsModal from '@core/components/modal/VtsModal.vue'
import VtsModalCancelButton from '@core/components/modal/VtsModalCancelButton.vue'
import VtsModalConfirmButton from '@core/components/modal/VtsModalConfirmButton.vue'
import VtsSelect from '@core/components/select/VtsSelect.vue'
import UiModal from '@core/components/ui/modal/UiModal.vue'
import { useFormSelect } from '@core/packages/form-select'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -37,7 +37,6 @@ defineProps<{
const emit = defineEmits<{
confirm: [compressionType: VM_COMPRESSION_TYPE]
cancel: []
}>()
const { t } = useI18n()

View File

@@ -1,5 +1,5 @@
<template>
<UiModal accent="info" @confirm="emit('confirm', selectedHost)" @dismiss="emit('cancel')">
<VtsModal accent="info" dismissible @confirm="emit('confirm', selectedHost)">
<template #title>
{{ t('action:migrate-n-vms', { n: count }) }}
</template>
@@ -13,21 +13,21 @@
</template>
<template #buttons>
<VtsOverlayCancelButton @click="emit('cancel')" />
<VtsOverlayConfirmButton :disabled="!selectedHost">
<VtsModalCancelButton />
<VtsModalConfirmButton :disabled="!selectedHost">
{{ t('action:migrate-n-vms', { n: count }) }}
</VtsOverlayConfirmButton>
</VtsModalConfirmButton>
</template>
</UiModal>
</VtsModal>
</template>
<script lang="ts" setup>
import type { XenApiHost } from '@/libs/xen-api/xen-api.types'
import VtsInputWrapper from '@core/components/input-wrapper/VtsInputWrapper.vue'
import VtsOverlayCancelButton from '@core/components/overlay/VtsOverlayCancelButton.vue'
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
import VtsModal from '@core/components/modal/VtsModal.vue'
import VtsModalCancelButton from '@core/components/modal/VtsModalCancelButton.vue'
import VtsModalConfirmButton from '@core/components/modal/VtsModalConfirmButton.vue'
import VtsSelect from '@core/components/select/VtsSelect.vue'
import UiModal from '@core/components/ui/modal/UiModal.vue'
import { useFormSelect } from '@core/packages/form-select'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -39,7 +39,6 @@ const { availableHosts } = defineProps<{
const emit = defineEmits<{
confirm: [selectedHost: XenApiHost | undefined]
cancel: []
}>()
const selectedHost = ref<XenApiHost>()

View File

@@ -3,7 +3,7 @@
v-tooltip="areSomeVmsInExecution && t('selected-vms-in-execution')"
:disabled="isDisabled"
icon="fa:trash"
@click="openDeleteModal()"
@click="openDeleteModal"
>
{{ t('action:delete') }}
</MenuItem>
@@ -15,8 +15,8 @@ import type { XenApiVm } from '@/libs/xen-api/xen-api.types'
import { useVmStore } from '@/stores/xen-api/vm.store'
import { useXenApiStore } from '@/stores/xen-api.store.ts'
import MenuItem from '@core/components/menu/MenuItem.vue'
import { useDeleteModal } from '@core/composables/modals/use-delete-modal.ts'
import { vTooltip } from '@core/directives/tooltip.directive'
import { useModal } from '@core/packages/modal/use-modal.ts'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -36,15 +36,9 @@ const isDisabled = computed(() => vms.value.length === 0 || areSomeVmsInExecutio
const xenApi = useXenApiStore().getXapi()
const { open: openModal } = useDeleteModal()
function openDeleteModal() {
return openModal({
events: { onConfirm: () => xenApi.vm.delete(vmRefs) },
props: {
subject: t('n-vms', { n: vmRefs.length }),
confirmLabel: t('action:delete-n-vms', { n: vmRefs.length }),
},
})
}
const openDeleteModal = useModal({
component: import('@/components/modals/VmDeleteModal.vue'),
props: { count: computed(() => vmRefs.length) },
onConfirm: () => xenApi.vm.delete(vmRefs),
})
</script>

View File

@@ -5,22 +5,22 @@
"
:disabled="isDisabled"
icon="action:download"
@click="openExportModal()"
@click="openExportModal"
>
{{ t('action:export-vm', isSingleAction ? 1 : 2) }}
</MenuItem>
</template>
<script lang="ts" setup>
import { useVmExport } from '@/composables/vm-export.composable.ts'
import { areSomeVmOperationAllowed } from '@/libs/vm'
import { VM_OPERATION } from '@/libs/xen-api/xen-api.enums'
import type { XenApiVm } from '@/libs/xen-api/xen-api.types'
import { useVmStore } from '@/stores/xen-api/vm.store'
import { useXenApiStore } from '@/stores/xen-api.store.ts'
import MenuItem from '@core/components/menu/MenuItem.vue'
import { useDisabled } from '@core/composables/disabled.composable'
import { vTooltip } from '@core/directives/tooltip.directive'
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { useModal } from '@core/packages/modal/use-modal.ts'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -39,17 +39,13 @@ const isSomeExportable = computed(() =>
const isDisabled = useDisabled(() => !isSomeExportable.value)
const { exportVms } = useVmExport()
const xenApi = useXenApiStore().getXapi()
const { open: openModal } = useOverlay({
component: () => import('@/components/modals/VmExportModal.vue'),
events: {
onConfirm: compressionType => exportVms(vmRefs, compressionType),
onCancel: true,
},
const openModal = useModal()
const openExportModal = useModal({
component: import('@/components/modals/VmExportModal.vue'),
props: { count: computed(() => vmRefs.length) },
onConfirm: compressionType => xenApi.vm.export(vmRefs, compressionType, openModal),
})
function openExportModal() {
return openModal({ props: { count: vmRefs.length } })
}
</script>

View File

@@ -23,9 +23,8 @@ import { useVmStore } from '@/stores/xen-api/vm.store'
import MenuItem from '@core/components/menu/MenuItem.vue'
import { useDisabled } from '@core/composables/disabled.composable'
import { vTooltip } from '@core/directives/tooltip.directive'
import { KEEP_OVERLAY_OPEN } from '@core/packages/overlay/symbols.ts'
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { reactiveComputed } from '@vueuse/core'
import { ABORT_MODAL } from '@core/packages/modal/types.ts'
import { useModal } from '@core/packages/modal/use-modal.ts'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -54,28 +53,20 @@ const isDisabled = useDisabled(() => !isMigratable.value)
const { selectedHost, availableHosts, isValid, migrate } = useVmMigration(() => selectedRefs)
const { open: openModal } = useOverlay({
component: () => import('@/components/modals/VmMigrateModal.vue'),
events: {
onConfirm: async host => {
selectedHost.value = host
const openMigrateModal = useModal({
component: import('@/components/modals/VmMigrateModal.vue'),
props: {
count: computed(() => selectedRefs.length),
availableHosts,
},
onConfirm: async host => {
selectedHost.value = host
if (!isValid.value) {
return KEEP_OVERLAY_OPEN
}
if (!isValid.value) {
return ABORT_MODAL
}
await migrate()
},
onCancel: true,
await migrate()
},
})
const modalProps = reactiveComputed(() => ({
count: selectedRefs.length,
availableHosts: availableHosts.value,
}))
function openMigrateModal() {
return openModal({ props: modalProps })
}
</script>

View File

@@ -25,7 +25,7 @@
<script lang="ts" setup>
import type { XenApiNetwork, XenApiVif, XenApiVm } from '@/libs/xen-api/xen-api.types.ts'
import { useVifConnection } from '@/modules/vif/composables/use-vif-connection.composable.ts'
import { useVifConnectionToggleModal } from '@/modules/vif/composables/use-vif-connection-toggle-modal.composable.ts'
import { useNetworkStore } from '@/stores/xen-api/network.store'
import { useVifStore } from '@/stores/xen-api/vif.store'
import { useVmGuestMetricsStore } from '@/stores/xen-api/vm-guest-metrics.store'
@@ -36,8 +36,6 @@ import UiTitle from '@core/components/ui/title/UiTitle.vue'
import { usePagination } from '@core/composables/pagination.composable'
import { useRouteQuery } from '@core/composables/route-query.composable'
import { useTableState } from '@core/composables/table-state.composable'
import { useMapper } from '@core/packages/mapper'
import { type ActionItem } from '@core/tables/column-definitions/action-column.ts'
import { useVifColumns } from '@core/tables/column-sets/vif-columns'
import { CONNECTION_ACTION } from '@core/types/connection.ts'
import { getUniqueIpAddressesForDevice } from '@core/utils/ip-address.utils.ts'
@@ -97,40 +95,14 @@ const { HeadCells, BodyCells } = useVifColumns({
const ipAddresses = computed(() => getIpAddresses(vif))
const {
connectVifs,
disconnectVifs,
canConnectVifs,
canDisconnectVifs,
isConnectingVifs,
isDisconnectingVifs,
connectVifsErrorMessage,
disconnectVifsErrorMessage,
} = useVifConnection({
vifs: () => [vif],
vm: () => vm,
})
const connectionAction = useMapper(
openModal: openVifConnectionToggleModal,
canRun: canToggleVifConnection,
isRunning: isTogglingVifConnection,
errorMessage: toggleConnectionErrorMessage,
} = useVifConnectionToggleModal(
() => (vif.currently_attached ? CONNECTION_ACTION.DISCONNECT : CONNECTION_ACTION.CONNECT),
() => ({
connect: {
label: t('action:connect'),
icon: 'action:connect',
onClick: () => connectVifs(),
disabled: !canConnectVifs.value,
busy: isConnectingVifs.value,
hint: canConnectVifs.value ? undefined : connectVifsErrorMessage.value,
} satisfies ActionItem,
disconnect: {
label: t('action:disconnect'),
icon: 'action:disconnect',
onClick: () => disconnectVifs(),
disabled: !canDisconnectVifs.value,
busy: isDisconnectingVifs.value,
hint: canDisconnectVifs.value ? undefined : disconnectVifsErrorMessage.value,
} satisfies ActionItem,
}),
'connect'
() => [vif],
() => vm
)
return {
@@ -144,7 +116,16 @@ const { HeadCells, BodyCells } = useVifColumns({
actions: r =>
r({
onClick: () => (selectedVifId.value = vif.uuid),
actions: [connectionAction.value],
actions: [
{
label: vif.currently_attached ? t('action:disconnect') : t('action:connect'),
hint: !canToggleVifConnection.value ? toggleConnectionErrorMessage.value : undefined,
icon: vif.currently_attached ? 'action:disconnect' : 'action:connect',
onClick: () => openVifConnectionToggleModal(),
disabled: !canToggleVifConnection.value,
busy: isTogglingVifConnection.value,
},
],
}),
}
},

View File

@@ -1,9 +1,9 @@
import { ipToHostname } from '@/libs/utils'
import { useHostStore } from '@/stores/xen-api/host.store'
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { reactiveComputed, whenever } from '@vueuse/core'
import { useModal } from '@core/packages/modal/use-modal.ts'
import { whenever } from '@vueuse/core'
import { difference } from 'lodash-es'
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
export const useUnreachableHosts = () => {
const { records: hosts } = useHostStore().subscribe()
@@ -18,33 +18,15 @@ export const useUnreachableHosts = () => {
})
})
const { open } = useOverlay({
component: () => import('@/components/modals/UnreachableHostsModal.vue'),
events: {
onConfirm: true,
onCancel: true,
},
const openModal = useModal({
keepOpenOnRouteChange: true,
component: import('@/components/modals/UnreachableHostsModal.vue'),
props: { urls: computed(() => Array.from(unreachableHostsUrls.value.values())) },
onConfirm: () => window.location.reload(),
onCancel: () => unreachableHostsUrls.value.clear(),
})
const modalProps = reactiveComputed(() => ({
urls: Array.from(unreachableHostsUrls.value.values()),
}))
async function openModal() {
const { event } = await open({ props: modalProps })
if (event === 'onConfirm') {
window.location.reload()
}
unreachableHostsUrls.value.clear()
}
whenever(
() => unreachableHostsUrls.value.size > 0,
() => openModal(),
{
immediate: true,
}
)
whenever(() => unreachableHostsUrls.value.size > 0, openModal, {
immediate: true,
})
}

View File

@@ -1,27 +0,0 @@
import type { VM_COMPRESSION_TYPE } from '@/libs/xen-api/xen-api.enums.ts'
import type { XenApiVm } from '@/libs/xen-api/xen-api.types.ts'
import { useXenApiStore } from '@/stores/xen-api.store.ts'
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
export function useVmExport() {
const xapi = useXenApiStore().getXapi()
const { open: openBlockedUrlsModal } = useOverlay({
component: () => import('@/components/modals/VmExportBlockedUrlsModal.vue'),
events: {
onClose: true,
},
})
async function exportVms(vmRefs: XenApiVm['$ref'][], compression: VM_COMPRESSION_TYPE) {
const { blockedUrls } = await xapi.vm.export(vmRefs, compression)
if (blockedUrls.length === 0) {
return
}
openBlockedUrlsModal({ props: { blockedUrls } })
}
return { exportVms }
}

View File

@@ -1,8 +1,9 @@
import { useVmExport } from '@/composables/vm-export.composable.ts'
import { vmsArg } from '@/jobs/args'
import { areSomeVmOperationAllowed, isVmOperationPending } from '@/libs/vm'
import { VM_COMPRESSION_TYPE, VM_OPERATION } from '@/libs/xen-api/xen-api.enums'
import { useXenApiStore } from '@/stores/xen-api.store'
import { defineJob, defineJobArg, JobError, JobRunningError } from '@core/packages/job'
import { useModal } from '@core/packages/modal/use-modal.ts'
import { useI18n } from 'vue-i18n'
const compressionArg = defineJobArg<VM_COMPRESSION_TYPE>({
@@ -11,15 +12,16 @@ const compressionArg = defineJobArg<VM_COMPRESSION_TYPE>({
})
export const useVmExportJob = defineJob('vm.export', [vmsArg, compressionArg], () => {
const xapi = useXenApiStore().getXapi()
const { t } = useI18n()
const { exportVms } = useVmExport()
const openModal = useModal()
return {
run: (vms, compression) =>
exportVms(
xapi.vm.export(
vms.map(vm => vm.$ref),
compression
compression,
openModal
),
validate: (isRunning, vms, compression) => {
if (vms.length === 0) {

View File

@@ -1,6 +1,7 @@
import type XenApi from '@/libs/xen-api/xen-api'
import type { VM_COMPRESSION_TYPE } from '@/libs/xen-api/xen-api.enums'
import type { XenApiHost, XenApiSr, XenApiVm } from '@/libs/xen-api/xen-api.types'
import type { OpenModal } from '@core/packages/modal/types.ts'
import type { MaybeArray } from '@core/types/utility.type.ts'
import { toArray } from '@core/utils/to-array.utils'
import type { OPAQUE_REF_NULL } from '@vates/types/common'
@@ -41,7 +42,7 @@ export function createVmOperations(xenApi: XenApi) {
delete: (vmRefs: VmRefs) => Promise.all(toArray(vmRefs).map(vmRef => xenApi.call('VM.destroy', [vmRef]))),
export: async (vmRefs: VmRefs, compression: VM_COMPRESSION_TYPE) => {
export: async (vmRefs: VmRefs, compression: VM_COMPRESSION_TYPE, openModal: OpenModal) => {
const blockedUrls: URL[] = []
toArray(vmRefs).forEach(vmRef => {
@@ -53,14 +54,22 @@ export function createVmOperations(xenApi: XenApi) {
use_compression: compression,
}).toString()
const openedWindow = window.open(url.href, '_blank')
const _window = window.open(url.href, '_blank')
if (openedWindow === null) {
if (_window === null) {
blockedUrls.push(url)
} else {
URL.revokeObjectURL(url.toString())
}
})
return { blockedUrls }
if (blockedUrls.length > 0) {
await openModal('blocked-urls', {
component: import('@/components/modals/VmExportBlockedUrlsModal.vue'),
props: { blockedUrls },
onConfirm: () => blockedUrls.forEach(url => URL.revokeObjectURL(url.toString())),
})
}
},
getAllowedVbdDevices: (vmRefs: VmRefs): Promise<string[][]> =>

View File

@@ -1,18 +1,18 @@
<template>
<UiButton
v-tooltip="!canConnectSrs && connectSrsErrorMessage"
v-tooltip="!canConnectSr && connectSrErrorMessage"
size="medium"
variant="tertiary"
accent="brand"
:disabled="!canConnectSrs"
:disabled="!canConnectSr"
left-icon="action:connect"
:busy="isConnectingSrs"
@click="connectSrs()"
:busy="isConnectingSr"
@click="openSrConnectModal()"
>
{{ t('action:connect') }}
<UiCounter
v-if="isPartiallyConnectedInScope"
:value="connectionTargetCount"
:value="targetCount"
accent="brand"
variant="secondary"
size="small"
@@ -23,7 +23,7 @@
<script lang="ts" setup>
import type { XenApiSr } from '@/libs/xen-api/xen-api.types.ts'
import { useSrUtils } from '@/modules/storage-repository/composables/sr-utils.composable.ts'
import { useSrConnection } from '@/modules/storage-repository/composables/use-sr-connection.composable.ts'
import { useSrConnectModal } from '@/modules/storage-repository/composables/use-sr-connect-modal.composable.ts'
import type { SrScope } from '@core/types/storage-repository.type.ts'
import UiButton from '@core/components/ui/button/UiButton.vue'
import UiCounter from '@core/components/ui/counter/UiCounter.vue'
@@ -42,8 +42,14 @@ const { isPartiallyConnectedInScope } = useSrUtils(
const { t } = useI18n()
const { connectSrs, canConnectSrs, isConnectingSrs, connectSrsErrorMessage, connectionTargetCount } = useSrConnection({
srs: () => [sr],
scope: () => scope,
})
const {
openModal: openSrConnectModal,
canRun: canConnectSr,
isRunning: isConnectingSr,
errorMessage: connectSrErrorMessage,
targetCount,
} = useSrConnectModal(
() => [sr],
() => scope
)
</script>

View File

@@ -1,11 +1,11 @@
<template>
<MenuItem
v-tooltip="!canDeleteSrs && deleteSrsErrorMessage"
v-tooltip="!canDeleteSr && deleteSrErrorMessage"
icon="action:delete"
:disabled="!canDeleteSrs"
:busy="isDeletingSrs"
:disabled="!canDeleteSr"
:busy="isDeletingSr"
class="delete"
@click="deleteSrs()"
@click="openSrDeleteModal()"
>
{{ t('action:delete') }}
</MenuItem>
@@ -13,7 +13,7 @@
<script lang="ts" setup>
import type { XenApiSr } from '@/libs/xen-api/xen-api.types.ts'
import { useSrDelete } from '@/modules/storage-repository/composables/use-sr-delete.composable.ts'
import { useSrDeleteModal } from '@/modules/storage-repository/composables/use-sr-delete-modal.composable.ts'
import MenuItem from '@core/components/menu/MenuItem.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { useI18n } from 'vue-i18n'
@@ -24,7 +24,12 @@ const { sr } = defineProps<{
const { t } = useI18n()
const { deleteSrs, canDeleteSrs, isDeletingSrs, deleteSrsErrorMessage } = useSrDelete(() => [sr])
const {
openModal: openSrDeleteModal,
canRun: canDeleteSr,
isRunning: isDeletingSr,
errorMessage: deleteSrErrorMessage,
} = useSrDeleteModal(() => [sr])
</script>
<style lang="postcss" scoped>

View File

@@ -1,15 +1,15 @@
<template>
<MenuItem
v-tooltip="!canDisconnectSrs && disconnectSrsErrorMessage"
v-tooltip="!canDisconnectSr && disconnectSrErrorMessage"
icon="action:disconnect"
:disabled="!canDisconnectSrs"
:busy="isDisconnectingSrs"
@click="disconnectSrs()"
:disabled="!canDisconnectSr"
:busy="isDisconnectingSr"
@click="openSrDisconnectModal()"
>
{{ t('action:disconnect') }}
<UiCounter
v-if="shouldShowTargetCount(scope, disconnectionTargetCount)"
:value="disconnectionTargetCount"
v-if="shouldShowTargetCount(scope, targetCount)"
:value="targetCount"
accent="brand"
variant="secondary"
size="small"
@@ -20,7 +20,7 @@
<script lang="ts" setup>
import type { XenApiSr } from '@/libs/xen-api/xen-api.types.ts'
import { useSrConnection } from '@/modules/storage-repository/composables/use-sr-connection.composable.ts'
import { useSrDisconnectModal } from '@/modules/storage-repository/composables/use-sr-disconnect-modal.composable.ts'
import MenuItem from '@core/components/menu/MenuItem.vue'
import UiCounter from '@core/components/ui/counter/UiCounter.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
@@ -36,11 +36,16 @@ const { sr, scope } = defineProps<{
const { t } = useI18n()
const { disconnectSrs, canDisconnectSrs, isDisconnectingSrs, disconnectSrsErrorMessage, disconnectionTargetCount } =
useSrConnection({
srs: () => [sr],
scope: () => scope,
})
const {
openModal: openSrDisconnectModal,
canRun: canDisconnectSr,
isRunning: isDisconnectingSr,
errorMessage: disconnectSrErrorMessage,
targetCount,
} = useSrDisconnectModal(
() => [sr],
() => scope
)
const hint = computed(() => (!canDisconnectSrs.value ? disconnectSrsErrorMessage.value : undefined))
const hint = computed(() => (!canDisconnectSr.value ? disconnectSrErrorMessage.value : undefined))
</script>

View File

@@ -24,8 +24,9 @@
<script setup lang="ts">
import type { XenApiPool, XenApiSr } from '@/libs/xen-api/xen-api.types.ts'
import { useGetPbdsInScope, useSrUtils } from '@/modules/storage-repository/composables/sr-utils.composable.ts'
import { useSrConnection } from '@/modules/storage-repository/composables/use-sr-connection.composable.ts'
import { useSrDelete } from '@/modules/storage-repository/composables/use-sr-delete.composable.ts'
import { useSrConnectModal } from '@/modules/storage-repository/composables/use-sr-connect-modal.composable.ts'
import { useSrDeleteModal } from '@/modules/storage-repository/composables/use-sr-delete-modal.composable.ts'
import { useSrDisconnectModal } from '@/modules/storage-repository/composables/use-sr-disconnect-modal.composable.ts'
import { useSrStore } from '@/stores/xen-api/sr.store.ts'
import VtsRow from '@core/components/table/VtsRow.vue'
import VtsTable from '@core/components/table/VtsTable.vue'
@@ -110,33 +111,44 @@ const { HeadCells, BodyCells } = useSrColumns({
const { srStatusIcon } = useSrUtils(sr, () => scope)
const { deleteSrs, canDeleteSrs, isDeletingSrs, deleteSrsErrorMessage } = useSrDelete(() => [sr])
const {
openModal: openSrDeleteModal,
canRun: canDeleteSr,
isRunning: isDeletingSr,
errorMessage: deleteSrErrorMessage,
} = useSrDeleteModal(() => [sr])
const {
connectSrs,
disconnectSrs,
canConnectSrs,
canDisconnectSrs,
isConnectingSrs,
isDisconnectingSrs,
connectSrsErrorMessage,
disconnectSrsErrorMessage,
connectionTargetCount,
disconnectionTargetCount,
} = useSrConnection({
srs: () => [sr],
scope: () => scope,
})
openModal: openSrConnectModal,
canRun: canConnectSr,
isRunning: isConnectingSr,
errorMessage: connectSrErrorMessage,
targetCount: connectTargetCount,
} = useSrConnectModal(
() => [sr],
() => scope
)
const {
openModal: openSrDisconnectModal,
canRun: canDisconnectSr,
isRunning: isDisconnectingSr,
errorMessage: disconnectSrErrorMessage,
targetCount: disconnectTargetCount,
} = useSrDisconnectModal(
() => [sr],
() => scope
)
const connectLabel = computed(() =>
shouldShowTargetCount(scope, connectionTargetCount.value)
? t('action:connect-n', { n: connectionTargetCount.value })
shouldShowTargetCount(scope, connectTargetCount.value)
? t('action:connect-n', { n: connectTargetCount.value })
: t('action:connect')
)
const disconnectLabel = computed(() =>
shouldShowTargetCount(scope, disconnectionTargetCount.value)
? t('action:disconnect-n', { n: disconnectionTargetCount.value })
shouldShowTargetCount(scope, disconnectTargetCount.value)
? t('action:disconnect-n', { n: disconnectTargetCount.value })
: t('action:disconnect')
)
@@ -158,26 +170,26 @@ const { HeadCells, BodyCells } = useSrColumns({
{
label: connectLabel.value,
icon: 'action:connect',
onClick: () => connectSrs(),
busy: isConnectingSrs.value,
disabled: !canConnectSrs.value,
hint: connectSrsErrorMessage.value,
onClick: () => openSrConnectModal(),
busy: isConnectingSr.value,
disabled: !canConnectSr.value,
hint: connectSrErrorMessage.value,
},
{
label: disconnectLabel.value,
icon: 'action:disconnect',
onClick: () => disconnectSrs(),
busy: isDisconnectingSrs.value,
disabled: !canDisconnectSrs.value,
hint: disconnectSrsErrorMessage.value,
onClick: () => openSrDisconnectModal(),
busy: isDisconnectingSr.value,
disabled: !canDisconnectSr.value,
hint: disconnectSrErrorMessage.value,
},
{
label: t('action:delete'),
icon: 'action:delete',
onClick: () => deleteSrs(),
busy: isDeletingSrs.value,
disabled: !canDeleteSrs.value,
hint: deleteSrsErrorMessage.value,
onClick: () => openSrDeleteModal(),
busy: isDeletingSr.value,
disabled: !canDeleteSr.value,
hint: deleteSrErrorMessage.value,
},
],
}),

View File

@@ -0,0 +1,56 @@
<template>
<VtsModal accent="info" icon="status:info-picto" dismissible>
<template #title>
{{ title }}
</template>
<template #content>
{{ info }}
</template>
<template #buttons>
<VtsModalCancelButton>{{ t('action:go-back') }}</VtsModalCancelButton>
<VtsModalConfirmButton>
{{ confirmLabel }}
</VtsModalConfirmButton>
</template>
</VtsModal>
</template>
<script lang="ts" setup>
import { useSrModalMessages } from '@/modules/storage-repository/composables/use-sr-modal-messages.composable.ts'
import type { SrAccessMode, SrScope } from '@core/types/storage-repository.type.ts'
import VtsModal from '@core/components/modal/VtsModal.vue'
import VtsModalCancelButton from '@core/components/modal/VtsModalCancelButton.vue'
import VtsModalConfirmButton from '@core/components/modal/VtsModalConfirmButton.vue'
import { useMapper } from '@core/packages/mapper'
import { CONNECTION_ACTION, type ConnectionAction } from '@core/types/connection.ts'
import { useI18n } from 'vue-i18n'
const { action, count, scope, accessMode, hostsCount } = defineProps<{
action: ConnectionAction
count: number
scope: SrScope
accessMode: SrAccessMode
hostsCount: number
}>()
const { t } = useI18n()
const { title, info } = useSrModalMessages({
action,
count: () => count,
scope: () => scope,
accessMode: () => accessMode,
hostsCount: () => hostsCount,
})
const confirmLabel = useMapper(
() => action,
() => ({
[CONNECTION_ACTION.CONNECT]: t('action:connect-n-srs', { n: count }),
[CONNECTION_ACTION.DISCONNECT]: t('action:disconnect-n-srs', { n: count }),
}),
CONNECTION_ACTION.CONNECT
)
</script>

View File

@@ -0,0 +1,27 @@
<template>
<VtsDeleteModal dismissible>
<template #title>
{{ t('n-srs', { n: count }) }}
</template>
<template #content>
{{ t('sr-delete-info', { n: count }) }}
<!-- TODO Add a type-to-confirm input if count > 1 -->
</template>
<template #confirm>
{{ t(`action:delete-n-srs`, { n: count }) }}
</template>
</VtsDeleteModal>
</template>
<script lang="ts" setup>
import VtsDeleteModal from '@core/components/modal/VtsDeleteModal.vue'
import { useI18n } from 'vue-i18n'
defineProps<{
count: number
}>()
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,41 @@
import { usePbdPlugJob } from '@/jobs/pbd-plug.job.ts'
import type { XenApiSr } from '@/libs/xen-api/xen-api.types.ts'
import { useGetPbdsInScope } from '@/modules/storage-repository/composables/sr-utils.composable.ts'
import type { SrScope } from '@core/types/storage-repository.type.ts'
import { useModal } from '@core/packages/modal/use-modal.ts'
import { CONNECTION_ACTION } from '@core/types/connection.ts'
import { getSrAccessMode } from '@core/utils/sr.utils.ts'
import { toComputed } from '@core/utils/to-computed.util.ts'
import { computed, type MaybeRefOrGetter } from 'vue'
export function useSrConnectModal(rawSrs: MaybeRefOrGetter<XenApiSr[]>, rawScope: MaybeRefOrGetter<SrScope>) {
const srs = toComputed(rawSrs)
const scope = toComputed(rawScope)
const { getDetachedPbdsInScope } = useGetPbdsInScope()
const plugTargets = computed(() => srs.value.flatMap(sr => getDetachedPbdsInScope(sr, scope.value)))
const targetCount = computed(() => plugTargets.value.length)
const { run, canRun, isRunning, errorMessage } = usePbdPlugJob(plugTargets)
const openModal = useModal(() => ({
component: import('@/modules/storage-repository/components/modal/SrConnectionToggleModal.vue'),
props: {
action: CONNECTION_ACTION.CONNECT,
count: srs.value.length,
scope: scope.value,
accessMode: getSrAccessMode(srs.value),
hostsCount: targetCount.value,
},
onConfirm: async () => {
try {
await run()
} catch (error) {
console.error(`Error when connecting SR:`, error)
}
},
}))
return { openModal, canRun, isRunning, errorMessage, targetCount }
}

View File

@@ -1,100 +0,0 @@
import { usePbdPlugJob } from '@/jobs/pbd-plug.job.ts'
import { usePbdUnplugJob } from '@/jobs/pbd-unplug.job.ts'
import type { XenApiSr } from '@/libs/xen-api/xen-api.types.ts'
import { useGetPbdsInScope } from '@/modules/storage-repository/composables/sr-utils.composable.ts'
import type { SrScope } from '@core/types/storage-repository.type.ts'
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { CONNECTION_ACTION } from '@core/types/connection.ts'
import { getSrAccessMode } from '@core/utils/sr.utils.ts'
import { toComputed } from '@core/utils/to-computed.util.ts'
import { computed, type MaybeRefOrGetter } from 'vue'
export function useSrConnection(options: { srs: MaybeRefOrGetter<XenApiSr[]>; scope: MaybeRefOrGetter<SrScope> }) {
const srs = toComputed(options.srs)
const scope = toComputed(options.scope)
const { getAttachedPbdsInScope, getDetachedPbdsInScope } = useGetPbdsInScope()
const connectionTargets = computed(() => srs.value.flatMap(sr => getDetachedPbdsInScope(sr, scope.value)))
const disconnectionTargets = computed(() => srs.value.flatMap(sr => getAttachedPbdsInScope(sr, scope.value)))
const connectionTargetCount = computed(() => connectionTargets.value.length)
const disconnectionTargetCount = computed(() => disconnectionTargets.value.length)
const {
run: runConnect,
canRun: canConnectSrs,
isRunning: isConnectingSrs,
errorMessage: connectSrsErrorMessage,
} = usePbdPlugJob(connectionTargets)
const {
run: runDisconnect,
canRun: canDisconnectSrs,
isRunning: isDisconnectingSrs,
errorMessage: disconnectSrsErrorMessage,
} = usePbdUnplugJob(disconnectionTargets)
const { open } = useOverlay({
component: () => import('@core/components/sr-connection-modal/VtsSrConnectionModal.vue'),
events: {
onConfirm: true,
onCancel: true,
},
})
function connectSrs() {
return open({
props: {
action: CONNECTION_ACTION.CONNECT,
count: srs.value.length,
scope: scope.value,
accessMode: getSrAccessMode(srs.value),
hostsCount: connectionTargetCount.value,
},
events: {
onConfirm: async () => {
try {
await runConnect()
} catch (error) {
console.error('Error when connecting SR:', error)
}
},
},
})
}
function disconnectSrs() {
return open({
props: {
action: CONNECTION_ACTION.DISCONNECT,
count: srs.value.length,
scope: scope.value,
accessMode: getSrAccessMode(srs.value),
hostsCount: disconnectionTargetCount.value,
},
events: {
onConfirm: async () => {
try {
await runDisconnect()
} catch (error) {
console.error('Error when disconnecting SR:', error)
}
},
},
})
}
return {
connectSrs,
disconnectSrs,
canConnectSrs,
canDisconnectSrs,
isConnectingSrs,
isDisconnectingSrs,
connectSrsErrorMessage,
disconnectSrsErrorMessage,
connectionTargetCount,
disconnectionTargetCount,
}
}

View File

@@ -0,0 +1,32 @@
import { useSrDeleteJob } from '@/jobs/sr-delete.job.ts'
import type { XenApiSr } from '@/libs/xen-api/xen-api.types.ts'
import { useRouteQuery } from '@core/composables/route-query.composable.ts'
import { useModal } from '@core/packages/modal/use-modal.ts'
import { toComputed } from '@core/utils/to-computed.util.ts'
import type { MaybeRefOrGetter } from 'vue'
export function useSrDeleteModal(rawSrs: MaybeRefOrGetter<XenApiSr[]>) {
const srs = toComputed(rawSrs)
const selectedSrId = useRouteQuery('id')
const { run, canRun, isRunning, errorMessage } = useSrDeleteJob(srs)
const openModal = useModal(() => ({
component: import('@/modules/storage-repository/components/modal/SrDeleteModal.vue'),
props: { count: srs.value.length },
onConfirm: async () => {
try {
await run()
if (srs.value.some(sr => sr.uuid === selectedSrId.value)) {
selectedSrId.value = ''
}
} catch (error) {
console.error('Error when deleting SR:', error)
}
},
}))
return { openModal, canRun, isRunning, errorMessage }
}

View File

@@ -1,52 +0,0 @@
import { useSrDeleteJob } from '@/jobs/sr-delete.job.ts'
import type { XenApiSr } from '@/libs/xen-api/xen-api.types.ts'
import { useDeleteModal } from '@core/composables/modals/use-delete-modal.ts'
import { useRouteQuery } from '@core/composables/route-query.composable.ts'
import { toComputed } from '@core/utils/to-computed.util.ts'
import type { MaybeRefOrGetter } from 'vue'
import { useI18n } from 'vue-i18n'
export function useSrDelete(rawSrs: MaybeRefOrGetter<XenApiSr[]>) {
const srs = toComputed(rawSrs)
const { t } = useI18n()
const selectedSrId = useRouteQuery('id')
const {
run,
canRun: canDeleteSrs,
isRunning: isDeletingSrs,
errorMessage: deleteSrsErrorMessage,
} = useSrDeleteJob(srs)
const { open } = useDeleteModal()
function deleteSrs() {
const count = srs.value.length
// TODO Add a type-to-confirm input if count > 1
return open({
props: {
subject: t('n-srs', { n: count }),
description: t('sr-delete-info', { n: count }),
confirmLabel: t('action:delete-n-srs', { n: count }),
},
events: {
onConfirm: async () => {
try {
await run()
if (srs.value.some(sr => sr.uuid === selectedSrId.value)) {
selectedSrId.value = ''
}
} catch (error) {
console.error('Error when deleting SR:', error)
}
},
},
})
}
return { deleteSrs, canDeleteSrs, isDeletingSrs, deleteSrsErrorMessage }
}

View File

@@ -0,0 +1,42 @@
import { usePbdUnplugJob } from '@/jobs/pbd-unplug.job.ts'
import type { XenApiSr } from '@/libs/xen-api/xen-api.types.ts'
import { useGetPbdsInScope } from '@/modules/storage-repository/composables/sr-utils.composable.ts'
import type { SrScope } from '@core/types/storage-repository.type.ts'
import { useModal } from '@core/packages/modal/use-modal.ts'
import { CONNECTION_ACTION } from '@core/types/connection.ts'
import { getSrAccessMode } from '@core/utils/sr.utils.ts'
import { toComputed } from '@core/utils/to-computed.util.ts'
import { computed, type MaybeRefOrGetter } from 'vue'
export function useSrDisconnectModal(rawSrs: MaybeRefOrGetter<XenApiSr[]>, rawScope: MaybeRefOrGetter<SrScope>) {
const srs = toComputed(rawSrs)
const scope = toComputed(rawScope)
const { getAttachedPbdsInScope } = useGetPbdsInScope()
const unplugTargets = computed(() => srs.value.flatMap(sr => getAttachedPbdsInScope(sr, scope.value)))
const targetCount = computed(() => unplugTargets.value.length)
const { run, canRun, isRunning, errorMessage } = usePbdUnplugJob(unplugTargets)
const openModal = useModal(() => ({
component: import('@/modules/storage-repository/components/modal/SrConnectionToggleModal.vue'),
props: {
action: CONNECTION_ACTION.DISCONNECT,
count: srs.value.length,
scope: scope.value,
accessMode: getSrAccessMode(srs.value),
hostsCount: targetCount.value,
},
onConfirm: async () => {
try {
await run()
} catch (error) {
console.error(`Error when disconnecting SR:`, error)
}
},
}))
return { openModal, canRun, isRunning, errorMessage, targetCount }
}

View File

@@ -0,0 +1,54 @@
import type { SrAccessMode, SrScope } from '@core/types/storage-repository.type.ts'
import { useMapper } from '@core/packages/mapper'
import { CONNECTION_ACTION, type ConnectionAction } from '@core/types/connection.ts'
import { getSrModalInfoVariant } from '@core/utils/sr.utils.ts'
import { toComputed } from '@core/utils/to-computed.util.ts'
import { type MaybeRefOrGetter } from 'vue'
import { useI18n } from 'vue-i18n'
export function useSrModalMessages(options: {
action: ConnectionAction
count: MaybeRefOrGetter<number>
scope: MaybeRefOrGetter<SrScope>
accessMode: MaybeRefOrGetter<SrAccessMode>
hostsCount: MaybeRefOrGetter<number>
}) {
const { t } = useI18n()
const count = toComputed(options.count)
const scope = toComputed(options.scope)
const accessMode = toComputed(options.accessMode)
const hostsCount = toComputed(options.hostsCount)
const title = useMapper(
() => options.action,
() => ({
[CONNECTION_ACTION.CONNECT]: t('sr-connect-title', { n: count.value }),
[CONNECTION_ACTION.DISCONNECT]: t('sr-disconnect-title', { n: count.value }),
}),
CONNECTION_ACTION.CONNECT
)
const info = useMapper(
() => `${options.action}-${getSrModalInfoVariant(scope.value, accessMode.value)}`,
() => ({
'connect-host': t('sr-connect-info-host', { n: count.value }),
'connect-pool-local': t('sr-connect-info-pool-local', { n: count.value }),
'connect-pool-mixed': t('sr-connect-info-pool-mixed'),
'connect-pool-shared': t('sr-connect-info-pool-shared', {
n: count.value,
hostsCount: hostsCount.value,
}),
'disconnect-host': t('sr-disconnect-info-host', { n: count.value }),
'disconnect-pool-local': t('sr-disconnect-info-pool-local', { n: count.value }),
'disconnect-pool-mixed': t('sr-disconnect-info-pool-mixed'),
'disconnect-pool-shared': t('sr-disconnect-info-pool-shared', {
n: count.value,
hostsCount: hostsCount.value,
}),
}),
'connect-pool-shared'
)
return { title, info }
}

View File

@@ -1,13 +1,13 @@
<template>
<UiButton
v-tooltip="!connection.canToggle && connection.errorMessage"
v-tooltip="!canToggleVifConnection && toggleConnectionErrorMessage"
size="medium"
variant="tertiary"
accent="brand"
:disabled="!connection.canToggle"
:disabled="!canToggleVifConnection"
:left-icon="connection.icon"
:busy="connection.isToggling"
@click="connection.toggle()"
:busy="isTogglingVifConnection"
@click="openVifConnectionToggleModal()"
>
{{ connection.label }}
</UiButton>
@@ -15,11 +15,12 @@
<script lang="ts" setup>
import type { XenApiVif, XenApiVm } from '@/libs/xen-api/xen-api.types.ts'
import { useVifConnection } from '@/modules/vif/composables/use-vif-connection.composable.ts'
import { useVifConnectionToggleModal } from '@/modules/vif/composables/use-vif-connection-toggle-modal.composable.ts'
import UiButton from '@core/components/ui/button/UiButton.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { useMapper } from '@core/packages/mapper'
import { CONNECTION_ACTION } from '@core/types/connection.ts'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { vif, vm } = defineProps<{
@@ -29,40 +30,25 @@ const { vif, vm } = defineProps<{
const { t } = useI18n()
const {
connectVifs,
disconnectVifs,
canConnectVifs,
canDisconnectVifs,
isConnectingVifs,
isDisconnectingVifs,
connectVifsErrorMessage,
disconnectVifsErrorMessage,
} = useVifConnection({
vifs: () => [vif],
vm: () => vm,
})
const action = computed(() => (vif.currently_attached ? CONNECTION_ACTION.DISCONNECT : CONNECTION_ACTION.CONNECT))
const connection = useMapper(
() => (vif.currently_attached ? CONNECTION_ACTION.DISCONNECT : CONNECTION_ACTION.CONNECT),
() => action.value,
() => ({
connect: {
label: t('action:connect'),
icon: 'action:connect' as const,
toggle: connectVifs,
canToggle: canConnectVifs.value,
isToggling: isConnectingVifs.value,
errorMessage: connectVifsErrorMessage.value,
},
disconnect: {
label: t('action:disconnect'),
icon: 'action:disconnect' as const,
toggle: disconnectVifs,
canToggle: canDisconnectVifs.value,
isToggling: isDisconnectingVifs.value,
errorMessage: disconnectVifsErrorMessage.value,
},
connect: { label: t('action:connect'), icon: 'action:connect' },
disconnect: { label: t('action:disconnect'), icon: 'action:disconnect' },
}),
'connect'
)
const {
openModal: openVifConnectionToggleModal,
canRun: canToggleVifConnection,
isRunning: isTogglingVifConnection,
errorMessage: toggleConnectionErrorMessage,
} = useVifConnectionToggleModal(
action,
() => [vif],
() => vm
)
</script>

View File

@@ -0,0 +1,40 @@
import type { XenApiVif, XenApiVm } from '@/libs/xen-api/xen-api.types.ts'
import { useVifConnectJob } from '@/modules/vif/jobs/vif-connect.job.ts'
import { useVifDisconnectJob } from '@/modules/vif/jobs/vif-disconnect.job.ts'
import { useModal } from '@core/packages/modal/use-modal.ts'
import { CONNECTION_ACTION, type ConnectionAction } from '@core/types/connection.ts'
import { toComputed } from '@core/utils/to-computed.util.ts'
import { computed, type MaybeRefOrGetter } from 'vue'
export function useVifConnectionToggleModal(
rawAction: MaybeRefOrGetter<ConnectionAction>,
rawVifs: MaybeRefOrGetter<XenApiVif[]>,
rawVm: MaybeRefOrGetter<XenApiVm>
) {
const action = toComputed(rawAction)
const vifs = toComputed(rawVifs)
const vm = toComputed(rawVm)
const connectJob = useVifConnectJob(vifs, vm)
const disconnectJob = useVifDisconnectJob(vifs, vm)
const job = computed(() => (action.value === CONNECTION_ACTION.CONNECT ? connectJob : disconnectJob))
const canRun = computed(() => job.value.canRun.value)
const isRunning = computed(() => job.value.isRunning.value)
const errorMessage = computed(() => job.value.errorMessage.value)
const openModal = useModal(() => ({
component: import('@core/components/vif-connection-toggle-modal/VtsVifConnectionToggleModal.vue'),
props: { action: action.value, count: vifs.value.length },
onConfirm: async () => {
try {
await job.value.run()
} catch (error) {
console.error(`Error when ${action.value}ing VIF:`, error)
}
},
}))
return { openModal, canRun, isRunning, errorMessage }
}

View File

@@ -1,81 +0,0 @@
import type { XenApiVif, XenApiVm } from '@/libs/xen-api/xen-api.types.ts'
import { useVifConnectJob } from '@/modules/vif/jobs/vif-connect.job.ts'
import { useVifDisconnectJob } from '@/modules/vif/jobs/vif-disconnect.job.ts'
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { CONNECTION_ACTION } from '@core/types/connection.ts'
import { toComputed } from '@core/utils/to-computed.util.ts'
import type { MaybeRefOrGetter } from 'vue'
export function useVifConnection(options: { vifs: MaybeRefOrGetter<XenApiVif[]>; vm: MaybeRefOrGetter<XenApiVm> }) {
const vifs = toComputed(options.vifs)
const vm = toComputed(options.vm)
const {
run: runConnect,
canRun: canConnectVifs,
isRunning: isConnectingVifs,
errorMessage: connectVifsErrorMessage,
} = useVifConnectJob(vifs, vm)
const {
run: runDisconnect,
canRun: canDisconnectVifs,
isRunning: isDisconnectingVifs,
errorMessage: disconnectVifsErrorMessage,
} = useVifDisconnectJob(vifs, vm)
const { open } = useOverlay({
component: () => import('@core/components/vif-connection-modal/VtsVifConnectionModal.vue'),
events: {
onConfirm: true,
onCancel: true,
},
})
function connectVifs() {
return open({
props: {
action: CONNECTION_ACTION.CONNECT,
count: vifs.value.length,
},
events: {
onConfirm: async () => {
try {
await runConnect()
} catch (error) {
console.error('Error when connecting VIF:', error)
}
},
},
})
}
function disconnectVifs() {
return open({
props: {
action: CONNECTION_ACTION.DISCONNECT,
count: vifs.value.length,
},
events: {
onConfirm: async () => {
try {
await runDisconnect()
} catch (error) {
console.error('Error when disconnecting VIF:', error)
}
},
},
})
}
return {
connectVifs,
disconnectVifs,
canConnectVifs,
canDisconnectVifs,
isConnectingVifs,
isDisconnectingVifs,
connectVifsErrorMessage,
disconnectVifsErrorMessage,
}
}

View File

@@ -205,7 +205,7 @@ import UiRadioButton from '@core/components/ui/radio-button/UiRadioButton.vue'
import UiRadioButtonGroup from '@core/components/ui/radio-button-group/UiRadioButtonGroup.vue'
import UiToggle from '@core/components/ui/toggle/UiToggle.vue'
import { useFormSelect } from '@core/packages/form-select'
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { useModal } from '@core/packages/modal/use-modal.ts'
import { useUiStore } from '@core/stores/ui.store'
import { logicNot } from '@vueuse/math'
import { computed, onUnmounted, ref, watch } from 'vue'
@@ -219,16 +219,10 @@ const router = useRouter()
usePageTitleStore().setTitle(() => t('action:deploy-xoa'))
const { open: openModal } = useOverlay({
component: () => import('@/components/modals/InvalidFieldModal.vue'),
events: {
onClose: true,
},
})
function openInvalidFieldModal(message: string) {
return openModal({ props: { message } })
}
const openInvalidFieldModal = useModal((message: string) => ({
component: import('@/components/modals/InvalidFieldModal.vue'),
props: { message },
}))
const uiStore = useUiStore()

View File

@@ -233,13 +233,6 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/story/overlay': RouteRecordInfo<
'/story/overlay',
'/story/overlay',
Record<never, never>,
Record<never, never>,
| never
>,
'/story/power-state-icon': RouteRecordInfo<
'/story/power-state-icon',
'/story/power-state-icon',
@@ -338,6 +331,13 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/story/web-core/drawer/vts-drawer': RouteRecordInfo<
'/story/web-core/drawer/vts-drawer',
'/story/web-core/drawer/vts-drawer',
Record<never, never>,
Record<never, never>,
| never
>,
'/story/web-core/dropdown-title/vts-dropdown-title': RouteRecordInfo<
'/story/web-core/dropdown-title/vts-dropdown-title',
'/story/web-core/dropdown-title/vts-dropdown-title',
@@ -387,6 +387,13 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/story/web-core/modal/vts-modal': RouteRecordInfo<
'/story/web-core/modal/vts-modal',
'/story/web-core/modal/vts-modal',
Record<never, never>,
Record<never, never>,
| never
>,
'/story/web-core/object-icon/vts-object-icon': RouteRecordInfo<
'/story/web-core/object-icon/vts-object-icon',
'/story/web-core/object-icon/vts-object-icon',
@@ -394,27 +401,6 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/story/web-core/overlay/vts-overlay-button': RouteRecordInfo<
'/story/web-core/overlay/vts-overlay-button',
'/story/web-core/overlay/vts-overlay-button',
Record<never, never>,
Record<never, never>,
| never
>,
'/story/web-core/overlay/vts-overlay-cancel-button': RouteRecordInfo<
'/story/web-core/overlay/vts-overlay-cancel-button',
'/story/web-core/overlay/vts-overlay-cancel-button',
Record<never, never>,
Record<never, never>,
| never
>,
'/story/web-core/overlay/vts-overlay-confirm-button': RouteRecordInfo<
'/story/web-core/overlay/vts-overlay-confirm-button',
'/story/web-core/overlay/vts-overlay-confirm-button',
Record<never, never>,
Record<never, never>,
| never
>,
'/story/web-core/panel/vts-panel': RouteRecordInfo<
'/story/web-core/panel/vts-panel',
'/story/web-core/panel/vts-panel',
@@ -1214,12 +1200,6 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/stories/overlay.story.vue': {
routes:
| '/story/overlay'
views:
| never
}
'src/stories/power-state-icon.story.vue': {
routes:
| '/story/power-state-icon'
@@ -1304,6 +1284,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/stories/web-core/drawer/vts-drawer.story.vue': {
routes:
| '/story/web-core/drawer/vts-drawer'
views:
| never
}
'src/stories/web-core/dropdown-title/vts-dropdown-title.story.vue': {
routes:
| '/story/web-core/dropdown-title/vts-dropdown-title'
@@ -1346,30 +1332,18 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/stories/web-core/modal/vts-modal.story.vue': {
routes:
| '/story/web-core/modal/vts-modal'
views:
| never
}
'src/stories/web-core/object-icon/vts-object-icon.story.vue': {
routes:
| '/story/web-core/object-icon/vts-object-icon'
views:
| never
}
'src/stories/web-core/overlay/vts-overlay-button.story.vue': {
routes:
| '/story/web-core/overlay/vts-overlay-button'
views:
| never
}
'src/stories/web-core/overlay/vts-overlay-cancel-button.story.vue': {
routes:
| '/story/web-core/overlay/vts-overlay-cancel-button'
views:
| never
}
'src/stories/web-core/overlay/vts-overlay-confirm-button.story.vue': {
routes:
| '/story/web-core/overlay/vts-overlay-confirm-button'
views:
| never
}
'src/stories/web-core/panel/vts-panel.story.vue': {
routes:
| '/story/web-core/panel/vts-panel'

View File

@@ -1,91 +0,0 @@
<template>
<UiCard class="overlay-story">
<UiCardTitle>Overlay system demo</UiCardTitle>
<p class="row">
VM name: <strong>{{ vmName }}</strong>
</p>
<p class="row">
The button opens a drawer, which opens a rename modal, which opens a confirmation modal. The confirmed name is
trimmed by the drawer, then propagated back here, where the drawer is kept open with the updated name (reactive
props).
</p>
<div class="row">
<UiButton accent="brand" size="medium" variant="primary" @click="openDrawer()">Open VM drawer</UiButton>
</div>
<div v-if="logs.length > 0" class="logs">
<UiCardTitle>Responses</UiCardTitle>
<ul>
<li v-for="(log, index) of logs" :key="index" class="row">{{ log }}</li>
</ul>
</div>
</UiCard>
</template>
<script lang="ts" setup>
import UiButton from '@core/components/ui/button/UiButton.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
import { KEEP_OVERLAY_OPEN, OVERLAY_ABORT_EVENT } from '@core/packages/overlay/symbols.ts'
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { reactiveComputed } from '@vueuse/core'
import { ref } from 'vue'
const vmName = ref('XO Lite VM')
const logs = ref<string[]>([])
function addLog(message: string) {
logs.value.unshift(message)
}
const { open: openVmDrawer } = useOverlay({
component: () => import('@/stories/overlay/DemoVmDrawer.vue'),
events: {
onDismiss: true,
},
})
// `reactiveComputed` keeps the drawer in sync when the name changes while it is open
const drawerProps = reactiveComputed(() => ({ name: vmName.value }))
async function openDrawer() {
const response = await openVmDrawer({
props: drawerProps,
events: {
onRename: newName => {
vmName.value = newName
addLog(`onRename → "${newName}" (drawer kept open, showing the new name)`)
return KEEP_OVERLAY_OPEN
},
},
})
if (response.event === OVERLAY_ABORT_EVENT) {
addLog('Drawer aborted (opener disposed or replaced)')
return
}
addLog(`${response.event} → drawer closed`)
}
</script>
<style lang="postcss" scoped>
.overlay-story {
margin: 1rem;
.row {
font-size: 1.6rem;
}
.logs {
margin-block-start: 1.6rem;
}
}
</style>

View File

@@ -1,43 +0,0 @@
<template>
<UiModal accent="warning" @confirm="emit('confirm')" @dismiss="emit('cancel')">
<template #title>Confirm rename</template>
<template #content>
<div class="demo-confirm-modal-content">
<p>
Rename "<strong>{{ currentName }}</strong
>" to "<strong>{{ newName }}</strong
>"?
</p>
<p>Confirming runs a fake 1.5s API call: the Rename button turns busy and the Cancel button is locked.</p>
</div>
</template>
<template #buttons>
<VtsOverlayCancelButton @click="emit('cancel')" />
<VtsOverlayConfirmButton>Rename</VtsOverlayConfirmButton>
</template>
</UiModal>
</template>
<script lang="ts" setup>
import VtsOverlayCancelButton from '@core/components/overlay/VtsOverlayCancelButton.vue'
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
import UiModal from '@core/components/ui/modal/UiModal.vue'
defineProps<{
currentName: string
newName: string
}>()
const emit = defineEmits<{
confirm: []
cancel: []
}>()
</script>
<style lang="postcss" scoped>
.demo-confirm-modal-content {
display: flex;
flex-direction: column;
gap: 1.6rem;
}
</style>

View File

@@ -1,96 +0,0 @@
<template>
<UiModal accent="info" @confirm="handleSubmit()" @dismiss="emit('cancel')">
<template #title>Rename VM</template>
<template #content>
<div class="demo-rename-modal-content">
<VtsInputWrapper label="New name">
<UiInput v-model.trim="newName" accent="brand" required />
</VtsInputWrapper>
<p>
Tip: name the VM "fail" to make the fake API call fail: the confirmation modal closes and this modal stays
open to pick another name.
</p>
<p v-if="hasRenameFailed" class="error">The rename failed. Try another name.</p>
</div>
</template>
<template #buttons>
<VtsOverlayCancelButton @click="emit('cancel')" />
<VtsOverlayConfirmButton>Save</VtsOverlayConfirmButton>
</template>
</UiModal>
</template>
<script lang="ts" setup>
import VtsInputWrapper from '@core/components/input-wrapper/VtsInputWrapper.vue'
import VtsOverlayCancelButton from '@core/components/overlay/VtsOverlayCancelButton.vue'
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
import UiInput from '@core/components/ui/input/UiInput.vue'
import UiModal from '@core/components/ui/modal/UiModal.vue'
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { ref } from 'vue'
const { name } = defineProps<{
name: string
}>()
const emit = defineEmits<{
submit: [newName: string]
cancel: []
}>()
const newName = ref(name)
const hasRenameFailed = ref(false)
const { open: openConfirmModal } = useOverlay({
component: () => import('@/stories/overlay/DemoConfirmModal.vue'),
events: {
onConfirm: true,
onCancel: true,
},
})
// Simulated API call, to showcase the busy state. Renaming to "fail" fails.
async function renameVm(value: string) {
await new Promise(resolve => setTimeout(resolve, 1500))
return value.toLowerCase() !== 'fail'
}
async function handleSubmit() {
hasRenameFailed.value = false
const response = await openConfirmModal({
props: { currentName: name, newName: newName.value },
events: {
// Per-call async handler: the confirmation modal is busy while it runs,
// then closes, and the API result becomes the response payload
onConfirm: () => renameVm(newName.value),
},
})
if (response.event !== 'onConfirm') {
return
}
if (!response.payload) {
hasRenameFailed.value = true
return
}
emit('submit', newName.value)
}
</script>
<style lang="postcss" scoped>
.demo-rename-modal-content {
display: flex;
flex-direction: column;
gap: 1.6rem;
.error {
color: var(--color-danger-txt-base);
}
}
</style>

View File

@@ -1,57 +0,0 @@
<template>
<UiDrawer title="VM settings" @confirm="handleRename()" @dismiss="emit('dismiss')">
<template #content>
<div class="demo-vm-drawer-content">
<p>
Current name: <strong>{{ name }}</strong>
</p>
<p>"Rename…" opens a modal on top of this drawer, which itself opens a confirmation modal.</p>
</div>
</template>
<template #buttons>
<VtsOverlayCancelButton @click="emit('dismiss')">Close</VtsOverlayCancelButton>
<VtsOverlayConfirmButton>Rename…</VtsOverlayConfirmButton>
</template>
</UiDrawer>
</template>
<script lang="ts" setup>
import VtsOverlayCancelButton from '@core/components/overlay/VtsOverlayCancelButton.vue'
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
import UiDrawer from '@core/components/ui/drawer/UiDrawer.vue'
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
const { name } = defineProps<{
name: string
}>()
const emit = defineEmits<{
rename: [newName: string]
dismiss: []
}>()
const { open: openRenameModal } = useOverlay({
component: () => import('@/stories/overlay/DemoRenameModal.vue'),
events: {
// Transforms the raw emitted value: the trimmed name becomes the payload
onSubmit: (newName: string) => newName.trim(),
onCancel: true,
},
})
async function handleRename() {
const response = await openRenameModal({ props: { name } })
if (response.event === 'onSubmit') {
emit('rename', response.payload)
}
}
</script>
<style lang="postcss" scoped>
.demo-vm-drawer-content {
display: flex;
flex-direction: column;
gap: 1.6rem;
}
</style>

View File

@@ -0,0 +1,121 @@
<template>
<ComponentStory
v-slot="{ properties, settings }"
:params="[
prop('isOpen').bool().help('Controls drawer visibility'),
prop('dismissible').bool().widget().help('Allow closing via backdrop click, dismiss button and Escape key'),
prop('current').bool().widget().help('Only the current (topmost) drawer reacts to Escape'),
event('dismiss').help('Emitted when the drawer is dismissed'),
slot('title').help('Drawer title'),
slot('content').help('Main content area'),
slot('buttons').help('Footer buttons area (wrapped in VtsButtonGroup)'),
setting('showForm').widget(boolean()).preset(false).help('Show a form example inside content'),
]"
:presets="{
Default: {
props: { isOpen: true, dismissible: true, current: true },
},
'With form': {
props: { isOpen: true, dismissible: true, current: true },
settings: { showForm: true },
},
}"
>
<UiButton variant="primary" accent="brand" size="medium" @click="isOpen = true">Open Drawer</UiButton>
<VtsDrawer
:is-open="isOpen"
:dismissible="properties.dismissible"
:current="properties.current"
@dismiss="handleDismiss()"
>
<template #title>Drawer Title</template>
<template #content>
<form v-if="settings.showForm" class="form" @submit.prevent="handleSubmit()">
<VtsInputWrapper
label="Name"
:message="isNameEmpty ? { content: 'Name is required', accent: 'danger' } : undefined"
>
<UiInput v-model.trim="form.name" required :accent="isNameEmpty ? 'danger' : 'brand'" />
</VtsInputWrapper>
<VtsInputWrapper label="Description">
<UiInput v-model="form.description" accent="brand" />
</VtsInputWrapper>
<div class="actions">
<UiButton variant="secondary" accent="brand" size="medium" @click="handleDismiss()">Cancel</UiButton>
<UiButton variant="primary" accent="brand" size="medium" type="submit" :disabled="isNameEmpty">
Save
</UiButton>
</div>
</form>
<p v-else>This is the drawer content.</p>
</template>
<template v-if="!settings.showForm" #buttons>
<UiButton
v-if="properties.dismissible"
variant="secondary"
accent="brand"
size="medium"
@click="handleDismiss()"
>
Cancel
</UiButton>
<UiButton variant="primary" accent="brand" size="medium" @click="handleDismiss()">Confirm</UiButton>
</template>
</VtsDrawer>
</ComponentStory>
</template>
<script lang="ts" setup>
import ComponentStory from '@/components/component-story/ComponentStory.vue'
import { event, prop, setting, slot } from '@/libs/story/story-param'
import { boolean } from '@/libs/story/story-widget'
import VtsDrawer from '@core/components/drawer/VtsDrawer.vue'
import VtsInputWrapper from '@core/components/input-wrapper/VtsInputWrapper.vue'
import UiButton from '@core/components/ui/button/UiButton.vue'
import UiInput from '@core/components/ui/input/UiInput.vue'
import { computed, reactive, ref } from 'vue'
const isOpen = ref(false)
const form = reactive({
name: '',
description: '',
})
const isNameEmpty = computed(() => form.name.trim() === '')
function handleSubmit() {
if (isNameEmpty.value) {
return
}
resetForm()
}
function handleDismiss() {
resetForm()
}
function resetForm() {
isOpen.value = false
form.name = ''
form.description = ''
}
</script>
<style lang="postcss" scoped>
.form {
display: flex;
flex-direction: column;
gap: 1.6rem;
width: 100%;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.8rem;
margin-top: 1.6rem;
}
</style>

View File

@@ -0,0 +1,52 @@
<template>
<ComponentStory
v-slot="{ properties, settings }"
:params="[
prop('accent').required().enum('info', 'warning', 'danger').preset('info').widget(),
iconProp(),
prop('dismissible').bool().widget(),
prop('onDismiss').type('function').widget(),
prop('onConfirm').type('function').widget(),
prop('current').bool().widget(),
event('confirm'),
event('dismiss'),
slot('title'),
slot('content'),
slot('buttons'),
setting('titleSlotContent')
.preset('Example content for title slot')
.widget(text())
.help('Content for title slot'),
setting('contentSlotContent')
.preset('Example content for content slot')
.widget(text())
.help('Content for content slot'),
setting('showDemoButtons').widget(boolean()),
]"
>
<VtsModal class="story" v-bind="properties">
<template #title>{{ settings.titleSlotContent }}</template>
<template #content>{{ settings.contentSlotContent }}</template>
<template v-if="settings.showDemoButtons" #buttons>
<VtsModalCancelButton>Cancel</VtsModalCancelButton>
<VtsModalConfirmButton> Confirm </VtsModalConfirmButton>
</template>
</VtsModal>
</ComponentStory>
</template>
<script lang="ts" setup>
import ComponentStory from '@/components/component-story/ComponentStory.vue'
import { iconProp, prop, event, slot, setting } from '@/libs/story/story-param'
import { text, boolean } from '@/libs/story/story-widget'
import VtsModal from '@core/components/modal/VtsModal.vue'
import VtsModalCancelButton from '@core/components/modal/VtsModalCancelButton.vue'
import VtsModalConfirmButton from '@core/components/modal/VtsModalConfirmButton.vue'
</script>
<style lang="postcss" scoped>
.story {
position: relative;
height: 100%;
}
</style>

View File

@@ -1,19 +0,0 @@
<template>
<ComponentStory
v-slot="{ properties, settings }"
:params="[
prop('variant').required().enum('primary', 'secondary', 'tertiary').preset('primary').widget(),
slot().help('Button label'),
setting('label').preset('Click me').widget(text()),
]"
>
<VtsOverlayButton v-bind="properties">{{ settings.label }}</VtsOverlayButton>
</ComponentStory>
</template>
<script lang="ts" setup>
import ComponentStory from '@/components/component-story/ComponentStory.vue'
import { prop, setting, slot } from '@/libs/story/story-param'
import { text } from '@/libs/story/story-widget'
import VtsOverlayButton from '@core/components/overlay/VtsOverlayButton.vue'
</script>

View File

@@ -1,20 +0,0 @@
<template>
<ComponentStory
v-slot="{ settings }"
:params="[
slot().help('Button label. Defaults to the translated \'Cancel\' label'),
setting('label').preset('').widget(text()).help('Leave empty to use the default label'),
]"
>
<VtsOverlayCancelButton>
<template v-if="settings.label" #default>{{ settings.label }}</template>
</VtsOverlayCancelButton>
</ComponentStory>
</template>
<script lang="ts" setup>
import ComponentStory from '@/components/component-story/ComponentStory.vue'
import { setting, slot } from '@/libs/story/story-param'
import { text } from '@/libs/story/story-widget'
import VtsOverlayCancelButton from '@core/components/overlay/VtsOverlayCancelButton.vue'
</script>

View File

@@ -1,21 +0,0 @@
<template>
<ComponentStory
v-slot="{ properties, settings }"
:params="[
event('click').help(
'When a listener is present, the button is of type \'button\' and emits this event. Otherwise it is of type \'submit\' and submits the surrounding overlay form'
),
slot().help('Button label'),
setting('label').preset('Confirm').widget(text()),
]"
>
<VtsOverlayConfirmButton v-bind="properties">{{ settings.label }}</VtsOverlayConfirmButton>
</ComponentStory>
</template>
<script lang="ts" setup>
import ComponentStory from '@/components/component-story/ComponentStory.vue'
import { event, setting, slot } from '@/libs/story/story-param'
import { text } from '@/libs/story/story-widget'
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
</script>

View File

@@ -3,22 +3,44 @@
v-slot="{ properties, settings }"
:params="[
prop('title').str().widget().preset('Drawer Title'),
event('confirm').help('Emitted when the form is submitted (e.g. a submit button is clicked or Enter is pressed)'),
event('dismiss').help('Emitted when the dismiss button or backdrop is clicked, or Escape is pressed'),
slot('title').help('Overrides the title prop'),
prop('isOpen').bool().widget(),
prop('isDismissible').bool().help('Show dismiss button and enable backdrop click'),
event('dismiss').help('Emitted when dismiss button or backdrop is clicked'),
slot('content').help('Main content area'),
slot('buttons').help('Footer buttons area'),
setting('contentSlotContent')
.preset('This is the drawer content.')
.widget(text())
.help('Content for content slot'),
setting('dismissible').widget(boolean()).preset(true).help('Toggle dismiss behavior in this story'),
setting('showButtons').widget(boolean()).preset(true),
]"
:presets="{
Dismissible: {
props: { isOpen: true },
settings: { dismissible: true },
},
Persistent: {
props: { isOpen: true },
settings: { dismissible: false },
},
'No title': {
props: { isOpen: true, title: '' },
settings: { dismissible: true },
},
'No buttons': {
props: { isOpen: true },
settings: { dismissible: true, showButtons: false },
},
}"
>
<UiDrawer class="story" v-bind="properties">
<template #content>{{ settings.contentSlotContent }}</template>
<UiButton variant="primary" accent="brand" size="medium" @click="isOpen = true">Open Drawer</UiButton>
<UiDrawer :is-open :title="properties.title" :is-dismissible="settings.dismissible" @dismiss="isOpen = false">
<template #content>
<p>This is the drawer content.</p>
</template>
<template v-if="settings.showButtons" #buttons>
<VtsOverlayConfirmButton>Confirm</VtsOverlayConfirmButton>
<UiButton v-if="settings.dismissible" variant="secondary" accent="brand" size="medium" @click="isOpen = false">
Cancel
</UiButton>
<UiButton variant="primary" accent="brand" size="medium" @click="isOpen = false">Confirm</UiButton>
</template>
</UiDrawer>
</ComponentStory>
@@ -27,18 +49,10 @@
<script lang="ts" setup>
import ComponentStory from '@/components/component-story/ComponentStory.vue'
import { event, prop, setting, slot } from '@/libs/story/story-param'
import { boolean, text } from '@/libs/story/story-widget'
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
import { boolean } from '@/libs/story/story-widget'
import UiButton from '@core/components/ui/button/UiButton.vue'
import UiDrawer from '@core/components/ui/drawer/UiDrawer.vue'
import { ref } from 'vue'
const isOpen = ref(false)
</script>
<style lang="postcss" scoped>
.story {
position: relative;
height: 100%;
:deep(.drawer) {
min-height: 100%;
}
}
</style>

View File

@@ -4,10 +4,9 @@
:params="[
prop('accent').required().enum('info', 'warning', 'danger').preset('info').widget(),
iconProp(),
event('confirm').help('Emitted when the form is submitted (e.g. a submit button is clicked or Enter is pressed)'),
event('dismiss').help(
'When a listener is present, the dismiss button is displayed. Also emitted on Escape or backdrop click'
),
prop('dismissible').bool().widget(),
prop('onDismiss').type('function').widget(),
event('dismiss'),
slot('title'),
slot('content'),
slot('buttons'),
@@ -26,7 +25,9 @@
<template #title>{{ settings.titleSlotContent }}</template>
<template #content>{{ settings.contentSlotContent }}</template>
<template v-if="settings.showDemoButton" #buttons>
<VtsOverlayConfirmButton>Confirm</VtsOverlayConfirmButton>
<UiButton :accent="properties.accent === 'info' ? 'brand' : properties.accent" variant="primary" size="medium">
Confirm
</UiButton>
</template>
</UiModal>
</ComponentStory>
@@ -36,7 +37,7 @@
import ComponentStory from '@/components/component-story/ComponentStory.vue'
import { iconProp, prop, event, slot, setting } from '@/libs/story/story-param'
import { text, boolean } from '@/libs/story/story-widget'
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
import UiButton from '@core/components/ui/button/UiButton.vue'
import UiModal from '@core/components/ui/modal/UiModal.vue'
</script>

View File

@@ -61,8 +61,8 @@ export function createExternalRouter(swaggerOpenApiSpec: OpenAPIV3.Document): {
if (route.security === undefined) route.security = '*'
await expressAuthentication(req as AuthenticatedRequest, route.security, route.scope === 'acl' ? ['acl'] : [])
// Coerces query strings to the types declared by the route
coerceQueryParams(req.query, route.query)
// Coerces query boolean from string to boolean
coerceBooleanQueryParams(req.query, route.query)
// Validate inputs, throws if invalid
paramsSchema?.parse(req.params)
@@ -337,23 +337,11 @@ function extractParametersFromZod(schema: z.ZodType, location: 'path' | 'query')
return []
}
// Express query params are always strings, coerce them to the types declared by the route
// Exported for testing
export function coerceQueryParams(query: Record<string, unknown>, def: RouteDefinition['query']): void {
function coerceBooleanQueryParams(query: Record<string, unknown>, def: RouteDefinition['query']): void {
if (!def) return
for (const [key, field] of Object.entries(def)) {
const value = query[key]
if (typeof value !== 'string') continue
if (field.type === 'boolean') {
if (value.toLowerCase() === 'true') {
query[key] = true
} else if (value.toLowerCase() === 'false') {
query[key] = false
}
} else if (field.type === 'number') {
// a non-numeric value becomes NaN, which is rejected by the zod validation
query[key] = value.trim() === '' ? NaN : Number(value)
if (field.type === 'boolean' && typeof query[key] === 'string') {
query[key] = query[key] === 'true'
}
}
}

View File

@@ -1,49 +0,0 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
// this module depends on the IoC container, which cannot be initialized on its own
// because of circular imports between the services: load the generated routes first,
// like `index.mts` does
import '../open-api/routes/routes.js'
import { coerceQueryParams } from './external-router.mjs'
import type { RouteDefinition } from './types.mjs'
const def: RouteDefinition['query'] = {
bool: { type: 'boolean', optional: true },
num: { type: 'number', optional: true },
str: { type: 'string', optional: true },
}
const coerce = (query: Record<string, unknown>) => {
coerceQueryParams(query, def)
return query
}
describe('coerceQueryParams', () => {
it('coerces booleans', () => {
assert.deepEqual(coerce({ bool: 'true' }), { bool: true })
assert.deepEqual(coerce({ bool: 'false' }), { bool: false })
assert.deepEqual(coerce({ bool: 'TRUE' }), { bool: true })
assert.deepEqual(coerce({ bool: 'FALSE' }), { bool: false })
})
it('keeps non-boolean values as strings', () => {
assert.deepEqual(coerce({ bool: 'abc' }), { bool: 'abc' })
})
it('coerces numbers', () => {
assert.deepEqual(coerce({ num: '42' }), { num: 42 })
assert.deepEqual(coerce({ num: '-4.2' }), { num: -4.2 })
})
it('coerces a non-numeric value to NaN, which fails the validation', () => {
assert.ok(Number.isNaN(coerce({ num: 'abc' }).num))
})
it('coerces empty strings to NaN, which fails the validation', () => {
assert.ok(Number.isNaN(coerce({ num: '' }).num))
})
it('leaves strings and undeclared params untouched', () => {
assert.deepEqual(coerce({ str: '42', other: 'true' }), { str: '42', other: 'true' })
})
})

View File

@@ -6,7 +6,7 @@
- [Icons](./icons.md)
- [Stores](./stores.md)
- [Subscribable stores](./subscribable-stores.md)
- [Overlays (modals & drawers)](./overlays.md)
- [Modals](./modals.md)
# Composables
@@ -28,4 +28,4 @@
- [Job System](../lib/packages/job/README.md)
- [Menu System](../lib/packages/menu/README.md)
- [Overlay System](../lib/packages/overlay/README.md)
- [Modal System](../lib/packages/modal/README.md)

View File

@@ -0,0 +1,115 @@
# Modals
## `useModal` hook
You can read the [useModal](../lib/packages/modal/README.md#opening-a-modal-usemodal-composable) documentation to learn more about how to open a modal.
A modal component should not contain any logic. It should only display the content of the modal and emit a `confirm` or `cancel` event.
## Create a modal with `VtsModal` component
### Basic dismissible modal
A dismissible modal can be closed by clicking on the close button or by clicking outside the modal.
```html
<template>
<VtsModal accent="info" dismissible>
<template #content> Hello World </template>
</VtsModal>
</template>
```
#### Custom dismiss event
By default, a `dismissible` modal will call the `onCancel` handler of `useModal`, if any. Else it will just close the modal.
If you want to use a custom event, you can pass the `dismiss` event to the `VtsModal` component.
```html
<VtsModal accent="info" dismissible @dismiss="handleDismiss()"></VtsModal>
```
### Modal with buttons
To confirm or cancel the modal, you can use `VtsModalConfirmButton` and `VtsModalCancelButton`.
For other actions, you can use `VtsModalButton`.
By default, these buttons will be the same color as the modal's accent color.
They also will be disabled with a spinner when the modal is busy.
```html
<template>
<VtsModal accent="info">
<template #content> You are about to delete this item. </template>
<template #buttons>
<VtsModalCancelButton />
<VtsModalConfirmButton>Delete</VtsModalConfirmButton>
</template>
</VtsModal>
</template>
```
### Custom `confirm` and `cancel` events
If you need to pass arguments to the `confirm` or `cancel` event, you can define and emit them manually.
```html
<template>
<VtsModal accent="info" @confirm="emit('confirm', name)">
<template #content>
What's your name?
<input v-model="name" />
</template>
<template #buttons>
<VtsModalCancelButton @click="emit('cancel', false)">Cancel</VtsModalCancelButton>
<VtsModalCancelButton @click="emit('cancel', true)">CANCEL!!!</VtsModalCancelButton>
<VtsModalConfirmButton>Submit</VtsModalConfirmButton>
</template>
</VtsModal>
</template>
<script lang="ts" setup>
const emit = defineEmits<{
confirm: [name: string]
cancel: [angry: boolean]
}>()
const name = ref('')
</script>
```
### Multiple confirm buttons
`VtsModal` is a form, and, by default, `VtsModalConfirmButton` will be a "submit" button.
When the form is submitted, it will emit a `confirm` event, if any. Otherwise, it will close the modal.
If you want to have multiple confirm buttons, to pass different payloads to the `confirm` event, you can pass a `@click` event.
In this case, the button will no longer be a "submit" button.
```html
<template>
<VtsModal accent="info">
<template #content> How many do you want? </template>
<template #buttons>
<VtsModalCancelButton>None</VtsModalCancelButton>
<VtsModalConfirmButton @click="emit('confirm', 1)">1</VtsModalConfirmButton>
<VtsModalConfirmButton @click="emit('confirm', 10)">10</VtsModalConfirmButton>
</template>
</VtsModal>
</template>
<script lang="ts" setup>
const emit = defineEmits<{
confirm: [count: number]
}>()
</script>
```

View File

@@ -1,144 +0,0 @@
# Overlays (modals & drawers)
## Opening an overlay
Overlays are opened with the `useOverlay` composable. Read the [Overlay System](../lib/packages/overlay/README.md) documentation to learn how to define an overlay, open it, and await the user's decision.
An overlay component should not contain any business logic. It should only display the content of the overlay and emit events (typically `confirm` and `cancel`); the logic lives in the composables that open it.
## Create a modal with the `UiModal` component
### Basic dismissible modal
`UiModal` emits a `dismiss` event when the user clicks outside the modal or presses Escape. When a `dismiss` listener is attached, a close button is also displayed.
```html
<template>
<UiModal accent="info" @dismiss="emit('cancel')">
<template #content> Hello World </template>
</UiModal>
</template>
<script lang="ts" setup>
const emit = defineEmits<{
cancel: []
}>()
</script>
```
A modal without a `dismiss` listener is not dismissible: the close button is hidden, and clicking outside or pressing Escape does nothing.
### Modal with buttons
To confirm or cancel the modal, use `VtsOverlayConfirmButton` and `VtsOverlayCancelButton` in the `buttons` slot.
For other actions, use `VtsOverlayButton`.
These buttons take the modal's accent color, and are automatically disabled while the overlay is handling an event — with a spinner on the button that triggered it.
`UiModal` is a form: `VtsOverlayConfirmButton` is a submit button by default, and submitting the form emits the `confirm` event.
```html
<template>
<UiModal accent="danger" @confirm="emit('confirm')" @dismiss="emit('cancel')">
<template #content> You are about to delete this item. </template>
<template #buttons>
<VtsOverlayCancelButton @click="emit('cancel')" />
<VtsOverlayConfirmButton>Delete</VtsOverlayConfirmButton>
</template>
</UiModal>
</template>
<script lang="ts" setup>
const emit = defineEmits<{
confirm: []
cancel: []
}>()
</script>
```
### Event payloads
If you need to pass a payload to the `confirm` or `cancel` events, emit it from the corresponding handler:
```html
<template>
<UiModal accent="info" @confirm="emit('confirm', name)" @dismiss="emit('cancel')">
<template #content>
What's your name?
<input v-model="name" />
</template>
<template #buttons>
<VtsOverlayCancelButton @click="emit('cancel')" />
<VtsOverlayConfirmButton>Submit</VtsOverlayConfirmButton>
</template>
</UiModal>
</template>
<script lang="ts" setup>
const emit = defineEmits<{
confirm: [name: string]
cancel: []
}>()
const name = ref('')
</script>
```
### Multiple confirm buttons
If you want multiple confirm buttons passing different payloads to the `confirm` event, give each one a `@click` handler.
A `VtsOverlayConfirmButton` with a `@click` handler is no longer a submit button.
```html
<template>
<UiModal accent="info">
<template #content> How many do you want? </template>
<template #buttons>
<VtsOverlayCancelButton @click="emit('cancel')">None</VtsOverlayCancelButton>
<VtsOverlayConfirmButton @click="emit('confirm', 1)">1</VtsOverlayConfirmButton>
<VtsOverlayConfirmButton @click="emit('confirm', 10)">10</VtsOverlayConfirmButton>
</template>
</UiModal>
</template>
<script lang="ts" setup>
const emit = defineEmits<{
confirm: [count: number]
cancel: []
}>()
</script>
```
## Create a drawer with the `UiDrawer` component
`UiDrawer` works the same way: it is a form emitting `confirm` on submit and `dismiss` on outside click or Escape, with `title`, `content`, and `buttons` slots.
```html
<template>
<UiDrawer :title="t('settings')" @confirm="emit('confirm')" @dismiss="emit('cancel')">
<template #content>
<!-- Drawer content -->
</template>
<template #buttons>
<VtsOverlayCancelButton @click="emit('cancel')" />
<VtsOverlayConfirmButton>Save</VtsOverlayConfirmButton>
</template>
</UiDrawer>
</template>
<script lang="ts" setup>
const emit = defineEmits<{
confirm: []
cancel: []
}>()
const { t } = useI18n()
</script>
```

View File

@@ -0,0 +1,89 @@
<template>
<UiDrawer
:is-open="effectiveIsOpen"
:is-dismissible="dismissible"
v-on="{ dismiss: handleDismiss, afterLeave: onAfterLeave }"
>
<template v-if="slots.title" #title>
<slot name="title" />
</template>
<template #content>
<slot name="content" />
</template>
<template v-if="slots.buttons" #buttons>
<slot name="buttons" />
</template>
</UiDrawer>
</template>
<script lang="ts" setup>
import UiDrawer from '@core/components/ui/drawer/UiDrawer.vue'
import { IK_DRAWER } from '@core/packages/drawer/types.ts'
import { useMagicKeys, whenever } from '@vueuse/core'
import { computed, inject, onMounted, ref } from 'vue'
const {
dismissible,
isOpen = undefined,
current,
} = defineProps<{
dismissible?: boolean
isOpen?: boolean
current?: boolean
}>()
const emit = defineEmits<{
dismiss: []
}>()
const slots = defineSlots<{
content(): any
title?(): any
buttons?(): any
}>()
const drawer = inject(IK_DRAWER, undefined)
const internalVisible = ref(false)
onMounted(() => {
if (isOpen === undefined) {
internalVisible.value = true
}
})
const effectiveIsOpen = computed(() => isOpen ?? internalVisible.value)
let afterLeaveCallback: (() => void) | undefined
function onAfterLeave() {
afterLeaveCallback?.()
afterLeaveCallback = undefined
}
const handleDismiss = computed(() => {
if (!dismissible) {
return undefined
}
return () => {
if (isOpen !== undefined) {
emit('dismiss')
return
}
afterLeaveCallback = () => drawer?.value.onCancel()
internalVisible.value = false
}
})
const { escape } = useMagicKeys()
whenever(escape, () => {
if (dismissible && current) {
handleDismiss.value?.()
}
})
</script>

View File

@@ -0,0 +1,22 @@
<template>
<UiButton :accent :busy="drawer?.isBusy.value" :variant size="medium">
<slot />
</UiButton>
</template>
<script lang="ts" setup>
import UiButton, { type ButtonAccent, type ButtonVariant } from '@core/components/ui/button/UiButton.vue'
import { IK_DRAWER } from '@core/packages/drawer/types.ts'
import { inject } from 'vue'
const { accent = 'brand' } = defineProps<{
variant: ButtonVariant
accent?: ButtonAccent
}>()
defineSlots<{
default(): any
}>()
const drawer = inject(IK_DRAWER)
</script>

View File

@@ -0,0 +1,33 @@
<template>
<VtsDrawerButton variant="secondary" @click="handleClick()">
<slot>{{ t('cancel') }}</slot>
</VtsDrawerButton>
</template>
<script lang="ts" setup>
import VtsDrawerButton from '@core/components/drawer/VtsDrawerButton.vue'
import { IK_DRAWER } from '@core/packages/drawer/types.ts'
import { inject } from 'vue'
import { useI18n } from 'vue-i18n'
const { onClick } = defineProps<{
onClick?: () => void
}>()
defineSlots<{
default?(): any
}>()
const { t } = useI18n()
const drawer = inject(IK_DRAWER)
function handleClick() {
if (onClick) {
onClick()
return
}
drawer?.value.onCancel()
}
</script>

View File

@@ -0,0 +1,32 @@
<template>
<VtsDrawerButton :accent variant="primary" @click="handleClick()">
<slot />
</VtsDrawerButton>
</template>
<script lang="ts" setup>
import VtsDrawerButton from '@core/components/drawer/VtsDrawerButton.vue'
import type { ButtonAccent } from '@core/components/ui/button/UiButton.vue'
import { IK_DRAWER } from '@core/packages/drawer/types.ts'
import { inject } from 'vue'
const { onClick, accent = 'brand' } = defineProps<{
onClick?: () => void
accent?: ButtonAccent
}>()
defineSlots<{
default(): any
}>()
const drawer = inject(IK_DRAWER)
function handleClick() {
if (onClick) {
onClick()
return
}
drawer?.value.onConfirm()
}
</script>

View File

@@ -0,0 +1,34 @@
<template>
<div v-if="drawerStore.drawers.length > 0" class="vts-drawer-list">
<DrawerProvider v-for="(drawer, index) of drawerStore.drawers" :key="drawer.id" :drawer>
<component
:is="drawer.component"
class="drawer-component"
v-bind="drawer.props"
:current="index === drawerStore.drawers.length - 1"
@confirm="drawer.onConfirm"
@cancel="drawer.onCancel"
/>
</DrawerProvider>
</div>
</template>
<script lang="ts" setup>
import { useDrawerStore } from '@core/packages/drawer/drawer.store.ts'
import DrawerProvider from '@core/packages/drawer/DrawerProvider.vue'
const drawerStore = useDrawerStore()
</script>
<style lang="postcss" scoped>
.vts-drawer-list {
position: fixed;
inset: 0;
background-color: var(--color-opacity-primary);
z-index: 1020;
.drawer-component:not(:last-child) {
filter: brightness(0.8);
}
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<UiModal :accent :icon @confirm="emit('confirm')" @dismiss="emit('cancel')">
<VtsModal :accent :icon dismissible>
<template #title>
<span>{{ modalTexts.title }}</span>
</template>
@@ -7,19 +7,19 @@
<span v-if="modalTexts.message">{{ modalTexts.message }}</span>
</template>
<template #buttons>
<VtsOverlayCancelButton @click="emit('cancel')">{{ t('action:go-back') }}</VtsOverlayCancelButton>
<VtsOverlayConfirmButton>
<VtsModalCancelButton>{{ t('action:go-back') }}</VtsModalCancelButton>
<VtsModalConfirmButton>
{{ modalTexts.action }}
</VtsOverlayConfirmButton>
</VtsModalConfirmButton>
</template>
</UiModal>
</VtsModal>
</template>
<script lang="ts" setup>
import VtsOverlayCancelButton from '@core/components/overlay/VtsOverlayCancelButton.vue'
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
import VtsModal from '@core/components/modal/VtsModal.vue'
import VtsModalCancelButton from '@core/components/modal/VtsModalCancelButton.vue'
import VtsModalConfirmButton from '@core/components/modal/VtsModalConfirmButton.vue'
import type { ModalAccent } from '@core/components/ui/modal/UiModal.vue'
import UiModal from '@core/components/ui/modal/UiModal.vue'
import type { IconName } from '@core/icons'
import { useMapper } from '@core/packages/mapper/use-mapper.ts'
import type { ActionsByObject, HostActions, ObjectType, VmActions } from '@core/types/object.type.ts'
@@ -54,11 +54,6 @@ const { action, object, hostName } = defineProps<
}
>()
const emit = defineEmits<{
confirm: []
cancel: []
}>()
const defaultActionByObject: ActionsByObject = {
vm: 'shutdown',
host: 'disable',

View File

@@ -1,5 +1,5 @@
<template>
<UiModal accent="danger" icon="status:danger-picto" @dismiss="emit('close')">
<VtsModal accent="danger" icon="status:danger-picto" dismissible>
<template #title>
<span>{{ modalTexts.title }}</span>
</template>
@@ -12,18 +12,18 @@
</template>
<template #buttons>
<VtsOverlayCancelButton @click="emit('close')">{{ t('action:go-back') }}</VtsOverlayCancelButton>
<UiLink :href icon="action:edit" size="medium" @click="emit('close')">
<VtsModalCancelButton>{{ t('action:go-back') }}</VtsModalCancelButton>
<UiLink :href icon="action:edit" size="medium" @click="emit('confirm')">
{{ t('action:edit-config') }}
</UiLink>
</template>
</UiModal>
</VtsModal>
</template>
<script lang="ts" setup>
import VtsOverlayCancelButton from '@core/components/overlay/VtsOverlayCancelButton.vue'
import VtsModal from '@core/components/modal/VtsModal.vue'
import VtsModalCancelButton from '@core/components/modal/VtsModalCancelButton.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import UiModal from '@core/components/ui/modal/UiModal.vue'
import { useMapper } from '@core/packages/mapper/use-mapper.ts'
import type { VmBlockedOperations } from '@core/types/object.type.ts'
import { useI18n } from 'vue-i18n'
@@ -40,7 +40,7 @@ const { blockedOperation } = defineProps<{
}>()
const emit = defineEmits<{
close: []
confirm: []
}>()
const { t } = useI18n()

View File

@@ -1,41 +1,36 @@
<template>
<UiModal accent="warning" icon="status:warning-picto" @confirm="emit('confirm')" @dismiss="emit('cancel')">
<VtsModal accent="warning" icon="status:warning-picto" dismissible>
<template #title>
<I18nT keypath="confirm-delete" scope="global" tag="div">
<span class="n-delete">
{{ subject }}
<slot name="title" />
</span>
</I18nT>
</template>
<template #content>
{{ description ?? t('please-confirm-to-continue') }}
<slot name="content">{{ t('please-confirm-to-continue') }}</slot>
</template>
<template #buttons>
<VtsOverlayCancelButton @click="emit('cancel')">{{ t('action:go-back') }}</VtsOverlayCancelButton>
<VtsOverlayConfirmButton>
{{ confirmLabel }}
</VtsOverlayConfirmButton>
<VtsModalCancelButton>{{ t('action:go-back') }}</VtsModalCancelButton>
<VtsModalConfirmButton>
<slot name="confirm" />
</VtsModalConfirmButton>
</template>
</UiModal>
</VtsModal>
</template>
<script lang="ts" setup>
import VtsOverlayCancelButton from '@core/components/overlay/VtsOverlayCancelButton.vue'
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
import UiModal from '@core/components/ui/modal/UiModal.vue'
import VtsModal from '@core/components/modal/VtsModal.vue'
import VtsModalCancelButton from '@core/components/modal/VtsModalCancelButton.vue'
import VtsModalConfirmButton from '@core/components/modal/VtsModalConfirmButton.vue'
import { useI18n } from 'vue-i18n'
const { subject, description, confirmLabel } = defineProps<{
subject: string
confirmLabel: string
description?: string
}>()
const emit = defineEmits<{
confirm: []
cancel: []
defineSlots<{
title(): any
content?(): any
confirm(): any
}>()
const { t } = useI18n()

View File

@@ -1,5 +1,5 @@
<template>
<UiModal accent="danger" icon="status:danger-picto" @dismiss="emit('close')" @confirm="emit('close')">
<VtsModal accent="danger" icon="status:danger-picto" dismissible>
<template #title>
{{ title }}
</template>
@@ -11,16 +11,16 @@
</template>
<template #buttons>
<VtsOverlayConfirmButton>
<VtsModalConfirmButton>
{{ t('action:close') }}
</VtsOverlayConfirmButton>
</VtsModalConfirmButton>
</template>
</UiModal>
</VtsModal>
</template>
<script lang="ts" setup>
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
import UiModal from '@core/components/ui/modal/UiModal.vue'
import VtsModal from '@core/components/modal/VtsModal.vue'
import VtsModalConfirmButton from '@core/components/modal/VtsModalConfirmButton.vue'
import { useI18n } from 'vue-i18n'
defineProps<{
@@ -28,10 +28,6 @@ defineProps<{
error?: string
}>()
const emit = defineEmits<{
close: []
}>()
defineSlots<{
content?(): any
}>()

View File

@@ -0,0 +1,82 @@
<template>
<UiModal :icon :accent v-on="{ dismiss: handleDismiss, submit: handleSubmit }">
<template v-if="slots.title" #title>
<slot name="title" />
</template>
<template #content>
<slot name="content" />
</template>
<template v-if="slots.buttons" #buttons>
<slot name="buttons" />
</template>
</UiModal>
</template>
<script lang="ts" setup>
import UiModal, { type ModalAccent } from '@core/components/ui/modal/UiModal.vue'
import type { IconName } from '@core/icons'
import { IK_MODAL } from '@core/packages/modal/types.ts'
import { IK_MODAL_ACCENT } from '@core/utils/injection-keys.util.ts'
import { useMagicKeys, whenever } from '@vueuse/core'
import { computed, inject, provide } from 'vue'
const { accent, dismissible, onConfirm, onDismiss, current } = defineProps<{
accent: ModalAccent
icon?: IconName
dismissible?: boolean
onConfirm?: () => void
onDismiss?: () => void
current?: boolean
}>()
const emit = defineEmits<{
confirm: []
dismiss: []
}>()
const slots = defineSlots<{
content(): any
buttons?(): any
title?(): any
}>()
const modal = inject(IK_MODAL)
provide(
IK_MODAL_ACCENT,
computed(() => accent)
)
const handleDismiss = computed(() => {
if (!dismissible) {
return undefined
}
if (onDismiss) {
return () => emit('dismiss')
}
return () => modal?.value.onCancel()
})
function handleSubmit(event: SubmitEvent) {
event.preventDefault()
if (onConfirm) {
emit('confirm')
return
}
modal?.value.onConfirm()
}
const { escape } = useMagicKeys()
whenever(escape, () => {
if (dismissible && current) {
handleDismiss.value?.()
}
})
</script>

View File

@@ -0,0 +1,35 @@
<template>
<UiButton :accent="buttonAccent" :busy="modal?.isBusy.value" :variant size="medium">
<slot />
</UiButton>
</template>
<script lang="ts" setup>
import UiButton, { type ButtonVariant } from '@core/components/ui/button/UiButton.vue'
import { useMapper } from '@core/packages/mapper'
import { IK_MODAL } from '@core/packages/modal/types.ts'
import { IK_MODAL_ACCENT } from '@core/utils/injection-keys.util.ts'
import { inject } from 'vue'
defineProps<{
variant: ButtonVariant
}>()
defineSlots<{
default(): any
}>()
const modal = inject(IK_MODAL)
const modalAccent = inject(IK_MODAL_ACCENT)
const buttonAccent = useMapper(
() => modalAccent?.value,
{
info: 'brand',
warning: 'warning',
danger: 'danger',
},
'info'
)
</script>

View File

@@ -0,0 +1,37 @@
<template>
<VtsModalButton variant="secondary" @click="handleClick">
<slot>{{ t('cancel') }}</slot>
</VtsModalButton>
</template>
<script lang="ts" setup>
import VtsModalButton from '@core/components/modal/VtsModalButton.vue'
import { IK_MODAL } from '@core/packages/modal/types.ts'
import { inject } from 'vue'
import { useI18n } from 'vue-i18n'
const { onClick } = defineProps<{
onClick?: () => void
}>()
const emit = defineEmits<{
click: []
}>()
defineSlots<{
default?(): any
}>()
const { t } = useI18n()
const modal = inject(IK_MODAL)
function handleClick() {
if (onClick) {
emit('click')
return
}
modal?.value.onCancel()
}
</script>

View File

@@ -0,0 +1,21 @@
<template>
<VtsModalButton variant="primary" :type="onClick ? 'button' : 'submit'" @click="emit('click')">
<slot />
</VtsModalButton>
</template>
<script lang="ts" setup>
import VtsModalButton from '@core/components/modal/VtsModalButton.vue'
const { onClick } = defineProps<{
onClick?: () => void
}>()
const emit = defineEmits<{
click: []
}>()
defineSlots<{
default(): any
}>()
</script>

View File

@@ -0,0 +1,34 @@
<template>
<div v-if="modalStore.modals.length > 0" class="vts-modal-list">
<ModalProvider v-for="(modal, index) of modalStore.modals" :key="modal.id" :modal>
<component
:is="modal.component"
class="modal-component"
v-bind="modal.props"
:current="index === modalStore.modals.length - 1"
@confirm="modal.onConfirm"
@cancel="modal.onCancel"
/>
</ModalProvider>
</div>
</template>
<script lang="ts" setup>
import { useModalStore } from '@core/packages/modal/modal.store.ts'
import ModalProvider from '@core/packages/modal/ModalProvider.vue'
const modalStore = useModalStore()
</script>
<style lang="postcss" scoped>
.vts-modal-list {
position: fixed;
inset: 0;
background-color: var(--color-opacity-primary);
z-index: 1012;
.modal-component:not(:last-child) {
filter: brightness(0.8);
}
}
</style>

View File

@@ -17,8 +17,7 @@
import UiButton from '@core/components/ui/button/UiButton.vue'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import { vTooltip } from '@core/directives/tooltip.directive'
import { KEEP_OVERLAY_OPEN } from '@core/packages/overlay/symbols.ts'
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { useModal } from '@core/packages/modal/use-modal'
import type { QueryBuilderGroup } from '@core/packages/query-builder/types'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -39,20 +38,15 @@ const tooltip = computed(() => (disabled ? 'Current filter is invalid or too com
const { t } = useI18n()
const { open } = useOverlay({
component: () => import('@core/components/query-builder/VtsQueryBuilderModal.vue'),
events: {
'onUpdate:modelValue': newRootGroup => {
const openModal = useModal(() => ({
component: import('@core/components/query-builder/VtsQueryBuilderModal.vue'),
props: {
modelValue: rootGroup,
'onUpdate:modelValue': (newRootGroup: QueryBuilderGroup) => {
rootGroup.value = newRootGroup
return KEEP_OVERLAY_OPEN
},
onConfirm: () => emit('confirm'),
onCancel: () => emit('cancel'),
},
})
function openModal() {
return open({ props: { modelValue: rootGroup.value } })
}
onConfirm: () => emit('confirm'),
onCancel: () => emit('cancel'),
}))
</script>

View File

@@ -1,30 +1,25 @@
<template>
<UiModal class="vts-query-builder-modal" accent="info" @dismiss="emit('cancel')" @confirm="emit('confirm')">
<VtsModal class="vts-query-builder-modal" accent="info" dismissible>
<template v-if="uiStore.isSmall" #title>{{ t('query-builder') }}</template>
<template #content>
<VtsQueryBuilderGroup v-model="rootGroup" root />
</template>
<template #buttons>
<VtsOverlayCancelButton @click="emit('cancel')" />
<VtsOverlayConfirmButton>{{ t('action:save') }}</VtsOverlayConfirmButton>
<VtsModalCancelButton />
<VtsModalConfirmButton>{{ t('action:save') }}</VtsModalConfirmButton>
</template>
</UiModal>
</VtsModal>
</template>
<script setup lang="ts">
import VtsOverlayCancelButton from '@core/components/overlay/VtsOverlayCancelButton.vue'
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
import VtsModal from '@core/components/modal/VtsModal.vue'
import VtsModalCancelButton from '@core/components/modal/VtsModalCancelButton.vue'
import VtsModalConfirmButton from '@core/components/modal/VtsModalConfirmButton.vue'
import VtsQueryBuilderGroup from '@core/components/query-builder/VtsQueryBuilderGroup.vue'
import UiModal from '@core/components/ui/modal/UiModal.vue'
import { type QueryBuilderGroup } from '@core/packages/query-builder/types'
import { useUiStore } from '@core/stores/ui.store.ts'
import { useI18n } from 'vue-i18n'
const emit = defineEmits<{
confirm: []
cancel: []
}>()
const rootGroup = defineModel<QueryBuilderGroup>({ required: true })
const { t } = useI18n()

View File

@@ -1,77 +0,0 @@
<template>
<UiModal accent="info" icon="status:info-picto" @confirm="emit('confirm')" @dismiss="emit('cancel')">
<template #title>
{{ title }}
</template>
<template #content>
{{ info }}
</template>
<template #buttons>
<VtsOverlayCancelButton @click="emit('cancel')">{{ t('action:go-back') }}</VtsOverlayCancelButton>
<VtsOverlayConfirmButton>
{{ confirmLabel }}
</VtsOverlayConfirmButton>
</template>
</UiModal>
</template>
<script lang="ts" setup>
import VtsOverlayCancelButton from '@core/components/overlay/VtsOverlayCancelButton.vue'
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
import UiModal from '@core/components/ui/modal/UiModal.vue'
import { useMapper } from '@core/packages/mapper'
import { CONNECTION_ACTION, type ConnectionAction } from '@core/types/connection.ts'
import type { SrAccessMode, SrScope } from '@core/types/storage-repository.type.ts'
import { getSrModalInfoVariant } from '@core/utils/sr.utils.ts'
import { useI18n } from 'vue-i18n'
const { action, count, scope, accessMode, hostsCount } = defineProps<{
action: ConnectionAction
count: number
scope: SrScope
accessMode: SrAccessMode
hostsCount: number
}>()
const emit = defineEmits<{
confirm: []
cancel: []
}>()
const { t } = useI18n()
const title = useMapper(
() => action,
() => ({
[CONNECTION_ACTION.CONNECT]: t('sr-connect-title', { n: count }),
[CONNECTION_ACTION.DISCONNECT]: t('sr-disconnect-title', { n: count }),
}),
CONNECTION_ACTION.CONNECT
)
const info = useMapper(
() => `${action}-${getSrModalInfoVariant(scope, accessMode)}`,
() => ({
'connect-host': t('sr-connect-info-host', { n: count }),
'connect-pool-local': t('sr-connect-info-pool-local', { n: count }),
'connect-pool-mixed': t('sr-connect-info-pool-mixed'),
'connect-pool-shared': t('sr-connect-info-pool-shared', { n: count, hostsCount }),
'disconnect-host': t('sr-disconnect-info-host', { n: count }),
'disconnect-pool-local': t('sr-disconnect-info-pool-local', { n: count }),
'disconnect-pool-mixed': t('sr-disconnect-info-pool-mixed'),
'disconnect-pool-shared': t('sr-disconnect-info-pool-shared', { n: count, hostsCount }),
}),
'connect-pool-shared'
)
const confirmLabel = useMapper(
() => action,
() => ({
[CONNECTION_ACTION.CONNECT]: t('action:connect-n-srs', { n: count }),
[CONNECTION_ACTION.DISCONNECT]: t('action:disconnect-n-srs', { n: count }),
}),
CONNECTION_ACTION.CONNECT
)
</script>

View File

@@ -1,50 +1,46 @@
<template>
<form class="ui-drawer" @click.self="emit('dismiss')" @submit.prevent="emit('confirm')">
<aside
:aria-labelledby="slots.title || title !== undefined ? titleId : undefined"
aria-modal="true"
class="drawer"
role="dialog"
>
<div class="header">
<div v-if="slots.title || title !== undefined" :id="titleId" class="typo-h4">
<slot name="title">
{{ title }}
</slot>
<Transition name="drawer" @after-leave="emit('afterLeave')">
<div v-if="isOpen" class="ui-drawer" aria-modal="true" role="dialog" @click.self="isDismissible && emit('dismiss')">
<aside class="drawer">
<div class="header">
<div v-if="slots.title || title !== undefined" class="typo-h4">
<slot name="title">
{{ title }}
</slot>
</div>
<UiButtonIcon
v-if="isDismissible"
:target-scale="2"
icon="action:close-cancel-clear"
accent="brand"
size="small"
@click="emit('dismiss')"
/>
</div>
<UiButtonIcon
v-if="onDismiss"
:target-scale="2"
icon="action:close-cancel-clear"
accent="brand"
size="small"
@click="emit('dismiss')"
/>
</div>
<div class="content">
<slot name="content" />
</div>
<VtsButtonGroup v-if="slots.buttons" no-stack class="buttons">
<slot name="buttons" />
</VtsButtonGroup>
</aside>
</form>
<div class="content">
<slot name="content" />
</div>
<VtsButtonGroup v-if="slots.buttons" no-stack class="buttons">
<slot name="buttons" />
</VtsButtonGroup>
</aside>
</div>
</Transition>
</template>
<script lang="ts" setup>
import VtsButtonGroup from '@core/components/button-group/VtsButtonGroup.vue'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import { useOverlayEscape } from '@core/packages/overlay/use-overlay-escape.ts'
import { useId } from 'vue'
defineProps<{
isOpen: boolean
isDismissible?: boolean
title?: string
onDismiss?: () => any
}>()
const emit = defineEmits<{
confirm: []
dismiss: []
afterLeave: []
}>()
const slots = defineSlots<{
@@ -52,13 +48,34 @@ const slots = defineSlots<{
title?(): any
buttons?(): any
}>()
useOverlayEscape(() => emit('dismiss'))
const titleId = useId()
</script>
<style lang="postcss" scoped>
/* Slide-in from right */
.drawer-enter-active.ui-drawer,
.drawer-leave-active.ui-drawer {
transition: opacity 0.3s ease;
}
.drawer-enter-active .drawer,
.drawer-leave-active .drawer {
transition: transform 0.3s ease;
}
.drawer-enter-from.ui-drawer,
.drawer-leave-to.ui-drawer {
opacity: 0;
}
.drawer-enter-from .drawer,
.drawer-leave-to .drawer {
transform: translateX(100%);
&:dir(rtl) {
transform: translateX(-100%);
}
}
.ui-drawer {
position: fixed;
display: flex;

View File

@@ -1,7 +1,7 @@
<!-- v4 -->
<template>
<form :class="className" class="ui-modal" @click.self="emit('dismiss')" @submit.prevent="emit('confirm')">
<div :aria-labelledby="slots.title ? titleId : undefined" aria-modal="true" class="modal" role="dialog">
<form :class="className" class="ui-modal" @click.self="emit('dismiss')">
<div class="modal">
<UiButtonIcon
v-if="onDismiss"
:accent="closeIconAccent"
@@ -13,7 +13,7 @@
/>
<main class="main">
<VtsIcon v-if="icon" :name="icon" class="icon" size="current" />
<div v-if="slots.title" :id="titleId" class="typo-h4">
<div v-if="slots.title" class="typo-h4">
<slot name="title" />
</div>
<div class="content">
@@ -33,21 +33,18 @@ import VtsIcon from '@core/components/icon/VtsIcon.vue'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import type { IconName } from '@core/icons'
import { useMapper } from '@core/packages/mapper'
import { useOverlayEscape } from '@core/packages/overlay/use-overlay-escape.ts'
import { IK_OVERLAY_ACCENT } from '@core/utils/injection-keys.util.ts'
import { toVariants } from '@core/utils/to-variants.util.ts'
import { computed, provide, useId } from 'vue'
import { computed } from 'vue'
export type ModalAccent = 'info' | 'warning' | 'danger'
const { accent } = defineProps<{
accent: ModalAccent
icon?: IconName
onDismiss?: () => any
onDismiss?: () => void
}>()
const emit = defineEmits<{
confirm: []
dismiss: []
}>()
@@ -57,10 +54,6 @@ const slots = defineSlots<{
title?(): any
}>()
useOverlayEscape(() => emit('dismiss'))
const titleId = useId()
const closeIconAccent = useMapper(
() => accent,
{
@@ -72,11 +65,6 @@ const closeIconAccent = useMapper(
)
const className = computed(() => toVariants({ accent }))
provide(
IK_OVERLAY_ACCENT,
computed(() => accent)
)
</script>
<style lang="postcss" scoped>

View File

@@ -1,26 +1,26 @@
<template>
<UiModal accent="info" icon="status:info-picto" @confirm="emit('confirm')" @dismiss="emit('cancel')">
<VtsModal accent="info" icon="status:info-picto" dismissible>
<template #title>
{{ connection.title }}
</template>
<template #content>
<template v-if="action === CONNECTION_ACTION.DISCONNECT">{{ t('vif-disconnect-info') }}</template>
<template v-if="action === CONNECTION_ACTION.DISCONNECT" #content>
{{ t('vif-disconnect-info') }}
</template>
<template #buttons>
<VtsOverlayCancelButton @click="emit('cancel')">{{ t('action:go-back') }}</VtsOverlayCancelButton>
<VtsOverlayConfirmButton>
<VtsModalCancelButton>{{ t('action:go-back') }}</VtsModalCancelButton>
<VtsModalConfirmButton>
{{ connection.action }}
</VtsOverlayConfirmButton>
</VtsModalConfirmButton>
</template>
</UiModal>
</VtsModal>
</template>
<script lang="ts" setup>
import VtsOverlayCancelButton from '@core/components/overlay/VtsOverlayCancelButton.vue'
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
import UiModal from '@core/components/ui/modal/UiModal.vue'
import VtsModal from '@core/components/modal/VtsModal.vue'
import VtsModalCancelButton from '@core/components/modal/VtsModalCancelButton.vue'
import VtsModalConfirmButton from '@core/components/modal/VtsModalConfirmButton.vue'
import { useMapper } from '@core/packages/mapper'
import { CONNECTION_ACTION, type ConnectionAction } from '@core/types/connection.ts'
import { useI18n } from 'vue-i18n'
@@ -30,11 +30,6 @@ const { action, count } = defineProps<{
count: number
}>()
const emit = defineEmits<{
confirm: []
cancel: []
}>()
const { t } = useI18n()
const connection = useMapper(

View File

@@ -1,11 +0,0 @@
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
export function useActionModal() {
return useOverlay({
component: () => import('@core/components/modal/VtsActionModal.vue'),
events: {
onConfirm: true,
onCancel: true,
},
})
}

View File

@@ -1,10 +0,0 @@
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
export function useBlockedModal() {
return useOverlay({
component: () => import('@core/components/modal/VtsBlockedModal.vue'),
events: {
onClose: true,
},
})
}

View File

@@ -1,11 +0,0 @@
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
export function useDeleteModal() {
return useOverlay({
component: () => import('@core/components/modal/VtsDeleteModal.vue'),
events: {
onConfirm: true,
onCancel: true,
},
})
}

View File

@@ -0,0 +1,17 @@
<template>
<slot />
</template>
<script lang="ts" setup>
import { IK_DRAWER, type RegisteredDrawer } from '@core/packages/drawer/types.ts'
import { computed, provide } from 'vue'
const { drawer } = defineProps<{
drawer: RegisteredDrawer
}>()
provide(
IK_DRAWER,
computed(() => drawer)
)
</script>

View File

@@ -0,0 +1,189 @@
# Drawer System
### Drawers list component
Add `VtsDrawerList` once in your app layout. It renders all active drawers from the store.
```vue
<!-- App.vue -->
<template>
...
<VtsDrawerList />
</template>
<script lang="ts" setup>
import VtsDrawerList from '@core/components/drawer/VtsDrawerList.vue'
</script>
```
### Opening a drawer (`useDrawer` composable)
```ts
const openMyDrawer = useDrawer({
component: import('path/to/MyDrawer.vue'),
props: {
foo: 'Foo',
},
onConfirm: () => console.log('Confirmed!'),
onCancel: () => console.log('Cancelled!'),
})
```
```html
<button @click="openMyDrawer()">Open drawer</button>
```
| Property | Type | Required | Default | Description |
| --------------------- | ----------------------------------------------------------------- | :------: | ----------- | ------------------------------------------ |
| component | `Promise<Component>` | ✓ | | `import('path/to/drawer.vue')` |
| props | `Record<string, MaybeRef<any>>` | | `{}` | Props passed to the drawer component |
| onConfirm | `(...args: any[]) => TConfirmPayload \| Promise<TConfirmPayload>` | | `undefined` | Called when the drawer is confirmed |
| onCancel | `(...args: any[]) => TCancelPayload \| Promise<TCancelPayload>` | | `undefined` | Called when the drawer is cancelled |
| keepOpenOnRouteChange | `boolean` | | `false` | By default the drawer closes on navigation |
`useDrawer()` with no arguments returns the raw `openDrawer(id, config)` function for advanced use cases where you manage the drawer ID yourself.
### Drawer component
A drawer component uses `VtsDrawer` and its sub-components:
```vue
<!-- MyDrawer.vue -->
<template>
<VtsDrawer :dismissible>
<template #title>My Drawer</template>
<template #content>
<!-- your content here -->
</template>
<template #buttons>
<VtsDrawerCancelButton />
<VtsDrawerConfirmButton>Save</VtsDrawerConfirmButton>
</template>
</VtsDrawer>
</template>
<script lang="ts" setup>
import VtsDrawer from '@core/components/drawer/VtsDrawer.vue'
import VtsDrawerCancelButton from '@core/components/drawer/VtsDrawerCancelButton.vue'
import VtsDrawerConfirmButton from '@core/components/drawer/VtsDrawerConfirmButton.vue'
defineProps<{
dismissible?: boolean
// Injected by VtsDrawerList: true when this drawer is the topmost in the stack.
// Pass it through to VtsDrawer so only the current drawer reacts to Escape.
current?: boolean
}>()
</script>
```
`VtsDrawer` props:
| Prop | Type | Default | Description |
| ------------- | --------- | ----------- | --------------------------------------------------------- |
| `dismissible` | `boolean` | `false` | Allows closing via backdrop click, X button, and Escape |
| `isOpen` | `boolean` | `undefined` | Controls visibility externally (uncontrolled by default) |
| `current` | `boolean` | `undefined` | Pass through from parent props — controls Escape handling |
When `isOpen` is omitted, `VtsDrawer` manages its own visibility internally (uncontrolled mode). In controlled mode (`:is-open` provided), the `dismiss` event is emitted instead of closing automatically.
`VtsDrawerCancelButton` and `VtsDrawerConfirmButton` accept an optional `onClick` prop. When provided, the button emits a `click` event instead of triggering `onCancel`/`onConfirm`:
```vue
<VtsDrawerCancelButton :on-click="handleCustomCancel" />
```
### Opening with arguments
```ts
const openMyDrawer = useDrawer((name: string) => ({
component: import('path/to/MyDrawer.vue'),
props: { name },
}))
openMyDrawer('John')
```
### Payload and response
Payloads are fully typed based on the return type of `onConfirm` and `onCancel`:
```ts
const openMyDrawer = useDrawer({
component: import('path/to/MyDrawer.vue'),
onConfirm: () => 'saved',
onCancel: () => 'aborted',
})
const result = await openMyDrawer()
if (result.confirmed) {
result.payload // string
} else {
result.payload // string
}
```
If `onConfirm` or `onCancel` throws, the promise rejects with the thrown error.
### `onConfirm` and `onCancel` event args
If your drawer component defines args for `confirm` and `cancel` events, you'll get them as arguments of the `onConfirm` and `onCancel` callbacks:
```vue
<!-- MyDrawer.vue -->
<script lang="ts" setup>
defineEmits<{
confirm: [value: string]
cancel: []
}>()
</script>
```
```ts
const openMyDrawer = useDrawer({
component: import('path/to/MyDrawer.vue'),
onConfirm: (value: string) => console.log('Confirmed with', value),
})
```
### Busy state
If `onConfirm` or `onCancel` returns a `Promise`, the buttons show a busy indicator while the promise is pending.
**Important asymmetry:**
- `onConfirm` closes the drawer **optimistically** before the async work runs. The drawer is gone before `await` settles.
- `onCancel` waits for the async work to complete **before** closing the drawer.
```ts
const openMyDrawer = useDrawer({
component: import('path/to/MyDrawer.vue'),
onConfirm: async () => {
await saveToServer() // drawer is already closed while this runs
},
onCancel: async () => {
await cleanup() // drawer stays open while this runs, then closes
},
})
```
### Aborting close
Return `ABORT_DRAWER` from `onCancel` to keep the drawer open (e.g. to show a confirmation prompt):
```ts
const openMyDrawer = useDrawer({
component: import('path/to/MyDrawer.vue'),
onCancel: async () => {
if (!hasUnsavedChanges()) {
return
}
const confirmed = await confirmDiscard()
if (!confirmed) {
return ABORT_DRAWER // drawer stays open
}
},
})
```
> **Note:** Returning `ABORT_DRAWER` from `onConfirm` does **not** keep the drawer open. The drawer is closed optimistically before `onConfirm` runs. Returning `ABORT_DRAWER` permanently prevents the promise from resolving — avoid it in `onConfirm`.

View File

@@ -0,0 +1,119 @@
import { useDrawerStore } from '@core/packages/drawer/drawer.store.ts'
import {
ABORT_DRAWER,
DrawerCancelResponse,
type DrawerConfig,
DrawerConfirmResponse,
type DrawerHandlerArgs,
type DrawerResponse,
} from '@core/packages/drawer/types.ts'
import { computed, defineAsyncComponent, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
export function createDrawerOpener() {
const drawerStore = useDrawerStore()
const route = useRoute()
const ids = new Set<symbol | string>()
const cancelers = new Map<string | symbol, () => void>()
function closeById(id: string | symbol) {
drawerStore.removeDrawer(id)
ids.delete(id)
cancelers.delete(id)
}
if (route !== undefined) {
watch(
() => route.path,
() => {
ids.forEach(id => {
if (!drawerStore.getDrawer(id)?.keepOpenOnRouteChange) {
cancelers.get(id)?.()
closeById(id)
}
})
}
)
}
return function openDrawer<
TProps,
TConfirmArgs extends DrawerHandlerArgs<TProps, 'onConfirm'>,
TCancelArgs extends DrawerHandlerArgs<TProps, 'onCancel'>,
TConfirmPayload = undefined,
TCancelPayload = undefined,
>(id: string | symbol, config: DrawerConfig<TProps, TConfirmArgs, TCancelArgs, TConfirmPayload, TCancelPayload>) {
const close = () => closeById(id)
const promise = new Promise<DrawerResponse<TConfirmPayload, TCancelPayload>>((resolve, reject) => {
const isBusy = ref(false)
cancelers.set(id, () => resolve(new DrawerCancelResponse(undefined as TCancelPayload)))
drawerStore.addDrawer({
id,
component: defineAsyncComponent({
loader: () => config.component,
onError(_error, _retry, fail) {
fail()
},
}),
isBusy: computed(() => isBusy.value),
props: reactive(config.props ?? {}),
keepOpenOnRouteChange: config.keepOpenOnRouteChange ?? false,
onConfirm: async (...args: TConfirmArgs) => {
try {
isBusy.value = true
close()
const result = config.onConfirm ? await config.onConfirm(...args) : (undefined as TConfirmPayload)
if (result === ABORT_DRAWER || result instanceof DrawerCancelResponse) {
return
}
if (result instanceof DrawerConfirmResponse) {
resolve(result)
} else {
resolve(new DrawerConfirmResponse(result))
}
} catch (error) {
reject(error)
} finally {
isBusy.value = false
}
},
onCancel: async (...args: TCancelArgs) => {
try {
isBusy.value = true
const result = config.onCancel ? await config.onCancel(...args) : (undefined as TCancelPayload)
if (result === ABORT_DRAWER || result instanceof DrawerCancelResponse) {
return
}
if (result instanceof DrawerConfirmResponse) {
resolve(new DrawerCancelResponse(result.payload))
} else {
resolve(new DrawerCancelResponse(result))
}
close()
} catch (error) {
reject(error)
} finally {
isBusy.value = false
}
},
})
ids.add(id)
})
return Object.assign(promise, { close })
}
}

View File

@@ -0,0 +1,22 @@
import type { RegisteredDrawer } from '@core/packages/drawer/types.ts'
import { defineStore } from 'pinia'
import { computed, shallowReactive } from 'vue'
export const useDrawerStore = defineStore('drawer', () => {
const drawers = shallowReactive(new Map<symbol | string, RegisteredDrawer>())
const addDrawer = (drawer: RegisteredDrawer) => {
drawers.set(drawer.id, drawer)
}
const removeDrawer = (id: symbol | string) => {
drawers.delete(id)
}
return {
drawers: computed(() => Array.from(drawers.values())),
getDrawer: (id: symbol | string) => drawers.get(id),
addDrawer,
removeDrawer,
}
})

View File

@@ -0,0 +1,93 @@
import type { Component, ComputedRef, InjectionKey, MaybeRef } from 'vue'
export type DrawerPropsOption<TProps> = {
[K in keyof TProps]: MaybeRef<TProps[K]>
}
export type MaybePromise<T> = T | Promise<T>
export type AreAllPropsOptional<TProps> = Record<string, never> extends TProps ? true : false
export type RegisteredDrawer = {
id: symbol | string
component: Component
keepOpenOnRouteChange: boolean
props: object
isBusy: ComputedRef<boolean>
onConfirm: (...args: any[]) => void | Promise<void>
onCancel: (...args: any[]) => void | Promise<void>
}
export type DrawerHandlerArgs<TProps, THandler extends 'onConfirm' | 'onCancel'> = TProps extends {
[K in THandler]?: (...args: infer TArgs) => any
}
? TArgs
: never
export abstract class AbstractDrawerResponse<TPayload> {
constructor(public readonly payload: TPayload) {}
abstract get confirmed(): boolean
abstract get canceled(): boolean
}
export class DrawerConfirmResponse<TPayload> extends AbstractDrawerResponse<TPayload> {
public readonly confirmed = true
public readonly canceled = false
}
export class DrawerCancelResponse<TPayload> extends AbstractDrawerResponse<TPayload> {
public readonly confirmed = false
public readonly canceled = true
}
export type DrawerResponse<TConfirmPayload, TCancelPayload> =
| DrawerConfirmResponse<TConfirmPayload>
| DrawerCancelResponse<TCancelPayload>
export const ABORT_DRAWER = Symbol('abort drawer')
export type OpenDrawerReturn<TConfirmPayload, TCancelPayload> = Promise<
DrawerResponse<TConfirmPayload, TCancelPayload>
> & {
close: () => void
}
export const IK_DRAWER = Symbol('drawer') as InjectionKey<ComputedRef<RegisteredDrawer>>
export type DrawerPropsConfigEntry<TProps> =
AreAllPropsOptional<TProps> extends true
? { props?: DrawerPropsOption<TProps> }
: { props: DrawerPropsOption<TProps> }
export type DrawerConfig<
TProps,
TConfirmArgs extends any[],
TCancelArgs extends any[],
TConfirmPayload,
TCancelPayload,
> = {
component: Promise<{
default: abstract new () => {
$props: TProps
}
}>
onConfirm?: (...args: TConfirmArgs) => MaybePromise<DrawerResponse<TConfirmPayload, any> | TConfirmPayload>
onCancel?: (...args: TCancelArgs) => MaybePromise<DrawerResponse<TCancelPayload, any> | TCancelPayload>
keepOpenOnRouteChange?: boolean
} & DrawerPropsConfigEntry<TProps>
export type UseDrawerReturn<TArgs extends any[] = any[], TConfirmPayload = any, TCancelPayload = any> = (
...args: TArgs
) => OpenDrawerReturn<TConfirmPayload, TCancelPayload>
export type OpenDrawer = <
TProps,
TConfirmArgs extends DrawerHandlerArgs<TProps, 'onConfirm'>,
TCancelArgs extends DrawerHandlerArgs<TProps, 'onCancel'>,
TConfirmPayload = undefined,
TCancelPayload = undefined,
>(
id: string,
config: DrawerConfig<TProps, TConfirmArgs, TCancelArgs, TConfirmPayload, TCancelPayload>
) => OpenDrawerReturn<TConfirmPayload, TCancelPayload>

View File

@@ -0,0 +1,53 @@
import { createDrawerOpener } from '@core/packages/drawer/create-drawer-opener.ts'
import { type DrawerConfig, type DrawerHandlerArgs, type UseDrawerReturn } from '@core/packages/drawer/types.ts'
export function useDrawer(): ReturnType<typeof createDrawerOpener>
export function useDrawer<
TProps,
TConfirmArgs extends DrawerHandlerArgs<TProps, 'onConfirm'>,
TCancelArgs extends DrawerHandlerArgs<TProps, 'onCancel'>,
TConfirmPayload = undefined,
TCancelPayload = undefined,
>(
config: DrawerConfig<TProps, TConfirmArgs, TCancelArgs, TConfirmPayload, TCancelPayload>
): UseDrawerReturn<[], TConfirmPayload, TCancelPayload>
export function useDrawer<
TConfigBuilderArgs extends any[],
TProps,
TConfirmArgs extends DrawerHandlerArgs<TProps, 'onConfirm'>,
TCancelArgs extends DrawerHandlerArgs<TProps, 'onCancel'>,
TConfirmPayload = undefined,
TCancelPayload = undefined,
>(
configBuilder: (
...args: TConfigBuilderArgs
) => DrawerConfig<TProps, TConfirmArgs, TCancelArgs, TConfirmPayload, TCancelPayload>
): UseDrawerReturn<TConfigBuilderArgs, TConfirmPayload, TCancelPayload>
export function useDrawer<
TConfigBuilderArgs extends any[],
TProps,
TConfirmArgs extends DrawerHandlerArgs<TProps, 'onConfirm'>,
TCancelArgs extends DrawerHandlerArgs<TProps, 'onCancel'>,
TConfig extends DrawerConfig<TProps, TConfirmArgs, TCancelArgs, TConfirmPayload, TCancelPayload>,
TConfirmPayload = undefined,
TCancelPayload = undefined,
>(
configOrBuilder?: TConfig | ((...args: TConfigBuilderArgs) => TConfig)
): UseDrawerReturn<TConfigBuilderArgs, TConfirmPayload, TCancelPayload> | ReturnType<typeof createDrawerOpener> {
const openDrawer = createDrawerOpener()
if (configOrBuilder === undefined) {
return openDrawer
}
const id = Symbol('drawer')
return (...args: TConfigBuilderArgs) => {
const config = typeof configOrBuilder === 'function' ? configOrBuilder(...args) : configOrBuilder
return openDrawer(id, config)
}
}

View File

@@ -0,0 +1,17 @@
<template>
<slot />
</template>
<script lang="ts" setup>
import { IK_MODAL, type RegisteredModal } from '@core/packages/modal/types.ts'
import { computed, provide } from 'vue'
const { modal } = defineProps<{
modal: RegisteredModal
}>()
provide(
IK_MODAL,
computed(() => modal)
)
</script>

View File

@@ -0,0 +1,253 @@
# Modal System
### Modals list component
First, create a component which will display the modals.
If needed, you can use the `ModalProvider` component to provide the `modal` to children components.
For example:
```vue
<template>
<div v-if="modalStore.modals.length > 0" class="modals">
<ModalProvider v-for="modal of modalStore.modals" :key="modal.id" :modal>
<component :is="modal.component" class="modal" v-bind="modal.bindings" />
</ModalProvider>
</div>
</template>
<script lang="ts" setup>
const modalStore = useModalStore()
</script>
<style lang="postcss" scoped>
.modals {
position: fixed;
inset: 0;
background-color: var(--color-opacity-primary);
z-index: 1010;
.modal:not(:last-child) {
filter: brightness(0.8);
}
}
</style>
```
### Modal component
You then need to create a Modal component which will emit `confirm` and `cancel` events, as needed.
```vue
<template>
<div class="modal-container">
<div class="modal">
<h1>My modal</h1>
<button @click="emit('confirm')">Confirm</button>
<button @click="emit('cancel')">Cancel</button>
</div>
</div>
</template>
<script lang="ts" setup>
const emit = defineEmits<{
confirm: []
cancel: []
}>()
</script>
<style lang="postcss" scoped>
.modal-container {
position: fixed;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
}
.modal {
background: white;
border-radius: 8px;
padding: 2rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
</style>
```
### Opening a modal (`useModal` composable)
You can create a function to open a modal with `useModal`
```ts
const openModal = useModal(config)
```
| Property | Type | Required | Default | Description |
| --------------------- | ------------------------------------- | :------: | :---------: | -------------------------------------------------------------------------------------------------------------------------------------------- |
| component | `Promise<Component>` | ✓ | | The promise of the modal component to open. (e.g: `component: import('path/to/modal.vue')` |
| props | `Record<string, MaybeRef<any>>` | | `{}` | The props to pass to the modal component |
| onConfirm | `(...args: any[]) => TConfirmPayload` | | `undefined` | An optional callback to call when the modal is confirmed. It will take the args of the `confirm` event of the modal's `defineEmits`, if any. |
| onCancel | `(...args: any[]) => TCancelPayload` | | `undefined` | An optional callback to call when the modal is cancelled. It will take the args of the `cancel` event of the modal's `defineEmits`, if any. |
| keepOpenOnRouteChange | `boolean` | | `false` | By default, the modal will close when the use navigates away from the page. If `true`, the modal will stay open until it is closed manually. |
The result of `openModal()` will be `Promise<ModalConfirmResponse<TConfirmPayload> | ModalCancelResponse<TCancelPayload>>`
If `onConfirm` / `onCancel` returns a `Promise`, then the modal will not close until the promise is resolved, and its `busy` state will be set to `true`.
```ts
const openMyModal = useModal({
component: import('path/to/MyModal.vue'),
props: {
foo: 'Foo',
bar: computed(() => someBar),
},
onConfirm: () => console.log('Confirmed!'),
onCancel: () => console.log('Cancelled!'),
})
```
```html
<button @click="openModal()">Open modal</button>
```
## Modal payload
Modal payloads are fully typed.
```ts
const openMyModal = useModal({
component: import('path/to/MyModal.vue'),
onConfirm: () => 'Hello',
onCancel: () => 1234,
})
const result = await openMyModal()
if (result.confirmed) {
result.payload // string
} else {
result.payload // number
}
```
## Opening a modal with arguments
You can pass a function returning a config instead of a config object.
```ts
const openMyModal = useModal((name: string) => ({
component: import('path/to/MyModal.vue'),
props: { name },
onConfirm: () => console.log('Confirmed for', name),
onCancel: () => console.log('Cancelled for', name),
})
```
```html
<button @click="openMyModal('John')">Open John modal</button>
<button @click="openMyModal('Jane')">Open Jane modal</button>
```
## `onConfirm` and `onCancel` event args
If your Modal component defines args for `confirm` and `cancel` events, you'll get them as arguments of the `onConfirm` and `onCancel` callbacks.
```vue
<!-- MyModal.vue -->
<template>
<div class="modal-container">
<div class="modal">
<h1>My modal</h1>
<button @click="emit('confirm', 1)">Select 1</button>
<button @click="emit('confirm', 10)">Select 10</button>
<button @click="emit('cancel')">Cancel</button>
</div>
</div>
</template>
<script lang="ts" setup>
defineEmits<{
confirm: [count: number]
cancel: []
}>()
```
```ts
const openModal = useModal({
component: import('path/to/MyModal.vue'),
onConfirm: (count: number) => console.log('Confirmed with count', count),
})
</script>
```
## Opening a modal from elsewhere
`useModal` muse be called at the root of your component.
If you need to open a modal from elsewhere, you can use `useModal()` with no arguments to get a function that you can call later to open the modal.
```ts
const openModal = useModal()
function myHandler() {
return someApiManager.doSomething(myArg, openModal)
}
// api-manager.ts
function doSomething(arg: string, openModal: OpenModal) {
// ... do something
openModal('some-id', {
component: import('path/to/MyModal.vue'),
props: { arg },
onConfirm: () => console.log('Confirmed!'),
onCancel: () => console.log('Cancelled!'),
})
}
```
## Chaining modals
You can open a modal from another modal.
For example, you can open a confirmation modal ("Are you sure...") from the `onConfirm` of a delete modal, or from the `onCancel` of a form modal which has unsaved changes.
If the second modal is canceled, the first modal will stay open.
```ts
const openModal = useModal()
const openDeleteModal = useModal({
component: import('path/to/DeleteModal.vue'),
onConfirm: () =>
openModal('confirm-delete', {
component: import('path/to/ConfirmModal.vue'),
props: { message: 'Are you sure?' },
onConfirm: async () => {
console.log('Deleting...')
await deleteResource()
},
}),
})
```
## Aborting the closing of a modal
If you want to prevent a modal from closing manually, you can return the `ABORT_MODAL` symbol from the `onConfirm` or `onCancel` handlers.
```ts
const openModal = useModal()
const openDeleteModal = useModal({
component: import('path/to/DeleteModal.vue'),
onConfirm: async () => {
try {
await tryingToDeleteResource()
} catch (e) {
return ABORT_MODAL
}
},
})
```

View File

@@ -0,0 +1,106 @@
import { useModalStore } from '@core/packages/modal/modal.store.ts'
import {
ABORT_MODAL,
ModalCancelResponse,
type ModalConfig,
ModalConfirmResponse,
type ModalHandlerArgs,
type ModalResponse,
} from '@core/packages/modal/types.ts'
import { computed, defineAsyncComponent, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
export function createModalOpener() {
const modalStore = useModalStore()
const route = useRoute()
const ids = new Set<symbol | string>()
function closeById(id: string | symbol) {
modalStore.removeModal(id)
ids.delete(id)
}
if (route !== undefined) {
watch(
() => route.path,
() => {
ids.forEach(id => {
if (!modalStore.getModal(id)?.keepOpenOnRouteChange) {
closeById(id)
}
})
}
)
}
return function openModal<
TProps,
TConfirmArgs extends ModalHandlerArgs<TProps, 'onConfirm'>,
TCancelArgs extends ModalHandlerArgs<TProps, 'onCancel'>,
TConfirmPayload = undefined,
TCancelPayload = undefined,
>(id: string | symbol, config: ModalConfig<TProps, TConfirmArgs, TCancelArgs, TConfirmPayload, TCancelPayload>) {
const close = () => closeById(id)
const promise = new Promise<ModalResponse<TConfirmPayload, TCancelPayload>>(resolve => {
const isBusy = ref(false)
modalStore.addModal({
id,
component: defineAsyncComponent(() => config.component),
isBusy: computed(() => isBusy.value),
props: reactive(config.props ?? {}),
keepOpenOnRouteChange: config.keepOpenOnRouteChange ?? false,
onConfirm: async (...args: TConfirmArgs) => {
try {
isBusy.value = true
// TODO carefully about the order of close, if any issue, put it after resolve
close()
const result = config.onConfirm ? await config.onConfirm(...args) : (undefined as TConfirmPayload)
if (result === ABORT_MODAL || result instanceof ModalCancelResponse) {
return
}
if (result instanceof ModalConfirmResponse) {
resolve(result)
} else {
resolve(new ModalConfirmResponse(result))
}
} finally {
isBusy.value = false
}
},
onCancel: async (...args: TCancelArgs) => {
try {
isBusy.value = true
const result = config.onCancel ? await config.onCancel(...args) : (undefined as TCancelPayload)
if (result === ABORT_MODAL || result instanceof ModalCancelResponse) {
return
}
if (result instanceof ModalConfirmResponse) {
resolve(new ModalCancelResponse(result.payload))
} else {
resolve(new ModalCancelResponse(result))
}
close()
} finally {
isBusy.value = false
}
},
})
})
return Object.assign(promise, {
close,
})
}
}

View File

@@ -0,0 +1,22 @@
import type { RegisteredModal } from '@core/packages/modal/types.ts'
import { defineStore } from 'pinia'
import { computed, shallowReactive } from 'vue'
export const useModalStore = defineStore('new-modal', () => {
const modals = shallowReactive(new Map<symbol | string, RegisteredModal>())
const addModal = (modal: RegisteredModal) => {
modals.set(modal.id, modal)
}
const removeModal = (id: symbol | string) => {
modals.delete(id)
}
return {
modals: computed(() => Array.from(modals.values())),
getModal: (id: symbol | string) => modals.get(id),
addModal,
removeModal,
}
})

View File

@@ -0,0 +1,91 @@
import type { Component, ComputedRef, InjectionKey, MaybeRef } from 'vue'
export type ModalPropsOption<TProps> = {
[K in keyof TProps]: MaybeRef<TProps[K]>
}
export type MaybePromise<T> = T | Promise<T>
export type AreAllPropsOptional<TProps> = Record<string, never> extends TProps ? true : false
export type RegisteredModal = {
id: symbol | string
component: Component
keepOpenOnRouteChange: boolean
props: object
isBusy: ComputedRef<boolean>
onConfirm: (...args: any[]) => void | Promise<void>
onCancel: (...args: any[]) => void | Promise<void>
}
export type ModalHandlerArgs<TProps, THandler extends 'onConfirm' | 'onCancel'> = TProps extends {
[K in THandler]?: (...args: infer TArgs) => any
}
? TArgs
: never
export abstract class AbstractModalResponse<TPayload> {
constructor(public readonly payload: TPayload) {}
abstract get confirmed(): boolean
abstract get canceled(): boolean
}
export class ModalConfirmResponse<TPayload> extends AbstractModalResponse<TPayload> {
public readonly confirmed = true
public readonly canceled = false
}
export class ModalCancelResponse<TPayload> extends AbstractModalResponse<TPayload> {
public readonly confirmed = false
public readonly canceled = true
}
export type ModalResponse<TConfirmPayload, TCancelPayload> =
| ModalConfirmResponse<TConfirmPayload>
| ModalCancelResponse<TCancelPayload>
export const ABORT_MODAL = Symbol('abort modal')
export type OpenModalReturn<TConfirmPayload, TCancelPayload> = Promise<
ModalResponse<TConfirmPayload, TCancelPayload>
> & {
close: () => void
}
export const IK_MODAL = Symbol('modal') as InjectionKey<ComputedRef<RegisteredModal>>
export type ModalPropsConfigEntry<TProps> =
AreAllPropsOptional<TProps> extends true ? { props?: ModalPropsOption<TProps> } : { props: ModalPropsOption<TProps> }
export type ModalConfig<
TProps,
TConfirmArgs extends any[],
TCancelArgs extends any[],
TConfirmPayload,
TCancelPayload,
> = {
component: Promise<{
default: abstract new () => {
$props: TProps
}
}>
onConfirm?: (...args: TConfirmArgs) => MaybePromise<ModalResponse<TConfirmPayload, any> | TConfirmPayload>
onCancel?: (...args: TCancelArgs) => MaybePromise<ModalResponse<TCancelPayload, any> | TCancelPayload>
keepOpenOnRouteChange?: boolean
} & ModalPropsConfigEntry<TProps>
export type UseModalReturn<TArgs extends any[] = any[], TConfirmPayload = any, TCancelPayload = any> = (
...args: TArgs
) => OpenModalReturn<TConfirmPayload, TCancelPayload>
export type OpenModal = <
TProps,
TConfirmArgs extends ModalHandlerArgs<TProps, 'onConfirm'>,
TCancelArgs extends ModalHandlerArgs<TProps, 'onCancel'>,
TConfirmPayload = undefined,
TCancelPayload = undefined,
>(
id: string,
config: ModalConfig<TProps, TConfirmArgs, TCancelArgs, TConfirmPayload, TCancelPayload>
) => OpenModalReturn<TConfirmPayload, TCancelPayload>

View File

@@ -0,0 +1,53 @@
import { createModalOpener } from '@core/packages/modal/create-modal-opener.ts'
import { type ModalConfig, type ModalHandlerArgs, type UseModalReturn } from '@core/packages/modal/types.ts'
export function useModal(): ReturnType<typeof createModalOpener>
export function useModal<
TProps,
TConfirmArgs extends ModalHandlerArgs<TProps, 'onConfirm'>,
TCancelArgs extends ModalHandlerArgs<TProps, 'onCancel'>,
TConfirmPayload = undefined,
TCancelPayload = undefined,
>(
config: ModalConfig<TProps, TConfirmArgs, TCancelArgs, TConfirmPayload, TCancelPayload>
): UseModalReturn<[], TConfirmPayload, TCancelPayload>
export function useModal<
TConfigBuilderArgs extends any[],
TProps,
TConfirmArgs extends ModalHandlerArgs<TProps, 'onConfirm'>,
TCancelArgs extends ModalHandlerArgs<TProps, 'onCancel'>,
TConfirmPayload = undefined,
TCancelPayload = undefined,
>(
configBuilder: (
...args: TConfigBuilderArgs
) => ModalConfig<TProps, TConfirmArgs, TCancelArgs, TConfirmPayload, TCancelPayload>
): UseModalReturn<TConfigBuilderArgs, TConfirmPayload, TCancelPayload>
export function useModal<
TConfigBuilderArgs extends any[],
TProps,
TConfirmArgs extends ModalHandlerArgs<TProps, 'onConfirm'>,
TCancelArgs extends ModalHandlerArgs<TProps, 'onCancel'>,
TConfig extends ModalConfig<TProps, TConfirmArgs, TCancelArgs, TConfirmPayload, TCancelPayload>,
TConfirmPayload = undefined,
TCancelPayload = undefined,
>(
configOrBuilder?: TConfig | ((...args: TConfigBuilderArgs) => TConfig)
): UseModalReturn<TConfigBuilderArgs, TConfirmPayload, TCancelPayload> | ReturnType<typeof createModalOpener> {
const openModal = createModalOpener()
if (configOrBuilder === undefined) {
return openModal
}
const id = Symbol('modal')
return (...args: TConfigBuilderArgs) => {
const config = typeof configOrBuilder === 'function' ? configOrBuilder(...args) : configOrBuilder
return openModal(id, config)
}
}

View File

@@ -23,4 +23,6 @@ export const IK_DISABLED = Symbol('IK_DISABLED') as InjectionKey<ComputedRef<boo
export const IK_INPUT_WRAPPER_CONTROLLER = Symbol('IK_INPUT_WRAPPER_CONTROLLER') as InjectionKey<InputWrapperController>
export const IK_MODAL_ACCENT = Symbol('IK_MODAL_ACCENT') as InjectionKey<ComputedRef<ModalAccent>>
export const IK_OVERLAY_ACCENT = Symbol('IK_OVERLAY_ACCENT') as InjectionKey<ComputedRef<ModalAccent>>

View File

@@ -2,18 +2,20 @@
<AppLayout>
<RouterView />
</AppLayout>
<VtsOverlayList />
<VtsModalList />
<VtsDrawerList />
<VtsTooltipList />
</template>
<script lang="ts" setup>
import AppLayout from '@/layouts/AppLayout.vue'
import { useTheme } from '@/shared/composables/theme.composable.ts'
import VtsOverlayList from '@core/components/overlay/VtsOverlayList.vue'
import VtsDrawerList from '@core/components/drawer/VtsDrawerList.vue'
import VtsModalList from '@core/components/modal/VtsModalList.vue'
import VtsTooltipList from '@core/components/tooltip-list/VtsTooltipList.vue'
import { useChartTheme } from '@core/composables/chart-theme.composable.ts'
import { locales } from '@core/i18n'
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { useModal } from '@core/packages/modal/use-modal'
import { useUiStore } from '@core/stores/ui.store'
import { useActiveElement, useLocalStorage, useMagicKeys, whenever } from '@vueuse/core'
import { useCookies } from '@vueuse/integrations/useCookies'
@@ -30,15 +32,14 @@ useTheme()
// TODO: Remove when we consider XO6 in more advanced state
const isFirstConnection = useLocalStorage('first-connection', true)
const { open: openFirstConnectionModal } = useOverlay({
component: () => import('@/shared/components/modals/FirstConnectionModal.vue'),
events: {
onClose: () => (isFirstConnection.value = false),
const openModal = useModal({
component: import('@/shared/components/modals/FirstConnection.vue'),
onConfirm: () => {
isFirstConnection.value = false
},
})
whenever(isFirstConnection, () => openFirstConnectionModal(), { immediate: true })
whenever(() => isFirstConnection.value, openModal, { immediate: true })
const cookieLang = get('lang')
locale.value = cookieLang && locales[cookieLang] ? cookieLang : 'en'

View File

@@ -17,8 +17,8 @@
import { useXoHostDisableJob } from '@/modules/host/jobs/xo-host-disable.job.ts'
import type { FrontXoHost } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import MenuItem from '@core/components/menu/MenuItem.vue'
import { useActionModal } from '@core/composables/modals/use-action-modal.ts'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { useModal } from '@core/packages/modal/use-modal.ts'
import { useI18n } from 'vue-i18n'
const { host } = defineProps<{
@@ -34,18 +34,15 @@ const {
errorMessage: disableHostErrorMessage,
} = useXoHostDisableJob(() => host)
const { open: openActionModal } = useActionModal()
function openDisableHostModal() {
return openActionModal({
events: { onConfirm: () => disableHost() },
props: {
accent: 'warning',
action: 'disable',
object: 'host',
hostName: host.name_label,
icon: 'status:warning-picto',
},
})
}
const openDisableHostModal = useModal({
component: import('@core/components/modal/VtsActionModal.vue'),
props: {
accent: 'warning',
action: 'disable',
object: 'host',
hostName: host.name_label,
icon: 'status:warning-picto',
},
onConfirm: () => disableHost(),
})
</script>

View File

@@ -17,8 +17,8 @@
import { useXoHostEnableJob } from '@/modules/host/jobs/xo-host-enable.job.ts'
import type { FrontXoHost } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import MenuItem from '@core/components/menu/MenuItem.vue'
import { useActionModal } from '@core/composables/modals/use-action-modal.ts'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { useModal } from '@core/packages/modal/use-modal.ts'
import { useI18n } from 'vue-i18n'
const { host } = defineProps<{
@@ -34,18 +34,15 @@ const {
errorMessage: enableHostErrorMessage,
} = useXoHostEnableJob(() => host)
const { open: openActionModal } = useActionModal()
function openEnableHostModal() {
return openActionModal({
events: { onConfirm: () => enableHost() },
props: {
accent: 'warning',
action: 'enable',
object: 'host',
hostName: host.name_label,
icon: 'status:warning-picto',
},
})
}
const openEnableHostModal = useModal({
component: import('@core/components/modal/VtsActionModal.vue'),
props: {
accent: 'warning',
action: 'enable',
object: 'host',
hostName: host.name_label,
icon: 'status:warning-picto',
},
onConfirm: () => enableHost(),
})
</script>

View File

@@ -4,7 +4,7 @@
:disabled="!canShutdownHost"
icon="action:shutdown"
:busy="isShuttingDownHost"
@click="shutdownHost()"
@click="openShutdownHostModal()"
>
{{ t('action:shutdown') }}
</MenuItem>
@@ -14,8 +14,8 @@
import { useXoHostShutdownJob } from '@/modules/host/jobs/xo-host-shutdown.job.ts'
import type { FrontXoHost } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import MenuItem from '@core/components/menu/MenuItem.vue'
import { useActionModal } from '@core/composables/modals/use-action-modal.ts'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { useModal } from '@core/packages/modal/use-modal.ts'
import { useI18n } from 'vue-i18n'
const { host } = defineProps<{
@@ -25,26 +25,21 @@ const { host } = defineProps<{
const { t } = useI18n()
const {
run,
run: shutdownHost,
canRun: canShutdownHost,
isRunning: isShuttingDownHost,
errorMessage: shutdownHostErrorMessage,
} = useXoHostShutdownJob(() => host)
const { open: openActionModal } = useActionModal()
function shutdownHost() {
openActionModal({
events: {
onConfirm: () => run(),
},
props: {
accent: 'warning',
action: 'shutdown',
object: 'host',
hostName: host.name_label,
icon: 'status:warning-picto',
},
})
}
const openShutdownHostModal = useModal({
component: import('@core/components/modal/VtsActionModal.vue'),
props: {
accent: 'warning',
action: 'shutdown',
object: 'host',
hostName: host.name_label,
icon: 'status:warning-picto',
},
onConfirm: () => shutdownHost(),
})
</script>

View File

@@ -4,7 +4,7 @@
:disabled="!canStartHost"
icon="fa:play"
:busy="isStartingHost"
@click="startHost()"
@click="openStartHostModal()"
>
{{ t('action:start') }}
</MenuItem>
@@ -14,8 +14,8 @@
import { useXoHostStartJob } from '@/modules/host/jobs/xo-host-start.job.ts'
import type { FrontXoHost } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import MenuItem from '@core/components/menu/MenuItem.vue'
import { useActionModal } from '@core/composables/modals/use-action-modal.ts'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { useModal } from '@core/packages/modal/use-modal.ts'
import { useI18n } from 'vue-i18n'
const { host } = defineProps<{
@@ -25,26 +25,21 @@ const { host } = defineProps<{
const { t } = useI18n()
const {
run,
run: startHost,
canRun: canStartHost,
isRunning: isStartingHost,
errorMessage: startHostErrorMessage,
} = useXoHostStartJob(() => host)
const { open: openActionModal } = useActionModal()
function startHost() {
openActionModal({
events: {
onConfirm: () => run(),
},
props: {
accent: 'warning',
action: 'start',
object: 'host',
hostName: host.name_label,
icon: 'status:warning-picto',
},
})
}
const openStartHostModal = useModal({
component: import('@core/components/modal/VtsActionModal.vue'),
props: {
accent: 'warning',
action: 'start',
object: 'host',
hostName: host.name_label,
icon: 'status:warning-picto',
},
onConfirm: () => startHost(),
})
</script>

View File

@@ -25,7 +25,7 @@
</template>
<script setup lang="ts">
import { useNetworkDelete } from '@/modules/network/composables/use-network-delete.composable.ts'
import { useNetworkDeleteModal } from '@/modules/network/composables/use-network-delete-modal.composable.ts'
import type { FrontXoNetwork } from '@/modules/network/remote-resources/use-xo-network-collection.ts'
import { getNetworkStatus } from '@/modules/network/utils/xo-network.util.ts'
import { useXoPifCollection } from '@/modules/pif/remote-resources/use-xo-pif-collection.ts'
@@ -117,7 +117,7 @@ const { HeadCells, BodyCells } = useNetworkColumns({
const defaultLockingMode = computed(() => getLockingMode(network.defaultIsLocked))
const href = computed(() => buildXo5Route(`/pools/${network.$pool}/network?s=1_0_asc-${network.id}`))
const { deleteNetworks, isDeletingNetworks } = useNetworkDelete(() => [network])
const { openModal: openDeleteModal, isRunning: isDeletingNetwork } = useNetworkDeleteModal(() => [network])
return {
network: r =>
@@ -138,8 +138,8 @@ const { HeadCells, BodyCells } = useNetworkColumns({
{
label: t('action:delete'),
icon: 'action:delete',
onClick: () => deleteNetworks(),
busy: isDeletingNetworks.value,
onClick: () => openDeleteModal(),
busy: isDeletingNetwork.value,
},
],
}),

View File

@@ -1,5 +1,5 @@
<template>
<VtsErrorModal :title="t('unable-to-delete-network')" @close="emit('close')">
<VtsErrorModal :title="t('unable-to-delete-network')">
<template #content>
{{ error ?? t('error') }}
<template v-if="showConnectedVifsMessage">
@@ -28,10 +28,6 @@ defineProps<{
showConnectedVifsMessage?: boolean
}>()
const emit = defineEmits<{
close: []
}>()
const { t } = useI18n()
const { buildXo5Route } = useXoRoutes()

Some files were not shown because too many files have changed in this diff Show More