Review labels widget (#22664)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled

* add review labels widget

* register widget and add to review section

* i18n

* add border to switches widget

* padding tweaks

* don't show audio labels if audio is not enabled

* add docs links

* ability to add custom labels to review

* add hint for empty selection in review labels and SwitchesWidget

* language consistency
This commit is contained in:
Josh Hawkins
2026-03-27 08:45:50 -06:00
committed by GitHub
parent 1a01513223
commit c35cee2d2f
8 changed files with 182 additions and 13 deletions
@@ -1,6 +1,6 @@
// Generic Switches Widget - Reusable component for selecting from any list of entities
import { WidgetProps } from "@rjsf/utils";
import { useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -43,6 +43,10 @@ export type SwitchesWidgetOptions = {
listClassName?: string;
/** Enable search input to filter the list */
enableSearch?: boolean;
/** Allow users to add custom entries not in the predefined list */
allowCustomEntries?: boolean;
/** i18n key for a hint shown when no entities are selected */
emptySelectionHintKey?: string;
};
function normalizeValue(value: unknown): string[] {
@@ -122,20 +126,51 @@ export function SwitchesWidget(props: WidgetProps) {
[props.options],
);
const allowCustomEntries = useMemo(
() => props.options?.allowCustomEntries as boolean | undefined,
[props.options],
);
const emptySelectionHintKey = useMemo(
() => props.options?.emptySelectionHintKey as string | undefined,
[props.options],
);
const selectedEntities = useMemo(() => normalizeValue(value), [value]);
const [isOpen, setIsOpen] = useState(selectedEntities.length > 0);
const [searchTerm, setSearchTerm] = useState("");
const [customEntries, setCustomEntries] = useState<string[]>([]);
const [customInput, setCustomInput] = useState("");
const allEntities = useMemo(() => {
if (customEntries.length === 0) {
return availableEntities;
}
const merged = new Set([...availableEntities, ...customEntries]);
return [...merged].sort();
}, [availableEntities, customEntries]);
const filteredEntities = useMemo(() => {
if (!enableSearch || !searchTerm.trim()) {
return availableEntities;
return allEntities;
}
const term = searchTerm.toLowerCase();
return availableEntities.filter((entity) => {
return allEntities.filter((entity) => {
const displayLabel = getDisplayLabel(entity, context);
return displayLabel.toLowerCase().includes(term);
});
}, [availableEntities, searchTerm, enableSearch, getDisplayLabel, context]);
}, [allEntities, searchTerm, enableSearch, getDisplayLabel, context]);
const addCustomEntry = useCallback(() => {
const trimmed = customInput.trim().toLowerCase();
if (!trimmed || allEntities.includes(trimmed)) {
setCustomInput("");
return;
}
setCustomEntries((prev) => [...prev, trimmed]);
onChange([...selectedEntities, trimmed]);
setCustomInput("");
}, [customInput, allEntities, selectedEntities, onChange]);
const toggleEntity = (entity: string, enabled: boolean) => {
if (enabled) {
@@ -163,7 +198,7 @@ export function SwitchesWidget(props: WidgetProps) {
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div className="space-y-3">
<div className="space-y-2">
<CollapsibleTrigger asChild>
<Button
type="button"
@@ -180,8 +215,14 @@ export function SwitchesWidget(props: WidgetProps) {
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="rounded-lg bg-secondary p-2 pr-0 md:max-w-md">
{availableEntities.length === 0 ? (
{emptySelectionHintKey && selectedEntities.length === 0 && t && (
<div className="mt-0 pb-2 text-sm text-success">
{t(emptySelectionHintKey, { ns: namespace })}
</div>
)}
<CollapsibleContent className="rounded-lg border border-input bg-secondary pb-1 pr-0 pt-2 md:max-w-md">
{allEntities.length === 0 && !allowCustomEntries ? (
<div className="text-sm text-muted-foreground">{emptyMessage}</div>
) : (
<>
@@ -223,6 +264,26 @@ export function SwitchesWidget(props: WidgetProps) {
);
})}
</div>
{allowCustomEntries && !disabled && !readonly && (
<div className="mx-2 mt-2 pb-1">
<Input
type="text"
placeholder={t?.("configForm.addCustomLabel", {
ns: "views/settings",
defaultValue: "Add custom label...",
})}
value={customInput}
onChange={(e) => setCustomInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addCustomEntry();
}
}}
onBlur={addCustomEntry}
/>
</div>
)}
</>
)}
</CollapsibleContent>