Files
RenewCMS/web/src/components/SchemaBuilderPage.vue
T

346 lines
11 KiB
Vue

<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useMediaQuery } from '@vueuse/core'
import ApiPreview from '@/components/ApiPreview.vue'
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 BuilderTypeBar from '@/components/BuilderTypeBar.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'
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 { formatClock } from '@/lib/updateFormat'
const DESKTOP_QUERY = '(min-width: 48rem)'
const props = defineProps<{ edit?: EditSource }>()
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 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 && !remove?.done.value,
title: () => form.value.name.trim() || t('New content type'),
})
const apiOpen = ref(false)
const apiSheetOpen = ref(false)
const fieldSheetOpen = ref(false)
const json = computed(() => sampleJson(form.value.key, form.value.fields))
const notice = computed(() => entriesNotice(builder.entryTotal))
const savedClock = computed(() =>
builder.savedAt.value ? formatClock(new Date(builder.savedAt.value)) : '',
)
const removedFields = computed(() => builder.removed.value.map((entry) => entry.field))
const refusalCauses = computed(() => builder.refusedChanges.value)
const selectedIndex = computed(() =>
form.value.fields.findIndex((field) => field.uid === builder.selectedUid.value),
)
const selectedErrors = computed(() =>
builder.selected.value
? (builder.shown.value.fields.get(builder.selected.value.uid) ?? {})
: {},
)
const selectedMarks = computed(() =>
builder.selected.value ? builder.marks.value.get(builder.selected.value.uid) : undefined,
)
const selectedName = computed(() => builder.selected.value?.label.trim() || t('Untitled field'))
const sheetTitle = computed(() => t('Field: {0}', [selectedName.value]))
const reviewName = computed(() => builder.baseline.value?.name ?? '')
function goHome() {
router.push(HOME_PATH)
}
function onSelect(uid: number) {
builder.selectField(uid)
if (!isDesktop.value) {
fieldSheetOpen.value = true
}
}
function onAdd() {
builder.addField()
if (!isDesktop.value) {
fieldSheetOpen.value = true
}
}
function onRemoveSelected() {
const uid = builder.selectedUid.value
if (uid !== null) {
builder.removeField(uid)
}
if (!isDesktop.value) {
fieldSheetOpen.value = false
}
}
function onMoveSelected(delta: number) {
const uid = builder.selectedUid.value
if (uid !== null) {
builder.moveField(uid, delta)
}
}
async function restoreRowFocus() {
await nextTick()
const uid = builder.selectedUid.value
document.querySelector<HTMLElement>(`[data-field-row][data-uid="${uid}"]`)?.focus()
}
</script>
<template>
<SchemaForbidden v-if="forbidden" @back="goHome" />
<template v-else>
<div
class="flex min-h-0 flex-1 flex-col max-md:shrink-0 max-md:basis-auto"
data-testid="schema-builder"
>
<div class="shrink-0 border-b border-default bg-default p-4 md:px-6">
<BuilderTypeBar
:name="form.name"
:key-value="form.key"
:key-mode="form.keyMode"
:key-locked="form.keyLocked"
:name-error="builder.shown.value.type.name"
:key-error="builder.shown.value.type.key"
:disabled="builder.saving.value"
:key-focus-token="builder.keyFocusToken.value"
@update:name="builder.setName"
@update:key="builder.setKey"
@unlock="builder.unlockKey"
@reset="builder.resetKey"
>
<template v-if="isDesktop" #actions>
<BuilderActions
:status="builder.status.value"
:problems="builder.problems.value"
:can-save="builder.canSave.value"
:can-reveal="builder.canReveal.value"
:can-discard="builder.canDiscard.value"
:saving="builder.saving.value"
:saved-at="savedClock"
@save="builder.save"
@discard="builder.discard"
>
<SchemaDeleteControls
v-if="remove"
:remove="remove"
:disabled="moreDisabled"
:mobile="false"
/>
</BuilderActions>
</template>
</BuilderTypeBar>
</div>
<div
v-if="notice"
data-testid="entries-notice"
class="flex shrink-0 items-center gap-2 bg-(--rc-info-soft) px-4 py-2 text-xs/4.5 md:px-6"
>
<UIcon name="i-lucide-info" class="size-3.5 shrink-0 text-info" />
<span class="text-toned">{{ notice }}</span>
</div>
<div
v-if="
builder.failure.value === 'failed' ||
builder.failure.value === 'invalid' ||
builder.refusal.value === 'banner'
"
class="flex flex-col gap-2 px-4 pt-3 md:px-6"
>
<RetryAlert
v-if="builder.failure.value === 'failed'"
:title="t('Couldn\'t save the content type')"
:description="
t(
'Something went wrong on our side. Your changes are still here. Try again in a moment.',
)
"
@retry="builder.save"
/>
<UAlert
v-else-if="builder.failure.value === 'invalid'"
role="alert"
color="error"
variant="soft"
icon="i-lucide-circle-x"
:title="t('Check the fields marked in the list.')"
/>
<RetryAlert
v-if="builder.refusal.value === 'banner'"
:title="t('Nothing was changed')"
:description="
t(
'Some entries don\'t fit the new fields, so the whole change was refused. RenewCMS can\'t tell which entries. The changes that can cause this are marked.',
)
"
:action-label="t('Review changes')"
action-icon=""
@retry="builder.reviewAgain"
/>
</div>
<div class="flex min-h-0 flex-1 flex-col md:flex-row">
<div
class="flex flex-col gap-2 p-4 md:min-h-0 md:w-95 md:shrink-0 md:overflow-y-auto md:border-r md:border-default md:bg-muted"
>
<FieldList
:fields="form.fields"
:selected-uid="builder.selectedUid.value"
:errors="builder.shown.value.fields"
:list-error="builder.shown.value.type.fields"
:disabled="builder.saving.value"
:mobile="!isDesktop"
:marks="builder.marks.value"
:removed="removedFields"
:can-undo="builder.mayRestore.value"
@select="onSelect"
@add="onAdd"
@remove="builder.removeField"
@move="builder.moveField"
@reorder="builder.reorderFields"
@undo="builder.undoRemoval"
>
<button
v-if="!isDesktop"
type="button"
data-testid="api-row"
class="flex min-h-11 items-center gap-2.5 border border-default bg-default px-3 text-highlighted focus-visible:outline-2 focus-visible:outline-primary"
@click="apiSheetOpen = true"
>
<UIcon name="i-lucide-braces" class="size-4" />
<span class="flex-1 text-left font-medium">{{ t('API preview') }}</span>
<UIcon name="i-lucide-chevron-right" class="size-3.5 text-muted" />
</button>
</FieldList>
</div>
<div
v-if="isDesktop"
class="flex min-h-0 min-w-0 flex-1 flex-col gap-3.5 overflow-y-auto bg-default px-6 py-4"
data-testid="inspector-pane"
>
<template v-if="builder.selected.value">
<div class="flex items-center gap-2">
<h2 class="text-md font-semibold text-highlighted">
{{ sheetTitle }}
</h2>
<BuilderBadge v-if="selectedMarks?.isNew" kind="new" />
<BuilderBadge v-if="selectedMarks?.changed" kind="changed" />
</div>
<BuilderInspector
:actions="builder"
:saving="builder.saving.value"
:focus-token="builder.labelFocusToken.value"
:field="builder.selected.value"
:errors="selectedErrors"
:compact="false"
/>
</template>
<template v-else>
<h2 class="text-md font-semibold text-highlighted">
{{ t('Field settings') }}
</h2>
<p class="text-muted">
{{ t('Add a field, then choose its type and rules here.') }}
</p>
</template>
</div>
</div>
<ApiPreview
v-if="isDesktop"
v-model:open="apiOpen"
:key-value="form.key"
:json="json"
/>
<div
v-else
class="sticky bottom-0 mt-auto border-t border-default bg-default px-4 py-3"
>
<BuilderActions
:status="builder.status.value"
:problems="builder.problems.value"
:can-save="builder.canSave.value"
:can-reveal="builder.canReveal.value"
:can-discard="builder.canDiscard.value"
:saving="builder.saving.value"
:saved-at="savedClock"
@save="builder.save"
@discard="builder.discard"
/>
</div>
</div>
<template v-if="!isDesktop">
<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"
:title="sheetTitle"
:first="selectedIndex <= 0"
:last="selectedIndex === form.fields.length - 1"
:disabled="builder.saving.value"
@up="onMoveSelected(-1)"
@down="onMoveSelected(1)"
@remove="onRemoveSelected"
@closed="restoreRowFocus"
>
<BuilderInspector
v-if="builder.selected.value"
:actions="builder"
:saving="builder.saving.value"
:focus-token="builder.labelFocusToken.value"
:field="builder.selected.value"
:errors="selectedErrors"
compact
/>
</FieldSheet>
</template>
<SchemaReviewDialog
v-if="builder.reviewOpen.value"
:name="reviewName"
:total="builder.entryTotal"
:changes="builder.changes.value"
:applying="builder.saving.value"
:offline="!builder.online.value"
@apply="builder.apply"
@close="builder.closeReview"
/>
<SchemaRefusalDialog
v-if="builder.refusal.value === 'dialog'"
:causes="refusalCauses"
@keep-editing="builder.keepEditing"
@discard="builder.discard"
/>
</template>
</template>