feat(config): redo config tab — lazy list, full-coverage search, per-field reset (#898)
- LazyColumn + precomputed flat dot-path value map: O(1) lookups, only
visible fields compose (829 live leaves on a real config)
- Search now matches keys, labels, descriptions, categories, CURRENT
values and select options; non-schema ('Other') paths are searchable too.
Result count + category chip per hit while searching
- Per-field reset-to-default icon; clear-to-blank honoring schema
'clearable'; searchable dropdown honoring schema 'searchable'
(select-all-free fresh filter, exact-match commits on blur)
- Number/object/list editors keep local text state: intermediate typing
('0.') no longer drops keystrokes; objects/lists edit as multi-line JSON
with validation
- Honor backend searchable/clearable schema flags (SchemaField model)
- Category reset now previews default values immediately
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
90c1e8dc5f
commit
9e454ce67b
5 files changed
+770
-215
No files matched your search
@@ -26,6 +26,8 @@ data class SchemaField(
|
||||
val description: String? = null,
|
||||
val category: String? = null,
|
||||
val options: List<String>? = null,
|
||||
val searchable: Boolean = false,
|
||||
val clearable: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.m57.hermescontrol.ui.config
|
||||
|
||||
import com.m57.hermescontrol.data.model.SchemaField
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* One renderable row of the config form: a schema-driven field or an
|
||||
* uncovered ("Other") config path. All rows are FLAT dot-paths — the nested
|
||||
* config response is flattened once on load so the form does O(1) lookups.
|
||||
*/
|
||||
data class ConfigRow(
|
||||
val key: String,
|
||||
val field: SchemaField?,
|
||||
val value: JsonElement?,
|
||||
) {
|
||||
/** Human label: last path segment, underscores → spaces. */
|
||||
val label: String =
|
||||
key.split(".").last().replace("_", " ").replaceFirstChar { it.uppercase() }
|
||||
|
||||
/** Search/display text of the current value. */
|
||||
val valueText: String = value?.let(::jsonText) ?: ""
|
||||
|
||||
val category: String = if (field == null) "Other" else field.category ?: "general"
|
||||
|
||||
val isUncovered: Boolean = field == null
|
||||
}
|
||||
|
||||
/** Flatten a nested config map into dot-path → leaf-value pairs. */
|
||||
fun flattenConfig(nested: Map<String, JsonElement>): Map<String, JsonElement> {
|
||||
val flat = mutableMapOf<String, JsonElement>()
|
||||
|
||||
fun walk(
|
||||
prefix: String,
|
||||
map: Map<String, JsonElement>,
|
||||
) {
|
||||
for ((key, value) in map) {
|
||||
val dotPath = if (prefix.isEmpty()) key else "$prefix.$key"
|
||||
if (value is JsonObject) {
|
||||
walk(dotPath, value)
|
||||
} else {
|
||||
flat[dotPath] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
walk("", nested)
|
||||
return flat
|
||||
}
|
||||
|
||||
/** True when [dotPath] is a schema key or lives under one (ancestor-or-self). */
|
||||
fun isCoveredBySchema(
|
||||
dotPath: String,
|
||||
schemaKeys: Set<String>,
|
||||
): Boolean = schemaKeys.any { dotPath == it || dotPath.startsWith("$it.") }
|
||||
|
||||
/** Flat dot-paths present in config but not covered by the schema (sorted). */
|
||||
fun collectUncoveredPaths(
|
||||
flatValues: Map<String, JsonElement>,
|
||||
schemaKeys: Set<String>,
|
||||
): List<String> =
|
||||
flatValues.keys
|
||||
.filterNot { isCoveredBySchema(it, schemaKeys) }
|
||||
.sorted()
|
||||
|
||||
/**
|
||||
* Match a row against a search query. Covers the key, human label, schema
|
||||
* description, category, the CURRENT VALUE, and every select option — so
|
||||
* searching "gpt-4o", "daytona" or "kittentts" finds the right field even
|
||||
* though none of them appear in a key or description.
|
||||
*/
|
||||
fun rowMatchesQuery(
|
||||
row: ConfigRow,
|
||||
query: String,
|
||||
): Boolean {
|
||||
if (query.isBlank()) return true
|
||||
val q = query.lowercase()
|
||||
if (row.key.lowercase().contains(q)) return true
|
||||
if (row.label.lowercase().contains(q)) return true
|
||||
row.field?.let { field ->
|
||||
if (field.description?.lowercase()?.contains(q) == true) return true
|
||||
if (field.category?.lowercase()?.contains(q) == true) return true
|
||||
field.options?.let { options ->
|
||||
if (options.any { it.lowercase().contains(q) }) return true
|
||||
}
|
||||
}
|
||||
return row.valueText.lowercase().contains(q)
|
||||
}
|
||||
|
||||
/** Stable text form of a JSON value for display and search. */
|
||||
fun jsonText(value: JsonElement): String =
|
||||
when (value) {
|
||||
is JsonPrimitive -> value.content
|
||||
is JsonArray -> value.toString()
|
||||
is JsonObject -> value.toString()
|
||||
}
|
||||
@@ -1,19 +1,25 @@
|
||||
package com.m57.hermescontrol.ui.config
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
@@ -23,13 +29,18 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SecondaryScrollableTabRow
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
@@ -37,10 +48,12 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -50,7 +63,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.SchemaField
|
||||
import com.m57.hermescontrol.data.model.ConfigSchemaResponse
|
||||
import com.m57.hermescontrol.ui.common.ErrorState
|
||||
import com.m57.hermescontrol.ui.common.ExposedDropdownField
|
||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||
@@ -58,9 +71,8 @@ import com.m57.hermescontrol.ui.common.NavIcon
|
||||
import com.m57.hermescontrol.ui.common.SearchBar
|
||||
import com.m57.hermescontrol.ui.common.SkeletonListState
|
||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
|
||||
@@ -96,11 +108,11 @@ fun ConfigScreen(
|
||||
},
|
||||
) { paddingValues ->
|
||||
when {
|
||||
state.isLoading && state.config == null -> {
|
||||
state.isLoading && state.values == null -> {
|
||||
SkeletonListState(modifier = Modifier.padding(paddingValues))
|
||||
}
|
||||
|
||||
state.errorMessage != null && state.config == null -> {
|
||||
state.errorMessage != null && state.values == null -> {
|
||||
ErrorState(
|
||||
message = state.errorMessage ?: "",
|
||||
onRetry = { viewModel.loadAll() },
|
||||
@@ -115,6 +127,8 @@ fun ConfigScreen(
|
||||
onSearchQueryChange = viewModel::setSearchQuery,
|
||||
onCategoryChange = viewModel::setActiveCategory,
|
||||
onFieldChange = viewModel::updateField,
|
||||
onResetField = viewModel::resetField,
|
||||
onClearField = viewModel::clearField,
|
||||
onSave = viewModel::saveConfig,
|
||||
onYamlTextChange = viewModel::setYamlText,
|
||||
onYamlSave = viewModel::saveYamlConfig,
|
||||
@@ -133,6 +147,8 @@ private fun ConfigContent(
|
||||
onSearchQueryChange: (String) -> Unit,
|
||||
onCategoryChange: (String) -> Unit,
|
||||
onFieldChange: (String, JsonElement) -> Unit,
|
||||
onResetField: (String) -> Unit,
|
||||
onClearField: (String) -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onYamlTextChange: (String) -> Unit,
|
||||
onYamlSave: () -> Unit,
|
||||
@@ -232,13 +248,6 @@ private fun ConfigContent(
|
||||
}
|
||||
|
||||
// ── Scrollable content ──
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
if (state.yamlMode) {
|
||||
YAMLEditor(
|
||||
yamlText = state.yamlText ?: "",
|
||||
@@ -249,22 +258,22 @@ private fun ConfigContent(
|
||||
)
|
||||
} else {
|
||||
FormEditor(
|
||||
values = state.values,
|
||||
schema = state.schema,
|
||||
config = state.config,
|
||||
defaults = state.defaults,
|
||||
uncoveredPaths = state.uncoveredPaths,
|
||||
activeCategory = state.activeCategory,
|
||||
searchQuery = state.searchQuery,
|
||||
modifiedKeys = state.modifiedKeys,
|
||||
isSaving = state.isSaving,
|
||||
onCategoryChange = onCategoryChange,
|
||||
onFieldChange = onFieldChange,
|
||||
onResetField = onResetField,
|
||||
onClearField = onClearField,
|
||||
onSave = onSave,
|
||||
onResetCategory = onResetCategory,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,13 +290,19 @@ private fun YAMLEditor(
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = yamlText,
|
||||
onValueChange = onYamlTextChange,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(400.dp),
|
||||
.weight(1f),
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
maxLines = Int.MAX_VALUE,
|
||||
)
|
||||
@@ -315,23 +330,29 @@ private fun YAMLEditor(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FormEditor(
|
||||
schema: com.m57.hermescontrol.data.model.ConfigSchemaResponse?,
|
||||
config: Map<String, JsonElement>?,
|
||||
values: Map<String, JsonElement>?,
|
||||
schema: ConfigSchemaResponse?,
|
||||
defaults: Map<String, JsonElement>?,
|
||||
uncoveredPaths: List<String>,
|
||||
activeCategory: String,
|
||||
searchQuery: String,
|
||||
modifiedKeys: Set<String>,
|
||||
isSaving: Boolean,
|
||||
onCategoryChange: (String) -> Unit,
|
||||
onFieldChange: (String, JsonElement) -> Unit,
|
||||
onResetField: (String) -> Unit,
|
||||
onClearField: (String) -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onResetCategory: (String) -> Unit,
|
||||
) {
|
||||
if (schema == null || config == null) return
|
||||
if (schema == null || values == null) return
|
||||
|
||||
val isSearching = searchQuery.isNotBlank()
|
||||
val categoryCounts =
|
||||
@@ -341,37 +362,73 @@ private fun FormEditor(
|
||||
.eachCount()
|
||||
}
|
||||
|
||||
// Non-schema config values — all dot-paths in the config that aren't in the schema
|
||||
val nonSchemaPaths =
|
||||
remember(config, schema) {
|
||||
collectUncoveredPaths(config, schema.fields.keys.toSet())
|
||||
// Every row is a flat dot-path with an O(1) value lookup — no nested walks.
|
||||
val rows: List<ConfigRow> =
|
||||
remember(values, schema, uncoveredPaths) {
|
||||
val covered =
|
||||
schema.fields.map { (key, field) ->
|
||||
ConfigRow(key = key, field = field, value = values[key])
|
||||
}
|
||||
val uncovered =
|
||||
uncoveredPaths.map { dotPath ->
|
||||
ConfigRow(key = dotPath, field = null, value = values[dotPath])
|
||||
}
|
||||
covered + uncovered
|
||||
}
|
||||
|
||||
val isOtherCategory = activeCategory == "Other"
|
||||
|
||||
// Show tabs only when not searching
|
||||
if (!isSearching) {
|
||||
val allCategories =
|
||||
if (nonSchemaPaths.isNotEmpty()) {
|
||||
schema.category_order.filter { it in categoryCounts } + "Other"
|
||||
val visibleRows: List<ConfigRow> =
|
||||
remember(rows, activeCategory, searchQuery) {
|
||||
if (isSearching) {
|
||||
rows.filter { rowMatchesQuery(it, searchQuery) }
|
||||
} else {
|
||||
schema.category_order.filter { it in categoryCounts }
|
||||
rows.filter { row ->
|
||||
if (activeCategory == "Other") {
|
||||
row.isUncovered
|
||||
} else {
|
||||
!row.isUncovered && row.category == activeCategory
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val allCategories =
|
||||
remember(schema, uncoveredPaths, categoryCounts) {
|
||||
val ordered =
|
||||
schema.category_order.filter { it in categoryCounts } +
|
||||
uncoveredPaths.takeIf { it.isNotEmpty() }?.let { listOf("Other") }.orEmpty()
|
||||
ordered.ifEmpty { categoryCounts.keys.sorted() }
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp),
|
||||
) {
|
||||
// Category tabs — hidden while searching
|
||||
if (!isSearching) {
|
||||
item(key = "tabs") {
|
||||
ConfigTabs(
|
||||
categories = allCategories,
|
||||
categoryCounts = categoryCounts,
|
||||
selectedCategory = activeCategory,
|
||||
onCategorySelected = onCategoryChange,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
} else {
|
||||
item(key = "search-meta") {
|
||||
val count = visibleRows.size
|
||||
Text(
|
||||
text = if (count == 1) "1 result" else "$count results",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Fields list
|
||||
if (isOtherCategory) {
|
||||
// Render non-schema config dot-paths as read-only cards
|
||||
nonSchemaPaths.forEach { dotPath ->
|
||||
val jsonText = getJsonValue(config, dotPath)?.toString() ?: ""
|
||||
if (activeCategory == "Other" && !isSearching) {
|
||||
// Non-schema config values — read-only cards
|
||||
items(uncoveredPaths, key = { it }) { dotPath ->
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -390,7 +447,7 @@ private fun FormEditor(
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = jsonText,
|
||||
text = values[dotPath]?.let(::jsonText) ?: "",
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -398,50 +455,38 @@ private fun FormEditor(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val visibleFields: List<Pair<String, SchemaField>> =
|
||||
remember(schema, activeCategory, searchQuery) {
|
||||
if (isSearching) {
|
||||
val query = searchQuery.lowercase()
|
||||
schema.fields
|
||||
.filter { (key, field) ->
|
||||
val label = key.split(".").last().replace("_", " ")
|
||||
key.lowercase().contains(query) ||
|
||||
label.contains(query) ||
|
||||
(field.description?.lowercase()?.contains(query) == true) ||
|
||||
(field.category?.lowercase()?.contains(query) == true)
|
||||
}.entries
|
||||
.map { it.key to it.value }
|
||||
} else {
|
||||
schema.fields
|
||||
.filter { (_, field) ->
|
||||
(field.category ?: "general") == activeCategory
|
||||
}.entries
|
||||
.map { it.key to it.value }
|
||||
}
|
||||
}
|
||||
visibleFields.forEach { (key, field) ->
|
||||
ConfigField(
|
||||
key = key,
|
||||
field = field,
|
||||
currentValue = getJsonValue(config, key),
|
||||
isModified = key in modifiedKeys,
|
||||
onChange = { onFieldChange(key, it) },
|
||||
items(visibleRows, key = { it.key }) { row ->
|
||||
ConfigFieldCard(
|
||||
row = row,
|
||||
defaultValue = defaults?.get(row.key),
|
||||
isModified = row.key in modifiedKeys,
|
||||
showCategoryChip = isSearching,
|
||||
onChange = { onFieldChange(row.key, it) },
|
||||
onReset = { onResetField(row.key) },
|
||||
onClear = { onClearField(row.key) },
|
||||
)
|
||||
}
|
||||
|
||||
if (visibleFields.isEmpty()) {
|
||||
if (visibleRows.isEmpty()) {
|
||||
item(key = "empty") {
|
||||
Text(
|
||||
text = if (isSearching) "No settings match your search." else "No fields in this category.",
|
||||
text =
|
||||
if (isSearching) {
|
||||
"No settings match your search."
|
||||
} else {
|
||||
"No fields in this category."
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
// Save + Reset buttons
|
||||
item(key = "actions") {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
@@ -481,6 +526,8 @@ private fun FormEditor(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -518,22 +565,20 @@ private fun ConfigTabs(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ConfigField(
|
||||
key: String,
|
||||
field: SchemaField,
|
||||
currentValue: JsonElement?,
|
||||
private fun ConfigFieldCard(
|
||||
row: ConfigRow,
|
||||
defaultValue: JsonElement?,
|
||||
isModified: Boolean,
|
||||
showCategoryChip: Boolean,
|
||||
onChange: (JsonElement) -> Unit,
|
||||
onReset: () -> Unit,
|
||||
onClear: () -> Unit,
|
||||
) {
|
||||
val label =
|
||||
key
|
||||
.split(".")
|
||||
.last()
|
||||
.replace("_", " ")
|
||||
.replaceFirstChar { it.uppercase() }
|
||||
val description = field.description ?: key.replace(".", " → ").replace("_", " ").replaceFirstChar { it.uppercase() }
|
||||
val field = row.field
|
||||
val description =
|
||||
field?.description
|
||||
?: row.key.replace(".", " → ").replace("_", " ").replaceFirstChar { it.uppercase() }
|
||||
|
||||
Card(
|
||||
modifier =
|
||||
@@ -551,12 +596,79 @@ private fun ConfigField(
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
// ── Header row: category chip (searching), label, actions ──
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (showCategoryChip) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
text = row.category.replaceFirstChar { it.uppercase() },
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
}
|
||||
|
||||
Text(
|
||||
text = row.label,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = if (isModified) FontWeight.Bold else FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (!field.type.isNullOrEmpty() && field.type != "string") {
|
||||
|
||||
if (isModified) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 6.dp)
|
||||
.size(8.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
) {}
|
||||
}
|
||||
}
|
||||
|
||||
if (defaultValue != null && defaultValue != row.value) {
|
||||
CompactIconButton(
|
||||
icon = Icons.Filled.Refresh,
|
||||
contentDescription = "Reset to default",
|
||||
onClick = onReset,
|
||||
)
|
||||
}
|
||||
|
||||
if (field?.clearable == true && row.valueText.isNotEmpty()) {
|
||||
CompactIconButton(
|
||||
icon = Icons.Filled.Clear,
|
||||
contentDescription = "Clear value",
|
||||
onClick = onClear,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (field == null) {
|
||||
// Uncovered path reached via search — show its value read-only
|
||||
Text(
|
||||
text = row.valueText,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
return@Column
|
||||
}
|
||||
|
||||
if (field.type.isNotEmpty() && field.type != "string") {
|
||||
Text(
|
||||
text = field.type,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
@@ -580,20 +692,30 @@ private fun ConfigField(
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Switch(
|
||||
checked = (currentValue as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
checked = (row.value as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
onCheckedChange = { onChange(JsonPrimitive(it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
"select" -> {
|
||||
ExposedDropdownField(
|
||||
label = label,
|
||||
if (field.searchable) {
|
||||
SearchableSelectField(
|
||||
key = row.key,
|
||||
label = row.label,
|
||||
options = field.options ?: emptyList(),
|
||||
selectedValue = (currentValue as? JsonPrimitive)?.content ?: "",
|
||||
selectedValue = (row.value as? JsonPrimitive)?.content ?: "",
|
||||
onOptionSelected = { onChange(JsonPrimitive(it)) },
|
||||
)
|
||||
if (description != label) {
|
||||
} else {
|
||||
ExposedDropdownField(
|
||||
label = row.label,
|
||||
options = field.options ?: emptyList(),
|
||||
selectedValue = (row.value as? JsonPrimitive)?.content ?: "",
|
||||
onOptionSelected = { onChange(JsonPrimitive(it)) },
|
||||
)
|
||||
}
|
||||
if (description != row.label) {
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -604,41 +726,27 @@ private fun ConfigField(
|
||||
}
|
||||
|
||||
"number" -> {
|
||||
OutlinedTextField(
|
||||
value =
|
||||
currentValue?.let {
|
||||
if (it is JsonPrimitive) it.content else ""
|
||||
} ?: "",
|
||||
onValueChange = { text ->
|
||||
text.toDoubleOrNull()?.let { doubleVal ->
|
||||
if (doubleVal == doubleVal.toLong().toDouble()) {
|
||||
onChange(JsonPrimitive(doubleVal.toLong()))
|
||||
} else {
|
||||
onChange(JsonPrimitive(doubleVal))
|
||||
NumberField(
|
||||
key = row.key,
|
||||
value = row.value,
|
||||
label = description,
|
||||
onChange = onChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text(description) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
|
||||
"object", "list" -> {
|
||||
JsonField(
|
||||
key = row.key,
|
||||
value = row.value,
|
||||
label = description,
|
||||
onChange = onChange,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
// String or other type
|
||||
val textValue =
|
||||
currentValue?.let {
|
||||
when (it) {
|
||||
is JsonPrimitive -> it.content
|
||||
is JsonObject -> it.toString()
|
||||
is JsonArray -> it.toString()
|
||||
}
|
||||
} ?: ""
|
||||
|
||||
// String
|
||||
OutlinedTextField(
|
||||
value = textValue,
|
||||
value = (row.value as? JsonPrimitive)?.content ?: "",
|
||||
onValueChange = { onChange(JsonPrimitive(it)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text(description) },
|
||||
@@ -651,55 +759,218 @@ private fun ConfigField(
|
||||
}
|
||||
}
|
||||
|
||||
/** Recursively find all config dot-paths not covered by the schema. */
|
||||
private fun collectUncoveredPaths(
|
||||
config: Map<String, JsonElement>,
|
||||
schemaPaths: Set<String>,
|
||||
prefix: String = "",
|
||||
): List<String> {
|
||||
val result = mutableListOf<String>()
|
||||
for ((key, value) in config) {
|
||||
val dotPath = if (prefix.isEmpty()) key else "$prefix.$key"
|
||||
// If this exact path is in the schema, it's covered — skip entirely
|
||||
if (dotPath in schemaPaths) continue
|
||||
if (value is JsonObject) {
|
||||
// Before recursing deeper, check if this whole sub-tree has any schema coverage
|
||||
val hasSchemaCoverage = schemaPaths.any { it == dotPath || it.startsWith("$dotPath.") }
|
||||
if (hasSchemaCoverage) {
|
||||
// Schema covers some sub-paths — recurse to find what's NOT covered
|
||||
result.addAll(collectUncoveredPaths(value, schemaPaths, dotPath))
|
||||
} else {
|
||||
// No schema coverage at all for this subtree — add the whole thing
|
||||
result.add(dotPath)
|
||||
}
|
||||
} else if (value is JsonArray) {
|
||||
// Arrays: check if the path is in schema; if not, add it
|
||||
if (dotPath !in schemaPaths) {
|
||||
result.add(dotPath)
|
||||
}
|
||||
} else {
|
||||
// Leaf value (string, number, boolean, null) not in schema
|
||||
result.add(dotPath)
|
||||
}
|
||||
}
|
||||
return result.sorted()
|
||||
/** Compact header action icon (reset / clear). */
|
||||
@Composable
|
||||
private fun CompactIconButton(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 4.dp)
|
||||
.size(18.dp)
|
||||
.clickable(onClick = onClick),
|
||||
)
|
||||
}
|
||||
|
||||
/** Get a nested value from a flat config Map using a dot-path key. */
|
||||
private fun getJsonValue(
|
||||
config: Map<String, JsonElement>,
|
||||
dotPath: String,
|
||||
): JsonElement? {
|
||||
val parts = dotPath.split(".")
|
||||
var current: JsonElement? = null
|
||||
for (i in parts.indices) {
|
||||
val key = parts[i]
|
||||
if (i == 0) {
|
||||
current = config[key]
|
||||
/**
|
||||
* Searchable dropdown (schema `searchable: true`, e.g. timezone's 590 options).
|
||||
* Typing filters the menu; only a picked option (or an exact-match on blur)
|
||||
* commits — closed-world, matching the desktop SearchableSelect. The first
|
||||
* keystroke after focus starts a FRESH filter (the existing value's prefix is
|
||||
* stripped) so typing "amman" over "Asia/Amman" doesn't append to it.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SearchableSelectField(
|
||||
key: String,
|
||||
label: String,
|
||||
options: List<String>,
|
||||
selectedValue: String,
|
||||
onOptionSelected: (String) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var query by remember(key) { mutableStateOf(selectedValue) }
|
||||
var hasFocus by remember { mutableStateOf(false) }
|
||||
var untouched by remember(key) { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(selectedValue) {
|
||||
if (!hasFocus) {
|
||||
query = selectedValue
|
||||
untouched = true
|
||||
}
|
||||
}
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { newText ->
|
||||
if (untouched && query == selectedValue && newText.startsWith(selectedValue)) {
|
||||
// Fresh filter over a pre-filled value: drop the old value
|
||||
query = newText.removePrefix(selectedValue)
|
||||
} else {
|
||||
current = (current as? JsonObject)?.get(key)
|
||||
query = newText
|
||||
}
|
||||
untouched = false
|
||||
expanded = true
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable)
|
||||
.onFocusChanged {
|
||||
hasFocus = it.isFocused
|
||||
if (!it.isFocused) {
|
||||
untouched = true
|
||||
if (query in options && query != selectedValue) {
|
||||
onOptionSelected(query)
|
||||
} else {
|
||||
query = selectedValue
|
||||
}
|
||||
}
|
||||
},
|
||||
label = { Text(label) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
val filtered = options.filter { it.contains(query, ignoreCase = true) }
|
||||
if (filtered.isEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = "No matches",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onClick = {},
|
||||
)
|
||||
} else {
|
||||
filtered.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option) },
|
||||
onClick = {
|
||||
query = option
|
||||
onOptionSelected(option)
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (current == null) return null
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
/**
|
||||
* Number editor with local text state. The value is committed only when the
|
||||
* text parses, and the field's text never snaps while focused — so typing
|
||||
* intermediate states like "0." or "42.0" works instead of dropping keys.
|
||||
*/
|
||||
@Composable
|
||||
private fun NumberField(
|
||||
key: String,
|
||||
value: JsonElement?,
|
||||
label: String,
|
||||
onChange: (JsonElement) -> Unit,
|
||||
) {
|
||||
var text by remember(key) { mutableStateOf((value as? JsonPrimitive)?.content ?: "") }
|
||||
var hasFocus by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(value) {
|
||||
if (!hasFocus) text = (value as? JsonPrimitive)?.content ?: ""
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { newText ->
|
||||
text = newText
|
||||
newText.toDoubleOrNull()?.let { doubleVal ->
|
||||
onChange(
|
||||
if (doubleVal == doubleVal.toLong().toDouble()) {
|
||||
JsonPrimitive(doubleVal.toLong())
|
||||
} else {
|
||||
JsonPrimitive(doubleVal)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.onFocusChanged {
|
||||
hasFocus = it.isFocused
|
||||
if (!it.isFocused) text = (value as? JsonPrimitive)?.content ?: ""
|
||||
},
|
||||
isError = text.isNotEmpty() && text.toDoubleOrNull() == null,
|
||||
label = { Text(label) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Object/list JSON editor with local text state. Commits only valid JSON;
|
||||
* invalid input shows an error but is never sent. Multi-line + monospace.
|
||||
*/
|
||||
@Composable
|
||||
private fun JsonField(
|
||||
key: String,
|
||||
value: JsonElement?,
|
||||
label: String,
|
||||
onChange: (JsonElement) -> Unit,
|
||||
) {
|
||||
var text by remember(key) { mutableStateOf(value?.let(::jsonText) ?: "") }
|
||||
var hasFocus by remember { mutableStateOf(false) }
|
||||
var invalid by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(value) {
|
||||
if (!hasFocus) text = value?.let(::jsonText) ?: ""
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { newText ->
|
||||
text = newText
|
||||
val parsed = runCatching { Json.parseToJsonElement(newText) }.getOrNull()
|
||||
invalid = newText.isNotBlank() && parsed == null
|
||||
if (parsed != null && newText.isNotBlank()) {
|
||||
onChange(parsed)
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 96.dp)
|
||||
.onFocusChanged {
|
||||
hasFocus = it.isFocused
|
||||
if (!it.isFocused) {
|
||||
invalid = false
|
||||
text = value?.let(::jsonText) ?: ""
|
||||
}
|
||||
},
|
||||
isError = invalid,
|
||||
supportingText =
|
||||
if (invalid) {
|
||||
{ Text("Invalid JSON") }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
label = { Text(label) },
|
||||
maxLines = Int.MAX_VALUE,
|
||||
)
|
||||
}
|
||||
@@ -20,13 +20,17 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
data class ConfigUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val isSaving: Boolean = false,
|
||||
val config: Map<String, JsonElement>? = null,
|
||||
/** Flattened dot-path → value map (see [flattenConfig]). */
|
||||
val values: Map<String, JsonElement>? = null,
|
||||
val schema: ConfigSchemaResponse? = null,
|
||||
val defaults: Map<String, JsonElement>? = null,
|
||||
/** Config paths present but not covered by the schema (the "Other" tab). */
|
||||
val uncoveredPaths: List<String> = emptyList(),
|
||||
val path: String? = null,
|
||||
val yamlText: String? = null,
|
||||
val modifiedKeys: Set<String> = emptySet(),
|
||||
@@ -68,7 +72,7 @@ class ConfigViewModel :
|
||||
val rawResult = rawDeferred.await()
|
||||
|
||||
if (configResult is NetworkResult.Success) {
|
||||
val configData = configResult.data
|
||||
val values = flattenConfig(configResult.data)
|
||||
val schema = (schemaResult as? NetworkResult.Success)?.data
|
||||
val defaults = (defaultsResult as? NetworkResult.Success)?.data
|
||||
val path = (rawResult as? NetworkResult.Success)?.data?.path
|
||||
@@ -76,9 +80,14 @@ class ConfigViewModel :
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
config = configData,
|
||||
values = values,
|
||||
schema = schema,
|
||||
defaults = defaults,
|
||||
uncoveredPaths =
|
||||
collectUncoveredPaths(
|
||||
values,
|
||||
schema?.fields?.keys ?: emptySet(),
|
||||
),
|
||||
path = path,
|
||||
activeCategory =
|
||||
if (schema?.category_order?.isNotEmpty() == true) {
|
||||
@@ -108,17 +117,34 @@ class ConfigViewModel :
|
||||
value: JsonElement,
|
||||
) {
|
||||
pendingChanges[key] = value
|
||||
val state = _uiState.value
|
||||
|
||||
// Apply the change locally so the UI reflects it immediately
|
||||
val updatedConfig =
|
||||
state.config?.let { config ->
|
||||
applyNestedValue(config, key, value)
|
||||
}
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
config = updatedConfig,
|
||||
values = it.values?.toMutableMap()?.apply { this[key] = value },
|
||||
modifiedKeys = pendingChanges.keys.toSet(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset ONE field to its default value (pending until Save). */
|
||||
fun resetField(key: String) {
|
||||
val state = _uiState.value
|
||||
val defaultVal = state.defaults?.get(key) ?: return
|
||||
pendingChanges[key] = defaultVal
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
values = it.values?.toMutableMap()?.apply { this[key] = defaultVal },
|
||||
modifiedKeys = pendingChanges.keys.toSet(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear a clearable field to blank (e.g. timezone → system default). */
|
||||
fun clearField(key: String) {
|
||||
val blank = JsonPrimitive("")
|
||||
pendingChanges[key] = blank
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
values = it.values?.toMutableMap()?.apply { this[key] = blank },
|
||||
modifiedKeys = pendingChanges.keys.toSet(),
|
||||
)
|
||||
}
|
||||
@@ -193,7 +219,9 @@ class ConfigViewModel :
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
config = (configResult as? NetworkResult.Success)?.data ?: it.config,
|
||||
values =
|
||||
(configResult as? NetworkResult.Success)?.data?.let(::flattenConfig)
|
||||
?: it.values,
|
||||
modifiedKeys = emptySet(),
|
||||
toastMessage = "Configuration saved successfully",
|
||||
)
|
||||
@@ -233,7 +261,9 @@ class ConfigViewModel :
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
yamlIsSaving = false,
|
||||
config = (configResult as? NetworkResult.Success)?.data ?: it.config,
|
||||
values =
|
||||
(configResult as? NetworkResult.Success)?.data?.let(::flattenConfig)
|
||||
?: it.values,
|
||||
toastMessage = "YAML configuration saved",
|
||||
)
|
||||
}
|
||||
@@ -262,15 +292,20 @@ class ConfigViewModel :
|
||||
}
|
||||
|
||||
var count = 0
|
||||
val updatedValues = state.values?.toMutableMap()
|
||||
for ((key, _) in categoryFields) {
|
||||
val defaultVal = defaults[key]
|
||||
if (defaultVal != null) {
|
||||
pendingChanges[key] = defaultVal
|
||||
updatedValues?.set(key, defaultVal)
|
||||
count++
|
||||
}
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(modifiedKeys = pendingChanges.keys.toSet())
|
||||
it.copy(
|
||||
values = updatedValues ?: it.values,
|
||||
modifiedKeys = pendingChanges.keys.toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
@@ -284,34 +319,6 @@ class ConfigViewModel :
|
||||
_uiState.update { it.copy(toastMessage = null) }
|
||||
}
|
||||
|
||||
/** Apply a value at a dot-path like "terminal.backend" into a flat config map. */
|
||||
private fun applyNestedValue(
|
||||
config: Map<String, JsonElement>,
|
||||
dotPath: String,
|
||||
value: JsonElement,
|
||||
): Map<String, JsonElement> {
|
||||
val parts = dotPath.split(".")
|
||||
if (parts.size == 1) {
|
||||
val mutable = config.toMutableMap()
|
||||
mutable[parts[0]] = value
|
||||
return mutable
|
||||
}
|
||||
val topKey = parts.first()
|
||||
val rest = parts.drop(1).joinToString(".")
|
||||
val topValue = config[topKey]
|
||||
val nestedJson = (topValue as? JsonObject) ?: JsonObject(emptyMap())
|
||||
val updatedNested =
|
||||
applyNestedValue(
|
||||
nestedJson,
|
||||
rest,
|
||||
value,
|
||||
)
|
||||
val nestedObj = JsonObject(updatedNested)
|
||||
val mutable = config.toMutableMap()
|
||||
mutable[topKey] = nestedObj
|
||||
return mutable
|
||||
}
|
||||
|
||||
/** Build a nested JSON changeset from dot-path pending changes. */
|
||||
private fun buildChangeset(changes: Map<String, JsonElement>): Map<String, JsonElement> {
|
||||
val root = mutableMapOf<String, Any>()
|
||||
@@ -333,18 +340,15 @@ class ConfigViewModel :
|
||||
current[parts.last()] = value
|
||||
}
|
||||
|
||||
fun toJsonObject(map: Map<String, Any>): JsonObject {
|
||||
val content =
|
||||
fun toJsonObject(map: Map<String, Any>): Map<String, JsonElement> =
|
||||
map.mapValues { (_, v) ->
|
||||
if (v is Map<*, *>) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
toJsonObject(v as Map<String, Any>)
|
||||
JsonObject(toJsonObject(v as Map<String, Any>))
|
||||
} else {
|
||||
v as JsonElement
|
||||
}
|
||||
}
|
||||
return JsonObject(content)
|
||||
}
|
||||
|
||||
return toJsonObject(root)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.m57.hermescontrol.ui.config
|
||||
|
||||
import com.m57.hermescontrol.data.model.SchemaField
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure-logic tests for the config form's data layer: flattening the nested
|
||||
* config response into dot-paths, "Other" path discovery, and the search
|
||||
* matcher (which must cover keys, labels, descriptions, categories, CURRENT
|
||||
* VALUES and select options — the value/option coverage was missing before
|
||||
* the redo).
|
||||
*/
|
||||
class ConfigFormDataTest {
|
||||
// ── flattenConfig ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `flattenConfig walks nested objects into dot paths`() {
|
||||
val nested =
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"model" to JsonPrimitive("gpt-4o"),
|
||||
"terminal" to
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"backend" to JsonPrimitive("local"),
|
||||
"timeout" to JsonPrimitive(30),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
val flat = flattenConfig(nested)
|
||||
assertEquals(
|
||||
mapOf(
|
||||
"model" to JsonPrimitive("gpt-4o"),
|
||||
"terminal.backend" to JsonPrimitive("local"),
|
||||
"terminal.timeout" to JsonPrimitive(30),
|
||||
),
|
||||
flat,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flattenConfig keeps arrays and nulls as leaf values`() {
|
||||
val nested =
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"toolsets" to JsonArray(listOf(JsonPrimitive("terminal"), JsonPrimitive("web"))),
|
||||
"unset" to JsonNull,
|
||||
),
|
||||
)
|
||||
val flat = flattenConfig(nested)
|
||||
assertEquals(2, flat.size)
|
||||
assertEquals(
|
||||
JsonArray(listOf(JsonPrimitive("terminal"), JsonPrimitive("web"))),
|
||||
flat["toolsets"],
|
||||
)
|
||||
assertEquals(JsonNull, flat["unset"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flattenConfig on already-flat map is identity`() {
|
||||
val flat = mapOf("a.b" to JsonPrimitive(1), "c" to JsonPrimitive("x"))
|
||||
assertEquals(flat, flattenConfig(flat))
|
||||
}
|
||||
|
||||
// ── isCoveredBySchema ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `isCoveredBySchema matches exact key and ancestors`() {
|
||||
val schema = setOf("tts.provider", "model")
|
||||
assertTrue(isCoveredBySchema("tts.provider", schema))
|
||||
assertTrue(isCoveredBySchema("model", schema))
|
||||
// Ancestor relationship: key below a schema key is covered
|
||||
assertTrue(isCoveredBySchema("tts.provider.x", schema))
|
||||
assertFalse(isCoveredBySchema("tts.other", schema))
|
||||
// Prefix boundary: "tts.providers" must NOT match "tts.provider"
|
||||
assertFalse(isCoveredBySchema("tts.providers.custom", schema))
|
||||
}
|
||||
|
||||
// ── collectUncoveredPaths ──────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `collectUncoveredPaths finds only schema-orphaned leaves`() {
|
||||
val flat =
|
||||
mapOf(
|
||||
"model" to JsonPrimitive("x"),
|
||||
"tts.provider" to JsonPrimitive("edge"),
|
||||
"tts.providers.custom.voice" to JsonPrimitive("amy"),
|
||||
"dashboard.host" to JsonPrimitive("0.0.0.0"),
|
||||
)
|
||||
val schema = setOf("model", "tts.provider")
|
||||
assertEquals(
|
||||
listOf("dashboard.host", "tts.providers.custom.voice"),
|
||||
collectUncoveredPaths(flat, schema),
|
||||
)
|
||||
}
|
||||
|
||||
// ── rowMatchesQuery ────────────────────────────────────────────────────
|
||||
|
||||
private fun row(
|
||||
key: String,
|
||||
field: SchemaField?,
|
||||
value: kotlinx.serialization.json.JsonElement? = null,
|
||||
) = ConfigRow(key = key, field = field, value = value)
|
||||
|
||||
@Test
|
||||
fun `search matches dot key and human label`() {
|
||||
val r = row("model_context_length", SchemaField(type = "number"), JsonPrimitive(8192))
|
||||
assertTrue(rowMatchesQuery(r, "model_context"))
|
||||
assertTrue(rowMatchesQuery(r, "context length"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search matches description and category`() {
|
||||
val r =
|
||||
row(
|
||||
"stt.local.model",
|
||||
SchemaField(type = "select", description = "Local faster-whisper model size", category = "stt"),
|
||||
)
|
||||
assertTrue(rowMatchesQuery(r, "whisper"))
|
||||
assertTrue(rowMatchesQuery(r, "stt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search matches current value`() {
|
||||
val r = row("model", SchemaField(type = "string"), JsonPrimitive("anthropic/claude-sonnet-4.6"))
|
||||
assertTrue(rowMatchesQuery(r, "claude-sonnet"))
|
||||
assertFalse(rowMatchesQuery(r, "gpt-4o"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search matches select options`() {
|
||||
val r =
|
||||
row(
|
||||
"terminal.backend",
|
||||
SchemaField(type = "select", options = listOf("local", "docker", "ssh", "modal", "daytona")),
|
||||
JsonPrimitive("local"),
|
||||
)
|
||||
assertTrue(rowMatchesQuery(r, "daytona"))
|
||||
assertTrue(rowMatchesQuery(r, "docker"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search matches uncovered paths by key and value`() {
|
||||
val r = row("plugins.myplugin.enabled", field = null, value = JsonPrimitive("true"))
|
||||
assertTrue(rowMatchesQuery(r, "myplugin"))
|
||||
assertTrue(rowMatchesQuery(r, "true"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search is case-insensitive and blank query matches all`() {
|
||||
val r = row("Model", SchemaField(type = "string"), JsonPrimitive("OpenAI"))
|
||||
assertTrue(rowMatchesQuery(r, "openai"))
|
||||
assertTrue(rowMatchesQuery(r, "MODEL"))
|
||||
assertTrue(rowMatchesQuery(r, ""))
|
||||
assertTrue(rowMatchesQuery(r, " "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search does not match unrelated keys`() {
|
||||
val r = row("memory.provider", SchemaField(type = "select"), JsonPrimitive("builtin"))
|
||||
assertFalse(rowMatchesQuery(r, "terminal"))
|
||||
}
|
||||
|
||||
// ── jsonText ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `jsonText renders primitives, objects and arrays`() {
|
||||
assertEquals("42", jsonText(JsonPrimitive(42)))
|
||||
assertEquals("hello", jsonText(JsonPrimitive("hello")))
|
||||
assertEquals("{\"a\":1}", jsonText(JsonObject(mapOf("a" to JsonPrimitive(1)))))
|
||||
assertEquals("[1,2]", jsonText(JsonArray(listOf(JsonPrimitive(1), JsonPrimitive(2)))))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user