simple-module-settings 0.0.39__tar.gz → 0.0.40__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/PKG-INFO +4 -4
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/package.json +1 -1
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/pyproject.toml +4 -4
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/endpoints/module_api.py +2 -8
- simple_module_settings-0.0.40/settings/invalidation.py +70 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/module.py +14 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/ModulesEdit.tsx +5 -1
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/ModuleForm.tsx +20 -8
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/TestConnectionButton.tsx +16 -4
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/reload.py +11 -7
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/store.py +4 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_reload.py +17 -1
- simple_module_settings-0.0.40/tests/test_settings_commit_invalidation.py +99 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_secret_json_roundtrip.py +7 -0
- simple_module_settings-0.0.40/tests-js/settings_ModuleFormRevert.test.tsx +88 -0
- simple_module_settings-0.0.40/tests-js/settings_TestConnectionButton.test.tsx +111 -0
- simple_module_settings-0.0.39/tests-js/settings_TestConnectionButton.test.tsx +0 -47
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/.gitignore +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/LICENSE +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/README.md +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/__init__.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/_announce.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/_field_meta.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/_listing.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/_managed_keys.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/_module_settings.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/_module_settings_props.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/_row_masking.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/_secrets.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/_unique_write.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/browse_query.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/cli.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/constants.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/contracts/__init__.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/contracts/accessor.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/contracts/events.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/contracts/invalidation.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/contracts/registry.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/contracts/schemas.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/deps.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/endpoints/__init__.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/endpoints/_create_form.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/endpoints/api.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/endpoints/tenant_api.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/endpoints/views.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/env_vars.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/errors.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/exceptions.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/hydrate.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/known_keys.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/locales/de.json +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/locales/en.json +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/models.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/module_registry.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/Browse.tsx +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/Create.tsx +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/Edit.tsx +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/api.generated.ts +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/api.ts +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/FieldInput.tsx +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/FieldSource.tsx +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/JsonInput.tsx +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/KeyField.tsx +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/ModuleFieldRow.tsx +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/ResolvedValue.tsx +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/ScopeTabs.tsx +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/StoreCards.tsx +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/StoreTable.tsx +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/ValueInput.tsx +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/last-test.ts +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/validation-errors.test.ts +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/validation-errors.ts +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/routes.ts +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/types.ts +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/py.typed +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/registration.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/service.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/services.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/settings.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/tenant_scope.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/tenant_view.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_clear_route_per_scope.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_cli_import.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_concurrent_overrides.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_duplicate_and_race.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_env_pinned.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_hydrate.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_hydrate_related_fields.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_known_keys_meta.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_lenient_related_fields_validators.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_module_api.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_module_api_json_fields.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_module_registry.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_module_settings.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_module_settings_render.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_module_settings_serialize.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_registration.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_accessor.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_api.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_default_clears_override.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_domain_errors.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_env_unprefixed.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_events.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_field_sources.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_framework_reexports.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_group_metadata.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_host_restart_flags.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_module.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_schemas.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_secret_masking.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_secret_urls.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_service.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_typed_secret_masking.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_view_authz.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_store.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_store_filters.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_store_secret_masking.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_tenant_settings.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_testable_checks.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_upload_key_delete.py +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests-js/JsonFields.test.tsx +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests-js/ModuleForm.test.tsx +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests-js/ModulesEdit.test.tsx +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests-js/RestartHint.test.tsx +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests-js/ScopeTabs.test.tsx +0 -0
- {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tsconfig.json +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_settings
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.40
|
|
4
4
|
Summary: Runtime settings UI — modules plug their own settings panels into a shared admin view
|
|
5
5
|
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
6
|
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
@@ -21,9 +21,9 @@ Classifier: Topic :: Internet :: WWW/HTTP
|
|
|
21
21
|
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
22
22
|
Classifier: Typing :: Typed
|
|
23
23
|
Requires-Python: >=3.12
|
|
24
|
-
Requires-Dist: simple-module-core==0.0.
|
|
25
|
-
Requires-Dist: simple-module-db==0.0.
|
|
26
|
-
Requires-Dist: simple-module-hosting==0.0.
|
|
24
|
+
Requires-Dist: simple-module-core==0.0.40
|
|
25
|
+
Requires-Dist: simple-module-db==0.0.40
|
|
26
|
+
Requires-Dist: simple-module-hosting==0.0.40
|
|
27
27
|
Requires-Dist: typer<0.28,>=0.12
|
|
28
28
|
Description-Content-Type: text/markdown
|
|
29
29
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_settings"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.40"
|
|
4
4
|
description = "Runtime settings UI — modules plug their own settings panels into a shared admin view"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -21,9 +21,9 @@ classifiers = [
|
|
|
21
21
|
"Typing :: Typed",
|
|
22
22
|
]
|
|
23
23
|
dependencies = [
|
|
24
|
-
"simple_module_core==0.0.
|
|
25
|
-
"simple_module_db==0.0.
|
|
26
|
-
"simple_module_hosting==0.0.
|
|
24
|
+
"simple_module_core==0.0.40",
|
|
25
|
+
"simple_module_db==0.0.40",
|
|
26
|
+
"simple_module_hosting==0.0.40",
|
|
27
27
|
"typer>=0.12,<0.28",
|
|
28
28
|
]
|
|
29
29
|
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/endpoints/module_api.py
RENAMED
|
@@ -12,7 +12,6 @@ from typing import Any
|
|
|
12
12
|
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, Response, status
|
|
13
13
|
from pydantic import ValidationError
|
|
14
14
|
from simple_module_core.module_settings import module_settings_registry
|
|
15
|
-
from simple_module_core.settings_hydrate import hydrate_settings
|
|
16
15
|
from simple_module_hosting.i18n_deps import TranslatorDep
|
|
17
16
|
from simple_module_hosting.permissions import RequiresPermission
|
|
18
17
|
|
|
@@ -23,8 +22,8 @@ from settings._module_settings import (
|
|
|
23
22
|
)
|
|
24
23
|
from settings._module_settings_props import serialize
|
|
25
24
|
from settings.constants import PERM_DELETE, PERM_EDIT, PERM_VIEW
|
|
26
|
-
from settings.contracts.events import SettingsReloaded
|
|
27
25
|
from settings.deps import get_setting_service
|
|
26
|
+
from settings.invalidation import reload_on_commit
|
|
28
27
|
from settings.reload import EnvPinnedError, apply_changes_and_reload
|
|
29
28
|
from settings.service import SettingService
|
|
30
29
|
from settings.store import SettingsStore
|
|
@@ -162,11 +161,6 @@ async def clear_module_field(
|
|
|
162
161
|
store = SettingsStore(service)
|
|
163
162
|
await store.clear_override(package, field)
|
|
164
163
|
|
|
165
|
-
|
|
166
|
-
services = getattr(request.app.state, package)
|
|
167
|
-
services.settings = hydrated
|
|
168
|
-
|
|
169
|
-
bus = request.app.state.sm.event_bus
|
|
170
|
-
await bus.publish(SettingsReloaded(package=package, changed=(field,)))
|
|
164
|
+
reload_on_commit(request.app, request.app.state.sm.event_bus, store, package)
|
|
171
165
|
|
|
172
166
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Rehydrate module settings and notify local consumers only after durable writes."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
from simple_module_core.app_state import get_services
|
|
7
|
+
from simple_module_core.events import EventBus
|
|
8
|
+
from simple_module_core.invalidation import InvalidationBus
|
|
9
|
+
from simple_module_core.invalidation_refresh import InvalidationRefresh
|
|
10
|
+
from simple_module_core.module_settings import module_settings_registry
|
|
11
|
+
from simple_module_core.settings_hydrate import hydrate_settings
|
|
12
|
+
from simple_module_db.stored_settings import settings_table_exists
|
|
13
|
+
|
|
14
|
+
from settings.contracts.events import SettingsReloaded
|
|
15
|
+
from settings.store import SettingsStore
|
|
16
|
+
|
|
17
|
+
CHANNEL = "settings.modules"
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def reload_package(app: FastAPI, bus: EventBus, store: SettingsStore, package: str) -> None:
|
|
22
|
+
cls = module_settings_registry(app).get(package)
|
|
23
|
+
if cls is None:
|
|
24
|
+
return
|
|
25
|
+
services = getattr(app.state, package, None)
|
|
26
|
+
if services is None:
|
|
27
|
+
return
|
|
28
|
+
hydrated = await hydrate_settings(cls, store, package, lenient=True)
|
|
29
|
+
changed = tuple(
|
|
30
|
+
sorted(
|
|
31
|
+
name
|
|
32
|
+
for name in cls.model_fields
|
|
33
|
+
if getattr(services.settings, name) != getattr(hydrated, name)
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
services.settings = hydrated
|
|
37
|
+
if changed:
|
|
38
|
+
await bus.publish(SettingsReloaded(package=package, changed=changed))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def reload_on_commit(app: FastAPI, bus: EventBus, store: SettingsStore, package: str) -> None:
|
|
42
|
+
async def committed() -> None:
|
|
43
|
+
invalidation = getattr(get_services(app), "invalidation", None)
|
|
44
|
+
if invalidation is None:
|
|
45
|
+
await reload_package(app, bus, store, package)
|
|
46
|
+
else:
|
|
47
|
+
await invalidation.publish(CHANNEL, key=package)
|
|
48
|
+
|
|
49
|
+
store.on_commit(committed)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def subscribe(bus: InvalidationBus, app: FastAPI) -> InvalidationRefresh:
|
|
53
|
+
from settings.service import SettingService
|
|
54
|
+
|
|
55
|
+
async def reload(key: str | None) -> None:
|
|
56
|
+
async with app.state.sm.db.session_factory() as db:
|
|
57
|
+
if not await settings_table_exists(db): # fresh install, before migrations
|
|
58
|
+
return
|
|
59
|
+
store = SettingsStore(SettingService(db))
|
|
60
|
+
packages = [key] if key is not None else module_settings_registry(app).all_packages()
|
|
61
|
+
for package in packages:
|
|
62
|
+
# One package's failed read or invalid cross-field rule must not
|
|
63
|
+
# starve the packages after it on every later cycle.
|
|
64
|
+
try:
|
|
65
|
+
await reload_package(app, app.state.sm.event_bus, store, package)
|
|
66
|
+
except Exception:
|
|
67
|
+
logger.exception("Reloading %s settings failed; keeping current ones", package)
|
|
68
|
+
await db.rollback()
|
|
69
|
+
|
|
70
|
+
return InvalidationRefresh(bus, CHANNEL, reload)
|
|
@@ -29,6 +29,7 @@ from settings.constants import (
|
|
|
29
29
|
)
|
|
30
30
|
|
|
31
31
|
if TYPE_CHECKING:
|
|
32
|
+
from simple_module_core.invalidation import InvalidationBus
|
|
32
33
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
33
34
|
|
|
34
35
|
|
|
@@ -79,6 +80,19 @@ class SettingsModule(ModuleBase):
|
|
|
79
80
|
# Self-register so the UI lists our own settings alongside other modules.
|
|
80
81
|
services.module_registry.register(MODULE_PACKAGE, SettingsSettings)
|
|
81
82
|
|
|
83
|
+
def register_invalidations(self, bus: InvalidationBus, app: FastAPI) -> None:
|
|
84
|
+
from settings.invalidation import subscribe
|
|
85
|
+
|
|
86
|
+
self._refresh = subscribe(bus, app)
|
|
87
|
+
|
|
88
|
+
async def on_startup(self, app: FastAPI) -> None:
|
|
89
|
+
if hasattr(self, "_refresh"):
|
|
90
|
+
self._refresh.start()
|
|
91
|
+
|
|
92
|
+
async def on_shutdown(self, app: FastAPI) -> None:
|
|
93
|
+
if hasattr(self, "_refresh"):
|
|
94
|
+
await self._refresh.stop()
|
|
95
|
+
|
|
82
96
|
def register_exception_handlers(self, app: FastAPI) -> None:
|
|
83
97
|
from settings.errors import install_exception_handlers
|
|
84
98
|
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/ModulesEdit.tsx
RENAMED
|
@@ -142,7 +142,11 @@ function ModulesEdit({ modules, testable = {} }: Props) {
|
|
|
142
142
|
</div>
|
|
143
143
|
</Card>
|
|
144
144
|
) : current ? (
|
|
145
|
-
<ModuleForm
|
|
145
|
+
<ModuleForm
|
|
146
|
+
key={current.package}
|
|
147
|
+
module={current}
|
|
148
|
+
checks={testable[current.package] ?? []}
|
|
149
|
+
/>
|
|
146
150
|
) : (
|
|
147
151
|
<p className="text-muted-foreground">{t(keys.settings.modules.empty_title)}</p>
|
|
148
152
|
)}
|
|
@@ -42,18 +42,23 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
|
|
|
42
42
|
return o;
|
|
43
43
|
}, [m.fields]);
|
|
44
44
|
|
|
45
|
-
const [values,
|
|
46
|
-
const [baseline, setBaseline] = useState<Record<string, unknown>>(initial);
|
|
45
|
+
const [{ values, baseline }, setBuffer] = useState({ values: initial, baseline: initial });
|
|
47
46
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
48
47
|
const [busy, setBusy] = useState(false);
|
|
49
48
|
const [saved, setSaved] = useState(false);
|
|
50
49
|
const [formErrors, setFormErrors] = useState<string[]>([]);
|
|
51
50
|
|
|
52
|
-
//
|
|
53
|
-
// switch, or server-reloaded props after a save/reset).
|
|
51
|
+
// Refresh saved values without discarding drafts in other fields (#637).
|
|
54
52
|
useEffect(() => {
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
setBuffer((prev) => ({
|
|
54
|
+
values: Object.fromEntries(
|
|
55
|
+
Object.entries(initial).map(([name, value]) => [
|
|
56
|
+
name,
|
|
57
|
+
notEqual(prev.values[name], prev.baseline[name]) ? prev.values[name] : value,
|
|
58
|
+
]),
|
|
59
|
+
),
|
|
60
|
+
baseline: initial,
|
|
61
|
+
}));
|
|
57
62
|
setErrors({});
|
|
58
63
|
setSaved(false);
|
|
59
64
|
}, [initial]);
|
|
@@ -107,7 +112,7 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
|
|
|
107
112
|
} else {
|
|
108
113
|
// Keeping the form mounted lets the confirmation remain visible instead
|
|
109
114
|
// of being discarded by an immediate Inertia reload.
|
|
110
|
-
|
|
115
|
+
setBuffer((prev) => ({ ...prev, baseline: { ...values } }));
|
|
111
116
|
setSaved(true);
|
|
112
117
|
}
|
|
113
118
|
setBusy(false);
|
|
@@ -121,6 +126,10 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
|
|
|
121
126
|
params: { path: { package: m.package, field: name } },
|
|
122
127
|
}),
|
|
123
128
|
);
|
|
129
|
+
setBuffer((prev) => ({
|
|
130
|
+
...prev,
|
|
131
|
+
values: { ...prev.values, [name]: prev.baseline[name] },
|
|
132
|
+
}));
|
|
124
133
|
router.reload({ only: ['modules'] });
|
|
125
134
|
}
|
|
126
135
|
|
|
@@ -187,7 +196,10 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
|
|
|
187
196
|
onReset={() => onReset(f.name)}
|
|
188
197
|
onChange={(name, v) => {
|
|
189
198
|
setSaved(false);
|
|
190
|
-
|
|
199
|
+
setBuffer((prev) => ({
|
|
200
|
+
...prev,
|
|
201
|
+
values: { ...prev.values, [name]: v },
|
|
202
|
+
}));
|
|
191
203
|
}}
|
|
192
204
|
/>
|
|
193
205
|
))}
|
|
@@ -3,7 +3,7 @@ import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
|
3
3
|
import { useRelativeTime } from '@simple-module-py/ui/hooks/use-relative-time';
|
|
4
4
|
import { apiFetch } from '@simple-module-py/ui/lib/api';
|
|
5
5
|
import { CheckCircle2, PlugZap, XCircle } from 'lucide-react';
|
|
6
|
-
import { useEffect, useState } from 'react';
|
|
6
|
+
import { useEffect, useRef, useState } from 'react';
|
|
7
7
|
import { ROUTES } from '../routes';
|
|
8
8
|
import { type LastTest, readLastTest, writeLastTest } from './last-test';
|
|
9
9
|
|
|
@@ -35,9 +35,19 @@ export function TestConnectionButton({ pkg, checks }: Props) {
|
|
|
35
35
|
const [error, setError] = useState<string | null>(null);
|
|
36
36
|
const [last, setLast] = useState<LastTest | null>(null);
|
|
37
37
|
|
|
38
|
+
const requestId = useRef(0);
|
|
39
|
+
|
|
38
40
|
// Read on mount rather than during render: the result is per-tab session
|
|
39
41
|
// state, and touching sessionStorage while rendering breaks SSR hydration.
|
|
40
|
-
useEffect(() =>
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
setLast(readLastTest(pkg));
|
|
44
|
+
setResults(null);
|
|
45
|
+
setError(null);
|
|
46
|
+
setBusy(false);
|
|
47
|
+
return () => {
|
|
48
|
+
requestId.current++;
|
|
49
|
+
};
|
|
50
|
+
}, [pkg]);
|
|
41
51
|
|
|
42
52
|
// "users.mailer" → "mailer". Naming the check is the difference between
|
|
43
53
|
// "Test connection" and knowing an SMTP dial is about to happen; with two
|
|
@@ -50,6 +60,7 @@ export function TestConnectionButton({ pkg, checks }: Props) {
|
|
|
50
60
|
: t(keys.settings.modules.test_connection_generic);
|
|
51
61
|
|
|
52
62
|
async function run() {
|
|
63
|
+
const id = ++requestId.current;
|
|
53
64
|
setBusy(true);
|
|
54
65
|
setError(null);
|
|
55
66
|
setResults(null);
|
|
@@ -57,8 +68,9 @@ export function TestConnectionButton({ pkg, checks }: Props) {
|
|
|
57
68
|
const result = await apiFetch<{ checks?: CheckResult[] }>(ROUTES.testConnection(pkg), {
|
|
58
69
|
method: 'POST',
|
|
59
70
|
});
|
|
60
|
-
if (
|
|
61
|
-
|
|
71
|
+
if (id !== requestId.current) return;
|
|
72
|
+
const checkResults = result.data?.checks;
|
|
73
|
+
if (result.ok && Array.isArray(checkResults) && checkResults.length > 0) {
|
|
62
74
|
setResults(checkResults);
|
|
63
75
|
setLast(
|
|
64
76
|
writeLastTest(
|
|
@@ -11,9 +11,13 @@ from pydantic_core import to_jsonable_python
|
|
|
11
11
|
from pydantic_settings import BaseSettings
|
|
12
12
|
from simple_module_core.events import EventBus
|
|
13
13
|
from simple_module_core.module_settings import module_settings_registry
|
|
14
|
-
from simple_module_core.settings_hydrate import
|
|
14
|
+
from simple_module_core.settings_hydrate import (
|
|
15
|
+
env_supplied_fields,
|
|
16
|
+
hydrate_settings,
|
|
17
|
+
value_type_for_field,
|
|
18
|
+
)
|
|
15
19
|
|
|
16
|
-
from settings.
|
|
20
|
+
from settings.invalidation import reload_on_commit
|
|
17
21
|
from settings.store import SettingsStore
|
|
18
22
|
|
|
19
23
|
|
|
@@ -87,7 +91,7 @@ async def apply_changes_and_reload(
|
|
|
87
91
|
package: str,
|
|
88
92
|
changes: dict[str, Any],
|
|
89
93
|
) -> BaseSettings:
|
|
90
|
-
"""Validate
|
|
94
|
+
"""Validate and persist; reload and publish ``SettingsReloaded`` after commit."""
|
|
91
95
|
registry = module_settings_registry(app)
|
|
92
96
|
cls = registry.get(package)
|
|
93
97
|
if cls is None:
|
|
@@ -97,8 +101,8 @@ async def apply_changes_and_reload(
|
|
|
97
101
|
if unknown:
|
|
98
102
|
raise KeyError(f"Unknown field(s) for {package!r}: {sorted(unknown)}")
|
|
99
103
|
|
|
100
|
-
|
|
101
|
-
current =
|
|
104
|
+
# Include prior writes in this transaction without exposing them to other requests.
|
|
105
|
+
current = await hydrate_settings(cls, store, package, lenient=True)
|
|
102
106
|
# Compare against the *dumped* current value, not the attribute: a field
|
|
103
107
|
# typed as a list of models (branding's ``footer_links``) holds model
|
|
104
108
|
# instances, while ``changes`` carries the plain dicts a DTO dumps to. The
|
|
@@ -118,6 +122,7 @@ async def apply_changes_and_reload(
|
|
|
118
122
|
await store.clear_override(package, name)
|
|
119
123
|
|
|
120
124
|
if not diff:
|
|
125
|
+
reload_on_commit(app, bus, store, package)
|
|
121
126
|
return current
|
|
122
127
|
|
|
123
128
|
# Only a field that would actually change is refused: a client that echoes
|
|
@@ -153,6 +158,5 @@ async def apply_changes_and_reload(
|
|
|
153
158
|
except ValidationError as exc:
|
|
154
159
|
raise _relocate(exc, field_name) from exc
|
|
155
160
|
|
|
156
|
-
|
|
157
|
-
await bus.publish(SettingsReloaded(package=package, changed=tuple(sorted(diff))))
|
|
161
|
+
reload_on_commit(app, bus, store, package)
|
|
158
162
|
return validated
|
|
@@ -18,6 +18,7 @@ see ``_module_settings._field_view``.
|
|
|
18
18
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
|
+
from simple_module_db.callbacks import OnCommitCallback
|
|
21
22
|
from simple_module_db.stored_settings import ( # noqa: F401 (re-exported, #500)
|
|
22
23
|
SYSTEM_SCOPE_ID,
|
|
23
24
|
get_overrides_sync,
|
|
@@ -41,6 +42,9 @@ class SettingsStore:
|
|
|
41
42
|
def __init__(self, service: SettingService) -> None:
|
|
42
43
|
self._service = service
|
|
43
44
|
|
|
45
|
+
def on_commit(self, callback: OnCommitCallback) -> None:
|
|
46
|
+
self._service.db.on_commit(callback)
|
|
47
|
+
|
|
44
48
|
async def get_overrides(self, package: str) -> dict[str, tuple[str, str]]:
|
|
45
49
|
"""Return ``{field_name: (raw_value, value_type)}`` for a package."""
|
|
46
50
|
items = await self._service.list_by_scope_unmasked(SettingScope.SYSTEM, SYSTEM_SCOPE_ID)
|
|
@@ -14,6 +14,7 @@ from settings.settings import SettingsSettings
|
|
|
14
14
|
from settings.store import SettingsStore
|
|
15
15
|
from simple_module_core.events import EventBus
|
|
16
16
|
from simple_module_core.module_settings import register_module_settings
|
|
17
|
+
from simple_module_db.transaction import finalize_session
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
class _UsersCfg(BaseSettings):
|
|
@@ -56,7 +57,10 @@ async def test_apply_changes_updates_app_state_and_fires_event(app_and_bus, db_s
|
|
|
56
57
|
|
|
57
58
|
assert new_settings.allow_signup is True
|
|
58
59
|
assert new_settings.smtp_port == 587
|
|
59
|
-
assert app.state.users.settings is
|
|
60
|
+
assert app.state.users.settings.allow_signup is False
|
|
61
|
+
assert received == []
|
|
62
|
+
await finalize_session(db_session)
|
|
63
|
+
assert app.state.users.settings == new_settings
|
|
60
64
|
assert received == [SettingsReloaded(package="users", changed=("allow_signup", "smtp_port"))]
|
|
61
65
|
|
|
62
66
|
persisted = await store.get_overrides("users")
|
|
@@ -96,3 +100,15 @@ async def test_apply_changes_unknown_package_raises(app_and_bus, db_session):
|
|
|
96
100
|
package="unknown",
|
|
97
101
|
changes={"x": 1},
|
|
98
102
|
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def test_multiple_settings_edits_in_one_transaction_keep_both(app_and_bus, db_session):
|
|
106
|
+
app, bus = app_and_bus
|
|
107
|
+
store = SettingsStore(SettingService(db_session))
|
|
108
|
+
await apply_changes_and_reload(app, bus, store, package="users", changes={"allow_signup": True})
|
|
109
|
+
await apply_changes_and_reload(app, bus, store, package="users", changes={"smtp_port": 587})
|
|
110
|
+
assert app.state.users.settings.allow_signup is False
|
|
111
|
+
assert app.state.users.settings.smtp_port == 25
|
|
112
|
+
await finalize_session(db_session)
|
|
113
|
+
assert app.state.users.settings.allow_signup is True
|
|
114
|
+
assert app.state.users.settings.smtp_port == 587
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Module settings and reload events only reflect committed state."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from settings.contracts.events import SettingsReloaded
|
|
5
|
+
from settings.reload import apply_changes_and_reload
|
|
6
|
+
from settings.service import SettingService
|
|
7
|
+
from settings.store import SettingsStore
|
|
8
|
+
from simple_module_core.invalidation import Invalidation
|
|
9
|
+
from simple_module_db.transaction import finalize_session, rollback_session
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.mark.parametrize("commit", [False, True])
|
|
13
|
+
async def test_settings_and_events_wait_for_commit(app, commit):
|
|
14
|
+
received = []
|
|
15
|
+
bus = app.state.sm.event_bus
|
|
16
|
+
|
|
17
|
+
async def record(event):
|
|
18
|
+
received.append(event)
|
|
19
|
+
|
|
20
|
+
bus.subscribe(SettingsReloaded, record)
|
|
21
|
+
original = app.state.users.settings
|
|
22
|
+
wanted = not original.allow_signup
|
|
23
|
+
async with app.state.sm.db.session_factory() as db:
|
|
24
|
+
await apply_changes_and_reload(
|
|
25
|
+
app,
|
|
26
|
+
bus,
|
|
27
|
+
SettingsStore(SettingService(db)),
|
|
28
|
+
package="users",
|
|
29
|
+
changes={"allow_signup": wanted},
|
|
30
|
+
)
|
|
31
|
+
assert app.state.users.settings is original
|
|
32
|
+
assert received == []
|
|
33
|
+
if commit:
|
|
34
|
+
await finalize_session(db)
|
|
35
|
+
else:
|
|
36
|
+
await rollback_session(db)
|
|
37
|
+
assert app.state.users.settings.allow_signup is (wanted if commit else original.allow_signup)
|
|
38
|
+
assert len(received) == int(commit)
|
|
39
|
+
if commit:
|
|
40
|
+
assert received[0].changed == ("allow_signup",)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def test_remote_settings_changes_and_clears_reload_and_emit_event(app):
|
|
44
|
+
received = []
|
|
45
|
+
|
|
46
|
+
async def record(event):
|
|
47
|
+
received.append(event)
|
|
48
|
+
|
|
49
|
+
app.state.sm.event_bus.subscribe(SettingsReloaded, record)
|
|
50
|
+
original = app.state.users.settings.allow_signup
|
|
51
|
+
factory = app.state.sm.db.session_factory
|
|
52
|
+
async with factory() as db:
|
|
53
|
+
await SettingsStore(SettingService(db)).set_override(
|
|
54
|
+
"users", "allow_signup", str(not original), "bool"
|
|
55
|
+
)
|
|
56
|
+
await db.commit()
|
|
57
|
+
message = Invalidation(channel="settings.modules", key="users", origin="other")
|
|
58
|
+
await app.state.sm.invalidation.deliver(message.to_wire())
|
|
59
|
+
assert app.state.users.settings.allow_signup is not original
|
|
60
|
+
assert received[-1].changed == ("allow_signup",)
|
|
61
|
+
async with factory() as db:
|
|
62
|
+
await SettingsStore(SettingService(db)).clear_override("users", "allow_signup")
|
|
63
|
+
await db.commit()
|
|
64
|
+
await app.state.sm.invalidation.deliver(message.to_wire())
|
|
65
|
+
assert app.state.users.settings.allow_signup is original
|
|
66
|
+
assert len(received) == 2
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@pytest.mark.parametrize("commit", [False, True])
|
|
70
|
+
async def test_clearing_module_setting_waits_for_commit(app, commit):
|
|
71
|
+
from settings.endpoints.module_api import clear_module_field
|
|
72
|
+
from starlette.requests import Request
|
|
73
|
+
|
|
74
|
+
original = app.state.users.settings.allow_signup
|
|
75
|
+
factory = app.state.sm.db.session_factory
|
|
76
|
+
async with factory() as db:
|
|
77
|
+
await SettingsStore(SettingService(db)).set_override(
|
|
78
|
+
"users", "allow_signup", str(not original), "bool"
|
|
79
|
+
)
|
|
80
|
+
await db.commit()
|
|
81
|
+
await app.state.sm.invalidation.publish("settings.modules", key="users")
|
|
82
|
+
received = []
|
|
83
|
+
|
|
84
|
+
async def record(event):
|
|
85
|
+
received.append(event)
|
|
86
|
+
|
|
87
|
+
app.state.sm.event_bus.subscribe(SettingsReloaded, record)
|
|
88
|
+
async with factory() as db:
|
|
89
|
+
await clear_module_field(
|
|
90
|
+
"users", "allow_signup", Request({"type": "http", "app": app}), SettingService(db)
|
|
91
|
+
)
|
|
92
|
+
assert app.state.users.settings.allow_signup is not original
|
|
93
|
+
assert received == []
|
|
94
|
+
if commit:
|
|
95
|
+
await finalize_session(db)
|
|
96
|
+
else:
|
|
97
|
+
await rollback_session(db)
|
|
98
|
+
assert app.state.users.settings.allow_signup is (original if commit else not original)
|
|
99
|
+
assert len(received) == int(commit)
|
|
@@ -25,6 +25,13 @@ class _SecretCfg(BaseSettings):
|
|
|
25
25
|
class _Store:
|
|
26
26
|
def __init__(self) -> None:
|
|
27
27
|
self.rows: dict[str, tuple[str, str]] = {}
|
|
28
|
+
self.callbacks = []
|
|
29
|
+
|
|
30
|
+
def on_commit(self, callback) -> None:
|
|
31
|
+
self.callbacks.append(callback)
|
|
32
|
+
|
|
33
|
+
async def get_overrides(self, package):
|
|
34
|
+
return dict(self.rows)
|
|
28
35
|
|
|
29
36
|
async def set_override(self, package, field, raw, value_type) -> None:
|
|
30
37
|
self.rows[field] = (raw, value_type)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import '@testing-library/jest-dom/vitest';
|
|
2
|
+
import { configureI18n } from '@simple-module-py/i18n';
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
4
|
+
import { afterEach, expect, test, vi } from 'vitest';
|
|
5
|
+
import type { FieldMeta } from '../settings/pages/components/FieldInput';
|
|
6
|
+
import { ModuleForm, type ModuleView } from '../settings/pages/components/ModuleForm';
|
|
7
|
+
import ModulesEdit from '../settings/pages/ModulesEdit';
|
|
8
|
+
|
|
9
|
+
const mocks = vi.hoisted(() => ({ reload: vi.fn() }));
|
|
10
|
+
vi.mock('@inertiajs/react', () => ({ router: { reload: mocks.reload }, Head: () => null }));
|
|
11
|
+
configureI18n({
|
|
12
|
+
locale: 'en',
|
|
13
|
+
messages: {
|
|
14
|
+
'settings.modules_form.save': 'Save',
|
|
15
|
+
'settings.modules_form.reset_to_default': 'Revert',
|
|
16
|
+
'settings.modules.unsaved_count_one': '{count} unsaved',
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function field(name: string, value: string, db_override = false): FieldMeta {
|
|
21
|
+
return {
|
|
22
|
+
name,
|
|
23
|
+
value,
|
|
24
|
+
db_override,
|
|
25
|
+
type: 'string',
|
|
26
|
+
default: '',
|
|
27
|
+
description: '',
|
|
28
|
+
is_secret: false,
|
|
29
|
+
requires_restart: false,
|
|
30
|
+
group: null,
|
|
31
|
+
env_var: 'SM_TEST_FIELD',
|
|
32
|
+
env_set: false,
|
|
33
|
+
env_readable: false,
|
|
34
|
+
choices: null,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function view(fields: FieldMeta[]): ModuleView {
|
|
38
|
+
return {
|
|
39
|
+
module_name: 'Users',
|
|
40
|
+
package: 'users',
|
|
41
|
+
env_prefix: 'SM_USERS_',
|
|
42
|
+
class_name: 'UsersSettings',
|
|
43
|
+
fields,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
vi.unstubAllGlobals();
|
|
49
|
+
vi.clearAllMocks();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('reverting one field preserves other drafts and their saved baseline after props reload', async () => {
|
|
53
|
+
const fetchMock = vi.fn(async () => new Response('{}', { status: 200 }));
|
|
54
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
55
|
+
const { rerender } = render(
|
|
56
|
+
<ModuleForm
|
|
57
|
+
module={view([field('smtp_host', 'override', true), field('sender', 'original')])}
|
|
58
|
+
/>,
|
|
59
|
+
);
|
|
60
|
+
fireEvent.change(screen.getByLabelText('sender'), { target: { value: 'draft' } });
|
|
61
|
+
fireEvent.change(screen.getByLabelText('smtp_host'), { target: { value: 'discard me' } });
|
|
62
|
+
fireEvent.click(screen.getByRole('button', { name: 'Revert' }));
|
|
63
|
+
await waitFor(() => expect(mocks.reload).toHaveBeenCalled());
|
|
64
|
+
rerender(
|
|
65
|
+
<ModuleForm module={view([field('smtp_host', 'default'), field('sender', 'original')])} />,
|
|
66
|
+
);
|
|
67
|
+
expect(screen.getByLabelText('smtp_host')).toHaveValue('default');
|
|
68
|
+
expect(screen.getByLabelText('sender')).toHaveValue('draft');
|
|
69
|
+
expect(screen.getByText('1 unsaved')).toBeVisible();
|
|
70
|
+
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
|
71
|
+
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
|
72
|
+
const [request] = fetchMock.mock.calls[1] as unknown as [Request];
|
|
73
|
+
expect(await request.json()).toEqual({ sender: 'draft' });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('switching modules starts a fresh edit buffer even when field names match', () => {
|
|
77
|
+
const users = view([field('sender', 'users original')]);
|
|
78
|
+
const mail = {
|
|
79
|
+
...view([field('sender', 'mail original')]),
|
|
80
|
+
module_name: 'Mail',
|
|
81
|
+
package: 'mail',
|
|
82
|
+
};
|
|
83
|
+
render(<ModulesEdit modules={[users, mail]} />);
|
|
84
|
+
fireEvent.change(screen.getByLabelText('sender'), { target: { value: 'users draft' } });
|
|
85
|
+
fireEvent.click(screen.getByRole('button', { name: /mail/ }));
|
|
86
|
+
expect(screen.getByLabelText('sender')).toHaveValue('mail original');
|
|
87
|
+
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
|
|
88
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import '@testing-library/jest-dom/vitest';
|
|
2
|
+
import { configureI18n, keys } from '@simple-module-py/i18n';
|
|
3
|
+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
configureI18n({ locale: 'en', messages: {} });
|
|
7
|
+
|
|
8
|
+
import { TestConnectionButton } from '../settings/pages/components/TestConnectionButton';
|
|
9
|
+
|
|
10
|
+
function stub(status: number, body: unknown) {
|
|
11
|
+
const fetchMock = vi.fn(async () => new Response(JSON.stringify(body), { status }));
|
|
12
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
13
|
+
return fetchMock;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const click = () =>
|
|
17
|
+
fireEvent.click(
|
|
18
|
+
screen.getByRole('button', { name: keys.settings.modules.test_connection_generic }),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
describe('TestConnectionButton', () => {
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.unstubAllGlobals();
|
|
24
|
+
window.sessionStorage.clear();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('clears the displayed result when the package changes', async () => {
|
|
28
|
+
stub(200, { checks: [{ name: 'a.b', status: 'healthy', detail: 'users connected' }] });
|
|
29
|
+
const { rerender } = render(<TestConnectionButton pkg="users" checks={['a.b', 'c.d']} />);
|
|
30
|
+
click();
|
|
31
|
+
expect(await screen.findByText('users connected')).toBeVisible();
|
|
32
|
+
rerender(<TestConnectionButton pkg="mail" checks={['a.b', 'c.d']} />);
|
|
33
|
+
expect(screen.queryByText('users connected')).not.toBeInTheDocument();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('ignores a pending result after switching packages, including switching back', async () => {
|
|
37
|
+
let finish!: (response: Response) => void;
|
|
38
|
+
vi.stubGlobal(
|
|
39
|
+
'fetch',
|
|
40
|
+
vi.fn(
|
|
41
|
+
() =>
|
|
42
|
+
new Promise<Response>((resolve) => {
|
|
43
|
+
finish = resolve;
|
|
44
|
+
}),
|
|
45
|
+
),
|
|
46
|
+
);
|
|
47
|
+
const { rerender } = render(<TestConnectionButton pkg="users" checks={['a.b', 'c.d']} />);
|
|
48
|
+
click();
|
|
49
|
+
rerender(<TestConnectionButton pkg="mail" checks={['a.b', 'c.d']} />);
|
|
50
|
+
expect(screen.getByRole('button')).toBeEnabled();
|
|
51
|
+
rerender(<TestConnectionButton pkg="users" checks={['a.b', 'c.d']} />);
|
|
52
|
+
await act(async () =>
|
|
53
|
+
finish(
|
|
54
|
+
new Response(
|
|
55
|
+
JSON.stringify({
|
|
56
|
+
checks: [{ name: 'a.b', status: 'healthy', detail: 'stale result' }],
|
|
57
|
+
}),
|
|
58
|
+
{ status: 200 },
|
|
59
|
+
),
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
expect(screen.queryByText('stale result')).not.toBeInTheDocument();
|
|
63
|
+
expect(window.sessionStorage.getItem('sm.settings.last-test.users')).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it.each([null, {}, { checks: [] }, { checks: 'invalid' }])(
|
|
67
|
+
'does not record success for an invalid check response: %j',
|
|
68
|
+
async (body) => {
|
|
69
|
+
stub(200, body);
|
|
70
|
+
render(<TestConnectionButton pkg="users" checks={['a.b', 'c.d']} />);
|
|
71
|
+
click();
|
|
72
|
+
expect(await screen.findByText(keys.settings.modules.test_failed)).toBeVisible();
|
|
73
|
+
expect(
|
|
74
|
+
JSON.parse(window.sessionStorage.getItem('sm.settings.last-test.users') ?? '{}').ok,
|
|
75
|
+
).toBe(false);
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
it('does not record success when the response body cannot be read', async () => {
|
|
80
|
+
const response = new Response('{}', { status: 200 });
|
|
81
|
+
vi.spyOn(response, 'text').mockRejectedValue(new TypeError('stream interrupted'));
|
|
82
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
|
|
83
|
+
render(<TestConnectionButton pkg="users" checks={['a.b', 'c.d']} />);
|
|
84
|
+
click();
|
|
85
|
+
expect(await screen.findByText(keys.settings.modules.test_failed)).toBeVisible();
|
|
86
|
+
expect(
|
|
87
|
+
JSON.parse(window.sessionStorage.getItem('sm.settings.last-test.users') ?? '{}').ok,
|
|
88
|
+
).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('POSTs to the view route and shows each check detail', async () => {
|
|
92
|
+
const fetchMock = stub(200, {
|
|
93
|
+
checks: [{ name: 'users.mailer', status: 'unhealthy', detail: 'connection refused' }],
|
|
94
|
+
});
|
|
95
|
+
render(<TestConnectionButton pkg="users" checks={['a.b', 'c.d']} />);
|
|
96
|
+
click();
|
|
97
|
+
expect(await screen.findByText('connection refused')).toBeInTheDocument();
|
|
98
|
+
const [req] = fetchMock.mock.calls[0] as unknown as [Request];
|
|
99
|
+
expect(req.method).toBe('POST');
|
|
100
|
+
expect(new URL(req.url).pathname).toBe('/admin/settings/test-connection/users');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('shows a translated line when the call itself fails', async () => {
|
|
104
|
+
stub(500, null);
|
|
105
|
+
render(<TestConnectionButton pkg="users" checks={['a.b', 'c.d']} />);
|
|
106
|
+
click();
|
|
107
|
+
await waitFor(() =>
|
|
108
|
+
expect(screen.getByText(keys.settings.modules.test_failed)).toBeInTheDocument(),
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import '@testing-library/jest-dom/vitest';
|
|
2
|
-
import { configureI18n, keys } from '@simple-module-py/i18n';
|
|
3
|
-
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
4
|
-
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
-
|
|
6
|
-
configureI18n({ locale: 'en', messages: {} });
|
|
7
|
-
|
|
8
|
-
import { TestConnectionButton } from '../settings/pages/components/TestConnectionButton';
|
|
9
|
-
|
|
10
|
-
function stub(status: number, body: unknown) {
|
|
11
|
-
const fetchMock = vi.fn(async () => new Response(JSON.stringify(body), { status }));
|
|
12
|
-
vi.stubGlobal('fetch', fetchMock);
|
|
13
|
-
return fetchMock;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const click = () =>
|
|
17
|
-
fireEvent.click(
|
|
18
|
-
screen.getByRole('button', { name: keys.settings.modules.test_connection_generic }),
|
|
19
|
-
);
|
|
20
|
-
|
|
21
|
-
describe('TestConnectionButton', () => {
|
|
22
|
-
afterEach(() => {
|
|
23
|
-
vi.unstubAllGlobals();
|
|
24
|
-
window.sessionStorage.clear();
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it('POSTs to the view route and shows each check detail', async () => {
|
|
28
|
-
const fetchMock = stub(200, {
|
|
29
|
-
checks: [{ name: 'users.mailer', status: 'unhealthy', detail: 'connection refused' }],
|
|
30
|
-
});
|
|
31
|
-
render(<TestConnectionButton pkg="users" checks={['a.b', 'c.d']} />);
|
|
32
|
-
click();
|
|
33
|
-
expect(await screen.findByText('connection refused')).toBeInTheDocument();
|
|
34
|
-
const [req] = fetchMock.mock.calls[0] as unknown as [Request];
|
|
35
|
-
expect(req.method).toBe('POST');
|
|
36
|
-
expect(new URL(req.url).pathname).toBe('/admin/settings/test-connection/users');
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('shows a translated line when the call itself fails', async () => {
|
|
40
|
-
stub(500, null);
|
|
41
|
-
render(<TestConnectionButton pkg="users" checks={['a.b', 'c.d']} />);
|
|
42
|
-
click();
|
|
43
|
-
await waitFor(() =>
|
|
44
|
-
expect(screen.getByText(keys.settings.modules.test_failed)).toBeInTheDocument(),
|
|
45
|
-
);
|
|
46
|
-
});
|
|
47
|
-
});
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/_module_settings.py
RENAMED
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/_module_settings_props.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/contracts/__init__.py
RENAMED
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/contracts/accessor.py
RENAMED
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/contracts/events.py
RENAMED
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/contracts/invalidation.py
RENAMED
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/contracts/registry.py
RENAMED
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/contracts/schemas.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/endpoints/__init__.py
RENAMED
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/endpoints/_create_form.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/endpoints/tenant_api.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/api.generated.ts
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_clear_route_per_scope.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_concurrent_overrides.py
RENAMED
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_duplicate_and_race.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_hydrate_related_fields.py
RENAMED
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_known_keys_meta.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_module_api_json_fields.py
RENAMED
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_module_registry.py
RENAMED
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_module_settings.py
RENAMED
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_module_settings_render.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_accessor.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_domain_errors.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_events.py
RENAMED
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_field_sources.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_module.py
RENAMED
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_schemas.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_secret_urls.py
RENAMED
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_service.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_view_authz.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_store_secret_masking.py
RENAMED
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_tenant_settings.py
RENAMED
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_testable_checks.py
RENAMED
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_upload_key_delete.py
RENAMED
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests-js/JsonFields.test.tsx
RENAMED
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests-js/ModuleForm.test.tsx
RENAMED
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests-js/ModulesEdit.test.tsx
RENAMED
|
File without changes
|
{simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests-js/RestartHint.test.tsx
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|