refac: split useSchemaBuilder & SchemaDeleteControls

This commit is contained in:
Florian Sylvain committed 2026-10-20 18:12:29 +02:00
1 parent 5404291af2
commit b4cbcb0395
4 files changed
+354 -240

No files matched your search

+6 -33
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useMediaQuery } from '@vueuse/core'
import ApiPreview from '@/components/ApiPreview.vue'
@@ -9,13 +9,12 @@ 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'
import SchemaForbidden from '@/components/SchemaForbidden.vue'
import SchemaDeleteControls from '@/components/SchemaDeleteControls.vue'
import SchemaRefusalDialog from '@/components/SchemaRefusalDialog.vue'
import SchemaReviewDialog from '@/components/SchemaReviewDialog.vue'
import { useSchemaBuilder, type EditSource } from '@/composables/useSchemaBuilder'
@@ -24,7 +23,6 @@ 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)'
@@ -36,7 +34,6 @@ 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),
@@ -73,16 +70,6 @@ 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)
}
@@ -160,11 +147,11 @@ async function restoreRowFocus() {
@save="builder.save"
@discard="builder.discard"
>
<BuilderMoreMenu
<SchemaDeleteControls
v-if="remove"
ref="moreMenu"
:remove="remove"
:disabled="moreDisabled"
@delete="remove.open"
:mobile="false"
/>
</BuilderActions>
</template>
@@ -311,9 +298,7 @@ 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>
<SchemaDeleteControls v-if="remove" :remove="remove" :disabled="moreDisabled" mobile />
<ApiPreviewSheet v-model:open="apiSheetOpen" :key-value="form.key" :json="json" />
<FieldSheet
v-model:open="fieldSheetOpen"
@@ -346,18 +331,6 @@ 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"
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import BuilderMoreMenu from '@/components/BuilderMoreMenu.vue'
import DeleteTypeDialog from '@/components/DeleteTypeDialog.vue'
import type { useSchemaDelete } from '@/composables/useSchemaDelete'
import { TOPBAR_ACTION_ID } from '@/lib/uiSlots'
const props = defineProps<{
remove: ReturnType<typeof useSchemaDelete>
disabled: boolean
mobile: boolean
}>()
const moreMenu = ref<InstanceType<typeof BuilderMoreMenu> | null>(null)
watch(
() => props.remove.stage.value,
async (stage) => {
if (stage === 'closed') {
await nextTick()
moreMenu.value?.focus()
}
},
)
</script>
<template>
<Teleport v-if="mobile" :to="`#${TOPBAR_ACTION_ID}`" defer>
<BuilderMoreMenu ref="moreMenu" :disabled="disabled" @delete="remove.open" />
</Teleport>
<BuilderMoreMenu v-else ref="moreMenu" :disabled="disabled" @delete="remove.open" />
<DeleteTypeDialog
v-if="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"
/>
</template>
+36 -207
View File
@@ -1,12 +1,8 @@
import { computed, ref } from 'vue'
import { useOnline } from '@vueuse/core'
import { useRouter } from 'vue-router'
import { t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { ApiError } from '@/composables/useApi'
import { useContentTypes, type ContentType } from '@/composables/useContentTypes'
import type { ContentType } from '@/composables/useContentTypes'
import { useSchemaSave } from '@/composables/useSchemaSave'
import { moveItem } from '@/lib/arrayMove'
import { SCHEMA_PATH } from '@/lib/routePaths'
import { deriveFieldKey, deriveTypeKey } from '@/lib/schemaBuilderKeys'
import {
BUILDER_LIMITS,
@@ -24,10 +20,8 @@ import {
diffFields,
flaggedChanges,
isFieldChanged,
type FieldChange,
} from '@/lib/schemaBuilderDiff'
import { baselinePayload, formFromType } from '@/lib/schemaBuilderLoad'
import { buildPayload, buildUpdatePayload } from '@/lib/schemaBuilderPayload'
import { reservedKeys, restoreIndex, type RemovedField } from '@/lib/schemaBuilderRemoval'
import { FIELD_ERROR_TOUCH, fieldTouchKey, typeTouchKey } from '@/lib/schemaBuilderTouch'
import type { FieldTouch, TypeTouch } from '@/lib/schemaBuilderTouch'
@@ -38,17 +32,8 @@ import {
type FieldErrors,
type FormErrors,
} from '@/lib/schemaBuilderValidation'
import { useContentTypesStore } from '@/stores/contentTypes'
const KEY_TAKEN_BODY = 'content_type_key_taken'
const INVALID_KEY_BODY = 'invalid_content_type_key'
const INVALID_FIELD_BODY = 'invalid_field'
const INCOMPATIBLE_BODY = 'schema_incompatible'
const TOAST_ID = 'content-type-saved'
export type SaveFailure = 'none' | 'failed' | 'invalid' | 'forbidden'
export type SaveState = 'editing' | 'saving' | 'saved' | 'failed'
export type RefusalState = 'none' | 'dialog' | 'banner'
export type { SaveFailure, SaveState, RefusalState } from '@/composables/useSchemaSave'
export interface EditSource {
type: ContentType
@@ -73,9 +58,6 @@ function signature(form: BuilderForm) {
}
export function useSchemaBuilder(source?: EditSource) {
const router = useRouter()
const store = useContentTypesStore()
const toast = useLocalizedToast()
const online = useOnline()
let nextUid = 1
const newUid = () => nextUid++
@@ -85,23 +67,14 @@ export function useSchemaBuilder(source?: EditSource) {
const entryTotal = source?.total ?? 0
const form = ref<BuilderForm>(source ? formFromType(source.type, newUid) : blankForm(newUid()))
const removed = ref<RemovedField[]>([])
const reviewOpen = ref(false)
const refusal = ref<RefusalState>('none')
const refusedUids = ref<number[]>([])
const refusedChanges = ref<FieldChange[]>([])
const savedAt = ref('')
const nameServerError = ref('')
let initialSignature = signature(form.value)
const selectedUid = ref<number | null>(form.value.fields[0]!.uid)
const touched = ref(new Set<string>())
const submitted = ref(false)
const saveState = ref<SaveState>('editing')
const failure = ref<SaveFailure>('none')
const keyServerError = ref('')
const keyFocusToken = ref(0)
const labelFocusToken = ref(0)
const saving = computed(() => saveState.value === 'saving')
const dirty = computed(
() => saveState.value !== 'saved' && signature(form.value) !== initialSignature,
)
@@ -111,6 +84,38 @@ export function useSchemaBuilder(source?: EditSource) {
const savedSignatures = computed(() => baselineSignatures(baselineFields.value))
const changes = computed(() => diffFields(baselineFields.value, form.value.fields))
const needsReview = computed(() => editing && entryTotal > 0 && changes.value.length > 0)
const {
saveState,
saving,
failure,
reviewOpen,
refusal,
refusedUids,
refusedChanges,
savedAt,
keyFocusToken,
resetSaveOutcome,
save,
apply,
reviewAgain,
closeReview,
keepEditing,
} = useSchemaSave({
editing,
form,
baseline,
selectedUid,
submitted,
keyServerError,
nameServerError,
changes,
needsReview,
canSave: () => canSave.value,
canReveal: () => canReveal.value,
newUid,
touch,
resetEditState,
})
const flaggedUids = computed(
() => new Set(flaggedChanges(changes.value).map((change) => change.uid)),
)
@@ -442,12 +447,8 @@ export function useSchemaBuilder(source?: EditSource) {
submitted.value = false
keyServerError.value = ''
nameServerError.value = ''
failure.value = 'none'
removed.value = []
reviewOpen.value = false
refusal.value = 'none'
refusedUids.value = []
refusedChanges.value = []
resetSaveOutcome()
}
function discard() {
@@ -456,178 +457,6 @@ export function useSchemaBuilder(source?: EditSource) {
saveState.value = 'editing'
}
function onKeyTaken() {
form.value.keyMode = 'manual'
keyServerError.value = t(
'The key "{0}" is already used by another content type. Choose a different key.',
[form.value.key],
)
keyFocusToken.value++
}
function onInvalidKey() {
form.value.keyMode = 'manual'
keyServerError.value = t('Use 2 to 40 lowercase letters, digits or hyphens.')
keyFocusToken.value++
}
function onIncompatible() {
const flagged = flaggedChanges(changes.value)
refusedChanges.value = flagged
refusedUids.value = flagged.flatMap((change) => (change.uid === null ? [] : [change.uid]))
refusal.value = 'dialog'
saveState.value = 'editing'
}
function onInvalidName() {
nameServerError.value = t('Give the content type a name.')
touch('name')
}
function onMissing() {
store.drop(form.value.key)
}
function onRefused(error: unknown) {
reviewOpen.value = false
if (error instanceof ApiError && error.status === 401) {
return
}
const body = error instanceof ApiError ? error.body : undefined
if (
editing &&
error instanceof ApiError &&
error.status === 409 &&
body === INCOMPATIBLE_BODY
) {
onIncompatible()
} else if (editing && error instanceof ApiError && error.status === 404) {
onMissing()
} else if (error instanceof ApiError && error.status === 409 && body === KEY_TAKEN_BODY) {
onKeyTaken()
} else if (error instanceof ApiError && error.status === 400 && body === INVALID_KEY_BODY) {
onInvalidKey()
} else if (editing && error instanceof ApiError && error.status === 400) {
saveState.value = 'editing'
onInvalidName()
} else if (
error instanceof ApiError &&
error.status === 422 &&
body === INVALID_FIELD_BODY
) {
failure.value = 'invalid'
} else if (error instanceof ApiError && error.status === 403) {
failure.value = 'forbidden'
} else {
failure.value = 'failed'
}
}
function announceSaved(name: string, count: number) {
toast.add(() => ({
id: TOAST_ID,
color: 'success',
icon: 'i-lucide-circle-check',
title: t('Content type saved'),
description:
count === 1
? t('{0} now has 1 field.', [name])
: t('{0} now has {1} fields.', [name, count]),
}))
}
async function save() {
if (!canSave.value && !canReveal.value) {
return
}
submitted.value = true
if (!canSave.value) {
return
}
if (needsReview.value) {
reviewOpen.value = true
return
}
await apply()
}
function reviewAgain() {
if (needsReview.value) {
reviewOpen.value = true
return
}
refusal.value = 'none'
}
function closeReview() {
if (!saving.value) {
reviewOpen.value = false
}
}
function keepEditing() {
if (refusal.value === 'dialog') {
refusal.value = 'banner'
}
}
function settleSaved(answered: ContentType) {
store.replace(answered)
baseline.value = answered
form.value = formFromType(
answered,
newUid,
form.value.fields.map((field) => field.uid),
)
const keep = selectedUid.value
resetEditState()
if (keep !== null && form.value.fields.some((field) => field.uid === keep)) {
selectedUid.value = keep
}
savedAt.value = answered.updated_at
saveState.value = 'saved'
announceSaved(answered.name, answered.fields.length)
}
async function apply() {
if (saving.value) {
return
}
failure.value = 'none'
saveState.value = 'saving'
if (!baseline.value) {
await create()
return
}
let answered: ContentType
try {
answered = await useContentTypes().updateContentType(
baseline.value.key,
buildUpdatePayload(form.value, baseline.value.icon),
)
} catch (error) {
saveState.value = 'failed'
onRefused(error)
return
}
settleSaved(answered)
}
async function create() {
let created: ContentType
try {
created = await useContentTypes().createContentType(buildPayload(form.value))
} catch (error) {
saveState.value = 'failed'
onRefused(error)
return
}
saveState.value = 'saved'
store.add(created)
announceSaved(created.name, created.fields.length)
await router.replace(SCHEMA_PATH).catch(() => undefined)
}
return {
form,
selectedUid,
+268
View File
@@ -0,0 +1,268 @@
import { computed, ref, type Ref } from 'vue'
import { useRouter } from 'vue-router'
import { t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { ApiError } from '@/composables/useApi'
import { useContentTypes, type ContentType } from '@/composables/useContentTypes'
import { SCHEMA_PATH } from '@/lib/routePaths'
import type { BuilderForm } from '@/lib/schemaBuilderModel'
import { flaggedChanges, type FieldChange } from '@/lib/schemaBuilderDiff'
import { formFromType } from '@/lib/schemaBuilderLoad'
import { buildPayload, buildUpdatePayload } from '@/lib/schemaBuilderPayload'
import type { TypeTouch } from '@/lib/schemaBuilderTouch'
import { useContentTypesStore } from '@/stores/contentTypes'
const KEY_TAKEN_BODY = 'content_type_key_taken'
const INVALID_KEY_BODY = 'invalid_content_type_key'
const INVALID_FIELD_BODY = 'invalid_field'
const INCOMPATIBLE_BODY = 'schema_incompatible'
const TOAST_ID = 'content-type-saved'
export type SaveFailure = 'none' | 'failed' | 'invalid' | 'forbidden'
export type SaveState = 'editing' | 'saving' | 'saved' | 'failed'
export type RefusalState = 'none' | 'dialog' | 'banner'
export interface SchemaSaveContext {
editing: boolean
form: Ref<BuilderForm>
baseline: Ref<ContentType | null>
selectedUid: Ref<number | null>
submitted: Ref<boolean>
keyServerError: Ref<string>
nameServerError: Ref<string>
changes: Readonly<Ref<FieldChange[]>>
needsReview: Readonly<Ref<boolean>>
canSave: () => boolean
canReveal: () => boolean
newUid: () => number
touch: (name: TypeTouch) => void
resetEditState: () => void
}
export function useSchemaSave(context: SchemaSaveContext) {
const router = useRouter()
const store = useContentTypesStore()
const toast = useLocalizedToast()
const {
editing,
form,
baseline,
selectedUid,
submitted,
keyServerError,
nameServerError,
changes,
needsReview,
newUid,
touch,
} = context
const saveState = ref<SaveState>('editing')
const failure = ref<SaveFailure>('none')
const reviewOpen = ref(false)
const refusal = ref<RefusalState>('none')
const refusedUids = ref<number[]>([])
const refusedChanges = ref<FieldChange[]>([])
const savedAt = ref('')
const keyFocusToken = ref(0)
const saving = computed(() => saveState.value === 'saving')
function resetSaveOutcome() {
failure.value = 'none'
reviewOpen.value = false
refusal.value = 'none'
refusedUids.value = []
refusedChanges.value = []
}
function onKeyTaken() {
form.value.keyMode = 'manual'
keyServerError.value = t(
'The key "{0}" is already used by another content type. Choose a different key.',
[form.value.key],
)
keyFocusToken.value++
}
function onInvalidKey() {
form.value.keyMode = 'manual'
keyServerError.value = t('Use 2 to 40 lowercase letters, digits or hyphens.')
keyFocusToken.value++
}
function onIncompatible() {
const flagged = flaggedChanges(changes.value)
refusedChanges.value = flagged
refusedUids.value = flagged.flatMap((change) => (change.uid === null ? [] : [change.uid]))
refusal.value = 'dialog'
saveState.value = 'editing'
}
function onInvalidName() {
nameServerError.value = t('Give the content type a name.')
touch('name')
}
function onMissing() {
store.drop(form.value.key)
}
function onRefused(error: unknown) {
reviewOpen.value = false
if (error instanceof ApiError && error.status === 401) {
return
}
const body = error instanceof ApiError ? error.body : undefined
if (
editing &&
error instanceof ApiError &&
error.status === 409 &&
body === INCOMPATIBLE_BODY
) {
onIncompatible()
} else if (editing && error instanceof ApiError && error.status === 404) {
onMissing()
} else if (error instanceof ApiError && error.status === 409 && body === KEY_TAKEN_BODY) {
onKeyTaken()
} else if (error instanceof ApiError && error.status === 400 && body === INVALID_KEY_BODY) {
onInvalidKey()
} else if (editing && error instanceof ApiError && error.status === 400) {
saveState.value = 'editing'
onInvalidName()
} else if (
error instanceof ApiError &&
error.status === 422 &&
body === INVALID_FIELD_BODY
) {
failure.value = 'invalid'
} else if (error instanceof ApiError && error.status === 403) {
failure.value = 'forbidden'
} else {
failure.value = 'failed'
}
}
function announceSaved(name: string, count: number) {
toast.add(() => ({
id: TOAST_ID,
color: 'success',
icon: 'i-lucide-circle-check',
title: t('Content type saved'),
description:
count === 1
? t('{0} now has 1 field.', [name])
: t('{0} now has {1} fields.', [name, count]),
}))
}
async function save() {
if (!context.canSave() && !context.canReveal()) {
return
}
submitted.value = true
if (!context.canSave()) {
return
}
if (needsReview.value) {
reviewOpen.value = true
return
}
await apply()
}
function reviewAgain() {
if (needsReview.value) {
reviewOpen.value = true
return
}
refusal.value = 'none'
}
function closeReview() {
if (!saving.value) {
reviewOpen.value = false
}
}
function keepEditing() {
if (refusal.value === 'dialog') {
refusal.value = 'banner'
}
}
function settleSaved(answered: ContentType) {
store.replace(answered)
baseline.value = answered
form.value = formFromType(
answered,
newUid,
form.value.fields.map((field) => field.uid),
)
const keep = selectedUid.value
context.resetEditState()
if (keep !== null && form.value.fields.some((field) => field.uid === keep)) {
selectedUid.value = keep
}
savedAt.value = answered.updated_at
saveState.value = 'saved'
announceSaved(answered.name, answered.fields.length)
}
async function apply() {
if (saving.value) {
return
}
failure.value = 'none'
saveState.value = 'saving'
if (!baseline.value) {
await create()
return
}
let answered: ContentType
try {
answered = await useContentTypes().updateContentType(
baseline.value.key,
buildUpdatePayload(form.value, baseline.value.icon),
)
} catch (error) {
saveState.value = 'failed'
onRefused(error)
return
}
settleSaved(answered)
}
async function create() {
let created: ContentType
try {
created = await useContentTypes().createContentType(buildPayload(form.value))
} catch (error) {
saveState.value = 'failed'
onRefused(error)
return
}
saveState.value = 'saved'
store.add(created)
announceSaved(created.name, created.fields.length)
await router.replace(SCHEMA_PATH).catch(() => undefined)
}
return {
saveState,
saving,
failure,
reviewOpen,
refusal,
refusedUids,
refusedChanges,
savedAt,
keyFocusToken,
resetSaveOutcome,
save,
apply,
reviewAgain,
closeReview,
keepEditing,
}
}