feat: schema builder's create flow
This commit is contained in:
1 parent
f514146fed
commit
083cc9e23e
71 files changed
+7223
-61
No files matched your search
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useJsonCopy } from '@/composables/useJsonCopy'
|
||||
import { previewRoutes } from '@/lib/schemaBuilderSample'
|
||||
|
||||
const props = defineProps<{ keyValue: string; json: string }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const { copied, copy, label: copyLabel, icon: copyIcon } = useJsonCopy()
|
||||
|
||||
const routes = computed(() => previewRoutes(props.keyValue))
|
||||
const summary = computed(() => `${routes.value[0]!.method} ${routes.value[0]!.path}`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:aria-label="t('API preview')"
|
||||
class="flex shrink-0 flex-col"
|
||||
data-testid="api-preview"
|
||||
>
|
||||
<div
|
||||
class="flex h-11 items-center gap-2.5 border-t border-default bg-default px-3 text-highlighted md:px-6"
|
||||
>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:icon="open ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
|
||||
:aria-expanded="open"
|
||||
data-testid="api-preview-toggle"
|
||||
class="h-11 min-w-0 flex-1 justify-start gap-2.5 px-0 font-semibold hover:bg-transparent"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="open = !open"
|
||||
>
|
||||
<span>{{ t('API preview') }}</span>
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate text-left font-mono text-xs font-normal text-muted"
|
||||
>{{ summary }}</span
|
||||
>
|
||||
</UButton>
|
||||
<UButton
|
||||
:icon="copyIcon"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
data-testid="api-copy"
|
||||
class="h-7 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0"
|
||||
:class="copied ? 'text-success' : ''"
|
||||
:ui="{ leadingIcon: 'size-4' }"
|
||||
@click="copy(json)"
|
||||
>
|
||||
<span aria-live="polite">{{ copyLabel }}</span>
|
||||
</UButton>
|
||||
</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">{{
|
||||
t('Where entries of this type live')
|
||||
}}</span>
|
||||
<ul
|
||||
class="m-0 flex list-none flex-col gap-0.5 border border-default bg-elevated px-3 py-2.5"
|
||||
>
|
||||
<li
|
||||
v-for="(route, index) in routes"
|
||||
:key="index"
|
||||
data-testid="api-route"
|
||||
class="truncate font-mono text-xs/5 text-highlighted"
|
||||
>
|
||||
<span class="inline-block w-14 text-muted">{{ route.method }}</span
|
||||
>{{ route.path }}
|
||||
</li>
|
||||
</ul>
|
||||
<span class="text-xs/4.5 text-pretty text-muted">{{
|
||||
t(
|
||||
'Updates as you edit. Fields you add or remove here appear after you save.',
|
||||
)
|
||||
}}</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">{{
|
||||
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"
|
||||
>{{ json }}</pre
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UCollapsible>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useJsonCopy } from '@/composables/useJsonCopy'
|
||||
import { previewRoutes } from '@/lib/schemaBuilderSample'
|
||||
import { BOTTOM_SHEET_UI } from '@/lib/sheetUi'
|
||||
|
||||
const props = defineProps<{ keyValue: string; json: string }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const { copy, label: copyLabel, icon: copyIcon } = useJsonCopy()
|
||||
|
||||
const first = computed(() => previewRoutes(props.keyValue)[0]!)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<USlideover
|
||||
v-model:open="open"
|
||||
side="bottom"
|
||||
:title="t('API preview')"
|
||||
:description="t('Where entries of this type live')"
|
||||
:ui="BOTTOM_SHEET_UI"
|
||||
>
|
||||
<template #body>
|
||||
<span class="text-xs text-muted" data-testid="api-sheet-route">{{
|
||||
`${first.method} ${first.path}`
|
||||
}}</span>
|
||||
<pre
|
||||
tabindex="0"
|
||||
data-testid="api-sheet-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"
|
||||
>{{ json }}</pre
|
||||
>
|
||||
</template>
|
||||
<template #footer>
|
||||
<UButton
|
||||
:icon="copyIcon"
|
||||
data-testid="api-sheet-copy"
|
||||
class="h-11 flex-1 justify-center"
|
||||
@click="copy(json)"
|
||||
>
|
||||
<span aria-live="polite">{{ copyLabel }}</span>
|
||||
</UButton>
|
||||
</template>
|
||||
</USlideover>
|
||||
</template>
|
||||
@@ -5,7 +5,8 @@ import { computed, nextTick, ref, useId } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import AuthorAvatar from '@/components/AuthorAvatar.vue'
|
||||
import type { Byline } from '@/composables/useArticles'
|
||||
import { insertionTarget, moveItem } from '@/lib/articleMeta'
|
||||
import { moveItem } from '@/lib/arrayMove'
|
||||
import { insertionTarget } from '@/lib/articleMeta'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 64rem)'
|
||||
const DESKTOP_AVATAR = 22
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import BuilderStatusChip from '@/components/BuilderStatusChip.vue'
|
||||
import type { BuilderStatus } from '@/lib/schemaBuilderModel'
|
||||
|
||||
defineProps<{
|
||||
status: BuilderStatus
|
||||
problems: number
|
||||
canSave: boolean
|
||||
canReveal: boolean
|
||||
canDiscard: boolean
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{ save: []; discard: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex shrink-0 items-center gap-2 md:gap-3">
|
||||
<BuilderStatusChip :status="status" :problems="problems" />
|
||||
<span class="flex-1 md:hidden" />
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:disabled="!canDiscard"
|
||||
class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 md:h-8"
|
||||
@click="$emit('discard')"
|
||||
>
|
||||
{{ t('Discard') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
:disabled="!canSave && !canReveal"
|
||||
:aria-disabled="canReveal ? 'true' : undefined"
|
||||
:loading="saving"
|
||||
class="h-11 px-4 md:h-8"
|
||||
@click="$emit('save')"
|
||||
>
|
||||
{{ saving ? t('Saving…') : t('Save') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{ kind: 'new' | 'required' | 'unique'; onSand?: boolean }>()
|
||||
|
||||
const badges = {
|
||||
new: {
|
||||
get label() {
|
||||
return t('New')
|
||||
},
|
||||
class: 'border border-(--rc-sand) bg-(--rc-sand) text-(--rc-on-sand)',
|
||||
},
|
||||
required: {
|
||||
get label() {
|
||||
return t('Required')
|
||||
},
|
||||
class: 'border border-accented bg-transparent text-toned',
|
||||
},
|
||||
unique: {
|
||||
get label() {
|
||||
return t('Unique')
|
||||
},
|
||||
class: 'border border-accented bg-transparent text-toned',
|
||||
},
|
||||
} as const
|
||||
|
||||
const OUTLINE_ON_SAND = 'border border-(--rc-on-sand) bg-transparent text-(--rc-on-sand)'
|
||||
|
||||
const badge = computed(() => badges[props.kind])
|
||||
const badgeClass = computed(() =>
|
||||
props.onSand && props.kind !== 'new' ? OUTLINE_ON_SAND : badge.value.class,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UBadge
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
:class="badgeClass"
|
||||
:ui="{ base: 'h-5 shrink-0 px-1.5 py-0 text-xs font-medium whitespace-nowrap' }"
|
||||
>
|
||||
{{ badge.label }}
|
||||
</UBadge>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import FieldInspector from '@/components/FieldInspector.vue'
|
||||
import type { useSchemaBuilder } from '@/composables/useSchemaBuilder'
|
||||
import type { BuilderField } from '@/lib/schemaBuilderModel'
|
||||
import type { FieldErrors } from '@/lib/schemaBuilderValidation'
|
||||
|
||||
type Builder = ReturnType<typeof useSchemaBuilder>
|
||||
|
||||
const props = defineProps<{
|
||||
builder: Builder
|
||||
field: BuilderField
|
||||
errors: FieldErrors
|
||||
compact: boolean
|
||||
}>()
|
||||
|
||||
const uid = () => props.field.uid
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FieldInspector
|
||||
:field="field"
|
||||
:errors="errors"
|
||||
:disabled="builder.saving.value"
|
||||
:compact="compact"
|
||||
:focus-token="builder.labelFocusToken.value"
|
||||
@label="builder.setLabel(uid(), $event)"
|
||||
@key="builder.setFieldKey(uid(), $event)"
|
||||
@unlock="builder.unlockFieldKey(uid())"
|
||||
@reset="builder.resetFieldKey(uid())"
|
||||
@type="builder.setFieldType(uid(), $event)"
|
||||
@required="builder.patchField(uid(), { required: $event })"
|
||||
@unique="builder.patchField(uid(), { unique: $event })"
|
||||
@limit="(which, value) => builder.setLimit(uid(), which, value)"
|
||||
@default="builder.setDefault(uid(), $event)"
|
||||
@default-on="builder.patchField(uid(), { defaultOn: $event })"
|
||||
@add-choice="builder.addChoice(uid(), $event)"
|
||||
@remove-choice="builder.removeChoice(uid(), $event)"
|
||||
@rename-choice="(index, text) => builder.renameChoice(uid(), index, text)"
|
||||
@reorder-choices="builder.reorderChoices(uid(), $event)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { BuilderStatus } from '@/lib/schemaBuilderModel'
|
||||
|
||||
const props = defineProps<{ status: BuilderStatus; problems: number }>()
|
||||
|
||||
const text = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'saving':
|
||||
return t('Saving…')
|
||||
case 'offline':
|
||||
return t('Waiting for connection')
|
||||
case 'failed':
|
||||
return t('Save failed')
|
||||
case 'problems':
|
||||
return props.problems === 1
|
||||
? t('1 problem to fix')
|
||||
: t('{0} problems to fix', [props.problems])
|
||||
default:
|
||||
return t('Unsaved changes')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="status !== 'none'"
|
||||
role="status"
|
||||
data-testid="builder-chip"
|
||||
class="inline-flex shrink-0 items-center gap-1.5 text-xs whitespace-nowrap"
|
||||
:class="{
|
||||
'text-warning': status === 'dirty',
|
||||
'text-error': status === 'failed' || status === 'problems',
|
||||
'text-muted': status === 'saving' || status === 'offline',
|
||||
}"
|
||||
>
|
||||
<UIcon
|
||||
v-if="status === 'saving'"
|
||||
name="i-lucide-loader-circle"
|
||||
class="size-3.5 animate-spin"
|
||||
/>
|
||||
<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 name="i-lucide-circle-alert" class="size-3.5" />
|
||||
<span>{{ text }}</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import SlugField from '@/components/SlugField.vue'
|
||||
import {
|
||||
BUILDER_LIMITS,
|
||||
PREVIEW_KEY_FALLBACK,
|
||||
keyDisplayMode,
|
||||
type KeyMode,
|
||||
} from '@/lib/schemaBuilderModel'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
keyValue: string
|
||||
keyMode: KeyMode
|
||||
nameError?: string
|
||||
keyError?: string
|
||||
disabled: boolean
|
||||
keyFocusToken: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:name': [value: string]
|
||||
'update:key': [value: string]
|
||||
unlock: []
|
||||
reset: []
|
||||
}>()
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const slugMode = computed(() => keyDisplayMode(props.keyMode, props.keyValue))
|
||||
const hasError = computed(() => Boolean(props.nameError || props.keyError))
|
||||
|
||||
watch(
|
||||
() => props.keyFocusToken,
|
||||
async () => {
|
||||
await nextTick()
|
||||
root.value?.querySelector<HTMLInputElement>('input[name="key"]')?.focus()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="root"
|
||||
class="flex min-w-0 flex-col gap-3 transition-opacity md:flex-row md:gap-4"
|
||||
:class="[hasError ? 'md:items-start' : 'md:items-end', disabled ? 'opacity-60' : '']"
|
||||
>
|
||||
<div class="md:w-80">
|
||||
<DirectoryField :label="t('Name')" name="name" required :error="nameError">
|
||||
<UInput
|
||||
:model-value="name"
|
||||
:placeholder="t('For example, Events')"
|
||||
:maxlength="BUILDER_LIMITS.nameMax"
|
||||
:disabled="disabled"
|
||||
:color="nameError ? 'error' : undefined"
|
||||
required
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm' }"
|
||||
@update:model-value="emit('update:name', String($event))"
|
||||
/>
|
||||
</DirectoryField>
|
||||
</div>
|
||||
<div :class="keyError ? 'md:w-90' : 'md:w-80'">
|
||||
<SlugField
|
||||
:label="t('Key')"
|
||||
name="key"
|
||||
:mode="slugMode"
|
||||
:value="keyValue"
|
||||
:error="keyError"
|
||||
:disabled="disabled"
|
||||
dense
|
||||
reset-in-label
|
||||
:help="
|
||||
t(
|
||||
'Used in URLs and the API. Lowercase letters, digits and hyphens. It can\'t change after you save.',
|
||||
)
|
||||
"
|
||||
:edit-label="t('Edit key')"
|
||||
:placeholder="PREVIEW_KEY_FALLBACK"
|
||||
:maxlength="BUILDER_LIMITS.typeKeyMax"
|
||||
@update:value="emit('update:key', $event)"
|
||||
@unlock="emit('unlock')"
|
||||
@reset="emit('reset')"
|
||||
/>
|
||||
</div>
|
||||
<span class="hidden flex-1 md:block" />
|
||||
<div v-if="$slots.actions" :class="hasError ? 'md:mt-5.5' : ''">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useKeyboardReorder } from '@/composables/useKeyboardReorder'
|
||||
import { BUILDER_LIMITS } from '@/lib/schemaBuilderModel'
|
||||
|
||||
const props = defineProps<{
|
||||
options: string[]
|
||||
rowErrors?: Record<number, string>
|
||||
listError?: string
|
||||
disabled: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
add: [text: string]
|
||||
remove: [index: number]
|
||||
rename: [index: number, text: string]
|
||||
reorder: [options: string[]]
|
||||
}>()
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const draft = ref('')
|
||||
const addInput = ref<{ inputRef?: HTMLInputElement | null } | null>(null)
|
||||
|
||||
interface ChoiceRow {
|
||||
index: number
|
||||
text: string
|
||||
}
|
||||
|
||||
const reorder = useKeyboardReorder<ChoiceRow>({
|
||||
items: () => props.options.map((text, index) => ({ index, text })),
|
||||
name: (row) => row.text.trim() || t('Choice'),
|
||||
commit: (next) =>
|
||||
emit(
|
||||
'reorder',
|
||||
next.map((row) => row.text),
|
||||
),
|
||||
root: () => root.value,
|
||||
})
|
||||
|
||||
const rows = computed(() => reorder.shown.value)
|
||||
const full = computed(() => props.options.length >= BUILDER_LIMITS.choicesMax)
|
||||
const countText = computed(() => t('{0} of {1}', [props.options.length, BUILDER_LIMITS.choicesMax]))
|
||||
const canAdd = computed(() => !props.disabled && !full.value && draft.value.trim() !== '')
|
||||
|
||||
async function add() {
|
||||
if (!canAdd.value) {
|
||||
return
|
||||
}
|
||||
emit('add', draft.value)
|
||||
draft.value = ''
|
||||
await nextTick()
|
||||
addInput.value?.inputRef?.focus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="flex flex-col gap-2" data-testid="choice-list">
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium text-highlighted"
|
||||
>{{ t('Choices') }}<span aria-hidden="true"> *</span></span
|
||||
>
|
||||
<span class="font-mono text-xs text-muted" data-testid="choice-count">{{
|
||||
countText
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="choice-live"
|
||||
:class="
|
||||
reorder.active.value
|
||||
? 'bg-inverted px-2.5 py-2 text-xs/4.5 text-inverted'
|
||||
: 'sr-only'
|
||||
"
|
||||
>
|
||||
{{ reorder.announcement.value }}
|
||||
</div>
|
||||
|
||||
<ul class="m-0 flex list-none flex-col gap-2 p-0">
|
||||
<li
|
||||
v-for="(row, index) in rows"
|
||||
:key="row.index"
|
||||
data-testid="choice-row"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1"
|
||||
:class="reorder.grabbed.value === index ? 'outline-2 outline-primary' : ''"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:data-handle="index"
|
||||
:aria-label="t('Move {0}', [row.text.trim() || t('Choice')])"
|
||||
:aria-pressed="reorder.grabbed.value === index"
|
||||
:disabled="disabled"
|
||||
class="flex size-7 shrink-0 items-center justify-center text-muted focus-visible:outline-2 focus-visible:outline-primary max-md:size-11"
|
||||
@keydown="reorder.onKeydown($event, index)"
|
||||
>
|
||||
<UIcon name="i-lucide-grip-vertical" class="size-4" />
|
||||
</button>
|
||||
<UInput
|
||||
:model-value="row.text"
|
||||
:placeholder="t('Choice')"
|
||||
:disabled="disabled"
|
||||
:color="rowErrors?.[row.index] ? 'error' : undefined"
|
||||
:aria-label="t('Choice {0}', [index + 1])"
|
||||
:aria-invalid="rowErrors?.[row.index] ? 'true' : undefined"
|
||||
:aria-describedby="
|
||||
rowErrors?.[row.index] ? `choice-error-${row.index}` : undefined
|
||||
"
|
||||
class="min-w-0 flex-1"
|
||||
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm' }"
|
||||
@update:model-value="emit('rename', row.index, String($event))"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-x"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:disabled="disabled"
|
||||
:aria-label="t('Remove {0}', [row.text.trim() || t('Choice')])"
|
||||
class="size-7 justify-center max-md:size-11"
|
||||
@click="emit('remove', row.index)"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
v-if="rowErrors?.[row.index]"
|
||||
:id="`choice-error-${row.index}`"
|
||||
role="alert"
|
||||
class="ml-8 flex items-start gap-1.5 text-xs/4.5 text-error max-md:ml-12"
|
||||
>
|
||||
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{{ rowErrors[row.index] }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-if="listError" role="alert" class="flex items-start gap-1.5 text-xs/4.5 text-error">
|
||||
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{{ listError }}</span>
|
||||
</p>
|
||||
|
||||
<div class="flex gap-2 pl-8 max-md:pl-12">
|
||||
<UInput
|
||||
ref="addInput"
|
||||
v-model="draft"
|
||||
:placeholder="t('Add a choice')"
|
||||
:aria-label="t('Add a choice')"
|
||||
:disabled="disabled || full"
|
||||
class="min-w-0 flex-1"
|
||||
:ui="{ base: 'h-11 border-dashed text-lg md:h-8 md:text-sm' }"
|
||||
@keydown.enter.prevent="add"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-plus"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:disabled="!canAdd"
|
||||
class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 md:h-8"
|
||||
@click="add"
|
||||
>
|
||||
{{ t('Add') }}
|
||||
</UButton>
|
||||
</div>
|
||||
<p class="text-xs/4.5 text-pretty text-muted">
|
||||
{{
|
||||
t(
|
||||
'Add 1 to 50 choices. Each one needs a different name. Removing a choice that entries use will be refused when you save.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -16,6 +16,7 @@ import ErrorView from '@/components/ErrorView.vue'
|
||||
import OfflineBanner from '@/components/OfflineBanner.vue'
|
||||
import UpdatePill from '@/components/UpdatePill.vue'
|
||||
import UserMenu from '@/components/UserMenu.vue'
|
||||
import { HOME_PATH, SCHEMA_PATH } from '@/lib/routePaths'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -67,7 +68,9 @@ const update = useUpdateStore()
|
||||
const api = useApi()
|
||||
const contentTypes = useContentTypesStore()
|
||||
|
||||
const wantsBuiltin = computed(() => session.canManageContent && /^\/schema(\/|$)/.test(route.path))
|
||||
const wantsBuiltin = computed(
|
||||
() => session.canManageContent && route.meta.needsBuiltinTypes === true,
|
||||
)
|
||||
|
||||
watch(
|
||||
[() => session.status === 'ready' && session.session?.verified === true, wantsBuiltin],
|
||||
@@ -115,7 +118,7 @@ const schemaItem = computed(() =>
|
||||
get label() {
|
||||
return t('Schema')
|
||||
},
|
||||
to: '/schema',
|
||||
to: SCHEMA_PATH,
|
||||
icon: 'i-lucide-shapes',
|
||||
},
|
||||
]
|
||||
@@ -128,7 +131,7 @@ const navItems = computed(() =>
|
||||
get label() {
|
||||
return t('Home')
|
||||
},
|
||||
to: '/home',
|
||||
to: HOME_PATH,
|
||||
icon: 'i-lucide-home',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,11 +5,12 @@ withDefaults(
|
||||
defineProps<{
|
||||
label?: string
|
||||
optional?: boolean
|
||||
required?: boolean
|
||||
error?: string
|
||||
help?: string
|
||||
name?: string
|
||||
}>(),
|
||||
{ label: '', optional: false, error: '', help: '', name: undefined },
|
||||
{ label: '', optional: false, required: false, error: '', help: '', name: undefined },
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -29,7 +30,7 @@ withDefaults(
|
||||
}"
|
||||
>
|
||||
<template v-if="label" #label>
|
||||
{{ label }}
|
||||
{{ label }}<span v-if="required" aria-hidden="true"> *</span>
|
||||
<span v-if="optional" class="font-normal text-muted"> {{ t('(optional)') }}</span>
|
||||
</template>
|
||||
<template v-if="$slots.hint" #hint>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import RetryAlert from '@/components/RetryAlert.vue'
|
||||
|
||||
defineProps<{ title: string; description?: string }>()
|
||||
|
||||
@@ -7,28 +8,9 @@ defineEmits<{ retry: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UAlert
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
orientation="vertical"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
<RetryAlert
|
||||
:title="title"
|
||||
:description="description ?? t('Check your connection and try again.')"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-rotate-cw"
|
||||
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') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
@retry="$emit('retry')"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
defineProps<{ first: boolean; last: boolean; disabled: boolean }>()
|
||||
|
||||
defineEmits<{ up: []; down: []; remove: [] }>()
|
||||
|
||||
const BUTTON_CLASS = 'size-7 justify-center max-md:size-11'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex shrink-0 gap-0.5">
|
||||
<UButton
|
||||
icon="i-lucide-chevron-up"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
data-action="up"
|
||||
:aria-label="t('Move up')"
|
||||
:disabled="disabled || first"
|
||||
:class="BUTTON_CLASS"
|
||||
@click.stop="$emit('up')"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-chevron-down"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
data-action="down"
|
||||
:aria-label="t('Move down')"
|
||||
:disabled="disabled || last"
|
||||
:class="BUTTON_CLASS"
|
||||
@click.stop="$emit('down')"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-trash-2"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
data-action="remove"
|
||||
:aria-label="t('Remove')"
|
||||
:disabled="disabled"
|
||||
:class="BUTTON_CLASS"
|
||||
@click.stop="$emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,310 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import AppSwitch from '@/components/AppSwitch.vue'
|
||||
import ChoiceList from '@/components/ChoiceList.vue'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import FieldTypePicker from '@/components/FieldTypePicker.vue'
|
||||
import SlugField from '@/components/SlugField.vue'
|
||||
import {
|
||||
BUILDER_LIMITS,
|
||||
STARTER_KEY,
|
||||
keyDisplayMode,
|
||||
supportsLimits,
|
||||
supportsUnique,
|
||||
trimmedOptions,
|
||||
type BuilderField,
|
||||
type FieldType,
|
||||
} from '@/lib/schemaBuilderModel'
|
||||
import type { FieldErrors } from '@/lib/schemaBuilderValidation'
|
||||
|
||||
const NO_DEFAULT = 'none'
|
||||
const CHOICE_PREFIX = 'choice:'
|
||||
|
||||
const props = defineProps<{
|
||||
field: BuilderField
|
||||
errors: FieldErrors
|
||||
disabled: boolean
|
||||
compact: boolean
|
||||
focusToken: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
label: [value: string]
|
||||
key: [value: string]
|
||||
unlock: []
|
||||
reset: []
|
||||
type: [type: FieldType]
|
||||
required: [value: boolean]
|
||||
unique: [value: boolean]
|
||||
limit: [which: 'min' | 'max', value: string]
|
||||
default: [value: string]
|
||||
defaultOn: [value: boolean]
|
||||
addChoice: [text: string]
|
||||
removeChoice: [index: number]
|
||||
renameChoice: [index: number, text: string]
|
||||
reorderChoices: [options: string[]]
|
||||
}>()
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
|
||||
const type = computed(() => props.field.type)
|
||||
const hasLimits = computed(() => supportsLimits(type.value))
|
||||
const canUnique = computed(() => supportsUnique(type.value))
|
||||
const keyMode = computed(() => keyDisplayMode(props.field.keyMode, props.field.key))
|
||||
const gridClass = computed(() => (props.compact ? 'grid-cols-1' : 'md:grid-cols-2'))
|
||||
|
||||
const limitLabels = computed(() => {
|
||||
if (type.value === 'number') {
|
||||
return [t('Lowest value'), t('Highest value')] as const
|
||||
}
|
||||
if (type.value === 'date') {
|
||||
return [t('Earliest date'), t('Latest date')] as const
|
||||
}
|
||||
return [t('Shortest'), t('Longest')] as const
|
||||
})
|
||||
const limitInput = computed(() => {
|
||||
if (type.value === 'date') {
|
||||
return 'date'
|
||||
}
|
||||
return 'number'
|
||||
})
|
||||
const isTextual = computed(() => type.value === 'text' || type.value === 'richtext')
|
||||
const limitSuffix = computed(() => (isTextual.value ? t('characters') : ''))
|
||||
const maxHelp = computed(() =>
|
||||
isTextual.value
|
||||
? t('Counted in characters. Entries show a counter.')
|
||||
: t('Entries outside the range are refused.'),
|
||||
)
|
||||
const noLimitsText = computed(() =>
|
||||
type.value === 'boolean'
|
||||
? t('Yes or no fields have no limits.')
|
||||
: t('Limits come from the choices below.'),
|
||||
)
|
||||
|
||||
const uniqueHelp = computed(() => {
|
||||
if (canUnique.value) {
|
||||
return t('Each entry needs a different value.')
|
||||
}
|
||||
return type.value === 'richtext'
|
||||
? t('Not available for rich text.')
|
||||
: t('Not available for yes or no fields.')
|
||||
})
|
||||
const defaultHelp = computed(() =>
|
||||
props.field.unique
|
||||
? t("A field with unique values can't have a default.")
|
||||
: t('Used when an entry is created and this field is left out.'),
|
||||
)
|
||||
const defaultOnHelp = computed(() =>
|
||||
props.field.defaultOn
|
||||
? t('New entries start with this switch on when the field is left out.')
|
||||
: t('New entries start with this switch off when the field is left out.'),
|
||||
)
|
||||
|
||||
const choiceItems = computed(() => [
|
||||
{ label: t('None'), value: NO_DEFAULT },
|
||||
...Array.from(new Set(trimmedOptions(props.field).filter((option) => option !== ''))).map(
|
||||
(option) => ({ label: option, value: `${CHOICE_PREFIX}${option}` }),
|
||||
),
|
||||
])
|
||||
const choiceValue = computed(() =>
|
||||
props.field.defaultValue === '' ? NO_DEFAULT : `${CHOICE_PREFIX}${props.field.defaultValue}`,
|
||||
)
|
||||
|
||||
function onChoice(value: unknown) {
|
||||
const text = String(value)
|
||||
emit('default', text === NO_DEFAULT ? '' : text.slice(CHOICE_PREFIX.length))
|
||||
}
|
||||
|
||||
const SWITCH_UI = {
|
||||
root: 'items-start gap-3',
|
||||
wrapper: 'ms-0',
|
||||
label: 'font-medium text-highlighted',
|
||||
description: 'mt-0 text-xs/4.5 text-pretty text-muted',
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.focusToken,
|
||||
async () => {
|
||||
await nextTick()
|
||||
root.value?.querySelector<HTMLInputElement>('input[name="label"]')?.focus()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="root"
|
||||
class="flex min-w-0 flex-col gap-3.5 transition-opacity max-md:gap-4"
|
||||
:class="disabled ? 'opacity-60' : ''"
|
||||
data-testid="field-inspector"
|
||||
>
|
||||
<div class="grid gap-3.5" :class="gridClass">
|
||||
<DirectoryField :label="t('Label')" name="label" required :error="errors.label">
|
||||
<UInput
|
||||
:model-value="field.label"
|
||||
:placeholder="t('For example, Title')"
|
||||
:disabled="disabled"
|
||||
:color="errors.label ? 'error' : undefined"
|
||||
required
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm' }"
|
||||
@update:model-value="emit('label', String($event))"
|
||||
/>
|
||||
</DirectoryField>
|
||||
<SlugField
|
||||
:label="t('Key')"
|
||||
name="field-key"
|
||||
:mode="keyMode"
|
||||
:value="field.key"
|
||||
:error="errors.key"
|
||||
:disabled="disabled"
|
||||
dense
|
||||
:placeholder="STARTER_KEY"
|
||||
:maxlength="BUILDER_LIMITS.fieldKeyMax"
|
||||
:help="
|
||||
t(
|
||||
'The name used in the API. Starts with a letter. Lowercase letters, digits and underscores.',
|
||||
)
|
||||
"
|
||||
:edit-label="t('Edit key')"
|
||||
:reset-label="t('Reset to match the label')"
|
||||
@update:value="emit('key', $event)"
|
||||
@unlock="emit('unlock')"
|
||||
@reset="emit('reset')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FieldTypePicker
|
||||
:model-value="type"
|
||||
:compact="compact"
|
||||
:disabled="disabled"
|
||||
@update:model-value="emit('type', $event)"
|
||||
/>
|
||||
|
||||
<div class="grid items-start gap-3.5" :class="gridClass">
|
||||
<div class="flex min-w-0 flex-col gap-3.5">
|
||||
<AppSwitch
|
||||
:model-value="field.required"
|
||||
:label="t('Required')"
|
||||
:description="t('An entry can\'t be saved with this field empty.')"
|
||||
:disabled="disabled"
|
||||
:ui="SWITCH_UI"
|
||||
@update:model-value="emit('required', Boolean($event))"
|
||||
/>
|
||||
<AppSwitch
|
||||
:model-value="canUnique && field.unique"
|
||||
:label="t('Unique values')"
|
||||
:description="uniqueHelp"
|
||||
:disabled="disabled || !canUnique"
|
||||
:ui="SWITCH_UI"
|
||||
@update:model-value="emit('unique', Boolean($event))"
|
||||
/>
|
||||
<AppSwitch
|
||||
v-if="type === 'boolean'"
|
||||
:model-value="field.defaultOn"
|
||||
:label="t('On by default')"
|
||||
:description="defaultOnHelp"
|
||||
:disabled="disabled"
|
||||
:ui="SWITCH_UI"
|
||||
@update:model-value="emit('defaultOn', Boolean($event))"
|
||||
/>
|
||||
<DirectoryField
|
||||
v-else
|
||||
:label="t('Default value')"
|
||||
name="default"
|
||||
:error="errors.default"
|
||||
:help="defaultHelp"
|
||||
>
|
||||
<USelect
|
||||
v-if="type === 'select'"
|
||||
:model-value="choiceValue"
|
||||
:items="choiceItems"
|
||||
:placeholder="t('None')"
|
||||
:disabled="disabled || field.unique"
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm' }"
|
||||
@update:model-value="onChoice"
|
||||
/>
|
||||
<UTextarea
|
||||
v-else-if="type === 'richtext'"
|
||||
:model-value="field.defaultValue"
|
||||
:placeholder="t('No default')"
|
||||
:disabled="disabled || field.unique"
|
||||
:rows="3"
|
||||
class="w-full"
|
||||
@update:model-value="emit('default', String($event))"
|
||||
/>
|
||||
<UInput
|
||||
v-else
|
||||
:model-value="field.defaultValue"
|
||||
:type="type === 'number' ? 'number' : type === 'date' ? 'date' : 'text'"
|
||||
:placeholder="t('No default')"
|
||||
:disabled="disabled || field.unique"
|
||||
:color="errors.default ? 'error' : undefined"
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm' }"
|
||||
@update:model-value="emit('default', String($event))"
|
||||
/>
|
||||
</DirectoryField>
|
||||
</div>
|
||||
|
||||
<div v-if="hasLimits" class="flex min-w-0 flex-col gap-3.5">
|
||||
<DirectoryField :label="limitLabels[0]" name="min" :error="errors.min">
|
||||
<UInput
|
||||
:model-value="field.min"
|
||||
:type="limitInput"
|
||||
:placeholder="t('No minimum')"
|
||||
:disabled="disabled"
|
||||
:color="errors.min ? 'error' : undefined"
|
||||
class="w-full"
|
||||
:ui="{ base: 'no-spin h-11 text-lg md:h-8 md:text-sm' }"
|
||||
@update:model-value="emit('limit', 'min', String($event))"
|
||||
>
|
||||
<template v-if="limitSuffix" #trailing>
|
||||
<span class="text-xs text-muted">{{ limitSuffix }}</span>
|
||||
</template>
|
||||
</UInput>
|
||||
</DirectoryField>
|
||||
<DirectoryField
|
||||
:label="limitLabels[1]"
|
||||
name="max"
|
||||
:error="errors.max"
|
||||
:help="errors.min ? '' : maxHelp"
|
||||
>
|
||||
<UInput
|
||||
:model-value="field.max"
|
||||
:type="limitInput"
|
||||
:placeholder="t('No maximum')"
|
||||
:disabled="disabled"
|
||||
:color="errors.max ? 'error' : undefined"
|
||||
class="w-full"
|
||||
:ui="{ base: 'no-spin h-11 text-lg md:h-8 md:text-sm' }"
|
||||
@update:model-value="emit('limit', 'max', String($event))"
|
||||
>
|
||||
<template v-if="limitSuffix" #trailing>
|
||||
<span class="text-xs text-muted">{{ limitSuffix }}</span>
|
||||
</template>
|
||||
</UInput>
|
||||
</DirectoryField>
|
||||
</div>
|
||||
<div v-else class="flex min-w-0 flex-col gap-1.5">
|
||||
<span class="font-medium text-highlighted">{{ t('Limits') }}</span>
|
||||
<span class="text-xs/4.5 text-muted">{{ noLimitsText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChoiceList
|
||||
v-if="type === 'select'"
|
||||
:options="field.options"
|
||||
:row-errors="errors.choices"
|
||||
:list-error="errors.options"
|
||||
:disabled="disabled"
|
||||
@add="emit('addChoice', $event)"
|
||||
@remove="emit('removeChoice', $event)"
|
||||
@rename="(index, text) => emit('renameChoice', index, text)"
|
||||
@reorder="emit('reorderChoices', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import FieldRow from '@/components/FieldRow.vue'
|
||||
import { useKeyboardReorder } from '@/composables/useKeyboardReorder'
|
||||
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 emit = defineEmits<{
|
||||
select: [uid: number]
|
||||
add: []
|
||||
remove: [uid: number]
|
||||
move: [uid: number, delta: number]
|
||||
reorder: [order: number[]]
|
||||
}>()
|
||||
|
||||
const list = ref<HTMLElement | null>(null)
|
||||
const focusedUid = ref<number | null>(null)
|
||||
|
||||
const reorder = useKeyboardReorder<BuilderField>({
|
||||
items: () => props.fields,
|
||||
name: (field) => field.label.trim() || t('Untitled field'),
|
||||
commit: (next) =>
|
||||
emit(
|
||||
'reorder',
|
||||
next.map((field) => field.uid),
|
||||
),
|
||||
root: () => list.value,
|
||||
})
|
||||
|
||||
const rows = computed(() => reorder.shown.value)
|
||||
const tabbableUid = computed(
|
||||
() => focusedUid.value ?? props.selectedUid ?? props.fields[0]?.uid ?? null,
|
||||
)
|
||||
const full = computed(() => props.fields.length >= BUILDER_LIMITS.fieldsMax)
|
||||
const countText = computed(() => t('{0} of {1}', [props.fields.length, BUILDER_LIMITS.fieldsMax]))
|
||||
|
||||
async function onMove(uid: number, delta: number) {
|
||||
const action = delta < 0 ? 'up' : 'down'
|
||||
emit('move', uid, delta)
|
||||
await nextTick()
|
||||
const row = list.value?.querySelector(`li[data-uid="${uid}"]`)
|
||||
const same = row?.querySelector<HTMLButtonElement>(`[data-action="${action}"]`)
|
||||
const other = row?.querySelector<HTMLButtonElement>(
|
||||
`[data-action="${action === 'up' ? 'down' : 'up'}"]`,
|
||||
)
|
||||
;(same && !same.disabled ? same : other)?.focus()
|
||||
}
|
||||
|
||||
function onRowKeydown(event: KeyboardEvent) {
|
||||
if (reorder.active.value || (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')) {
|
||||
return
|
||||
}
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.matches('[data-field-row]')) {
|
||||
return
|
||||
}
|
||||
const buttons = Array.from(list.value?.querySelectorAll<HTMLElement>('[data-field-row]') ?? [])
|
||||
const next = buttons[buttons.indexOf(target) + (event.key === 'ArrowUp' ? -1 : 1)]
|
||||
if (next) {
|
||||
event.preventDefault()
|
||||
next.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function onFocusin(event: FocusEvent) {
|
||||
const uid = (event.target as HTMLElement).closest('li[data-uid]')?.getAttribute('data-uid')
|
||||
focusedUid.value = uid ? Number(uid) : null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<div class="flex h-7 items-center justify-between">
|
||||
<h2 class="font-semibold text-highlighted">{{ t('Fields') }}</h2>
|
||||
<span class="font-mono text-xs text-muted" data-testid="field-count">{{
|
||||
countText
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="reorder-live"
|
||||
:class="
|
||||
reorder.active.value
|
||||
? 'bg-inverted px-2.5 py-2 text-xs/4.5 text-inverted'
|
||||
: 'sr-only'
|
||||
"
|
||||
>
|
||||
{{ reorder.announcement.value }}
|
||||
</div>
|
||||
|
||||
<ul
|
||||
v-if="rows.length"
|
||||
ref="list"
|
||||
class="m-0 flex list-none flex-col gap-2 p-0"
|
||||
@keydown="onRowKeydown"
|
||||
@focusin="onFocusin"
|
||||
>
|
||||
<FieldRow
|
||||
v-for="(field, index) in rows"
|
||||
:key="field.uid"
|
||||
:field="field"
|
||||
:index="index"
|
||||
:total="rows.length"
|
||||
:selected="!mobile && field.uid === selectedUid"
|
||||
:invalid="errors.has(field.uid)"
|
||||
:grabbed="reorder.grabbed.value === index"
|
||||
:disabled="disabled"
|
||||
:tabbable="field.uid === tabbableUid"
|
||||
:show-actions="!mobile"
|
||||
@select="emit('select', field.uid)"
|
||||
@up="onMove(field.uid, -1)"
|
||||
@down="onMove(field.uid, 1)"
|
||||
@remove="emit('remove', field.uid)"
|
||||
@grip-keydown="reorder.onKeydown($event, index)"
|
||||
/>
|
||||
</ul>
|
||||
<div
|
||||
v-else
|
||||
ref="list"
|
||||
data-testid="field-empty"
|
||||
class="flex flex-col items-center gap-2 border border-dashed px-4 py-8 text-center"
|
||||
:class="listError ? 'border-error' : 'border-accented'"
|
||||
>
|
||||
<UIcon name="i-lucide-rows-3" class="size-5 text-muted" />
|
||||
<span class="font-semibold">{{ t('No fields yet') }}</span>
|
||||
<span :class="listError ? 'text-error' : 'text-muted'">{{
|
||||
listError || t('Add the first field.')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
icon="i-lucide-plus"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:disabled="disabled || full"
|
||||
class="h-11 w-full justify-center border border-accented bg-default px-3 text-highlighted ring-0 md:h-8"
|
||||
@click="emit('add')"
|
||||
>
|
||||
{{ t('Add field') }}
|
||||
</UButton>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import BuilderBadge from '@/components/BuilderBadge.vue'
|
||||
import FieldActions from '@/components/FieldActions.vue'
|
||||
import type { BuilderField } from '@/lib/schemaBuilderModel'
|
||||
import { fieldIcon } from '@/lib/schemaFields'
|
||||
|
||||
const props = defineProps<{
|
||||
field: BuilderField
|
||||
index: number
|
||||
total: number
|
||||
selected: boolean
|
||||
invalid: boolean
|
||||
grabbed: boolean
|
||||
disabled: boolean
|
||||
tabbable: boolean
|
||||
showActions: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
select: []
|
||||
up: []
|
||||
down: []
|
||||
remove: []
|
||||
'grip-keydown': [event: KeyboardEvent]
|
||||
}>()
|
||||
|
||||
const name = computed(() => props.field.label.trim() || t('Untitled field'))
|
||||
const key = computed(() => props.field.key || '–')
|
||||
|
||||
const rowClass = computed(() => [
|
||||
props.invalid ? 'border-error' : props.grabbed ? 'border-primary' : 'border-default',
|
||||
props.grabbed ? 'shadow-(--shadow-popover) outline-2 -outline-offset-1 outline-primary' : '',
|
||||
props.selected ? 'bg-(--rc-sand) text-(--rc-on-sand)' : 'bg-default text-highlighted',
|
||||
props.disabled ? 'opacity-60' : '',
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
data-testid="field-row"
|
||||
:data-uid="field.uid"
|
||||
class="flex min-h-14 items-center gap-0.5 border py-1.5 pr-2 pl-1 max-md:min-h-16 max-md:gap-1"
|
||||
:class="rowClass"
|
||||
@click="$emit('select')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-grip
|
||||
:data-handle="index"
|
||||
:aria-label="t('Move {0}', [name])"
|
||||
:aria-pressed="grabbed"
|
||||
:disabled="disabled"
|
||||
:tabindex="tabbable ? 0 : -1"
|
||||
class="flex h-8 w-6 shrink-0 items-center justify-center focus-visible:outline-2 focus-visible:outline-primary max-md:h-11 max-md:w-9"
|
||||
:class="selected ? 'text-(--rc-on-sand)' : 'text-muted'"
|
||||
@keydown="$emit('grip-keydown', $event)"
|
||||
@click.stop
|
||||
>
|
||||
<UIcon name="i-lucide-grip-vertical" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-field-row
|
||||
:data-uid="field.uid"
|
||||
: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"
|
||||
@click.stop="$emit('select')"
|
||||
>
|
||||
<span
|
||||
class="flex size-7 shrink-0 items-center justify-center text-highlighted"
|
||||
:class="selected ? 'bg-default' : 'bg-elevated'"
|
||||
>
|
||||
<UIcon :name="fieldIcon(field.type)" class="size-4" />
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="truncate font-medium" data-testid="field-row-label">{{ name }}</span>
|
||||
<span
|
||||
class="truncate font-mono text-xs"
|
||||
:class="selected ? 'text-(--rc-on-sand)' : 'text-muted'"
|
||||
data-testid="field-row-key"
|
||||
>{{ key }}</span
|
||||
>
|
||||
</span>
|
||||
</button>
|
||||
<span class="flex shrink-0 items-center gap-1" data-testid="field-row-badges">
|
||||
<BuilderBadge kind="new" />
|
||||
<BuilderBadge v-if="field.required" kind="required" :on-sand="selected" />
|
||||
<BuilderBadge v-if="field.unique" kind="unique" :on-sand="selected" />
|
||||
<UIcon
|
||||
v-if="invalid"
|
||||
name="i-lucide-circle-alert"
|
||||
data-testid="field-row-alert"
|
||||
class="size-4 text-error"
|
||||
/>
|
||||
</span>
|
||||
<FieldActions
|
||||
v-if="selected && showActions"
|
||||
:first="index === 0"
|
||||
:last="index === total - 1"
|
||||
:disabled="disabled"
|
||||
@up="$emit('up')"
|
||||
@down="$emit('down')"
|
||||
@remove="$emit('remove')"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { ref } from 'vue'
|
||||
import FieldActions from '@/components/FieldActions.vue'
|
||||
import { BOTTOM_SHEET_UI } from '@/lib/sheetUi'
|
||||
|
||||
defineProps<{ title: string; first: boolean; last: boolean; disabled: boolean }>()
|
||||
|
||||
const emit = defineEmits<{ up: []; down: []; remove: []; closed: [] }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const titleElement = ref<HTMLElement | null>(null)
|
||||
|
||||
function focusTitle(event: Event) {
|
||||
event.preventDefault()
|
||||
titleElement.value?.focus()
|
||||
}
|
||||
|
||||
function returnFocus(event: Event) {
|
||||
event.preventDefault()
|
||||
emit('closed')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<USlideover
|
||||
v-model:open="open"
|
||||
side="bottom"
|
||||
:title="title"
|
||||
:description="title"
|
||||
:content="{ onOpenAutoFocus: focusTitle, onCloseAutoFocus: returnFocus }"
|
||||
:ui="BOTTOM_SHEET_UI"
|
||||
>
|
||||
<template #title>
|
||||
<span
|
||||
ref="titleElement"
|
||||
tabindex="-1"
|
||||
data-testid="field-sheet-title"
|
||||
class="outline-none"
|
||||
>{{ title }}</span
|
||||
>
|
||||
</template>
|
||||
<template #body>
|
||||
<FieldActions
|
||||
:first="first"
|
||||
:last="last"
|
||||
:disabled="disabled"
|
||||
class="self-end"
|
||||
@up="emit('up')"
|
||||
@down="emit('down')"
|
||||
@remove="emit('remove')"
|
||||
/>
|
||||
<slot />
|
||||
</template>
|
||||
<template #footer>
|
||||
<UButton class="h-11 flex-1 justify-center" @click="open = false">
|
||||
{{ t('Done') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</USlideover>
|
||||
</template>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
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 }>()
|
||||
|
||||
defineEmits<{ 'update:modelValue': [type: FieldType] }>()
|
||||
|
||||
function describe(type: FieldType) {
|
||||
switch (type) {
|
||||
case 'richtext':
|
||||
return t('Long text with formatting, written in Markdown')
|
||||
case 'number':
|
||||
return t('Whole or decimal numbers')
|
||||
case 'boolean':
|
||||
return t('An on or off switch')
|
||||
case 'date':
|
||||
return t('A calendar day, without a time')
|
||||
case 'select':
|
||||
return t('One option from a list you define')
|
||||
default:
|
||||
return t('Short text, like a title or a name')
|
||||
}
|
||||
}
|
||||
|
||||
const items = computed(() =>
|
||||
FIELD_TYPES.map((type) => ({ value: type, label: fieldTypeLabel(type) })),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<URadioGroup
|
||||
:model-value="modelValue"
|
||||
:items="items"
|
||||
:disabled="disabled"
|
||||
:legend="t('Type')"
|
||||
variant="card"
|
||||
indicator="hidden"
|
||||
orientation="horizontal"
|
||||
:ui="{
|
||||
root: 'w-full',
|
||||
legend: 'mb-1.5 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',
|
||||
label: 'w-full text-base font-medium text-inherit',
|
||||
}"
|
||||
@update:model-value="$emit('update:modelValue', $event as FieldType)"
|
||||
>
|
||||
<template #label="{ item }">
|
||||
<span class="flex items-start gap-2.5 text-left">
|
||||
<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>
|
||||
<span
|
||||
class="text-xs/4 font-normal text-pretty text-muted group-has-data-[state=checked]:text-(--rc-on-sand)"
|
||||
>{{ describe(item.value) }}</span
|
||||
>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</URadioGroup>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
defineProps<{ title: string; description: string }>()
|
||||
|
||||
defineEmits<{ retry: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UAlert
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
orientation="vertical"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-rotate-cw"
|
||||
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') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
</template>
|
||||
@@ -16,6 +16,9 @@ const props = withDefaults(
|
||||
help?: string
|
||||
manualHelp?: string
|
||||
locked?: boolean
|
||||
disabled?: boolean
|
||||
dense?: boolean
|
||||
resetInLabel?: boolean
|
||||
lockedHelp?: string
|
||||
editLabel?: string
|
||||
resetLabel?: string
|
||||
@@ -30,6 +33,9 @@ const props = withDefaults(
|
||||
help: '',
|
||||
manualHelp: '',
|
||||
locked: false,
|
||||
disabled: false,
|
||||
dense: false,
|
||||
resetInLabel: false,
|
||||
lockedHelp: '',
|
||||
editLabel: '',
|
||||
resetLabel: '',
|
||||
@@ -50,7 +56,7 @@ const emit = defineEmits<{
|
||||
const readonly = computed(() => props.locked || props.mode === 'empty' || props.mode === 'auto')
|
||||
const showAuto = computed(() => props.mode === 'auto' && !props.locked)
|
||||
const inputUi = computed(() => ({
|
||||
base: `h-11 font-mono text-base text-ellipsis md:h-10 md:text-code ${readonly.value ? 'bg-elevated' : ''} ${showAuto.value ? 'max-md:h-11.5 pe-28 md:pe-24' : ''}`,
|
||||
base: `h-11 font-mono text-base text-ellipsis ${props.dense ? 'md:h-8' : 'md:h-10'} md:text-code ${readonly.value ? 'bg-elevated' : ''} ${showAuto.value ? 'max-md:h-11.5 pe-28 md:pe-24' : ''}`,
|
||||
}))
|
||||
const showReset = computed(
|
||||
() => !props.locked && (props.mode === 'manual' || (props.mode === 'saved' && props.error)),
|
||||
@@ -72,9 +78,23 @@ const placeholderText = computed(() => props.placeholder || t('Generated from th
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-col gap-1.5">
|
||||
<DirectoryField :label="label" :name="name" :error="error" :help="shownHelp">
|
||||
<template v-if="resetInLabel && showReset" #hint>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="link"
|
||||
icon="i-lucide-rotate-ccw"
|
||||
:disabled="disabled"
|
||||
class="gap-1 p-0 font-sans text-xs font-medium text-highlighted underline underline-offset-3 max-md:hidden"
|
||||
:ui="{ leadingIcon: 'size-3' }"
|
||||
@click="emit('reset')"
|
||||
>
|
||||
{{ resetText }}
|
||||
</UButton>
|
||||
</template>
|
||||
<UInput
|
||||
:model-value="value"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:placeholder="placeholderText"
|
||||
:maxlength="maxlength"
|
||||
:data-autofocus="autofocus ? '' : undefined"
|
||||
@@ -103,6 +123,7 @@ const placeholderText = computed(() => props.placeholder || t('Generated from th
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:aria-label="pencilLabel"
|
||||
:disabled="disabled"
|
||||
class="size-7 justify-center text-highlighted max-md:size-11"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="emit('unlock')"
|
||||
@@ -127,8 +148,10 @@ const placeholderText = computed(() => props.placeholder || t('Generated from th
|
||||
v-if="showReset"
|
||||
color="neutral"
|
||||
variant="link"
|
||||
:disabled="disabled"
|
||||
icon="i-lucide-rotate-ccw"
|
||||
class="min-h-11 gap-1 self-start p-0 text-xs font-medium text-highlighted underline underline-offset-3 md:min-h-5"
|
||||
:class="resetInLabel ? 'md:hidden' : ''"
|
||||
:ui="{ leadingIcon: 'size-3' }"
|
||||
@click="emit('reset')"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import ApiPreview from '../ApiPreview.vue'
|
||||
import { mountInApp, text, unmountApp, holder } from './directoryKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
const writeText = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
writeText.mockReset().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
|
||||
})
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
const JSON_TEXT = '{\n "id": 42\n}'
|
||||
|
||||
const toggle = () => document.body.querySelector<HTMLElement>('[data-testid="api-preview-toggle"]')!
|
||||
|
||||
describe('ApiPreview', () => {
|
||||
it('is collapsed, then opens and closes with the toggle', async () => {
|
||||
await mountInApp(ApiPreview, { keyValue: 'recipes', json: JSON_TEXT })
|
||||
expect(toggle().getAttribute('aria-expanded')).toBe('false')
|
||||
expect(document.body.querySelector('[data-testid="api-json"]')).toBeNull()
|
||||
|
||||
toggle().click()
|
||||
await flushPromises()
|
||||
expect(toggle().getAttribute('aria-expanded')).toBe('true')
|
||||
expect(document.body.querySelector('[data-testid="api-json"]')!.textContent).toBe(JSON_TEXT)
|
||||
expect(holder.wrapper!.findComponent(ApiPreview).emitted('update:open')![0]).toEqual([true])
|
||||
})
|
||||
|
||||
it('shows the first route in the bar and falls back to events', async () => {
|
||||
await mountInApp(ApiPreview, { keyValue: '', json: JSON_TEXT })
|
||||
|
||||
expect(text(document.body.querySelector('[data-testid="api-preview"]'))).toContain(
|
||||
'GET /v1/content-type/events/entry',
|
||||
)
|
||||
})
|
||||
|
||||
it('copies the JSON and reads Copied', async () => {
|
||||
await mountInApp(ApiPreview, { keyValue: 'recipes', json: JSON_TEXT })
|
||||
|
||||
document.body.querySelector<HTMLElement>('[data-testid="api-copy"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(JSON_TEXT)
|
||||
expect(text(document.body.querySelector('[data-testid="api-copy"]'))).toBe('Copied')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import ChoiceList from '../ChoiceList.vue'
|
||||
import { mountInApp, text, unmountApp, holder } from './directoryKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
function props(extra: Record<string, unknown> = {}) {
|
||||
return { options: ['Talk', 'Workshop', 'Meetup'], disabled: false, ...extra }
|
||||
}
|
||||
|
||||
function emitted(name: string) {
|
||||
return holder.wrapper!.findComponent(ChoiceList).emitted(name) ?? []
|
||||
}
|
||||
|
||||
const addInput = () =>
|
||||
document.body.querySelector<HTMLInputElement>('input[aria-label="Add a choice"]')!
|
||||
|
||||
async function type(input: HTMLInputElement, value: string) {
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function addButton() {
|
||||
return [...document.body.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||
(candidate) => text(candidate) === 'Add',
|
||||
)!
|
||||
}
|
||||
|
||||
describe('ChoiceList', () => {
|
||||
it('shows the count, the rows and the help', async () => {
|
||||
await mountInApp(ChoiceList, props())
|
||||
|
||||
expect(text(document.body.querySelector('[data-testid="choice-count"]'))).toBe('3 of 50')
|
||||
expect(document.body.querySelectorAll('[data-testid="choice-row"]')).toHaveLength(3)
|
||||
expect(text(document.body)).toContain('Add 1 to 50 choices.')
|
||||
})
|
||||
|
||||
it('emits add from the button and from Enter, and ignores a blank draft', async () => {
|
||||
await mountInApp(ChoiceList, props())
|
||||
expect(addButton().disabled).toBe(true)
|
||||
|
||||
await type(addInput(), 'Panel')
|
||||
addButton().click()
|
||||
await flushPromises()
|
||||
await type(addInput(), 'Social')
|
||||
addInput().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
|
||||
await flushPromises()
|
||||
await type(addInput(), ' ')
|
||||
addInput().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted('add')).toEqual([['Panel'], ['Social']])
|
||||
expect(addInput().value).toBe(' ')
|
||||
})
|
||||
|
||||
it('emits rename and remove for a row', async () => {
|
||||
await mountInApp(ChoiceList, props())
|
||||
|
||||
await type(
|
||||
document.body.querySelectorAll<HTMLInputElement>(
|
||||
'[data-testid="choice-row"] input',
|
||||
)[1]!,
|
||||
'Seminar',
|
||||
)
|
||||
document.body.querySelector<HTMLElement>('[aria-label="Remove Talk"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted('rename')).toEqual([[1, 'Seminar']])
|
||||
expect(emitted('remove')).toEqual([[0]])
|
||||
})
|
||||
|
||||
it('shows a row error with an alert icon and ties it to the input', async () => {
|
||||
await mountInApp(ChoiceList, props({ rowErrors: { 1: 'Two choices are the same.' } }))
|
||||
|
||||
const row = document.body.querySelectorAll('[data-testid="choice-row"]')[1]!
|
||||
expect(text(row.querySelector('[role="alert"]'))).toBe('Two choices are the same.')
|
||||
expect(row.querySelector('[role="alert"] svg')).not.toBeNull()
|
||||
const input = row.querySelector('input')!
|
||||
expect(input.getAttribute('aria-invalid')).toBe('true')
|
||||
expect(input.getAttribute('aria-describedby')).toBe('choice-error-1')
|
||||
})
|
||||
|
||||
it('shows a list error and disables Add at 50 choices', async () => {
|
||||
await mountInApp(
|
||||
ChoiceList,
|
||||
props({ options: Array.from({ length: 50 }, (_, index) => `C${index}`) }),
|
||||
)
|
||||
await type(addInput(), 'more')
|
||||
|
||||
expect(addButton().disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the list error', async () => {
|
||||
await mountInApp(ChoiceList, props({ options: [], listError: 'Add at least one choice.' }))
|
||||
|
||||
expect(text(document.body.querySelector('p[role="alert"]'))).toBe(
|
||||
'Add at least one choice.',
|
||||
)
|
||||
})
|
||||
|
||||
it('emits the new order on drop only', async () => {
|
||||
await mountInApp(ChoiceList, props())
|
||||
const grip = () => document.body.querySelector<HTMLElement>('[aria-label="Move Talk"]')!
|
||||
const key = async (name: string) => {
|
||||
grip().dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: name, bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
await key(' ')
|
||||
await key('ArrowDown')
|
||||
expect(emitted('reorder')).toHaveLength(0)
|
||||
await key(' ')
|
||||
|
||||
expect(emitted('reorder')).toEqual([[['Workshop', 'Talk', 'Meetup']]])
|
||||
})
|
||||
|
||||
it('keeps each error with its choice while a row is being moved', async () => {
|
||||
await mountInApp(
|
||||
ChoiceList,
|
||||
props({
|
||||
options: ['Talk', 'Talk', 'Meetup'],
|
||||
rowErrors: { 1: 'Two choices are the same.' },
|
||||
}),
|
||||
)
|
||||
const grip = document.body.querySelector<HTMLElement>('[aria-label="Move Talk"]')!
|
||||
grip.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
grip.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
const rows = [...document.body.querySelectorAll('[data-testid="choice-row"]')]
|
||||
expect(rows.map((row) => text(row.querySelector('[role="alert"]')))).toEqual([
|
||||
'Two choices are the same.',
|
||||
'',
|
||||
'',
|
||||
])
|
||||
})
|
||||
|
||||
it('emits the data index for rename and remove while a row is being moved', async () => {
|
||||
await mountInApp(ChoiceList, props())
|
||||
const grip = document.body.querySelector<HTMLElement>('[aria-label="Move Talk"]')!
|
||||
grip.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
grip.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
const removers = [...document.body.querySelectorAll<HTMLButtonElement>('button')].filter(
|
||||
(candidate) => candidate.getAttribute('aria-label')?.startsWith('Remove '),
|
||||
)
|
||||
removers[0]!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted('remove')).toEqual([[1]])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import FieldInspector from '../FieldInspector.vue'
|
||||
import { createField } from '@/lib/schemaBuilderModel'
|
||||
import { mountInApp, text, unmountApp, holder } from './directoryKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
function props(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
field: createField(1, { label: 'Title', key: 'title' }),
|
||||
errors: {},
|
||||
disabled: false,
|
||||
compact: false,
|
||||
focusToken: 0,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
function emitted(name: string) {
|
||||
return holder.wrapper!.findComponent(FieldInspector).emitted(name) ?? []
|
||||
}
|
||||
|
||||
async function type(selector: string, value: string) {
|
||||
const input = document.body.querySelector<HTMLInputElement>(selector)!
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('FieldInspector', () => {
|
||||
it('emits label, key, type, limit and default edits', async () => {
|
||||
await mountInApp(
|
||||
FieldInspector,
|
||||
props({ field: createField(1, { label: 'T', key: 't', keyMode: 'manual' }) }),
|
||||
)
|
||||
|
||||
await type('input[name="label"]', 'Headline')
|
||||
await type('input[name="field-key"]', 'headline')
|
||||
await type('input[name="max"]', '80')
|
||||
await type('input[name="default"]', 'Draft')
|
||||
document.body.querySelector<HTMLElement>('[role="radio"][value="number"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted('label')).toEqual([['Headline']])
|
||||
expect(emitted('key')).toEqual([['headline']])
|
||||
expect(emitted('limit')).toEqual([['max', '80']])
|
||||
expect(emitted('default')).toEqual([['Draft']])
|
||||
expect(emitted('type')).toEqual([['number']])
|
||||
})
|
||||
|
||||
it('emits the Required switch', async () => {
|
||||
await mountInApp(FieldInspector, props())
|
||||
|
||||
const label = [...document.body.querySelectorAll('label')].find((node) =>
|
||||
text(node).startsWith('Required'),
|
||||
)!
|
||||
document.getElementById(label.getAttribute('for')!)!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted('required')).toEqual([[true]])
|
||||
})
|
||||
|
||||
it('dims and disables every control while disabled', async () => {
|
||||
await mountInApp(FieldInspector, props({ disabled: true }))
|
||||
|
||||
const root = document.body.querySelector('[data-testid="field-inspector"]')!
|
||||
expect(root.className).toContain('opacity-60')
|
||||
expect(
|
||||
[...root.querySelectorAll<HTMLInputElement>('input')].every((input) => input.disabled),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the errors under their controls', async () => {
|
||||
await mountInApp(
|
||||
FieldInspector,
|
||||
props({
|
||||
field: createField(1, { type: 'number', min: '6', max: '5', defaultValue: '0' }),
|
||||
errors: {
|
||||
label: 'Give the field a label.',
|
||||
key: 'Bad key.',
|
||||
min: 'Order.',
|
||||
default: 'Outside.',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const alerts = [...document.body.querySelectorAll('[role="alert"]')].map(text)
|
||||
expect(alerts).toEqual(['Give the field a label.', 'Bad key.', 'Outside.', 'Order.'])
|
||||
})
|
||||
|
||||
it('lays the controls out in one column when compact', async () => {
|
||||
await mountInApp(FieldInspector, props({ compact: true }))
|
||||
|
||||
expect(document.body.querySelector('.grid-cols-1')).not.toBeNull()
|
||||
expect(document.body.querySelector('.grid-cols-2')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import FieldList from '../FieldList.vue'
|
||||
import { createField, type BuilderField } from '@/lib/schemaBuilderModel'
|
||||
import { mountInApp, text, unmountApp, holder } from './directoryKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
const FIELDS: BuilderField[] = [
|
||||
createField(1, { label: 'Title', key: 'title' }),
|
||||
createField(2, { label: 'Starts on', key: 'starts_on', type: 'date' }),
|
||||
createField(3, { label: 'Ends on', key: 'ends_on', type: 'date' }),
|
||||
]
|
||||
|
||||
function props(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
fields: FIELDS,
|
||||
selectedUid: 2,
|
||||
errors: new Map(),
|
||||
disabled: false,
|
||||
mobile: false,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
function emitted(name: string) {
|
||||
return holder.wrapper!.findComponent(FieldList).emitted(name) ?? []
|
||||
}
|
||||
|
||||
function grip(label: string) {
|
||||
return document.body.querySelector<HTMLElement>(`[aria-label="Move ${label}"]`)!
|
||||
}
|
||||
|
||||
async function press(target: Element, key: string) {
|
||||
target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function labels() {
|
||||
return [...document.body.querySelectorAll('[data-testid="field-row-label"]')].map(text)
|
||||
}
|
||||
|
||||
describe('FieldList', () => {
|
||||
it('lists the rows and counts them against 50', async () => {
|
||||
await mountInApp(FieldList, props())
|
||||
|
||||
expect(labels()).toEqual(['Title', 'Starts on', 'Ends on'])
|
||||
expect(text(document.body.querySelector('[data-testid="field-count"]'))).toBe('3 of 50')
|
||||
})
|
||||
|
||||
it('emits select, add, move and remove', async () => {
|
||||
await mountInApp(FieldList, props())
|
||||
|
||||
document.body.querySelector<HTMLElement>('[data-field-row][data-uid="3"]')!.click()
|
||||
;(document.body.querySelector('[data-action="up"]') as HTMLElement).click()
|
||||
;(document.body.querySelector('[data-action="remove"]') as HTMLElement).click()
|
||||
const add = [...document.body.querySelectorAll('button')].find(
|
||||
(candidate) => text(candidate) === 'Add field',
|
||||
)!
|
||||
add.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted('select')).toEqual([[3]])
|
||||
expect(emitted('move')).toEqual([[2, -1]])
|
||||
expect(emitted('remove')).toEqual([[2]])
|
||||
expect(emitted('add')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('disables Add field at 50 rows and when disabled', async () => {
|
||||
const many = Array.from({ length: 50 }, (_, index) =>
|
||||
createField(index + 1, { label: 'F' }),
|
||||
)
|
||||
await mountInApp(FieldList, props({ fields: many, selectedUid: 1 }))
|
||||
|
||||
const add = [...document.body.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||
(candidate) => text(candidate) === 'Add field',
|
||||
)!
|
||||
expect(add.disabled).toBe(true)
|
||||
expect(text(document.body.querySelector('[data-testid="field-count"]'))).toBe('50 of 50')
|
||||
})
|
||||
|
||||
it('shows the empty state with the error message in the error colour', async () => {
|
||||
await mountInApp(FieldList, props({ fields: [], selectedUid: null, listError: 'Nope.' }))
|
||||
|
||||
const empty = document.body.querySelector('[data-testid="field-empty"]')!
|
||||
expect(text(empty)).toBe('No fields yet Nope.')
|
||||
expect(empty.querySelector('.text-error')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('emits the new order once, on drop, and nothing on cancel', async () => {
|
||||
await mountInApp(FieldList, props())
|
||||
|
||||
await press(grip('Starts on'), ' ')
|
||||
await press(grip('Starts on'), 'ArrowUp')
|
||||
await press(grip('Starts on'), 'Escape')
|
||||
expect(emitted('reorder')).toHaveLength(0)
|
||||
expect(labels()).toEqual(['Title', 'Starts on', 'Ends on'])
|
||||
|
||||
await press(grip('Starts on'), ' ')
|
||||
await press(grip('Starts on'), 'ArrowUp')
|
||||
expect(emitted('reorder')).toHaveLength(0)
|
||||
await press(grip('Starts on'), ' ')
|
||||
|
||||
expect(emitted('reorder')).toEqual([[[2, 1, 3]]])
|
||||
})
|
||||
|
||||
it('moves the focus between rows with the arrow keys', async () => {
|
||||
await mountInApp(FieldList, props())
|
||||
const rows = [...document.body.querySelectorAll<HTMLElement>('[data-field-row]')]
|
||||
rows[0]!.focus()
|
||||
|
||||
await press(rows[0]!, 'ArrowDown')
|
||||
expect(document.activeElement).toBe(rows[1])
|
||||
await press(rows[1]!, 'ArrowUp')
|
||||
expect(document.activeElement).toBe(rows[0])
|
||||
})
|
||||
|
||||
it('keeps one roving tab stop for the rows', async () => {
|
||||
await mountInApp(FieldList, props())
|
||||
|
||||
const stops = [...document.body.querySelectorAll<HTMLElement>('[data-field-row]')].filter(
|
||||
(row) => row.tabIndex === 0,
|
||||
)
|
||||
expect(stops.map((row) => row.getAttribute('data-uid'))).toEqual(['2'])
|
||||
})
|
||||
|
||||
it('hides the row actions on mobile', async () => {
|
||||
await mountInApp(FieldList, props({ mobile: true }))
|
||||
|
||||
expect(document.body.querySelector('[data-action]')).toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user