mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-03 22:04:53 +03:00
add config messages to sections and fields
This commit is contained in:
parent
148e11afc5
commit
41c760345e
48
web/src/components/config-form/ConfigFieldMessage.tsx
Normal file
48
web/src/components/config-form/ConfigFieldMessage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
web/src/components/config-form/ConfigMessageBanner.tsx
Normal file
52
web/src/components/config-form/ConfigMessageBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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<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(() => {
|
||||
if (!currentFormData || typeof currentFormData !== "object") {
|
||||
return undefined;
|
||||
@ -874,6 +936,7 @@ export function ConfigSection({
|
||||
|
||||
const sectionContent = (
|
||||
<div className="space-y-6">
|
||||
<ConfigMessageBanner messages={activeMessages} />
|
||||
<ConfigForm
|
||||
key={formKey}
|
||||
schema={modifiedSchema}
|
||||
@ -885,7 +948,7 @@ export function ConfigSection({
|
||||
hiddenFields={effectiveHiddenFields}
|
||||
advancedFields={sectionConfig.advancedFields}
|
||||
liveValidate={sectionConfig.liveValidate}
|
||||
uiSchema={sectionConfig.uiSchema}
|
||||
uiSchema={effectiveUiSchema}
|
||||
disabled={disabled || isSaving}
|
||||
readonly={readonly}
|
||||
showSubmit={false}
|
||||
|
||||
@ -28,6 +28,7 @@ import {
|
||||
} from "../utils";
|
||||
import { normalizeOverridePath } from "../utils/overrides";
|
||||
import get from "lodash/get";
|
||||
import { ConfigFieldMessage } from "../../ConfigFieldMessage";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { SPLIT_ROW_CLASS_NAME } from "@/components/card/SettingsGroupCard";
|
||||
|
||||
@ -382,6 +383,46 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
|
||||
const beforeContent = renderCustom(beforeSpec);
|
||||
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(
|
||||
"WrapIfAdditionalTemplate",
|
||||
registry,
|
||||
@ -600,6 +641,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
>
|
||||
<div className="flex flex-col space-y-6">
|
||||
{beforeContent}
|
||||
{beforeMessagesContent}
|
||||
<div className={cn("space-y-1")} data-field-id={translationPath}>
|
||||
{renderStandardLabel()}
|
||||
{renderFieldLayout()}
|
||||
@ -607,6 +649,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
{errors}
|
||||
{help}
|
||||
</div>
|
||||
{afterMessagesContent}
|
||||
{afterContent}
|
||||
</div>
|
||||
</WrapIfAdditionalTemplate>
|
||||
|
||||
27
web/src/hooks/use-config-messages.ts
Normal file
27
web/src/hooks/use-config-messages.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user