diff --git a/web/src/components/config-form/ConfigFieldMessage.tsx b/web/src/components/config-form/ConfigFieldMessage.tsx new file mode 100644 index 000000000..5c0a5c505 --- /dev/null +++ b/web/src/components/config-form/ConfigFieldMessage.tsx @@ -0,0 +1,48 @@ +import { useTranslation } from "react-i18next"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { LuInfo, LuTriangleAlert, LuCircleAlert } from "react-icons/lu"; +import type { MessageSeverity } from "./section-configs/types"; + +const severityVariantMap: Record< + MessageSeverity, + "info" | "warning" | "destructive" +> = { + info: "info", + warning: "warning", + error: "destructive", +}; + +function SeverityIcon({ severity }: { severity: string }) { + switch (severity) { + case "info": + return ; + case "warning": + return ; + case "error": + return ; + default: + return ; + } +} + +type ConfigFieldMessageProps = { + messageKey: string; + severity: string; +}; + +export function ConfigFieldMessage({ + messageKey, + severity, +}: ConfigFieldMessageProps) { + const { t } = useTranslation("views/settings"); + + return ( + + + {t(messageKey)} + + ); +} diff --git a/web/src/components/config-form/ConfigMessageBanner.tsx b/web/src/components/config-form/ConfigMessageBanner.tsx new file mode 100644 index 000000000..15455705d --- /dev/null +++ b/web/src/components/config-form/ConfigMessageBanner.tsx @@ -0,0 +1,52 @@ +import { useTranslation } from "react-i18next"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { LuInfo, LuTriangleAlert, LuCircleAlert } from "react-icons/lu"; +import type { + ConditionalMessage, + MessageSeverity, +} from "./section-configs/types"; + +const severityVariantMap: Record< + MessageSeverity, + "info" | "warning" | "destructive" +> = { + info: "info", + warning: "warning", + error: "destructive", +}; + +function SeverityIcon({ severity }: { severity: MessageSeverity }) { + switch (severity) { + case "info": + return ; + case "warning": + return ; + case "error": + return ; + } +} + +type ConfigMessageBannerProps = { + messages: ConditionalMessage[]; +}; + +export function ConfigMessageBanner({ messages }: ConfigMessageBannerProps) { + const { t } = useTranslation("views/settings"); + + if (messages.length === 0) return null; + + return ( +
+ {messages.map((msg) => ( + + + {t(msg.messageKey)} + + ))} +
+ ); +} diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 8a35f4dff..3dbf1ee50 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -71,6 +71,13 @@ import { } from "@/utils/configUtil"; import RestartDialog from "@/components/overlay/dialog/RestartDialog"; import { useRestart } from "@/api/ws"; +import type { + ConditionalMessage, + FieldConditionalMessage, + MessageConditionContext, +} from "../section-configs/types"; +import { useConfigMessages } from "@/hooks/use-config-messages"; +import { ConfigMessageBanner } from "../ConfigMessageBanner"; export interface SectionConfig { /** Field ordering within the section */ @@ -100,6 +107,10 @@ export interface SectionConfig { formData: unknown, errors: FormValidation, ) => FormValidation; + /** Conditional messages displayed as banners above the section form */ + messages?: ConditionalMessage[]; + /** Conditional messages displayed inline with specific fields */ + fieldMessages?: FieldConditionalMessage[]; } export interface BaseSectionProps { @@ -536,6 +547,57 @@ export function ConfigSection({ const currentFormData = pendingData || formData; const effectiveBaselineFormData = baselineSnapshot; + // Build context for conditional messages + const messageContext = useMemo(() => { + if (!config || !currentFormData) return undefined; + return { + fullConfig: config, + fullCameraConfig: + effectiveLevel === "camera" && cameraName + ? config.cameras?.[cameraName] + : undefined, + level: effectiveLevel, + cameraName, + formData: currentFormData as ConfigSectionData, + }; + }, [config, currentFormData, effectiveLevel, cameraName]); + + const { activeMessages, activeFieldMessages } = useConfigMessages( + sectionConfig.messages, + sectionConfig.fieldMessages, + messageContext, + ); + + // Merge field-level conditional messages into uiSchema + const effectiveUiSchema = useMemo(() => { + if (activeFieldMessages.length === 0) return sectionConfig.uiSchema; + const merged = { ...(sectionConfig.uiSchema ?? {}) }; + for (const msg of activeFieldMessages) { + const fieldKey = msg.field; + const existing = merged[fieldKey] as Record | undefined; + const existingMessages = ((existing?.["ui:messages"] as unknown[]) ?? + []) as Array<{ + key: string; + messageKey: string; + severity: string; + position?: string; + }>; + merged[fieldKey] = { + ...existing, + "ui:messages": [ + ...existingMessages, + { + key: msg.key, + messageKey: msg.messageKey, + severity: msg.severity, + position: msg.position ?? "before", + }, + ], + }; + } + return merged; + }, [sectionConfig.uiSchema, activeFieldMessages]); + const currentOverrides = useMemo(() => { if (!currentFormData || typeof currentFormData !== "object") { return undefined; @@ -874,6 +936,7 @@ export function ConfigSection({ const sectionContent = (
+ + | undefined; + const beforeMessages = fieldMessageSpecs?.filter( + (m) => (m.position ?? "before") === "before", + ); + const afterMessages = fieldMessageSpecs?.filter( + (m) => m.position === "after", + ); + const beforeMessagesContent = + beforeMessages && beforeMessages.length > 0 ? ( +
+ {beforeMessages.map((m) => ( + + ))} +
+ ) : null; + const afterMessagesContent = + afterMessages && afterMessages.length > 0 ? ( +
+ {afterMessages.map((m) => ( + + ))} +
+ ) : null; const WrapIfAdditionalTemplate = getTemplate( "WrapIfAdditionalTemplate", registry, @@ -600,6 +641,7 @@ export function FieldTemplate(props: FieldTemplateProps) { >
{beforeContent} + {beforeMessagesContent}
{renderStandardLabel()} {renderFieldLayout()} @@ -607,6 +649,7 @@ export function FieldTemplate(props: FieldTemplateProps) { {errors} {help}
+ {afterMessagesContent} {afterContent}
diff --git a/web/src/hooks/use-config-messages.ts b/web/src/hooks/use-config-messages.ts new file mode 100644 index 000000000..6c2b43853 --- /dev/null +++ b/web/src/hooks/use-config-messages.ts @@ -0,0 +1,27 @@ +import { useMemo } from "react"; +import type { + ConditionalMessage, + FieldConditionalMessage, + MessageConditionContext, +} from "@/components/config-form/section-configs/types"; + +export function useConfigMessages( + messages: ConditionalMessage[] | undefined, + fieldMessages: FieldConditionalMessage[] | undefined, + context: MessageConditionContext | undefined, +): { + activeMessages: ConditionalMessage[]; + activeFieldMessages: FieldConditionalMessage[]; +} { + const activeMessages = useMemo(() => { + if (!messages || !context) return []; + return messages.filter((msg) => msg.condition(context)); + }, [messages, context]); + + const activeFieldMessages = useMemo(() => { + if (!fieldMessages || !context) return []; + return fieldMessages.filter((msg) => msg.condition(context)); + }, [fieldMessages, context]); + + return { activeMessages, activeFieldMessages }; +}