diff --git a/.tasks/ui-config-rjsf/task.md b/.tasks/ui-config-rjsf/task.md new file mode 100644 index 000000000..95ea2f716 --- /dev/null +++ b/.tasks/ui-config-rjsf/task.md @@ -0,0 +1,488 @@ +--- +task: UI Configuration using react-jsonschema-form +slug: ui-config-rjsf +created: 2026-01-21 +status: planning +--- + +# UI Configuration using react-jsonschema-form + +## Overview + +Implement a comprehensive configuration UI for Frigate NVR using react-jsonschema-form (RJSF), driven by the existing Pydantic configuration schema. The UI should allow users to configure Frigate through a web interface instead of manually editing YAML files, with proper validation, reusable components, and clear visual indicators for global vs camera-level settings. + +## Research Findings + +### Key Components + +| Component | Location | Purpose | +| ---------------- | --------------------------------------------------------------------------- | ----------------------------------------------------------- | +| FrigateConfig | [frigate/config/config.py](frigate/config/config.py#L297-L412) | Root Pydantic model defining complete Frigate configuration | +| CameraConfig | [frigate/config/camera/camera.py](frigate/config/camera/camera.py#L49-L130) | Camera-level configuration model | +| FrigateBaseModel | [frigate/config/base.py](frigate/config/base.py) | Base model with `extra="forbid"` validation | +| Config API | [frigate/api/app.py](frigate/api/app.py#L367-L456) | PUT /config/set endpoint for saving config | +| Schema Endpoint | [frigate/api/app.py](frigate/api/app.py#L73-L77) | GET /config/schema.json - exposes Pydantic schema | +| Settings Page | [web/src/pages/Settings.tsx](web/src/pages/Settings.tsx) | Main settings page with sidebar navigation | +| Form Components | [web/src/components/ui/form.tsx](web/src/components/ui/form.tsx) | Existing react-hook-form based form components | + +### Architecture + +The Frigate configuration system is structured as follows: + +1. **Pydantic Models**: All configuration is defined using Pydantic v2 models in `frigate/config/`. The `FrigateConfig` class is the root model. + +2. **Schema Generation**: Pydantic automatically generates JSON Schema via `model_json_schema()`. The schema is already exposed at `/api/config/schema.json` and used by the ConfigEditor (monaco-yaml) for validation. + +3. **Config API**: The `/config/set` endpoint accepts configuration updates in two formats: + - Query string parameters (e.g., `?cameras.front.detect.enabled=True`) + - JSON body via `config_data` field in `AppConfigSetBody` + + The JSON body is preferred. + +4. **Frontend Stack**: + - React 18 with TypeScript + - Radix UI primitives + Tailwind CSS (shadcn/ui patterns) + - react-hook-form + zod for form validation + - SWR for data fetching + - react-i18next for translations + - axios for API calls + +### Data Flow + +1. **Config Loading**: `useSWR("config")` fetches current config from `/api/config` +2. **Schema Loading**: JSON Schema available at `/api/config/schema.json` +3. **Config Updates**: PUT to `/config/set` with JSON body +4. **Real-time Updates**: Some settings support `requires_restart: 0` for live updates via WebSocket pub/sub + +### Reusable Config Sections (Global + Camera Level) + +Based on schema analysis, these sections appear at BOTH global and camera levels using the **same** Pydantic model: + +| Section | Model | Notes | +| --------------- | -------------------- | ------------------------------------------------------------------- | +| audio | AudioConfig | Full model reuse | +| detect | DetectConfig | Full model reuse, nested StationaryConfig | +| live | CameraLiveConfig | Full model reuse | +| motion | MotionConfig | Full model reuse | +| notifications | NotificationConfig | Full model reuse | +| objects | ObjectConfig | Full model reuse, nested FilterConfig, GenAIObjectConfig | +| record | RecordConfig | Full model reuse, nested EventsConfig, RetainConfig | +| review | ReviewConfig | Full model reuse, nested ReviewAlertsConfig, ReviewDetectionsConfig | +| snapshots | SnapshotsConfig | Full model reuse, nested SnapshotsRetainConfig | +| timestamp_style | TimestampStyleConfig | Full model reuse, nested ColorConfig | + +**Different models at global vs camera level** (require conditional handling): + +| Section | Global Model | Camera Model | Notes | +| ------------------- | ----------------------------- | ----------------------------------- | -------------------- | +| audio_transcription | AudioTranscriptionConfig | CameraAudioTranscriptionConfig | Camera has subset | +| birdseye | BirdseyeConfig | BirdseyeCameraConfig | Different fields | +| face_recognition | FaceRecognitionConfig | CameraFaceRecognitionConfig | Camera has subset | +| ffmpeg | FfmpegConfig | CameraFfmpegConfig | Camera adds inputs[] | +| lpr | LicensePlateRecognitionConfig | CameraLicensePlateRecognitionConfig | Camera has subset | +| semantic_search | SemanticSearchConfig | CameraSemanticSearchConfig | Completely different | + +### Special Field Types Requiring Custom Widgets + +| Type | Examples | Widget Needed | +| --------------------- | --------------------------------------------------- | ------------------------------ | +| Enums | BirdseyeModeEnum, RetainModeEnum, RecordQualityEnum | Select dropdown | +| list[str] | objects.track, zones[], required_zones | Tag input / Multi-select | +| Union[str, list[str]] | mask fields | Text or array input | +| dict[str, T] | zones, objects.filters | Key-value editor / Nested form | +| ColorConfig | timestamp_style.color (RGB) | Color picker | +| Coordinates/Masks | zone.coordinates | Polygon editor (existing) | +| Password fields | mqtt.password | Password input with show/hide | + +### Current UI Patterns + +1. **Settings Navigation**: [web/src/pages/Settings.tsx](web/src/pages/Settings.tsx#L68-L114) uses a sidebar with grouped sections +2. **Camera Selection**: Many views accept `selectedCamera` prop for per-camera settings +3. **Form Pattern**: Uses react-hook-form with zod schemas (see [CameraReviewSettingsView.tsx](web/src/views/settings/CameraReviewSettingsView.tsx#L113-L124)) +4. **Save Pattern**: Axios PUT to `config/set?key=value` with `requires_restart` flag +5. **Translations**: All strings in `web/public/locales/{lang}/views/` JSON files + +### Dependencies + +**Internal**: + +- Existing form components: Form, FormField, FormItem, FormLabel, FormControl, FormMessage +- UI primitives: Switch, Select, Input, Checkbox, Slider, Tabs +- Existing hooks: useSWR, useTranslation, useOptimisticState + +**External (to add)**: + +- @rjsf/core (react-jsonschema-form core) +- @rjsf/utils (utilities) +- @rjsf/validator-ajv8 (JSON Schema validation) + +### Configuration + +The `/config/set` API expects: + +```typescript +interface AppConfigSetBody { + requires_restart: number; // 0 = live update, 1 = needs restart + update_topic?: string; // For pub/sub notification + config_data?: Record; // Bulk config updates +} +``` + +Query string format: `?key.path.to.field=value` (e.g., `?cameras.front.detect.enabled=True`) + +### Tests + +| Test File | Coverage | +| ---------------------------------------------------- | ------------------------------------- | +| No existing React component tests found | RJSF forms would benefit from testing | +| Pydantic validation tests implicit in config loading | Schema validation ensures correctness | + +## Implementation Plan + +### Goal + +Users can configure all user-facing Frigate settings through a form-based UI with validation, clear global vs camera-level distinction, and proper handling of advanced settings. + +### Current State Analysis + +- Schema already exposed at `/api/config/schema.json` +- Settings page structure exists with sidebar navigation +- Form components exist (react-hook-form based) +- No react-jsonschema-form currently installed +- Config set API supports both query strings and JSON body + +### What We're NOT Doing + +- Replacing the raw YAML ConfigEditor (remains as advanced option) +- Changing the Pydantic models structure +- Modifying the config/set API endpoint +- Auto-generating translations (manual translation required) + +### Prerequisites + +- [x] Install @rjsf/core, @rjsf/utils, @rjsf/validator-ajv8 +- [x] Create exclusion list and advanced settings list JSON files + +--- + +## Phases + +| # | Phase | Status | Plan | Notes | +| --- | -------------------------------- | ------- | ---- | ------------------------------------------------------------ | +| 1 | Schema Pipeline & RJSF Setup | ✅ Done | — | Install deps, create schema transformer, set up RJSF theme | +| 2 | Core Reusable Section Components | ✅ Done | — | Create shared components for detect, record, snapshots, etc. | +| 3 | Global Configuration View | ✅ Done | — | Build main config sections (mqtt, auth, database, etc.) | +| 4 | Camera Configuration with Tabs | ✅ Done | — | Multi-camera tabs, override indicators, section reuse | +| 5 | Advanced Settings & Exclusions | ✅ Done | — | Progressive disclosure, contributor-editable lists | +| 6 | Validation & Error Handling | ✅ Done | — | Inline errors, save blocking, API integration | +| 7 | Integration & Polish | ✅ Done | — | Settings page integration, translations, documentation | + +**Status:** ⬜ Not Started → 📋 Planned → 🔄 In Progress → ✅ Done + +--- + +## Phase Details + +### Phase 1: Schema Pipeline & RJSF Setup + +**Overview**: Install react-jsonschema-form dependencies, create a schema transformation layer to convert Pydantic JSON Schema to RJSF-compatible format with UI customizations. + +**Changes Required**: + +1. **Install Dependencies** + - Add to package.json: @rjsf/core, @rjsf/utils, @rjsf/validator-ajv8 + +2. **Create Schema Transformer** + - File: `web/src/lib/config-schema/` + - Transform Pydantic schema to RJSF uiSchema + - Handle nested $defs/references + - Apply field ordering + +3. **Create Custom RJSF Theme** + - File: `web/src/components/config-form/theme/` + - Map RJSF templates to existing shadcn/ui components + - Custom widgets for special types (color, coordinates, etc.) + +**Success Criteria**: + +- RJSF renders basic form from schema +- Existing UI component styling preserved +- Schema fetching from /api/config/schema.json works + +### Phase 2: Core Reusable Section Components + +**Overview**: Create composable section components for config areas that appear at both global and camera levels. + +**Changes Required**: + +1. **Section Component Architecture** + - File: `web/src/components/config-form/sections/` + - Create: DetectSection, RecordSection, SnapshotsSection, MotionSection, ObjectsSection, ReviewSection, AudioSection, NotificationsSection, LiveSection, TimestampSection + +2. **Each Section Component**: + - Accepts `level: "global" | "camera"` prop + - Accepts `cameraName?: string` for camera context + - Accepts `showOverrideIndicator?: boolean` + - Uses shared RJSF form with section-specific uiSchema + +3. **Override Detection Hook** + - File: `web/src/hooks/use-config-override.ts` + - Compare camera value vs global default + - Return override status for visual indicators + +4. **Field Ordering and Layout Customization** + +**Requirement**: Field ordering and layout within each section must be easily customizable by contributors without requiring deep knowledge of RJSF internals. + +**Implementation Approach**: + +- Each reusable section component (DetectSection, RecordSection, etc.) should define its own field ordering and layout configuration +- This can be accomplished as a TypeScript constant within the section component file itself +- The configuration should specify: + - Field display order + - Field grouping (which fields appear together) + - Layout hints (e.g., multiple fields per row, nested groupings) + - Any section-specific uiSchema customizations + +**Example Structure**: + +```typescript +// In DetectSection.tsx or DetectSection.config.ts +export const detectSectionConfig = { + fieldOrder: [ + "enabled", + "fps", + "width", + "height", + "max_disappeared", + "stationary", + ], + fieldGroups: { + resolution: ["width", "height"], + performance: ["fps", "max_disappeared"], + }, + // ... other layout hints +}; +``` + +**Success Criteria**: + +- Contributors can reorder fields by editing a clear configuration structure +- No need to modify RJSF internals or complex uiSchema objects directly +- Layout changes are localized to single files per section + +**Success Criteria**: + +- DetectSection renders identically at global and camera level +- Override indicators show when camera differs from global +- Adding new fields requires editing only section definition + +### Phase 3: Global Configuration View + +**Overview**: Build the global configuration form for non-camera settings. + +**Changes Required**: + +1. **Global Config View** + - File: `web/src/views/settings/GlobalConfigView.tsx` + - Sections: MQTT, Auth, Database, Telemetry, TLS, Proxy, Networking, UI, Detectors, Model, GenAI, Classification, Birdseye + +2. **Per-Section Subforms** + - Each section as collapsible card + - Progressive disclosure for advanced fields + - Individual save buttons per section OR unified save + +3. **Translations** + - File: `web/public/locales/en/views/settings.json` + - Add keys for all config field labels/descriptions + +**Success Criteria**: + +- All global-only settings configurable +- Proper field grouping and labels +- Validation errors inline + +### Phase 4: Camera Configuration with Tabs + +**Overview**: Create per-camera configuration with tab navigation and override indicators. + +**Changes Required**: + +1. **Camera Config View** + - File: `web/src/views/settings/CameraConfigView.tsx` + - Tab per camera + - Uses reusable section components + +2. **Override Visual Indicators** + - Badge/icon when field overrides global + - "Reset to global default" action + - Color coding (e.g., highlighted border) + +3. **Camera-Specific Sections** + - FFmpeg inputs configuration + - Masks and Zones (link to existing editor) + - ONVIF settings + +**Success Criteria**: + +- Switch between cameras via tabs +- Clear visual distinction for overridden settings +- Reset to global default works + +### Phase 5: Advanced Settings & Exclusions + +**Overview**: Implement progressive disclosure and maintainable exclusion lists. + +**Changes Required**: + +1. **Exclusion System** + - Simple consts in the component for field names + - Filter schema before rendering + - Document exclusion format for contributors + +2. **Advanced Fields Toggle** + - "Show Advanced Settings" switch per section + - Simple consts in the component for advanced field names + - Default collapsed state + +**Success Criteria**: + +- Excluded fields never shown in UI +- Advanced fields hidden by default + +### Phase 6: Validation & Error Handling + +**Overview**: Ensure robust validation and user-friendly error messages. + +**Changes Required**: + +1. **Client-Side Validation** + - ajv8 validator with Pydantic schema + - Custom error messages for common issues + - Real-time validation on blur + +2. **Server-Side Validation** + - Handle 400 responses from /config/set + - Parse Pydantic validation errors + - Map to form fields + +3. **Save Blocking** + - Disable save button when invalid + - Show error count badge + - Scroll to first error on submit attempt + +**Success Criteria**: + +- Invalid forms cannot be saved +- Errors shown inline next to fields +- Clear error messages (not technical schema errors) + +### Phase 7: Integration & Polish + +**Overview**: Integrate into settings page, finalize translations, and document. + +**Changes Required**: + +1. **Settings Page Integration** + - Add new views to settingsGroups in Settings.tsx + - Sidebar navigation updates + - Route configuration + +2. **Translations** + - All field labels and descriptions + - Error messages + - Section headers + +3. **Documentation** + - User-facing docs for UI configuration + +4. **Testing** + - Basic render tests for form components + - Validation behavior tests + - Save/cancel flow tests + +**Success Criteria**: + +- Seamless navigation from settings page +- All strings translated +- Documentation complete + +--- + +## Testing Strategy + +### Project Maturity Level + +Active Development - Frigate has extensive test infrastructure but limited frontend tests. + +### Unit Tests + +- Schema transformer functions +- Override detection hook +- Custom widgets +- Coverage target: 70% for new components + +### Integration/Manual Tests + +- Full form render with live schema +- Save/validation flow end-to-end +- Camera tab switching +- Override indicator accuracy +- Mobile responsiveness + +--- + +## Rollback Plan + +1. All changes are additive - existing ConfigEditor remains functional +2. New views can be feature-flagged if needed +3. No database migrations required +4. No backend changes required (uses existing API) + +--- + +## Component Hierarchy + +``` +web/src/ +├── components/ +│ └── config-form/ +│ ├── theme/ +│ │ ├── index.ts # RJSF theme export +│ │ ├── templates/ # Base templates (ObjectFieldTemplate, etc.) +│ │ └── widgets/ # Custom widgets (ColorWidget, TagsWidget, etc.) +│ ├── sections/ +│ │ ├── DetectSection.tsx # Reusable for global + camera +│ │ ├── RecordSection.tsx +│ │ ├── SnapshotsSection.tsx +│ │ ├── MotionSection.tsx +│ │ ├── ObjectsSection.tsx +│ │ ├── ReviewSection.tsx +│ │ ├── AudioSection.tsx +│ │ ├── NotificationsSection.tsx +│ │ ├── LiveSection.tsx +│ │ └── TimestampSection.tsx +│ └── ConfigForm.tsx # Main form wrapper +├── lib/ +│ └── config-schema/ +│ ├── index.ts # Schema utilities +│ ├── transformer.ts # Pydantic -> RJSF schema +├── hooks/ +│ └── use-config-override.ts # Override detection +└── views/ + └── settings/ + ├── GlobalConfigView.tsx # Global settings form + └── CameraConfigView.tsx # Per-camera tabs form +``` + +--- + +## Key Design Decisions + +1. **RJSF over custom forms**: Leverage schema-driven forms for maintainability and automatic updates when Pydantic models change. + +2. **Reusable sections via composition**: Same component renders at global and camera level, with props controlling context and override indicators. + +3. **Existing UI primitives**: Custom RJSF theme wraps existing shadcn/ui components for visual consistency. + +4. **Incremental adoption**: Existing settings views remain, new RJSF views added alongside. diff --git a/web/package-lock.json b/web/package-lock.json index cfd5aa2c6..4a6c9e390 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -32,6 +32,9 @@ "@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.2.8", + "@rjsf/core": "^6.2.5", + "@rjsf/utils": "^6.2.5", + "@rjsf/validator-ajv8": "^6.2.5", "apexcharts": "^3.52.0", "axios": "^1.7.7", "class-variance-authority": "^0.7.1", @@ -3301,6 +3304,86 @@ "node": ">=14.0.0" } }, + "node_modules/@rjsf/core": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.2.5.tgz", + "integrity": "sha512-k/2aAKj9IY7JBcnPrYv7frgHkfK0KsS7h8PgPW14GJREh+X5EX/icrypcQu5ge/Ggbwi+90plJll07YiRV/lFg==", + "license": "Apache-2.0", + "dependencies": { + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "markdown-to-jsx": "^8.0.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@rjsf/utils": "^6.2.x", + "react": ">=18" + } + }, + "node_modules/@rjsf/utils": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.2.5.tgz", + "integrity": "sha512-29SvRuY3gKyAHUUnIiJiAF/mTnokgrE7XqUXMj+CZK+sGcmAegwhlnQMJgLQciTodMwTwOaDyV1Fxc47VKTHFw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@x0k/json-schema-merge": "^1.0.2", + "fast-uri": "^3.1.0", + "jsonpointer": "^5.0.1", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "react-is": "^18.3.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "react": ">=18" + } + }, + "node_modules/@rjsf/validator-ajv8": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.2.5.tgz", + "integrity": "sha512-+yLhFRuT2aY91KiUujhUKg8SyTBrUuQP3QAFINeGi+RljA3S+NQN56oeCaNdz9X+35+Sdy6jqmxy/0Q2K+K9vQ==", + "license": "Apache-2.0", + "dependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^2.1.1", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@rjsf/utils": "^6.2.x" + } + }, + "node_modules/@rjsf/validator-ajv8/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@rjsf/validator-ajv8/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.34.9", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.9.tgz", @@ -3859,6 +3942,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, "node_modules/@types/lodash": { "version": "4.17.12", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.12.tgz", @@ -3872,6 +3961,7 @@ "integrity": "sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -3886,6 +3976,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -3896,6 +3987,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", "devOptional": true, + "peer": true, "dependencies": { "@types/react": "*" } @@ -4046,6 +4138,7 @@ "integrity": "sha512-dm/J2UDY3oV3TKius2OUZIFHsomQmpHtsV0FTh1WO8EKgHLQ1QCADUqscPgTpU+ih1e21FQSRjXckHn3txn6kQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.12.0", "@typescript-eslint/types": "7.12.0", @@ -4313,6 +4406,15 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@x0k/json-schema-merge": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@x0k/json-schema-merge/-/json-schema-merge-1.0.2.tgz", + "integrity": "sha512-1734qiJHNX3+cJGDMMw2yz7R+7kpbAtl5NdPs1c/0gO5kYT6s4dMbLXiIfpZNsOYhGZI3aH7FWrj4Zxz7epXNg==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15" + } + }, "node_modules/@yr/monotone-cubic-spline": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz", @@ -4323,6 +4425,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4370,6 +4473,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -4445,6 +4587,7 @@ "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.52.0.tgz", "integrity": "sha512-7dg0ADKs8AA89iYMZMe2sFDG0XK5PfqllKV9N+i3hKHm3vEtdhwz8AlXGm+/b0nJ6jKiaXsqci5LfVxNhtB+dA==", "license": "MIT", + "peer": true, "dependencies": { "@yr/monotone-cubic-spline": "^1.0.3", "svg.draggable.js": "^2.2.2", @@ -4645,6 +4788,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001646", "electron-to-chromium": "^1.5.4", @@ -5394,6 +5538,7 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -5664,7 +5809,8 @@ "version": "8.2.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.2.0.tgz", "integrity": "sha512-rf2GIX8rab9E6ZZN0Uhz05746qu2KrDje9IfFyHzjwxLwhvGjUt6y9+uaY1Sf+B0OPSa3sgas7BE2hWZCtopTA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/embla-carousel-react": { "version": "8.2.0", @@ -5827,6 +5973,7 @@ "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5882,6 +6029,7 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -6121,7 +6269,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-diff": { @@ -6175,6 +6322,22 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", @@ -6672,6 +6835,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.23.2" }, @@ -7138,6 +7302,15 @@ "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==" }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7166,7 +7339,8 @@ "url": "https://github.com/sponsors/lavrton" } ], - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/levn": { "version": "0.4.1", @@ -8002,6 +8176,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", @@ -8136,6 +8311,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -8330,6 +8506,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -8396,6 +8573,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -8465,6 +8643,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.52.1.tgz", "integrity": "sha512-uNKIhaoICJ5KQALYZ4TOaOLElyM+xipord+Ha3crEFhTntdLvWZqVY49Wqd/0GiVCA/f9NjemLeiNPjG7Hpurg==", "license": "MIT", + "peer": true, "engines": { "node": ">=12.22.0" }, @@ -8508,10 +8687,10 @@ } }, "node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" }, "node_modules/react-konva": { "version": "18.2.10", @@ -8806,6 +8985,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -9082,6 +9270,7 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -9523,6 +9712,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.9.tgz", "integrity": "sha512-1SEOvRr6sSdV5IDf9iC+NU4dhwdqzF4zKKq3sAbasUWHEM6lsMhX+eNN5gkPx1BvLFEnZQEUFbXnGj8Qlp83Pg==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -9853,6 +10043,7 @@ "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10083,6 +10274,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -10220,6 +10412,7 @@ "integrity": "sha512-IP7gPK3LS3Fvn44x30X1dM9vtawm0aesAa2yBIZ9vQf+qB69NXC5776+Qmcr7ohUXIQuLhk7xQR0aSUIDPqavg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "3.0.7", "@vitest/mocker": "3.0.7", diff --git a/web/package.json b/web/package.json index 46d667058..73e6937ab 100644 --- a/web/package.json +++ b/web/package.json @@ -38,6 +38,9 @@ "@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.2.8", + "@rjsf/core": "^6.2.5", + "@rjsf/utils": "^6.2.5", + "@rjsf/validator-ajv8": "^6.2.5", "apexcharts": "^3.52.0", "axios": "^1.7.7", "class-variance-authority": "^0.7.1", diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index a84c15619..e39db7940 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -9,11 +9,16 @@ "motionTuner": "Motion Tuner - Frigate", "object": "Debug - Frigate", "general": "UI Settings - Frigate", + "globalConfig": "Global Configuration - Frigate", + "cameraConfig": "Camera Configuration - Frigate", "frigatePlus": "Frigate+ Settings - Frigate", - "notifications": "Notification Settings - Frigate" + "notifications": "Notification Settings - Frigate", + "maintenance": "Maintenance - Frigate" }, "menu": { "ui": "UI", + "globalConfig": "Global Config", + "cameraConfig": "Camera Config", "enrichments": "Enrichments", "cameraManagement": "Management", "cameraReview": "Review", @@ -24,7 +29,8 @@ "users": "Users", "roles": "Roles", "notifications": "Notifications", - "frigateplus": "Frigate+" + "frigateplus": "Frigate+", + "maintenance": "Maintenance" }, "dialog": { "unsavedChanges": { @@ -1115,5 +1121,155 @@ "exports": "Exports", "recordings": "Recordings" } + }, + "configForm": { + "showAdvanced": "Show Advanced Settings", + "tabs": { + "sharedDefaults": "Shared Defaults", + "system": "System", + "integrations": "Integrations" + }, + "sections": { + "detect": "Detection", + "record": "Recording", + "snapshots": "Snapshots", + "motion": "Motion", + "objects": "Objects", + "review": "Review", + "audio": "Audio", + "notifications": "Notifications", + "live": "Live View", + "timestamp_style": "Timestamps", + "mqtt": "MQTT", + "database": "Database", + "telemetry": "Telemetry", + "auth": "Authentication", + "tls": "TLS", + "proxy": "Proxy", + "go2rtc": "go2rtc", + "ffmpeg": "FFmpeg", + "detectors": "Detectors", + "model": "Model", + "semantic_search": "Semantic Search", + "genai": "GenAI", + "face_recognition": "Face Recognition", + "lpr": "License Plate Recognition", + "birdseye": "Birdseye" + }, + "detect": { + "title": "Detection Settings", + "toast": { + "success": "Detection settings saved successfully", + "error": "Failed to save detection settings", + "resetSuccess": "Detection settings reset to global defaults", + "resetError": "Failed to reset detection settings" + } + }, + "record": { + "title": "Recording Settings", + "toast": { + "success": "Recording settings saved successfully", + "error": "Failed to save recording settings", + "resetSuccess": "Recording settings reset to global defaults", + "resetError": "Failed to reset recording settings" + } + }, + "snapshots": { + "title": "Snapshot Settings", + "toast": { + "success": "Snapshot settings saved successfully", + "error": "Failed to save snapshot settings", + "resetSuccess": "Snapshot settings reset to global defaults", + "resetError": "Failed to reset snapshot settings" + } + }, + "motion": { + "title": "Motion Settings", + "toast": { + "success": "Motion settings saved successfully", + "error": "Failed to save motion settings", + "resetSuccess": "Motion settings reset to global defaults", + "resetError": "Failed to reset motion settings" + } + }, + "objects": { + "title": "Object Settings", + "toast": { + "success": "Object settings saved successfully", + "error": "Failed to save object settings", + "resetSuccess": "Object settings reset to global defaults", + "resetError": "Failed to reset object settings" + } + }, + "review": { + "title": "Review Settings", + "toast": { + "success": "Review settings saved successfully", + "error": "Failed to save review settings", + "resetSuccess": "Review settings reset to global defaults", + "resetError": "Failed to reset review settings" + } + }, + "audio": { + "title": "Audio Settings", + "toast": { + "success": "Audio settings saved successfully", + "error": "Failed to save audio settings", + "resetSuccess": "Audio settings reset to global defaults", + "resetError": "Failed to reset audio settings" + } + }, + "notifications": { + "title": "Notification Settings", + "toast": { + "success": "Notification settings saved successfully", + "error": "Failed to save notification settings", + "resetSuccess": "Notification settings reset to global defaults", + "resetError": "Failed to reset notification settings" + } + }, + "live": { + "title": "Live View Settings", + "toast": { + "success": "Live view settings saved successfully", + "error": "Failed to save live view settings", + "resetSuccess": "Live view settings reset to global defaults", + "resetError": "Failed to reset live view settings" + } + }, + "timestamp_style": { + "title": "Timestamp Settings", + "toast": { + "success": "Timestamp settings saved successfully", + "error": "Failed to save timestamp settings", + "resetSuccess": "Timestamp settings reset to global defaults", + "resetError": "Failed to reset timestamp settings" + } + } + }, + "globalConfig": { + "title": "Global Configuration", + "description": "Configure global settings that apply to all cameras unless overridden.", + "toast": { + "success": "Global settings saved successfully", + "error": "Failed to save global settings", + "validationError": "Validation failed" + } + }, + "cameraConfig": { + "title": "Camera Configuration", + "description": "Configure settings for individual cameras. Settings override global defaults.", + "overriddenBadge": "Overridden", + "resetToGlobal": "Reset to Global", + "toast": { + "success": "Camera settings saved successfully", + "error": "Failed to save camera settings" + } + }, + "common": { + "overridden": "Overridden", + "resetToGlobal": "Reset to Global", + "save": "Save", + "cancel": "Cancel" } } diff --git a/web/src/components/config-form/ConfigForm.tsx b/web/src/components/config-form/ConfigForm.tsx new file mode 100644 index 000000000..4c26e51db --- /dev/null +++ b/web/src/components/config-form/ConfigForm.tsx @@ -0,0 +1,157 @@ +// ConfigForm - Main RJSF form wrapper component +import Form from "@rjsf/core"; +import validator from "@rjsf/validator-ajv8"; +import type { RJSFSchema, UiSchema } from "@rjsf/utils"; +import type { IChangeEvent } from "@rjsf/core"; +import { frigateTheme } from "./theme"; +import { transformSchema } from "@/lib/config-schema"; +import { createErrorTransformer } from "@/lib/config-schema/errorMessages"; +import { useMemo, useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/utils"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; + +export interface ConfigFormProps { + /** JSON Schema for the form */ + schema: RJSFSchema; + /** Current form data */ + formData?: Record; + /** Called when form data changes */ + onChange?: (data: Record) => void; + /** Called when form is submitted */ + onSubmit?: (data: Record) => void; + /** Called when form has errors on submit */ + onError?: (errors: unknown[]) => void; + /** Additional uiSchema overrides */ + uiSchema?: UiSchema; + /** Field ordering */ + fieldOrder?: string[]; + /** Fields to hide */ + hiddenFields?: string[]; + /** Fields marked as advanced (collapsed by default) */ + advancedFields?: string[]; + /** Whether form is disabled */ + disabled?: boolean; + /** Whether form is read-only */ + readonly?: boolean; + /** Whether to show submit button */ + showSubmit?: boolean; + /** Custom class name */ + className?: string; + /** Live validation mode */ + liveValidate?: boolean; + /** Form context passed to all widgets */ + formContext?: Record; +} + +export function ConfigForm({ + schema, + formData, + onChange, + onSubmit, + onError, + uiSchema: customUiSchema, + fieldOrder, + hiddenFields, + advancedFields, + disabled = false, + readonly = false, + showSubmit = true, + className, + liveValidate = false, + formContext, +}: ConfigFormProps) { + const { t } = useTranslation(["views/settings"]); + const [showAdvanced, setShowAdvanced] = useState(false); + + // Determine which fields to hide based on advanced toggle + const effectiveHiddenFields = useMemo(() => { + if (showAdvanced || !advancedFields || advancedFields.length === 0) { + return hiddenFields; + } + // Hide advanced fields when toggle is off + return [...(hiddenFields || []), ...advancedFields]; + }, [hiddenFields, advancedFields, showAdvanced]); + + // Transform schema and generate uiSchema + const { schema: transformedSchema, uiSchema: generatedUiSchema } = useMemo( + () => + transformSchema(schema, { + fieldOrder, + hiddenFields: effectiveHiddenFields, + advancedFields: showAdvanced ? advancedFields : [], + }), + [schema, fieldOrder, effectiveHiddenFields, advancedFields, showAdvanced], + ); + + // Merge generated uiSchema with custom overrides + const finalUiSchema = useMemo( + () => ({ + ...generatedUiSchema, + ...customUiSchema, + "ui:submitButtonOptions": showSubmit + ? { norender: false } + : { norender: true }, + }), + [generatedUiSchema, customUiSchema, showSubmit], + ); + + // Create error transformer for user-friendly error messages + const errorTransformer = useMemo(() => createErrorTransformer(), []); + + const handleChange = useCallback( + (e: IChangeEvent) => { + onChange?.(e.formData); + }, + [onChange], + ); + + const handleSubmit = useCallback( + (e: IChangeEvent) => { + onSubmit?.(e.formData); + }, + [onSubmit], + ); + + const hasAdvancedFields = advancedFields && advancedFields.length > 0; + + return ( +
+ {hasAdvancedFields && ( +
+ + +
+ )} +
+
+ ); +} + +export default ConfigForm; diff --git a/web/src/components/config-form/sections/AudioSection.tsx b/web/src/components/config-form/sections/AudioSection.tsx new file mode 100644 index 000000000..ae6d00911 --- /dev/null +++ b/web/src/components/config-form/sections/AudioSection.tsx @@ -0,0 +1,30 @@ +// Audio Section Component +// Reusable for both global and camera-level audio settings + +import { createConfigSection, type SectionConfig } from "./BaseSection"; + +// Configuration for the audio section +export const audioSectionConfig: SectionConfig = { + fieldOrder: [ + "enabled", + "listen", + "filters", + "min_volume", + "max_not_heard", + "num_threads", + ], + fieldGroups: { + detection: ["listen", "filters"], + sensitivity: ["min_volume", "max_not_heard"], + }, + hiddenFields: ["enabled_in_config"], + advancedFields: ["min_volume", "max_not_heard", "num_threads"], +}; + +export const AudioSection = createConfigSection({ + sectionPath: "audio", + translationKey: "configForm.audio", + defaultConfig: audioSectionConfig, +}); + +export default AudioSection; diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx new file mode 100644 index 000000000..bfb23a358 --- /dev/null +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -0,0 +1,249 @@ +// Base Section Component for config form sections +// Used as a foundation for reusable section components + +import { useMemo, useCallback } from "react"; +import useSWR from "swr"; +import axios from "axios"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import { ConfigForm } from "../ConfigForm"; +import { useConfigOverride } from "@/hooks/use-config-override"; +import { useSectionSchema } from "@/hooks/use-config-schema"; +import type { FrigateConfig } from "@/types/frigateConfig"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { LuRotateCcw } from "react-icons/lu"; +import get from "lodash/get"; + +export interface SectionConfig { + /** Field ordering within the section */ + fieldOrder?: string[]; + /** Fields to group together */ + fieldGroups?: Record; + /** Fields to hide from UI */ + hiddenFields?: string[]; + /** Fields to show in advanced section */ + advancedFields?: string[]; +} + +export interface BaseSectionProps { + /** Whether this is at global or camera level */ + level: "global" | "camera"; + /** Camera name (required if level is "camera") */ + cameraName?: string; + /** Whether to show override indicator badge */ + showOverrideIndicator?: boolean; + /** Custom section configuration */ + sectionConfig?: SectionConfig; + /** Whether the section is disabled */ + disabled?: boolean; + /** Whether the section is read-only */ + readonly?: boolean; + /** Callback when settings are saved */ + onSave?: () => void; + /** Whether a restart is required after changes */ + requiresRestart?: boolean; +} + +export interface CreateSectionOptions { + /** The config path for this section (e.g., "detect", "record") */ + sectionPath: string; + /** Translation key prefix for this section */ + translationKey: string; + /** Default section configuration */ + defaultConfig: SectionConfig; +} + +/** + * Factory function to create reusable config section components + */ +export function createConfigSection({ + sectionPath, + translationKey, + defaultConfig, +}: CreateSectionOptions) { + return function ConfigSection({ + level, + cameraName, + showOverrideIndicator = true, + sectionConfig = defaultConfig, + disabled = false, + readonly = false, + onSave, + requiresRestart = true, + }: BaseSectionProps) { + const { t } = useTranslation(["views/settings"]); + + // Fetch config + const { data: config, mutate: refreshConfig } = + useSWR("config"); + + // Get section schema using cached hook + const sectionSchema = useSectionSchema(sectionPath, level); + + // Get override status + const { isOverridden, globalValue, cameraValue, resetToGlobal } = + useConfigOverride({ + config, + cameraName: level === "camera" ? cameraName : undefined, + sectionPath, + }); + + // Get current form data + const formData = useMemo(() => { + if (!config) return {}; + + if (level === "camera" && cameraName) { + return get(config.cameras?.[cameraName], sectionPath) || {}; + } + + return get(config, sectionPath) || {}; + }, [config, level, cameraName]); + + // Handle form submission + const handleSubmit = useCallback( + async (data: Record) => { + try { + const basePath = + level === "camera" && cameraName + ? `cameras.${cameraName}.${sectionPath}` + : sectionPath; + + await axios.put("config/set", { + requires_restart: requiresRestart ? 1 : 0, + config_data: { + [basePath]: data, + }, + }); + + toast.success( + t(`${translationKey}.toast.success`, { + defaultValue: "Settings saved successfully", + }), + ); + + refreshConfig(); + onSave?.(); + } catch (error) { + // Parse Pydantic validation errors from API response + if (axios.isAxiosError(error) && error.response?.data) { + const responseData = error.response.data; + // Pydantic errors come as { detail: [...] } or { message: "..." } + if (responseData.detail && Array.isArray(responseData.detail)) { + const validationMessages = responseData.detail + .map((err: { loc?: string[]; msg?: string }) => { + const field = err.loc?.slice(1).join(".") || "unknown"; + return `${field}: ${err.msg || "Invalid value"}`; + }) + .join(", "); + toast.error( + t(`${translationKey}.toast.validationError`, { + defaultValue: `Validation failed: ${validationMessages}`, + }), + ); + } else if (responseData.message) { + toast.error(responseData.message); + } else { + toast.error( + t(`${translationKey}.toast.error`, { + defaultValue: "Failed to save settings", + }), + ); + } + } else { + toast.error( + t(`${translationKey}.toast.error`, { + defaultValue: "Failed to save settings", + }), + ); + } + } + }, + [level, cameraName, requiresRestart, t, refreshConfig, onSave], + ); + + // Handle reset to global + const handleResetToGlobal = useCallback(async () => { + if (level !== "camera" || !cameraName) return; + + try { + const basePath = `cameras.${cameraName}.${sectionPath}`; + + // Reset by setting to null/undefined or removing the override + await axios.put("config/set", { + requires_restart: requiresRestart ? 1 : 0, + config_data: { + [basePath]: resetToGlobal(), + }, + }); + + toast.success( + t(`${translationKey}.toast.resetSuccess`, { + defaultValue: "Reset to global defaults", + }), + ); + + refreshConfig(); + } catch { + toast.error( + t(`${translationKey}.toast.resetError`, { + defaultValue: "Failed to reset settings", + }), + ); + } + }, [level, cameraName, requiresRestart, t, refreshConfig, resetToGlobal]); + + if (!sectionSchema) { + return null; + } + + const title = t(`${translationKey}.title`, { + defaultValue: sectionPath.charAt(0).toUpperCase() + sectionPath.slice(1), + }); + + return ( + + +
+ {title} + {showOverrideIndicator && level === "camera" && isOverridden && ( + + {t("common.overridden", { defaultValue: "Overridden" })} + + )} +
+ {level === "camera" && isOverridden && ( + + )} +
+ + + +
+ ); + }; +} diff --git a/web/src/components/config-form/sections/DetectSection.tsx b/web/src/components/config-form/sections/DetectSection.tsx new file mode 100644 index 000000000..ab6bff8b2 --- /dev/null +++ b/web/src/components/config-form/sections/DetectSection.tsx @@ -0,0 +1,37 @@ +// Detect Section Component +// Reusable for both global and camera-level detect settings + +import { createConfigSection, type SectionConfig } from "./BaseSection"; + +// Configuration for the detect section +export const detectSectionConfig: SectionConfig = { + fieldOrder: [ + "enabled", + "fps", + "width", + "height", + "min_initialized", + "max_disappeared", + "annotation_offset", + "stationary", + ], + fieldGroups: { + resolution: ["width", "height"], + tracking: ["min_initialized", "max_disappeared"], + }, + hiddenFields: ["enabled_in_config"], + advancedFields: [ + "min_initialized", + "max_disappeared", + "annotation_offset", + "stationary", + ], +}; + +export const DetectSection = createConfigSection({ + sectionPath: "detect", + translationKey: "configForm.detect", + defaultConfig: detectSectionConfig, +}); + +export default DetectSection; diff --git a/web/src/components/config-form/sections/LiveSection.tsx b/web/src/components/config-form/sections/LiveSection.tsx new file mode 100644 index 000000000..7612b8204 --- /dev/null +++ b/web/src/components/config-form/sections/LiveSection.tsx @@ -0,0 +1,20 @@ +// Live Section Component +// Reusable for both global and camera-level live settings + +import { createConfigSection, type SectionConfig } from "./BaseSection"; + +// Configuration for the live section +export const liveSectionConfig: SectionConfig = { + fieldOrder: ["stream_name", "height", "quality"], + fieldGroups: {}, + hiddenFields: ["enabled_in_config"], + advancedFields: ["quality"], +}; + +export const LiveSection = createConfigSection({ + sectionPath: "live", + translationKey: "configForm.live", + defaultConfig: liveSectionConfig, +}); + +export default LiveSection; diff --git a/web/src/components/config-form/sections/MotionSection.tsx b/web/src/components/config-form/sections/MotionSection.tsx new file mode 100644 index 000000000..b8f584191 --- /dev/null +++ b/web/src/components/config-form/sections/MotionSection.tsx @@ -0,0 +1,42 @@ +// Motion Section Component +// Reusable for both global and camera-level motion settings + +import { createConfigSection, type SectionConfig } from "./BaseSection"; + +// Configuration for the motion section +export const motionSectionConfig: SectionConfig = { + fieldOrder: [ + "enabled", + "threshold", + "lightning_threshold", + "improve_contrast", + "contour_area", + "delta_alpha", + "frame_alpha", + "frame_height", + "mask", + "mqtt_off_delay", + ], + fieldGroups: { + sensitivity: ["threshold", "lightning_threshold", "contour_area"], + algorithm: ["improve_contrast", "delta_alpha", "frame_alpha"], + }, + hiddenFields: ["enabled_in_config"], + advancedFields: [ + "lightning_threshold", + "improve_contrast", + "contour_area", + "delta_alpha", + "frame_alpha", + "frame_height", + "mqtt_off_delay", + ], +}; + +export const MotionSection = createConfigSection({ + sectionPath: "motion", + translationKey: "configForm.motion", + defaultConfig: motionSectionConfig, +}); + +export default MotionSection; diff --git a/web/src/components/config-form/sections/NotificationsSection.tsx b/web/src/components/config-form/sections/NotificationsSection.tsx new file mode 100644 index 000000000..e95665f9a --- /dev/null +++ b/web/src/components/config-form/sections/NotificationsSection.tsx @@ -0,0 +1,20 @@ +// Notifications Section Component +// Reusable for both global and camera-level notification settings + +import { createConfigSection, type SectionConfig } from "./BaseSection"; + +// Configuration for the notifications section +export const notificationsSectionConfig: SectionConfig = { + fieldOrder: ["enabled", "email"], + fieldGroups: {}, + hiddenFields: ["enabled_in_config"], + advancedFields: [], +}; + +export const NotificationsSection = createConfigSection({ + sectionPath: "notifications", + translationKey: "configForm.notifications", + defaultConfig: notificationsSectionConfig, +}); + +export default NotificationsSection; diff --git a/web/src/components/config-form/sections/ObjectsSection.tsx b/web/src/components/config-form/sections/ObjectsSection.tsx new file mode 100644 index 000000000..7ed21e39b --- /dev/null +++ b/web/src/components/config-form/sections/ObjectsSection.tsx @@ -0,0 +1,23 @@ +// Objects Section Component +// Reusable for both global and camera-level objects settings + +import { createConfigSection, type SectionConfig } from "./BaseSection"; + +// Configuration for the objects section +export const objectsSectionConfig: SectionConfig = { + fieldOrder: ["track", "alert", "detect", "filters", "mask"], + fieldGroups: { + tracking: ["track", "alert", "detect"], + filtering: ["filters", "mask"], + }, + hiddenFields: ["enabled_in_config"], + advancedFields: ["filters", "mask"], +}; + +export const ObjectsSection = createConfigSection({ + sectionPath: "objects", + translationKey: "configForm.objects", + defaultConfig: objectsSectionConfig, +}); + +export default ObjectsSection; diff --git a/web/src/components/config-form/sections/RecordSection.tsx b/web/src/components/config-form/sections/RecordSection.tsx new file mode 100644 index 000000000..c5166f296 --- /dev/null +++ b/web/src/components/config-form/sections/RecordSection.tsx @@ -0,0 +1,32 @@ +// Record Section Component +// Reusable for both global and camera-level record settings + +import { createConfigSection, type SectionConfig } from "./BaseSection"; + +// Configuration for the record section +export const recordSectionConfig: SectionConfig = { + fieldOrder: [ + "enabled", + "expire_interval", + "continuous", + "motion", + "alerts", + "detections", + "preview", + "export", + ], + fieldGroups: { + retention: ["continuous", "motion"], + events: ["alerts", "detections"], + }, + hiddenFields: ["enabled_in_config"], + advancedFields: ["expire_interval", "preview", "export"], +}; + +export const RecordSection = createConfigSection({ + sectionPath: "record", + translationKey: "configForm.record", + defaultConfig: recordSectionConfig, +}); + +export default RecordSection; diff --git a/web/src/components/config-form/sections/ReviewSection.tsx b/web/src/components/config-form/sections/ReviewSection.tsx new file mode 100644 index 000000000..fe89ad698 --- /dev/null +++ b/web/src/components/config-form/sections/ReviewSection.tsx @@ -0,0 +1,20 @@ +// Review Section Component +// Reusable for both global and camera-level review settings + +import { createConfigSection, type SectionConfig } from "./BaseSection"; + +// Configuration for the review section +export const reviewSectionConfig: SectionConfig = { + fieldOrder: ["alerts", "detections"], + fieldGroups: {}, + hiddenFields: ["enabled_in_config"], + advancedFields: [], +}; + +export const ReviewSection = createConfigSection({ + sectionPath: "review", + translationKey: "configForm.review", + defaultConfig: reviewSectionConfig, +}); + +export default ReviewSection; diff --git a/web/src/components/config-form/sections/SnapshotsSection.tsx b/web/src/components/config-form/sections/SnapshotsSection.tsx new file mode 100644 index 000000000..f7095646a --- /dev/null +++ b/web/src/components/config-form/sections/SnapshotsSection.tsx @@ -0,0 +1,29 @@ +// Snapshots Section Component +// Reusable for both global and camera-level snapshots settings + +import { createConfigSection, type SectionConfig } from "./BaseSection"; + +// Configuration for the snapshots section +export const snapshotsSectionConfig: SectionConfig = { + fieldOrder: [ + "enabled", + "bounding_box", + "crop", + "quality", + "timestamp", + "retain", + ], + fieldGroups: { + display: ["bounding_box", "crop", "quality", "timestamp"], + }, + hiddenFields: ["enabled_in_config"], + advancedFields: ["quality", "retain"], +}; + +export const SnapshotsSection = createConfigSection({ + sectionPath: "snapshots", + translationKey: "configForm.snapshots", + defaultConfig: snapshotsSectionConfig, +}); + +export default SnapshotsSection; diff --git a/web/src/components/config-form/sections/TimestampSection.tsx b/web/src/components/config-form/sections/TimestampSection.tsx new file mode 100644 index 000000000..b4cf88574 --- /dev/null +++ b/web/src/components/config-form/sections/TimestampSection.tsx @@ -0,0 +1,22 @@ +// Timestamp Section Component +// Reusable for both global and camera-level timestamp_style settings + +import { createConfigSection, type SectionConfig } from "./BaseSection"; + +// Configuration for the timestamp_style section +export const timestampSectionConfig: SectionConfig = { + fieldOrder: ["position", "format", "color", "thickness", "effect"], + fieldGroups: { + appearance: ["color", "thickness", "effect"], + }, + hiddenFields: ["enabled_in_config"], + advancedFields: ["thickness", "effect"], +}; + +export const TimestampSection = createConfigSection({ + sectionPath: "timestamp_style", + translationKey: "configForm.timestampStyle", + defaultConfig: timestampSectionConfig, +}); + +export default TimestampSection; diff --git a/web/src/components/config-form/sections/index.ts b/web/src/components/config-form/sections/index.ts new file mode 100644 index 000000000..0fd932b51 --- /dev/null +++ b/web/src/components/config-form/sections/index.ts @@ -0,0 +1,23 @@ +// Config Form Section Components +// Reusable components for both global and camera-level settings + +export { + createConfigSection, + type BaseSectionProps, + type SectionConfig, + type CreateSectionOptions, +} from "./BaseSection"; + +export { DetectSection, detectSectionConfig } from "./DetectSection"; +export { RecordSection, recordSectionConfig } from "./RecordSection"; +export { SnapshotsSection, snapshotsSectionConfig } from "./SnapshotsSection"; +export { MotionSection, motionSectionConfig } from "./MotionSection"; +export { ObjectsSection, objectsSectionConfig } from "./ObjectsSection"; +export { ReviewSection, reviewSectionConfig } from "./ReviewSection"; +export { AudioSection, audioSectionConfig } from "./AudioSection"; +export { + NotificationsSection, + notificationsSectionConfig, +} from "./NotificationsSection"; +export { LiveSection, liveSectionConfig } from "./LiveSection"; +export { TimestampSection, timestampSectionConfig } from "./TimestampSection"; diff --git a/web/src/components/config-form/theme/fields/nullableUtils.ts b/web/src/components/config-form/theme/fields/nullableUtils.ts new file mode 100644 index 000000000..db1d891fd --- /dev/null +++ b/web/src/components/config-form/theme/fields/nullableUtils.ts @@ -0,0 +1,67 @@ +// Utilities for handling anyOf with null patterns +import type { StrictRJSFSchema } from "@rjsf/utils"; + +/** + * Checks if a schema is anyOf with exactly [PrimitiveType, null] + * where the primitive has no additional constraints + */ +export function isSimpleNullableField(schema: StrictRJSFSchema): boolean { + if ( + !schema.anyOf || + !Array.isArray(schema.anyOf) || + schema.anyOf.length !== 2 + ) { + return false; + } + + const items = schema.anyOf; + let hasNull = false; + let simpleType: StrictRJSFSchema | null = null; + + // eslint-disable-next-line no-restricted-syntax + for (const item of items) { + if (typeof item !== "object" || item === null) { + return false; + } + + const itemSchema = item as StrictRJSFSchema; + + if (itemSchema.type === "null") { + hasNull = true; + } else if ( + itemSchema.type && + !("$ref" in itemSchema) && + !("additionalProperties" in itemSchema) && + !("items" in itemSchema) && + !("pattern" in itemSchema) && + !("minimum" in itemSchema) && + !("maximum" in itemSchema) && + !("exclusiveMinimum" in itemSchema) && + !("exclusiveMaximum" in itemSchema) + ) { + simpleType = itemSchema; + } + } + + return hasNull && simpleType !== null; +} + +/** + * Get the non-null schema from an anyOf containing [Type, null] + */ +export function getNonNullSchema( + schema: StrictRJSFSchema, +): StrictRJSFSchema | null { + if (!schema.anyOf || !Array.isArray(schema.anyOf)) { + return null; + } + + return ( + (schema.anyOf.find( + (item) => + typeof item === "object" && + item !== null && + (item as StrictRJSFSchema).type !== "null", + ) as StrictRJSFSchema) || null + ); +} diff --git a/web/src/components/config-form/theme/frigateTheme.ts b/web/src/components/config-form/theme/frigateTheme.ts new file mode 100644 index 000000000..4c847261a --- /dev/null +++ b/web/src/components/config-form/theme/frigateTheme.ts @@ -0,0 +1,76 @@ +// Custom RJSF Theme for Frigate +// Maps RJSF templates and widgets to shadcn/ui components + +import type { + WidgetProps, + FieldTemplateProps, + RegistryWidgetsType, + RegistryFieldsType, + TemplatesType, +} from "@rjsf/utils"; +import { getDefaultRegistry } from "@rjsf/core"; + +import { SwitchWidget } from "./widgets/SwitchWidget"; +import { SelectWidget } from "./widgets/SelectWidget"; +import { TextWidget } from "./widgets/TextWidget"; +import { PasswordWidget } from "./widgets/PasswordWidget"; +import { CheckboxWidget } from "./widgets/CheckboxWidget"; +import { RangeWidget } from "./widgets/RangeWidget"; +import { TagsWidget } from "./widgets/TagsWidget"; +import { ColorWidget } from "./widgets/ColorWidget"; +import { TextareaWidget } from "./widgets/TextareaWidget"; + +import { FieldTemplate } from "./templates/FieldTemplate"; +import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate"; +import { ArrayFieldTemplate } from "./templates/ArrayFieldTemplate"; +import { BaseInputTemplate } from "./templates/BaseInputTemplate"; +import { DescriptionFieldTemplate } from "./templates/DescriptionFieldTemplate"; +import { TitleFieldTemplate } from "./templates/TitleFieldTemplate"; +import { ErrorListTemplate } from "./templates/ErrorListTemplate"; +import { SubmitButton } from "./templates/SubmitButton"; +import { MultiSchemaFieldTemplate } from "./templates/MultiSchemaFieldTemplate"; + +export interface FrigateTheme { + widgets: RegistryWidgetsType; + templates: Partial; + fields: RegistryFieldsType; +} + +const defaultRegistry = getDefaultRegistry(); + +export const frigateTheme: FrigateTheme = { + widgets: { + ...defaultRegistry.widgets, + // Override default widgets with shadcn/ui styled versions + TextWidget: TextWidget, + PasswordWidget: PasswordWidget, + SelectWidget: SelectWidget, + CheckboxWidget: CheckboxWidget, + // Custom widgets + switch: SwitchWidget, + password: PasswordWidget, + select: SelectWidget, + range: RangeWidget, + tags: TagsWidget, + color: ColorWidget, + textarea: TextareaWidget, + }, + templates: { + ...defaultRegistry.templates, + FieldTemplate: FieldTemplate as React.ComponentType, + ObjectFieldTemplate: ObjectFieldTemplate, + ArrayFieldTemplate: ArrayFieldTemplate, + BaseInputTemplate: BaseInputTemplate as React.ComponentType, + DescriptionFieldTemplate: DescriptionFieldTemplate, + TitleFieldTemplate: TitleFieldTemplate, + ErrorListTemplate: ErrorListTemplate, + MultiSchemaFieldTemplate: MultiSchemaFieldTemplate, + ButtonTemplates: { + ...defaultRegistry.templates.ButtonTemplates, + SubmitButton: SubmitButton, + }, + }, + fields: { + ...defaultRegistry.fields, + }, +}; diff --git a/web/src/components/config-form/theme/index.ts b/web/src/components/config-form/theme/index.ts new file mode 100644 index 000000000..fbc4123c3 --- /dev/null +++ b/web/src/components/config-form/theme/index.ts @@ -0,0 +1,5 @@ +// RJSF Custom Theme +// Maps RJSF components to existing shadcn/ui components + +export { frigateTheme } from "./frigateTheme"; +export type { FrigateTheme } from "./frigateTheme"; diff --git a/web/src/components/config-form/theme/templates/ArrayFieldTemplate.tsx b/web/src/components/config-form/theme/templates/ArrayFieldTemplate.tsx new file mode 100644 index 000000000..c43afc88e --- /dev/null +++ b/web/src/components/config-form/theme/templates/ArrayFieldTemplate.tsx @@ -0,0 +1,101 @@ +// Array Field Template - renders array fields with add/remove controls +import type { ArrayFieldTemplateProps } from "@rjsf/utils"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { LuPlus, LuTrash2, LuGripVertical } from "react-icons/lu"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/utils"; + +interface ArrayItem { + key: string; + index: number; + children: React.ReactNode; + hasRemove: boolean; + onDropIndexClick: (index: number) => () => void; +} + +export function ArrayFieldTemplate(props: ArrayFieldTemplateProps) { + const { items, canAdd, onAddClick, disabled, readonly, schema } = props; + + const { t } = useTranslation(["common"]); + + // Simple items (strings, numbers) render inline + const isSimpleType = + schema.items && + typeof schema.items === "object" && + "type" in schema.items && + ["string", "number", "integer", "boolean"].includes( + schema.items.type as string, + ); + + return ( +
+ {items.length === 0 && !canAdd && ( +

+ {t("no_items", { ns: "common", defaultValue: "No items" })} +

+ )} + + {(items as unknown as ArrayItem[]).map((element: ArrayItem) => ( +
+ {isSimpleType ? ( +
+ +
{element.children}
+ {element.hasRemove && ( + + )} +
+ ) : ( + + +
+ +
{element.children}
+ {element.hasRemove && ( + + )} +
+
+
+ )} +
+ ))} + + {canAdd && ( + + )} +
+ ); +} diff --git a/web/src/components/config-form/theme/templates/BaseInputTemplate.tsx b/web/src/components/config-form/theme/templates/BaseInputTemplate.tsx new file mode 100644 index 000000000..957c9e0cf --- /dev/null +++ b/web/src/components/config-form/theme/templates/BaseInputTemplate.tsx @@ -0,0 +1,44 @@ +// Base Input Template - default input wrapper +import type { WidgetProps } from "@rjsf/utils"; +import { Input } from "@/components/ui/input"; + +export function BaseInputTemplate(props: WidgetProps) { + const { + id, + type, + value, + disabled, + readonly, + onChange, + onBlur, + onFocus, + placeholder, + schema, + } = props; + + const inputType = type || "text"; + + const handleChange = (e: React.ChangeEvent) => { + const val = e.target.value; + if (inputType === "number") { + const num = parseFloat(val); + onChange(val === "" ? undefined : isNaN(num) ? undefined : num); + } else { + onChange(val === "" ? undefined : val); + } + }; + + return ( + onBlur(id, e.target.value)} + onFocus={(e) => onFocus(id, e.target.value)} + aria-label={schema.title} + /> + ); +} diff --git a/web/src/components/config-form/theme/templates/DescriptionFieldTemplate.tsx b/web/src/components/config-form/theme/templates/DescriptionFieldTemplate.tsx new file mode 100644 index 000000000..4d1b6f7eb --- /dev/null +++ b/web/src/components/config-form/theme/templates/DescriptionFieldTemplate.tsx @@ -0,0 +1,16 @@ +// Description Field Template +import type { DescriptionFieldProps } from "@rjsf/utils"; + +export function DescriptionFieldTemplate(props: DescriptionFieldProps) { + const { description, id } = props; + + if (!description) { + return null; + } + + return ( +

+ {description} +

+ ); +} diff --git a/web/src/components/config-form/theme/templates/ErrorListTemplate.tsx b/web/src/components/config-form/theme/templates/ErrorListTemplate.tsx new file mode 100644 index 000000000..f957d38ce --- /dev/null +++ b/web/src/components/config-form/theme/templates/ErrorListTemplate.tsx @@ -0,0 +1,33 @@ +// Error List Template - displays form-level errors +import type { ErrorListProps, RJSFValidationError } from "@rjsf/utils"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { LuCircleAlert } from "react-icons/lu"; +import { useTranslation } from "react-i18next"; + +export function ErrorListTemplate(props: ErrorListProps) { + const { errors } = props; + const { t } = useTranslation(["common"]); + + if (!errors || errors.length === 0) { + return null; + } + + return ( + + + {t("validation_errors", { ns: "common" })} + +
    + {errors.map((error: RJSFValidationError, index: number) => ( +
  • + {error.property && ( + {error.property}: + )} + {error.message} +
  • + ))} +
+
+
+ ); +} diff --git a/web/src/components/config-form/theme/templates/FieldTemplate.tsx b/web/src/components/config-form/theme/templates/FieldTemplate.tsx new file mode 100644 index 000000000..d404ce73c --- /dev/null +++ b/web/src/components/config-form/theme/templates/FieldTemplate.tsx @@ -0,0 +1,81 @@ +// Field Template - wraps each form field with label and description +import type { FieldTemplateProps } from "@rjsf/utils"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; + +export function FieldTemplate(props: FieldTemplateProps) { + const { + id, + label, + children, + errors, + help, + description, + hidden, + required, + displayLabel, + schema, + uiSchema, + } = props; + + if (hidden) { + return
{children}
; + } + + // Get UI options + const uiOptions = uiSchema?.["ui:options"] || {}; + const isAdvanced = uiOptions.advanced === true; + + // Boolean fields (switches) render label inline + const isBoolean = schema.type === "boolean"; + + return ( +
+ {displayLabel && label && !isBoolean && ( + + )} + + {isBoolean ? ( +
+
+ {displayLabel && label && ( + + )} + {description && ( +

{description}

+ )} +
+ {children} +
+ ) : ( + <> + {description && ( +

{description}

+ )} + {children} + + )} + + {errors} + {help} +
+ ); +} diff --git a/web/src/components/config-form/theme/templates/MultiSchemaFieldTemplate.tsx b/web/src/components/config-form/theme/templates/MultiSchemaFieldTemplate.tsx new file mode 100644 index 000000000..5457475b1 --- /dev/null +++ b/web/src/components/config-form/theme/templates/MultiSchemaFieldTemplate.tsx @@ -0,0 +1,40 @@ +// Custom MultiSchemaFieldTemplate to handle anyOf [Type, null] fields +// Renders simple nullable types as single inputs instead of dropdowns + +import type { + MultiSchemaFieldTemplateProps, + StrictRJSFSchema, + FormContextType, +} from "@rjsf/utils"; +import { isSimpleNullableField } from "../fields/nullableUtils"; + +/** + * Custom MultiSchemaFieldTemplate that: + * 1. Renders simple anyOf [Type, null] fields as single inputs + * 2. Falls back to default behavior for complex types + */ +export function MultiSchemaFieldTemplate< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + T = any, + S extends StrictRJSFSchema = StrictRJSFSchema, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + F extends FormContextType = any, +>(props: MultiSchemaFieldTemplateProps): JSX.Element { + const { schema, selector, optionSchemaField } = props; + + // Check if this is a simple nullable field that should be handled specially + if (isSimpleNullableField(schema)) { + // For simple nullable fields, just render the field directly without the dropdown selector + // This handles the case where empty input = null + return <>{optionSchemaField}; + } + + // For all other cases, render with both selector and field (default MultiSchemaField behavior) + return ( + <> + {selector} + {optionSchemaField} + + ); +} + diff --git a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx new file mode 100644 index 000000000..a8c8b6dc4 --- /dev/null +++ b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx @@ -0,0 +1,118 @@ +// Object Field Template - renders nested object fields +import type { ObjectFieldTemplateProps } from "@rjsf/utils"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { Button } from "@/components/ui/button"; +import { useState } from "react"; +import { LuChevronDown, LuChevronRight } from "react-icons/lu"; + +export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { + const { title, description, properties } = props; + + // Check if this is a root-level object + const isRoot = !title; + const [isOpen, setIsOpen] = useState(true); + + // Check for advanced section grouping + const advancedProps = properties.filter( + (p) => p.content.props.uiSchema?.["ui:options"]?.advanced === true, + ); + const regularProps = properties.filter( + (p) => p.content.props.uiSchema?.["ui:options"]?.advanced !== true, + ); + + const [showAdvanced, setShowAdvanced] = useState(false); + + // Root level renders children directly + if (isRoot) { + return ( +
+ {regularProps.map((element) => ( +
{element.content}
+ ))} + + {advancedProps.length > 0 && ( + + + + + + {advancedProps.map((element) => ( +
{element.content}
+ ))} +
+
+ )} +
+ ); + } + + // Nested objects render as collapsible cards + return ( + + + + +
+
+ {title} + {description && ( +

+ {description} +

+ )} +
+ {isOpen ? ( + + ) : ( + + )} +
+
+
+ + + {regularProps.map((element) => ( +
{element.content}
+ ))} + + {advancedProps.length > 0 && ( + + + + + + {advancedProps.map((element) => ( +
{element.content}
+ ))} +
+
+ )} +
+
+
+
+ ); +} diff --git a/web/src/components/config-form/theme/templates/SubmitButton.tsx b/web/src/components/config-form/theme/templates/SubmitButton.tsx new file mode 100644 index 000000000..d3812a97f --- /dev/null +++ b/web/src/components/config-form/theme/templates/SubmitButton.tsx @@ -0,0 +1,21 @@ +// Submit Button Template +import type { SubmitButtonProps } from "@rjsf/utils"; +import { Button } from "@/components/ui/button"; +import { useTranslation } from "react-i18next"; +import { LuSave } from "react-icons/lu"; + +export function SubmitButton(props: SubmitButtonProps) { + const { uiSchema } = props; + const { t } = useTranslation(["common"]); + + const submitText = + (uiSchema?.["ui:options"]?.submitText as string) || + t("save", { ns: "common" }); + + return ( + + ); +} diff --git a/web/src/components/config-form/theme/templates/TitleFieldTemplate.tsx b/web/src/components/config-form/theme/templates/TitleFieldTemplate.tsx new file mode 100644 index 000000000..3c0ce59bc --- /dev/null +++ b/web/src/components/config-form/theme/templates/TitleFieldTemplate.tsx @@ -0,0 +1,17 @@ +// Title Field Template +import type { TitleFieldProps } from "@rjsf/utils"; + +export function TitleFieldTemplate(props: TitleFieldProps) { + const { title, id, required } = props; + + if (!title) { + return null; + } + + return ( +

+ {title} + {required && *} +

+ ); +} diff --git a/web/src/components/config-form/theme/widgets/CheckboxWidget.tsx b/web/src/components/config-form/theme/widgets/CheckboxWidget.tsx new file mode 100644 index 000000000..29f5bf3dc --- /dev/null +++ b/web/src/components/config-form/theme/widgets/CheckboxWidget.tsx @@ -0,0 +1,17 @@ +// Checkbox Widget - maps to shadcn/ui Checkbox +import type { WidgetProps } from "@rjsf/utils"; +import { Checkbox } from "@/components/ui/checkbox"; + +export function CheckboxWidget(props: WidgetProps) { + const { id, value, disabled, readonly, onChange, label, schema } = props; + + return ( + onChange(checked)} + aria-label={label || schema.title || "Checkbox"} + /> + ); +} diff --git a/web/src/components/config-form/theme/widgets/ColorWidget.tsx b/web/src/components/config-form/theme/widgets/ColorWidget.tsx new file mode 100644 index 000000000..3e64bf62d --- /dev/null +++ b/web/src/components/config-form/theme/widgets/ColorWidget.tsx @@ -0,0 +1,53 @@ +// Color Widget - For RGB color objects +import type { WidgetProps } from "@rjsf/utils"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useMemo, useCallback } from "react"; + +interface RGBColor { + red: number; + green: number; + blue: number; +} + +export function ColorWidget(props: WidgetProps) { + const { id, value, disabled, readonly, onChange } = props; + + // Convert object to hex for color picker + const hexValue = useMemo(() => { + if (!value || typeof value !== "object") { + return "#ffffff"; + } + const { red = 255, green = 255, blue = 255 } = value as RGBColor; + return `#${red.toString(16).padStart(2, "0")}${green.toString(16).padStart(2, "0")}${blue.toString(16).padStart(2, "0")}`; + }, [value]); + + const handleColorChange = useCallback( + (e: React.ChangeEvent) => { + const hex = e.target.value; + const red = parseInt(hex.slice(1, 3), 16); + const green = parseInt(hex.slice(3, 5), 16); + const blue = parseInt(hex.slice(5, 7), 16); + onChange({ red, green, blue }); + }, + [onChange], + ); + + return ( +
+ +
+ + + +
+
+ ); +} diff --git a/web/src/components/config-form/theme/widgets/NumberWidget.tsx b/web/src/components/config-form/theme/widgets/NumberWidget.tsx new file mode 100644 index 000000000..0000c5067 --- /dev/null +++ b/web/src/components/config-form/theme/widgets/NumberWidget.tsx @@ -0,0 +1,44 @@ +// Number Widget - Input with number type +import type { WidgetProps } from "@rjsf/utils"; +import { Input } from "@/components/ui/input"; + +export function NumberWidget(props: WidgetProps) { + const { + id, + value, + disabled, + readonly, + onChange, + onBlur, + onFocus, + schema, + options, + } = props; + + const handleChange = (e: React.ChangeEvent) => { + const val = e.target.value; + if (val === "") { + onChange(undefined); + } else { + const num = + schema.type === "integer" ? parseInt(val, 10) : parseFloat(val); + onChange(isNaN(num) ? undefined : num); + } + }; + + return ( + onBlur(id, e.target.value)} + onFocus={(e) => onFocus(id, e.target.value)} + aria-label={schema.title} + /> + ); +} diff --git a/web/src/components/config-form/theme/widgets/PasswordWidget.tsx b/web/src/components/config-form/theme/widgets/PasswordWidget.tsx new file mode 100644 index 000000000..07612e57f --- /dev/null +++ b/web/src/components/config-form/theme/widgets/PasswordWidget.tsx @@ -0,0 +1,55 @@ +// Password Widget - Input with password type +import type { WidgetProps } from "@rjsf/utils"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { useState } from "react"; +import { LuEye, LuEyeOff } from "react-icons/lu"; + +export function PasswordWidget(props: WidgetProps) { + const { + id, + value, + disabled, + readonly, + onChange, + onBlur, + onFocus, + placeholder, + schema, + } = props; + + const [showPassword, setShowPassword] = useState(false); + + return ( +
+ + onChange(e.target.value === "" ? undefined : e.target.value) + } + onBlur={(e) => onBlur(id, e.target.value)} + onFocus={(e) => onFocus(id, e.target.value)} + aria-label={schema.title} + className="pr-10" + /> + +
+ ); +} diff --git a/web/src/components/config-form/theme/widgets/RangeWidget.tsx b/web/src/components/config-form/theme/widgets/RangeWidget.tsx new file mode 100644 index 000000000..bd05bfe94 --- /dev/null +++ b/web/src/components/config-form/theme/widgets/RangeWidget.tsx @@ -0,0 +1,31 @@ +// Range Widget - maps to shadcn/ui Slider +import type { WidgetProps } from "@rjsf/utils"; +import { Slider } from "@/components/ui/slider"; +import { cn } from "@/lib/utils"; + +export function RangeWidget(props: WidgetProps) { + const { id, value, disabled, readonly, onChange, schema, options } = props; + + const min = schema.minimum ?? 0; + const max = schema.maximum ?? 100; + const step = + (options.step as number) || (schema.type === "integer" ? 1 : 0.1); + + return ( +
+ onChange(vals[0])} + className={cn("flex-1", disabled && "opacity-50")} + /> + + {value ?? min} + +
+ ); +} diff --git a/web/src/components/config-form/theme/widgets/SelectWidget.tsx b/web/src/components/config-form/theme/widgets/SelectWidget.tsx new file mode 100644 index 000000000..abe05c8fc --- /dev/null +++ b/web/src/components/config-form/theme/widgets/SelectWidget.tsx @@ -0,0 +1,49 @@ +// Select Widget - maps to shadcn/ui Select +import type { WidgetProps } from "@rjsf/utils"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +export function SelectWidget(props: WidgetProps) { + const { + id, + options, + value, + disabled, + readonly, + onChange, + placeholder, + schema, + } = props; + + const { enumOptions = [] } = options; + + return ( + + ); +} diff --git a/web/src/components/config-form/theme/widgets/SwitchWidget.tsx b/web/src/components/config-form/theme/widgets/SwitchWidget.tsx new file mode 100644 index 000000000..8e323e5a1 --- /dev/null +++ b/web/src/components/config-form/theme/widgets/SwitchWidget.tsx @@ -0,0 +1,17 @@ +// Switch Widget - maps to shadcn/ui Switch +import type { WidgetProps } from "@rjsf/utils"; +import { Switch } from "@/components/ui/switch"; + +export function SwitchWidget(props: WidgetProps) { + const { id, value, disabled, readonly, onChange, label, schema } = props; + + return ( + onChange(checked)} + aria-label={label || schema.title || "Toggle"} + /> + ); +} diff --git a/web/src/components/config-form/theme/widgets/TagsWidget.tsx b/web/src/components/config-form/theme/widgets/TagsWidget.tsx new file mode 100644 index 000000000..718805b47 --- /dev/null +++ b/web/src/components/config-form/theme/widgets/TagsWidget.tsx @@ -0,0 +1,74 @@ +// Tags Widget - For array of strings input +import type { WidgetProps } from "@rjsf/utils"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { useState, useCallback, useMemo } from "react"; +import { LuX } from "react-icons/lu"; + +export function TagsWidget(props: WidgetProps) { + const { id, value = [], disabled, readonly, onChange, schema } = props; + + const [inputValue, setInputValue] = useState(""); + + const tags = useMemo(() => (Array.isArray(value) ? value : []), [value]); + + const addTag = useCallback(() => { + const trimmed = inputValue.trim(); + if (trimmed && !tags.includes(trimmed)) { + onChange([...tags, trimmed]); + setInputValue(""); + } + }, [inputValue, tags, onChange]); + + const removeTag = useCallback( + (tagToRemove: string) => { + onChange(tags.filter((tag: string) => tag !== tagToRemove)); + }, + [tags, onChange], + ); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + addTag(); + } else if (e.key === "Backspace" && inputValue === "" && tags.length > 0) { + removeTag(tags[tags.length - 1]); + } + }; + + return ( +
+
+ {tags.map((tag: string, index: number) => ( + + {tag} + {!disabled && !readonly && ( + + )} + + ))} +
+ {!readonly && ( + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={addTag} + /> + )} +
+ ); +} diff --git a/web/src/components/config-form/theme/widgets/TextWidget.tsx b/web/src/components/config-form/theme/widgets/TextWidget.tsx new file mode 100644 index 000000000..58eb9b79d --- /dev/null +++ b/web/src/components/config-form/theme/widgets/TextWidget.tsx @@ -0,0 +1,34 @@ +// Text Widget - maps to shadcn/ui Input +import type { WidgetProps } from "@rjsf/utils"; +import { Input } from "@/components/ui/input"; + +export function TextWidget(props: WidgetProps) { + const { + id, + value, + disabled, + readonly, + onChange, + onBlur, + onFocus, + placeholder, + schema, + options, + } = props; + + return ( + + onChange(e.target.value === "" ? undefined : e.target.value) + } + onBlur={(e) => onBlur(id, e.target.value)} + onFocus={(e) => onFocus(id, e.target.value)} + aria-label={schema.title} + /> + ); +} diff --git a/web/src/components/config-form/theme/widgets/TextareaWidget.tsx b/web/src/components/config-form/theme/widgets/TextareaWidget.tsx new file mode 100644 index 000000000..0a2e03305 --- /dev/null +++ b/web/src/components/config-form/theme/widgets/TextareaWidget.tsx @@ -0,0 +1,34 @@ +// Textarea Widget - maps to shadcn/ui Textarea +import type { WidgetProps } from "@rjsf/utils"; +import { Textarea } from "@/components/ui/textarea"; + +export function TextareaWidget(props: WidgetProps) { + const { + id, + value, + disabled, + readonly, + onChange, + onBlur, + onFocus, + placeholder, + schema, + options, + } = props; + + return ( +