Compare commits

...
4 Commits
Author SHA1 Message Date
dependabot[bot]andGitHub 4bb0d5b948 Merge 2d7aca34b6 into 5f6043aa92 2026-07-13 12:08:09 +02:00
Josh HawkinsandGitHub 5f6043aa92 Fix enabled flag for custom classification models (#23681)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* honor enabled flag for custom classification models

for both startup and dynamically, even though the UI doesn't currently have a way to toggle dynamically

* add test
2026-07-12 03:48:14 -08:00
Josh HawkinsandGitHub da4037eb52 UI tweaks (#23679)
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
* lock saved GenAI provider keys and add labels/validation to config map-key fields

* fix docs
2026-07-11 16:30:40 -06:00
dependabot[bot]andGitHub 2d7aca34b6 Bump uuid and @rjsf/shadcn in /web
Bumps [uuid](https://github.com/uuidjs/uuid) to 14.0.0 and updates ancestor dependency [@rjsf/shadcn](https://github.com/rjsf-team/react-jsonschema-form). These dependencies need to be updated together.


Updates `uuid` from 13.0.0 to 14.0.0
- [Release notes](https://github.com/uuidjs/uuid/releases)
- [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/uuidjs/uuid/compare/v13.0.0...v14.0.0)

Updates `@rjsf/shadcn` from 6.4.1 to 6.5.2
- [Release notes](https://github.com/rjsf-team/react-jsonschema-form/releases)
- [Changelog](https://github.com/rjsf-team/react-jsonschema-form/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rjsf-team/react-jsonschema-form/compare/6.4.1...6.5.2)

---
updated-dependencies:
- dependency-name: uuid
  dependency-version: 14.0.0
  dependency-type: indirect
- dependency-name: "@rjsf/shadcn"
  dependency-version: 6.5.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-05 21:36:09 +00:00
10 changed files with 302 additions and 77 deletions
+4 -4
View File
@@ -72,10 +72,10 @@ Variables prefixed with `FRIGATE_` can be referenced in config fields that suppo
Navigate to <NavPath path="Settings > System > Environment variables" /> to add or edit environment variables. Navigate to <NavPath path="Settings > System > Environment variables" /> to add or edit environment variables.
| Field | Description | | Field | Description |
| --------- | --------------------------------------------------------- | | ----------------- | --------------------------------------------------------- |
| **Key** | The environment variable name (e.g., `FRIGATE_MQTT_USER`) | | **Variable name** | The environment variable name (e.g., `FRIGATE_MQTT_USER`) |
| **Value** | The value for the variable | | **Value** | The value for the variable |
Variables defined here can be referenced elsewhere in your configuration using the `{FRIGATE_VARIABLE_NAME}` syntax. Variables defined here can be referenced elsewhere in your configuration using the `{FRIGATE_VARIABLE_NAME}` syntax.
+35 -18
View File
@@ -200,6 +200,9 @@ class EmbeddingMaintainer(threading.Thread):
) )
for model_config in self.config.classification.custom.values(): for model_config in self.config.classification.custom.values():
if not model_config.enabled:
continue
self.realtime_processors.append( self.realtime_processors.append(
CustomStateClassificationProcessor( CustomStateClassificationProcessor(
self.config, model_config, self.requestor, self.metrics self.config, model_config, self.requestor, self.metrics
@@ -332,6 +335,25 @@ class EmbeddingMaintainer(threading.Thread):
for processor in self.post_processors: for processor in self.post_processors:
processor.update_config(topic, payload) processor.update_config(topic, payload)
def _remove_custom_classification_processor(self, model_name: str) -> None:
"""Shut down and drop any running processor for a custom model."""
remaining = []
for processor in self.realtime_processors:
if (
isinstance(
processor,
(
CustomStateClassificationProcessor,
CustomObjectClassificationProcessor,
),
)
and processor.model_config.name == model_name
):
processor.shutdown()
else:
remaining.append(processor)
self.realtime_processors = remaining
def _handle_custom_classification_update( def _handle_custom_classification_update(
self, topic: str, model_config: Any self, topic: str, model_config: Any
) -> None: ) -> None:
@@ -339,23 +361,7 @@ class EmbeddingMaintainer(threading.Thread):
model_name = topic.split("/")[-1] model_name = topic.split("/")[-1]
if model_config is None: if model_config is None:
remaining = [] self._remove_custom_classification_processor(model_name)
for processor in self.realtime_processors:
if (
isinstance(
processor,
(
CustomStateClassificationProcessor,
CustomObjectClassificationProcessor,
),
)
and processor.model_config.name == model_name
):
processor.shutdown()
else:
remaining.append(processor)
self.realtime_processors = remaining
logger.info( logger.info(
f"Successfully removed classification processor for model: {model_name}" f"Successfully removed classification processor for model: {model_name}"
) )
@@ -363,6 +369,13 @@ class EmbeddingMaintainer(threading.Thread):
self.config.classification.custom[model_name] = model_config self.config.classification.custom[model_name] = model_config
# A disabled model must not run; tear down any existing processor and
# do not register a new one.
if not model_config.enabled:
self._remove_custom_classification_processor(model_name)
logger.info(f"Disabled classification processor for model: {model_name}")
return
# Check if processor already exists # Check if processor already exists
for processor in self.realtime_processors: for processor in self.realtime_processors:
if isinstance( if isinstance(
@@ -702,7 +715,11 @@ class EmbeddingMaintainer(threading.Thread):
and "license_plate" not in camera_config.objects.track and "license_plate" not in camera_config.objects.track
) )
if not dedicated_lpr_enabled and len(self.config.classification.custom) == 0: has_enabled_custom = any(
c.enabled for c in self.config.classification.custom.values()
)
if not dedicated_lpr_enabled and not has_enabled_custom:
# no active features that use this data # no active features that use this data
return return
+106
View File
@@ -0,0 +1,106 @@
"""Tests that disabled custom classification models are not registered or run."""
import sys
import unittest
from unittest.mock import MagicMock
# Mock TFLite before importing the maintainer / classification modules
_MOCK_MODULES = [
"tflite_runtime",
"tflite_runtime.interpreter",
"ai_edge_litert",
"ai_edge_litert.interpreter",
]
for mod in _MOCK_MODULES:
if mod not in sys.modules:
sys.modules[mod] = MagicMock()
from frigate.data_processing.real_time.custom_classification import ( # noqa: E402
CustomObjectClassificationProcessor,
)
from frigate.embeddings.maintainer import EmbeddingMaintainer # noqa: E402
class TestCustomClassificationEnabledGating(unittest.TestCase):
"""A model with enabled: false must not keep a processor registered."""
def _make_maintainer(self) -> EmbeddingMaintainer:
# Bypass the heavy __init__; only the attributes touched by the
# config update path are needed for these tests.
maintainer = EmbeddingMaintainer.__new__(EmbeddingMaintainer)
maintainer.realtime_processors = []
maintainer.config = MagicMock()
maintainer.config.classification.custom = {}
maintainer.requestor = MagicMock()
maintainer.metrics = MagicMock()
maintainer.event_metadata_publisher = MagicMock()
return maintainer
def _make_model_config(self, name: str, enabled: bool) -> MagicMock:
model_config = MagicMock()
model_config.name = name
model_config.enabled = enabled
model_config.state_config = None
return model_config
def _make_processor(self, name: str) -> MagicMock:
processor = MagicMock(spec=CustomObjectClassificationProcessor)
processor.model_config = MagicMock()
processor.model_config.name = name
return processor
def test_disabled_update_tears_down_existing_processor(self):
"""Toggling a running model to disabled shuts down and drops its processor."""
maintainer = self._make_maintainer()
processor = self._make_processor("atli")
maintainer.realtime_processors = [processor]
maintainer._handle_custom_classification_update(
"config/classification/custom/atli",
self._make_model_config("atli", enabled=False),
)
processor.shutdown.assert_called_once()
self.assertEqual(maintainer.realtime_processors, [])
def test_disabled_update_does_not_register_processor(self):
"""A disabled model that has no processor is never registered."""
maintainer = self._make_maintainer()
maintainer._handle_custom_classification_update(
"config/classification/custom/atli",
self._make_model_config("atli", enabled=False),
)
self.assertEqual(maintainer.realtime_processors, [])
def test_disabled_update_leaves_other_processors_untouched(self):
"""Disabling one model must not affect other running processors."""
maintainer = self._make_maintainer()
other = self._make_processor("simbi")
maintainer.realtime_processors = [other]
maintainer._handle_custom_classification_update(
"config/classification/custom/atli",
self._make_model_config("atli", enabled=False),
)
other.shutdown.assert_not_called()
self.assertEqual(maintainer.realtime_processors, [other])
def test_removed_model_tears_down_processor(self):
"""A None payload (model deleted) still shuts down its processor."""
maintainer = self._make_maintainer()
processor = self._make_processor("atli")
maintainer.realtime_processors = [processor]
maintainer._handle_custom_classification_update(
"config/classification/custom/atli", None
)
processor.shutdown.assert_called_once()
self.assertEqual(maintainer.realtime_processors, [])
if __name__ == "__main__":
unittest.main()
+41 -31
View File
@@ -35,7 +35,7 @@
"@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@rjsf/core": "^6.4.1", "@rjsf/core": "^6.4.1",
"@rjsf/shadcn": "^6.4.1", "@rjsf/shadcn": "^6.5.2",
"@rjsf/utils": "^6.4.1", "@rjsf/utils": "^6.4.1",
"@rjsf/validator-ajv8": "^6.4.1", "@rjsf/validator-ajv8": "^6.4.1",
"apexcharts": "^3.52.0", "apexcharts": "^3.52.0",
@@ -4887,13 +4887,13 @@
} }
}, },
"node_modules/@rjsf/core": { "node_modules/@rjsf/core": {
"version": "6.4.1", "version": "6.5.2",
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.4.1.tgz", "resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.5.2.tgz",
"integrity": "sha512-+QaiSgQnOuO6ghIsohH2u/QcylkN+Da2968a75g/i4oARYJRYVxXDm2u3JR5aXndpMb4t4jTFrYyG8cNIv6oEg==", "integrity": "sha512-Fx+aVNQRYQyoY0vM8zYDZkuOiNe+5PLsxUySUdHfjljlT23mJnTCpPKMkxWJwh4UEWeSN0xjmknW9LfIwuQmOg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"lodash": "^4.17.23", "lodash": "^4.18.1",
"lodash-es": "^4.17.23", "lodash-es": "^4.18.1",
"markdown-to-jsx": "^8.0.0", "markdown-to-jsx": "^8.0.0",
"prop-types": "^15.8.1" "prop-types": "^15.8.1"
}, },
@@ -4901,14 +4901,14 @@
"node": ">=20" "node": ">=20"
}, },
"peerDependencies": { "peerDependencies": {
"@rjsf/utils": "^6.4.x", "@rjsf/utils": "^6.5.x",
"react": ">=18" "react": ">=18"
} }
}, },
"node_modules/@rjsf/shadcn": { "node_modules/@rjsf/shadcn": {
"version": "6.4.1", "version": "6.5.2",
"resolved": "https://registry.npmjs.org/@rjsf/shadcn/-/shadcn-6.4.1.tgz", "resolved": "https://registry.npmjs.org/@rjsf/shadcn/-/shadcn-6.5.2.tgz",
"integrity": "sha512-WzwXW3XY7K1jo9XrBv6M41ScdHrnQDKpSxip5i1N6xCgEE6hiyX+wn7pDO689OoidvL3lWQmtnoqMdcoJvEWjw==", "integrity": "sha512-h3FsGRy07Gw4MrORlxNTuSb7icuyiUM0KXimg1jgtlYLhYvMKaHzJgdL/SrAyBWiUtXIsxuBhPU3rFxnA9Ml6Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
@@ -4925,19 +4925,19 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"lodash": "^4.17.23", "lodash": "^4.18.1",
"lodash-es": "^4.17.23", "lodash-es": "^4.18.1",
"lucide-react": "^0.548.0", "lucide-react": "^0.548.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"uuid": "^13.0.0" "uuid": "^14.0.0"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
}, },
"peerDependencies": { "peerDependencies": {
"@rjsf/core": "^6.4.x", "@rjsf/core": "^6.5.x",
"@rjsf/utils": "^6.4.x", "@rjsf/utils": "^6.5.x",
"react": ">=18" "react": ">=18"
} }
}, },
@@ -4951,9 +4951,9 @@
} }
}, },
"node_modules/@rjsf/shadcn/node_modules/tailwind-merge": { "node_modules/@rjsf/shadcn/node_modules/tailwind-merge": {
"version": "3.4.0", "version": "3.5.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
@@ -4961,16 +4961,17 @@
} }
}, },
"node_modules/@rjsf/utils": { "node_modules/@rjsf/utils": {
"version": "6.4.1", "version": "6.5.2",
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.4.1.tgz", "resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.5.2.tgz",
"integrity": "sha512-5NL3jwt3rIS5/WRTrKt++y40FS/ScKGVwYJ3jIrHSQHSwBdLnd4cHf2zcnA97L1Klj8I6tvS/ugh+blf/Diwuw==", "integrity": "sha512-qBVQ5qf9BKMOQy/DjMl/IjD5s6akRDx18cgiSYunKt/CYc+kPzHyCVA2TU0rXCsigNlhgnfM8piC3LEHye6vpA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@x0k/json-schema-merge": "^1.0.2", "@x0k/json-schema-merge": "^1.0.3",
"fast-equals": "^6.0.0",
"fast-uri": "^3.1.0", "fast-uri": "^3.1.0",
"jsonpointer": "^5.0.1", "jsonpointer": "^5.0.1",
"lodash": "^4.17.23", "lodash": "^4.18.1",
"lodash-es": "^4.17.23", "lodash-es": "^4.18.1",
"react-is": "^18.3.1" "react-is": "^18.3.1"
}, },
"engines": { "engines": {
@@ -4980,6 +4981,15 @@
"react": ">=18" "react": ">=18"
} }
}, },
"node_modules/@rjsf/utils/node_modules/fast-equals": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.0.0.tgz",
"integrity": "sha512-PFhhIGgdM79r5Uztdj9Zb6Tt1zKafqVfdMGwVca1z5z6fbX7DmsySSuJd8HiP6I1j505DCS83cLxo5rmSNeVEA==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@rjsf/validator-ajv8": { "node_modules/@rjsf/validator-ajv8": {
"version": "6.4.1", "version": "6.4.1",
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.4.1.tgz", "resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.4.1.tgz",
@@ -6173,9 +6183,9 @@
} }
}, },
"node_modules/@x0k/json-schema-merge": { "node_modules/@x0k/json-schema-merge": {
"version": "1.0.2", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@x0k/json-schema-merge/-/json-schema-merge-1.0.2.tgz", "resolved": "https://registry.npmjs.org/@x0k/json-schema-merge/-/json-schema-merge-1.0.3.tgz",
"integrity": "sha512-1734qiJHNX3+cJGDMMw2yz7R+7kpbAtl5NdPs1c/0gO5kYT6s4dMbLXiIfpZNsOYhGZI3aH7FWrj4Zxz7epXNg==", "integrity": "sha512-lerJC4sI9CNUQWdff3PnU1YJOqazD6TjMcvxZIPXUBjn4j1cUiXE0LvzhMnGYzKKr271TkvXJtH7gEwksrtn+w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/json-schema": "^7.0.15" "@types/json-schema": "^7.0.15"
@@ -13977,9 +13987,9 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
}, },
"node_modules/uuid": { "node_modules/uuid": {
"version": "13.0.0", "version": "14.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==",
"funding": [ "funding": [
"https://github.com/sponsors/broofa", "https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan" "https://github.com/sponsors/ctavan"
+1 -1
View File
@@ -49,7 +49,7 @@
"@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@rjsf/core": "^6.4.1", "@rjsf/core": "^6.4.1",
"@rjsf/shadcn": "^6.4.1", "@rjsf/shadcn": "^6.5.2",
"@rjsf/utils": "^6.4.1", "@rjsf/utils": "^6.4.1",
"@rjsf/validator-ajv8": "^6.4.1", "@rjsf/validator-ajv8": "^6.4.1",
"apexcharts": "^3.52.0", "apexcharts": "^3.52.0",
+8 -1
View File
@@ -1490,7 +1490,14 @@
"keyLabel": "Key", "keyLabel": "Key",
"valueLabel": "Value", "valueLabel": "Value",
"keyPlaceholder": "New key", "keyPlaceholder": "New key",
"remove": "Remove" "remove": "Remove",
"providerNameLabel": "Provider name",
"providerNamePlaceholder": "e.g., openai",
"variableNameLabel": "Variable name",
"variableNamePlaceholder": "e.g., MY_VARIABLE",
"loggerNameLabel": "Logger name",
"loggerNamePlaceholder": "e.g., frigate.record",
"keyPatternError": "Use only letters, numbers, hyphens, and underscores (no spaces)"
}, },
"knownPlates": { "knownPlates": {
"namePlaceholder": "e.g., Wife's Car", "namePlaceholder": "e.g., Wife's Car",
@@ -7,7 +7,13 @@ const environmentVars: SectionConfigOverrides = {
advancedFields: [], advancedFields: [],
uiSchema: { uiSchema: {
additionalProperties: { additionalProperties: {
"ui:options": { size: "lg" }, "ui:options": {
size: "lg",
additionalPropertyKeyLabel:
"configForm.additionalProperties.variableNameLabel",
additionalPropertyKeyPlaceholder:
"configForm.additionalProperties.variableNamePlaceholder",
},
}, },
}, },
}, },
@@ -9,7 +9,15 @@ const genai: SectionConfigOverrides = {
uiSchema: { uiSchema: {
"ui:options": { disableNestedCard: true }, "ui:options": { disableNestedCard: true },
"*": { "*": {
"ui:options": { disableNestedCard: true }, "ui:options": {
disableNestedCard: true,
additionalPropertyKeyLabel:
"configForm.additionalProperties.providerNameLabel",
additionalPropertyKeyPlaceholder:
"configForm.additionalProperties.providerNamePlaceholder",
additionalPropertyKeyPattern: "^[a-zA-Z0-9_-]+$",
preventKeyRename: true,
},
"ui:order": [ "ui:order": [
"provider", "provider",
"api_key", "api_key",
@@ -12,7 +12,13 @@ const logger: SectionConfigOverrides = {
}, },
logs: { logs: {
additionalProperties: { additionalProperties: {
"ui:options": { enumI18nPrefix: "logger.logLevel" }, "ui:options": {
enumI18nPrefix: "logger.logLevel",
additionalPropertyKeyLabel:
"configForm.additionalProperties.loggerNameLabel",
additionalPropertyKeyPlaceholder:
"configForm.additionalProperties.loggerNamePlaceholder",
},
}, },
}, },
}, },
@@ -6,12 +6,14 @@ import {
StrictRJSFSchema, StrictRJSFSchema,
WrapIfAdditionalTemplateProps, WrapIfAdditionalTemplateProps,
} from "@rjsf/utils"; } from "@rjsf/utils";
import { useEffect, useMemo, useState, type FocusEvent } from "react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LuTrash2 } from "react-icons/lu"; import { LuTrash2 } from "react-icons/lu";
import type { ConfigFormContext } from "@/types/configForm";
export function WrapIfAdditionalTemplate< export function WrapIfAdditionalTemplate<
T = unknown, T = unknown,
@@ -30,6 +32,7 @@ export function WrapIfAdditionalTemplate<
onKeyRenameBlur, onKeyRenameBlur,
readonly, readonly,
required, required,
registry,
schema, schema,
uiSchema, uiSchema,
} = props; } = props;
@@ -38,6 +41,55 @@ export function WrapIfAdditionalTemplate<
const additional = ADDITIONAL_PROPERTY_FLAG in schema; const additional = ADDITIONAL_PROPERTY_FLAG in schema;
const uiOptions = getUiOptions(uiSchema);
const keyIsReadonly = uiOptions.additionalPropertyKeyReadonly === true;
const keyLabelKey =
typeof uiOptions.additionalPropertyKeyLabel === "string"
? uiOptions.additionalPropertyKeyLabel
: undefined;
const keyPlaceholderKey =
typeof uiOptions.additionalPropertyKeyPlaceholder === "string"
? uiOptions.additionalPropertyKeyPlaceholder
: undefined;
const keyPattern =
typeof uiOptions.additionalPropertyKeyPattern === "string"
? uiOptions.additionalPropertyKeyPattern
: undefined;
const preventKeyRename = uiOptions.preventKeyRename === true;
const formContext = registry?.formContext as ConfigFormContext | undefined;
// optionally, lock the key once it's been saved
const baseline = formContext?.baselineFormData;
const keyLocked =
preventKeyRename &&
typeof label === "string" &&
!!baseline &&
Object.prototype.hasOwnProperty.call(baseline, label);
// controlled key value so we can validate live and block invalid renames.
const [keyValue, setKeyValue] = useState<string>(label ?? "");
useEffect(() => {
setKeyValue(label ?? "");
}, [label]);
const keyRegex = useMemo(
() => (keyPattern ? new RegExp(keyPattern) : undefined),
[keyPattern],
);
const keyError = useMemo(() => {
if (!keyRegex || keyLocked) return null;
if (!keyRegex.test(keyValue)) {
return t("configForm.additionalProperties.keyPatternError", {
ns: "views/settings",
defaultValue:
"Use only letters, numbers, hyphens, and underscores (no spaces)",
});
}
return null;
}, [keyRegex, keyLocked, keyValue, t]);
if (!additional) { if (!additional) {
return ( return (
<div className={classNames} style={style}> <div className={classNames} style={style}>
@@ -47,20 +99,26 @@ export function WrapIfAdditionalTemplate<
} }
const keyId = `${id}-key`; const keyId = `${id}-key`;
const keyLabel = t("configForm.additionalProperties.keyLabel", { const keyLabel = keyLabelKey
ns: "views/settings", ? t(keyLabelKey, { ns: "views/settings" })
}); : t("configForm.additionalProperties.keyLabel", { ns: "views/settings" });
const valueLabel = t("configForm.additionalProperties.valueLabel", { const valueLabel = t("configForm.additionalProperties.valueLabel", {
ns: "views/settings", ns: "views/settings",
}); });
const keyPlaceholder = t("configForm.additionalProperties.keyPlaceholder", { const keyPlaceholder = keyPlaceholderKey
ns: "views/settings", ? t(keyPlaceholderKey, { ns: "views/settings" })
}); : t("configForm.additionalProperties.keyPlaceholder", {
ns: "views/settings",
});
const removeLabel = t("configForm.additionalProperties.remove", { const removeLabel = t("configForm.additionalProperties.remove", {
ns: "views/settings", ns: "views/settings",
}); });
const uiOptions = getUiOptions(uiSchema);
const keyIsReadonly = uiOptions.additionalPropertyKeyReadonly === true; const commitKeyRename = (e: FocusEvent<HTMLInputElement>) => {
if (readonly) return;
if (keyError) return;
onKeyRenameBlur?.(e);
};
return ( return (
<div <div
@@ -70,23 +128,30 @@ export function WrapIfAdditionalTemplate<
{!keyIsReadonly && ( {!keyIsReadonly && (
<div className="col-span-12 space-y-2 md:col-span-2"> <div className="col-span-12 space-y-2 md:col-span-2">
{displayLabel && <Label htmlFor={keyId}>{keyLabel}</Label>} {displayLabel && <Label htmlFor={keyId}>{keyLabel}</Label>}
{keyIsReadonly ? ( {keyLocked ? (
<div <div
id={keyId} id={keyId}
className="flex items-center text-sm text-muted-foreground" className="flex items-center break-all text-sm text-primary-variant"
> >
{label} {label}
</div> </div>
) : ( ) : (
<Input <>
id={keyId} <Input
name={keyId} id={keyId}
required={required} name={keyId}
defaultValue={label} required={required}
placeholder={keyPlaceholder} value={keyValue}
disabled={disabled || readonly} placeholder={keyPlaceholder}
onBlur={!readonly ? onKeyRenameBlur : undefined} disabled={disabled || readonly}
/> onChange={(e) => setKeyValue(e.target.value)}
onBlur={!readonly ? commitKeyRename : undefined}
aria-invalid={keyError ? true : undefined}
/>
{keyError && (
<p className="text-xs text-destructive">{keyError}</p>
)}
</>
)} )}
</div> </div>
)} )}