Files
sky_phone/frontend/src/components/AdminConfigValueEditor.vue
T
2026-08-21 03:59:19 +02:00

1635 lines
44 KiB
Vue

<script setup lang="ts">
import {
ChevronDown,
Plus,
Rows3,
TableProperties,
Trash2,
} from 'lucide-vue-next'
import { computed, ref } from 'vue'
import type { AdminConfiguratorStructure } from '@/types/admin'
import type { AdminConfiguratorDescribe } from '@/utils/adminConfiguratorDescription'
export type AdminConfigEditorLabels = {
addField: string
addRow: string
configuredSecret: string
convertToList: string
convertToMap: string
convertToTable: string
emptyList: string
emptyTable: string
entry: string
general: string
keyPlaceholder: string
list: string
remove: string
table: string
types: {
boolean: string
list: string
number: string
string: string
table: string
}
vector: string
}
defineOptions({ name: 'AdminConfigValueEditor' })
const props = withDefaults(
defineProps<{
ariaLabel?: string
depth?: number
describe: AdminConfiguratorDescribe
disabled?: boolean
labels: AdminConfigEditorLabels
modelValue: unknown
path?: string
structure?: AdminConfiguratorStructure
}>(),
{ ariaLabel: '', depth: 0, disabled: false, path: '' },
)
const emit = defineEmits<{ 'update:modelValue': [value: unknown] }>()
type ValueKind = 'boolean' | 'list' | 'number' | 'string' | 'table'
type MapKeyKind = 'number' | 'string'
type SerializedMapEntry = {
key: number | string
keyType: MapKeyKind
value: unknown
}
type RootTableTab = { count: number; id: string; key?: string; label: string }
const newArrayKind = ref<ValueKind>('string')
const newObjectKey = ref('')
const newObjectKind = ref<ValueKind>('string')
const newMapKey = ref('')
const newMapKeyKind = ref<MapKeyKind>('string')
const newMapValueKind = ref<ValueKind>('string')
const expandedEntry = ref<string | null>(null)
const selectedTableTab = ref('')
const isList = computed(
() =>
props.structure?.kind === 'list' ||
(props.structure?.kind !== 'map' &&
props.structure?.kind !== 'table' &&
Array.isArray(props.modelValue)),
)
const listValue = computed<unknown[]>(() =>
Array.isArray(props.modelValue) ? props.modelValue : [],
)
const isTable = computed(
() =>
props.structure?.kind === 'map' ||
props.structure?.kind === 'table' ||
props.structure?.kind === 'vector' ||
(props.modelValue !== null &&
typeof props.modelValue === 'object' &&
!Array.isArray(props.modelValue)),
)
const tableValue = computed<Record<string, unknown>>(() =>
isTable.value && !Array.isArray(props.modelValue)
? (props.modelValue as Record<string, unknown>)
: {},
)
const vectorType = computed(() => {
const value = tableValue.value.__skyType
return typeof value === 'string' && /^vector[234]$/.test(value) ? value : ''
})
const mapType = computed(
() => props.structure?.kind === 'map' || tableValue.value.__skyType === 'map',
)
const mapEntries = computed<SerializedMapEntry[]>(() => {
const entries = tableValue.value.entries
if (!mapType.value || !Array.isArray(entries)) return []
return entries.filter(
(entry): entry is SerializedMapEntry =>
entry !== null &&
typeof entry === 'object' &&
(entry.keyType === 'number' || entry.keyType === 'string'),
)
})
const listStructure = computed(() =>
props.structure?.kind === 'list' ? props.structure : null,
)
const listTemplate = computed(
() => listStructure.value?.template ?? listStructure.value?.items[0],
)
const tableStructure = computed(() =>
props.structure?.kind === 'table' ? props.structure : null,
)
const mapStructure = computed(() =>
props.structure?.kind === 'map' ? props.structure : null,
)
const mapTemplate = computed(() => mapStructure.value?.template)
const newMapEntryKeyKind = computed<MapKeyKind>(
() => mapStructure.value?.keyType ?? newMapKeyKind.value,
)
const canAddTableField = computed(() => {
const key = newObjectKey.value.trim()
if (
!key ||
key === '__skyType' ||
Object.prototype.hasOwnProperty.call(tableValue.value, key)
)
return false
return !tableStructure.value?.mutableKeys || /^[a-z0-9_-]+$/.test(key)
})
const canExtendTable = computed(
() =>
!vectorType.value &&
(!tableStructure.value || tableStructure.value.mutableKeys === true),
)
const tableEntries = computed(() =>
Object.entries(tableValue.value).filter(
([key]) => key !== '__skyType' && (!mapType.value || key !== 'entries'),
),
)
const rootTableTabs = computed<RootTableTab[]>(() => {
if (props.depth !== 0 || !tableStructure.value || mapType.value) return []
const structuredEntries = tableEntries.value.filter(([key, value]) =>
isStructuredValue(value, tableFieldStructure(key)),
)
if (!structuredEntries.length) return []
const scalarCount = tableEntries.value.length - structuredEntries.length
return [
...(scalarCount
? [{ count: scalarCount, id: 'general', label: props.labels.general }]
: []),
...structuredEntries.map(([key]) => ({
count: configuratorStructureSize(tableFieldStructure(key)),
id: `field:${key}`,
key,
label: key,
})),
]
})
const activeRootTableTab = computed(
() =>
rootTableTabs.value.find((tab) => tab.id === selectedTableTab.value) ??
rootTableTabs.value[0] ??
null,
)
const activeRootTableField = computed(() => {
const key = activeRootTableTab.value?.key
if (!key) return null
const entry = tableEntries.value.find(([candidate]) => candidate === key)
return entry ? { key: entry[0], value: entry[1] } : null
})
const visibleTableEntries = computed(() => {
if (!rootTableTabs.value.length) return tableEntries.value
if (activeRootTableField.value) return []
return tableEntries.value.filter(
([key, value]) => !isStructuredValue(value, tableFieldStructure(key)),
)
})
const isMaskedSecret = computed(() => props.modelValue === '***REDACTED***')
const parsedNewMapKey = computed(() => {
const key = newMapKey.value.trim()
if (!key) return null
if (newMapEntryKeyKind.value === 'string') return key
const numeric = Number(key)
return Number.isFinite(numeric) ? numeric : null
})
const canAddMapEntry = computed(() => {
const key = parsedNewMapKey.value
if (key === null) return false
return !mapEntries.value.some(
(entry) => entry.keyType === newMapEntryKeyKind.value && entry.key === key,
)
})
function blankValue(kind: ValueKind): unknown {
if (kind === 'boolean') return false
if (kind === 'number') return 0
if (kind === 'list') return []
if (kind === 'table') return {}
return ''
}
function blankLike(value: unknown): unknown {
if (Array.isArray(value)) return []
if (value !== null && typeof value === 'object') {
const blank: Record<string, unknown> = {}
for (const [key, child] of Object.entries(value)) {
blank[key] = key === '__skyType' ? child : blankLike(child)
}
return blank
}
if (typeof value === 'boolean') return false
if (typeof value === 'number') return 0
return ''
}
function blankCollectionValue(kind: ValueKind, siblings: unknown[]): unknown {
const template = siblings.find((value) => {
if (kind === 'list') return Array.isArray(value)
if (kind === 'table') {
return (
value !== null && typeof value === 'object' && !Array.isArray(value)
)
}
return typeof value === kind
})
return template === undefined ? blankValue(kind) : blankLike(template)
}
function isStructuredValue(
value: unknown,
structure?: AdminConfiguratorStructure,
): boolean {
if (
structure?.kind === 'list' ||
structure?.kind === 'map' ||
structure?.kind === 'table' ||
structure?.kind === 'vector'
)
return true
return value !== null && typeof value === 'object'
}
function structuredValueLabel(
value: unknown,
structure?: AdminConfiguratorStructure,
): string {
if (structure?.kind === 'vector') {
return `${props.labels.vector} ${structure.vectorType.slice(-1)}`
}
if (structure?.kind === 'list' || Array.isArray(value)) {
return props.labels.list
}
return props.labels.table
}
function structureTypeLabel(structure: AdminConfiguratorStructure): string {
if (structure.kind === 'optionalString') return props.labels.types.string
if (structure.kind === 'value') return props.labels.types[structure.valueType]
if (structure.kind === 'list') return props.labels.types.list
if (structure.kind === 'vector') {
return `${props.labels.vector} ${structure.vectorType.slice(-1)}`
}
return props.labels.types.table
}
function configuratorStructureSize(
structure: AdminConfiguratorStructure | undefined,
): number {
if (
!structure ||
structure.kind === 'optionalString' ||
structure.kind === 'value'
) {
return 1
}
if (structure.kind === 'vector') {
return Number(structure.vectorType.slice(-1))
}
if (structure.kind === 'list') {
return Math.max(
1,
structure.items.reduce(
(total, item) => total + configuratorStructureSize(item),
0,
),
)
}
if (structure.kind === 'map') {
return Math.max(
1,
structure.entries.reduce(
(total, entry) => total + configuratorStructureSize(entry.structure),
0,
),
)
}
return Math.max(
1,
Object.values(structure.fields).reduce(
(total, field) => total + configuratorStructureSize(field),
0,
),
)
}
function toggleStructuredEntry(entry: string): void {
expandedEntry.value = expandedEntry.value === entry ? null : entry
}
function tableEntryPath(key: string): string {
return props.path ? `${props.path}.${key}` : key
}
function listEntryPath(index: number): string {
return `${props.path || props.ariaLabel}[${index + 1}]`
}
function listItemStructure(
index: number,
): AdminConfiguratorStructure | undefined {
return listStructure.value?.items[index] ?? listTemplate.value
}
function mapValuePath(entry: SerializedMapEntry): string {
return props.path ? `${props.path}.${entry.key}` : String(entry.key)
}
function blankFromStructure(structure: AdminConfiguratorStructure): unknown {
if (structure.kind === 'optionalString') return ''
if (structure.kind === 'value') return blankValue(structure.valueType)
if (structure.kind === 'vector') {
const axes = ['x', 'y', 'z', 'w'].slice(
0,
Number(structure.vectorType.slice(-1)),
)
return Object.fromEntries([
['__skyType', structure.vectorType],
...axes.map((axis) => [axis, 0]),
])
}
if (structure.kind === 'list') {
return structure.items.map(blankFromStructure)
}
if (structure.kind === 'map') {
return {
__skyType: 'map',
entries: structure.entries.map((entry) => ({
key: entry.key,
keyType: entry.keyType,
value: blankFromStructure(entry.structure),
})),
}
}
return Object.fromEntries(
Object.entries(structure.fields).map(([key, field]) => [
key,
blankFromStructure(field),
]),
)
}
function updateScalar(event: Event): void {
const target = event.target
if (!(target instanceof HTMLInputElement)) return
if (typeof props.modelValue === 'number') {
const value = Number(target.value)
if (Number.isFinite(value)) emit('update:modelValue', value)
return
}
emit('update:modelValue', target.value)
}
function updateBoolean(event: Event): void {
const target = event.target
if (target instanceof HTMLInputElement) {
emit('update:modelValue', target.checked)
}
}
function updateOptionalString(event: Event): void {
const target = event.target
if (target instanceof HTMLInputElement) {
emit('update:modelValue', target.value)
}
}
function toggleOptionalString(event: Event): void {
const target = event.target
if (!(target instanceof HTMLInputElement)) return
emit('update:modelValue', target.checked ? '' : false)
}
function addListRow(): void {
const rows = Array.isArray(props.modelValue) ? [...props.modelValue] : []
const index = rows.length
const value = rows.length
? blankLike(rows[0])
: listTemplate.value
? blankFromStructure(listTemplate.value)
: blankValue(newArrayKind.value)
rows.push(value)
emit('update:modelValue', rows)
if (isStructuredValue(value, listTemplate.value)) {
expandedEntry.value = `list:${index}`
}
}
function updateListRow(index: number, value: unknown): void {
const rows = Array.isArray(props.modelValue) ? [...props.modelValue] : []
rows[index] = value
emit('update:modelValue', rows)
}
function removeListRow(index: number): void {
if (listStructure.value?.items[index]) return
const rows = Array.isArray(props.modelValue) ? [...props.modelValue] : []
rows.splice(index, 1)
emit('update:modelValue', rows)
expandedEntry.value = null
}
function updateTableField(key: string, value: unknown): void {
emit('update:modelValue', { ...tableValue.value, [key]: value })
}
function tableFieldStructure(
key: string,
): AdminConfiguratorStructure | undefined {
if (!tableStructure.value) return undefined
return (
tableStructure.value.fields[key] ??
(tableStructure.value.mutableKeys
? tableStructure.value.template
: undefined)
)
}
function isFixedTableField(key: string): boolean {
return (
tableStructure.value?.mutableKeys !== true &&
Object.prototype.hasOwnProperty.call(
tableStructure.value?.fields ?? {},
key,
)
)
}
function removeTableField(key: string): void {
if (isFixedTableField(key)) return
const next = { ...tableValue.value }
delete next[key]
emit('update:modelValue', next)
if (expandedEntry.value === `table:${key}`) expandedEntry.value = null
}
function addTableField(): void {
const key = newObjectKey.value.trim()
if (!canAddTableField.value) return
const template = tableStructure.value?.mutableKeys
? tableStructure.value.template
: undefined
const value = template
? blankFromStructure(template)
: blankCollectionValue(
newObjectKind.value,
Object.values(tableValue.value).filter((_, index) => index < 50),
)
emit('update:modelValue', {
...tableValue.value,
[key]: value,
})
if (isStructuredValue(value, template)) {
expandedEntry.value = `table:${key}`
if (props.depth === 0) selectedTableTab.value = `field:${key}`
}
newObjectKey.value = ''
}
function emitMap(entries: SerializedMapEntry[]): void {
emit('update:modelValue', { __skyType: 'map', entries })
}
function convertTableToMap(): void {
emitMap(
Object.entries(tableValue.value)
.filter(([key]) => key !== '__skyType')
.map(([key, value]) => ({ key, keyType: 'string', value })),
)
}
function updateMapKey(index: number, event: Event): void {
const target = event.target
if (!(target instanceof HTMLInputElement)) return
const current = mapEntries.value[index]
if (!current) return
if (fixedMapEntryStructure(current)) {
target.value = String(current.key)
return
}
const key =
current.keyType === 'number' ? Number(target.value) : target.value.trim()
if (
key === '' ||
(typeof key === 'number' && !Number.isFinite(key)) ||
mapEntries.value.some(
(entry, candidateIndex) =>
candidateIndex !== index &&
entry.keyType === current.keyType &&
entry.key === key,
)
) {
target.value = String(current.key)
return
}
const entries = mapEntries.value.map((entry, candidateIndex) =>
candidateIndex === index ? { ...entry, key } : entry,
)
emitMap(entries)
}
function updateMapValue(index: number, value: unknown): void {
emitMap(
mapEntries.value.map((entry, candidateIndex) =>
candidateIndex === index ? { ...entry, value } : entry,
),
)
}
function removeMapEntry(index: number): void {
const entry = mapEntries.value[index]
if (!entry || fixedMapEntryStructure(entry)) return
emitMap(
mapEntries.value.filter((_, candidateIndex) => candidateIndex !== index),
)
expandedEntry.value = null
}
function addMapEntry(): void {
if (!canAddMapEntry.value || parsedNewMapKey.value === null) return
const key = parsedNewMapKey.value
const value = mapTemplate.value
? blankFromStructure(mapTemplate.value)
: blankCollectionValue(
newMapValueKind.value,
mapEntries.value.slice(0, 50).map((entry) => entry.value),
)
emitMap([
...mapEntries.value,
{
key,
keyType: newMapEntryKeyKind.value,
value,
},
])
if (isStructuredValue(value, mapTemplate.value)) {
expandedEntry.value = `map:${newMapEntryKeyKind.value}:${key}`
}
newMapKey.value = ''
}
function fixedMapEntryStructure(
entry: SerializedMapEntry,
): AdminConfiguratorStructure | undefined {
return mapStructure.value?.entries.find(
(candidate) =>
candidate.keyType === entry.keyType && candidate.key === entry.key,
)?.structure
}
function mapEntryStructure(
entry: SerializedMapEntry,
): AdminConfiguratorStructure | undefined {
return fixedMapEntryStructure(entry) ?? mapTemplate.value
}
</script>
<template>
<div
v-if="isList"
class="config-structured-editor"
:class="{
'has-structure': Boolean(structure),
'is-nested': depth > 0,
}"
>
<header class="config-structured-editor__bar">
<span><Rows3 :size="14" />{{ labels.list }}</span>
<div class="config-structured-editor__actions">
<button
v-if="!listValue.length && !structure"
type="button"
:disabled="disabled"
:title="labels.convertToTable"
@click="emit('update:modelValue', {})"
>
<TableProperties :size="13" />
</button>
</div>
</header>
<div v-if="listValue.length" class="config-structured-editor__rows">
<div
v-for="(row, index) in listValue"
:key="index"
class="config-structured-editor__row"
:class="{
'has-structured-value': isStructuredValue(
row,
listItemStructure(index),
),
'is-expanded': expandedEntry === `list:${index}`,
}"
>
<span class="config-structured-editor__index">{{ index + 1 }}</span>
<button
v-if="isStructuredValue(row, listItemStructure(index))"
type="button"
class="config-structured-editor__section-toggle"
:aria-label="`${ariaLabel} ${index + 1}`"
:aria-expanded="expandedEntry === `list:${index}`"
@click="toggleStructuredEntry(`list:${index}`)"
>
<span class="config-structured-editor__field-copy">
<strong>{{ labels.entry }} {{ index + 1 }}</strong>
<small
:title="
describe(listEntryPath(index), row, listItemStructure(index))
"
>
{{
describe(listEntryPath(index), row, listItemStructure(index))
}}
</small>
</span>
<em>{{ structuredValueLabel(row, listItemStructure(index)) }}</em>
<ChevronDown :size="14" />
</button>
<span v-else class="config-structured-editor__field-copy is-list-entry">
<strong>{{ labels.entry }} {{ index + 1 }}</strong>
<small
:title="
describe(listEntryPath(index), row, listItemStructure(index))
"
>
{{ describe(listEntryPath(index), row, listItemStructure(index)) }}
</small>
</span>
<AdminConfigValueEditor
v-if="
!isStructuredValue(row, listItemStructure(index)) ||
expandedEntry === `list:${index}`
"
:model-value="row"
:structure="listItemStructure(index)"
:aria-label="`${ariaLabel} ${index + 1}`"
:describe="describe"
:labels="labels"
:disabled="disabled"
:depth="depth + 1"
:path="listEntryPath(index)"
@update:model-value="updateListRow(index, $event)"
/>
<button
v-if="!listStructure?.items[index]"
type="button"
class="config-structured-editor__remove"
:disabled="disabled"
:title="labels.remove"
@click="removeListRow(index)"
>
<Trash2 :size="13" />
</button>
</div>
</div>
<div v-else class="config-structured-editor__empty">
{{ labels.emptyList }}
</div>
<form
class="config-structured-editor__add-field is-list"
@submit.prevent="addListRow"
>
<select
v-if="!listValue.length && !listTemplate"
v-model="newArrayKind"
:disabled="disabled"
>
<option value="string">{{ labels.types.string }}</option>
<option value="number">{{ labels.types.number }}</option>
<option value="boolean">{{ labels.types.boolean }}</option>
<option value="table">{{ labels.types.table }}</option>
</select>
<span
v-else-if="listTemplate"
class="config-structured-editor__fixed-type"
>
{{ structureTypeLabel(listTemplate) }}
</span>
<button type="submit" :disabled="disabled">
<Plus :size="13" />{{ labels.addRow }}
</button>
</form>
</div>
<div
v-else-if="isTable"
class="config-structured-editor"
:class="{
'has-structure': Boolean(structure),
'is-nested': depth > 0,
}"
>
<header class="config-structured-editor__bar">
<span>
<TableProperties :size="14" />
{{
vectorType ? `${labels.vector} ${vectorType.slice(-1)}` : labels.table
}}
</span>
<div
v-if="!structure && !mapType && !vectorType"
class="config-structured-editor__actions"
>
<button
type="button"
:disabled="disabled"
:title="labels.convertToMap"
@click="convertTableToMap"
>
<TableProperties :size="13" />{{ labels.convertToMap }}
</button>
<button
v-if="!tableEntries.length"
type="button"
:disabled="disabled"
:title="labels.convertToList"
@click="emit('update:modelValue', [])"
>
<Rows3 :size="13" />{{ labels.convertToList }}
</button>
</div>
</header>
<nav
v-if="rootTableTabs.length"
class="config-structured-editor__tabs"
role="tablist"
:aria-label="ariaLabel"
>
<button
v-for="tableTab in rootTableTabs"
:key="tableTab.id"
type="button"
role="tab"
:class="{ 'is-active': activeRootTableTab?.id === tableTab.id }"
:aria-selected="activeRootTableTab?.id === tableTab.id"
@click="selectedTableTab = tableTab.id"
>
<span>{{ tableTab.label }}</span>
<em>{{ tableTab.count }}</em>
</button>
</nav>
<div
v-if="activeRootTableField"
class="config-structured-editor__tab-panel"
role="tabpanel"
>
<div
v-if="!isFixedTableField(activeRootTableField.key)"
class="config-structured-editor__tab-panel-actions"
>
<strong>{{ activeRootTableField.key }}</strong>
<button
type="button"
:disabled="disabled"
:title="labels.remove"
@click="removeTableField(activeRootTableField.key)"
>
<Trash2 :size="13" />{{ labels.remove }}
</button>
</div>
<AdminConfigValueEditor
:model-value="activeRootTableField.value"
:structure="tableFieldStructure(activeRootTableField.key)"
:aria-label="`${ariaLabel} ${activeRootTableField.key}`"
:describe="describe"
:labels="labels"
:disabled="disabled"
:depth="depth + 1"
:path="tableEntryPath(activeRootTableField.key)"
@update:model-value="updateTableField(activeRootTableField.key, $event)"
/>
</div>
<div
v-else-if="mapType"
class="config-structured-editor__properties is-map"
>
<div
v-for="(entry, index) in mapEntries"
:key="`${entry.keyType}:${entry.key}`"
class="config-structured-editor__property is-map"
:class="{
'has-structured-value': isStructuredValue(
entry.value,
mapEntryStructure(entry),
),
'is-expanded': expandedEntry === `map:${entry.keyType}:${entry.key}`,
}"
>
<span class="config-structured-editor__index">{{ index + 1 }}</span>
<span class="config-structured-editor__map-key">
<small>{{ labels.types[entry.keyType] }}</small>
<input
:type="entry.keyType === 'number' ? 'number' : 'text'"
:value="entry.key"
:aria-label="`${ariaLabel} ${labels.types[entry.keyType]} ${index + 1}`"
:disabled="disabled || Boolean(fixedMapEntryStructure(entry))"
@change="updateMapKey(index, $event)"
/>
<em
:title="
describe(
mapValuePath(entry),
entry.value,
mapEntryStructure(entry),
)
"
>
{{
describe(
mapValuePath(entry),
entry.value,
mapEntryStructure(entry),
)
}}
</em>
</span>
<button
v-if="isStructuredValue(entry.value, mapEntryStructure(entry))"
type="button"
class="config-structured-editor__section-toggle is-map"
:aria-label="`${ariaLabel} ${entry.key}`"
:aria-expanded="expandedEntry === `map:${entry.keyType}:${entry.key}`"
@click="toggleStructuredEntry(`map:${entry.keyType}:${entry.key}`)"
>
<small>{{
structuredValueLabel(entry.value, mapEntryStructure(entry))
}}</small>
<ChevronDown :size="14" />
</button>
<AdminConfigValueEditor
v-if="
!isStructuredValue(entry.value, mapEntryStructure(entry)) ||
expandedEntry === `map:${entry.keyType}:${entry.key}`
"
:model-value="entry.value"
:structure="mapEntryStructure(entry)"
:aria-label="`${ariaLabel} ${entry.key}`"
:describe="describe"
:labels="labels"
:disabled="disabled"
:depth="depth + 1"
:path="mapValuePath(entry)"
@update:model-value="updateMapValue(index, $event)"
/>
<button
v-if="!fixedMapEntryStructure(entry)"
type="button"
class="config-structured-editor__remove"
:disabled="disabled"
:title="labels.remove"
@click="removeMapEntry(index)"
>
<Trash2 :size="13" />
</button>
</div>
<div v-if="!mapEntries.length" class="config-structured-editor__empty">
{{ labels.emptyTable }}
</div>
</div>
<div
v-else-if="visibleTableEntries.length"
class="config-structured-editor__properties"
:role="rootTableTabs.length ? 'tabpanel' : undefined"
>
<div
v-for="([key, value], index) in visibleTableEntries"
:key="key"
class="config-structured-editor__property"
:class="{
'has-structured-value': isStructuredValue(
value,
tableFieldStructure(key),
),
'is-expanded': expandedEntry === `table:${key}`,
}"
>
<span class="config-structured-editor__index">{{ index + 1 }}</span>
<button
v-if="isStructuredValue(value, tableFieldStructure(key))"
type="button"
class="config-structured-editor__section-toggle"
:aria-label="`${ariaLabel} ${key}`"
:aria-expanded="expandedEntry === `table:${key}`"
@click="toggleStructuredEntry(`table:${key}`)"
>
<span class="config-structured-editor__field-copy">
<strong>{{ key }}</strong>
<small
:title="
describe(tableEntryPath(key), value, tableFieldStructure(key))
"
>
{{
describe(tableEntryPath(key), value, tableFieldStructure(key))
}}
</small>
</span>
<em>{{ structuredValueLabel(value, tableFieldStructure(key)) }}</em>
<ChevronDown :size="14" />
</button>
<span v-else class="config-structured-editor__field-copy">
<strong>{{ key }}</strong>
<small
:title="
describe(tableEntryPath(key), value, tableFieldStructure(key))
"
>
{{ describe(tableEntryPath(key), value, tableFieldStructure(key)) }}
</small>
</span>
<AdminConfigValueEditor
v-if="
!isStructuredValue(value, tableFieldStructure(key)) ||
expandedEntry === `table:${key}`
"
:model-value="value"
:structure="tableFieldStructure(key)"
:aria-label="`${ariaLabel} ${key}`"
:describe="describe"
:labels="labels"
:disabled="disabled"
:depth="depth + 1"
:path="tableEntryPath(key)"
@update:model-value="updateTableField(key, $event)"
/>
<button
v-if="!vectorType && !isFixedTableField(key)"
type="button"
class="config-structured-editor__remove"
:disabled="disabled"
:title="labels.remove"
@click="removeTableField(key)"
>
<Trash2 :size="13" />
</button>
</div>
</div>
<div
v-else-if="!rootTableTabs.length"
class="config-structured-editor__empty"
>
{{ labels.emptyTable }}
</div>
<form
v-if="mapType"
class="config-structured-editor__add-field is-map"
@submit.prevent="addMapEntry"
>
<select
v-if="!mapStructure?.keyType"
v-model="newMapKeyKind"
:disabled="disabled"
>
<option value="string">{{ labels.types.string }}</option>
<option value="number">{{ labels.types.number }}</option>
</select>
<span v-else class="config-structured-editor__fixed-type is-key-type">
{{ labels.types[mapStructure.keyType] }}
</span>
<input
v-model="newMapKey"
:type="newMapEntryKeyKind === 'number' ? 'number' : 'text'"
:disabled="disabled"
:placeholder="labels.keyPlaceholder"
:aria-label="labels.keyPlaceholder"
/>
<select
v-if="!mapTemplate"
v-model="newMapValueKind"
:disabled="disabled"
>
<option value="string">{{ labels.types.string }}</option>
<option value="number">{{ labels.types.number }}</option>
<option value="boolean">{{ labels.types.boolean }}</option>
<option value="list">{{ labels.types.list }}</option>
<option value="table">{{ labels.types.table }}</option>
</select>
<span v-else class="config-structured-editor__fixed-type">
{{ structureTypeLabel(mapTemplate) }}
</span>
<button type="submit" :disabled="disabled || !canAddMapEntry">
<Plus :size="13" />{{ labels.addField }}
</button>
</form>
<form
v-else-if="canExtendTable"
class="config-structured-editor__add-field"
@submit.prevent="addTableField"
>
<input
v-model="newObjectKey"
type="text"
:disabled="disabled"
:placeholder="labels.keyPlaceholder"
:aria-label="labels.keyPlaceholder"
/>
<select
v-if="!tableStructure"
v-model="newObjectKind"
:disabled="disabled"
>
<option value="string">{{ labels.types.string }}</option>
<option value="number">{{ labels.types.number }}</option>
<option value="boolean">{{ labels.types.boolean }}</option>
<option value="list">{{ labels.types.list }}</option>
<option value="table">{{ labels.types.table }}</option>
</select>
<span
v-else-if="tableStructure.template"
class="config-structured-editor__fixed-type"
>
{{ structureTypeLabel(tableStructure.template) }}
</span>
<button type="submit" :disabled="disabled || !canAddTableField">
<Plus :size="13" />{{ labels.addField }}
</button>
</form>
</div>
<span
v-else-if="structure?.kind === 'optionalString'"
class="config-value-optional"
>
<input
class="config-value-input"
type="text"
:aria-label="ariaLabel"
:value="modelValue === false ? '' : String(modelValue ?? '')"
:disabled="disabled || modelValue === false"
autocomplete="off"
@input="updateOptionalString"
/>
<label class="config-value-toggle">
<input
type="checkbox"
:aria-label="ariaLabel"
:checked="modelValue !== false"
:disabled="disabled"
@change="toggleOptionalString"
/>
<i></i>
</label>
</span>
<label
v-else-if="typeof modelValue === 'boolean'"
class="config-value-toggle"
>
<input
type="checkbox"
:aria-label="ariaLabel"
:checked="modelValue"
:disabled="disabled"
@change="updateBoolean"
/>
<i></i>
</label>
<input
v-else
class="config-value-input"
:aria-label="ariaLabel"
:type="
isMaskedSecret
? 'password'
: typeof modelValue === 'number'
? 'number'
: 'text'
"
:value="isMaskedSecret ? '' : String(modelValue ?? '')"
:placeholder="isMaskedSecret ? labels.configuredSecret : ''"
:disabled="disabled"
:autocomplete="isMaskedSecret ? 'new-password' : 'off'"
@input="updateScalar"
/>
</template>
<style scoped>
.config-structured-editor {
min-width: 0;
overflow: hidden;
border-radius: 4px;
background: #121413;
outline: 1px solid rgba(255, 255, 255, 0.065);
}
.config-structured-editor.is-nested {
background: #0f1110;
}
.config-structured-editor.is-nested.has-structure
> .config-structured-editor__bar {
display: none;
}
.config-structured-editor__bar {
min-height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 5px 7px;
background: rgba(255, 255, 255, 0.025);
}
.config-structured-editor__bar > span,
.config-structured-editor__bar > div {
display: flex;
align-items: center;
gap: 6px;
}
.config-structured-editor__actions:empty {
display: none;
}
.config-structured-editor__bar > span {
color: var(--admin-muted);
font-size: 8px;
font-weight: 650;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.config-structured-editor__tabs {
display: flex;
gap: 3px;
overflow-x: auto;
padding: 6px 7px 0;
background: #0c0e0d;
scrollbar-width: thin;
}
.config-structured-editor .config-structured-editor__tabs > button {
min-width: max-content;
min-height: 29px;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 0 10px;
border-radius: 3px 3px 0 0;
outline: 0;
color: var(--admin-muted);
background: transparent;
font-size: 8px;
}
.config-structured-editor .config-structured-editor__tabs > button:hover,
.config-structured-editor .config-structured-editor__tabs > button.is-active {
color: var(--admin-text);
background: #171918;
}
.config-structured-editor .config-structured-editor__tabs > button.is-active {
box-shadow: inset 0 -1px var(--admin-green);
}
.config-structured-editor__tabs em {
color: var(--admin-dim);
font-size: 7px;
font-style: normal;
}
.config-structured-editor__tab-panel {
min-width: 0;
background: #0f1110;
}
.config-structured-editor__tab-panel > .config-structured-editor {
border-radius: 0;
outline: 0;
}
.config-structured-editor__tab-panel-actions {
min-height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 5px 7px;
background: #141615;
}
.config-structured-editor__tab-panel-actions strong {
color: var(--admin-text);
font-size: 9px;
}
.config-structured-editor .config-structured-editor__tab-panel-actions button {
color: #b66a6a;
}
.config-structured-editor button,
.config-structured-editor select,
.config-structured-editor input,
.config-value-input {
border: 0;
border-radius: 3px;
outline: 1px solid rgba(255, 255, 255, 0.075);
color: var(--admin-text);
background: #1a1c1b;
font: inherit;
font-size: 9px;
}
.config-structured-editor button {
min-height: 23px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 0 7px;
color: var(--admin-green);
cursor: pointer;
}
.config-structured-editor button:hover:not(:disabled) {
background: #222423;
}
.config-structured-editor button:disabled,
.config-structured-editor input:disabled,
.config-structured-editor select:disabled,
.config-value-input:disabled {
opacity: 0.45;
cursor: default;
}
.config-structured-editor select {
height: 23px;
padding: 0 5px;
}
.config-structured-editor__rows,
.config-structured-editor__properties {
display: grid;
gap: 1px;
background: #0d0f0d;
}
.config-structured-editor__row,
.config-structured-editor__property {
display: grid;
grid-template-columns: 24px minmax(180px, 0.82fr) minmax(140px, 1.18fr) 27px;
align-items: center;
gap: 6px;
padding: 5px 6px;
background: #151716;
}
.config-structured-editor__property {
grid-template-columns: 24px minmax(200px, 0.9fr) minmax(150px, 1.1fr) 27px;
}
.config-structured-editor__property.is-map {
grid-template-columns: 24px minmax(180px, 0.82fr) minmax(150px, 1.18fr) 27px;
}
.config-structured-editor__row.has-structured-value,
.config-structured-editor__property.has-structured-value {
align-items: start;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.022),
rgba(255, 255, 255, 0.008)
);
}
.config-structured-editor__row.has-structured-value.is-expanded,
.config-structured-editor__property.has-structured-value.is-expanded {
background: linear-gradient(
90deg,
color-mix(in srgb, var(--admin-green) 7%, #1a1d1a),
#181b18 58%
);
}
.config-structured-editor__row.has-structured-value > .config-structured-editor,
.config-structured-editor__property.has-structured-value
> .config-structured-editor {
grid-column: 1 / -1;
grid-row: 2;
width: calc(100% + 12px);
margin-inline: -6px;
}
.config-structured-editor__row.has-structured-value
> .config-structured-editor__index,
.config-structured-editor__property.has-structured-value
> .config-structured-editor__index {
grid-column: 1;
grid-row: 1;
align-self: center;
}
.config-structured-editor__property.has-structured-value > strong,
.config-structured-editor__property.has-structured-value
> .config-structured-editor__section-toggle {
grid-column: 2 / 4;
grid-row: 1;
align-self: center;
}
.config-structured-editor__row.has-structured-value
> .config-structured-editor__section-toggle {
grid-column: 2 / 4;
grid-row: 1;
}
.config-structured-editor__property.is-map.has-structured-value
> .config-structured-editor__map-key {
grid-column: 2;
grid-row: 1;
align-self: center;
}
.config-structured-editor__property.is-map.has-structured-value
> .config-structured-editor__section-toggle {
grid-column: 3;
}
.config-structured-editor__row.has-structured-value
> .config-structured-editor__remove {
grid-column: 4;
grid-row: 1;
}
.config-structured-editor__property.has-structured-value
> .config-structured-editor__remove {
grid-column: 4;
grid-row: 1;
}
.config-structured-editor .config-structured-editor__section-toggle {
width: 100%;
min-width: 0;
min-height: 27px;
justify-content: flex-start;
padding: 0 7px;
outline: 0;
color: #c9cec9;
background: transparent;
}
.config-structured-editor
.config-structured-editor__section-toggle:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.025);
}
.config-structured-editor__field-copy {
min-width: 0;
display: grid;
gap: 2px;
text-align: left;
}
.config-structured-editor__field-copy strong {
overflow: hidden;
min-width: 0;
color: #c9cec9;
font-size: 9px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.config-structured-editor__field-copy small {
overflow: hidden;
color: var(--admin-muted);
font-size: 7.5px;
font-weight: 450;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.config-structured-editor__section-toggle
> .config-structured-editor__field-copy {
flex: 1 1 auto;
}
.config-structured-editor__section-toggle > em {
margin-left: auto;
color: var(--admin-muted);
font-size: 7px;
font-weight: 600;
font-style: normal;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.config-structured-editor__section-toggle svg {
flex: 0 0 auto;
color: var(--admin-muted);
transition: transform 150ms ease;
}
.config-structured-editor__row.is-expanded
> .config-structured-editor__section-toggle
svg,
.config-structured-editor__property.is-expanded
> .config-structured-editor__section-toggle
svg {
transform: rotate(180deg);
}
.config-structured-editor__section-toggle:focus-visible {
outline: 1px solid color-mix(in srgb, var(--admin-green) 55%, transparent);
}
@media (prefers-reduced-motion: reduce) {
.config-structured-editor__section-toggle svg {
transition: none;
}
}
.config-structured-editor__map-key {
min-width: 0;
display: grid;
gap: 3px;
}
.config-structured-editor__map-key > small {
color: var(--admin-dim);
font-size: 7px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.config-structured-editor__map-key > em {
overflow: hidden;
color: var(--admin-muted);
font-size: 7px;
font-style: normal;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.config-structured-editor__map-key input {
width: 100%;
min-width: 0;
height: 25px;
padding: 0 7px;
}
.config-structured-editor__index {
color: var(--admin-dim);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 8px;
text-align: center;
}
.config-structured-editor .config-structured-editor__remove {
width: 25px;
padding: 0;
color: #b66a6a;
}
.config-value-optional {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 7px;
}
.config-structured-editor__empty {
padding: 13px 10px;
color: var(--admin-dim);
font-size: 9px;
text-align: center;
}
.config-structured-editor__add-field {
min-height: 37px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
padding: 6px;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.012),
rgba(255, 255, 255, 0.035)
);
}
.config-structured-editor__add-field input {
min-width: 0;
flex: 1 1 120px;
height: 25px;
padding: 0 7px;
}
.config-structured-editor__add-field select {
width: 90px;
flex: 0 0 90px;
}
.config-structured-editor__fixed-type {
width: 90px;
height: 23px;
flex: 0 0 90px;
display: inline-flex;
align-items: center;
padding: 0 7px;
border-radius: 3px;
outline: 1px solid rgba(255, 255, 255, 0.055);
color: var(--admin-muted);
background: rgba(255, 255, 255, 0.025);
font-size: 8px;
}
.config-structured-editor__fixed-type.is-key-type {
width: 76px;
flex-basis: 76px;
}
.config-structured-editor__add-field button {
min-width: 74px;
flex: 0 0 auto;
}
.config-structured-editor__add-field.is-map select:first-child {
width: 76px;
flex-basis: 76px;
}
.config-structured-editor__add-field.is-map select:nth-of-type(2) {
width: 82px;
flex-basis: 82px;
}
.config-structured-editor__add-field.is-list select,
.config-structured-editor__add-field.is-list
.config-structured-editor__fixed-type {
margin-left: auto;
}
.config-value-input {
width: 100%;
min-width: 0;
height: 29px;
padding: 0 8px;
}
.config-value-input:focus,
.config-structured-editor input:focus,
.config-structured-editor select:focus {
outline-color: color-mix(in srgb, var(--admin-green) 45%, transparent);
}
.config-value-toggle {
position: relative;
justify-self: end;
width: 32px;
height: 18px;
}
.config-value-toggle input {
position: absolute;
inset: 0;
z-index: 1;
margin: 0;
opacity: 0;
cursor: pointer;
}
.config-value-toggle i {
position: absolute;
inset: 0;
border-radius: 999px;
background: #393d39;
}
.config-value-toggle i::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #c7ccc7;
transition: transform 150ms ease;
}
.config-value-toggle input:checked + i {
background: color-mix(in srgb, var(--admin-green) 72%, #1f321f);
}
.config-value-toggle input:checked + i::after {
transform: translateX(14px);
background: #f4f7f4;
}
</style>