mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-30 03:44:57 +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": {
|
||||
"summary": "{{count}} labels selected",
|
||||
"empty": "No labels available",
|
||||
"allNonAlertDetections": "All non-alert activity will be included as detections."
|
||||
"empty": "No labels available"
|
||||
},
|
||||
"filters": {
|
||||
"objectFieldLabel": "{{field}} for {{label}}"
|
||||
@ -1606,5 +1605,38 @@
|
||||
"onvif": {
|
||||
"profileAuto": "Auto",
|
||||
"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 = {
|
||||
base: {
|
||||
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: [],
|
||||
fieldOrder: [
|
||||
"enabled",
|
||||
|
||||
@ -3,6 +3,19 @@ import type { SectionConfigOverrides } from "./types";
|
||||
const audioTranscription: SectionConfigOverrides = {
|
||||
base: {
|
||||
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: [],
|
||||
fieldOrder: ["enabled", "language", "device", "model_size"],
|
||||
hiddenFields: ["enabled_in_config", "live_enabled"],
|
||||
|
||||
@ -3,6 +3,20 @@ import type { SectionConfigOverrides } from "./types";
|
||||
const birdseye: SectionConfigOverrides = {
|
||||
base: {
|
||||
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: [],
|
||||
fieldOrder: ["enabled", "mode", "order"],
|
||||
hiddenFields: [],
|
||||
|
||||
@ -3,6 +3,21 @@ import type { SectionConfigOverrides } from "./types";
|
||||
const detect: SectionConfigOverrides = {
|
||||
base: {
|
||||
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: [
|
||||
"enabled",
|
||||
"width",
|
||||
|
||||
@ -3,6 +3,26 @@ import type { SectionConfigOverrides } from "./types";
|
||||
const faceRecognition: SectionConfigOverrides = {
|
||||
base: {
|
||||
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: [],
|
||||
fieldOrder: ["enabled", "min_area"],
|
||||
hiddenFields: [],
|
||||
|
||||
@ -3,6 +3,28 @@ import type { SectionConfigOverrides } from "./types";
|
||||
const lpr: SectionConfigOverrides = {
|
||||
base: {
|
||||
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: {
|
||||
enhancement: "/configuration/license_plate_recognition#enhancement",
|
||||
},
|
||||
|
||||
@ -3,6 +3,19 @@ import type { SectionConfigOverrides } from "./types";
|
||||
const record: SectionConfigOverrides = {
|
||||
base: {
|
||||
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: [],
|
||||
fieldOrder: [
|
||||
"enabled",
|
||||
|
||||
@ -3,6 +3,45 @@ import type { SectionConfigOverrides } from "./types";
|
||||
const review: SectionConfigOverrides = {
|
||||
base: {
|
||||
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: {
|
||||
"alerts.labels": "/configuration/review/#alerts-and-detections",
|
||||
"detections.labels": "/configuration/review/#alerts-and-detections",
|
||||
@ -35,8 +74,6 @@ const review: SectionConfigOverrides = {
|
||||
"ui:widget": "reviewLabels",
|
||||
"ui:options": {
|
||||
suppressMultiSchema: true,
|
||||
emptySelectionHintKey:
|
||||
"configForm.reviewLabels.allNonAlertDetections",
|
||||
},
|
||||
},
|
||||
required_zones: {
|
||||
|
||||
@ -3,6 +3,17 @@ import type { SectionConfigOverrides } from "./types";
|
||||
const snapshots: SectionConfigOverrides = {
|
||||
base: {
|
||||
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: [],
|
||||
fieldOrder: [
|
||||
"enabled",
|
||||
|
||||
@ -1,5 +1,39 @@
|
||||
import type { FrigateConfig, CameraConfig } from "@/types/frigateConfig";
|
||||
import type { ConfigSectionData } from "@/types/configForm";
|
||||
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 = {
|
||||
base?: SectionConfig;
|
||||
global?: Partial<SectionConfig>;
|
||||
|
||||
@ -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,65 @@ 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 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(() => {
|
||||
if (!currentFormData || typeof currentFormData !== "object") {
|
||||
return undefined;
|
||||
@ -874,6 +944,7 @@ export function ConfigSection({
|
||||
|
||||
const sectionContent = (
|
||||
<div className="space-y-6">
|
||||
<ConfigMessageBanner messages={activeMessages} />
|
||||
<ConfigForm
|
||||
key={formKey}
|
||||
schema={modifiedSchema}
|
||||
@ -885,7 +956,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>
|
||||
|
||||
@ -45,8 +45,6 @@ export type SwitchesWidgetOptions = {
|
||||
enableSearch?: boolean;
|
||||
/** Allow users to add custom entries not in the predefined list */
|
||||
allowCustomEntries?: boolean;
|
||||
/** i18n key for a hint shown when no entities are selected */
|
||||
emptySelectionHintKey?: string;
|
||||
};
|
||||
|
||||
function normalizeValue(value: unknown): string[] {
|
||||
@ -131,11 +129,6 @@ export function SwitchesWidget(props: WidgetProps) {
|
||||
[props.options],
|
||||
);
|
||||
|
||||
const emptySelectionHintKey = useMemo(
|
||||
() => props.options?.emptySelectionHintKey as string | undefined,
|
||||
[props.options],
|
||||
);
|
||||
|
||||
const selectedEntities = useMemo(() => normalizeValue(value), [value]);
|
||||
const [isOpen, setIsOpen] = useState(selectedEntities.length > 0);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
@ -215,12 +208,6 @@ export function SwitchesWidget(props: WidgetProps) {
|
||||
</Button>
|
||||
</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">
|
||||
{allEntities.length === 0 && !allowCustomEntries ? (
|
||||
<div className="text-sm text-muted-foreground">{emptyMessage}</div>
|
||||
|
||||
@ -11,6 +11,9 @@ const alertVariants = cva(
|
||||
default: "bg-background text-foreground",
|
||||
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: {
|
||||
|
||||
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