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