diff --git a/web/src/components/SchemaBuilderPage.vue b/web/src/components/SchemaBuilderPage.vue
index 6c5e395..e3cc411 100644
--- a/web/src/components/SchemaBuilderPage.vue
+++ b/web/src/components/SchemaBuilderPage.vue
@@ -1,7 +1,7 @@
+
+
+
+
+
+
+
+
diff --git a/web/src/composables/useSchemaBuilder.ts b/web/src/composables/useSchemaBuilder.ts
index c4e2ca6..5992d34 100644
--- a/web/src/composables/useSchemaBuilder.ts
+++ b/web/src/composables/useSchemaBuilder.ts
@@ -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(source ? formFromType(source.type, newUid) : blankForm(newUid()))
const removed = ref([])
- const reviewOpen = ref(false)
- const refusal = ref('none')
- const refusedUids = ref([])
- const refusedChanges = ref([])
- const savedAt = ref('')
const nameServerError = ref('')
let initialSignature = signature(form.value)
const selectedUid = ref(form.value.fields[0]!.uid)
const touched = ref(new Set())
const submitted = ref(false)
- const saveState = ref('editing')
- const failure = ref('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,
diff --git a/web/src/composables/useSchemaSave.ts b/web/src/composables/useSchemaSave.ts
new file mode 100644
index 0000000..dcafeac
--- /dev/null
+++ b/web/src/composables/useSchemaSave.ts
@@ -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
+ baseline: Ref
+ selectedUid: Ref
+ submitted: Ref
+ keyServerError: Ref
+ nameServerError: Ref
+ changes: Readonly[>
+ needsReview: Readonly][>
+ 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('editing')
+ const failure = ref('none')
+ const reviewOpen = ref(false)
+ const refusal = ref('none')
+ const refusedUids = ref([])
+ const refusedChanges = ref([])
+ 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,
+ }
+}
]