mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-30 11:54:52 +03:00
Add UI config messages framework (#22692)
* add config messages to sections and fields * add alert variants * add messages to types * add detect fps, review, and audio messages * add a basic set of messages * remove emptySelectionHintKey from switches widget use the new messages framework and revert the changes made in #22664
This commit is contained in:
parent
257dae11c1
commit
953d244c52
@ -1433,8 +1433,7 @@
|
|||||||
},
|
},
|
||||||
"reviewLabels": {
|
"reviewLabels": {
|
||||||
"summary": "{{count}} labels selected",
|
"summary": "{{count}} labels selected",
|
||||||
"empty": "No labels available",
|
"empty": "No labels available"
|
||||||
"allNonAlertDetections": "All non-alert activity will be included as detections."
|
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"objectFieldLabel": "{{field}} for {{label}}"
|
"objectFieldLabel": "{{field}} for {{label}}"
|
||||||
@ -1606,5 +1605,38 @@
|
|||||||
"onvif": {
|
"onvif": {
|
||||||
"profileAuto": "Auto",
|
"profileAuto": "Auto",
|
||||||
"profileLoading": "Loading profiles..."
|
"profileLoading": "Loading profiles..."
|
||||||
|
},
|
||||||
|
"configMessages": {
|
||||||
|
"review": {
|
||||||
|
"recordDisabled": "Recording is disabled, review items will not be generated.",
|
||||||
|
"detectDisabled": "Object detection is disabled. Review items require detected objects to categorize alerts and detections.",
|
||||||
|
"allNonAlertDetections": "All non-alert activity will be included as detections."
|
||||||
|
},
|
||||||
|
"audio": {
|
||||||
|
"noAudioRole": "No streams have the audio role defined. You must enable the audio role for audio detection to function."
|
||||||
|
},
|
||||||
|
"audioTranscription": {
|
||||||
|
"audioDetectionDisabled": "Audio detection is not enabled for this camera. Audio transcription requires audio detection to be active."
|
||||||
|
},
|
||||||
|
"detect": {
|
||||||
|
"fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended."
|
||||||
|
},
|
||||||
|
"faceRecognition": {
|
||||||
|
"globalDisabled": "Face recognition is not enabled at the global level. Enable it in global settings for camera-level face recognition to function.",
|
||||||
|
"personNotTracked": "Face recognition requires the 'person' object to be tracked. Ensure 'person' is in the object tracking list."
|
||||||
|
},
|
||||||
|
"lpr": {
|
||||||
|
"globalDisabled": "License plate recognition is not enabled at the global level. Enable it in global settings for camera-level LPR to function.",
|
||||||
|
"vehicleNotTracked": "License plate recognition requires 'car' or 'motorcycle' to be tracked."
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"noRecordRole": "No streams have the record role defined. Recording will not function."
|
||||||
|
},
|
||||||
|
"birdseye": {
|
||||||
|
"objectsModeDetectDisabled": "Birdseye is set to 'objects' mode, but object detection is disabled for this camera. The camera will not appear in Birdseye."
|
||||||
|
},
|
||||||
|
"snapshots": {
|
||||||
|
"detectDisabled": "Object detection is disabled. Snapshots are generated from tracked objects and will not be created."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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="max-w-5xl space-y-2">
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<Alert
|
||||||
|
key={msg.key}
|
||||||
|
variant={severityVariantMap[msg.severity]}
|
||||||
|
className="flex items-center [&>svg+div]:translate-y-0 [&>svg]:static [&>svg~*]:pl-2"
|
||||||
|
>
|
||||||
|
<SeverityIcon severity={msg.severity} />
|
||||||
|
<AlertDescription>{t(msg.messageKey)}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,6 +3,21 @@ import type { SectionConfigOverrides } from "./types";
|
|||||||
const audio: SectionConfigOverrides = {
|
const audio: SectionConfigOverrides = {
|
||||||
base: {
|
base: {
|
||||||
sectionDocs: "/configuration/audio_detectors",
|
sectionDocs: "/configuration/audio_detectors",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
key: "no-audio-role",
|
||||||
|
messageKey: "configMessages.audio.noAudioRole",
|
||||||
|
severity: "warning",
|
||||||
|
condition: (ctx) => {
|
||||||
|
if (ctx.level === "camera" && ctx.fullCameraConfig) {
|
||||||
|
return !ctx.fullCameraConfig.ffmpeg?.inputs?.some((input) =>
|
||||||
|
input.roles?.includes("audio"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
restartRequired: [],
|
restartRequired: [],
|
||||||
fieldOrder: [
|
fieldOrder: [
|
||||||
"enabled",
|
"enabled",
|
||||||
|
|||||||
@ -3,6 +3,19 @@ import type { SectionConfigOverrides } from "./types";
|
|||||||
const audioTranscription: SectionConfigOverrides = {
|
const audioTranscription: SectionConfigOverrides = {
|
||||||
base: {
|
base: {
|
||||||
sectionDocs: "/configuration/audio_detectors#audio-transcription",
|
sectionDocs: "/configuration/audio_detectors#audio-transcription",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
key: "audio-detection-disabled",
|
||||||
|
messageKey: "configMessages.audioTranscription.audioDetectionDisabled",
|
||||||
|
severity: "warning",
|
||||||
|
condition: (ctx) => {
|
||||||
|
if (ctx.level === "camera" && ctx.fullCameraConfig) {
|
||||||
|
return ctx.fullCameraConfig.audio.enabled === false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
restartRequired: [],
|
restartRequired: [],
|
||||||
fieldOrder: ["enabled", "language", "device", "model_size"],
|
fieldOrder: ["enabled", "language", "device", "model_size"],
|
||||||
hiddenFields: ["enabled_in_config", "live_enabled"],
|
hiddenFields: ["enabled_in_config", "live_enabled"],
|
||||||
|
|||||||
@ -3,6 +3,20 @@ import type { SectionConfigOverrides } from "./types";
|
|||||||
const birdseye: SectionConfigOverrides = {
|
const birdseye: SectionConfigOverrides = {
|
||||||
base: {
|
base: {
|
||||||
sectionDocs: "/configuration/birdseye",
|
sectionDocs: "/configuration/birdseye",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
key: "objects-mode-detect-disabled",
|
||||||
|
messageKey: "configMessages.birdseye.objectsModeDetectDisabled",
|
||||||
|
severity: "info",
|
||||||
|
condition: (ctx) => {
|
||||||
|
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
|
||||||
|
return (
|
||||||
|
ctx.formData?.mode === "objects" &&
|
||||||
|
ctx.fullCameraConfig.detect?.enabled === false
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
restartRequired: [],
|
restartRequired: [],
|
||||||
fieldOrder: ["enabled", "mode", "order"],
|
fieldOrder: ["enabled", "mode", "order"],
|
||||||
hiddenFields: [],
|
hiddenFields: [],
|
||||||
|
|||||||
@ -3,6 +3,21 @@ import type { SectionConfigOverrides } from "./types";
|
|||||||
const detect: SectionConfigOverrides = {
|
const detect: SectionConfigOverrides = {
|
||||||
base: {
|
base: {
|
||||||
sectionDocs: "/configuration/camera_specific",
|
sectionDocs: "/configuration/camera_specific",
|
||||||
|
fieldMessages: [
|
||||||
|
{
|
||||||
|
key: "fps-greater-than-five",
|
||||||
|
field: "fps",
|
||||||
|
messageKey: "configMessages.detect.fpsGreaterThanFive",
|
||||||
|
severity: "info",
|
||||||
|
position: "after",
|
||||||
|
condition: (ctx) => {
|
||||||
|
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
|
||||||
|
const detectFps = ctx.formData?.fps as number | undefined;
|
||||||
|
const streamFps = ctx.fullCameraConfig.detect?.fps;
|
||||||
|
return detectFps != null && streamFps != null && detectFps > 5;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
fieldOrder: [
|
fieldOrder: [
|
||||||
"enabled",
|
"enabled",
|
||||||
"width",
|
"width",
|
||||||
|
|||||||
@ -3,6 +3,26 @@ import type { SectionConfigOverrides } from "./types";
|
|||||||
const faceRecognition: SectionConfigOverrides = {
|
const faceRecognition: SectionConfigOverrides = {
|
||||||
base: {
|
base: {
|
||||||
sectionDocs: "/configuration/face_recognition",
|
sectionDocs: "/configuration/face_recognition",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
key: "global-disabled",
|
||||||
|
messageKey: "configMessages.faceRecognition.globalDisabled",
|
||||||
|
severity: "warning",
|
||||||
|
condition: (ctx) => {
|
||||||
|
if (ctx.level !== "camera") return false;
|
||||||
|
return ctx.fullConfig.face_recognition?.enabled === false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "person-not-tracked",
|
||||||
|
messageKey: "configMessages.faceRecognition.personNotTracked",
|
||||||
|
severity: "info",
|
||||||
|
condition: (ctx) => {
|
||||||
|
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
|
||||||
|
return !ctx.fullCameraConfig.objects?.track?.includes("person");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
restartRequired: [],
|
restartRequired: [],
|
||||||
fieldOrder: ["enabled", "min_area"],
|
fieldOrder: ["enabled", "min_area"],
|
||||||
hiddenFields: [],
|
hiddenFields: [],
|
||||||
|
|||||||
@ -3,6 +3,28 @@ import type { SectionConfigOverrides } from "./types";
|
|||||||
const lpr: SectionConfigOverrides = {
|
const lpr: SectionConfigOverrides = {
|
||||||
base: {
|
base: {
|
||||||
sectionDocs: "/configuration/license_plate_recognition",
|
sectionDocs: "/configuration/license_plate_recognition",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
key: "global-disabled",
|
||||||
|
messageKey: "configMessages.lpr.globalDisabled",
|
||||||
|
severity: "warning",
|
||||||
|
condition: (ctx) => {
|
||||||
|
if (ctx.level !== "camera") return false;
|
||||||
|
return ctx.fullConfig.lpr?.enabled === false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "vehicle-not-tracked",
|
||||||
|
messageKey: "configMessages.lpr.vehicleNotTracked",
|
||||||
|
severity: "info",
|
||||||
|
condition: (ctx) => {
|
||||||
|
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
|
||||||
|
if (ctx.fullCameraConfig.type === "lpr") return false;
|
||||||
|
const tracked = ctx.fullCameraConfig.objects?.track ?? [];
|
||||||
|
return !tracked.some((o) => ["car", "motorcycle"].includes(o));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
fieldDocs: {
|
fieldDocs: {
|
||||||
enhancement: "/configuration/license_plate_recognition#enhancement",
|
enhancement: "/configuration/license_plate_recognition#enhancement",
|
||||||
},
|
},
|
||||||
|
|||||||
@ -3,6 +3,19 @@ import type { SectionConfigOverrides } from "./types";
|
|||||||
const record: SectionConfigOverrides = {
|
const record: SectionConfigOverrides = {
|
||||||
base: {
|
base: {
|
||||||
sectionDocs: "/configuration/record",
|
sectionDocs: "/configuration/record",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
key: "no-record-role",
|
||||||
|
messageKey: "configMessages.record.noRecordRole",
|
||||||
|
severity: "warning",
|
||||||
|
condition: (ctx) => {
|
||||||
|
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
|
||||||
|
return !ctx.fullCameraConfig.ffmpeg?.inputs?.some((i) =>
|
||||||
|
i.roles?.includes("record"),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
restartRequired: [],
|
restartRequired: [],
|
||||||
fieldOrder: [
|
fieldOrder: [
|
||||||
"enabled",
|
"enabled",
|
||||||
|
|||||||
@ -3,6 +3,45 @@ import type { SectionConfigOverrides } from "./types";
|
|||||||
const review: SectionConfigOverrides = {
|
const review: SectionConfigOverrides = {
|
||||||
base: {
|
base: {
|
||||||
sectionDocs: "/configuration/review",
|
sectionDocs: "/configuration/review",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
key: "record-disabled",
|
||||||
|
messageKey: "configMessages.review.recordDisabled",
|
||||||
|
severity: "warning",
|
||||||
|
condition: (ctx) => {
|
||||||
|
if (ctx.level === "camera" && ctx.fullCameraConfig) {
|
||||||
|
return ctx.fullCameraConfig.record.enabled === false;
|
||||||
|
}
|
||||||
|
return ctx.fullConfig.record?.enabled === false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "detect-disabled",
|
||||||
|
messageKey: "configMessages.review.detectDisabled",
|
||||||
|
severity: "info",
|
||||||
|
condition: (ctx) => {
|
||||||
|
if (ctx.level === "camera" && ctx.fullCameraConfig) {
|
||||||
|
return ctx.fullCameraConfig.detect?.enabled === false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fieldMessages: [
|
||||||
|
{
|
||||||
|
key: "detections-all-non-alert",
|
||||||
|
field: "detections.labels",
|
||||||
|
messageKey: "configMessages.review.allNonAlertDetections",
|
||||||
|
severity: "info",
|
||||||
|
position: "after",
|
||||||
|
condition: (ctx) => {
|
||||||
|
const labels = (
|
||||||
|
ctx.formData?.detections as Record<string, unknown> | undefined
|
||||||
|
)?.labels;
|
||||||
|
return !Array.isArray(labels) || labels.length === 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
fieldDocs: {
|
fieldDocs: {
|
||||||
"alerts.labels": "/configuration/review/#alerts-and-detections",
|
"alerts.labels": "/configuration/review/#alerts-and-detections",
|
||||||
"detections.labels": "/configuration/review/#alerts-and-detections",
|
"detections.labels": "/configuration/review/#alerts-and-detections",
|
||||||
@ -35,8 +74,6 @@ const review: SectionConfigOverrides = {
|
|||||||
"ui:widget": "reviewLabels",
|
"ui:widget": "reviewLabels",
|
||||||
"ui:options": {
|
"ui:options": {
|
||||||
suppressMultiSchema: true,
|
suppressMultiSchema: true,
|
||||||
emptySelectionHintKey:
|
|
||||||
"configForm.reviewLabels.allNonAlertDetections",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required_zones: {
|
required_zones: {
|
||||||
|
|||||||
@ -3,6 +3,17 @@ import type { SectionConfigOverrides } from "./types";
|
|||||||
const snapshots: SectionConfigOverrides = {
|
const snapshots: SectionConfigOverrides = {
|
||||||
base: {
|
base: {
|
||||||
sectionDocs: "/configuration/snapshots",
|
sectionDocs: "/configuration/snapshots",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
key: "detect-disabled",
|
||||||
|
messageKey: "configMessages.snapshots.detectDisabled",
|
||||||
|
severity: "info",
|
||||||
|
condition: (ctx) => {
|
||||||
|
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
|
||||||
|
return ctx.fullCameraConfig.detect?.enabled === false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
restartRequired: [],
|
restartRequired: [],
|
||||||
fieldOrder: [
|
fieldOrder: [
|
||||||
"enabled",
|
"enabled",
|
||||||
|
|||||||
@ -1,5 +1,39 @@
|
|||||||
|
import type { FrigateConfig, CameraConfig } from "@/types/frigateConfig";
|
||||||
|
import type { ConfigSectionData } from "@/types/configForm";
|
||||||
import type { SectionConfig } from "../sections/BaseSection";
|
import type { SectionConfig } from "../sections/BaseSection";
|
||||||
|
|
||||||
|
/** Context provided to message condition functions */
|
||||||
|
export type MessageConditionContext = {
|
||||||
|
fullConfig: FrigateConfig;
|
||||||
|
fullCameraConfig?: CameraConfig;
|
||||||
|
level: "global" | "camera";
|
||||||
|
cameraName?: string;
|
||||||
|
formData: ConfigSectionData;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Severity levels for conditional messages */
|
||||||
|
export type MessageSeverity = "info" | "warning" | "error";
|
||||||
|
|
||||||
|
/** A conditional message definition */
|
||||||
|
export type ConditionalMessage = {
|
||||||
|
/** Unique key for React list rendering and deduplication */
|
||||||
|
key: string;
|
||||||
|
/** Translation key resolved via t() in the views/settings namespace */
|
||||||
|
messageKey: string;
|
||||||
|
/** Severity level controlling visual styling */
|
||||||
|
severity: MessageSeverity;
|
||||||
|
/** Function returning true when the message should be shown */
|
||||||
|
condition: (ctx: MessageConditionContext) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Field-level conditional message, adds field targeting */
|
||||||
|
export type FieldConditionalMessage = ConditionalMessage & {
|
||||||
|
/** Dot-separated field path (e.g., "enabled", "alerts.labels") */
|
||||||
|
field: string;
|
||||||
|
/** Whether to render before or after the field (default: "before") */
|
||||||
|
position?: "before" | "after";
|
||||||
|
};
|
||||||
|
|
||||||
export type SectionConfigOverrides = {
|
export type SectionConfigOverrides = {
|
||||||
base?: SectionConfig;
|
base?: SectionConfig;
|
||||||
global?: Partial<SectionConfig>;
|
global?: Partial<SectionConfig>;
|
||||||
|
|||||||
@ -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,65 @@ 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 segments = msg.field.split(".");
|
||||||
|
// Navigate to the nested uiSchema node, shallow-cloning along the way
|
||||||
|
let node = merged;
|
||||||
|
for (let i = 0; i < segments.length - 1; i++) {
|
||||||
|
const seg = segments[i];
|
||||||
|
node[seg] = { ...(node[seg] as Record<string, unknown>) };
|
||||||
|
node = node[seg] as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
const leafKey = segments[segments.length - 1];
|
||||||
|
const existing = node[leafKey] as Record<string, unknown> | undefined;
|
||||||
|
const existingMessages = ((existing?.["ui:messages"] as unknown[]) ??
|
||||||
|
[]) as Array<{
|
||||||
|
key: string;
|
||||||
|
messageKey: string;
|
||||||
|
severity: string;
|
||||||
|
position?: string;
|
||||||
|
}>;
|
||||||
|
node[leafKey] = {
|
||||||
|
...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 +944,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 +956,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>
|
||||||
|
|||||||
@ -45,8 +45,6 @@ export type SwitchesWidgetOptions = {
|
|||||||
enableSearch?: boolean;
|
enableSearch?: boolean;
|
||||||
/** Allow users to add custom entries not in the predefined list */
|
/** Allow users to add custom entries not in the predefined list */
|
||||||
allowCustomEntries?: boolean;
|
allowCustomEntries?: boolean;
|
||||||
/** i18n key for a hint shown when no entities are selected */
|
|
||||||
emptySelectionHintKey?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeValue(value: unknown): string[] {
|
function normalizeValue(value: unknown): string[] {
|
||||||
@ -131,11 +129,6 @@ export function SwitchesWidget(props: WidgetProps) {
|
|||||||
[props.options],
|
[props.options],
|
||||||
);
|
);
|
||||||
|
|
||||||
const emptySelectionHintKey = useMemo(
|
|
||||||
() => props.options?.emptySelectionHintKey as string | undefined,
|
|
||||||
[props.options],
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedEntities = useMemo(() => normalizeValue(value), [value]);
|
const selectedEntities = useMemo(() => normalizeValue(value), [value]);
|
||||||
const [isOpen, setIsOpen] = useState(selectedEntities.length > 0);
|
const [isOpen, setIsOpen] = useState(selectedEntities.length > 0);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
@ -215,12 +208,6 @@ export function SwitchesWidget(props: WidgetProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
{emptySelectionHintKey && selectedEntities.length === 0 && t && (
|
|
||||||
<div className="mt-0 pb-2 text-sm text-success">
|
|
||||||
{t(emptySelectionHintKey, { ns: namespace })}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CollapsibleContent className="rounded-lg border border-input bg-secondary pb-1 pr-0 pt-2 md:max-w-md">
|
<CollapsibleContent className="rounded-lg border border-input bg-secondary pb-1 pr-0 pt-2 md:max-w-md">
|
||||||
{allEntities.length === 0 && !allowCustomEntries ? (
|
{allEntities.length === 0 && !allowCustomEntries ? (
|
||||||
<div className="text-sm text-muted-foreground">{emptyMessage}</div>
|
<div className="text-sm text-muted-foreground">{emptyMessage}</div>
|
||||||
|
|||||||
@ -11,6 +11,9 @@ const alertVariants = cva(
|
|||||||
default: "bg-background text-foreground",
|
default: "bg-background text-foreground",
|
||||||
destructive:
|
destructive:
|
||||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||||
|
warning:
|
||||||
|
"border-amber-500/50 bg-amber-50 text-amber-800 dark:bg-amber-950/20 dark:text-amber-400 dark:border-amber-500/30 [&>svg]:text-amber-600 dark:[&>svg]:text-amber-400",
|
||||||
|
info: "border-blue-500/50 bg-blue-50 text-blue-800 dark:bg-blue-950/20 dark:text-blue-400 dark:border-blue-500/30 [&>svg]:text-blue-600 dark:[&>svg]:text-blue-400",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
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