add config messages to sections and fields

This commit is contained in:
Josh Hawkins 2026-03-29 11:13:50 -05:00
parent 148e11afc5
commit 41c760345e
5 changed files with 234 additions and 1 deletions

View File

@ -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 <LuInfo className="size-4" />;
case "warning":
return <LuTriangleAlert className="size-4" />;
case "error":
return <LuCircleAlert className="size-4" />;
default:
return <LuInfo className="size-4" />;
}
}
type ConfigFieldMessageProps = {
messageKey: string;
severity: string;
};
export function ConfigFieldMessage({
messageKey,
severity,
}: ConfigFieldMessageProps) {
const { t } = useTranslation("views/settings");
return (
<Alert
variant={severityVariantMap[severity as MessageSeverity] ?? "info"}
className="flex items-center [&>svg+div]:translate-y-0 [&>svg]:static [&>svg~*]:pl-2"
>
<SeverityIcon severity={severity} />
<AlertDescription>{t(messageKey)}</AlertDescription>
</Alert>
);
}

View File

@ -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 <LuInfo className="size-4" />;
case "warning":
return <LuTriangleAlert className="size-4" />;
case "error":
return <LuCircleAlert className="size-4" />;
}
}
type ConfigMessageBannerProps = {
messages: ConditionalMessage[];
};
export function ConfigMessageBanner({ messages }: ConfigMessageBannerProps) {
const { t } = useTranslation("views/settings");
if (messages.length === 0) return null;
return (
<div className="space-y-2">
{messages.map((msg) => (
<Alert
key={msg.key}
variant={severityVariantMap[msg.severity]}
className="flex items-center [&>svg]:static [&>svg~*]:pl-2 [&>svg+div]:translate-y-0"
>
<SeverityIcon severity={msg.severity} />
<AlertDescription>{t(msg.messageKey)}</AlertDescription>
</Alert>
))}
</div>
);
}

View File

@ -71,6 +71,13 @@ import {
} from "@/utils/configUtil"; } from "@/utils/configUtil";
import RestartDialog from "@/components/overlay/dialog/RestartDialog"; import RestartDialog from "@/components/overlay/dialog/RestartDialog";
import { useRestart } from "@/api/ws"; 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 { export interface SectionConfig {
/** Field ordering within the section */ /** Field ordering within the section */
@ -100,6 +107,10 @@ export interface SectionConfig {
formData: unknown, formData: unknown,
errors: FormValidation, errors: FormValidation,
) => 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 { export interface BaseSectionProps {
@ -536,6 +547,57 @@ export function ConfigSection({
const currentFormData = pendingData || formData; const currentFormData = pendingData || formData;
const effectiveBaselineFormData = baselineSnapshot; const effectiveBaselineFormData = baselineSnapshot;
// Build context for conditional messages
const messageContext = useMemo<MessageConditionContext | undefined>(() => {
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<string, unknown> | 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(() => { const currentOverrides = useMemo(() => {
if (!currentFormData || typeof currentFormData !== "object") { if (!currentFormData || typeof currentFormData !== "object") {
return undefined; return undefined;
@ -874,6 +936,7 @@ export function ConfigSection({
const sectionContent = ( const sectionContent = (
<div className="space-y-6"> <div className="space-y-6">
<ConfigMessageBanner messages={activeMessages} />
<ConfigForm <ConfigForm
key={formKey} key={formKey}
schema={modifiedSchema} schema={modifiedSchema}
@ -885,7 +948,7 @@ export function ConfigSection({
hiddenFields={effectiveHiddenFields} hiddenFields={effectiveHiddenFields}
advancedFields={sectionConfig.advancedFields} advancedFields={sectionConfig.advancedFields}
liveValidate={sectionConfig.liveValidate} liveValidate={sectionConfig.liveValidate}
uiSchema={sectionConfig.uiSchema} uiSchema={effectiveUiSchema}
disabled={disabled || isSaving} disabled={disabled || isSaving}
readonly={readonly} readonly={readonly}
showSubmit={false} showSubmit={false}

View File

@ -28,6 +28,7 @@ import {
} from "../utils"; } from "../utils";
import { normalizeOverridePath } from "../utils/overrides"; import { normalizeOverridePath } from "../utils/overrides";
import get from "lodash/get"; import get from "lodash/get";
import { ConfigFieldMessage } from "../../ConfigFieldMessage";
import isEqual from "lodash/isEqual"; import isEqual from "lodash/isEqual";
import { SPLIT_ROW_CLASS_NAME } from "@/components/card/SettingsGroupCard"; import { SPLIT_ROW_CLASS_NAME } from "@/components/card/SettingsGroupCard";
@ -382,6 +383,46 @@ export function FieldTemplate(props: FieldTemplateProps) {
const beforeContent = renderCustom(beforeSpec); const beforeContent = renderCustom(beforeSpec);
const afterContent = renderCustom(afterSpec); const afterContent = renderCustom(afterSpec);
// Render conditional field messages from ui:messages
const fieldMessageSpecs = uiSchema?.["ui:messages"] as
| Array<{
key: string;
messageKey: string;
severity: string;
position?: string;
}>
| undefined;
const beforeMessages = fieldMessageSpecs?.filter(
(m) => (m.position ?? "before") === "before",
);
const afterMessages = fieldMessageSpecs?.filter(
(m) => m.position === "after",
);
const beforeMessagesContent =
beforeMessages && beforeMessages.length > 0 ? (
<div className="space-y-2">
{beforeMessages.map((m) => (
<ConfigFieldMessage
key={m.key}
messageKey={m.messageKey}
severity={m.severity}
/>
))}
</div>
) : null;
const afterMessagesContent =
afterMessages && afterMessages.length > 0 ? (
<div className="space-y-2">
{afterMessages.map((m) => (
<ConfigFieldMessage
key={m.key}
messageKey={m.messageKey}
severity={m.severity}
/>
))}
</div>
) : null;
const WrapIfAdditionalTemplate = getTemplate( const WrapIfAdditionalTemplate = getTemplate(
"WrapIfAdditionalTemplate", "WrapIfAdditionalTemplate",
registry, registry,
@ -600,6 +641,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
> >
<div className="flex flex-col space-y-6"> <div className="flex flex-col space-y-6">
{beforeContent} {beforeContent}
{beforeMessagesContent}
<div className={cn("space-y-1")} data-field-id={translationPath}> <div className={cn("space-y-1")} data-field-id={translationPath}>
{renderStandardLabel()} {renderStandardLabel()}
{renderFieldLayout()} {renderFieldLayout()}
@ -607,6 +649,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
{errors} {errors}
{help} {help}
</div> </div>
{afterMessagesContent}
{afterContent} {afterContent}
</div> </div>
</WrapIfAdditionalTemplate> </WrapIfAdditionalTemplate>

View File

@ -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 };
}