mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
ENH - refine configurator editing experience
This commit is contained in:
@@ -21,6 +21,7 @@ export type AdminConfigEditorLabels = {
|
||||
emptyList: string
|
||||
emptyTable: string
|
||||
entry: string
|
||||
general: string
|
||||
keyPlaceholder: string
|
||||
list: string
|
||||
remove: string
|
||||
@@ -59,6 +60,7 @@ type SerializedMapEntry = {
|
||||
keyType: MapKeyKind
|
||||
value: unknown
|
||||
}
|
||||
type RootTableTab = { count: number; id: string; key?: string; label: string }
|
||||
|
||||
const newArrayKind = ref<ValueKind>('string')
|
||||
const newObjectKey = ref('')
|
||||
@@ -67,6 +69,7 @@ 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(
|
||||
() =>
|
||||
@@ -112,12 +115,19 @@ const mapEntries = computed<SerializedMapEntry[]>(() => {
|
||||
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 (
|
||||
@@ -138,11 +148,49 @@ const tableEntries = computed(() =>
|
||||
([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 (newMapKeyKind.value === 'string') return key
|
||||
if (newMapEntryKeyKind.value === 'string') return key
|
||||
const numeric = Number(key)
|
||||
return Number.isFinite(numeric) ? numeric : null
|
||||
})
|
||||
@@ -150,7 +198,7 @@ const canAddMapEntry = computed(() => {
|
||||
const key = parsedNewMapKey.value
|
||||
if (key === null) return false
|
||||
return !mapEntries.value.some(
|
||||
(entry) => entry.keyType === newMapKeyKind.value && entry.key === key,
|
||||
(entry) => entry.keyType === newMapEntryKeyKind.value && entry.key === key,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -216,6 +264,56 @@ function structuredValueLabel(
|
||||
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
|
||||
}
|
||||
@@ -228,6 +326,12 @@ 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)
|
||||
}
|
||||
@@ -302,10 +406,14 @@ function addListRow(): void {
|
||||
const index = rows.length
|
||||
const value = rows.length
|
||||
? blankLike(rows[0])
|
||||
: blankValue(newArrayKind.value)
|
||||
: listTemplate.value
|
||||
? blankFromStructure(listTemplate.value)
|
||||
: blankValue(newArrayKind.value)
|
||||
rows.push(value)
|
||||
emit('update:modelValue', rows)
|
||||
if (isStructuredValue(value)) expandedEntry.value = `list:${index}`
|
||||
if (isStructuredValue(value, listTemplate.value)) {
|
||||
expandedEntry.value = `list:${index}`
|
||||
}
|
||||
}
|
||||
|
||||
function updateListRow(index: number, value: unknown): void {
|
||||
@@ -374,6 +482,7 @@ function addTableField(): void {
|
||||
})
|
||||
if (isStructuredValue(value, template)) {
|
||||
expandedEntry.value = `table:${key}`
|
||||
if (props.depth === 0) selectedTableTab.value = `field:${key}`
|
||||
}
|
||||
newObjectKey.value = ''
|
||||
}
|
||||
@@ -395,7 +504,7 @@ function updateMapKey(index: number, event: Event): void {
|
||||
if (!(target instanceof HTMLInputElement)) return
|
||||
const current = mapEntries.value[index]
|
||||
if (!current) return
|
||||
if (mapEntryStructure(current)) {
|
||||
if (fixedMapEntryStructure(current)) {
|
||||
target.value = String(current.key)
|
||||
return
|
||||
}
|
||||
@@ -430,7 +539,7 @@ function updateMapValue(index: number, value: unknown): void {
|
||||
|
||||
function removeMapEntry(index: number): void {
|
||||
const entry = mapEntries.value[index]
|
||||
if (!entry || mapEntryStructure(entry)) return
|
||||
if (!entry || fixedMapEntryStructure(entry)) return
|
||||
emitMap(
|
||||
mapEntries.value.filter((_, candidateIndex) => candidateIndex !== index),
|
||||
)
|
||||
@@ -440,25 +549,27 @@ function removeMapEntry(index: number): void {
|
||||
function addMapEntry(): void {
|
||||
if (!canAddMapEntry.value || parsedNewMapKey.value === null) return
|
||||
const key = parsedNewMapKey.value
|
||||
const value = blankCollectionValue(
|
||||
newMapValueKind.value,
|
||||
mapEntries.value.slice(0, 50).map((entry) => entry.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: newMapKeyKind.value,
|
||||
keyType: newMapEntryKeyKind.value,
|
||||
value,
|
||||
},
|
||||
])
|
||||
if (isStructuredValue(value)) {
|
||||
expandedEntry.value = `map:${newMapKeyKind.value}:${key}`
|
||||
if (isStructuredValue(value, mapTemplate.value)) {
|
||||
expandedEntry.value = `map:${newMapEntryKeyKind.value}:${key}`
|
||||
}
|
||||
newMapKey.value = ''
|
||||
}
|
||||
|
||||
function mapEntryStructure(
|
||||
function fixedMapEntryStructure(
|
||||
entry: SerializedMapEntry,
|
||||
): AdminConfiguratorStructure | undefined {
|
||||
return mapStructure.value?.entries.find(
|
||||
@@ -466,6 +577,12 @@ function mapEntryStructure(
|
||||
candidate.keyType === entry.keyType && candidate.key === entry.key,
|
||||
)?.structure
|
||||
}
|
||||
|
||||
function mapEntryStructure(
|
||||
entry: SerializedMapEntry,
|
||||
): AdminConfiguratorStructure | undefined {
|
||||
return fixedMapEntryStructure(entry) ?? mapTemplate.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -500,14 +617,14 @@ function mapEntryStructure(
|
||||
:class="{
|
||||
'has-structured-value': isStructuredValue(
|
||||
row,
|
||||
listStructure?.items[index],
|
||||
listItemStructure(index),
|
||||
),
|
||||
'is-expanded': expandedEntry === `list:${index}`,
|
||||
}"
|
||||
>
|
||||
<span class="config-structured-editor__index">{{ index + 1 }}</span>
|
||||
<button
|
||||
v-if="isStructuredValue(row, listStructure?.items[index])"
|
||||
v-if="isStructuredValue(row, listItemStructure(index))"
|
||||
type="button"
|
||||
class="config-structured-editor__section-toggle"
|
||||
:aria-label="`${ariaLabel} ${index + 1}`"
|
||||
@@ -518,36 +635,34 @@ function mapEntryStructure(
|
||||
<strong>{{ labels.entry }} {{ index + 1 }}</strong>
|
||||
<small
|
||||
:title="
|
||||
describe(listEntryPath(index), row, listStructure?.items[index])
|
||||
describe(listEntryPath(index), row, listItemStructure(index))
|
||||
"
|
||||
>
|
||||
{{
|
||||
describe(listEntryPath(index), row, listStructure?.items[index])
|
||||
describe(listEntryPath(index), row, listItemStructure(index))
|
||||
}}
|
||||
</small>
|
||||
</span>
|
||||
<em>{{ structuredValueLabel(row, listStructure?.items[index]) }}</em>
|
||||
<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, listStructure?.items[index])
|
||||
describe(listEntryPath(index), row, listItemStructure(index))
|
||||
"
|
||||
>
|
||||
{{
|
||||
describe(listEntryPath(index), row, listStructure?.items[index])
|
||||
}}
|
||||
{{ describe(listEntryPath(index), row, listItemStructure(index)) }}
|
||||
</small>
|
||||
</span>
|
||||
<AdminConfigValueEditor
|
||||
v-if="
|
||||
!isStructuredValue(row, listStructure?.items[index]) ||
|
||||
!isStructuredValue(row, listItemStructure(index)) ||
|
||||
expandedEntry === `list:${index}`
|
||||
"
|
||||
:model-value="row"
|
||||
:structure="listStructure?.items[index]"
|
||||
:structure="listItemStructure(index)"
|
||||
:aria-label="`${ariaLabel} ${index + 1}`"
|
||||
:describe="describe"
|
||||
:labels="labels"
|
||||
@@ -577,7 +692,7 @@ function mapEntryStructure(
|
||||
@submit.prevent="addListRow"
|
||||
>
|
||||
<select
|
||||
v-if="!listValue.length"
|
||||
v-if="!listValue.length && !listTemplate"
|
||||
v-model="newArrayKind"
|
||||
:disabled="disabled"
|
||||
>
|
||||
@@ -586,6 +701,12 @@ function mapEntryStructure(
|
||||
<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>
|
||||
@@ -631,7 +752,62 @@ function mapEntryStructure(
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="mapType" class="config-structured-editor__properties is-map">
|
||||
<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}`"
|
||||
@@ -651,7 +827,7 @@ function mapEntryStructure(
|
||||
:type="entry.keyType === 'number' ? 'number' : 'text'"
|
||||
:value="entry.key"
|
||||
:aria-label="`${ariaLabel} ${labels.types[entry.keyType]} ${index + 1}`"
|
||||
:disabled="disabled || Boolean(mapEntryStructure(entry))"
|
||||
:disabled="disabled || Boolean(fixedMapEntryStructure(entry))"
|
||||
@change="updateMapKey(index, $event)"
|
||||
/>
|
||||
<em
|
||||
@@ -701,7 +877,7 @@ function mapEntryStructure(
|
||||
@update:model-value="updateMapValue(index, $event)"
|
||||
/>
|
||||
<button
|
||||
v-if="!mapEntryStructure(entry)"
|
||||
v-if="!fixedMapEntryStructure(entry)"
|
||||
type="button"
|
||||
class="config-structured-editor__remove"
|
||||
:disabled="disabled"
|
||||
@@ -717,11 +893,12 @@ function mapEntryStructure(
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="tableEntries.length"
|
||||
v-else-if="visibleTableEntries.length"
|
||||
class="config-structured-editor__properties"
|
||||
:role="rootTableTabs.length ? 'tabpanel' : undefined"
|
||||
>
|
||||
<div
|
||||
v-for="([key, value], index) in tableEntries"
|
||||
v-for="([key, value], index) in visibleTableEntries"
|
||||
:key="key"
|
||||
class="config-structured-editor__property"
|
||||
:class="{
|
||||
@@ -793,7 +970,10 @@ function mapEntryStructure(
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="config-structured-editor__empty">
|
||||
<div
|
||||
v-else-if="!rootTableTabs.length"
|
||||
class="config-structured-editor__empty"
|
||||
>
|
||||
{{ labels.emptyTable }}
|
||||
</div>
|
||||
|
||||
@@ -802,24 +982,38 @@ function mapEntryStructure(
|
||||
class="config-structured-editor__add-field is-map"
|
||||
@submit.prevent="addMapEntry"
|
||||
>
|
||||
<select v-model="newMapKeyKind" :disabled="disabled">
|
||||
<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="newMapKeyKind === 'number' ? 'number' : 'text'"
|
||||
:type="newMapEntryKeyKind === 'number' ? 'number' : 'text'"
|
||||
:disabled="disabled"
|
||||
:placeholder="labels.keyPlaceholder"
|
||||
:aria-label="labels.keyPlaceholder"
|
||||
/>
|
||||
<select v-model="newMapValueKind" :disabled="disabled">
|
||||
<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>
|
||||
@@ -838,7 +1032,7 @@ function mapEntryStructure(
|
||||
:aria-label="labels.keyPlaceholder"
|
||||
/>
|
||||
<select
|
||||
v-if="!tableStructure?.mutableKeys"
|
||||
v-if="!tableStructure"
|
||||
v-model="newObjectKind"
|
||||
:disabled="disabled"
|
||||
>
|
||||
@@ -848,6 +1042,12 @@ function mapEntryStructure(
|
||||
<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>
|
||||
@@ -917,12 +1117,12 @@ function mapEntryStructure(
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background: #181b18;
|
||||
background: #121413;
|
||||
outline: 1px solid rgba(255, 255, 255, 0.065);
|
||||
}
|
||||
|
||||
.config-structured-editor.is-nested {
|
||||
background: #151715;
|
||||
background: #0f1110;
|
||||
}
|
||||
|
||||
.config-structured-editor.is-nested.has-structure
|
||||
@@ -959,6 +1159,74 @@ function mapEntryStructure(
|
||||
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,
|
||||
@@ -967,7 +1235,7 @@ function mapEntryStructure(
|
||||
border-radius: 3px;
|
||||
outline: 1px solid rgba(255, 255, 255, 0.075);
|
||||
color: var(--admin-text);
|
||||
background: #212421;
|
||||
background: #1a1c1b;
|
||||
font: inherit;
|
||||
font-size: 9px;
|
||||
}
|
||||
@@ -984,7 +1252,7 @@ function mapEntryStructure(
|
||||
}
|
||||
|
||||
.config-structured-editor button:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--admin-green) 12%, #212421);
|
||||
background: #222423;
|
||||
}
|
||||
|
||||
.config-structured-editor button:disabled,
|
||||
@@ -1010,19 +1278,19 @@ function mapEntryStructure(
|
||||
.config-structured-editor__row,
|
||||
.config-structured-editor__property {
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(105px, 0.42fr) minmax(140px, 1fr) 27px;
|
||||
grid-template-columns: 24px minmax(180px, 0.82fr) minmax(140px, 1.18fr) 27px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 6px;
|
||||
background: #1a1d1a;
|
||||
background: #151716;
|
||||
}
|
||||
|
||||
.config-structured-editor__property {
|
||||
grid-template-columns: 24px minmax(80px, 0.35fr) minmax(150px, 1fr) 27px;
|
||||
grid-template-columns: 24px minmax(200px, 0.9fr) minmax(150px, 1.1fr) 27px;
|
||||
}
|
||||
|
||||
.config-structured-editor__property.is-map {
|
||||
grid-template-columns: 24px minmax(105px, 0.42fr) minmax(150px, 1fr) 27px;
|
||||
grid-template-columns: 24px minmax(180px, 0.82fr) minmax(150px, 1.18fr) 27px;
|
||||
}
|
||||
|
||||
.config-structured-editor__row.has-structured-value,
|
||||
@@ -1267,6 +1535,25 @@ function mapEntryStructure(
|
||||
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;
|
||||
@@ -1282,7 +1569,9 @@ function mapEntryStructure(
|
||||
flex-basis: 82px;
|
||||
}
|
||||
|
||||
.config-structured-editor__add-field.is-list select {
|
||||
.config-structured-editor__add-field.is-list select,
|
||||
.config-structured-editor__add-field.is-list
|
||||
.config-structured-editor__fixed-type {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -265,6 +265,10 @@ describe('standalone admin panel contracts', () => {
|
||||
expect(configuratorServer).toContain('path == "Companies.Definitions"')
|
||||
expect(configuratorServer).toContain('flatten_company_fields')
|
||||
expect(configuratorServer).toContain('structure.mutableKeys')
|
||||
expect(configuratorServer).toContain('local function empty_structure(')
|
||||
expect(configuratorServer).toContain('template = items[1]')
|
||||
expect(configuratorServer).toContain('structure.template')
|
||||
expect(configuratorServer).toContain('if not structure.fields[key] then')
|
||||
expect(configuratorServer).toContain('field.type == "stringOrFalse"')
|
||||
expect(configuratorServer).not.toContain('Config.Media = client_payload')
|
||||
expect(configuratorClient).toContain(
|
||||
@@ -275,6 +279,10 @@ describe('standalone admin panel contracts', () => {
|
||||
'.admin-panel-rail .admin-panel-rail__configurator',
|
||||
)
|
||||
expect(source).toContain('<AdminConfigValueEditor')
|
||||
expect(source).toContain('class="admin-panel-config-scopes"')
|
||||
expect(source).toContain("selectConfiguratorScope('config')")
|
||||
expect(source).toContain("selectConfiguratorScope('media')")
|
||||
expect(source).not.toContain('class="admin-panel-config-meta"')
|
||||
expect(configuratorValueEditor).toContain('function addListRow()')
|
||||
expect(configuratorValueEditor).toContain('function addTableField()')
|
||||
expect(configuratorValueEditor).toContain(
|
||||
@@ -288,8 +296,23 @@ describe('standalone admin panel contracts', () => {
|
||||
expect(configuratorValueEditor).toContain('function removeTableField(')
|
||||
expect(configuratorValueEditor).toContain('function addMapEntry()')
|
||||
expect(configuratorValueEditor).toContain('function updateMapKey(')
|
||||
expect(configuratorValueEditor).toContain('mapEntryStructure(current)')
|
||||
expect(configuratorValueEditor).toContain('fixedMapEntryStructure(current)')
|
||||
expect(configuratorValueEditor).toContain('listStructure?.items[index]')
|
||||
expect(configuratorValueEditor).toContain('function listItemStructure(')
|
||||
expect(configuratorValueEditor).toContain('const listTemplate = computed(')
|
||||
expect(configuratorValueEditor).toContain('function structureTypeLabel(')
|
||||
expect(configuratorValueEditor).toContain(
|
||||
'class="config-structured-editor__fixed-type"',
|
||||
)
|
||||
expect(configuratorValueEditor).toContain(
|
||||
'const rootTableTabs = computed<RootTableTab[]>(',
|
||||
)
|
||||
expect(configuratorValueEditor).toContain(
|
||||
'class="config-structured-editor__tabs"',
|
||||
)
|
||||
expect(configuratorValueEditor).toContain(
|
||||
'class="config-structured-editor__tab-panel"',
|
||||
)
|
||||
expect(configuratorValueEditor).toContain('function tableFieldStructure(')
|
||||
expect(configuratorValueEditor).toContain('function isFixedTableField(')
|
||||
expect(configuratorValueEditor).toContain('function blankCollectionValue(')
|
||||
|
||||
@@ -73,6 +73,7 @@ type AdminTab =
|
||||
| 'moderation'
|
||||
| 'audit'
|
||||
| 'configurator'
|
||||
type ConfiguratorScope = 'config' | 'media'
|
||||
type DeviceAction = 'reset-passcode' | 'change-number' | 'factory-reset'
|
||||
type PendingAction = { kind: 'close' } | { kind: 'player'; source: number }
|
||||
|
||||
@@ -83,6 +84,7 @@ const tab = ref<AdminTab>('overview')
|
||||
const playerQuery = ref('')
|
||||
const appQuery = ref('')
|
||||
const configuratorQuery = ref('')
|
||||
const configuratorScope = ref<ConfiguratorScope>('config')
|
||||
const selectedConfiguratorSection = ref('')
|
||||
const selectedImei = ref('')
|
||||
const drafts = ref<Record<string, Record<string, boolean>>>({})
|
||||
@@ -263,7 +265,9 @@ const selectedCalls = computed(() =>
|
||||
: [],
|
||||
)
|
||||
const filteredConfiguratorSections = computed(() => {
|
||||
const sections = admin.configurator?.sections ?? []
|
||||
const sections = (admin.configurator?.sections ?? []).filter(
|
||||
(section) => section.scope === configuratorScope.value,
|
||||
)
|
||||
const needle = configuratorQuery.value.trim().toLocaleLowerCase(phone.lang)
|
||||
if (!needle) return sections
|
||||
return sections.filter(
|
||||
@@ -278,6 +282,13 @@ const filteredConfiguratorSections = computed(() => {
|
||||
),
|
||||
)
|
||||
})
|
||||
const configuratorScopeCounts = computed(() => {
|
||||
const sections = admin.configurator?.sections ?? []
|
||||
return {
|
||||
config: sections.filter((section) => section.scope === 'config').length,
|
||||
media: sections.filter((section) => section.scope === 'media').length,
|
||||
}
|
||||
})
|
||||
const filteredConfiguratorFieldCount = computed(() =>
|
||||
filteredConfiguratorSections.value.reduce(
|
||||
(total, section) => total + configuratorSectionCount(section.fields),
|
||||
@@ -305,14 +316,17 @@ watch(
|
||||
)
|
||||
|
||||
watch(
|
||||
() => admin.configurator?.sections,
|
||||
(sections) => {
|
||||
[() => admin.configurator?.sections, configuratorScope],
|
||||
([sections, scope]) => {
|
||||
if (
|
||||
!sections?.some(
|
||||
(section) => section.id === selectedConfiguratorSection.value,
|
||||
(section) =>
|
||||
section.id === selectedConfiguratorSection.value &&
|
||||
section.scope === scope,
|
||||
)
|
||||
) {
|
||||
selectedConfiguratorSection.value = sections?.[0]?.id ?? ''
|
||||
selectedConfiguratorSection.value =
|
||||
sections?.find((section) => section.scope === scope)?.id ?? ''
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -337,6 +351,11 @@ function t(key: string, params?: Record<string, string>): string {
|
||||
return phone.t('AdminPanel.' + key, params)
|
||||
}
|
||||
|
||||
function selectConfiguratorScope(scope: ConfiguratorScope): void {
|
||||
configuratorScope.value = scope
|
||||
configuratorQuery.value = ''
|
||||
}
|
||||
|
||||
function configuratorDescription(
|
||||
path: string,
|
||||
value: unknown,
|
||||
@@ -356,6 +375,7 @@ const configuratorEditorLabels = computed<AdminConfigEditorLabels>(() => ({
|
||||
emptyList: t('configurator.table.emptyList'),
|
||||
emptyTable: t('configurator.table.emptyTable'),
|
||||
entry: t('configurator.table.entry'),
|
||||
general: t('configurator.table.general'),
|
||||
keyPlaceholder: t('configurator.table.keyPlaceholder'),
|
||||
list: t('configurator.table.list'),
|
||||
remove: t('configurator.table.remove'),
|
||||
@@ -1038,6 +1058,32 @@ onBeforeUnmount(() => {
|
||||
:aria-label="t('configurator.search')"
|
||||
/>
|
||||
</label>
|
||||
<div
|
||||
class="admin-panel-config-scopes"
|
||||
role="tablist"
|
||||
:aria-label="t('configurator.sections')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="configuratorScope === 'config'"
|
||||
:class="{ 'is-active': configuratorScope === 'config' }"
|
||||
@click="selectConfiguratorScope('config')"
|
||||
>
|
||||
<span>{{ t('configurator.configScope') }}</span>
|
||||
<em>{{ configuratorScopeCounts.config }}</em>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="configuratorScope === 'media'"
|
||||
:class="{ 'is-active': configuratorScope === 'media' }"
|
||||
@click="selectConfiguratorScope('media')"
|
||||
>
|
||||
<span>{{ t('configurator.mediaScope') }}</span>
|
||||
<em>{{ configuratorScopeCounts.media }}</em>
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-panel-config-sections">
|
||||
<button
|
||||
v-for="section in filteredConfiguratorSections"
|
||||
@@ -1274,10 +1320,6 @@ onBeforeUnmount(() => {
|
||||
<h1>{{ t('configurator.title') }}</h1>
|
||||
<p>{{ t('configurator.body') }}</p>
|
||||
</div>
|
||||
<div class="admin-panel-config-meta">
|
||||
<span>SQL</span>
|
||||
<strong>R{{ admin.configurator.revision }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article
|
||||
@@ -2178,28 +2220,28 @@ onBeforeUnmount(() => {
|
||||
|
||||
<style scoped>
|
||||
.admin-panel-overlay {
|
||||
--admin-bg: #090b0a;
|
||||
--admin-panel: #111311;
|
||||
--admin-panel-raised: #171917;
|
||||
--admin-panel-hover: #202320;
|
||||
--admin-nav-active: #292c29;
|
||||
--admin-bg: #070908;
|
||||
--admin-panel: #0d0f0e;
|
||||
--admin-panel-raised: #131514;
|
||||
--admin-panel-hover: #191b1a;
|
||||
--admin-nav-active: #202220;
|
||||
--admin-border: rgba(255, 255, 255, 0.045);
|
||||
--admin-border-strong: rgba(255, 255, 255, 0.085);
|
||||
--admin-text: #f0f3f0;
|
||||
--admin-muted: #818781;
|
||||
--admin-dim: #555b55;
|
||||
--admin-green: var(--admin-accent, #74d66f);
|
||||
--admin-green-soft: color-mix(in srgb, var(--admin-green) 14%, transparent);
|
||||
--admin-green-soft: color-mix(in srgb, var(--admin-green) 9%, transparent);
|
||||
--admin-row-hover: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--admin-green) 10%, #1b1e1b) 0%,
|
||||
rgba(27, 30, 27, 0.52) 48%,
|
||||
#1a1c1b 0%,
|
||||
rgba(24, 26, 25, 0.52) 48%,
|
||||
transparent 100%
|
||||
);
|
||||
--admin-row-active: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--admin-green) 19%, #1d201d) 0%,
|
||||
color-mix(in srgb, var(--admin-green) 7%, #171917) 45%,
|
||||
#212321 0%,
|
||||
#171918 45%,
|
||||
transparent 100%
|
||||
);
|
||||
--admin-red: #ef6969;
|
||||
@@ -2219,12 +2261,12 @@ onBeforeUnmount(() => {
|
||||
width: min(76vw, 1220px);
|
||||
height: min(74vh, 700px);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||
border: 1px solid rgba(255, 255, 255, 0.065);
|
||||
border-radius: 4px;
|
||||
background: var(--admin-bg);
|
||||
box-shadow:
|
||||
0 18px 52px rgba(0, 0, 0, 0.62),
|
||||
inset 0 1px rgba(255, 255, 255, 0.025);
|
||||
0 20px 56px rgba(0, 0, 0, 0.72),
|
||||
inset 0 1px rgba(255, 255, 255, 0.018);
|
||||
}
|
||||
|
||||
.admin-panel-header {
|
||||
@@ -2347,14 +2389,15 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.admin-panel-save.is-ready {
|
||||
border-color: color-mix(in srgb, var(--admin-green) 50%, transparent);
|
||||
color: #091009;
|
||||
background: var(--admin-green);
|
||||
box-shadow: 0 0 18px color-mix(in srgb, var(--admin-green) 18%, transparent);
|
||||
border-color: var(--admin-border-strong);
|
||||
color: #74d66f;
|
||||
background: #121413;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.admin-panel-save.is-ready:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
color: color-mix(in srgb, #74d66f 82%, white);
|
||||
background: #191b1a;
|
||||
}
|
||||
|
||||
.admin-panel-close:hover {
|
||||
@@ -2559,6 +2602,49 @@ button:disabled {
|
||||
color: #5f655f;
|
||||
}
|
||||
|
||||
.admin-panel-config-scopes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 2px;
|
||||
margin: 0 12px 8px;
|
||||
padding: 3px;
|
||||
border-radius: 4px;
|
||||
background: #171917;
|
||||
}
|
||||
|
||||
.admin-panel-config-scopes button {
|
||||
min-width: 0;
|
||||
height: 27px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
color: var(--admin-muted);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-panel-config-scopes button:hover,
|
||||
.admin-panel-config-scopes button.is-active {
|
||||
color: var(--admin-text);
|
||||
background: var(--admin-row-active);
|
||||
}
|
||||
|
||||
.admin-panel-config-scopes button.is-active {
|
||||
box-shadow: inset 0 -1px var(--admin-green);
|
||||
}
|
||||
|
||||
.admin-panel-config-scopes em {
|
||||
color: var(--admin-dim);
|
||||
font-size: 7px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.admin-panel-player-list,
|
||||
.admin-panel-audit-mini-list,
|
||||
.admin-panel-config-sections {
|
||||
@@ -2570,7 +2656,7 @@ button:disabled {
|
||||
}
|
||||
|
||||
.admin-panel-config-sections {
|
||||
height: calc(100% - 111px);
|
||||
height: calc(100% - 149px);
|
||||
overflow-y: auto;
|
||||
padding: 0 8px 16px;
|
||||
scrollbar-color: #343834 transparent;
|
||||
@@ -3481,23 +3567,6 @@ button:disabled {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.admin-panel-config-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-left: auto;
|
||||
padding: 5px 8px;
|
||||
border-radius: 4px;
|
||||
color: var(--admin-muted);
|
||||
background: #171917;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.admin-panel-config-meta strong {
|
||||
color: var(--admin-green);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.admin-panel-config-disabled,
|
||||
.admin-panel-config-notice {
|
||||
display: grid;
|
||||
@@ -3601,7 +3670,7 @@ button:disabled {
|
||||
.admin-panel-config-field {
|
||||
min-height: 51px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(170px, 0.72fr) minmax(230px, 1.28fr);
|
||||
grid-template-columns: minmax(220px, 0.9fr) minmax(210px, 1.1fr);
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 8px 12px;
|
||||
|
||||
@@ -918,6 +918,7 @@ const adminPanelFallbackLocales = {
|
||||
table: 'Key table',
|
||||
vector: 'Vector',
|
||||
entry: 'Entry',
|
||||
general: 'General',
|
||||
addRow: 'Add row',
|
||||
addField: 'Add field',
|
||||
remove: 'Remove',
|
||||
|
||||
@@ -127,6 +127,7 @@ export type AdminConfiguratorStructure =
|
||||
| {
|
||||
kind: 'list'
|
||||
items: AdminConfiguratorStructure[]
|
||||
template?: AdminConfiguratorStructure
|
||||
}
|
||||
| {
|
||||
fields: Record<string, AdminConfiguratorStructure>
|
||||
@@ -140,7 +141,9 @@ export type AdminConfiguratorStructure =
|
||||
keyType: 'number' | 'string'
|
||||
structure: AdminConfiguratorStructure
|
||||
}>
|
||||
keyType?: 'number' | 'string'
|
||||
kind: 'map'
|
||||
template?: AdminConfiguratorStructure
|
||||
}
|
||||
| {
|
||||
kind: 'value'
|
||||
|
||||
@@ -400,6 +400,82 @@ function maskValue(value, path) {
|
||||
: value
|
||||
}
|
||||
|
||||
function emptyStructure(scope, path) {
|
||||
if (scope !== 'config') return undefined
|
||||
if (path === 'Garage.VehicleImages.ModelNames') {
|
||||
return {
|
||||
entries: [],
|
||||
keyType: 'number',
|
||||
kind: 'map',
|
||||
template: { kind: 'value', valueType: 'string' },
|
||||
}
|
||||
}
|
||||
if (
|
||||
path === 'CrewLink.ExternalPingResources' ||
|
||||
path === 'CustomApps.TrustedAdapters'
|
||||
) {
|
||||
return {
|
||||
fields: {},
|
||||
kind: 'table',
|
||||
mutableKeys: true,
|
||||
template: { kind: 'value', valueType: 'boolean' },
|
||||
}
|
||||
}
|
||||
if (path === 'FlipTok.MusicTracks') {
|
||||
return {
|
||||
items: [],
|
||||
kind: 'list',
|
||||
template: {
|
||||
fields: {
|
||||
Artist: { kind: 'value', valueType: 'string' },
|
||||
Id: { kind: 'value', valueType: 'string' },
|
||||
Title: { kind: 'value', valueType: 'string' },
|
||||
Url: { kind: 'value', valueType: 'string' },
|
||||
},
|
||||
kind: 'table',
|
||||
},
|
||||
}
|
||||
}
|
||||
if (path === 'Music.Tracks') {
|
||||
return {
|
||||
items: [],
|
||||
kind: 'list',
|
||||
template: {
|
||||
fields: {
|
||||
Artist: { kind: 'value', valueType: 'string' },
|
||||
Id: { kind: 'value', valueType: 'string' },
|
||||
Title: { kind: 'value', valueType: 'string' },
|
||||
},
|
||||
kind: 'table',
|
||||
},
|
||||
}
|
||||
}
|
||||
if (path === 'Payphones.CustomLocations') {
|
||||
return {
|
||||
items: [],
|
||||
kind: 'list',
|
||||
template: { kind: 'vector', vectorType: 'vector4' },
|
||||
}
|
||||
}
|
||||
if (/^Companies\.Definitions\.[^.]+\.Services$/.test(path)) {
|
||||
return {
|
||||
items: [],
|
||||
kind: 'list',
|
||||
template: {
|
||||
fields: {
|
||||
Description: { kind: 'value', valueType: 'string' },
|
||||
Id: { kind: 'value', valueType: 'string' },
|
||||
Price: { kind: 'value', valueType: 'string' },
|
||||
RequestsEnabled: { kind: 'value', valueType: 'boolean' },
|
||||
Title: { kind: 'value', valueType: 'string' },
|
||||
},
|
||||
kind: 'table',
|
||||
},
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function buildStructure(value, scope, path) {
|
||||
if (scope === 'config' && path === 'Phone.Keybind') {
|
||||
return { kind: 'optionalString' }
|
||||
@@ -425,30 +501,35 @@ function buildStructure(value, scope, path) {
|
||||
template: keys[0] ? fields[keys[0]] : undefined,
|
||||
}
|
||||
}
|
||||
if (
|
||||
scope === 'config' &&
|
||||
path === 'Garage.VehicleImages.ModelNames' &&
|
||||
Array.isArray(value) &&
|
||||
value.length === 0
|
||||
) {
|
||||
return { kind: 'map', entries: [] }
|
||||
const configuredEmptyStructure =
|
||||
Array.isArray(value) && value.length === 0
|
||||
? emptyStructure(scope, path)
|
||||
: undefined
|
||||
if (configuredEmptyStructure) {
|
||||
return configuredEmptyStructure
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const items = value.map((child, index) =>
|
||||
buildStructure(child, scope, `${path}.${index + 1}`),
|
||||
)
|
||||
return {
|
||||
kind: 'list',
|
||||
items: value.map((child, index) =>
|
||||
buildStructure(child, scope, `${path}.${index + 1}`),
|
||||
),
|
||||
items,
|
||||
template: items[0],
|
||||
}
|
||||
}
|
||||
if (value?.__skyType === 'map' && Array.isArray(value.entries)) {
|
||||
const entries = value.entries.map((entry) => ({
|
||||
key: entry.key,
|
||||
keyType: entry.keyType,
|
||||
structure: buildStructure(entry.value, scope, `${path}.${entry.key}`),
|
||||
}))
|
||||
const keyTypes = new Set(entries.map((entry) => entry.keyType))
|
||||
return {
|
||||
kind: 'map',
|
||||
entries: value.entries.map((entry) => ({
|
||||
key: entry.key,
|
||||
keyType: entry.keyType,
|
||||
structure: buildStructure(entry.value, scope, `${path}.${entry.key}`),
|
||||
})),
|
||||
entries,
|
||||
keyType: keyTypes.size === 1 ? entries[0]?.keyType : undefined,
|
||||
template: entries[0]?.structure,
|
||||
}
|
||||
}
|
||||
if (/^vector[234]$/.test(value?.__skyType ?? '')) {
|
||||
|
||||
@@ -15,6 +15,7 @@ type ConfiguratorStructure = {
|
||||
entries?: Array<{ structure: ConfiguratorStructure }>
|
||||
fields?: Record<string, ConfiguratorStructure>
|
||||
items?: ConfiguratorStructure[]
|
||||
keyType?: 'number' | 'string'
|
||||
kind: string
|
||||
mutableKeys?: boolean
|
||||
template?: ConfiguratorStructure
|
||||
@@ -128,4 +129,66 @@ describe('admin configurator fixture', () => {
|
||||
garage?.structure?.fields?.VehicleImages.fields?.ModelNames.kind,
|
||||
).toBe('map')
|
||||
})
|
||||
|
||||
it('publishes fixed schemas for every empty configurable collection', () => {
|
||||
const fields = loadConfiguratorSections().flatMap(
|
||||
(section) => section.fields,
|
||||
)
|
||||
const root = (path: string) =>
|
||||
fields.find((field) => field.path === path)?.structure
|
||||
|
||||
expect(root('Music')?.fields?.Tracks).toMatchObject({
|
||||
items: [],
|
||||
kind: 'list',
|
||||
template: {
|
||||
fields: {
|
||||
Artist: { kind: 'value', valueType: 'string' },
|
||||
Id: { kind: 'value', valueType: 'string' },
|
||||
Title: { kind: 'value', valueType: 'string' },
|
||||
},
|
||||
kind: 'table',
|
||||
},
|
||||
})
|
||||
expect(root('FlipTok')?.fields?.MusicTracks.template).toMatchObject({
|
||||
fields: { Url: { kind: 'value', valueType: 'string' } },
|
||||
kind: 'table',
|
||||
})
|
||||
expect(root('Payphones')?.fields?.CustomLocations.template).toEqual({
|
||||
kind: 'vector',
|
||||
vectorType: 'vector4',
|
||||
})
|
||||
expect(root('CrewLink')?.fields?.ExternalPingResources).toMatchObject({
|
||||
fields: {},
|
||||
kind: 'table',
|
||||
mutableKeys: true,
|
||||
template: { kind: 'value', valueType: 'boolean' },
|
||||
})
|
||||
expect(root('CustomApps')?.fields?.TrustedAdapters).toMatchObject({
|
||||
fields: {},
|
||||
kind: 'table',
|
||||
mutableKeys: true,
|
||||
template: { kind: 'value', valueType: 'boolean' },
|
||||
})
|
||||
expect(
|
||||
root('Garage')?.fields?.VehicleImages.fields?.ModelNames,
|
||||
).toMatchObject({
|
||||
entries: [],
|
||||
keyType: 'number',
|
||||
kind: 'map',
|
||||
template: { kind: 'value', valueType: 'string' },
|
||||
})
|
||||
expect(
|
||||
root('Companies.Definitions')?.fields?.police.fields?.Services,
|
||||
).toMatchObject({
|
||||
items: [],
|
||||
kind: 'list',
|
||||
template: {
|
||||
fields: {
|
||||
Id: { kind: 'value', valueType: 'string' },
|
||||
RequestsEnabled: { kind: 'value', valueType: 'boolean' },
|
||||
},
|
||||
kind: 'table',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -206,7 +206,7 @@ Locales["de"] = {
|
||||
tabs = { overview = "Übersicht", players = "Spieler", devices = "Geräte", apps = "Apps", accounts = "Accounts", messages = "Nachrichten", calls = "Anrufe", moderation = "Moderation", audit = "Audit", configurator = "Phone Configurator" },
|
||||
overview = { eyebrow = "Server", title = "Dashboard", body = "Spieler, Geräte, Apps und Handydaten.", stats = "Server-Handystatistik", online = "Online", devices = "Geräte", accounts = "Accounts", audit = "Audit-Einträge", control = "Navigation", features = "Module", featuresBody = "Öffne ein Verwaltungsmodul.", recent = "Letzte Aktivitäten", playerFeature = "Identität, Finanzen, Job und Dienst", deviceFeature = "IMEI, SIM, Nummer und Aktivität", appFeature = "Handy-Apps installieren oder entfernen", accountFeature = "Accountzugriff und geschützte Zugangsdaten", messageFeature = "Letzte SMS-Aktivitäten prüfen", callFeature = "Letzte Anrufaktivitäten prüfen", moderationFeature = "Zugriff, Nummer oder Gerätedaten zurücksetzen", auditFeature = "Sensible Admin-Aktionen prüfen", configuratorFeature = "config.lua und media.lua über SQL verwalten" },
|
||||
appearance = { eyebrow = "Darstellung", title = "Akzentfarbe", body = "Ändere den Akzent im gesamten Admin-Arbeitsbereich.", colors = { emerald = "Smaragd", blue = "Blau", violet = "Violett", orange = "Orange", red = "Rot" } },
|
||||
configurator = { context = "Runtime-Konfiguration", eyebrow = "Systemwerkzeug", sections = "Konfiguration", search = "Einstellungen oder Pfade suchen", configScope = "config.lua", mediaScope = "media.lua", noResults = "Keine passenden Einstellungen", loading = "SQL-Konfiguration wird geladen...", title = "Phone Configurator", body = "Verwalte Handy- und Media-Einstellungen im geschützten Admin-Bereich.", disabledTitle = "SQL-Konfiguration ist nicht aktiv", disabledBody = "Aktiviere den Configurator am Anfang der config.lua und starte sky_phone neu. Bis dahin bleiben die Dateiwerte aktiv und die Bearbeitung gesperrt.", manualSave = "Manuelles Speichern", restartNotice = "Nichts wird automatisch gespeichert. Der grüne Haken schreibt alle vorgemerkten Werte. Startgebundene Einstellungen werden nach einem Neustart von sky_phone vollständig aktiv.", fieldCount = "{count} Felder", secretConfigured = "Secret gesetzt · Ersatzwert eingeben", invalidValue = "Prüfe den markierten Tabellen- oder Zahlenwert.", saved = "SQL-Konfiguration gespeichert.", descriptions = { featureToggle = "Schaltet {name} ein oder aus.", boolean = "Legt fest, ob {name} erlaubt ist.", number = "Legt den Zahlenwert für {name} fest.", text = "Legt den Textwert für {name} fest.", optionalText = "Legt den optionalen Wert für {name} fest; der Schalter deaktiviert ihn.", list = "Verwaltet alle Einträge für {name}.", table = "Bündelt die zusammengehörigen Einstellungen für {name}.", credential = "Speichert den geschützten Zugangsschlüssel für {name}.", url = "Legt die URL oder den Endpunkt für {name} fest.", hosts = "Legt die erlaubten Domains für {name} fest.", milliseconds = "Legt die Zeit für {name} in Millisekunden fest.", seconds = "Legt die Zeit für {name} in Sekunden fest.", rateLimit = "Begrenzt die Aktionen für {name} pro Minute.", byteLimit = "Legt die maximal erlaubte Datengröße für {name} fest.", textLimit = "Legt die maximal erlaubte Textlänge für {name} fest.", distance = "Legt die Distanz in der Spielwelt für {name} fest.", coordinates = "Legt Weltkoordinaten oder Ausrichtung für {name} fest.", gameAsset = "Legt das GTA-Modell oder Prop für {name} fest.", animation = "Legt die Animationsdatei für {name} fest.", access = "Legt Jobs, Gruppen oder Berechtigungsstufen für {name} fest.", integration = "Wählt Framework oder Anbieter für {name} aus.", path = "Legt den Speicher- oder Ressourcenpfad für {name} fest.", color = "Legt die Oberflächenfarbe für {name} fest.", displayText = "Legt den Spielern angezeigten Text für {name} fest.", phoneNumber = "Legt die Telefon- oder Servicenummer für {name} fest.", routing = "Steuert die Verteilung eingehender Anfragen für {name}.", command = "Legt den Chat-Befehl zum Öffnen oder Ausführen von {name} fest.", locale = "Wählt die Sprache für {name} aus.", debug = "Steuert ausführliche Diagnoseausgaben für {name}.", mediaQuality = "Legt Medienqualität oder Lautstärke für {name} fest.", amount = "Legt die maximale oder angezeigte Menge für {name} fest." }, table = { list = "Liste", table = "Schlüsseltabelle", vector = "Vektor", entry = "Eintrag", addRow = "Zeile hinzufügen", addField = "Feld hinzufügen", remove = "Entfernen", emptyList = "Noch keine Zeilen. Füge die erste Zeile mit Plus hinzu.", emptyTable = "Noch keine Felder. Füge unten den ersten Schlüssel hinzu.", keyPlaceholder = "Neuer Schlüssel", convertToList = "Als Liste nutzen", convertToMap = "Als typisierte Schlüsseltabelle nutzen", convertToTable = "Als Schlüsseltabelle nutzen", types = { string = "Text", number = "Zahl", boolean = "Schalter", list = "Liste", table = "Tabelle" } } },
|
||||
configurator = { context = "Runtime-Konfiguration", eyebrow = "Systemwerkzeug", sections = "Konfiguration", search = "Einstellungen oder Pfade suchen", configScope = "config.lua", mediaScope = "media.lua", noResults = "Keine passenden Einstellungen", loading = "SQL-Konfiguration wird geladen...", title = "Phone Configurator", body = "Verwalte Handy- und Media-Einstellungen im geschützten Admin-Bereich.", disabledTitle = "SQL-Konfiguration ist nicht aktiv", disabledBody = "Aktiviere den Configurator am Anfang der config.lua und starte sky_phone neu. Bis dahin bleiben die Dateiwerte aktiv und die Bearbeitung gesperrt.", manualSave = "Manuelles Speichern", restartNotice = "Nichts wird automatisch gespeichert. Der grüne Haken schreibt alle vorgemerkten Werte. Startgebundene Einstellungen werden nach einem Neustart von sky_phone vollständig aktiv.", fieldCount = "{count} Felder", secretConfigured = "Secret gesetzt · Ersatzwert eingeben", invalidValue = "Prüfe den markierten Tabellen- oder Zahlenwert.", saved = "SQL-Konfiguration gespeichert.", descriptions = { featureToggle = "Schaltet {name} ein oder aus.", boolean = "Legt fest, ob {name} erlaubt ist.", number = "Legt den Zahlenwert für {name} fest.", text = "Legt den Textwert für {name} fest.", optionalText = "Legt den optionalen Wert für {name} fest; der Schalter deaktiviert ihn.", list = "Verwaltet alle Einträge für {name}.", table = "Bündelt die zusammengehörigen Einstellungen für {name}.", credential = "Speichert den geschützten Zugangsschlüssel für {name}.", url = "Legt die URL oder den Endpunkt für {name} fest.", hosts = "Legt die erlaubten Domains für {name} fest.", milliseconds = "Legt die Zeit für {name} in Millisekunden fest.", seconds = "Legt die Zeit für {name} in Sekunden fest.", rateLimit = "Begrenzt die Aktionen für {name} pro Minute.", byteLimit = "Legt die maximal erlaubte Datengröße für {name} fest.", textLimit = "Legt die maximal erlaubte Textlänge für {name} fest.", distance = "Legt die Distanz in der Spielwelt für {name} fest.", coordinates = "Legt Weltkoordinaten oder Ausrichtung für {name} fest.", gameAsset = "Legt das GTA-Modell oder Prop für {name} fest.", animation = "Legt die Animationsdatei für {name} fest.", access = "Legt Jobs, Gruppen oder Berechtigungsstufen für {name} fest.", integration = "Wählt Framework oder Anbieter für {name} aus.", path = "Legt den Speicher- oder Ressourcenpfad für {name} fest.", color = "Legt die Oberflächenfarbe für {name} fest.", displayText = "Legt den Spielern angezeigten Text für {name} fest.", phoneNumber = "Legt die Telefon- oder Servicenummer für {name} fest.", routing = "Steuert die Verteilung eingehender Anfragen für {name}.", command = "Legt den Chat-Befehl zum Öffnen oder Ausführen von {name} fest.", locale = "Wählt die Sprache für {name} aus.", debug = "Steuert ausführliche Diagnoseausgaben für {name}.", mediaQuality = "Legt Medienqualität oder Lautstärke für {name} fest.", amount = "Legt die maximale oder angezeigte Menge für {name} fest." }, table = { list = "Liste", table = "Schlüsseltabelle", vector = "Vektor", entry = "Eintrag", general = "Allgemein", addRow = "Zeile hinzufügen", addField = "Feld hinzufügen", remove = "Entfernen", emptyList = "Noch keine Zeilen. Füge die erste Zeile mit Plus hinzu.", emptyTable = "Noch keine Felder. Füge unten den ersten Schlüssel hinzu.", keyPlaceholder = "Neuer Schlüssel", convertToList = "Als Liste nutzen", convertToMap = "Als typisierte Schlüsseltabelle nutzen", convertToTable = "Als Schlüsseltabelle nutzen", types = { string = "Text", number = "Zahl", boolean = "Schalter", list = "Liste", table = "Tabelle" } } },
|
||||
players = { eyebrow = "Aktive Sitzungen", title = "Online-Spieler", online = "Jetzt online", empty = "Keine Spieler gefunden", emptyBody = "Passe die Suche an oder aktualisiere die Spielerliste." },
|
||||
search = { players = "Name, ID, Job oder Nummer suchen", apps = "Apps suchen", clear = "Suche leeren" },
|
||||
detail = { character = "Charakterprofil", data = "Spielerdatenübersicht", cash = "Bargeld", bank = "Bank", job = "Job", duty = "Dienst", onDuty = "Im Dienst", offDuty = "Außer Dienst", identity = "Identität", playerData = "Spielerdaten", identifier = "Charakter-Identifier", birthdate = "Geburtsdatum", grade = "Job-Rang", unknown = "Unbekannt" },
|
||||
|
||||
@@ -206,7 +206,7 @@ Locales["en"] = {
|
||||
tabs = { overview = "Overview", players = "Players", devices = "Devices", apps = "Apps", accounts = "Accounts", messages = "Messages", calls = "Calls", moderation = "Moderation", audit = "Audit", configurator = "Phone configurator" },
|
||||
overview = { eyebrow = "Server", title = "Dashboard", body = "Players, devices, apps, and phone data.", stats = "Server phone statistics", online = "Online", devices = "Devices", accounts = "Accounts", audit = "Audit entries", control = "Navigation", features = "Modules", featuresBody = "Open an administration module.", recent = "Recent activity", playerFeature = "Identity, finances, job, and duty", deviceFeature = "IMEI, SIM, number, and activity", appFeature = "Install or remove phone apps", accountFeature = "Account access and protected credentials", messageFeature = "Review recent SMS activity", callFeature = "Review recent call activity", moderationFeature = "Reset access, number, or device data", auditFeature = "Review sensitive admin actions", configuratorFeature = "Manage config.lua and media.lua through SQL" },
|
||||
appearance = { eyebrow = "Appearance", title = "Accent color", body = "Change the accent across the complete admin workspace.", colors = { emerald = "Emerald", blue = "Blue", violet = "Violet", orange = "Orange", red = "Red" } },
|
||||
configurator = { context = "Runtime configuration", eyebrow = "System tool", sections = "Configuration", search = "Search settings or paths", configScope = "config.lua", mediaScope = "media.lua", noResults = "No matching settings", loading = "Loading SQL configuration...", title = "Phone configurator", body = "Manage phone and media settings from the protected admin workspace.", disabledTitle = "SQL configuration is not active", disabledBody = "Enable the configurator at the beginning of config.lua and restart sky_phone. Until then, file values remain active and editing is locked.", manualSave = "Manual save", restartNotice = "Nothing is written automatically. Press the green check to persist all staged values. Startup-bound settings take full effect after restarting sky_phone.", fieldCount = "{count} fields", secretConfigured = "Secret configured · enter a replacement", invalidValue = "Check the highlighted table or number value.", saved = "SQL configuration saved.", descriptions = { featureToggle = "Turns {name} on or off.", boolean = "Controls whether {name} is allowed.", number = "Sets the numeric value for {name}.", text = "Sets the text value used for {name}.", optionalText = "Sets the optional value for {name}; switch it off to disable it.", list = "Manages all entries used for {name}.", table = "Groups the related settings for {name}.", credential = "Stores the protected credential used by {name}.", url = "Sets the URL or endpoint used by {name}.", hosts = "Defines which domains are allowed for {name}.", milliseconds = "Sets the timing for {name} in milliseconds.", seconds = "Sets the timing for {name} in seconds.", rateLimit = "Limits how many {name} actions are allowed per minute.", byteLimit = "Sets the maximum data size allowed for {name}.", textLimit = "Sets the maximum text length allowed for {name}.", distance = "Sets the world distance used for {name}.", coordinates = "Sets the world coordinates or orientation for {name}.", gameAsset = "Sets the GTA model or prop used for {name}.", animation = "Sets the animation asset used for {name}.", access = "Defines the jobs, groups or permission level for {name}.", integration = "Selects the connected framework or provider for {name}.", path = "Sets the storage or resource path used for {name}.", color = "Sets the interface color used for {name}.", displayText = "Sets the text shown to players for {name}.", phoneNumber = "Sets the phone or service number used for {name}.", routing = "Controls how incoming requests are routed for {name}.", command = "Sets the chat command used to open or run {name}.", locale = "Selects the language used for {name}.", debug = "Controls detailed diagnostic output for {name}.", mediaQuality = "Sets the media quality or volume used for {name}.", amount = "Sets the maximum or displayed amount for {name}." }, table = { list = "List", table = "Key table", vector = "Vector", entry = "Entry", addRow = "Add row", addField = "Add field", remove = "Remove", emptyList = "No rows yet. Add the first row with plus.", emptyTable = "No fields yet. Add the first key below.", keyPlaceholder = "New key", convertToList = "Use list", convertToMap = "Use typed key table", convertToTable = "Use key table", types = { string = "Text", number = "Number", boolean = "Switch", list = "List", table = "Table" } } },
|
||||
configurator = { context = "Runtime configuration", eyebrow = "System tool", sections = "Configuration", search = "Search settings or paths", configScope = "config.lua", mediaScope = "media.lua", noResults = "No matching settings", loading = "Loading SQL configuration...", title = "Phone configurator", body = "Manage phone and media settings from the protected admin workspace.", disabledTitle = "SQL configuration is not active", disabledBody = "Enable the configurator at the beginning of config.lua and restart sky_phone. Until then, file values remain active and editing is locked.", manualSave = "Manual save", restartNotice = "Nothing is written automatically. Press the green check to persist all staged values. Startup-bound settings take full effect after restarting sky_phone.", fieldCount = "{count} fields", secretConfigured = "Secret configured · enter a replacement", invalidValue = "Check the highlighted table or number value.", saved = "SQL configuration saved.", descriptions = { featureToggle = "Turns {name} on or off.", boolean = "Controls whether {name} is allowed.", number = "Sets the numeric value for {name}.", text = "Sets the text value used for {name}.", optionalText = "Sets the optional value for {name}; switch it off to disable it.", list = "Manages all entries used for {name}.", table = "Groups the related settings for {name}.", credential = "Stores the protected credential used by {name}.", url = "Sets the URL or endpoint used by {name}.", hosts = "Defines which domains are allowed for {name}.", milliseconds = "Sets the timing for {name} in milliseconds.", seconds = "Sets the timing for {name} in seconds.", rateLimit = "Limits how many {name} actions are allowed per minute.", byteLimit = "Sets the maximum data size allowed for {name}.", textLimit = "Sets the maximum text length allowed for {name}.", distance = "Sets the world distance used for {name}.", coordinates = "Sets the world coordinates or orientation for {name}.", gameAsset = "Sets the GTA model or prop used for {name}.", animation = "Sets the animation asset used for {name}.", access = "Defines the jobs, groups or permission level for {name}.", integration = "Selects the connected framework or provider for {name}.", path = "Sets the storage or resource path used for {name}.", color = "Sets the interface color used for {name}.", displayText = "Sets the text shown to players for {name}.", phoneNumber = "Sets the phone or service number used for {name}.", routing = "Controls how incoming requests are routed for {name}.", command = "Sets the chat command used to open or run {name}.", locale = "Selects the language used for {name}.", debug = "Controls detailed diagnostic output for {name}.", mediaQuality = "Sets the media quality or volume used for {name}.", amount = "Sets the maximum or displayed amount for {name}." }, table = { list = "List", table = "Key table", vector = "Vector", entry = "Entry", general = "General", addRow = "Add row", addField = "Add field", remove = "Remove", emptyList = "No rows yet. Add the first row with plus.", emptyTable = "No fields yet. Add the first key below.", keyPlaceholder = "New key", convertToList = "Use list", convertToMap = "Use typed key table", convertToTable = "Use key table", types = { string = "Text", number = "Number", boolean = "Switch", list = "List", table = "Table" } } },
|
||||
players = { eyebrow = "Active sessions", title = "Online players", online = "Online now", empty = "No players found", emptyBody = "Adjust the search or refresh the live player list." },
|
||||
search = { players = "Search name, ID, job, or number", apps = "Search apps", clear = "Clear search" },
|
||||
detail = { character = "Character profile", data = "Player data overview", cash = "Cash", bank = "Bank", job = "Job", duty = "Duty", onDuty = "On duty", offDuty = "Off duty", identity = "Identity", playerData = "Player data", identifier = "Character identifier", birthdate = "Birthdate", grade = "Job grade", unknown = "Unknown" },
|
||||
|
||||
@@ -431,15 +431,77 @@ local function restore_redacted_values(value, current, path)
|
||||
return restored
|
||||
end
|
||||
|
||||
local function empty_structure_kind(scope, path)
|
||||
local function empty_structure(scope, path)
|
||||
if scope ~= "config" then
|
||||
return nil
|
||||
end
|
||||
if path == "Garage.VehicleImages.ModelNames" then
|
||||
return "map"
|
||||
return {
|
||||
entries = {},
|
||||
keyType = "number",
|
||||
kind = "map",
|
||||
template = { kind = "value", valueType = "string" },
|
||||
}
|
||||
end
|
||||
if path == "FlipTok.MusicTracks" or path:match("^Companies%.Definitions%.[^.]+%.Services$") then
|
||||
return "list"
|
||||
if path == "CrewLink.ExternalPingResources" or path == "CustomApps.TrustedAdapters" then
|
||||
return {
|
||||
fields = {},
|
||||
kind = "table",
|
||||
mutableKeys = true,
|
||||
template = { kind = "value", valueType = "boolean" },
|
||||
}
|
||||
end
|
||||
if path == "FlipTok.MusicTracks" then
|
||||
return {
|
||||
items = {},
|
||||
kind = "list",
|
||||
template = {
|
||||
fields = {
|
||||
Artist = { kind = "value", valueType = "string" },
|
||||
Id = { kind = "value", valueType = "string" },
|
||||
Title = { kind = "value", valueType = "string" },
|
||||
Url = { kind = "value", valueType = "string" },
|
||||
},
|
||||
kind = "table",
|
||||
},
|
||||
}
|
||||
end
|
||||
if path == "Music.Tracks" then
|
||||
return {
|
||||
items = {},
|
||||
kind = "list",
|
||||
template = {
|
||||
fields = {
|
||||
Artist = { kind = "value", valueType = "string" },
|
||||
Id = { kind = "value", valueType = "string" },
|
||||
Title = { kind = "value", valueType = "string" },
|
||||
},
|
||||
kind = "table",
|
||||
},
|
||||
}
|
||||
end
|
||||
if path == "Payphones.CustomLocations" then
|
||||
return {
|
||||
items = {},
|
||||
kind = "list",
|
||||
template = { kind = "vector", vectorType = "vector4" },
|
||||
}
|
||||
end
|
||||
if path:match("^Companies%.Definitions%.[^.]+%.Services$") then
|
||||
return {
|
||||
items = {},
|
||||
kind = "list",
|
||||
template = {
|
||||
fields = {
|
||||
Description = { kind = "value", valueType = "string" },
|
||||
Id = { kind = "value", valueType = "string" },
|
||||
Price = { kind = "value", valueType = "string" },
|
||||
RequestsEnabled = { kind = "value", valueType = "boolean" },
|
||||
Title = { kind = "value", valueType = "string" },
|
||||
},
|
||||
kind = "table",
|
||||
},
|
||||
}
|
||||
end
|
||||
return nil
|
||||
end
|
||||
@@ -483,30 +545,29 @@ local function build_structure(value, scope, path)
|
||||
end
|
||||
if value.__skyType == "map" then
|
||||
local entries = {}
|
||||
local key_type
|
||||
for index, entry in ipairs(value.entries or {}) do
|
||||
entries[index] = {
|
||||
key = entry.key,
|
||||
keyType = entry.keyType,
|
||||
structure = build_structure(entry.value, scope, path .. "." .. tostring(entry.key)),
|
||||
}
|
||||
if index == 1 then
|
||||
key_type = entry.keyType
|
||||
elseif key_type ~= entry.keyType then
|
||||
key_type = nil
|
||||
end
|
||||
end
|
||||
return {
|
||||
entries = entries,
|
||||
keyType = key_type,
|
||||
kind = "map",
|
||||
template = entries[1] and entries[1].structure or nil,
|
||||
}
|
||||
end
|
||||
local empty_kind = next(value) == nil and empty_structure_kind(scope, path) or nil
|
||||
if empty_kind == "map" then
|
||||
return {
|
||||
entries = {},
|
||||
kind = "map",
|
||||
}
|
||||
end
|
||||
if empty_kind == "list" then
|
||||
return {
|
||||
items = {},
|
||||
kind = "list",
|
||||
}
|
||||
local configured_empty_structure = next(value) == nil and empty_structure(scope, path) or nil
|
||||
if configured_empty_structure then
|
||||
return configured_empty_structure
|
||||
end
|
||||
if is_sequence(value) then
|
||||
local items = {}
|
||||
@@ -516,6 +577,7 @@ local function build_structure(value, scope, path)
|
||||
return {
|
||||
items = items,
|
||||
kind = "list",
|
||||
template = items[1],
|
||||
}
|
||||
end
|
||||
|
||||
@@ -794,11 +856,19 @@ local function validate_locked_structure(structure, value)
|
||||
if type(value) ~= "table" or value.__skyType or (next(value) and not is_sequence(value)) then
|
||||
return false
|
||||
end
|
||||
for index, item_structure in ipairs(structure.items or {}) do
|
||||
local fixed_items = structure.items or {}
|
||||
for index, item_structure in ipairs(fixed_items) do
|
||||
if value[index] == nil or not validate_locked_structure(item_structure, value[index]) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
if structure.template then
|
||||
for index = #fixed_items + 1, #value do
|
||||
if not validate_locked_structure(structure.template, value[index]) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
if structure.kind == "table" then
|
||||
@@ -806,6 +876,11 @@ local function validate_locked_structure(structure, value)
|
||||
return false
|
||||
end
|
||||
if not structure.mutableKeys then
|
||||
for key in pairs(value) do
|
||||
if not structure.fields[key] then
|
||||
return false
|
||||
end
|
||||
end
|
||||
for key, field_structure in pairs(structure.fields or {}) do
|
||||
if value[key] == nil or not validate_locked_structure(field_structure, value[key]) then
|
||||
return false
|
||||
@@ -842,6 +917,21 @@ local function validate_locked_structure(structure, value)
|
||||
return false
|
||||
end
|
||||
end
|
||||
if structure.template or structure.keyType then
|
||||
local fixed_entries = {}
|
||||
for _, entry in ipairs(structure.entries or {}) do
|
||||
fixed_entries[entry.keyType .. ":" .. tostring(entry.key)] = true
|
||||
end
|
||||
for _, entry in ipairs(value.entries or {}) do
|
||||
local identity = entry.keyType .. ":" .. tostring(entry.key)
|
||||
local invalid_key_type = structure.keyType and entry.keyType ~= structure.keyType
|
||||
local invalid_value = structure.template
|
||||
and not validate_locked_structure(structure.template, entry.value)
|
||||
if not fixed_entries[identity] and (invalid_key_type or invalid_value) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user