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, + } +}