frigate/web/src/components/config-form/theme/fields/DictAsYamlField.tsx

135 lines
4.0 KiB
TypeScript
Raw Normal View History

import type { FieldPathList, FieldProps } from "@rjsf/utils";
import yaml from "js-yaml";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
2026-04-05 06:32:26 +03:00
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2026-05-09 10:21:51 +03:00
import { ConfigFormContext } from "@/types/configForm";
import { useTranslation } from "react-i18next";
function formatYaml(value: unknown): string {
if (
value == null ||
(typeof value === "object" &&
!Array.isArray(value) &&
Object.keys(value as Record<string, unknown>).length === 0)
) {
return "";
}
try {
return yaml.dump(value, { indent: 2, lineWidth: -1 }).trimEnd();
} catch {
return "";
}
}
function parseYaml(text: string): {
value: Record<string, unknown> | undefined;
error: string | undefined;
} {
const trimmed = text.trim();
if (trimmed === "") {
return { value: {}, error: undefined };
}
try {
const parsed = yaml.load(trimmed);
if (
typeof parsed !== "object" ||
parsed === null ||
Array.isArray(parsed)
) {
return { value: undefined, error: "Must be a YAML mapping" };
}
return { value: parsed as Record<string, unknown>, error: undefined };
} catch (e) {
const msg = e instanceof yaml.YAMLException ? e.reason : "Invalid YAML";
return { value: undefined, error: msg };
}
}
export function DictAsYamlField(props: FieldProps) {
2026-05-09 10:21:51 +03:00
const { formData, onChange, readonly, disabled, idSchema, schema, registry } =
props;
const formContext = registry.formContext as ConfigFormContext | undefined;
const configNamespace =
formContext?.i18nNamespace ??
(formContext?.level === "camera" ? "config/cameras" : "config/global");
const { t: fallbackT } = useTranslation(["common", configNamespace]);
const t = formContext?.t ?? fallbackT;
const emptyPath = useMemo(() => [] as FieldPathList, []);
const fieldPath =
(props as { fieldPathId?: { path?: FieldPathList } }).fieldPathId?.path ??
emptyPath;
const [text, setText] = useState(() => formatYaml(formData));
const [error, setError] = useState<string>();
2026-04-05 06:32:26 +03:00
const focusedRef = useRef(false);
useEffect(() => {
2026-04-05 06:32:26 +03:00
// Only sync from external formData changes, not our own onChange
if (!focusedRef.current) {
setText(formatYaml(formData));
setError(undefined);
}
}, [formData]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const raw = e.target.value;
setText(raw);
const { value, error: parseError } = parseYaml(raw);
setError(parseError);
if (value !== undefined) {
onChange(value, fieldPath);
}
},
[onChange, fieldPath],
);
2026-04-05 06:32:26 +03:00
const handleFocus = useCallback(() => {
focusedRef.current = true;
}, []);
const handleBlur = useCallback(
(_e: React.FocusEvent<HTMLTextAreaElement>) => {
2026-04-05 06:32:26 +03:00
focusedRef.current = false;
// Reformat on blur if valid
const { value } = parseYaml(text);
if (value !== undefined) {
setText(formatYaml(value));
}
},
[text],
);
const id = idSchema?.$id ?? props.name;
2026-05-09 10:21:51 +03:00
const sectionPrefix = formContext?.sectionI18nPrefix;
2026-05-09 10:21:51 +03:00
const title = t(`${sectionPrefix}.${id}.label`) ?? schema.title;
const description =
t(`${sectionPrefix}.${id}.description`) ?? schema.description;
return (
<div className="flex flex-col gap-1.5">
2026-05-09 10:21:51 +03:00
{title && (
<label htmlFor={id} className="text-sm font-medium">
2026-05-09 10:21:51 +03:00
{title}
</label>
)}
<Textarea
id={id}
className={cn("font-mono text-sm", error && "border-destructive")}
value={text}
disabled={disabled || readonly}
placeholder={"key: value"}
rows={Math.max(3, text.split("\n").length + 1)}
onChange={handleChange}
2026-04-05 06:32:26 +03:00
onFocus={handleFocus}
onBlur={handleBlur}
/>
{error && <p className="text-xs text-destructive">{error}</p>}
2026-05-09 10:21:51 +03:00
{description && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
</div>
);
}