FIX - make company jobs freely configurable

This commit is contained in:
Leon.Schmidt
2026-08-21 02:17:28 +02:00
parent d8a7721917
commit 42f5e8f493
7 changed files with 278 additions and 44 deletions
+5 -3
View File
@@ -232,7 +232,8 @@ Every `Config.*` value from `config.lua`, including server-only sections, and ev
file-owned because it decides whether SQL configuration is loaded. Lists, nested objects, vectors,
and numeric-keyed Lua tables use structured editors instead of raw JSON. Shipped schema rows stay
editable but cannot be renamed, converted, or removed. Every list and table still accepts any number
of additional rows; administrator-added rows remain removable.
of additional rows; administrator-added rows remain removable. Company job keys are intentionally
fully removable because `Config.Companies.Definitions` is a freely managed job collection.
Media API keys and server peppers are never returned in plaintext to the NUI. Existing secrets are
shown only as configured and are replaced only when an administrator enters a new value.
@@ -562,8 +563,9 @@ Select `rtx`, `quasar`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_prop
Company jobs, public profiles, service numbers, services, permissions, locations, and default
availability are configured under `Config.Companies.Definitions`. Definitions are not limited to the
shipped jobs: add any number of company IDs in the in-game configurator, choose `Table`, and fill the
freely configurable `Job` value in the generated full company template.
shipped jobs: add any number of company IDs in the in-game configurator and fill the freely
configurable `Job` value in the automatically generated full company template. Existing job keys can
also be removed; the remaining Companies settings stay available as normal individual fields.
### Weazel News
@@ -107,6 +107,16 @@ const tableStructure = computed(() =>
const mapStructure = computed(() =>
props.structure?.kind === 'map' ? props.structure : null,
)
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 tableEntries = computed(() =>
Object.entries(tableValue.value).filter(
([key]) => key !== '__skyType' && (!mapType.value || key !== 'entries'),
@@ -163,6 +173,40 @@ function blankCollectionValue(kind: ValueKind, siblings: unknown[]): unknown {
return template === undefined ? blankValue(kind) : blankLike(template)
}
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
@@ -217,12 +261,30 @@ function updateTableField(key: string, value: unknown): void {
emit('update:modelValue', { ...tableValue.value, [key]: value })
}
function removeTableField(key: string): void {
if (
tableStructure.value &&
Object.prototype.hasOwnProperty.call(tableStructure.value.fields, key)
function tableFieldStructure(
key: string,
): AdminConfiguratorStructure | undefined {
if (!tableStructure.value) return undefined
return (
tableStructure.value.fields[key] ??
(tableStructure.value.mutableKeys
? tableStructure.value.template
: undefined)
)
return
}
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)
@@ -230,18 +292,18 @@ function removeTableField(key: string): void {
function addTableField(): void {
const key = newObjectKey.value.trim()
if (
!key ||
key === '__skyType' ||
Object.prototype.hasOwnProperty.call(tableValue.value, key)
)
return
if (!canAddTableField.value) return
const template = tableStructure.value?.mutableKeys
? tableStructure.value.template
: undefined
emit('update:modelValue', {
...tableValue.value,
[key]: blankCollectionValue(
newObjectKind.value,
Object.values(tableValue.value).filter((_, index) => index < 50),
),
[key]: template
? blankFromStructure(template)
: blankCollectionValue(
newObjectKind.value,
Object.values(tableValue.value).filter((_, index) => index < 50),
),
})
newObjectKey.value = ''
}
@@ -484,7 +546,7 @@ function mapEntryStructure(
<strong>{{ key }}</strong>
<AdminConfigValueEditor
:model-value="value"
:structure="tableStructure?.fields[key]"
:structure="tableFieldStructure(key)"
:aria-label="`${ariaLabel} ${key}`"
:labels="labels"
:disabled="disabled"
@@ -492,13 +554,7 @@ function mapEntryStructure(
@update:model-value="updateTableField(key, $event)"
/>
<button
v-if="
!vectorType &&
!Object.prototype.hasOwnProperty.call(
tableStructure?.fields ?? {},
key,
)
"
v-if="!vectorType && !isFixedTableField(key)"
type="button"
class="config-structured-editor__remove"
:disabled="disabled"
@@ -544,6 +600,7 @@ function mapEntryStructure(
<form
v-else-if="!vectorType"
class="config-structured-editor__add-field"
:class="{ 'is-mutable-table': tableStructure?.mutableKeys }"
@submit.prevent="addTableField"
>
<input
@@ -553,14 +610,18 @@ function mapEntryStructure(
:placeholder="labels.keyPlaceholder"
:aria-label="labels.keyPlaceholder"
/>
<select v-model="newObjectKind" :disabled="disabled">
<select
v-if="!tableStructure?.mutableKeys"
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>
<button type="submit" :disabled="disabled || !newObjectKey.trim()">
<button type="submit" :disabled="disabled || !canAddTableField">
<Plus :size="13" />{{ labels.addField }}
</button>
</form>
@@ -797,6 +858,10 @@ function mapEntryStructure(
grid-template-columns: 76px minmax(90px, 1fr) 82px auto;
}
.config-structured-editor__add-field.is-mutable-table {
grid-template-columns: minmax(100px, 1fr) auto;
}
.config-structured-editor__add-field input {
min-width: 0;
height: 25px;
@@ -262,6 +262,9 @@ describe('standalone admin panel contracts', () => {
expect(configuratorServer).toContain('validate_structured_value')
expect(configuratorServer).toContain('validate_locked_structure')
expect(configuratorServer).toContain('build_structure(default_value')
expect(configuratorServer).toContain('path == "Companies.Definitions"')
expect(configuratorServer).toContain('flatten_company_fields')
expect(configuratorServer).toContain('structure.mutableKeys')
expect(configuratorServer).toContain('field.type == "stringOrFalse"')
expect(configuratorServer).not.toContain('Config.Media = client_payload')
expect(configuratorClient).toContain(
@@ -280,8 +283,10 @@ describe('standalone admin panel contracts', () => {
expect(configuratorValueEditor).toContain('function updateMapKey(')
expect(configuratorValueEditor).toContain('mapEntryStructure(current)')
expect(configuratorValueEditor).toContain('listStructure?.items[index]')
expect(configuratorValueEditor).toContain('tableStructure?.fields[key]')
expect(configuratorValueEditor).toContain('function tableFieldStructure(')
expect(configuratorValueEditor).toContain('function isFixedTableField(')
expect(configuratorValueEditor).toContain('function blankCollectionValue(')
expect(configuratorValueEditor).toContain('function blankFromStructure(')
expect(configuratorValueEditor).not.toContain('<textarea')
})
})
+2
View File
@@ -131,6 +131,8 @@ export type AdminConfiguratorStructure =
| {
fields: Record<string, AdminConfiguratorStructure>
kind: 'table'
mutableKeys?: boolean
template?: AdminConfiguratorStructure
}
| {
entries: Array<{
+58 -2
View File
@@ -404,6 +404,27 @@ function buildStructure(value, scope, path) {
if (scope === 'config' && path === 'Phone.Keybind') {
return { kind: 'optionalString' }
}
if (
scope === 'config' &&
path === 'Companies.Definitions' &&
value !== null &&
typeof value === 'object' &&
!Array.isArray(value)
) {
const keys = Object.keys(value).sort()
const fields = Object.fromEntries(
keys.map((key) => [
key,
buildStructure(value[key], scope, `${path}.${key}`),
]),
)
return {
fields,
kind: 'table',
mutableKeys: true,
template: keys[0] ? fields[keys[0]] : undefined,
}
}
if (
scope === 'config' &&
path === 'Garage.VehicleImages.ModelNames' &&
@@ -452,7 +473,10 @@ function addField(fields, scope, path, value) {
const sensitive = typeof value === 'string' && sensitivePath(path)
fields.push({
configured: sensitive ? value !== '' : undefined,
label: humanize(path.split('.').at(-1)),
label:
scope === 'config' && path === 'Companies.Definitions'
? 'Jobs'
: humanize(path.split('.').at(-1)),
path,
scope,
sensitive,
@@ -470,12 +494,44 @@ function addField(fields, scope, path, value) {
})
}
function flattenCompanyFields(fields, path, value) {
if (
path === 'Companies.Definitions' ||
value === null ||
typeof value !== 'object' ||
Array.isArray(value) ||
value.__skyType ||
Object.keys(value).length === 0
) {
addField(fields, 'config', path, value)
return
}
for (const key of Object.keys(value).sort()) {
flattenCompanyFields(fields, `${path}.${key}`, value[key])
}
}
function buildSections(scope, payload) {
const sections = []
const generalFields = []
for (const key of Object.keys(payload).sort()) {
const value = payload[key]
if (
if (scope === 'config' && key === 'Companies') {
const fields = []
flattenCompanyFields(fields, 'Companies', value)
fields.sort((left, right) => {
if (left.path === 'Companies.Definitions') return 1
if (right.path === 'Companies.Definitions') return -1
return left.path.localeCompare(right.path)
})
sections.push({
fields,
id: 'config:Companies',
label: humanize(key),
scope,
})
} else if (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
@@ -4,6 +4,7 @@ import { createRequire } from 'node:module'
import { describe, expect, it } from 'vitest'
type ConfiguratorField = {
label: string
path: string
structure?: ConfiguratorStructure
type: string
@@ -15,6 +16,8 @@ type ConfiguratorStructure = {
fields?: Record<string, ConfiguratorStructure>
items?: ConfiguratorStructure[]
kind: string
mutableKeys?: boolean
template?: ConfiguratorStructure
}
type ConfiguratorSection = {
@@ -94,7 +97,9 @@ describe('admin configurator fixture', () => {
)
const darkChat = fields.find((field) => field.path === 'DarkChat')
const phone = fields.find((field) => field.path === 'Phone')
const companies = fields.find((field) => field.path === 'Companies')
const companyJobs = fields.find(
(field) => field.path === 'Companies.Definitions',
)
const garage = fields.find((field) => field.path === 'Garage')
const timers = (darkChat?.value as Record<string, unknown> | undefined)
?.AllowedDisappearTimers
@@ -109,7 +114,16 @@ describe('admin configurator fixture', () => {
})
expect(darkChat?.structure?.fields?.AllowedDisappearTimers.kind).toBe('map')
expect(phone?.structure?.fields?.Keybind.kind).toBe('optionalString')
expect(companies?.structure?.fields?.Definitions.kind).toBe('table')
expect(companyJobs?.label).toBe('Jobs')
expect(companyJobs?.structure).toMatchObject({
fields: {
ambulance: { kind: 'table' },
police: { kind: 'table' },
},
kind: 'table',
mutableKeys: true,
template: { kind: 'table' },
})
expect(
garage?.structure?.fields?.VehicleImages.fields?.ModelNames.kind,
).toBe('map')
+101 -11
View File
@@ -235,10 +235,14 @@ local function upgrade_legacy_map(defaults, saved)
return { __skyType = "map", entries = entries }
end
local function merge_values(defaults, saved)
local function merge_values(defaults, saved, path)
path = path or ""
if type(defaults) ~= "table" or type(saved) ~= "table" then
return copy_value(saved)
end
if path == "Companies.Definitions" then
return copy_value(saved)
end
if defaults.__skyType == "map" and not saved.__skyType then
saved = upgrade_legacy_map(defaults, saved)
end
@@ -256,7 +260,9 @@ local function merge_values(defaults, saved)
entries[#entries + 1] = {
key = entry.key,
keyType = entry.keyType,
value = saved_entry and merge_values(entry.value, saved_entry.value) or copy_value(entry.value),
value = saved_entry
and merge_values(entry.value, saved_entry.value, path .. "." .. tostring(entry.key))
or copy_value(entry.value),
}
included[identity] = true
end
@@ -274,7 +280,9 @@ local function merge_values(defaults, saved)
if is_sequence(defaults) then
local merged = copy_value(saved)
for index, child in ipairs(defaults) do
merged[index] = saved[index] ~= nil and merge_values(child, saved[index]) or copy_value(child)
merged[index] = saved[index] ~= nil
and merge_values(child, saved[index], path .. "." .. tostring(index))
or copy_value(child)
end
return merged
end
@@ -285,7 +293,8 @@ local function merge_values(defaults, saved)
local merged = copy_value(defaults)
for key, child in pairs(saved) do
if merged[key] ~= nil then
merged[key] = merge_values(merged[key], child)
local child_path = path == "" and tostring(key) or (path .. "." .. tostring(key))
merged[key] = merge_values(merged[key], child, child_path)
else
merged[key] = copy_value(child)
end
@@ -440,6 +449,25 @@ local function build_structure(value, scope, path)
if scope == "config" and path == "Phone.Keybind" then
return { kind = "optionalString" }
end
if scope == "config" and path == "Companies.Definitions" and value_type == "table" then
local keys = {}
for key in pairs(value) do
keys[#keys + 1] = key
end
table.sort(keys, function(left, right)
return tostring(left) < tostring(right)
end)
local fields = {}
for _, key in ipairs(keys) do
fields[key] = build_structure(value[key], scope, path .. "." .. tostring(key))
end
return {
fields = fields,
kind = "table",
mutableKeys = true,
template = keys[1] and fields[keys[1]] or nil,
}
end
if value_type ~= "table" then
return {
kind = "value",
@@ -516,7 +544,9 @@ local function add_field(fields, field_index, scope, path, value, default_value)
local sensitive = type(value) == "string" and sensitive_path(path)
local field = {
configured = sensitive and value ~= "" or nil,
label = humanize(path:match("([^.]+)$") or path),
label = scope == "config" and path == "Companies.Definitions"
and "Jobs"
or humanize(path:match("([^.]+)$") or path),
path = path,
scope = scope,
sensitive = sensitive,
@@ -528,6 +558,35 @@ local function add_field(fields, field_index, scope, path, value, default_value)
field_index[scope .. ":" .. path] = field
end
local function flatten_company_fields(fields, field_index, path, value, default_value)
if path == "Companies.Definitions"
or type(value) ~= "table"
or value.__skyType
or is_sequence(value)
or next(value) == nil
then
add_field(fields, field_index, "config", path, value, default_value)
return
end
local keys = {}
for key in pairs(value) do
keys[#keys + 1] = key
end
table.sort(keys, function(left, right)
return tostring(left) < tostring(right)
end)
for _, key in ipairs(keys) do
flatten_company_fields(
fields,
field_index,
path .. "." .. tostring(key),
value[key],
type(default_value) == "table" and default_value[key] or nil
)
end
end
local function build_sections(scope, payload, defaults, sections, field_index)
local general_fields = {}
local keys = {}
@@ -540,7 +599,25 @@ local function build_sections(scope, payload, defaults, sections, field_index)
for _, key in ipairs(keys) do
local value = payload[key]
if type(value) == "table" and not value.__skyType and not is_sequence(value) and next(value) ~= nil then
if scope == "config" and key == "Companies" then
local fields = {}
flatten_company_fields(fields, field_index, "Companies", value, defaults[key])
table.sort(fields, function(left, right)
if left.path == "Companies.Definitions" then
return false
end
if right.path == "Companies.Definitions" then
return true
end
return left.path < right.path
end)
sections[#sections + 1] = {
fields = fields,
id = "config:Companies",
label = humanize(key),
scope = scope,
}
elseif type(value) == "table" and not value.__skyType and not is_sequence(value) and next(value) ~= nil then
local fields = {}
add_field(fields, field_index, scope, tostring(key), value, defaults[key])
sections[#sections + 1] = {
@@ -728,9 +805,22 @@ local function validate_locked_structure(structure, value)
if type(value) ~= "table" or value.__skyType or is_sequence(value) then
return false
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
if not structure.mutableKeys then
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
end
end
end
if structure.mutableKeys and structure.template then
for key, child in pairs(value) do
if type(key) ~= "string"
or #key > 64
or not key:match("^[a-z0-9_-]+$")
or not validate_locked_structure(structure.template, child)
then
return false
end
end
end
return true
@@ -832,8 +922,8 @@ if not row then
error("[sky_phone] Phone configurator could not load its SQL row after initialization.")
end
stored_config = merge_values(default_config, decode_payload(row.config_payload, "config"))
stored_media = merge_values(default_media, decode_payload(row.media_payload, "media"))
stored_config = merge_values(default_config, decode_payload(row.config_payload, "config"), "")
stored_media = merge_values(default_media, decode_payload(row.media_payload, "media"), "")
revision = tonumber(row.revision) or 1
updated_at = row.updated_at
updated_by_name = row.updated_by_name