feat: relation field and picker
This commit is contained in:
1 parent
b45c99be3b
commit
37a77c9c22
92 files changed
+6826
-80
No files matched your search
@@ -29,6 +29,7 @@ const props = defineProps<{
|
||||
field: BuilderField
|
||||
errors: FieldErrors
|
||||
compact: boolean
|
||||
ownerName?: string
|
||||
}>()
|
||||
|
||||
const uid = () => props.field.uid
|
||||
@@ -40,12 +41,15 @@ const uid = () => props.field.uid
|
||||
:errors="errors"
|
||||
:disabled="saving"
|
||||
:compact="compact"
|
||||
:owner-name="ownerName"
|
||||
:focus-token="focusToken"
|
||||
@label="actions.setLabel(uid(), $event)"
|
||||
@key="actions.setFieldKey(uid(), $event)"
|
||||
@unlock="actions.unlockFieldKey(uid())"
|
||||
@reset="actions.resetFieldKey(uid())"
|
||||
@type="actions.setFieldType(uid(), $event)"
|
||||
@relation-type="actions.patchField(uid(), { relationType: $event }, 'relation')"
|
||||
@relation-kind="actions.patchField(uid(), { relationKind: $event })"
|
||||
@required="actions.patchField(uid(), { required: $event })"
|
||||
@unique="actions.patchField(uid(), { unique: $event })"
|
||||
@limit="(which, value) => actions.setLimit(uid(), which, value)"
|
||||
|
||||
@@ -11,6 +11,7 @@ const props = defineProps<{
|
||||
name: string
|
||||
keyValue: string
|
||||
total: number
|
||||
referrer: string
|
||||
pending: boolean
|
||||
offline: boolean
|
||||
}>()
|
||||
@@ -43,6 +44,20 @@ const notice = computed(() => {
|
||||
),
|
||||
}
|
||||
}
|
||||
if (props.stage === 'referenced') {
|
||||
return {
|
||||
title: t("You can't delete {0} yet", [props.name]),
|
||||
description: props.referrer
|
||||
? t('{0} has a field that points to {1}. Remove that field first.', [
|
||||
props.referrer,
|
||||
props.name,
|
||||
])
|
||||
: t(
|
||||
'Another content type has a field that points to {0}. Remove that field first.',
|
||||
[props.name],
|
||||
),
|
||||
}
|
||||
}
|
||||
if (props.stage === 'failed') {
|
||||
return {
|
||||
title: t("Couldn't delete the content type"),
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
thumbnailColumn,
|
||||
} from '@/lib/entryCells'
|
||||
import { t } from '@/i18n'
|
||||
import { fieldTypeSpec } from '@/lib/fieldTypes'
|
||||
import { mediaOf } from '@/lib/mediaField'
|
||||
import { summariesOf } from '@/lib/relationField'
|
||||
import { entryPath } from '@/lib/routePaths'
|
||||
import { computed } from 'vue'
|
||||
|
||||
@@ -26,11 +28,21 @@ const props = defineProps<{
|
||||
|
||||
const shown = computed(() => cardFields(props.fields))
|
||||
const lead = computed(() => thumbnailColumn(props.columns))
|
||||
const relationColumns = computed(() =>
|
||||
props.columns.filter(
|
||||
(field) => field !== shown.value.first && fieldTypeSpec(field.type).cell === 'relation',
|
||||
),
|
||||
)
|
||||
|
||||
function textOf(field: BuiltinField | undefined, entry: ContentEntry) {
|
||||
return field
|
||||
? plainCellText(
|
||||
describeCell(field, entry.data[field.key], mediaOf(entry, field.key) ?? undefined),
|
||||
describeCell(
|
||||
field,
|
||||
entry.data[field.key],
|
||||
mediaOf(entry, field.key) ?? undefined,
|
||||
summariesOf(entry, field.key),
|
||||
),
|
||||
)
|
||||
: ''
|
||||
}
|
||||
@@ -80,6 +92,20 @@ function nameOf(entry: ContentEntry) {
|
||||
<span class="truncate text-md/5.25 font-medium text-highlighted">{{
|
||||
titleOf(entry)
|
||||
}}</span>
|
||||
<span
|
||||
v-for="field in relationColumns"
|
||||
:key="field.key"
|
||||
data-testid="entry-card-relation"
|
||||
class="flex flex-wrap items-center gap-1.5"
|
||||
>
|
||||
<span class="text-xs text-muted">{{ field.label }}</span>
|
||||
<FieldCell
|
||||
:field="field"
|
||||
:value="entry.data[field.key]"
|
||||
:relations="summariesOf(entry, field.key)"
|
||||
compact
|
||||
/>
|
||||
</span>
|
||||
<span class="flex items-center gap-2 text-xs text-muted">
|
||||
<template v-if="shown.second && !secondIsChoice">
|
||||
<span class="shrink-0">{{ textOf(shown.second, entry) }}</span>
|
||||
|
||||
@@ -111,6 +111,8 @@ function backToList() {
|
||||
:fields="page.fields.value"
|
||||
:values="page.form.values.value"
|
||||
:images="page.form.images.value"
|
||||
:relations="page.form.relations.value"
|
||||
:type-name="page.typeName.value"
|
||||
:issues="page.form.issues.value"
|
||||
:first-issue="page.form.firstIssue.value"
|
||||
:focus-token="page.focusToken.value"
|
||||
|
||||
@@ -4,10 +4,12 @@ import BuilderBadge from '@/components/BuilderBadge.vue'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import EntryControl from '@/components/EntryControl.vue'
|
||||
import MediaFieldControl from '@/components/MediaFieldControl.vue'
|
||||
import RelationField from '@/components/RelationField.vue'
|
||||
import type { BuiltinField } from '@/lib/contentTypes'
|
||||
import type { FieldIssue } from '@/lib/entryForm'
|
||||
import { fieldTypeSpec } from '@/lib/fieldTypes'
|
||||
import { isMediaId, type MediaSummary } from '@/lib/mediaField'
|
||||
import { holdsManyRelations, type RelationSummary } from '@/lib/relationField'
|
||||
|
||||
const props = defineProps<{
|
||||
field: BuiltinField
|
||||
@@ -16,14 +18,24 @@ const props = defineProps<{
|
||||
isDefault: boolean
|
||||
disabled: boolean
|
||||
image?: MediaSummary | null
|
||||
relations?: RelationSummary[]
|
||||
typeName: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ update: [key: string, value: unknown, image?: MediaSummary | null] }>()
|
||||
const emit = defineEmits<{
|
||||
update: [
|
||||
key: string,
|
||||
value: unknown,
|
||||
image?: MediaSummary | null,
|
||||
relations?: RelationSummary[],
|
||||
]
|
||||
}>()
|
||||
|
||||
const editor = computed(() => fieldTypeSpec(props.field.type).editor)
|
||||
const counter = computed(() => editor.value.counter(props.field, props.value))
|
||||
const help = computed(() => editor.value.help(props.field))
|
||||
const isMedia = computed(() => editor.value.control === 'media')
|
||||
const isRelation = computed(() => editor.value.control === 'relation')
|
||||
const mediaId = computed(() => (isMediaId(props.value) ? props.value : null))
|
||||
</script>
|
||||
|
||||
@@ -40,6 +52,20 @@ const mediaId = computed(() => (isMediaId(props.value) ? props.value : null))
|
||||
:error="issue?.message"
|
||||
@change="(id, picked) => emit('update', field.key, id, picked)"
|
||||
/>
|
||||
<RelationField
|
||||
v-else-if="isRelation"
|
||||
:label="field.label"
|
||||
:name="field.key"
|
||||
:required="field.required"
|
||||
:many="holdsManyRelations(field)"
|
||||
:target="field.relation?.content_type ?? ''"
|
||||
:type-name="typeName"
|
||||
:value="value"
|
||||
:summaries="relations"
|
||||
:disabled="disabled"
|
||||
:error="issue?.message"
|
||||
@change="(next, summaries) => emit('update', field.key, next, undefined, summaries)"
|
||||
/>
|
||||
<DirectoryField
|
||||
v-else
|
||||
:label="field.label"
|
||||
|
||||
@@ -6,6 +6,7 @@ import EntryField from '@/components/EntryField.vue'
|
||||
import type { BuiltinField } from '@/lib/contentTypes'
|
||||
import type { EntryIssues, EntryValues } from '@/lib/entryForm'
|
||||
import type { MediaMap, MediaSummary } from '@/lib/mediaField'
|
||||
import type { RelationSummaries, RelationSummary } from '@/lib/relationField'
|
||||
import { groupFields } from '@/lib/entryLayout'
|
||||
|
||||
const FOCUSABLE =
|
||||
@@ -15,6 +16,8 @@ const props = defineProps<{
|
||||
fields: BuiltinField[]
|
||||
values: EntryValues
|
||||
images: MediaMap
|
||||
relations: RelationSummaries
|
||||
typeName: string
|
||||
issues: EntryIssues
|
||||
firstIssue: string | null
|
||||
focusToken: number
|
||||
@@ -28,7 +31,12 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [key: string, value: unknown, image?: MediaSummary | null]
|
||||
update: [
|
||||
key: string,
|
||||
value: unknown,
|
||||
image?: MediaSummary | null,
|
||||
relations?: RelationSummary[],
|
||||
]
|
||||
submit: []
|
||||
}>()
|
||||
|
||||
@@ -73,10 +81,14 @@ watch(() => props.focusToken, focusFirstIssue)
|
||||
:field="field"
|
||||
:value="values[field.key]"
|
||||
:image="images[field.key] ?? null"
|
||||
:relations="relations[field.key] ?? []"
|
||||
:type-name="typeName"
|
||||
:issue="issues[field.key]"
|
||||
:is-default="isDefault(field)"
|
||||
:disabled="locked"
|
||||
@update="(key, value, image) => emit('update', key, value, image)"
|
||||
@update="
|
||||
(key, value, image, summaries) => emit('update', key, value, image, summaries)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,12 +20,17 @@ const props = defineProps<{
|
||||
|
||||
defineEmits<{ retry: []; reload: [] }>()
|
||||
|
||||
const NAMED_CONTROLS = ['media', 'relation']
|
||||
|
||||
const uniqueField = computed(() =>
|
||||
props.fields.find((field) => props.issues[field.key]?.rule === 'unique'),
|
||||
props.fields.find(
|
||||
(field) =>
|
||||
props.issues[field.key]?.rule === 'unique' && fieldTypeSpec(field.type).supportsUnique,
|
||||
),
|
||||
)
|
||||
const changedLine = computed(() => {
|
||||
const changed = props.fields.find((field) => field.key === props.schemaField)
|
||||
return changed && fieldTypeSpec(changed.type).editor.control === 'media'
|
||||
return changed && NAMED_CONTROLS.includes(fieldTypeSpec(changed.type).editor.control)
|
||||
? mediaChangedLine(changed.label)
|
||||
: schemaChangedLine(props.schemaField)
|
||||
})
|
||||
|
||||
@@ -9,11 +9,13 @@ import { EMPTY_TEXT, formatEntryDate } from '@/lib/entryCells'
|
||||
import { ENTRY_SORT_FIELDS, sortParts } from '@/lib/entryListQuery'
|
||||
import { fieldTypeSpec } from '@/lib/fieldTypes'
|
||||
import { mediaOf } from '@/lib/mediaField'
|
||||
import { summariesOf } from '@/lib/relationField'
|
||||
import { entryPath } from '@/lib/routePaths'
|
||||
|
||||
const ID_WIDTH = 'w-16.5'
|
||||
const NARROW_WIDTH = 'w-23'
|
||||
const FIELD_WIDTH = 'w-33'
|
||||
const WIDE_WIDTH = 'w-50'
|
||||
const CREATED_WIDTH = 'w-30.5'
|
||||
const UPDATED_WIDTH = 'w-40.5'
|
||||
const UPDATED_BY_WIDTH = 'w-37'
|
||||
@@ -37,7 +39,11 @@ function widthOf(field: BuiltinField, index: number) {
|
||||
if (index === 0) {
|
||||
return ''
|
||||
}
|
||||
return fieldTypeSpec(field.type).narrowColumn ? NARROW_WIDTH : FIELD_WIDTH
|
||||
const spec = fieldTypeSpec(field.type)
|
||||
if (spec.wideColumn) {
|
||||
return WIDE_WIDTH
|
||||
}
|
||||
return spec.narrowColumn ? NARROW_WIDTH : FIELD_WIDTH
|
||||
}
|
||||
|
||||
function column(id: string, header: string, width: string, edge = 'px-1.5') {
|
||||
@@ -152,6 +158,7 @@ watchPostEffect(() => {
|
||||
:field="field"
|
||||
:value="row.original.data[field.key]"
|
||||
:image="mediaOf(row.original, field.key)"
|
||||
:relations="summariesOf(row.original, field.key)"
|
||||
class="min-w-0"
|
||||
/>
|
||||
</RouterLink>
|
||||
@@ -160,6 +167,7 @@ watchPostEffect(() => {
|
||||
:field="field"
|
||||
:value="row.original.data[field.key]"
|
||||
:image="mediaOf(row.original, field.key)"
|
||||
:relations="summariesOf(row.original, field.key)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -2,18 +2,23 @@
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import RelationChip from '@/components/RelationChip.vue'
|
||||
import type { BuiltinField } from '@/lib/contentTypes'
|
||||
import { EMPTY_TEXT, describeCell, isEmptyCell } from '@/lib/entryCells'
|
||||
import { isMissingImage, type MediaSummary } from '@/lib/mediaField'
|
||||
import type { RelationSummary } from '@/lib/relationField'
|
||||
|
||||
const props = defineProps<{
|
||||
field: BuiltinField
|
||||
value: unknown
|
||||
image?: MediaSummary | null
|
||||
relations?: RelationSummary[]
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const cell = computed(() => describeCell(props.field, props.value, props.image ?? undefined))
|
||||
const cell = computed(() =>
|
||||
describeCell(props.field, props.value, props.image ?? undefined, props.relations),
|
||||
)
|
||||
const shownImage = computed(() => {
|
||||
const image = cell.value.image
|
||||
return image && !isMissingImage(image) ? image : null
|
||||
@@ -61,6 +66,21 @@ const TIP_UI = {
|
||||
<UIcon name="i-lucide-unlink" :class="compact ? 'size-3' : 'size-3.5'" />
|
||||
</span>
|
||||
</UTooltip>
|
||||
<span
|
||||
v-else-if="cell.kind === 'relation'"
|
||||
data-testid="relation-cell"
|
||||
:title="cell.text"
|
||||
class="flex min-w-0 items-center gap-1"
|
||||
:class="compact ? 'flex-wrap' : 'overflow-hidden'"
|
||||
>
|
||||
<RelationChip
|
||||
v-for="chip in cell.chips"
|
||||
:key="chip.id"
|
||||
:label="chip.label"
|
||||
:missing="chip.missing"
|
||||
/>
|
||||
<span v-if="cell.more" class="text-xs text-muted">+{{ cell.more }}</span>
|
||||
</span>
|
||||
<span v-else-if="cell.kind === 'boolean'" class="flex items-center gap-1.5">
|
||||
<UIcon
|
||||
:name="cell.yes ? 'i-lucide-check' : 'i-lucide-minus'"
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 RelationPicker from '@/components/RelationPicker.vue'
|
||||
import SlugField from '@/components/SlugField.vue'
|
||||
import { fieldTypeSpec } from '@/lib/fieldTypes'
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
type BuilderField,
|
||||
type FieldType,
|
||||
} from '@/lib/schemaBuilderModel'
|
||||
import type { RelationKind } from '@/lib/relationField'
|
||||
import type { FieldErrors } from '@/lib/schemaBuilderValidation'
|
||||
|
||||
const NO_DEFAULT = 'none'
|
||||
@@ -29,6 +31,7 @@ const props = defineProps<{
|
||||
disabled: boolean
|
||||
compact: boolean
|
||||
focusToken: number
|
||||
ownerName?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -37,6 +40,8 @@ const emit = defineEmits<{
|
||||
unlock: []
|
||||
reset: []
|
||||
type: [type: FieldType]
|
||||
relationType: [key: string]
|
||||
relationKind: [kind: RelationKind]
|
||||
required: [value: boolean]
|
||||
unique: [value: boolean]
|
||||
limit: [which: 'min' | 'max', value: string]
|
||||
@@ -58,6 +63,7 @@ const showUnique = computed(() => canUnique.value || spec.value.showsUnsupported
|
||||
const showRuleGrid = computed(
|
||||
() => hasLimits.value || spec.value.supportsDefault || spec.value.showsUnsupportedOptions,
|
||||
)
|
||||
const holdsRelation = computed(() => spec.value.holdsRelation)
|
||||
const hint = computed(() => spec.value.builderHint?.() ?? '')
|
||||
const keyMode = computed(() => keyDisplayMode(props.field.keyMode, props.field.key))
|
||||
const gridClass = computed(() => (props.compact ? 'grid-cols-1' : 'md:grid-cols-2'))
|
||||
@@ -193,6 +199,20 @@ watch(
|
||||
@update:model-value="emit('type', $event)"
|
||||
/>
|
||||
|
||||
<RelationPicker
|
||||
v-if="holdsRelation"
|
||||
:label="field.label"
|
||||
:owner-name="ownerName ?? ''"
|
||||
:type-key="field.relationType"
|
||||
:kind="field.relationKind"
|
||||
:locked="field.saved"
|
||||
:disabled="disabled"
|
||||
:compact="compact"
|
||||
:error="errors.relation"
|
||||
@type="emit('relationType', $event)"
|
||||
@kind="emit('relationKind', $event)"
|
||||
/>
|
||||
|
||||
<div class="flex min-w-0 flex-col border-t border-default">
|
||||
<AppSwitch
|
||||
:model-value="field.required"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ label: string; missing?: boolean; tall?: boolean }>(), {
|
||||
missing: false,
|
||||
tall: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UBadge
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
:label="label"
|
||||
data-testid="relation-chip"
|
||||
:data-missing="missing ? 'true' : undefined"
|
||||
class="max-w-full ring-0"
|
||||
:class="
|
||||
missing
|
||||
? 'border border-dashed border-accented bg-transparent text-toned'
|
||||
: 'bg-(--rc-sand) text-(--rc-on-sand)'
|
||||
"
|
||||
:ui="{
|
||||
base: [
|
||||
tall ? 'h-5.5 px-2' : 'h-5 px-1.5',
|
||||
'rounded-none py-0 text-xs font-medium whitespace-nowrap',
|
||||
],
|
||||
label: 'truncate',
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,461 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onMounted, ref, useId, watch } from 'vue'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import { useRelationSearch } from '@/composables/useRelationSearch'
|
||||
import { relationLabel } from '@/lib/entryCells'
|
||||
import {
|
||||
RELATION_MAX,
|
||||
isMissingRelation,
|
||||
relationIdsOf,
|
||||
type RelationSummary,
|
||||
} from '@/lib/relationField'
|
||||
import { relationRowOf } from '@/lib/relationRows'
|
||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||
|
||||
interface ChipItem {
|
||||
label: string
|
||||
value: number
|
||||
missing: boolean
|
||||
summary: RelationSummary
|
||||
}
|
||||
|
||||
interface RowItem {
|
||||
label: string
|
||||
description: string
|
||||
value: number
|
||||
summary: RelationSummary
|
||||
}
|
||||
|
||||
interface HighlightPayload {
|
||||
ref?: HTMLElement
|
||||
}
|
||||
|
||||
const SKELETON_ROWS = 3
|
||||
const LISTBOX = '[role="listbox"]'
|
||||
const OWNER_ATTRIBUTE = 'data-relation-owner'
|
||||
const VOWEL = /^[aeiou]/
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
name?: string
|
||||
required?: boolean
|
||||
many?: boolean
|
||||
target: string
|
||||
typeName: string
|
||||
value: unknown
|
||||
summaries?: RelationSummary[]
|
||||
disabled?: boolean
|
||||
error?: string
|
||||
}>(),
|
||||
{
|
||||
name: undefined,
|
||||
required: false,
|
||||
many: false,
|
||||
summaries: () => [],
|
||||
disabled: false,
|
||||
error: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [value: number | number[] | null, summaries: RelationSummary[]]
|
||||
}>()
|
||||
|
||||
const id = useId()
|
||||
const labelId = `${id}-label`
|
||||
const helpId = `${id}-help`
|
||||
const errorId = `${id}-error`
|
||||
|
||||
const store = useContentTypesStore()
|
||||
const search = useRelationSearch(() => props.target)
|
||||
const open = ref(false)
|
||||
const searchTerm = ref('')
|
||||
const announcement = ref('')
|
||||
const activeId = ref<string | undefined>(undefined)
|
||||
const listboxId = ref<string | undefined>(undefined)
|
||||
|
||||
const ids = computed(() => relationIdsOf(props.value))
|
||||
const chips = computed<ChipItem[]>(() =>
|
||||
ids.value.map((chipId) => {
|
||||
const summary = props.summaries.find((candidate) => candidate.id === chipId) ?? {
|
||||
id: chipId,
|
||||
missing: true as const,
|
||||
}
|
||||
return {
|
||||
label: relationLabel(summary),
|
||||
value: chipId,
|
||||
missing: isMissingRelation(summary),
|
||||
summary,
|
||||
}
|
||||
}),
|
||||
)
|
||||
const targetType = computed(() => store.items.find((type) => type.key === props.target))
|
||||
const targetName = computed(() => targetType.value?.name ?? props.target)
|
||||
const atCap = computed(() => props.many && ids.value.length >= RELATION_MAX)
|
||||
const rows = computed<RowItem[]>(() =>
|
||||
search.entries.value
|
||||
.map((entry) => relationRowOf(entry, props.target, targetType.value?.fields ?? []))
|
||||
.filter((row) => !ids.value.includes(row.id))
|
||||
.map((row) => ({
|
||||
label: row.title,
|
||||
description: row.detail,
|
||||
value: row.id,
|
||||
summary: row.summary,
|
||||
})),
|
||||
)
|
||||
const menuItems = computed(() => (atCap.value || search.showSkeleton.value ? [] : rows.value))
|
||||
const hasMissing = computed(() => chips.value.some((chip) => chip.missing))
|
||||
const ownerWord = computed(() => props.typeName.replace(/s$/i, '').toLowerCase())
|
||||
const help = computed(() => {
|
||||
if (hasMissing.value) {
|
||||
return props.required
|
||||
? t('{0} is required. Choose another to save.', [props.label])
|
||||
: t('You can still save. The entry keeps the link until you change it.')
|
||||
}
|
||||
if (!props.many) {
|
||||
return ''
|
||||
}
|
||||
const plural = props.label.toLowerCase()
|
||||
return VOWEL.test(ownerWord.value)
|
||||
? t('An {0} can have many {1}.', [ownerWord.value, plural])
|
||||
: t('A {0} can have many {1}.', [ownerWord.value, plural])
|
||||
})
|
||||
const describedBy = computed(() => (props.error ? `${errorId} ${helpId}` : helpId))
|
||||
const emptyKind = computed(() => {
|
||||
if (atCap.value) {
|
||||
return 'cap'
|
||||
}
|
||||
if (search.showSkeleton.value) {
|
||||
return 'loading'
|
||||
}
|
||||
const status = search.status.value
|
||||
if (status === 'limited' || status === 'failed') {
|
||||
return status
|
||||
}
|
||||
if (status !== 'ready') {
|
||||
return 'none'
|
||||
}
|
||||
if (search.total.value > 0) {
|
||||
return 'chosen'
|
||||
}
|
||||
return search.query.value === '' ? 'empty' : 'noMatch'
|
||||
})
|
||||
const baseUi = computed(() =>
|
||||
props.error
|
||||
? 'border-error shadow-[inset_0_0_0_1px_var(--ui-error)] has-focus-visible:border-error'
|
||||
: 'border-accented has-focus-visible:border-primary has-focus-visible:shadow-[inset_0_0_0_1px_var(--ui-primary)]',
|
||||
)
|
||||
const menuUi = computed(() => ({
|
||||
base: `min-h-8 gap-1.5 rounded-none border bg-default px-2 py-1 text-sm ring-0 outline-0 has-focus-visible:ring-0 has-focus-visible:outline-0 max-md:min-h-11 max-md:text-lg ${baseUi.value} ${props.many ? '' : 'pe-8'}`,
|
||||
tagsItem:
|
||||
'h-6 gap-1.5 rounded-none bg-(--rc-sand) py-0 ps-2 pe-1 text-sm font-medium text-(--rc-on-sand) ring-0 has-data-[missing=true]:border has-data-[missing=true]:border-dashed has-data-[missing=true]:border-accented has-data-[missing=true]:bg-transparent has-data-[missing=true]:font-normal has-data-[missing=true]:text-toned max-md:h-8',
|
||||
tagsItemText: 'truncate',
|
||||
tagsItemDelete:
|
||||
'relative rounded-none text-inherit hover:bg-transparent hover:text-inherit max-md:before:absolute max-md:before:-inset-1.5',
|
||||
tagsInput: 'min-w-20 flex-1 placeholder:text-muted',
|
||||
trailing: 'pe-2.5',
|
||||
trailingIcon: 'size-3.5 text-muted',
|
||||
content: 'rounded-none border border-default p-1 shadow-(--shadow-popover) ring-0',
|
||||
empty: 'p-2 text-left text-sm text-muted',
|
||||
group: 'p-0',
|
||||
item: 'min-h-10 flex-col justify-center gap-0 rounded-none px-2 py-1 before:inset-0 before:rounded-none data-highlighted:not-data-disabled:before:bg-elevated max-md:min-h-12',
|
||||
itemLabel: 'font-medium text-highlighted',
|
||||
itemDescription: 'text-xs/5 text-muted',
|
||||
}))
|
||||
|
||||
function removeName(element: HTMLElement, binding: { value: string }) {
|
||||
const button = element.closest('button')
|
||||
button?.removeAttribute('aria-labelledby')
|
||||
button?.setAttribute('aria-label', binding.value)
|
||||
}
|
||||
|
||||
const vRemoveName = { mounted: removeName, updated: removeName }
|
||||
|
||||
function removeLabel(item: ChipItem) {
|
||||
return t('Remove {0} from {1}', [item.label, props.label])
|
||||
}
|
||||
|
||||
function announce(message: string) {
|
||||
announcement.value = ''
|
||||
nextTick(() => {
|
||||
announcement.value = message
|
||||
})
|
||||
}
|
||||
|
||||
function summaryOf(chipId: number, picked: RowItem[]) {
|
||||
return (
|
||||
chips.value.find((chip) => chip.value === chipId)?.summary ??
|
||||
picked.find((row) => row.value === chipId)?.summary
|
||||
)
|
||||
}
|
||||
|
||||
function changeSingle(picked: RowItem[]) {
|
||||
const row = picked[picked.length - 1]
|
||||
if (!row) {
|
||||
emit('change', null, [])
|
||||
announce(t('{0} cleared.', [props.label]))
|
||||
return
|
||||
}
|
||||
emit('change', row.value, [row.summary])
|
||||
announce(t('{0} set to {1}.', [props.label, row.label]))
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function changeMany(next: Array<ChipItem | RowItem>, picked: RowItem[]) {
|
||||
const nextIds = next.map((item) => item.value)
|
||||
emit(
|
||||
'change',
|
||||
nextIds,
|
||||
nextIds.flatMap((nextId) => summaryOf(nextId, picked) ?? []),
|
||||
)
|
||||
const removed = chips.value.find((chip) => !nextIds.includes(chip.value))
|
||||
if (removed) {
|
||||
announce(t('{0} removed from {1}.', [removed.label, props.label]))
|
||||
} else if (picked[0]) {
|
||||
announce(t('{0} added to {1}.', [picked[0].label, props.label]))
|
||||
}
|
||||
}
|
||||
|
||||
function onUpdate(next: Array<ChipItem | RowItem>) {
|
||||
if (props.disabled) {
|
||||
return
|
||||
}
|
||||
const picked = next.filter(
|
||||
(item): item is RowItem => !ids.value.includes(item.value) && 'description' in item,
|
||||
)
|
||||
if (props.many) {
|
||||
changeMany(next, picked)
|
||||
} else {
|
||||
changeSingle(picked)
|
||||
}
|
||||
}
|
||||
|
||||
function onBackspace(event: KeyboardEvent) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const lastChip = chips.value.length - 1
|
||||
if (
|
||||
event.key !== 'Backspace' ||
|
||||
input.getAttribute('role') !== 'combobox' ||
|
||||
input.value !== '' ||
|
||||
lastChip < 0 ||
|
||||
props.disabled
|
||||
) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onUpdate(chips.value.slice(0, lastChip))
|
||||
}
|
||||
|
||||
function onHighlight(payload: HighlightPayload | undefined) {
|
||||
activeId.value = payload?.ref?.id || undefined
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchTerm.value = ''
|
||||
}
|
||||
|
||||
async function trackListbox() {
|
||||
await nextTick()
|
||||
const owned = `[${OWNER_ATTRIBUTE}="${id}"]`
|
||||
listboxId.value =
|
||||
document.querySelector<HTMLElement>(`${owned}${LISTBOX}, ${owned} ${LISTBOX}`)?.id ||
|
||||
undefined
|
||||
}
|
||||
|
||||
function announceResults() {
|
||||
const total = search.total.value
|
||||
announce(total > 0 ? t('{0} results', [total]) : t('No results'))
|
||||
}
|
||||
|
||||
watch(searchTerm, (term) => {
|
||||
if (open.value) {
|
||||
search.search(term)
|
||||
}
|
||||
})
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
search.search(searchTerm.value)
|
||||
trackListbox()
|
||||
} else {
|
||||
activeId.value = undefined
|
||||
listboxId.value = undefined
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => search.status.value,
|
||||
(status) => {
|
||||
if (open.value && status === 'ready') {
|
||||
announceResults()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => store.load())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-w-0">
|
||||
<DirectoryField
|
||||
:label="label"
|
||||
:required="required"
|
||||
:name="name"
|
||||
:label-id="labelId"
|
||||
:error="error"
|
||||
:error-id="errorId"
|
||||
>
|
||||
<div
|
||||
role="group"
|
||||
:aria-labelledby="labelId"
|
||||
data-testid="relation-control"
|
||||
@keydown.capture="onBackspace"
|
||||
>
|
||||
<UInputMenu
|
||||
:model-value="chips"
|
||||
v-model:open="open"
|
||||
v-model:search-term="searchTerm"
|
||||
multiple
|
||||
ignore-filter
|
||||
open-on-focus
|
||||
open-on-click
|
||||
by="value"
|
||||
:items="menuItems"
|
||||
:placeholder="t('Search to add')"
|
||||
:disabled="disabled"
|
||||
:color="error ? 'error' : undefined"
|
||||
:trailing="!many"
|
||||
trailing-icon="i-lucide-search"
|
||||
:aria-controls="listboxId"
|
||||
:aria-required="required ? 'true' : undefined"
|
||||
:aria-activedescendant="activeId"
|
||||
:aria-describedby="describedBy"
|
||||
class="w-full"
|
||||
:content="{ sideOffset: 6, [OWNER_ATTRIBUTE]: id }"
|
||||
:ui="menuUi"
|
||||
@update:model-value="onUpdate"
|
||||
@highlight="onHighlight"
|
||||
>
|
||||
<template #tags-item-text="{ item }">
|
||||
<span :data-missing="item.missing ? 'true' : undefined">{{
|
||||
item.label
|
||||
}}</span>
|
||||
</template>
|
||||
<template #tags-item-delete="{ item }">
|
||||
<UIcon
|
||||
v-remove-name="removeLabel(item)"
|
||||
name="i-lucide-x"
|
||||
class="size-3.5"
|
||||
/>
|
||||
</template>
|
||||
<template #item="{ item }">
|
||||
<span class="font-medium text-highlighted">{{ item.label }}</span>
|
||||
<span v-if="item.description" class="text-xs/5 text-muted">{{
|
||||
item.description
|
||||
}}</span>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div data-testid="relation-empty" class="flex flex-col items-start gap-2">
|
||||
<template v-if="emptyKind === 'loading'">
|
||||
<span class="sr-only" role="status" aria-live="polite">{{
|
||||
t('Searching…')
|
||||
}}</span>
|
||||
<USkeleton
|
||||
v-for="row in SKELETON_ROWS"
|
||||
:key="row"
|
||||
data-testid="relation-skeleton"
|
||||
class="h-10 w-full rounded-none bg-accented max-md:h-12"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="emptyKind === 'failed'">
|
||||
<span class="font-medium text-error">{{
|
||||
t("Couldn't load {0}", [targetName])
|
||||
}}</span>
|
||||
<span>{{ t('Check your connection and try again.') }}</span>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-rotate-cw"
|
||||
class="h-8 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 max-md:h-11"
|
||||
@click="search.reload()"
|
||||
>
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</template>
|
||||
<template v-else-if="emptyKind === 'limited'">
|
||||
<span class="font-medium text-warning">{{
|
||||
t('Too many requests')
|
||||
}}</span>
|
||||
<span>{{
|
||||
search.limit.active.value
|
||||
? t('Try again in {0}', [search.limit.formatted.value])
|
||||
: t('Wait a moment before trying again.')
|
||||
}}</span>
|
||||
<UButton
|
||||
v-if="!search.limit.active.value"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-rotate-cw"
|
||||
class="h-8 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 max-md:h-11"
|
||||
@click="search.reload()"
|
||||
>
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</template>
|
||||
<span v-else-if="emptyKind === 'cap'">{{
|
||||
t('You can add up to {0}.', [RELATION_MAX])
|
||||
}}</span>
|
||||
<span v-else-if="emptyKind === 'empty'">{{
|
||||
t('No {0} yet. Add one in Content, then come back.', [targetName])
|
||||
}}</span>
|
||||
<template v-else-if="emptyKind === 'noMatch'">
|
||||
<span>{{
|
||||
t('No {0} match "{1}"', [targetName, search.query.value])
|
||||
}}</span>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
class="h-8 px-2 text-xs text-highlighted max-md:h-11"
|
||||
@click="clearSearch"
|
||||
>
|
||||
{{ t('Clear search') }}
|
||||
</UButton>
|
||||
</template>
|
||||
<span v-else-if="emptyKind === 'chosen'">{{
|
||||
t('No other {0} to add.', [targetName])
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #content-bottom>
|
||||
<UButton
|
||||
v-if="search.hasMore.value && !atCap"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:loading="search.loadingMore.value"
|
||||
class="h-8 w-full justify-center text-xs text-highlighted max-md:h-11"
|
||||
@click="search.more()"
|
||||
>
|
||||
{{ t('Load more') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UInputMenu>
|
||||
</div>
|
||||
<p
|
||||
v-if="help"
|
||||
:id="helpId"
|
||||
data-testid="relation-help"
|
||||
class="mt-1.5 text-xs/4.5 text-pretty text-muted"
|
||||
:class="error ? 'sr-only' : ''"
|
||||
>
|
||||
{{ help }}
|
||||
</p>
|
||||
<span class="sr-only" role="status" aria-live="polite" data-testid="relation-live">{{
|
||||
announcement
|
||||
}}</span>
|
||||
</DirectoryField>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,257 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, onMounted } from 'vue'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import RelationChip from '@/components/RelationChip.vue'
|
||||
import { RELATION_KINDS, isRelationKind, type RelationKind } from '@/lib/relationField'
|
||||
import {
|
||||
inverseNoun,
|
||||
inverseSide,
|
||||
kindFromInverseSide,
|
||||
kindFromOwnerSide,
|
||||
kindSentence,
|
||||
ownerNoun,
|
||||
ownerSide,
|
||||
relationNames,
|
||||
relationSample,
|
||||
type RelationSide,
|
||||
} from '@/lib/relationSentences'
|
||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||
|
||||
const DIAGRAMS: Record<RelationKind, readonly [number, number]> = {
|
||||
one_to_one: [1, 1],
|
||||
one_to_many: [3, 1],
|
||||
many_to_many: [2, 2],
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
ownerName: string
|
||||
typeKey: string
|
||||
kind: RelationKind
|
||||
locked: boolean
|
||||
disabled: boolean
|
||||
compact: boolean
|
||||
error?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ type: [key: string]; kind: [kind: RelationKind] }>()
|
||||
|
||||
const store = useContentTypesStore()
|
||||
|
||||
const typeItems = computed(() =>
|
||||
store.items.map((type) => ({ label: type.name, value: type.key, hint: type.key })),
|
||||
)
|
||||
const related = computed(() => store.items.find((type) => type.key === props.typeKey))
|
||||
const names = computed(() => relationNames(props.ownerName, related.value?.name ?? '', props.label))
|
||||
const frozen = computed(() => props.locked || props.disabled)
|
||||
const kindItems = computed(() =>
|
||||
RELATION_KINDS.map((kind) => ({
|
||||
value: kind,
|
||||
label: kindTitle(kind),
|
||||
sentence: kindSentence(kind, names.value),
|
||||
})),
|
||||
)
|
||||
const sideItems = computed(() => [
|
||||
{ label: t('one'), value: 'one' },
|
||||
{ label: t('many'), value: 'many' },
|
||||
])
|
||||
const sample = computed(() => relationSample(props.kind))
|
||||
|
||||
function kindTitle(kind: RelationKind) {
|
||||
if (kind === 'one_to_one') {
|
||||
return t('One to one')
|
||||
}
|
||||
return kind === 'one_to_many' ? t('One to many') : t('Many to many')
|
||||
}
|
||||
|
||||
function squares(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => index)
|
||||
}
|
||||
|
||||
function onKind(value: unknown) {
|
||||
if (isRelationKind(value)) {
|
||||
emit('kind', value)
|
||||
}
|
||||
}
|
||||
|
||||
function onOwnerSide(value: unknown) {
|
||||
emit('kind', kindFromOwnerSide(value as RelationSide, props.kind))
|
||||
}
|
||||
|
||||
function onInverseSide(value: unknown) {
|
||||
emit('kind', kindFromInverseSide(value as RelationSide, props.kind))
|
||||
}
|
||||
|
||||
const SIDE_UI = {
|
||||
base: 'mx-1 h-7 w-auto gap-1.5 rounded-none border border-accented bg-default py-0 ps-2 pe-7 text-sm font-medium text-highlighted ring-0',
|
||||
trailing: 'pe-2',
|
||||
trailingIcon: 'size-3 text-muted',
|
||||
value: 'truncate',
|
||||
}
|
||||
const CARD_UI = {
|
||||
root: 'w-full',
|
||||
legend: 'mb-1.5 w-full font-medium text-highlighted',
|
||||
item: 'group min-h-0 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)',
|
||||
wrapper: 'ms-0 w-full',
|
||||
label: 'w-full text-base font-medium text-inherit',
|
||||
}
|
||||
|
||||
onMounted(() => store.load())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-col gap-3.5" data-testid="relation-picker">
|
||||
<p
|
||||
v-if="locked"
|
||||
data-testid="relation-locked-note"
|
||||
class="flex items-center gap-1.5 text-xs/4.5 text-muted"
|
||||
>
|
||||
<UIcon name="i-lucide-lock" class="size-3 shrink-0" />
|
||||
{{ t("The related type and the connection can't change after saving.") }}
|
||||
</p>
|
||||
|
||||
<DirectoryField
|
||||
:label="t('Related content type')"
|
||||
name="relation-type"
|
||||
:error="error"
|
||||
:help="t('Choose the content type this field points to.')"
|
||||
>
|
||||
<USelect
|
||||
:model-value="typeKey === '' ? undefined : typeKey"
|
||||
:items="typeItems"
|
||||
:placeholder="t('Choose a content type')"
|
||||
:disabled="frozen"
|
||||
:color="error ? 'error' : undefined"
|
||||
class="w-full"
|
||||
:ui="{
|
||||
base: 'h-11 text-lg disabled:bg-elevated disabled:opacity-100 md:h-8 md:text-sm',
|
||||
content: 'rounded-none shadow-(--shadow-popover)',
|
||||
}"
|
||||
@update:model-value="emit('type', String($event))"
|
||||
>
|
||||
<template #item-label="{ item }">
|
||||
<span class="flex min-w-0 items-baseline gap-2">
|
||||
<span class="truncate">{{ item.label }}</span>
|
||||
<span class="font-mono text-xs text-muted">{{ item.hint }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</USelect>
|
||||
</DirectoryField>
|
||||
|
||||
<URadioGroup
|
||||
:model-value="kind"
|
||||
:items="kindItems"
|
||||
:disabled="frozen"
|
||||
:legend="t('How are they connected?')"
|
||||
variant="card"
|
||||
indicator="hidden"
|
||||
orientation="horizontal"
|
||||
:ui="{
|
||||
...CARD_UI,
|
||||
fieldset: compact ? 'grid grid-cols-1 gap-2' : 'grid grid-cols-3 gap-2',
|
||||
}"
|
||||
@update:model-value="onKind"
|
||||
>
|
||||
<template #label="{ item }">
|
||||
<span
|
||||
class="flex flex-col gap-1.5 text-left"
|
||||
:class="frozen && !(item.value === kind) ? 'opacity-60' : ''"
|
||||
>
|
||||
<span class="flex items-center gap-1.5" aria-hidden="true">
|
||||
<span class="flex gap-0.75">
|
||||
<span
|
||||
v-for="square in squares(DIAGRAMS[item.value as RelationKind][0])"
|
||||
:key="square"
|
||||
class="size-2.5 bg-current"
|
||||
/>
|
||||
</span>
|
||||
<span class="h-px w-5 bg-current" />
|
||||
<span class="flex gap-0.75">
|
||||
<span
|
||||
v-for="square in squares(DIAGRAMS[item.value as RelationKind][1])"
|
||||
:key="square"
|
||||
class="size-2.5 bg-current"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<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)"
|
||||
>{{ item.sentence }}</span
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
</URadioGroup>
|
||||
|
||||
<div data-testid="relation-preview" class="flex flex-col gap-2 bg-elevated p-3">
|
||||
<span class="flex flex-wrap items-center" data-testid="relation-owner-sentence">
|
||||
{{ t('Each {0} has', [names.ownerSingular]) }}
|
||||
<USelect
|
||||
:model-value="ownerSide(kind)"
|
||||
:items="sideItems"
|
||||
:disabled="frozen"
|
||||
:aria-label="t('Each {0} has', [names.ownerSingular])"
|
||||
:ui="SIDE_UI"
|
||||
@update:model-value="onOwnerSide"
|
||||
/>
|
||||
{{ ownerNoun(kind, names) }}
|
||||
</span>
|
||||
<span class="flex flex-wrap items-center" data-testid="relation-inverse-sentence">
|
||||
{{ t('Each {0} has', [names.relatedSingular]) }}
|
||||
<USelect
|
||||
:model-value="inverseSide(kind)"
|
||||
:items="sideItems"
|
||||
:disabled="frozen"
|
||||
:aria-label="t('Each {0} has', [names.relatedSingular])"
|
||||
:ui="SIDE_UI"
|
||||
@update:model-value="onInverseSide"
|
||||
/>
|
||||
{{ inverseNoun(kind, names) }}
|
||||
<span class="text-muted">{{
|
||||
` · ${t('shown on {0} as the field "{1}"', [names.relatedSingular, names.ownerPlural])}`
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3" :class="compact ? 'flex-col' : 'flex-row'">
|
||||
<div
|
||||
data-testid="relation-example-owner"
|
||||
class="flex min-w-0 flex-1 flex-col gap-2 border border-default bg-default p-3"
|
||||
>
|
||||
<span class="font-semibold text-highlighted">{{ names.ownerSingular }}</span>
|
||||
<span class="text-xs text-muted">{{ ownerNoun(kind, names) }}</span>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<RelationChip
|
||||
v-for="chip in sample.ownerChips"
|
||||
:key="chip"
|
||||
:label="chip"
|
||||
tall
|
||||
/>
|
||||
<span v-if="sample.ownerMore" class="self-center text-xs text-muted"
|
||||
>+{{ sample.ownerMore }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="relation-example-related"
|
||||
class="flex min-w-0 flex-1 flex-col gap-2 border border-default bg-default p-3"
|
||||
>
|
||||
<span class="font-semibold text-highlighted">{{ names.relatedSingular }}</span>
|
||||
<span class="text-xs text-muted">{{ inverseNoun(kind, names) }}</span>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<RelationChip
|
||||
v-for="chip in sample.relatedChips"
|
||||
:key="chip"
|
||||
:label="chip"
|
||||
tall
|
||||
/>
|
||||
<span v-if="sample.relatedMore" class="self-center text-xs text-muted"
|
||||
>+{{ sample.relatedMore }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -262,6 +262,7 @@ async function restoreRowFocus() {
|
||||
:field="builder.selected.value"
|
||||
:errors="selectedErrors"
|
||||
:compact="false"
|
||||
:owner-name="form.name"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -320,6 +321,7 @@ async function restoreRowFocus() {
|
||||
:focus-token="builder.labelFocusToken.value"
|
||||
:field="builder.selected.value"
|
||||
:errors="selectedErrors"
|
||||
:owner-name="form.name"
|
||||
compact
|
||||
/>
|
||||
</FieldSheet>
|
||||
|
||||
@@ -35,6 +35,7 @@ watch(
|
||||
:name="remove.type.value.name"
|
||||
:key-value="remove.type.value.key"
|
||||
:total="remove.total.value"
|
||||
:referrer="remove.referrer.value"
|
||||
:pending="remove.pending.value"
|
||||
:offline="remove.offline.value"
|
||||
@confirm="remove.confirm"
|
||||
|
||||
@@ -7,7 +7,7 @@ import SchemaChangeList from '@/components/SchemaChangeList.vue'
|
||||
import { entriesLine } from '@/lib/schemaEntries'
|
||||
import {
|
||||
hasRefusedChange,
|
||||
isMediaOnlyClean,
|
||||
isNoDefaultOnlyClean,
|
||||
summaryLine,
|
||||
type FieldChange,
|
||||
} from '@/lib/schemaBuilderDiff'
|
||||
@@ -25,7 +25,7 @@ defineEmits<{ apply: []; close: [] }>()
|
||||
const summary = computed(() => summaryLine(props.changes))
|
||||
const entries = computed(() => entriesLine(props.name, props.total))
|
||||
const refused = computed(() => hasRefusedChange(props.changes))
|
||||
const mediaOnlyClean = computed(() => isMediaOnlyClean(props.changes))
|
||||
const noDefaultOnlyClean = computed(() => isNoDefaultOnlyClean(props.changes))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -53,7 +53,7 @@ const mediaOnlyClean = computed(() => isMediaOnlyClean(props.changes))
|
||||
<p v-if="refused" class="text-xs/4.5 text-muted">
|
||||
{{ t('Back to editing keeps your other changes.') }}
|
||||
</p>
|
||||
<p v-else-if="!mediaOnlyClean && !applying" class="text-xs/4.5 text-muted">
|
||||
<p v-else-if="!noDefaultOnlyClean && !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.",
|
||||
|
||||
Reference in new issue
Block a user