refac: split useSchemaBuilder & SchemaDeleteControls
This commit is contained in:
1 parent
5404291af2
commit
b4cbcb0395
4 files changed
+354
-240
No files matched your search
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
import { computed, nextTick, ref, watch } from 'vue'
|
import { computed, nextTick, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useMediaQuery } from '@vueuse/core'
|
import { useMediaQuery } from '@vueuse/core'
|
||||||
import ApiPreview from '@/components/ApiPreview.vue'
|
import ApiPreview from '@/components/ApiPreview.vue'
|
||||||
@@ -9,13 +9,12 @@ import ApiPreviewSheet from '@/components/ApiPreviewSheet.vue'
|
|||||||
import BuilderActions from '@/components/BuilderActions.vue'
|
import BuilderActions from '@/components/BuilderActions.vue'
|
||||||
import BuilderBadge from '@/components/BuilderBadge.vue'
|
import BuilderBadge from '@/components/BuilderBadge.vue'
|
||||||
import BuilderInspector from '@/components/BuilderInspector.vue'
|
import BuilderInspector from '@/components/BuilderInspector.vue'
|
||||||
import BuilderMoreMenu from '@/components/BuilderMoreMenu.vue'
|
|
||||||
import BuilderTypeBar from '@/components/BuilderTypeBar.vue'
|
import BuilderTypeBar from '@/components/BuilderTypeBar.vue'
|
||||||
import DeleteTypeDialog from '@/components/DeleteTypeDialog.vue'
|
|
||||||
import FieldList from '@/components/FieldList.vue'
|
import FieldList from '@/components/FieldList.vue'
|
||||||
import FieldSheet from '@/components/FieldSheet.vue'
|
import FieldSheet from '@/components/FieldSheet.vue'
|
||||||
import RetryAlert from '@/components/RetryAlert.vue'
|
import RetryAlert from '@/components/RetryAlert.vue'
|
||||||
import SchemaForbidden from '@/components/SchemaForbidden.vue'
|
import SchemaForbidden from '@/components/SchemaForbidden.vue'
|
||||||
|
import SchemaDeleteControls from '@/components/SchemaDeleteControls.vue'
|
||||||
import SchemaRefusalDialog from '@/components/SchemaRefusalDialog.vue'
|
import SchemaRefusalDialog from '@/components/SchemaRefusalDialog.vue'
|
||||||
import SchemaReviewDialog from '@/components/SchemaReviewDialog.vue'
|
import SchemaReviewDialog from '@/components/SchemaReviewDialog.vue'
|
||||||
import { useSchemaBuilder, type EditSource } from '@/composables/useSchemaBuilder'
|
import { useSchemaBuilder, type EditSource } from '@/composables/useSchemaBuilder'
|
||||||
@@ -24,7 +23,6 @@ import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard'
|
|||||||
import { HOME_PATH } from '@/lib/routePaths'
|
import { HOME_PATH } from '@/lib/routePaths'
|
||||||
import { sampleJson } from '@/lib/schemaBuilderSample'
|
import { sampleJson } from '@/lib/schemaBuilderSample'
|
||||||
import { entriesNotice } from '@/lib/schemaEntries'
|
import { entriesNotice } from '@/lib/schemaEntries'
|
||||||
import { TOPBAR_ACTION_ID } from '@/lib/uiSlots'
|
|
||||||
import { formatClock } from '@/lib/updateFormat'
|
import { formatClock } from '@/lib/updateFormat'
|
||||||
|
|
||||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||||
@@ -36,7 +34,6 @@ const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
|||||||
const builder = useSchemaBuilder(props.edit)
|
const builder = useSchemaBuilder(props.edit)
|
||||||
const { form } = builder
|
const { form } = builder
|
||||||
const remove = props.edit ? useSchemaDelete(() => props.edit!) : undefined
|
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 moreDisabled = computed(() => builder.saving.value || Boolean(remove?.pending.value))
|
||||||
const forbidden = computed(
|
const forbidden = computed(
|
||||||
() => builder.failure.value === 'forbidden' || Boolean(remove?.forbidden.value),
|
() => 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 sheetTitle = computed(() => t('Field: {0}', [selectedName.value]))
|
||||||
const reviewName = computed(() => builder.baseline.value?.name ?? '')
|
const reviewName = computed(() => builder.baseline.value?.name ?? '')
|
||||||
|
|
||||||
watch(
|
|
||||||
() => remove?.stage.value,
|
|
||||||
async (stage) => {
|
|
||||||
if (stage === 'closed') {
|
|
||||||
await nextTick()
|
|
||||||
moreMenu.value?.focus()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
function goHome() {
|
function goHome() {
|
||||||
router.push(HOME_PATH)
|
router.push(HOME_PATH)
|
||||||
}
|
}
|
||||||
@@ -160,11 +147,11 @@ async function restoreRowFocus() {
|
|||||||
@save="builder.save"
|
@save="builder.save"
|
||||||
@discard="builder.discard"
|
@discard="builder.discard"
|
||||||
>
|
>
|
||||||
<BuilderMoreMenu
|
<SchemaDeleteControls
|
||||||
v-if="remove"
|
v-if="remove"
|
||||||
ref="moreMenu"
|
:remove="remove"
|
||||||
:disabled="moreDisabled"
|
:disabled="moreDisabled"
|
||||||
@delete="remove.open"
|
:mobile="false"
|
||||||
/>
|
/>
|
||||||
</BuilderActions>
|
</BuilderActions>
|
||||||
</template>
|
</template>
|
||||||
@@ -311,9 +298,7 @@ async function restoreRowFocus() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="!isDesktop">
|
<template v-if="!isDesktop">
|
||||||
<Teleport v-if="remove" :to="`#${TOPBAR_ACTION_ID}`" defer>
|
<SchemaDeleteControls v-if="remove" :remove="remove" :disabled="moreDisabled" mobile />
|
||||||
<BuilderMoreMenu ref="moreMenu" :disabled="moreDisabled" @delete="remove.open" />
|
|
||||||
</Teleport>
|
|
||||||
<ApiPreviewSheet v-model:open="apiSheetOpen" :key-value="form.key" :json="json" />
|
<ApiPreviewSheet v-model:open="apiSheetOpen" :key-value="form.key" :json="json" />
|
||||||
<FieldSheet
|
<FieldSheet
|
||||||
v-model:open="fieldSheetOpen"
|
v-model:open="fieldSheetOpen"
|
||||||
@@ -346,18 +331,6 @@ async function restoreRowFocus() {
|
|||||||
@apply="builder.apply"
|
@apply="builder.apply"
|
||||||
@close="builder.closeReview"
|
@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
|
<SchemaRefusalDialog
|
||||||
v-if="builder.refusal.value === 'dialog'"
|
v-if="builder.refusal.value === 'dialog'"
|
||||||
:causes="refusalCauses"
|
: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>
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useOnline } from '@vueuse/core'
|
import { useOnline } from '@vueuse/core'
|
||||||
import { useRouter } from 'vue-router'
|
import type { ContentType } from '@/composables/useContentTypes'
|
||||||
import { t } from '@/i18n'
|
import { useSchemaSave } from '@/composables/useSchemaSave'
|
||||||
import { useLocalizedToast } from '@/i18n/presentation'
|
|
||||||
import { ApiError } from '@/composables/useApi'
|
|
||||||
import { useContentTypes, type ContentType } from '@/composables/useContentTypes'
|
|
||||||
import { moveItem } from '@/lib/arrayMove'
|
import { moveItem } from '@/lib/arrayMove'
|
||||||
import { SCHEMA_PATH } from '@/lib/routePaths'
|
|
||||||
import { deriveFieldKey, deriveTypeKey } from '@/lib/schemaBuilderKeys'
|
import { deriveFieldKey, deriveTypeKey } from '@/lib/schemaBuilderKeys'
|
||||||
import {
|
import {
|
||||||
BUILDER_LIMITS,
|
BUILDER_LIMITS,
|
||||||
@@ -24,10 +20,8 @@ import {
|
|||||||
diffFields,
|
diffFields,
|
||||||
flaggedChanges,
|
flaggedChanges,
|
||||||
isFieldChanged,
|
isFieldChanged,
|
||||||
type FieldChange,
|
|
||||||
} from '@/lib/schemaBuilderDiff'
|
} from '@/lib/schemaBuilderDiff'
|
||||||
import { baselinePayload, formFromType } from '@/lib/schemaBuilderLoad'
|
import { baselinePayload, formFromType } from '@/lib/schemaBuilderLoad'
|
||||||
import { buildPayload, buildUpdatePayload } from '@/lib/schemaBuilderPayload'
|
|
||||||
import { reservedKeys, restoreIndex, type RemovedField } from '@/lib/schemaBuilderRemoval'
|
import { reservedKeys, restoreIndex, type RemovedField } from '@/lib/schemaBuilderRemoval'
|
||||||
import { FIELD_ERROR_TOUCH, fieldTouchKey, typeTouchKey } from '@/lib/schemaBuilderTouch'
|
import { FIELD_ERROR_TOUCH, fieldTouchKey, typeTouchKey } from '@/lib/schemaBuilderTouch'
|
||||||
import type { FieldTouch, TypeTouch } from '@/lib/schemaBuilderTouch'
|
import type { FieldTouch, TypeTouch } from '@/lib/schemaBuilderTouch'
|
||||||
@@ -38,17 +32,8 @@ import {
|
|||||||
type FieldErrors,
|
type FieldErrors,
|
||||||
type FormErrors,
|
type FormErrors,
|
||||||
} from '@/lib/schemaBuilderValidation'
|
} from '@/lib/schemaBuilderValidation'
|
||||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
|
||||||
|
|
||||||
const KEY_TAKEN_BODY = 'content_type_key_taken'
|
export type { SaveFailure, SaveState, RefusalState } from '@/composables/useSchemaSave'
|
||||||
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 EditSource {
|
export interface EditSource {
|
||||||
type: ContentType
|
type: ContentType
|
||||||
@@ -73,9 +58,6 @@ function signature(form: BuilderForm) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useSchemaBuilder(source?: EditSource) {
|
export function useSchemaBuilder(source?: EditSource) {
|
||||||
const router = useRouter()
|
|
||||||
const store = useContentTypesStore()
|
|
||||||
const toast = useLocalizedToast()
|
|
||||||
const online = useOnline()
|
const online = useOnline()
|
||||||
let nextUid = 1
|
let nextUid = 1
|
||||||
const newUid = () => nextUid++
|
const newUid = () => nextUid++
|
||||||
@@ -85,23 +67,14 @@ export function useSchemaBuilder(source?: EditSource) {
|
|||||||
const entryTotal = source?.total ?? 0
|
const entryTotal = source?.total ?? 0
|
||||||
const form = ref<BuilderForm>(source ? formFromType(source.type, newUid) : blankForm(newUid()))
|
const form = ref<BuilderForm>(source ? formFromType(source.type, newUid) : blankForm(newUid()))
|
||||||
const removed = ref<RemovedField[]>([])
|
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('')
|
const nameServerError = ref('')
|
||||||
let initialSignature = signature(form.value)
|
let initialSignature = signature(form.value)
|
||||||
const selectedUid = ref<number | null>(form.value.fields[0]!.uid)
|
const selectedUid = ref<number | null>(form.value.fields[0]!.uid)
|
||||||
const touched = ref(new Set<string>())
|
const touched = ref(new Set<string>())
|
||||||
const submitted = ref(false)
|
const submitted = ref(false)
|
||||||
const saveState = ref<SaveState>('editing')
|
|
||||||
const failure = ref<SaveFailure>('none')
|
|
||||||
const keyServerError = ref('')
|
const keyServerError = ref('')
|
||||||
const keyFocusToken = ref(0)
|
|
||||||
const labelFocusToken = ref(0)
|
const labelFocusToken = ref(0)
|
||||||
|
|
||||||
const saving = computed(() => saveState.value === 'saving')
|
|
||||||
const dirty = computed(
|
const dirty = computed(
|
||||||
() => saveState.value !== 'saved' && signature(form.value) !== initialSignature,
|
() => saveState.value !== 'saved' && signature(form.value) !== initialSignature,
|
||||||
)
|
)
|
||||||
@@ -111,6 +84,38 @@ export function useSchemaBuilder(source?: EditSource) {
|
|||||||
const savedSignatures = computed(() => baselineSignatures(baselineFields.value))
|
const savedSignatures = computed(() => baselineSignatures(baselineFields.value))
|
||||||
const changes = computed(() => diffFields(baselineFields.value, form.value.fields))
|
const changes = computed(() => diffFields(baselineFields.value, form.value.fields))
|
||||||
const needsReview = computed(() => editing && entryTotal > 0 && changes.value.length > 0)
|
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(
|
const flaggedUids = computed(
|
||||||
() => new Set(flaggedChanges(changes.value).map((change) => change.uid)),
|
() => new Set(flaggedChanges(changes.value).map((change) => change.uid)),
|
||||||
)
|
)
|
||||||
@@ -442,12 +447,8 @@ export function useSchemaBuilder(source?: EditSource) {
|
|||||||
submitted.value = false
|
submitted.value = false
|
||||||
keyServerError.value = ''
|
keyServerError.value = ''
|
||||||
nameServerError.value = ''
|
nameServerError.value = ''
|
||||||
failure.value = 'none'
|
|
||||||
removed.value = []
|
removed.value = []
|
||||||
reviewOpen.value = false
|
resetSaveOutcome()
|
||||||
refusal.value = 'none'
|
|
||||||
refusedUids.value = []
|
|
||||||
refusedChanges.value = []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function discard() {
|
function discard() {
|
||||||
@@ -456,178 +457,6 @@ export function useSchemaBuilder(source?: EditSource) {
|
|||||||
saveState.value = 'editing'
|
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 {
|
return {
|
||||||
form,
|
form,
|
||||||
selectedUid,
|
selectedUid,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user