mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -286,6 +286,7 @@ export type ConfigSnapshot = {
|
||||
export type ConfigUiHint = {
|
||||
label?: string;
|
||||
help?: string;
|
||||
tags?: string[];
|
||||
group?: string;
|
||||
order?: number;
|
||||
advanced?: boolean;
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
69
ui/src/ui/views/config-form.search.node.test.ts
Normal file
69
ui/src/ui/views/config-form.search.node.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
50
ui/src/ui/views/config-search.node.test.ts
Normal file
50
ui/src/ui/views/config-search.node.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
92
ui/src/ui/views/config-search.ts
Normal file
92
ui/src/ui/views/config-search.ts
Normal 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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user