Skip to content

Commit f2cbcf2

Browse files
committed
feat: per-entry tracking for array field saves
When editing a single custom endpoint, changes are now tracked at the individual entry level (endpoints.custom.2) instead of replacing the entire array. This makes the save dialog show only the changed entry's before/after diff instead of all 20+ endpoints. On the server side, mergeIndexedArrayEntries() detects indexed paths, fetches the current base config, merges updated entries into the existing array, and sends the complete array to the API. The confirm dialog resolves indexed paths to entry names (e.g., "endpoints.custom[2] (Moonshot)") for clarity. Structural changes (add/remove entries) still use full-array replacement via the existing onChange path.
1 parent e37d0f3 commit f2cbcf2

8 files changed

Lines changed: 156 additions & 9 deletions

File tree

src/components/configuration/ConfigPage.tsx

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
flattenObject,
2121
unflattenObject,
2222
serializeKVPairs,
23+
deepSerializeKVPairs,
2324
cn,
2425
normalizeImportConfig,
2526
hasConfigCapability,
@@ -269,7 +270,32 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
269270
return unflattenObject(scopeResolvedValues) as Record<string, t.ConfigValue>;
270271
}, [isEditingScope, scopeResolvedValues]);
271272

272-
const activeConfigValues = isEditingScope ? scopeConfigValues : configValues;
273+
const baseActiveConfigValues = isEditingScope ? scopeConfigValues : configValues;
274+
275+
const activeConfigValues = useMemo(() => {
276+
if (!baseActiveConfigValues) return baseActiveConfigValues;
277+
const indexedEdits = Object.entries(editedValues).filter(([k]) => /\.\d+$/.test(k));
278+
if (indexedEdits.length === 0) return baseActiveConfigValues;
279+
const merged = { ...baseActiveConfigValues };
280+
for (const [path, value] of indexedEdits) {
281+
const segments = path.split('.');
282+
const index = Number(segments.pop()!);
283+
const arrayPath = segments;
284+
let parent: Record<string, t.ConfigValue> = merged;
285+
for (let i = 0; i < arrayPath.length - 1; i++) {
286+
const seg = arrayPath[i];
287+
if (parent[seg] && typeof parent[seg] === 'object' && !Array.isArray(parent[seg])) {
288+
parent[seg] = { ...(parent[seg] as Record<string, t.ConfigValue>) };
289+
parent = parent[seg] as Record<string, t.ConfigValue>;
290+
} else break;
291+
}
292+
const lastSeg = arrayPath[arrayPath.length - 1];
293+
const arr = Array.isArray(parent[lastSeg]) ? [...(parent[lastSeg] as t.ConfigValue[])] : [];
294+
arr[index] = value;
295+
parent[lastSeg] = arr;
296+
}
297+
return merged;
298+
}, [baseActiveConfigValues, editedValues]);
273299

274300
const scopeConfiguredPaths = useMemo(() => {
275301
if (!scopeChangedPaths) return new Set<string>();
@@ -356,7 +382,16 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
356382
delete next[path];
357383
return next;
358384
}
359-
return { ...prev, [path]: value };
385+
const next = { ...prev, [path]: value };
386+
if (Array.isArray(value)) {
387+
const prefix = `${path}.`;
388+
for (const k of Object.keys(next)) {
389+
if (k.startsWith(prefix) && /\.\d+$/.test(k)) delete next[k];
390+
}
391+
}
392+
const indexMatch = /^(.+)\.\d+$/.exec(path);
393+
if (indexMatch) delete next[indexMatch[1]];
394+
return next;
360395
});
361396
});
362397
},
@@ -437,7 +472,10 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
437472

438473
const saves = touched
439474
.filter((p) => editedValues[p] !== undefined)
440-
.map((p) => ({ fieldPath: p, value: serializeKVPairs(editedValues[p]) }));
475+
.map((p) => ({
476+
fieldPath: p,
477+
value: /\.\d+$/.test(p) ? deepSerializeKVPairs(editedValues[p]) : serializeKVPairs(editedValues[p]),
478+
}));
441479
const resets = touched.filter((p) => editedValues[p] === undefined);
442480

443481
setSaving(true);
@@ -506,11 +544,29 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
506544
const serializedEditedValues = useMemo(() => {
507545
const result: t.FlatConfigMap = {};
508546
for (const [k, v] of Object.entries(editedValues)) {
509-
result[k] = serializeKVPairs(v);
547+
result[k] = /\.\d+$/.test(k) ? deepSerializeKVPairs(v) : serializeKVPairs(v);
510548
}
511549
return result;
512550
}, [editedValues]);
513551

552+
const originalValuesForDialog = useMemo(() => {
553+
const baseline = isEditingScope ? scopeBaseline : flatBaseline;
554+
const result: t.FlatConfigMap = { ...baseline };
555+
for (const path of Object.keys(editedValues)) {
556+
if (path in result) continue;
557+
const segments = path.split('.');
558+
let current: t.ConfigValue = configValues;
559+
for (const seg of segments) {
560+
if (current == null || typeof current !== 'object') { current = undefined; break; }
561+
current = Array.isArray(current)
562+
? (current as t.ConfigValue[])[Number(seg)]
563+
: (current as Record<string, t.ConfigValue>)[seg];
564+
}
565+
if (current !== undefined) result[path] = current;
566+
}
567+
return result;
568+
}, [editedValues, flatBaseline, isEditingScope, scopeBaseline, configValues]);
569+
514570
const [importSuccessMessage, setImportSuccessMessage] = useState<string | null>(null);
515571

516572
const showImportSuccess = useCallback((message?: string) => {
@@ -846,7 +902,7 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
846902
<ConfirmSaveDialog
847903
open={confirmSaveOpen}
848904
editedValues={serializedEditedValues}
849-
originalValues={isEditingScope ? scopeBaseline : flatBaseline}
905+
originalValues={originalValuesForDialog}
850906
saving={saving}
851907
error={saveError}
852908
onConfirm={handleConfirmSave}

src/components/configuration/ConfirmSaveDialog.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,16 @@ export function ConfirmSaveDialog({
6666
);
6767
}
6868

69+
function resolvePathLabel(path: string, newValue: t.ConfigValue, oldValue: t.ConfigValue): string {
70+
const match = /^(.+)\.(\d+)$/.exec(path);
71+
if (!match) return path;
72+
const val = (newValue ?? oldValue) as Record<string, t.ConfigValue> | undefined;
73+
const name = val && typeof val === 'object' && !Array.isArray(val)
74+
? (val as Record<string, string>).name
75+
: undefined;
76+
return name ? `${match[1]}[${match[2]}] (${name})` : `${match[1]}[${match[2]}]`;
77+
}
78+
6979
function ChangeCard({
7080
path,
7181
oldValue,
@@ -79,12 +89,13 @@ function ChangeCard({
7989
const notSet = localize('com_config_field_not_set');
8090
const isRemoval = newValue === undefined || newValue === null;
8191
const isAddition = oldValue === undefined || oldValue === null;
92+
const displayPath = resolvePathLabel(path, newValue, oldValue);
8293

8394
return (
8495
<div className="rounded-lg border border-(--cui-color-stroke-default) bg-(--cui-color-background-muted)">
8596
<div className="flex items-center gap-2 border-b border-(--cui-color-stroke-default) px-3 py-2">
8697
<span className="font-mono text-xs font-medium text-(--cui-color-text-default)">
87-
{path}
98+
{displayPath}
8899
</span>
89100
{isAddition && (
90101
<Badge text={localize('com_config_field_added')} state="success" size="sm" />

src/components/configuration/FieldRenderer.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Icon } from '@clickhouse/click-ui';
2-
import { useState, useRef, useEffect } from 'react';
2+
import { useState, useRef, useEffect, useCallback } from 'react';
33
import type { ReactNode } from 'react';
44
import type * as t from '@/types';
55
import {
@@ -66,12 +66,18 @@ function ArrayObjectNestedGroup({
6666
const addTriggerRef = useRef<(() => void) | null>(null);
6767
const handleAdd = disabled ? undefined : () => addTriggerRef.current?.();
6868

69+
const handleEntryChange = useCallback(
70+
(index: number, value: t.ConfigValue) => onChange(`${path}.${index}`, value),
71+
[onChange, path],
72+
);
73+
6974
const arrayField = (
7075
<ArrayObjectField
7176
id={fieldId}
7277
value={currentValue}
7378
fields={field.children ?? []}
7479
onChange={(v) => onChange(path, v)}
80+
onEntryChange={handleEntryChange}
7581
disabled={disabled}
7682
hideAddButton
7783
addTriggerRef={addTriggerRef}

src/components/configuration/fields/ArrayObjectField.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export function ArrayObjectField({
1919
value,
2020
fields,
2121
onChange,
22+
onEntryChange,
2223
disabled,
2324
hideAddButton,
2425
addTriggerRef,
@@ -83,11 +84,15 @@ export function ArrayObjectField({
8384

8485
const handleEntryChange = useCallback(
8586
(index: number, newValue: t.ConfigValue) => {
87+
if (onEntryChange) {
88+
onEntryChange(index, newValue);
89+
return;
90+
}
8691
const next = [...items];
8792
next[index] = newValue;
8893
onChange(next);
8994
},
90-
[items, onChange],
95+
[items, onChange, onEntryChange],
9196
);
9297

9398
return (

src/components/configuration/sections/EndpointsRenderer.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,7 @@ export function CustomEndpointsRenderer(props: t.FieldRendererProps) {
632632
value={value}
633633
fields={customField.children ?? []}
634634
onChange={(v) => onChange(path, v)}
635+
onEntryChange={(index, v) => onChange(`${path}.${index}`, v)}
635636
disabled={disabled}
636637
hideAddButton
637638
renderFields={renderGroupedEndpointFields}

src/server/config.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,50 @@ export const baseConfigOptions = queryOptions({
801801
staleTime: 30_000,
802802
});
803803

804+
const INDEXED_ARRAY_RE = /^(.+)\.(\d+)$/;
805+
806+
async function mergeIndexedArrayEntries(
807+
entries: Array<{ fieldPath: string; value: unknown }>,
808+
): Promise<Array<{ fieldPath: string; value: unknown }>> {
809+
const indexed = new Map<string, Map<number, unknown>>();
810+
const rest: Array<{ fieldPath: string; value: unknown }> = [];
811+
812+
for (const entry of entries) {
813+
const match = INDEXED_ARRAY_RE.exec(entry.fieldPath);
814+
if (match) {
815+
const [, arrayPath, indexStr] = match;
816+
if (!indexed.has(arrayPath)) indexed.set(arrayPath, new Map());
817+
indexed.get(arrayPath)!.set(Number(indexStr), entry.value);
818+
} else {
819+
rest.push(entry);
820+
}
821+
}
822+
823+
if (indexed.size === 0) return entries;
824+
825+
const baseResponse = await apiFetch('/api/admin/config/base');
826+
if (!baseResponse.ok) throw new Error(`Failed to fetch base config: ${baseResponse.status}`);
827+
const { config: baseConfig } = (await baseResponse.json()) as {
828+
config: Record<string, unknown>;
829+
};
830+
831+
for (const [arrayPath, updates] of indexed) {
832+
const segments = arrayPath.split('.');
833+
let current: unknown = baseConfig;
834+
for (const seg of segments) {
835+
if (current == null || typeof current !== 'object') { current = undefined; break; }
836+
current = (current as Record<string, unknown>)[seg];
837+
}
838+
const arr = Array.isArray(current) ? [...current] : [];
839+
for (const [idx, value] of updates) {
840+
arr[idx] = value;
841+
}
842+
rest.push({ fieldPath: arrayPath, value: arr });
843+
}
844+
845+
return rest;
846+
}
847+
804848
export const saveBaseConfigFn = createServerFn({ method: 'POST' })
805849
.inputValidator(
806850
z.object({
@@ -811,8 +855,13 @@ export const saveBaseConfigFn = createServerFn({ method: 'POST' })
811855
}),
812856
)
813857
.handler(async ({ data }) => {
814-
const filtered = data.entries.filter((e) => !isInterfacePermissionPath(e.fieldPath));
858+
let filtered = data.entries.filter((e) => !isInterfacePermissionPath(e.fieldPath));
815859
if (filtered.length === 0) return { success: true };
860+
861+
// Merge indexed array entries (e.g. endpoints.custom.2) back into
862+
// full arrays so the API receives complete field values.
863+
filtered = await mergeIndexedArrayEntries(filtered);
864+
816865
const sections = [...new Set(filtered.map((e) => e.fieldPath.split('.')[0]))];
817866
await requireAllSectionCapabilities(sections);
818867

src/types/fields.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ export interface ArrayObjectFieldProps {
100100
value: ConfigValue;
101101
fields: SchemaField[];
102102
onChange: (value: ConfigValue) => void;
103+
/** Per-entry change callback. When provided, individual entry edits use
104+
* this instead of replacing the entire array via `onChange`. */
105+
onEntryChange?: (index: number, value: ConfigValue) => void;
103106
disabled?: boolean;
104107
/** Hide the bottom "Add entry" button (e.g. when add is in the section header). */
105108
hideAddButton?: boolean;

src/utils/format.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,22 @@ export function serializeKVPairs(value: t.ConfigValue): t.ConfigValue {
1919
return record;
2020
}
2121

22+
/** Recursively serialize KV pairs within an object tree. */
23+
export function deepSerializeKVPairs(value: t.ConfigValue): t.ConfigValue {
24+
if (value == null || typeof value !== 'object') return value;
25+
if (Array.isArray(value)) {
26+
const serialized = serializeKVPairs(value);
27+
if (serialized !== value) return serialized;
28+
return value.map(deepSerializeKVPairs);
29+
}
30+
const obj = value as Record<string, t.ConfigValue>;
31+
const result: Record<string, t.ConfigValue> = {};
32+
for (const [k, v] of Object.entries(obj)) {
33+
result[k] = deepSerializeKVPairs(v);
34+
}
35+
return result;
36+
}
37+
2238
function coerceKVValue(raw: string, type: t.KVValueType): t.ConfigValue {
2339
if (type === 'boolean') return raw === 'true';
2440
if (type === 'number') {

0 commit comments

Comments
 (0)