feat: edit an existing custom type

This commit is contained in:
Florian Sylvain committed 2026-10-20 04:42:50 +02:00
1 parent 083cc9e23e
commit 8e344b2500
57 files changed
+5648 -425

No files matched your search

+12 -7
View File
@@ -5,6 +5,8 @@ import { computed } from 'vue'
import { useJsonCopy } from '@/composables/useJsonCopy'
import { previewRoutes } from '@/lib/schemaBuilderSample'
const PANEL_COLUMNS = '1fr 1.4fr'
const props = defineProps<{ keyValue: string; json: string }>()
const open = defineModel<boolean>('open', { default: false })
@@ -55,9 +57,12 @@ const summary = computed(() => `${routes.value[0]!.method} ${routes.value[0]!.pa
</div>
<UCollapsible :open="open" :ui="{ content: 'overflow-hidden' }">
<template #content>
<div class="grid h-62 grid-cols-12 grid-rows-1 gap-4 bg-default px-6 pb-4">
<div class="col-span-5 flex min-h-0 min-w-0 flex-col gap-1.5">
<span class="text-xs text-muted">{{
<div
class="grid h-62 grid-rows-1 gap-4 bg-default px-6 pb-4"
:style="{ gridTemplateColumns: PANEL_COLUMNS }"
>
<div class="flex min-h-0 min-w-0 flex-col gap-1.5">
<span class="text-xs/4.5 text-muted">{{
t('Where entries of this type live')
}}</span>
<ul
@@ -67,7 +72,7 @@ const summary = computed(() => `${routes.value[0]!.method} ${routes.value[0]!.pa
v-for="(route, index) in routes"
:key="index"
data-testid="api-route"
class="truncate font-mono text-xs/5 text-highlighted"
class="truncate font-mono text-xs/4.75 text-highlighted"
>
<span class="inline-block w-14 text-muted">{{ route.method }}</span
>{{ route.path }}
@@ -79,14 +84,14 @@ const summary = computed(() => `${routes.value[0]!.method} ${routes.value[0]!.pa
)
}}</span>
</div>
<div class="col-span-7 flex min-h-0 min-w-0 flex-col gap-1.5">
<span class="text-xs text-muted">{{
<div class="flex min-h-0 min-w-0 flex-col gap-1.5">
<span class="text-xs/4.5 text-muted">{{
t("A sample entry. Values come from each field's type and default.")
}}</span>
<pre
tabindex="0"
data-testid="api-json"
class="m-0 min-h-0 flex-1 overflow-auto border border-default bg-elevated px-3 py-2.5 font-mono text-xs/5 whitespace-pre text-highlighted"
class="m-0 min-h-0 flex-1 overflow-auto border border-default bg-elevated px-3 py-2.5 font-mono text-xs/4.75 whitespace-pre text-highlighted"
>{{ json }}</pre
>
</div>
+3 -1
View File
@@ -11,6 +11,7 @@ defineProps<{
canReveal: boolean
canDiscard: boolean
saving: boolean
savedAt?: string
}>()
defineEmits<{ save: []; discard: [] }>()
@@ -18,7 +19,7 @@ defineEmits<{ save: []; discard: [] }>()
<template>
<div class="flex shrink-0 items-center gap-2 md:gap-3">
<BuilderStatusChip :status="status" :problems="problems" />
<BuilderStatusChip :status="status" :problems="problems" :saved-at="savedAt" />
<span class="flex-1 md:hidden" />
<UButton
color="neutral"
@@ -34,6 +35,7 @@ defineEmits<{ save: []; discard: [] }>()
:aria-disabled="canReveal ? 'true' : undefined"
:loading="saving"
class="h-11 px-4 md:h-8"
:class="saving ? 'disabled:opacity-40' : ''"
@click="$emit('save')"
>
{{ saving ? t('Saving…') : t('Save') }}
+23 -2
View File
@@ -3,7 +3,10 @@ import { t } from '@/i18n'
import { computed } from 'vue'
const props = defineProps<{ kind: 'new' | 'required' | 'unique'; onSand?: boolean }>()
const props = defineProps<{
kind: 'new' | 'changed' | 'cause' | 'required' | 'unique'
onSand?: boolean
}>()
const badges = {
new: {
@@ -12,6 +15,19 @@ const badges = {
},
class: 'border border-(--rc-sand) bg-(--rc-sand) text-(--rc-on-sand)',
},
changed: {
get label() {
return t('Changed')
},
class: 'border border-(--rc-info-soft) bg-(--rc-info-soft) text-info',
},
cause: {
get label() {
return t('Possible cause')
},
icon: 'i-lucide-triangle-alert',
class: 'border border-(--rc-warning-soft) bg-(--rc-warning-soft) text-warning',
},
required: {
get label() {
return t('Required')
@@ -29,6 +45,7 @@ const badges = {
const OUTLINE_ON_SAND = 'border border-(--rc-on-sand) bg-transparent text-(--rc-on-sand)'
const badge = computed(() => badges[props.kind])
const badgeIcon = computed(() => ('icon' in badge.value ? badge.value.icon : undefined))
const badgeClass = computed(() =>
props.onSand && props.kind !== 'new' ? OUTLINE_ON_SAND : badge.value.class,
)
@@ -38,8 +55,12 @@ const badgeClass = computed(() =>
<UBadge
color="neutral"
variant="soft"
:icon="badgeIcon"
:class="badgeClass"
:ui="{ base: 'h-5 shrink-0 px-1.5 py-0 text-xs font-medium whitespace-nowrap' }"
:ui="{
base: 'h-5 shrink-0 gap-1 px-1.5 py-0 text-xs font-medium whitespace-nowrap',
leadingIcon: 'size-3',
}"
>
{{ badge.label }}
</UBadge>
+9 -2
View File
@@ -4,7 +4,11 @@ import { t } from '@/i18n'
import { computed } from 'vue'
import type { BuilderStatus } from '@/lib/schemaBuilderModel'
const props = defineProps<{ status: BuilderStatus; problems: number }>()
const props = defineProps<{
status: BuilderStatus
problems: number
savedAt?: string
}>()
const text = computed(() => {
switch (props.status) {
@@ -12,6 +16,8 @@ const text = computed(() => {
return t('Saving…')
case 'offline':
return t('Waiting for connection')
case 'saved':
return props.savedAt ? t('Saved at {0}', [props.savedAt]) : t('Saved')
case 'failed':
return t('Save failed')
case 'problems':
@@ -33,7 +39,7 @@ const text = computed(() => {
:class="{
'text-warning': status === 'dirty',
'text-error': status === 'failed' || status === 'problems',
'text-muted': status === 'saving' || status === 'offline',
'text-muted': status === 'saving' || status === 'offline' || status === 'saved',
}"
>
<UIcon
@@ -43,6 +49,7 @@ const text = computed(() => {
/>
<UIcon v-else-if="status === 'offline'" name="i-lucide-wifi-off" class="size-3.5" />
<UIcon v-else-if="status === 'dirty'" name="i-lucide-circle-dot" class="size-3.5" />
<UIcon v-else-if="status === 'saved'" name="i-lucide-check" class="size-3.5" />
<UIcon v-else name="i-lucide-circle-alert" class="size-3.5" />
<span>{{ text }}</span>
</span>
+32 -1
View File
@@ -19,6 +19,7 @@ const props = defineProps<{
keyError?: string
disabled: boolean
keyFocusToken: number
keyLocked?: boolean
}>()
const emit = defineEmits<{
@@ -29,6 +30,7 @@ const emit = defineEmits<{
}>()
const root = ref<HTMLElement | null>(null)
const tipOpen = ref(false)
const slugMode = computed(() => keyDisplayMode(props.keyMode, props.keyValue))
const hasError = computed(() => Boolean(props.nameError || props.keyError))
@@ -62,7 +64,36 @@ watch(
/>
</DirectoryField>
</div>
<div :class="keyError ? 'md:w-90' : 'md:w-80'">
<div v-if="keyLocked" class="md:w-80">
<UTooltip
v-model:open="tipOpen"
:text="
t(
'The key is used in URLs and the API, so it can\'t change after the type is created.',
)
"
:content="{ side: 'bottom', align: 'start' }"
:ui="{
content:
'h-auto w-65 items-start bg-inverted px-2.5 py-2 text-xs/4.5 text-inverted shadow-(--shadow-popover) ring-0',
text: 'whitespace-normal',
}"
>
<div @focusin="tipOpen = true" @focusout="tipOpen = false">
<SlugField
:label="t('Key')"
name="key"
mode="manual"
:value="keyValue"
:disabled="disabled"
dense
locked
:maxlength="BUILDER_LIMITS.typeKeyMax"
/>
</div>
</UTooltip>
</div>
<div v-else :class="keyError ? 'md:w-90' : 'md:w-80'">
<SlugField
:label="t('Key')"
name="key"
+3
View File
@@ -27,6 +27,7 @@ const props = withDefaults(
fullBleed?: boolean
compactPadding?: boolean
bleedOfflineBanner?: boolean
flushOfflineBanner?: boolean
renderWhileChecking?: boolean
noCurrentNav?: boolean
offlineMessage?: string
@@ -42,6 +43,7 @@ const props = withDefaults(
fullBleed: false,
compactPadding: false,
bleedOfflineBanner: false,
flushOfflineBanner: false,
renderWhileChecking: false,
noCurrentNav: false,
},
@@ -411,6 +413,7 @@ async function onLogout() {
:icon="offlineIcon"
:bleed="compactPadding || bleedOfflineBanner"
:roomy="!compactPadding && bleedOfflineBanner"
:flush="flushOfflineBanner"
/>
<AppBoot v-if="showBoot" />
+34 -5
View File
@@ -2,7 +2,7 @@
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
import { t } from '@/i18n'
import { computed } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import type { DirectoryNotice } from '@/lib/directoryTypes'
const props = withDefaults(
@@ -17,6 +17,7 @@ const props = withDefaults(
notice?: DirectoryNotice | null
closable?: boolean
confirmDisabled?: boolean
wide?: boolean
}>(),
{
cancelLabel: '',
@@ -25,13 +26,30 @@ const props = withDefaults(
notice: null,
closable: false,
confirmDisabled: false,
wide: false,
},
)
const emit = defineEmits<{ confirm: []; close: [] }>()
const emit = defineEmits<{ confirm: []; close: []; cancel: [] }>()
const open = defineModel<boolean>('open', { default: true })
const titleElement = ref<HTMLElement | null>(null)
const contentClass = computed(
() =>
`${SHEET_CONTENT_CLASS} ${props.wide ? 'sm:top-15 sm:max-w-140' : 'sm:top-35 sm:max-w-115'}`,
)
watch(
() => props.pending,
async (value) => {
if (value) {
await nextTick()
titleElement.value?.focus()
}
},
)
const confirmText = computed(() => (props.pending ? props.pendingLabel : props.confirmLabel))
const closeButton = computed(() =>
props.closable && !props.pending
@@ -50,6 +68,7 @@ function onOpenChange(value: boolean) {
}
function onCancel() {
emit('cancel')
onOpenChange(false)
}
</script>
@@ -66,8 +85,11 @@ function onCancel() {
'aria-busy': pending ? 'true' : undefined,
}"
:ui="{
content: `${SHEET_CONTENT_CLASS} sm:top-35 sm:max-w-115`,
header: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
content: contentClass,
header:
props.wide && props.closable
? 'min-h-0 px-6 pe-20 pt-5 pb-0 sm:px-6 sm:pe-16'
: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
title: 'text-lg/6 font-semibold',
description: 'mt-1 text-sm text-muted text-pretty',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
@@ -75,6 +97,9 @@ function onCancel() {
}"
@update:open="onOpenChange"
>
<template #title>
<span ref="titleElement" tabindex="-1" class="outline-none">{{ title }}</span>
</template>
<template #body>
<div class="flex flex-col gap-4">
<UAlert
@@ -100,7 +125,10 @@ function onCancel() {
color="neutral"
variant="outline"
block
class="h-11 justify-center text-sm text-highlighted disabled:text-dimmed disabled:opacity-100 sm:h-8 sm:w-auto sm:px-3"
class="h-11 justify-center text-sm text-highlighted sm:h-8 sm:w-auto sm:px-3"
:class="
wide ? 'disabled:opacity-40' : 'disabled:text-dimmed disabled:opacity-100'
"
:disabled="pending"
@click="onCancel"
>
@@ -112,6 +140,7 @@ function onCancel() {
:disabled="pending || confirmDisabled"
block
class="h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3"
:class="wide ? 'disabled:opacity-40' : ''"
@click="emit('confirm')"
>
{{ confirmText }}
+3
View File
@@ -154,6 +154,8 @@ watch(
/>
</DirectoryField>
<SlugField
:locked="field.saved"
:locked-help="t('Used in the API, so it can\'t change.')"
:label="t('Key')"
name="field-key"
:mode="keyMode"
@@ -180,6 +182,7 @@ watch(
:model-value="type"
:compact="compact"
:disabled="disabled"
:locked="field.saved"
@update:model-value="emit('type', $event)"
/>
+46 -8
View File
@@ -4,17 +4,31 @@ import { t } from '@/i18n'
import { computed, nextTick, ref } from 'vue'
import FieldRow from '@/components/FieldRow.vue'
import { useKeyboardReorder } from '@/composables/useKeyboardReorder'
import type { RowMarks } from '@/composables/useSchemaBuilder'
import { BUILDER_LIMITS, type BuilderField } from '@/lib/schemaBuilderModel'
import type { FieldErrors } from '@/lib/schemaBuilderValidation'
const props = defineProps<{
fields: BuilderField[]
selectedUid: number | null
errors: Map<number, FieldErrors>
listError?: string
disabled: boolean
mobile: boolean
}>()
const NEW_MARKS: RowMarks = { isNew: true, changed: false, cause: false }
const props = withDefaults(
defineProps<{
fields: BuilderField[]
selectedUid: number | null
errors: Map<number, FieldErrors>
listError?: string
disabled: boolean
mobile: boolean
marks?: Map<number, RowMarks>
removed?: BuilderField[]
canUndo?: boolean
}>(),
{
listError: undefined,
marks: undefined,
removed: () => [],
canUndo: true,
},
)
const emit = defineEmits<{
select: [uid: number]
@@ -22,6 +36,7 @@ const emit = defineEmits<{
remove: [uid: number]
move: [uid: number, delta: number]
reorder: [order: number[]]
undo: [key: string]
}>()
const list = ref<HTMLElement | null>(null)
@@ -120,6 +135,7 @@ function onFocusin(event: FocusEvent) {
:disabled="disabled"
:tabbable="field.uid === tabbableUid"
:show-actions="!mobile"
:marks="marks?.get(field.uid) ?? NEW_MARKS"
@select="emit('select', field.uid)"
@up="onMove(field.uid, -1)"
@down="onMove(field.uid, 1)"
@@ -141,6 +157,28 @@ function onFocusin(event: FocusEvent) {
}}</span>
</div>
<div
v-for="field in removed"
:key="field.key"
data-testid="undo-row"
class="flex min-h-11 items-center gap-2 border border-dashed border-accented px-3 text-toned"
:class="disabled ? 'opacity-60' : ''"
>
<UIcon name="i-lucide-minus" class="size-3.5 shrink-0" />
<span class="flex-1">{{
t('{0} will be removed', [field.label.trim() || t('Untitled field')])
}}</span>
<button
type="button"
data-testid="undo-action"
:disabled="disabled || !canUndo"
class="min-h-11 font-medium text-highlighted underline underline-offset-3 focus-visible:outline-2 focus-visible:outline-primary disabled:text-dimmed md:min-h-0"
@click="emit('undo', field.key)"
>
{{ t('Undo') }}
</button>
</div>
<UButton
icon="i-lucide-plus"
color="neutral"
+6 -2
View File
@@ -5,6 +5,7 @@ import { computed } from 'vue'
import BuilderBadge from '@/components/BuilderBadge.vue'
import FieldActions from '@/components/FieldActions.vue'
import type { BuilderField } from '@/lib/schemaBuilderModel'
import type { RowMarks } from '@/composables/useSchemaBuilder'
import { fieldIcon } from '@/lib/schemaFields'
const props = defineProps<{
@@ -17,6 +18,7 @@ const props = defineProps<{
disabled: boolean
tabbable: boolean
showActions: boolean
marks: RowMarks
}>()
defineEmits<{
@@ -68,7 +70,7 @@ const rowClass = computed(() => [
:aria-current="selected ? 'true' : undefined"
:disabled="disabled"
:tabindex="tabbable ? 0 : -1"
class="flex min-w-0 flex-1 items-center gap-2 self-stretch text-left focus-visible:outline-2 focus-visible:outline-primary"
class="flex min-w-0 flex-1 items-center gap-2.5 self-stretch text-left max-md:gap-3 focus-visible:outline-2 focus-visible:outline-primary"
@click.stop="$emit('select')"
>
<span
@@ -88,7 +90,9 @@ const rowClass = computed(() => [
</span>
</button>
<span class="flex shrink-0 items-center gap-1" data-testid="field-row-badges">
<BuilderBadge kind="new" />
<BuilderBadge v-if="marks.isNew" kind="new" />
<BuilderBadge v-if="marks.changed" kind="changed" />
<BuilderBadge v-if="marks.cause" kind="cause" />
<BuilderBadge v-if="field.required" kind="required" :on-sand="selected" />
<BuilderBadge v-if="field.unique" kind="unique" :on-sand="selected" />
<UIcon
+25 -4
View File
@@ -5,7 +5,12 @@ import { computed } from 'vue'
import { FIELD_TYPES, type FieldType } from '@/lib/schemaBuilderModel'
import { fieldIcon, fieldTypeLabel } from '@/lib/schemaFields'
defineProps<{ modelValue: FieldType; compact: boolean; disabled: boolean }>()
const props = defineProps<{
modelValue: FieldType
compact: boolean
disabled: boolean
locked?: boolean
}>()
defineEmits<{ 'update:modelValue': [type: FieldType] }>()
@@ -27,7 +32,11 @@ function describe(type: FieldType) {
}
const items = computed(() =>
FIELD_TYPES.map((type) => ({ value: type, label: fieldTypeLabel(type) })),
FIELD_TYPES.map((type) => ({
value: type,
label: fieldTypeLabel(type),
disabled: props.locked && type !== props.modelValue,
})),
)
</script>
@@ -42,7 +51,7 @@ const items = computed(() =>
orientation="horizontal"
:ui="{
root: 'w-full',
legend: 'mb-1.5 font-medium text-highlighted',
legend: 'mb-1.5 w-full font-medium text-highlighted',
fieldset: compact ? 'grid grid-cols-2 gap-2' : 'grid grid-cols-3 gap-2',
item: 'group min-h-14 rounded-none border-accented p-2.5 text-highlighted has-data-[state=checked]:border-primary has-data-[state=checked]:bg-(--rc-sand) has-data-[state=checked]:text-(--rc-on-sand) max-md:min-h-16',
wrapper: 'ms-0 w-full',
@@ -50,8 +59,20 @@ const items = computed(() =>
}"
@update:model-value="$emit('update:modelValue', $event as FieldType)"
>
<template v-if="locked" #legend>
<span class="flex items-center justify-between gap-2">
<span>{{ t('Type') }}</span>
<span class="flex items-center gap-1 text-xs font-normal text-muted">
<UIcon name="i-lucide-lock" class="size-3" />
{{ t("Can't change after saving") }}
</span>
</span>
</template>
<template #label="{ item }">
<span class="flex items-start gap-2.5 text-left">
<span
class="flex items-start gap-2.5 text-left"
:class="item.disabled ? 'opacity-40' : ''"
>
<UIcon :name="fieldIcon(item.value)" class="mt-0.5 size-4 shrink-0" />
<span class="flex min-w-0 flex-col">
<span class="font-medium">{{ item.label }}</span>
+17 -2
View File
@@ -16,17 +16,32 @@ const BLEED_UI = {
title: 'whitespace-normal font-normal text-wrap',
}
const FLUSH_UI = { ...BLEED_UI, root: 'w-auto' }
const props = withDefaults(
defineProps<{
message?: string
icon?: string
bleed?: boolean
roomy?: boolean
flush?: boolean
}>(),
{ message: DEFAULT_MESSAGE, icon: undefined, bleed: false, roomy: false },
{
message: DEFAULT_MESSAGE,
icon: undefined,
bleed: false,
roomy: false,
flush: false,
},
)
const bleedUi = computed(() => (props.roomy ? { ...BLEED_UI, root: ROOMY_BLEED_ROOT } : BLEED_UI))
const bannerUi = computed(() => {
if (props.flush) {
return FLUSH_UI
}
return props.bleed ? bleedUi.value : undefined
})
const isOffline = ref(!navigator.onLine)
@@ -51,6 +66,6 @@ onUnmounted(() => {
color="neutral"
:icon="icon"
:title="translate(message)"
:ui="bleed ? bleedUi : undefined"
:ui="bannerUi"
/>
</template>
+12 -4
View File
@@ -1,7 +1,15 @@
<script setup lang="ts">
import { t } from '@/i18n'
defineProps<{ title: string; description: string }>()
withDefaults(
defineProps<{
title: string
description: string
actionLabel?: string
actionIcon?: string
}>(),
{ actionLabel: '', actionIcon: 'i-lucide-rotate-cw' },
)
defineEmits<{ retry: [] }>()
</script>
@@ -16,18 +24,18 @@ defineEmits<{ retry: [] }>()
icon="i-lucide-circle-x"
:title="title"
:description="description"
:ui="{ actions: 'mt-2' }"
:ui="{ actions: 'mt-1.5' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
:icon="actionIcon || undefined"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
:ui="{ leadingIcon: 'size-3.5' }"
@click="$emit('retry')"
>
{{ t('Try again') }}
{{ actionLabel || t('Try again') }}
</UButton>
</template>
</UAlert>
+326
View File
@@ -0,0 +1,326 @@
<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 SchemaRefusalDialog from '@/components/SchemaRefusalDialog.vue'
import SchemaReviewDialog from '@/components/SchemaReviewDialog.vue'
import { useSchemaBuilder, type EditSource } from '@/composables/useSchemaBuilder'
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
useUnsavedChangesGuard({
isDirty: () => builder.dirty.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="builder.failure.value === '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"
/>
</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
:builder="builder"
: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">
<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"
:builder="builder"
: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>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import SchemaChangeRow from '@/components/SchemaChangeRow.vue'
import type { FieldChange } from '@/lib/schemaBuilderDiff'
defineProps<{ changes: FieldChange[]; testId: string; cause?: boolean }>()
</script>
<template>
<ul class="m-0 flex list-none flex-col gap-2 p-0" :data-testid="testId">
<SchemaChangeRow
v-for="change in changes"
:key="`${change.kind}:${change.key}`"
:change="change"
:cause="cause"
/>
</ul>
</template>
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import {
causeText,
changeText,
changeTitle,
type ChangeKind,
type FieldChange,
} from '@/lib/schemaBuilderDiff'
const props = defineProps<{ change: FieldChange; cause?: boolean }>()
const ICONS: Record<ChangeKind, string> = {
add: 'i-lucide-plus',
change: 'i-lucide-pencil',
remove: 'i-lucide-minus',
}
const flagged = computed(() => props.cause || props.change.flag !== null)
const badge = computed(() => {
if (props.cause) {
return t('Possible cause')
}
if (props.change.flag === 'mayNotFit') {
return t('Entries may not fit')
}
return props.change.flag === 'removesData' ? t('Removes data') : ''
})
const text = computed(() => (props.cause ? causeText(props.change) : changeText(props.change)))
</script>
<template>
<li
data-testid="change-row"
class="flex gap-2.5 border border-default px-3 py-2.5"
:class="flagged ? 'bg-(--rc-warning-soft)' : 'bg-default'"
>
<UIcon
:name="ICONS[change.kind]"
class="mt-0.5 size-4 shrink-0"
:class="flagged ? 'text-warning' : 'text-toned'"
/>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<div class="flex flex-wrap items-center gap-2">
<span class="font-semibold text-highlighted" data-testid="change-title">{{
changeTitle(change)
}}</span>
<UBadge
v-if="badge"
color="neutral"
variant="soft"
icon="i-lucide-triangle-alert"
data-testid="change-flag"
class="border border-(--rc-warning-soft) bg-(--rc-warning-soft) text-warning"
:ui="{
base: 'h-5 shrink-0 gap-1 px-1.5 py-0 text-xs font-medium whitespace-nowrap',
leadingIcon: 'size-3',
}"
>
{{ badge }}
</UBadge>
</div>
<span class="text-pretty text-toned" data-testid="change-text">{{ text }}</span>
</div>
</li>
</template>
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { t } from '@/i18n'
import SchemaChangeList from '@/components/SchemaChangeList.vue'
import DirectoryDialog from '@/components/DirectoryDialog.vue'
import type { FieldChange } from '@/lib/schemaBuilderDiff'
defineProps<{ causes: FieldChange[] }>()
defineEmits<{ keepEditing: []; discard: [] }>()
</script>
<template>
<DirectoryDialog
:title="t('Nothing was changed')"
:description="
t(
'RenewCMS refused the changes because some entries don\'t fit the new fields. It doesn\'t say which entries.',
)
"
:confirm-label="t('Back to editing')"
:pending-label="t('Back to editing')"
:cancel-label="t('Discard changes')"
:notice="{
title: t('Your content type is as it was'),
description: t('No field was added, changed or removed.'),
}"
tone="neutral"
wide
closable
@confirm="$emit('keepEditing')"
@cancel="$emit('discard')"
@close="$emit('keepEditing')"
>
<template v-if="causes.length > 0">
<span class="font-semibold text-highlighted">{{
t('The change that can cause this in your edit')
}}</span>
<SchemaChangeList :changes="causes" test-id="refusal-causes" cause />
</template>
<p class="text-xs/4.5 text-pretty text-muted">
{{
t(
'This kind of change is refused when an entry is over the new limit, a new required field has no default, a field becomes unique while entries share a value, or a removed choice is still in use.',
)
}}
</p>
</DirectoryDialog>
</template>
+53
View File
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import DirectoryDialog from '@/components/DirectoryDialog.vue'
import SchemaChangeList from '@/components/SchemaChangeList.vue'
import { entriesLine } from '@/lib/schemaEntries'
import { summaryLine, type FieldChange } from '@/lib/schemaBuilderDiff'
const props = defineProps<{
name: string
total: number
changes: FieldChange[]
applying: boolean
offline: boolean
}>()
defineEmits<{ apply: []; close: [] }>()
const summary = computed(() => summaryLine(props.changes))
const entries = computed(() => entriesLine(props.name, props.total))
</script>
<template>
<DirectoryDialog
:title="t('Review changes to {0}', [name])"
:description="t('Apply them to save the content type.')"
:confirm-label="t('Apply changes')"
:pending-label="t('Applying…')"
:cancel-label="t('Back to editing')"
tone="neutral"
wide
:pending="applying"
:confirm-disabled="offline"
@confirm="$emit('apply')"
@close="$emit('close')"
>
<div class="flex flex-col gap-1">
<span class="text-md/4.5 font-semibold text-highlighted" data-testid="review-summary">{{
summary
}}</span>
<span class="text-muted">{{ entries }}</span>
</div>
<SchemaChangeList :changes="changes" test-id="review-changes" />
<p v-if="!applying" class="text-xs/4.5 text-muted">
{{
t(
"Flagged changes can be refused if existing entries don't fit. If that happens, nothing is changed.",
)
}}
</p>
</DirectoryDialog>
</template>
+1 -1
View File
@@ -99,7 +99,7 @@ const placeholderText = computed(() => props.placeholder || t('Generated from th
:maxlength="maxlength"
:data-autofocus="autofocus ? '' : undefined"
:color="error ? 'error' : undefined"
:highlight="mode === 'manual' && !error"
:highlight="mode === 'manual' && !error && !locked"
class="w-full"
:ui="inputUi"
@update:model-value="emit('update:value', String($event))"