Config UI: tag filters and complete schema help/labels coverage (#23796)

* Config UI: add tag filters and complete schema help/labels

* Config UI: finalize tags/help polish and unblock test suite

* Protocol: regenerate Swift gateway models
This commit is contained in:
Tak Hoffman
2026-02-22 15:17:07 -06:00
committed by GitHub
parent c539782c09
commit f8171ffcdc
28 changed files with 3409 additions and 274 deletions

View File

@@ -53,14 +53,19 @@
/* Search */
.config-search {
position: relative;
padding: 14px;
display: grid;
gap: 6px;
padding: 12px 14px 10px;
border-bottom: 1px solid var(--border);
}
.config-search__input-row {
position: relative;
}
.config-search__icon {
position: absolute;
left: 28px;
left: 14px;
top: 50%;
transform: translateY(-50%);
width: 16px;
@@ -103,7 +108,7 @@
.config-search__clear {
position: absolute;
right: 22px;
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 22px;
@@ -128,6 +133,131 @@
color: var(--text);
}
.config-search__hint {
display: grid;
gap: 6px;
}
.config-search__hint-label {
font-size: 10px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.03em;
white-space: nowrap;
}
.config-search__tag-picker {
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-elevated);
transition:
border-color var(--duration-fast) ease,
box-shadow var(--duration-fast) ease,
background var(--duration-fast) ease;
}
.config-search__tag-picker[open] {
border-color: var(--accent);
box-shadow: var(--focus-ring);
background: var(--bg-hover);
}
:root[data-theme="light"] .config-search__tag-picker {
background: white;
}
.config-search__tag-trigger {
list-style: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 30px;
padding: 6px 8px;
cursor: pointer;
}
.config-search__tag-trigger::-webkit-details-marker {
display: none;
}
.config-search__tag-placeholder {
font-size: 11px;
color: var(--muted);
}
.config-search__tag-chips {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
min-width: 0;
}
.config-search__tag-chip {
display: inline-flex;
align-items: center;
border: 1px solid var(--border);
border-radius: var(--radius-full);
padding: 2px 7px;
font-size: 10px;
font-weight: 500;
color: var(--text);
background: var(--bg);
}
.config-search__tag-chip--count {
color: var(--muted);
}
.config-search__tag-caret {
color: var(--muted);
font-size: 12px;
line-height: 1;
}
.config-search__tag-picker[open] .config-search__tag-caret {
transform: rotate(180deg);
}
.config-search__tag-menu {
max-height: 104px;
overflow-y: auto;
border-top: 1px solid var(--border);
padding: 6px;
display: grid;
gap: 6px;
}
.config-search__tag-option {
display: block;
width: 100%;
border: 1px solid transparent;
border-radius: var(--radius-sm);
padding: 6px 8px;
background: transparent;
color: var(--muted);
font-size: 11px;
text-align: left;
cursor: pointer;
transition:
background var(--duration-fast) ease,
color var(--duration-fast) ease,
border-color var(--duration-fast) ease;
}
.config-search__tag-option:hover {
background: var(--bg-hover);
color: var(--text);
}
.config-search__tag-option.active {
background: var(--accent-subtle);
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 34%, transparent);
}
/* Navigation */
.config-nav {
flex: 1;
@@ -536,7 +666,7 @@
.config-form--modern {
display: grid;
gap: 26px;
gap: 20px;
}
.config-section-card {
@@ -603,7 +733,7 @@
}
.config-section-card__content {
padding: 22px;
padding: 18px;
}
/* ===========================================
@@ -612,12 +742,16 @@
.cfg-fields {
display: grid;
gap: 22px;
gap: 14px;
}
.cfg-fields--inline {
gap: 10px;
}
.cfg-field {
display: grid;
gap: 8px;
gap: 6px;
}
.cfg-field--error {
@@ -639,6 +773,28 @@
line-height: 1.45;
}
.cfg-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.cfg-tag {
display: inline-flex;
align-items: center;
border: 1px solid var(--border);
border-radius: var(--radius-full);
padding: 2px 8px;
font-size: 11px;
color: var(--muted);
background: var(--bg-elevated);
white-space: nowrap;
}
:root[data-theme="light"] .cfg-tag {
background: white;
}
.cfg-field__error {
font-size: 12px;
color: var(--danger);
@@ -989,22 +1145,25 @@
.cfg-object {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--bg-accent);
background: transparent;
overflow: hidden;
}
:root[data-theme="light"] .cfg-object {
background: white;
background: transparent;
}
.cfg-object__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
padding: 10px 12px;
cursor: pointer;
list-style: none;
transition: background var(--duration-fast) ease;
transition:
background var(--duration-fast) ease,
border-color var(--duration-fast) ease;
border-radius: var(--radius-md);
}
.cfg-object__header:hover {
@@ -1021,6 +1180,12 @@
color: var(--text);
}
.cfg-object__title-wrap {
display: grid;
gap: 6px;
min-width: 0;
}
.cfg-object__chevron {
width: 18px;
height: 18px;
@@ -1038,16 +1203,16 @@
}
.cfg-object__help {
padding: 0 18px 14px;
padding: 0 12px 10px;
font-size: 12px;
color: var(--muted);
border-bottom: 1px solid var(--border);
}
.cfg-object__content {
padding: 18px;
padding: 12px;
display: grid;
gap: 18px;
gap: 12px;
border-top: 1px solid var(--border);
}
/* Array */
@@ -1061,7 +1226,7 @@
display: flex;
align-items: center;
gap: 14px;
padding: 14px 18px;
padding: 10px 12px;
background: var(--bg-accent);
border-bottom: 1px solid var(--border);
}
@@ -1071,12 +1236,18 @@
}
.cfg-array__label {
flex: 1;
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.cfg-array__title {
flex: 1;
min-width: 0;
display: grid;
gap: 6px;
}
.cfg-array__count {
font-size: 12px;
color: var(--muted);
@@ -1124,7 +1295,7 @@
}
.cfg-array__help {
padding: 12px 18px;
padding: 10px 12px;
font-size: 12px;
color: var(--muted);
border-bottom: 1px solid var(--border);
@@ -1151,7 +1322,7 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 18px;
padding: 10px 12px;
background: var(--bg-accent);
border-bottom: 1px solid var(--border);
}
@@ -1200,7 +1371,7 @@
}
.cfg-array__item-content {
padding: 18px;
padding: 12px;
}
/* Map (custom entries) */
@@ -1215,7 +1386,7 @@
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 14px 18px;
padding: 10px 12px;
background: var(--bg-accent);
border-bottom: 1px solid var(--border);
}
@@ -1268,15 +1439,28 @@
.cfg-map__items {
display: grid;
gap: 10px;
padding: 14px;
gap: 8px;
padding: 10px 12px 12px;
}
.cfg-map__item {
display: grid;
grid-template-columns: 150px 1fr auto;
gap: 10px;
align-items: start;
gap: 8px;
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-accent);
}
:root[data-theme="light"] .cfg-map__item {
background: white;
}
.cfg-map__item-header {
display: grid;
grid-template-columns: minmax(0, 300px) auto;
gap: 8px;
align-items: center;
}
.cfg-map__item-key {
@@ -1287,9 +1471,13 @@
min-width: 0;
}
.cfg-map__item-value > .cfg-fields {
gap: 10px;
}
.cfg-map__item-remove {
width: 34px;
height: 34px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
@@ -1410,6 +1598,10 @@
gap: 10px;
}
.cfg-map__item-header {
grid-template-columns: 1fr auto;
}
.cfg-map__item-remove {
justify-self: end;
}

View File

@@ -197,6 +197,113 @@ describe("config form renderer", () => {
expect(container.textContent).toContain("Plugin Enabled");
});
it("renders tags from uiHints metadata", () => {
const onPatch = vi.fn();
const container = document.createElement("div");
const analysis = analyzeConfigSchema(rootSchema);
render(
renderConfigForm({
schema: analysis.schema,
uiHints: {
"gateway.auth.token": { tags: ["security", "secret"] },
},
unsupportedPaths: analysis.unsupportedPaths,
value: {},
onPatch,
}),
container,
);
const tags = Array.from(container.querySelectorAll(".cfg-tag")).map((node) =>
node.textContent?.trim(),
);
expect(tags).toContain("security");
expect(tags).toContain("secret");
});
it("filters by tag query", () => {
const onPatch = vi.fn();
const container = document.createElement("div");
const analysis = analyzeConfigSchema(rootSchema);
render(
renderConfigForm({
schema: analysis.schema,
uiHints: {
"gateway.auth.token": { tags: ["security"] },
},
unsupportedPaths: analysis.unsupportedPaths,
value: {},
searchQuery: "tag:security",
onPatch,
}),
container,
);
expect(container.textContent).toContain("Gateway");
expect(container.textContent).toContain("Token");
expect(container.textContent).not.toContain("Allow From");
expect(container.textContent).not.toContain("Mode");
});
it("does not treat plain text as tag filter", () => {
const onPatch = vi.fn();
const container = document.createElement("div");
const analysis = analyzeConfigSchema(rootSchema);
render(
renderConfigForm({
schema: analysis.schema,
uiHints: {
"gateway.auth.token": { tags: ["security"] },
},
unsupportedPaths: analysis.unsupportedPaths,
value: {},
searchQuery: "security",
onPatch,
}),
container,
);
expect(container.textContent).toContain('No settings match "security"');
});
it("requires both text and tag when combined", () => {
const onPatch = vi.fn();
const container = document.createElement("div");
const analysis = analyzeConfigSchema(rootSchema);
render(
renderConfigForm({
schema: analysis.schema,
uiHints: {
"gateway.auth.token": { tags: ["security"] },
},
unsupportedPaths: analysis.unsupportedPaths,
value: {},
searchQuery: "token tag:security",
onPatch,
}),
container,
);
expect(container.textContent).toContain("Token");
expect(container.textContent).not.toContain('No settings match "token tag:security"');
const noMatchContainer = document.createElement("div");
render(
renderConfigForm({
schema: analysis.schema,
uiHints: {
"gateway.auth.token": { tags: ["security"] },
},
unsupportedPaths: analysis.unsupportedPaths,
value: {},
searchQuery: "mode tag:security",
onPatch,
}),
noMatchContainer,
);
expect(noMatchContainer.textContent).toContain('No settings match "mode tag:security"');
});
it("flags unsupported unions", () => {
const schema = {
type: "object",

View File

@@ -286,6 +286,7 @@ export type ConfigSnapshot = {
export type ConfigUiHint = {
label?: string;
help?: string;
tags?: string[];
group?: string;
order?: number;
advanced?: boolean;

View File

@@ -9,7 +9,7 @@ import {
type JsonSchema,
} from "./config-form.shared.ts";
const META_KEYS = new Set(["title", "description", "default", "nullable"]);
const META_KEYS = new Set(["title", "description", "default", "nullable", "tags", "x-tags"]);
function isAnySchema(schema: JsonSchema): boolean {
const keys = Object.keys(schema ?? {}).filter((key) => !META_KEYS.has(key));
@@ -94,6 +94,234 @@ const icons = {
`,
};
type FieldMeta = {
label: string;
help?: string;
tags: string[];
};
export type ConfigSearchCriteria = {
text: string;
tags: string[];
};
function hasSearchCriteria(criteria: ConfigSearchCriteria | undefined): boolean {
return Boolean(criteria && (criteria.text.length > 0 || criteria.tags.length > 0));
}
export function parseConfigSearchQuery(query: string): ConfigSearchCriteria {
const tags: string[] = [];
const seen = new Set<string>();
const raw = query.trim();
const stripped = raw.replace(/(^|\s)tag:([^\s]+)/gi, (_, leading: string, token: string) => {
const normalized = token.trim().toLowerCase();
if (normalized && !seen.has(normalized)) {
seen.add(normalized);
tags.push(normalized);
}
return leading;
});
return {
text: stripped.trim().toLowerCase(),
tags,
};
}
function normalizeTags(raw: unknown): string[] {
if (!Array.isArray(raw)) {
return [];
}
const seen = new Set<string>();
const tags: string[] = [];
for (const value of raw) {
if (typeof value !== "string") {
continue;
}
const tag = value.trim();
if (!tag) {
continue;
}
const key = tag.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
tags.push(tag);
}
return tags;
}
function resolveFieldMeta(
path: Array<string | number>,
schema: JsonSchema,
hints: ConfigUiHints,
): FieldMeta {
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const schemaTags = normalizeTags(schema["x-tags"] ?? schema.tags);
const hintTags = normalizeTags(hint?.tags);
return {
label,
help,
tags: hintTags.length > 0 ? hintTags : schemaTags,
};
}
function matchesText(text: string, candidates: Array<string | undefined>): boolean {
if (!text) {
return true;
}
for (const candidate of candidates) {
if (candidate && candidate.toLowerCase().includes(text)) {
return true;
}
}
return false;
}
function matchesTags(filterTags: string[], fieldTags: string[]): boolean {
if (filterTags.length === 0) {
return true;
}
const normalized = new Set(fieldTags.map((tag) => tag.toLowerCase()));
return filterTags.every((tag) => normalized.has(tag));
}
function matchesNodeSelf(params: {
schema: JsonSchema;
path: Array<string | number>;
hints: ConfigUiHints;
criteria: ConfigSearchCriteria;
}): boolean {
const { schema, path, hints, criteria } = params;
if (!hasSearchCriteria(criteria)) {
return true;
}
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
if (!matchesTags(criteria.tags, tags)) {
return false;
}
if (!criteria.text) {
return true;
}
const pathLabel = path
.filter((segment): segment is string => typeof segment === "string")
.join(".");
const enumText =
schema.enum && schema.enum.length > 0
? schema.enum.map((value) => String(value)).join(" ")
: "";
return matchesText(criteria.text, [
label,
help,
schema.title,
schema.description,
pathLabel,
enumText,
]);
}
export function matchesNodeSearch(params: {
schema: JsonSchema;
value: unknown;
path: Array<string | number>;
hints: ConfigUiHints;
criteria: ConfigSearchCriteria;
}): boolean {
const { schema, value, path, hints, criteria } = params;
if (!hasSearchCriteria(criteria)) {
return true;
}
if (matchesNodeSelf({ schema, path, hints, criteria })) {
return true;
}
const type = schemaType(schema);
if (type === "object") {
const fallback = value ?? schema.default;
const obj =
fallback && typeof fallback === "object" && !Array.isArray(fallback)
? (fallback as Record<string, unknown>)
: {};
const props = schema.properties ?? {};
for (const [propKey, node] of Object.entries(props)) {
if (
matchesNodeSearch({
schema: node,
value: obj[propKey],
path: [...path, propKey],
hints,
criteria,
})
) {
return true;
}
}
const additional = schema.additionalProperties;
if (additional && typeof additional === "object") {
const reserved = new Set(Object.keys(props));
for (const [entryKey, entryValue] of Object.entries(obj)) {
if (reserved.has(entryKey)) {
continue;
}
if (
matchesNodeSearch({
schema: additional,
value: entryValue,
path: [...path, entryKey],
hints,
criteria,
})
) {
return true;
}
}
}
return false;
}
if (type === "array") {
const itemsSchema = Array.isArray(schema.items) ? schema.items[0] : schema.items;
if (!itemsSchema) {
return false;
}
const arr = Array.isArray(value) ? value : Array.isArray(schema.default) ? schema.default : [];
if (arr.length === 0) {
return false;
}
for (let idx = 0; idx < arr.length; idx += 1) {
if (
matchesNodeSearch({
schema: itemsSchema,
value: arr[idx],
path: [...path, idx],
hints,
criteria,
})
) {
return true;
}
}
}
return false;
}
function renderTags(tags: string[]): TemplateResult | typeof nothing {
if (tags.length === 0) {
return nothing;
}
return html`
<div class="cfg-tags">
${tags.map((tag) => html`<span class="cfg-tag">${tag}</span>`)}
</div>
`;
}
export function renderNode(params: {
schema: JsonSchema;
value: unknown;
@@ -102,15 +330,15 @@ export function renderNode(params: {
unsupported: Set<string>;
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult | typeof nothing {
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const type = schemaType(schema);
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const key = pathKey(path);
const criteria = params.searchCriteria;
if (unsupported.has(key)) {
return html`<div class="cfg-field cfg-field--error">
@@ -118,6 +346,13 @@ export function renderNode(params: {
<div class="cfg-field__error">Unsupported schema node. Use Raw mode.</div>
</div>`;
}
if (
criteria &&
hasSearchCriteria(criteria) &&
!matchesNodeSearch({ schema, value, path, hints, criteria })
) {
return nothing;
}
// Handle anyOf/oneOf unions
if (schema.anyOf || schema.oneOf) {
@@ -150,6 +385,7 @@ export function renderNode(params: {
<div class="cfg-field">
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
${renderTags(tags)}
<div class="cfg-segmented">
${literals.map(
(lit) => html`
@@ -215,6 +451,7 @@ export function renderNode(params: {
<div class="cfg-field">
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
${renderTags(tags)}
<div class="cfg-segmented">
${options.map(
(opt) => html`
@@ -258,6 +495,7 @@ export function renderNode(params: {
<div class="cfg-toggle-row__content">
<span class="cfg-toggle-row__label">${label}</span>
${help ? html`<span class="cfg-toggle-row__help">${help}</span>` : nothing}
${renderTags(tags)}
</div>
<div class="cfg-toggle">
<input
@@ -298,14 +536,14 @@ function renderTextInput(params: {
hints: ConfigUiHints;
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
inputType: "text" | "number";
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, disabled, onPatch, inputType } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const isSensitive =
(hint?.sensitive ?? false) && !/^\$\{[^}]*\}$/.test(String(value ?? "").trim());
const placeholder =
@@ -322,6 +560,7 @@ function renderTextInput(params: {
<div class="cfg-field">
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
${renderTags(tags)}
<div class="cfg-input-wrap">
<input
type=${isSensitive ? "password" : inputType}
@@ -375,13 +614,12 @@ function renderNumberInput(params: {
hints: ConfigUiHints;
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const displayValue = value ?? schema.default ?? "";
const numValue = typeof displayValue === "number" ? displayValue : 0;
@@ -389,6 +627,7 @@ function renderNumberInput(params: {
<div class="cfg-field">
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
${renderTags(tags)}
<div class="cfg-number">
<button
type="button"
@@ -425,14 +664,13 @@ function renderSelect(params: {
hints: ConfigUiHints;
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
options: unknown[];
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, disabled, options, onPatch } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const resolvedValue = value ?? schema.default;
const currentIndex = options.findIndex(
(opt) => opt === resolvedValue || String(opt) === String(resolvedValue),
@@ -443,6 +681,7 @@ function renderSelect(params: {
<div class="cfg-field">
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
${renderTags(tags)}
<select
class="cfg-select"
?disabled=${disabled}
@@ -471,12 +710,17 @@ function renderObject(params: {
unsupported: Set<string>;
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const { schema, value, path, hints, unsupported, disabled, onPatch, searchCriteria } = params;
const showLabel = params.showLabel ?? true;
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const selfMatched =
searchCriteria && hasSearchCriteria(searchCriteria)
? matchesNodeSelf({ schema, path, hints, criteria: searchCriteria })
: false;
const childSearchCriteria = selfMatched ? undefined : searchCriteria;
const fallback = value ?? schema.default;
const obj =
@@ -509,6 +753,7 @@ function renderObject(params: {
hints,
unsupported,
disabled,
searchCriteria: childSearchCriteria,
onPatch,
}),
)}
@@ -522,6 +767,7 @@ function renderObject(params: {
unsupported,
disabled,
reservedKeys: reserved,
searchCriteria: childSearchCriteria,
onPatch,
})
: nothing
@@ -537,11 +783,22 @@ function renderObject(params: {
`;
}
if (!showLabel) {
return html`
<div class="cfg-fields cfg-fields--inline">
${fields}
</div>
`;
}
// Nested objects get collapsible treatment
return html`
<details class="cfg-object" open>
<details class="cfg-object" ?open=${path.length <= 2}>
<summary class="cfg-object__header">
<span class="cfg-object__title">${label}</span>
<span class="cfg-object__title-wrap">
<span class="cfg-object__title">${label}</span>
${renderTags(tags)}
</span>
<span class="cfg-object__chevron">${icons.chevronDown}</span>
</summary>
${help ? html`<div class="cfg-object__help">${help}</div>` : nothing}
@@ -560,13 +817,17 @@ function renderArray(params: {
unsupported: Set<string>;
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
const { schema, value, path, hints, unsupported, disabled, onPatch, searchCriteria } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const selfMatched =
searchCriteria && hasSearchCriteria(searchCriteria)
? matchesNodeSelf({ schema, path, hints, criteria: searchCriteria })
: false;
const childSearchCriteria = selfMatched ? undefined : searchCriteria;
const itemsSchema = Array.isArray(schema.items) ? schema.items[0] : schema.items;
if (!itemsSchema) {
@@ -583,7 +844,10 @@ function renderArray(params: {
return html`
<div class="cfg-array">
<div class="cfg-array__header">
${showLabel ? html`<span class="cfg-array__label">${label}</span>` : nothing}
<div class="cfg-array__title">
${showLabel ? html`<span class="cfg-array__label">${label}</span>` : nothing}
${renderTags(tags)}
</div>
<span class="cfg-array__count">${arr.length} item${arr.length !== 1 ? "s" : ""}</span>
<button
type="button"
@@ -634,6 +898,7 @@ function renderArray(params: {
hints,
unsupported,
disabled,
searchCriteria: childSearchCriteria,
showLabel: false,
onPatch,
})}
@@ -656,11 +921,34 @@ function renderMapField(params: {
unsupported: Set<string>;
disabled: boolean;
reservedKeys: Set<string>;
searchCriteria?: ConfigSearchCriteria;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, unsupported, disabled, reservedKeys, onPatch } = params;
const {
schema,
value,
path,
hints,
unsupported,
disabled,
reservedKeys,
onPatch,
searchCriteria,
} = params;
const anySchema = isAnySchema(schema);
const entries = Object.entries(value ?? {}).filter(([key]) => !reservedKeys.has(key));
const visibleEntries =
searchCriteria && hasSearchCriteria(searchCriteria)
? entries.filter(([key, entryValue]) =>
matchesNodeSearch({
schema,
value: entryValue,
path: [...path, key],
hints,
criteria: searchCriteria,
}),
)
: entries;
return html`
<div class="cfg-map">
@@ -688,38 +976,53 @@ function renderMapField(params: {
</div>
${
entries.length === 0
visibleEntries.length === 0
? html`
<div class="cfg-map__empty">No custom entries.</div>
`
: html`
<div class="cfg-map__items">
${entries.map(([key, entryValue]) => {
${visibleEntries.map(([key, entryValue]) => {
const valuePath = [...path, key];
const fallback = jsonValue(entryValue);
return html`
<div class="cfg-map__item">
<div class="cfg-map__item-key">
<input
type="text"
class="cfg-input cfg-input--sm"
placeholder="Key"
.value=${key}
<div class="cfg-map__item-header">
<div class="cfg-map__item-key">
<input
type="text"
class="cfg-input cfg-input--sm"
placeholder="Key"
.value=${key}
?disabled=${disabled}
@change=${(e: Event) => {
const nextKey = (e.target as HTMLInputElement).value.trim();
if (!nextKey || nextKey === key) {
return;
}
const next = { ...value };
if (nextKey in next) {
return;
}
next[nextKey] = next[key];
delete next[key];
onPatch(path, next);
}}
/>
</div>
<button
type="button"
class="cfg-map__item-remove"
title="Remove entry"
?disabled=${disabled}
@change=${(e: Event) => {
const nextKey = (e.target as HTMLInputElement).value.trim();
if (!nextKey || nextKey === key) {
return;
}
@click=${() => {
const next = { ...value };
if (nextKey in next) {
return;
}
next[nextKey] = next[key];
delete next[key];
onPatch(path, next);
}}
/>
>
${icons.trash}
</button>
</div>
<div class="cfg-map__item-value">
${
@@ -753,24 +1056,12 @@ function renderMapField(params: {
hints,
unsupported,
disabled,
searchCriteria,
showLabel: false,
onPatch,
})
}
</div>
<button
type="button"
class="cfg-map__item-remove"
title="Remove entry"
?disabled=${disabled}
@click=${() => {
const next = { ...value };
delete next[key];
onPatch(path, next);
}}
>
${icons.trash}
</button>
</div>
`;
})}

View File

@@ -1,7 +1,7 @@
import { html, nothing } from "lit";
import { icons } from "../icons.ts";
import type { ConfigUiHints } from "../types.ts";
import { renderNode } from "./config-form.node.ts";
import { matchesNodeSearch, parseConfigSearchQuery, renderNode } from "./config-form.node.ts";
import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared.ts";
export type ConfigFormProps = {
@@ -278,20 +278,27 @@ function getSectionIcon(key: string) {
return sectionIcons[key as keyof typeof sectionIcons] ?? sectionIcons.default;
}
function matchesSearch(key: string, schema: JsonSchema, query: string): boolean {
if (!query) {
function matchesSearch(params: {
key: string;
schema: JsonSchema;
sectionValue: unknown;
uiHints: ConfigUiHints;
query: string;
}): boolean {
if (!params.query) {
return true;
}
const q = query.toLowerCase();
const meta = SECTION_META[key];
const criteria = parseConfigSearchQuery(params.query);
const q = criteria.text;
const meta = SECTION_META[params.key];
// Check key name
if (key.toLowerCase().includes(q)) {
if (q && params.key.toLowerCase().includes(q)) {
return true;
}
// Check label and description
if (meta) {
if (q && meta) {
if (meta.label.toLowerCase().includes(q)) {
return true;
}
@@ -300,56 +307,13 @@ function matchesSearch(key: string, schema: JsonSchema, query: string): boolean
}
}
return schemaMatches(schema, q);
}
function schemaMatches(schema: JsonSchema, query: string): boolean {
if (schema.title?.toLowerCase().includes(query)) {
return true;
}
if (schema.description?.toLowerCase().includes(query)) {
return true;
}
if (schema.enum?.some((value) => String(value).toLowerCase().includes(query))) {
return true;
}
if (schema.properties) {
for (const [propKey, propSchema] of Object.entries(schema.properties)) {
if (propKey.toLowerCase().includes(query)) {
return true;
}
if (schemaMatches(propSchema, query)) {
return true;
}
}
}
if (schema.items) {
const items = Array.isArray(schema.items) ? schema.items : [schema.items];
for (const item of items) {
if (item && schemaMatches(item, query)) {
return true;
}
}
}
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
if (schemaMatches(schema.additionalProperties, query)) {
return true;
}
}
const unions = schema.anyOf ?? schema.oneOf ?? schema.allOf;
if (unions) {
for (const entry of unions) {
if (entry && schemaMatches(entry, query)) {
return true;
}
}
}
return false;
return matchesNodeSearch({
schema: params.schema,
value: params.sectionValue,
path: [params.key],
hints: params.uiHints,
criteria,
});
}
export function renderConfigForm(props: ConfigFormProps) {
@@ -368,6 +332,7 @@ export function renderConfigForm(props: ConfigFormProps) {
const unsupported = new Set(props.unsupportedPaths ?? []);
const properties = schema.properties;
const searchQuery = props.searchQuery ?? "";
const searchCriteria = parseConfigSearchQuery(searchQuery);
const activeSection = props.activeSection;
const activeSubsection = props.activeSubsection ?? null;
@@ -384,7 +349,16 @@ export function renderConfigForm(props: ConfigFormProps) {
if (activeSection && key !== activeSection) {
return false;
}
if (searchQuery && !matchesSearch(key, node, searchQuery)) {
if (
searchQuery &&
!matchesSearch({
key,
schema: node,
sectionValue: value[key],
uiHints: props.uiHints,
query: searchQuery,
})
) {
return false;
}
return true;
@@ -456,6 +430,7 @@ export function renderConfigForm(props: ConfigFormProps) {
unsupported,
disabled: props.disabled ?? false,
showLabel: false,
searchCriteria,
onPatch: props.onPatch,
})}
</div>
@@ -490,6 +465,7 @@ export function renderConfigForm(props: ConfigFormProps) {
unsupported,
disabled: props.disabled ?? false,
showLabel: false,
searchCriteria,
onPatch: props.onPatch,
})}
</div>

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { matchesNodeSearch, parseConfigSearchQuery } from "./config-form.node.ts";
const schema = {
type: "object",
properties: {
gateway: {
type: "object",
properties: {
auth: {
type: "object",
properties: {
token: { type: "string" },
},
},
},
},
mode: {
type: "string",
enum: ["off", "token"],
},
},
};
describe("config form search", () => {
it("parses tag-prefixed query terms", () => {
const parsed = parseConfigSearchQuery("token tag:security tag:Auth");
expect(parsed.text).toBe("token");
expect(parsed.tags).toEqual(["security", "auth"]);
});
it("matches fields by tag through ui hints", () => {
const parsed = parseConfigSearchQuery("tag:security");
const matched = matchesNodeSearch({
schema: schema.properties.gateway,
value: {},
path: ["gateway"],
hints: {
"gateway.auth.token": { tags: ["security", "secret"] },
},
criteria: parsed,
});
expect(matched).toBe(true);
});
it("requires text and tag when combined", () => {
const positive = matchesNodeSearch({
schema: schema.properties.gateway,
value: {},
path: ["gateway"],
hints: {
"gateway.auth.token": { tags: ["security"] },
},
criteria: parseConfigSearchQuery("token tag:security"),
});
expect(positive).toBe(true);
const negative = matchesNodeSearch({
schema: schema.properties.gateway,
value: {},
path: ["gateway"],
hints: {
"gateway.auth.token": { tags: ["security"] },
},
criteria: parseConfigSearchQuery("mode tag:security"),
});
expect(negative).toBe(false);
});
});

View File

@@ -4,6 +4,8 @@ export type JsonSchema = {
type?: string | string[];
title?: string;
description?: string;
tags?: string[];
"x-tags"?: string[];
properties?: Record<string, JsonSchema>;
items?: JsonSchema | JsonSchema[];
additionalProperties?: JsonSchema | boolean;

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import {
appendTagFilter,
getTagFilters,
hasTagFilter,
removeTagFilter,
replaceTagFilters,
toggleTagFilter,
} from "./config-search.ts";
describe("config search tag helper", () => {
it("adds a tag when query is empty", () => {
expect(appendTagFilter("", "security")).toBe("tag:security");
});
it("appends a tag to existing text query", () => {
expect(appendTagFilter("token", "security")).toBe("token tag:security");
});
it("deduplicates existing tag filters case-insensitively", () => {
expect(appendTagFilter("token tag:Security", "security")).toBe("token tag:Security");
});
it("detects exact tag terms", () => {
expect(hasTagFilter("tag:security token", "security")).toBe(true);
expect(hasTagFilter("tag:security-hard token", "security")).toBe(false);
});
it("removes only the selected active tag", () => {
expect(removeTagFilter("token tag:security tag:auth", "security")).toBe("token tag:auth");
});
it("toggle removes active tag and keeps text", () => {
expect(toggleTagFilter("token tag:security", "security")).toBe("token");
});
it("toggle adds missing tag", () => {
expect(toggleTagFilter("token", "channels")).toBe("token tag:channels");
});
it("extracts unique normalized tags from query", () => {
expect(getTagFilters("token tag:Security tag:auth tag:security")).toEqual(["security", "auth"]);
});
it("replaces only tag filters and preserves free text", () => {
expect(replaceTagFilters("token tag:security mode", ["auth", "channels"])).toBe(
"token mode tag:auth tag:channels",
);
});
});

View File

@@ -0,0 +1,92 @@
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function normalizeTag(tag: string): string {
return tag.trim().toLowerCase();
}
export function getTagFilters(query: string): string[] {
const seen = new Set<string>();
const tags: string[] = [];
const pattern = /(^|\s)tag:([^\s]+)/gi;
const raw = query.trim();
let match: RegExpExecArray | null = pattern.exec(raw);
while (match) {
const normalized = normalizeTag(match[2] ?? "");
if (normalized && !seen.has(normalized)) {
seen.add(normalized);
tags.push(normalized);
}
match = pattern.exec(raw);
}
return tags;
}
export function hasTagFilter(query: string, tag: string): boolean {
const normalizedTag = normalizeTag(tag);
if (!normalizedTag) {
return false;
}
const pattern = new RegExp(`(^|\\s)tag:${escapeRegExp(normalizedTag)}(?=\\s|$)`, "i");
return pattern.test(query.trim());
}
export function appendTagFilter(query: string, tag: string): string {
const normalizedTag = normalizeTag(tag);
const trimmed = query.trim();
if (!normalizedTag) {
return trimmed;
}
if (!trimmed) {
return `tag:${normalizedTag}`;
}
if (hasTagFilter(trimmed, normalizedTag)) {
return trimmed;
}
return `${trimmed} tag:${normalizedTag}`;
}
export function removeTagFilter(query: string, tag: string): string {
const normalizedTag = normalizeTag(tag);
const trimmed = query.trim();
if (!normalizedTag || !trimmed) {
return trimmed;
}
const pattern = new RegExp(`(^|\\s)tag:${escapeRegExp(normalizedTag)}(?=\\s|$)`, "ig");
return trimmed.replace(pattern, " ").replace(/\s+/g, " ").trim();
}
export function replaceTagFilters(query: string, tags: readonly string[]): string {
const uniqueTags: string[] = [];
const seen = new Set<string>();
for (const tag of tags) {
const normalized = normalizeTag(tag);
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
uniqueTags.push(normalized);
}
const trimmed = query.trim();
const withoutTags = trimmed
.replace(/(^|\s)tag:([^\s]+)/gi, " ")
.replace(/\s+/g, " ")
.trim();
const tagTokens = uniqueTags.map((tag) => `tag:${tag}`).join(" ");
if (withoutTags && tagTokens) {
return `${withoutTags} ${tagTokens}`;
}
if (withoutTags) {
return withoutTags;
}
return tagTokens;
}
export function toggleTagFilter(query: string, tag: string): string {
if (hasTagFilter(query, tag)) {
return removeTagFilter(query, tag);
}
return appendTagFilter(query, tag);
}

View File

@@ -198,4 +198,35 @@ describe("config view", () => {
input.dispatchEvent(new Event("input", { bubbles: true }));
expect(onSearchChange).toHaveBeenCalledWith("gateway");
});
it("shows all tag options in compact tag picker", () => {
const container = document.createElement("div");
render(renderConfig(baseProps()), container);
const options = Array.from(container.querySelectorAll(".config-search__tag-option")).map(
(option) => option.textContent?.trim(),
);
expect(options).toContain("tag:security");
expect(options).toContain("tag:advanced");
expect(options).toHaveLength(15);
});
it("updates search query when toggling a tag option", () => {
const container = document.createElement("div");
const onSearchChange = vi.fn();
render(
renderConfig({
...baseProps(),
onSearchChange,
}),
container,
);
const option = container.querySelector<HTMLButtonElement>(
'.config-search__tag-option[data-tag="security"]',
);
expect(option).toBeTruthy();
option?.click();
expect(onSearchChange).toHaveBeenCalledWith("tag:security");
});
});

View File

@@ -2,6 +2,7 @@ import { html, nothing } from "lit";
import type { ConfigUiHints } from "../types.ts";
import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared.ts";
import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form.ts";
import { getTagFilters, replaceTagFilters } from "./config-search.ts";
export type ConfigProps = {
raw: string;
@@ -34,6 +35,24 @@ export type ConfigProps = {
onUpdate: () => void;
};
const TAG_SEARCH_PRESETS = [
"security",
"auth",
"network",
"access",
"privacy",
"observability",
"performance",
"reliability",
"storage",
"models",
"media",
"automation",
"channels",
"tools",
"advanced",
] as const;
// SVG Icons for sidebar (Lucide-style)
const sidebarIcons = {
all: html`
@@ -443,6 +462,7 @@ export function renderConfig(props: ConfigProps) {
hasChanges &&
(props.formMode === "raw" ? true : canSaveForm);
const canUpdate = props.connected && !props.applying && !props.updating;
const selectedTags = new Set(getTagFilters(props.searchQuery));
return html`
<div class="config-layout">
@@ -460,35 +480,91 @@ export function renderConfig(props: ConfigProps) {
<!-- Search -->
<div class="config-search">
<svg
class="config-search__icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="M21 21l-4.35-4.35"></path>
</svg>
<input
type="text"
class="config-search__input"
placeholder="Search settings..."
.value=${props.searchQuery}
@input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)}
/>
${
props.searchQuery
? html`
<button
class="config-search__clear"
@click=${() => props.onSearchChange("")}
>
×
</button>
`
: nothing
}
<div class="config-search__input-row">
<svg
class="config-search__icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="M21 21l-4.35-4.35"></path>
</svg>
<input
type="text"
class="config-search__input"
placeholder="Search settings..."
.value=${props.searchQuery}
@input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)}
/>
${
props.searchQuery
? html`
<button
class="config-search__clear"
@click=${() => props.onSearchChange("")}
>
×
</button>
`
: nothing
}
</div>
<div class="config-search__hint">
<span class="config-search__hint-label" id="config-tag-filter-label">Tag filters:</span>
<details class="config-search__tag-picker">
<summary class="config-search__tag-trigger" aria-labelledby="config-tag-filter-label">
${
selectedTags.size === 0
? html`
<span class="config-search__tag-placeholder">Add tags</span>
`
: html`
<div class="config-search__tag-chips">
${Array.from(selectedTags)
.slice(0, 2)
.map(
(tag) =>
html`<span class="config-search__tag-chip">tag:${tag}</span>`,
)}
${
selectedTags.size > 2
? html`
<span class="config-search__tag-chip config-search__tag-chip--count"
>+${selectedTags.size - 2}</span
>
`
: nothing
}
</div>
`
}
<span class="config-search__tag-caret" aria-hidden="true">▾</span>
</summary>
<div class="config-search__tag-menu">
${TAG_SEARCH_PRESETS.map((tag) => {
const active = selectedTags.has(tag);
return html`
<button
type="button"
class="config-search__tag-option ${active ? "active" : ""}"
data-tag="${tag}"
aria-pressed=${active ? "true" : "false"}
@click=${() => {
const nextTags = active
? Array.from(selectedTags).filter((value) => value !== tag)
: [...selectedTags, tag];
props.onSearchChange(replaceTagFilters(props.searchQuery, nextTags));
}}
>
tag:${tag}
</button>
`;
})}
</div>
</details>
</div>
</div>
<!-- Section nav -->