feat: edit an existing custom type
This commit is contained in:
1 parent
083cc9e23e
commit
8e344b2500
57 files changed
+5648
-425
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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)"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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))"
|
||||
|
||||
Reference in new issue
Block a user