mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-12 10:12:58 -06:00
Compare commits
5 Commits
debug_time
...
fix_undefi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf2bea4651 | ||
|
|
5014409baa | ||
|
|
435d660c84 | ||
|
|
dbb6e252e6 | ||
|
|
6ebbd2c968 |
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
22
@xen-orchestra/lite/src/components/modals/VmDeleteModal.vue
Normal file
22
@xen-orchestra/lite/src/components/modals/VmDeleteModal.vue
Normal 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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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[][]> =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
78
@xen-orchestra/lite/src/route-map.d.ts
vendored
78
@xen-orchestra/lite/src/route-map.d.ts
vendored
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
115
@xen-orchestra/web-core/docs/modals.md
Normal file
115
@xen-orchestra/web-core/docs/modals.md
Normal 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>
|
||||
```
|
||||
@@ -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>
|
||||
```
|
||||
89
@xen-orchestra/web-core/lib/components/drawer/VtsDrawer.vue
Normal file
89
@xen-orchestra/web-core/lib/components/drawer/VtsDrawer.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}>()
|
||||
|
||||
82
@xen-orchestra/web-core/lib/components/modal/VtsModal.vue
Normal file
82
@xen-orchestra/web-core/lib/components/modal/VtsModal.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
189
@xen-orchestra/web-core/lib/packages/drawer/README.md
Normal file
189
@xen-orchestra/web-core/lib/packages/drawer/README.md
Normal 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`.
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
22
@xen-orchestra/web-core/lib/packages/drawer/drawer.store.ts
Normal file
22
@xen-orchestra/web-core/lib/packages/drawer/drawer.store.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
93
@xen-orchestra/web-core/lib/packages/drawer/types.ts
Normal file
93
@xen-orchestra/web-core/lib/packages/drawer/types.ts
Normal 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>
|
||||
53
@xen-orchestra/web-core/lib/packages/drawer/use-drawer.ts
Normal file
53
@xen-orchestra/web-core/lib/packages/drawer/use-drawer.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
17
@xen-orchestra/web-core/lib/packages/modal/ModalProvider.vue
Normal file
17
@xen-orchestra/web-core/lib/packages/modal/ModalProvider.vue
Normal 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>
|
||||
253
@xen-orchestra/web-core/lib/packages/modal/README.md
Normal file
253
@xen-orchestra/web-core/lib/packages/modal/README.md
Normal 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
|
||||
}
|
||||
},
|
||||
})
|
||||
```
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
22
@xen-orchestra/web-core/lib/packages/modal/modal.store.ts
Normal file
22
@xen-orchestra/web-core/lib/packages/modal/modal.store.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
91
@xen-orchestra/web-core/lib/packages/modal/types.ts
Normal file
91
@xen-orchestra/web-core/lib/packages/modal/types.ts
Normal 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>
|
||||
53
@xen-orchestra/web-core/lib/packages/modal/use-modal.ts
Normal file
53
@xen-orchestra/web-core/lib/packages/modal/use-modal.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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>>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user