feat: delete a custom type
This commit is contained in:
1 parent
76e9e1220c
commit
5404291af2
25 files changed
+1768
-46
No files matched your search
@@ -40,5 +40,6 @@ defineEmits<{ save: []; discard: [] }>()
|
||||
>
|
||||
{{ saving ? t('Saving…') : t('Save') }}
|
||||
</UButton>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
defineProps<{ disabled: boolean }>()
|
||||
|
||||
const emit = defineEmits<{ delete: [] }>()
|
||||
|
||||
const trigger = ref<{ $el: HTMLElement } | null>(null)
|
||||
|
||||
const items = computed(() => [
|
||||
[
|
||||
{
|
||||
label: t('Delete content type'),
|
||||
icon: 'i-lucide-trash-2',
|
||||
color: 'error' as const,
|
||||
onSelect: () => emit('delete'),
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
function focus() {
|
||||
trigger.value?.$el.focus()
|
||||
}
|
||||
|
||||
defineExpose({ focus })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDropdownMenu
|
||||
:items="items"
|
||||
:content="{ align: 'end' }"
|
||||
:ui="{
|
||||
content: 'w-55 p-1',
|
||||
group: 'p-0',
|
||||
item: 'h-8 gap-2 px-2 text-sm max-md:h-11',
|
||||
itemLeadingIcon: 'size-4',
|
||||
}"
|
||||
>
|
||||
<UButton
|
||||
ref="trigger"
|
||||
icon="i-lucide-ellipsis"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:disabled="disabled"
|
||||
:aria-label="t('More actions')"
|
||||
class="size-8 shrink-0 justify-center text-muted data-[state=open]:outline-2 data-[state=open]:outline-offset-2 data-[state=open]:outline-primary max-md:size-11"
|
||||
/>
|
||||
</UDropdownMenu>
|
||||
</template>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import DirectoryDialog from '@/components/DirectoryDialog.vue'
|
||||
import { entriesCountLine } from '@/lib/schemaEntries'
|
||||
import { isConfirmStage, type DeleteStage } from '@/lib/schemaDeleteStage'
|
||||
|
||||
const props = defineProps<{
|
||||
stage: Exclude<DeleteStage, 'closed'>
|
||||
name: string
|
||||
keyValue: string
|
||||
total: number
|
||||
pending: boolean
|
||||
offline: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ confirm: []; close: []; viewEntries: [] }>()
|
||||
|
||||
const blocked = computed(() => props.stage === 'blocked')
|
||||
const conflict = computed(() => props.stage === 'conflict')
|
||||
const confirming = computed(() => isConfirmStage(props.stage))
|
||||
const informational = computed(() => !confirming.value)
|
||||
|
||||
const title = computed(() =>
|
||||
blocked.value ? t("You can't delete {0} yet", [props.name]) : t('Delete {0}?', [props.name]),
|
||||
)
|
||||
const description = computed(() => {
|
||||
if (blocked.value) {
|
||||
return t('A content type can only be deleted when it has no entries.')
|
||||
}
|
||||
return t(
|
||||
"{0} has no entries. Its fields and its API routes will be deleted. This can't be undone.",
|
||||
[props.name],
|
||||
)
|
||||
})
|
||||
const notice = computed(() => {
|
||||
if (conflict.value) {
|
||||
return {
|
||||
title: t('{0} now has entries', [props.name]),
|
||||
description: t(
|
||||
'Someone added an entry after this page loaded, so nothing was deleted. Delete the entries first, then try again.',
|
||||
),
|
||||
}
|
||||
}
|
||||
if (props.stage === 'failed') {
|
||||
return {
|
||||
title: t("Couldn't delete the content type"),
|
||||
description: t(
|
||||
'Something went wrong on our side. Nothing was changed. Try again in a moment.',
|
||||
),
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
function onConfirm() {
|
||||
if (informational.value) {
|
||||
emit('viewEntries')
|
||||
} else {
|
||||
emit('confirm')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryDialog
|
||||
:title="title"
|
||||
:description="description"
|
||||
:confirm-label="informational ? t('View entries') : t('Delete content type')"
|
||||
:pending-label="t('Deleting…')"
|
||||
:cancel-label="informational ? t('Close') : t('Cancel')"
|
||||
:tone="informational ? 'neutral' : 'danger'"
|
||||
:pending="pending"
|
||||
:confirm-disabled="offline && !informational"
|
||||
:notice="notice"
|
||||
closable
|
||||
close-gutter
|
||||
@confirm="onConfirm"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<UAlert
|
||||
v-if="blocked"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
icon="i-lucide-triangle-alert"
|
||||
:title="entriesCountLine(name, total)"
|
||||
:description="t('Delete all of its entries first, then delete the content type.')"
|
||||
:ui="{
|
||||
root: 'items-start gap-2.5 px-3.5 py-3',
|
||||
title: 'font-semibold',
|
||||
description: 'text-toned',
|
||||
}"
|
||||
/>
|
||||
<p v-else-if="confirming" class="text-toned">
|
||||
{{ t('Key') }}: <span class="font-mono text-xs">{{ keyValue }}</span>
|
||||
</p>
|
||||
</DirectoryDialog>
|
||||
</template>
|
||||
@@ -18,6 +18,7 @@ const props = withDefaults(
|
||||
closable?: boolean
|
||||
confirmDisabled?: boolean
|
||||
wide?: boolean
|
||||
closeGutter?: boolean
|
||||
}>(),
|
||||
{
|
||||
cancelLabel: '',
|
||||
@@ -27,6 +28,7 @@ const props = withDefaults(
|
||||
closable: false,
|
||||
confirmDisabled: false,
|
||||
wide: false,
|
||||
closeGutter: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -51,6 +53,15 @@ watch(
|
||||
)
|
||||
|
||||
const confirmText = computed(() => (props.pending ? props.pendingLabel : props.confirmLabel))
|
||||
const headerClass = computed(() => {
|
||||
if (props.wide && props.closable) {
|
||||
return 'min-h-0 px-6 pe-20 pt-5 pb-0 sm:px-6 sm:pe-16'
|
||||
}
|
||||
if (props.closeGutter && props.closable) {
|
||||
return 'min-h-0 px-6 pe-14 pt-5 pb-0 sm:px-6 sm:pe-10'
|
||||
}
|
||||
return 'min-h-0 px-6 pt-5 pb-0 sm:px-6'
|
||||
})
|
||||
const closeButton = computed(() =>
|
||||
props.closable && !props.pending
|
||||
? { class: 'size-7 justify-center text-muted max-md:size-11' }
|
||||
@@ -86,10 +97,7 @@ function onCancel() {
|
||||
}"
|
||||
:ui="{
|
||||
content: contentClass,
|
||||
header:
|
||||
props.wide && props.closable
|
||||
? 'min-h-0 px-6 pe-20 pt-5 pb-0 sm:px-6 sm:pe-16'
|
||||
: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
|
||||
header: headerClass,
|
||||
title: 'text-lg/6 font-semibold',
|
||||
description: 'mt-1 text-sm text-muted text-pretty',
|
||||
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import ApiPreview from '@/components/ApiPreview.vue'
|
||||
@@ -9,7 +9,9 @@ import ApiPreviewSheet from '@/components/ApiPreviewSheet.vue'
|
||||
import BuilderActions from '@/components/BuilderActions.vue'
|
||||
import BuilderBadge from '@/components/BuilderBadge.vue'
|
||||
import BuilderInspector from '@/components/BuilderInspector.vue'
|
||||
import BuilderMoreMenu from '@/components/BuilderMoreMenu.vue'
|
||||
import BuilderTypeBar from '@/components/BuilderTypeBar.vue'
|
||||
import DeleteTypeDialog from '@/components/DeleteTypeDialog.vue'
|
||||
import FieldList from '@/components/FieldList.vue'
|
||||
import FieldSheet from '@/components/FieldSheet.vue'
|
||||
import RetryAlert from '@/components/RetryAlert.vue'
|
||||
@@ -17,10 +19,12 @@ import SchemaForbidden from '@/components/SchemaForbidden.vue'
|
||||
import SchemaRefusalDialog from '@/components/SchemaRefusalDialog.vue'
|
||||
import SchemaReviewDialog from '@/components/SchemaReviewDialog.vue'
|
||||
import { useSchemaBuilder, type EditSource } from '@/composables/useSchemaBuilder'
|
||||
import { useSchemaDelete } from '@/composables/useSchemaDelete'
|
||||
import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard'
|
||||
import { HOME_PATH } from '@/lib/routePaths'
|
||||
import { sampleJson } from '@/lib/schemaBuilderSample'
|
||||
import { entriesNotice } from '@/lib/schemaEntries'
|
||||
import { TOPBAR_ACTION_ID } from '@/lib/uiSlots'
|
||||
import { formatClock } from '@/lib/updateFormat'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
@@ -31,9 +35,15 @@ const router = useRouter()
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const builder = useSchemaBuilder(props.edit)
|
||||
const { form } = builder
|
||||
const remove = props.edit ? useSchemaDelete(() => props.edit!) : undefined
|
||||
const moreMenu = ref<InstanceType<typeof BuilderMoreMenu> | null>(null)
|
||||
const moreDisabled = computed(() => builder.saving.value || Boolean(remove?.pending.value))
|
||||
const forbidden = computed(
|
||||
() => builder.failure.value === 'forbidden' || Boolean(remove?.forbidden.value),
|
||||
)
|
||||
|
||||
useUnsavedChangesGuard({
|
||||
isDirty: () => builder.dirty.value,
|
||||
isDirty: () => builder.dirty.value && !remove?.done.value,
|
||||
title: () => form.value.name.trim() || t('New content type'),
|
||||
})
|
||||
|
||||
@@ -63,6 +73,16 @@ const selectedName = computed(() => builder.selected.value?.label.trim() || t('U
|
||||
const sheetTitle = computed(() => t('Field: {0}', [selectedName.value]))
|
||||
const reviewName = computed(() => builder.baseline.value?.name ?? '')
|
||||
|
||||
watch(
|
||||
() => remove?.stage.value,
|
||||
async (stage) => {
|
||||
if (stage === 'closed') {
|
||||
await nextTick()
|
||||
moreMenu.value?.focus()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function goHome() {
|
||||
router.push(HOME_PATH)
|
||||
}
|
||||
@@ -106,7 +126,7 @@ async function restoreRowFocus() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SchemaForbidden v-if="builder.failure.value === 'forbidden'" @back="goHome" />
|
||||
<SchemaForbidden v-if="forbidden" @back="goHome" />
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
@@ -139,7 +159,14 @@ async function restoreRowFocus() {
|
||||
:saved-at="savedClock"
|
||||
@save="builder.save"
|
||||
@discard="builder.discard"
|
||||
/>
|
||||
>
|
||||
<BuilderMoreMenu
|
||||
v-if="remove"
|
||||
ref="moreMenu"
|
||||
:disabled="moreDisabled"
|
||||
@delete="remove.open"
|
||||
/>
|
||||
</BuilderActions>
|
||||
</template>
|
||||
</BuilderTypeBar>
|
||||
</div>
|
||||
@@ -284,6 +311,9 @@ async function restoreRowFocus() {
|
||||
</div>
|
||||
|
||||
<template v-if="!isDesktop">
|
||||
<Teleport v-if="remove" :to="`#${TOPBAR_ACTION_ID}`" defer>
|
||||
<BuilderMoreMenu ref="moreMenu" :disabled="moreDisabled" @delete="remove.open" />
|
||||
</Teleport>
|
||||
<ApiPreviewSheet v-model:open="apiSheetOpen" :key-value="form.key" :json="json" />
|
||||
<FieldSheet
|
||||
v-model:open="fieldSheetOpen"
|
||||
@@ -316,6 +346,18 @@ async function restoreRowFocus() {
|
||||
@apply="builder.apply"
|
||||
@close="builder.closeReview"
|
||||
/>
|
||||
<DeleteTypeDialog
|
||||
v-if="remove && remove.stage.value !== 'closed'"
|
||||
:stage="remove.stage.value"
|
||||
:name="remove.type.value.name"
|
||||
:key-value="remove.type.value.key"
|
||||
:total="remove.total.value"
|
||||
:pending="remove.pending.value"
|
||||
:offline="remove.offline.value"
|
||||
@confirm="remove.confirm"
|
||||
@close="remove.close"
|
||||
@view-entries="remove.viewEntries"
|
||||
/>
|
||||
<SchemaRefusalDialog
|
||||
v-if="builder.refusal.value === 'dialog'"
|
||||
:causes="refusalCauses"
|
||||
|
||||
Reference in new issue
Block a user