config uislopping

This commit is contained in:
legop3
2026-09-14 03:06:41 -04:00
parent 81994f8a56
commit 3d2e75572f
16 changed files with 243 additions and 135 deletions
@@ -2,6 +2,7 @@
// Purpose: Connects the one hierarchical configuration form to revision, secret, validation, and save behavior.
// Scope: Edits and saves one complete configuration document as one immutable revision.
import { useEffect, useMemo, useState } from 'react';
import CardFrame from '../../components/CardFrame/index.jsx';
import { updateConfiguration } from '../api.js';
import SchemaConfigurationForm from './SchemaConfigurationForm.jsx';
@@ -63,12 +64,12 @@ export default function ConfigurationEditor({ snapshot, socket, runSensitive, on
return (
<div className="space-y-0.5">
<div className="sticky top-0 z-20 flex flex-wrap items-center justify-between gap-0.5 border border-neutral-500/60 bg-neutral-900/95 p-0.5 backdrop-blur">
<div>
<p className="font-semibold text-slate-100">Configuration</p>
<p className="text-[0.7rem] text-slate-400">Editing the complete revision {revision}. Saved changes apply after an application restart.</p>
</div>
<div className="flex gap-0.5">
<CardFrame className="sticky top-0 z-20 bg-neutral-900/95 backdrop-blur" title="Configuration" meta={`revision ${revision}`} bodyClassName="space-y-0.5 p-0.5">
<p className="text-[0.7rem] text-slate-400">Saved changes apply after an application restart.</p>
{/* All document actions stay together at the start of the toolbar. The
editor may use a wide canvas, but width is never used to separate a
control from the content that explains it. */}
<div className="flex flex-wrap gap-0.5">
<button type="button" className="button-dark" disabled={saving} onClick={onReload}>Reload</button>
<button type="button" className="button-dark" disabled={!dirty || saving} onClick={() => {
setDraft(clone(serverValue));
@@ -76,7 +77,7 @@ export default function ConfigurationEditor({ snapshot, socket, runSensitive, on
}}>Reset</button>
<button type="button" className="button-dark" disabled={!dirty || saving} onClick={save}>{saving ? 'Saving…' : 'Save configuration'}</button>
</div>
</div>
</CardFrame>
{error ? <p className="border border-red-500/60 bg-red-950/40 p-1 text-xs text-red-100">{error}</p> : null}
{validationErrors.length ? (
<div className="border border-red-500/60 bg-red-950/40 p-1 text-xs text-red-100">
@@ -1,9 +1,10 @@
// Schema-Generated Configuration Form
// Purpose: Renders the server-provided JSON Schema as one hierarchical form without feature-specific React components.
// Scope: Adds only the generic secret interaction that ordinary JSON Schema form controls cannot safely infer.
// Purpose: Renders the server-provided JSON Schema in the same cards, surfaces, fields, and buttons as the rest of MultiRover.
// Scope: Defines one generic RJSF presentation; it never names or special-cases an individual service or setting.
import { useMemo } from 'react';
import Form from '@rjsf/core';
import validator from '@rjsf/validator-ajv8';
import CardFrame from '../../components/CardFrame/index.jsx';
function buildUiSchema(schema, path = '') {
/*
@@ -41,7 +42,9 @@ function SecretWidget({ id, disabled, readonly, options, registry }) {
return (
<div className="surface space-y-0.5 p-0.5">
<div className="flex flex-wrap items-center justify-between gap-0.5">
{/* Secret actions stay beside their status. A wide configuration card
must not turn related controls into a trip across the screen. */}
<div className="flex flex-wrap items-center gap-0.5">
<span className="text-[0.7rem] text-slate-500">
{clearing ? 'Will be cleared when saved' : replacing ? 'Replacement pending' : configured ? 'Configured' : 'Not configured'}
</span>
@@ -70,9 +73,143 @@ function SecretWidget({ id, disabled, readonly, options, registry }) {
);
}
function ConfigurationFieldTemplate({
children,
classNames,
description,
displayLabel,
errors,
help,
hidden,
id,
label,
required,
schema,
style,
}) {
if (hidden) return <div className="hidden">{children}</div>;
const containerField = schema?.type === 'object' || schema?.type === 'array';
if (containerField) {
// Objects and arrays own their visible boundaries and headings in the
// templates below. Keeping this wrapper structural avoids duplicated
// titles while retaining RJSF's useful field-type classes for layout.
return <div className={classNames} style={style}>{children}{errors}{help}</div>;
}
return (
<div className={`${classNames || ''} configuration-field surface p-1`} style={style}>
{displayLabel ? (
<label htmlFor={id} className="block text-xs font-semibold text-slate-100">
{label}{required ? <span className="ml-0.25 text-sky-300">*</span> : null}
</label>
) : null}
{displayLabel && description ? <div className="mt-0.25">{description}</div> : null}
<div className={displayLabel ? 'mt-0.5' : ''}>{children}</div>
{errors}
{help}
</div>
);
}
function ConfigurationObjectTemplate({ description, fieldPathId, properties, title }) {
const visibleProperties = properties.filter((property) => !property.hidden);
const propertyGrid = (
<div className="configuration-property-grid grid gap-0.5 md:grid-cols-2 xl:grid-cols-3">
{/* RJSF gives each property content its own keyed field wrapper. Rendering
it directly preserves field-object and field-array on the grid child,
allowing containers to span the row without another frontend schema. */}
{visibleProperties.map((property) => property.content)}
</div>
);
if (fieldPathId.path.length === 0) {
// The editor toolbar already identifies the root document. The root is a
// simple ordered stack so every service-owned top-level object receives
// the full page width before arranging its own fields responsively.
return <div className="space-y-1">{visibleProperties.map((property) => property.content)}</div>;
}
if (typeof fieldPathId.path.at(-1) === 'number') {
// Array items receive their numbered heading and action row from the array
// item template. Rendering only their property grid prevents redundant
// nested boxes such as "Item 1" followed by another anonymous object box.
return propertyGrid;
}
if (fieldPathId.path.length === 1) {
return (
<CardFrame title={title} clipOverflow={false} bodyClassName="space-y-0.5 p-0.5">
{description ? <div className="px-0.5 text-xs text-slate-400">{description}</div> : null}
{propertyGrid}
</CardFrame>
);
}
return (
<section className="configuration-object surface border border-neutral-500/60 p-0.5">
<h3 className="mb-0.5 text-sm font-semibold text-slate-100">{title}</h3>
{description ? <div className="mb-0.5 text-xs text-slate-400">{description}</div> : null}
{propertyGrid}
</section>
);
}
function ConfigurationArrayItemTemplate({ buttonsProps, children, hasToolbar, index }) {
const unavailable = buttonsProps.disabled || buttonsProps.readonly;
return (
<article className="surface-muted space-y-0.5 border border-neutral-500/60 p-0.5">
{hasToolbar ? (
// Text controls are intentionally kept immediately after the item
// number. RJSF's default Bootstrap toolbox pushes empty glyphicon
// buttons to the far edge, which is both unclear and hard to reach.
<header className="flex flex-wrap items-center gap-0.5">
<span className="mr-0.5 text-xs font-semibold text-slate-100">Item {index + 1}</span>
{(buttonsProps.hasMoveUp || buttonsProps.hasMoveDown) ? (
<button type="button" className="button-dark text-xs" disabled={unavailable || !buttonsProps.hasMoveUp} onClick={buttonsProps.onMoveUpItem}>Move up</button>
) : null}
{(buttonsProps.hasMoveUp || buttonsProps.hasMoveDown) ? (
<button type="button" className="button-dark text-xs" disabled={unavailable || !buttonsProps.hasMoveDown} onClick={buttonsProps.onMoveDownItem}>Move down</button>
) : null}
{buttonsProps.hasCopy ? (
<button type="button" className="button-dark text-xs" disabled={unavailable} onClick={buttonsProps.onCopyItem}>Duplicate</button>
) : null}
{buttonsProps.hasRemove ? (
<button type="button" className="button-danger text-xs" disabled={unavailable} onClick={buttonsProps.onRemoveItem}>Remove</button>
) : null}
</header>
) : null}
{children}
</article>
);
}
function ConfigurationArrayTemplate({ canAdd, disabled, items, onAddClick, readonly, schema, title }) {
return (
<section className="configuration-array surface space-y-0.5 border border-neutral-500/60 p-0.5">
<header className="flex flex-wrap items-center gap-0.5">
<h3 className="text-sm font-semibold text-slate-100">{title}</h3>
<span className="text-[0.7rem] text-slate-400">{items.length} {items.length === 1 ? 'item' : 'items'}</span>
</header>
{schema.description ? <div className="text-xs text-slate-400">{schema.description}</div> : null}
{items.length ? <div className="space-y-0.5">{items}</div> : <p className="text-xs text-slate-500">No items configured.</p>}
{canAdd ? (
<button type="button" className="button-dark text-xs" disabled={disabled || readonly} onClick={onAddClick}>Add item</button>
) : null}
</section>
);
}
export default function SchemaConfigurationForm({ schema, value, onChange, configuredSecrets, secretOperations, setSecretOperation }) {
const uiSchema = useMemo(() => buildUiSchema(schema), [schema]);
const widgets = useMemo(() => ({ SecretWidget }), []);
const templates = useMemo(() => ({
ArrayFieldItemTemplate: ConfigurationArrayItemTemplate,
ArrayFieldTemplate: ConfigurationArrayTemplate,
FieldTemplate: ConfigurationFieldTemplate,
ObjectFieldTemplate: ConfigurationObjectTemplate,
}), []);
const formContext = useMemo(() => ({
configuredSecrets,
secretOperations,
@@ -87,6 +224,7 @@ export default function SchemaConfigurationForm({ schema, value, onChange, confi
formData={value}
validator={validator}
widgets={widgets}
templates={templates}
formContext={formContext}
noHtml5Validate
showErrorList={false}