simple-module-feature-flags 0.0.37__tar.gz → 0.0.38__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_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/.gitignore +1 -2
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/PKG-INFO +5 -5
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/README.md +1 -1
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/contracts/schemas.py +1 -1
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/endpoints/views.py +3 -1
- simple_module_feature_flags-0.0.38/feature_flags/locales/de.json +56 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/locales/en.json +2 -1
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/module.py +3 -5
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/pages/Browse.tsx +2 -4
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/service.py +26 -6
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/package.json +1 -1
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/pyproject.toml +4 -4
- simple_module_feature_flags-0.0.38/tests/test_flag_description_i18n.py +38 -0
- simple_module_feature_flags-0.0.38/tests-js/feature_flags_Browse.test.tsx +195 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/LICENSE +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/__init__.py +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/constants.py +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/contracts/__init__.py +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/deps.py +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/endpoints/__init__.py +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/endpoints/api.py +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/models.py +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/pages/components/FlagTable.tsx +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/pages/components/ScopeCard.tsx +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/pages/components/TenantPicker.tsx +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/pages/components/ToggleConfirmDialog.tsx +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/pages/types.ts +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/py.typed +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/tests/conftest.py +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/tests/test_feature_flags_api.py +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/tests/test_feature_flags_audit_links.py +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/tests/test_feature_flags_menu_section.py +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/tests/test_feature_flags_service.py +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/tests/test_feature_flags_tenancy.py +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/tests/test_store_outage_fallback.py +0 -0
- {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/tsconfig.json +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_feature_flags
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.38
|
|
4
4
|
Summary: Simple feature-flag module with per-tenant overrides and a consumer API for simple_module
|
|
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.38
|
|
25
|
+
Requires-Dist: simple-module-db==0.0.38
|
|
26
|
+
Requires-Dist: simple-module-hosting==0.0.38
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
28
28
|
|
|
29
29
|
# simple_module_feature_flags
|
|
@@ -61,7 +61,7 @@ Gate a route using the registry dependency:
|
|
|
61
61
|
```python
|
|
62
62
|
from fastapi import APIRouter, HTTPException
|
|
63
63
|
|
|
64
|
-
from feature_flags.deps import FeatureFlagRegistryDep
|
|
64
|
+
from feature_flags.deps import FeatureFlagRegistryDep
|
|
65
65
|
|
|
66
66
|
router = APIRouter()
|
|
67
67
|
|
|
@@ -33,7 +33,7 @@ Gate a route using the registry dependency:
|
|
|
33
33
|
```python
|
|
34
34
|
from fastapi import APIRouter, HTTPException
|
|
35
35
|
|
|
36
|
-
from feature_flags.deps import FeatureFlagRegistryDep
|
|
36
|
+
from feature_flags.deps import FeatureFlagRegistryDep
|
|
37
37
|
|
|
38
38
|
router = APIRouter()
|
|
39
39
|
|
|
@@ -11,7 +11,7 @@ from sqlmodel import Field, SQLModel
|
|
|
11
11
|
class FeatureFlagOverrideOut(SQLModel):
|
|
12
12
|
"""Stored override row as returned by the service/API."""
|
|
13
13
|
|
|
14
|
-
model_config = ConfigDict(from_attributes=True)
|
|
14
|
+
model_config = ConfigDict(from_attributes=True) # ty: ignore[invalid-assignment]
|
|
15
15
|
|
|
16
16
|
id: int
|
|
17
17
|
scope: str
|
|
@@ -9,6 +9,7 @@ frontend doesn't need separate routes per scope.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
from fastapi import APIRouter, Depends, Request
|
|
12
|
+
from simple_module_hosting.i18n_deps import TranslatorDep
|
|
12
13
|
from simple_module_hosting.inertia_deps import InertiaDep
|
|
13
14
|
from simple_module_hosting.permissions import RequiresPermission
|
|
14
15
|
from simple_module_inertia import InertiaResponse
|
|
@@ -75,9 +76,10 @@ async def browse(
|
|
|
75
76
|
inertia: InertiaDep,
|
|
76
77
|
service: FeatureFlagServiceDep,
|
|
77
78
|
registry: FeatureFlagRegistryDep,
|
|
79
|
+
translator: TranslatorDep,
|
|
78
80
|
tenant_id: str | None = None,
|
|
79
81
|
) -> InertiaResponse:
|
|
80
|
-
flags = await service.list_flags(registry, tenant_id=tenant_id)
|
|
82
|
+
flags = await service.list_flags(registry, tenant_id=tenant_id, translate=translator.t)
|
|
81
83
|
tenants = await service.list_tenants_with_overrides()
|
|
82
84
|
return await inertia.render(
|
|
83
85
|
PAGE_BROWSE,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"browse": {
|
|
3
|
+
"title": "Funktionsschalter",
|
|
4
|
+
"description": "Laufzeitschalter. Eine Organisationsüberschreibung hat Vorrang vor dem Systemwert.",
|
|
5
|
+
"empty_title": "Keine Funktionsschalter registriert",
|
|
6
|
+
"empty_description": "Module deklarieren Schalter über register_feature_flags(). Installieren Sie eines, um sie hier zu sehen.",
|
|
7
|
+
"scope_label": "Geltungsbereich",
|
|
8
|
+
"viewing_system": "Systemweite Schalter — gelten für jede Organisation, sofern sie nicht überschrieben werden.",
|
|
9
|
+
"viewing_tenant": "Überschreibungen für Organisation {tenant_id} — nicht gesetzte Schalter verwenden den Systemwert.",
|
|
10
|
+
"view_history": "Änderungsverlauf anzeigen →",
|
|
11
|
+
"audit_note": "Jede Umschaltung wird mit Akteur und vorherigem Wert im Änderungsprotokoll erfasst.",
|
|
12
|
+
"tenant_id_label": "Organisations-ID",
|
|
13
|
+
"tenant_id_placeholder": "z. B. acme",
|
|
14
|
+
"scope_system": "System",
|
|
15
|
+
"scope_custom": "Andere…",
|
|
16
|
+
"go": "Anzeigen",
|
|
17
|
+
"cancel": "Abbrechen"
|
|
18
|
+
},
|
|
19
|
+
"table": {
|
|
20
|
+
"name": "Name",
|
|
21
|
+
"description": "Beschreibung",
|
|
22
|
+
"system": "System",
|
|
23
|
+
"effective": "Wirksam",
|
|
24
|
+
"actions": "Aktionen",
|
|
25
|
+
"on": "ein",
|
|
26
|
+
"off": "aus",
|
|
27
|
+
"enabled": "Aktiviert",
|
|
28
|
+
"disabled": "Deaktiviert",
|
|
29
|
+
"overridden": "Überschreibung",
|
|
30
|
+
"following_default": "Standardwert gilt",
|
|
31
|
+
"following_system": "Systemwert gilt",
|
|
32
|
+
"clear_override": "Überschreibung entfernen"
|
|
33
|
+
},
|
|
34
|
+
"confirm": {
|
|
35
|
+
"enable_title": "„{name}“ aktivieren?",
|
|
36
|
+
"disable_title": "„{name}“ deaktivieren?",
|
|
37
|
+
"scope_system": "Dies ändert den Wert sofort für jede Organisation.",
|
|
38
|
+
"scope_tenant": "Dies setzt eine Überschreibung nur für die Organisation „{tenant_id}“. Andere Organisationen verwenden weiterhin den Systemwert.",
|
|
39
|
+
"enable_action": "Aktivieren",
|
|
40
|
+
"disable_action": "Deaktivieren",
|
|
41
|
+
"cancel": "Abbrechen"
|
|
42
|
+
},
|
|
43
|
+
"toasts": {
|
|
44
|
+
"enabled": "„{name}“ aktiviert",
|
|
45
|
+
"disabled": "„{name}“ deaktiviert",
|
|
46
|
+
"cleared": "Überschreibung für „{name}“ entfernt",
|
|
47
|
+
"toggle_failed": "Funktionsschalter konnte nicht aktualisiert werden"
|
|
48
|
+
},
|
|
49
|
+
"audit": {
|
|
50
|
+
"override": "Funktionsschalterüberschreibung"
|
|
51
|
+
},
|
|
52
|
+
"nav": {
|
|
53
|
+
"feature_flags": "Funktionsschalter"
|
|
54
|
+
},
|
|
55
|
+
"permission_group": "Feature-Flags"
|
|
56
|
+
}
|
{simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/module.py
RENAMED
|
@@ -103,14 +103,11 @@ class FeatureFlagsModule(ModuleBase):
|
|
|
103
103
|
from feature_flags.models import FeatureFlagOverride
|
|
104
104
|
|
|
105
105
|
registry.register(
|
|
106
|
-
AuditLink(
|
|
107
|
-
|
|
108
|
-
# The table name travels alongside as the audit log's type tag.
|
|
109
|
-
entity_type=FeatureFlagOverride.__name__,
|
|
106
|
+
AuditLink.for_model(
|
|
107
|
+
FeatureFlagOverride,
|
|
110
108
|
url_template=f"{MENU_URL}?{QP_OVERRIDE}={{id}}",
|
|
111
109
|
label=AUDIT_LINK_LABEL,
|
|
112
110
|
label_key=AUDIT_LINK_LABEL_KEY,
|
|
113
|
-
table_name=FeatureFlagOverride.__tablename__,
|
|
114
111
|
label_resolver=_resolve_override_labels,
|
|
115
112
|
)
|
|
116
113
|
)
|
|
@@ -122,6 +119,7 @@ class FeatureFlagsModule(ModuleBase):
|
|
|
122
119
|
PERM_FEATURE_FLAGS_VIEW,
|
|
123
120
|
PERM_FEATURE_FLAGS_MANAGE,
|
|
124
121
|
],
|
|
122
|
+
label_key="feature_flags.permission_group",
|
|
125
123
|
)
|
|
126
124
|
|
|
127
125
|
def locale_dirs(self) -> dict[str, Path]:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Head, router
|
|
1
|
+
import { Head, router } from '@inertiajs/react';
|
|
2
2
|
import { keys, useT } from '@simple-module-py/i18n';
|
|
3
3
|
import { PageShell } from '@simple-module-py/ui/components/PageShell';
|
|
4
4
|
import { usePermissions } from '@simple-module-py/ui/hooks/use-permissions';
|
|
@@ -32,9 +32,7 @@ function actionUrl(name: string, action: 'toggle' | 'clear', tenantId: string |
|
|
|
32
32
|
return tenantId ? `${base}?tenant_id=${encodeURIComponent(tenantId)}` : base;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
function Browse() {
|
|
36
|
-
const { flags, tenant_id, tenants, audit_log_url } = usePage<{ props: Props }>()
|
|
37
|
-
.props as unknown as Props;
|
|
35
|
+
function Browse({ flags, tenant_id, tenants, audit_log_url }: Props) {
|
|
38
36
|
const { t } = useT();
|
|
39
37
|
const { can } = usePermissions();
|
|
40
38
|
const canManage = can('feature_flags.manage');
|
{simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/service.py
RENAMED
|
@@ -7,6 +7,8 @@ mirrors mutations into the registry.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
|
|
10
12
|
from simple_module_core.feature_flags import FeatureFlagDefinition, FeatureFlagRegistry
|
|
11
13
|
from sqlalchemy import select
|
|
12
14
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
@@ -21,16 +23,25 @@ def _registry_tenant_id(scope: str, scope_id: str) -> str | None:
|
|
|
21
23
|
return scope_id if scope == SCOPE_TENANT else None
|
|
22
24
|
|
|
23
25
|
|
|
26
|
+
def _description(flag: FeatureFlagDefinition, translate: Callable[[str], str] | None) -> str:
|
|
27
|
+
# Translator.t() echoes an unknown key back; keep the literal in that case.
|
|
28
|
+
if not flag.description_key or translate is None:
|
|
29
|
+
return flag.description
|
|
30
|
+
translated = translate(flag.description_key)
|
|
31
|
+
return flag.description if translated == flag.description_key else translated
|
|
32
|
+
|
|
33
|
+
|
|
24
34
|
def _build_view(
|
|
25
35
|
flag: FeatureFlagDefinition,
|
|
26
36
|
*,
|
|
27
37
|
enabled: bool,
|
|
28
38
|
overridden: bool,
|
|
29
39
|
system_enabled: bool | None = None,
|
|
40
|
+
translate: Callable[[str], str] | None = None,
|
|
30
41
|
) -> FeatureFlagView:
|
|
31
42
|
return FeatureFlagView(
|
|
32
43
|
name=flag.name,
|
|
33
|
-
description=flag
|
|
44
|
+
description=_description(flag, translate),
|
|
34
45
|
default_enabled=flag.default_enabled,
|
|
35
46
|
enabled=enabled,
|
|
36
47
|
overridden=overridden,
|
|
@@ -73,7 +84,10 @@ class FeatureFlagService:
|
|
|
73
84
|
return list(result.scalars())
|
|
74
85
|
|
|
75
86
|
async def list_flags(
|
|
76
|
-
self,
|
|
87
|
+
self,
|
|
88
|
+
registry: FeatureFlagRegistry,
|
|
89
|
+
tenant_id: str | None = None,
|
|
90
|
+
translate: Callable[[str], str] | None = None,
|
|
77
91
|
) -> list[FeatureFlagView]:
|
|
78
92
|
"""Join registered definitions with persisted overrides for admin display.
|
|
79
93
|
|
|
@@ -95,7 +109,12 @@ class FeatureFlagService:
|
|
|
95
109
|
system_enabled = system_value if system_value is not None else flag.default_enabled
|
|
96
110
|
if tenant_id is None:
|
|
97
111
|
views.append(
|
|
98
|
-
_build_view(
|
|
112
|
+
_build_view(
|
|
113
|
+
flag,
|
|
114
|
+
enabled=system_enabled,
|
|
115
|
+
overridden=system_value is not None,
|
|
116
|
+
translate=translate,
|
|
117
|
+
)
|
|
99
118
|
)
|
|
100
119
|
continue
|
|
101
120
|
tenant_value = registry.tenant_override(flag.name, tenant_id)
|
|
@@ -105,6 +124,7 @@ class FeatureFlagService:
|
|
|
105
124
|
enabled=tenant_value if tenant_value is not None else system_enabled,
|
|
106
125
|
overridden=tenant_value is not None,
|
|
107
126
|
system_enabled=system_enabled,
|
|
127
|
+
translate=translate,
|
|
108
128
|
)
|
|
109
129
|
)
|
|
110
130
|
return views
|
|
@@ -131,9 +151,9 @@ class FeatureFlagService:
|
|
|
131
151
|
async def _find(self, scope: str, scope_id: str, name: str) -> FeatureFlagOverride | None:
|
|
132
152
|
result = await self.db.execute(
|
|
133
153
|
select(FeatureFlagOverride).where(
|
|
134
|
-
FeatureFlagOverride.scope == scope,
|
|
135
|
-
FeatureFlagOverride.scope_id == scope_id,
|
|
136
|
-
FeatureFlagOverride.name == name,
|
|
154
|
+
FeatureFlagOverride.scope == scope, # ty: ignore[invalid-argument-type]
|
|
155
|
+
FeatureFlagOverride.scope_id == scope_id, # ty: ignore[invalid-argument-type]
|
|
156
|
+
FeatureFlagOverride.name == name, # ty: ignore[invalid-argument-type]
|
|
137
157
|
)
|
|
138
158
|
)
|
|
139
159
|
return result.scalar_one_or_none()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_feature_flags"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.38"
|
|
4
4
|
description = "Simple feature-flag module with per-tenant overrides and a consumer API for simple_module"
|
|
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.38",
|
|
25
|
+
"simple_module_db==0.0.38",
|
|
26
|
+
"simple_module_hosting==0.0.38",
|
|
27
27
|
]
|
|
28
28
|
|
|
29
29
|
[project.entry-points.simple_module]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Feature flag descriptions are translated server-side (TEST-016)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from feature_flags.service import FeatureFlagService
|
|
6
|
+
from simple_module_core.feature_flags import FeatureFlagDefinition, FeatureFlagRegistry
|
|
7
|
+
from simple_module_core.i18n import Translator
|
|
8
|
+
|
|
9
|
+
PUBLIC_UPLOADS = "file_storage.public_uploads"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _translator(app, locale: str) -> Translator:
|
|
13
|
+
registry = app.state.sm.i18n_registry
|
|
14
|
+
if locale not in registry.supported_locales: # test hosts default to ["en"]
|
|
15
|
+
registry.supported_locales = [*registry.supported_locales, locale]
|
|
16
|
+
registry.load()
|
|
17
|
+
return Translator(registry, locale=locale, default_locale="en")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def _description(app, db_session, locale: str) -> str:
|
|
21
|
+
svc = FeatureFlagService(db_session)
|
|
22
|
+
views = await svc.list_flags(app.state.sm.feature_flags, translate=_translator(app, locale).t)
|
|
23
|
+
return next(v.description for v in views if v.name == PUBLIC_UPLOADS)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def test_description_follows_the_locale(app, db_session):
|
|
27
|
+
english = await _description(app, db_session, "en")
|
|
28
|
+
german = await _description(app, db_session, "de")
|
|
29
|
+
assert english == "Allow unauthenticated uploads (future)."
|
|
30
|
+
assert german != english
|
|
31
|
+
assert "Uploads" in german
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def test_unresolved_key_keeps_the_literal(db_session):
|
|
35
|
+
reg = FeatureFlagRegistry()
|
|
36
|
+
reg.add(FeatureFlagDefinition(name="x", description="Literal", description_key="nope.key"))
|
|
37
|
+
views = await FeatureFlagService(db_session).list_flags(reg, translate=lambda k: k)
|
|
38
|
+
assert views[0].description == "Literal"
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { configureI18n, keys } from '@simple-module-py/i18n';
|
|
2
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import type { ComponentProps } from 'react';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
configureI18n({ locale: 'en', messages: {} });
|
|
7
|
+
|
|
8
|
+
const state = vi.hoisted(() => ({ props: {} as Record<string, unknown> }));
|
|
9
|
+
const post = vi.hoisted(() => vi.fn());
|
|
10
|
+
const visit = vi.hoisted(() => vi.fn());
|
|
11
|
+
|
|
12
|
+
vi.mock('@inertiajs/react', () => ({
|
|
13
|
+
Head: () => null,
|
|
14
|
+
Link: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
|
15
|
+
<a href={href}>{children}</a>
|
|
16
|
+
),
|
|
17
|
+
router: { post, visit },
|
|
18
|
+
usePage: () => ({ props: state.props }),
|
|
19
|
+
}));
|
|
20
|
+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
|
21
|
+
|
|
22
|
+
import { toast } from 'sonner';
|
|
23
|
+
import Browse from '../feature_flags/pages/Browse';
|
|
24
|
+
import type { FeatureFlag } from '../feature_flags/pages/types';
|
|
25
|
+
|
|
26
|
+
const FLAGS: FeatureFlag[] = [
|
|
27
|
+
{
|
|
28
|
+
name: 'beta_search',
|
|
29
|
+
description: 'New search',
|
|
30
|
+
default_enabled: false,
|
|
31
|
+
enabled: false,
|
|
32
|
+
overridden: false,
|
|
33
|
+
system_enabled: null,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'dark_mode',
|
|
37
|
+
description: 'Dark theme',
|
|
38
|
+
default_enabled: false,
|
|
39
|
+
enabled: true,
|
|
40
|
+
overridden: true,
|
|
41
|
+
system_enabled: null,
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
function setProps(over: Record<string, unknown> = {}, permissions = ['feature_flags.manage']) {
|
|
46
|
+
state.props = {
|
|
47
|
+
flags: FLAGS,
|
|
48
|
+
tenant_id: null,
|
|
49
|
+
tenants: ['acme'],
|
|
50
|
+
audit_log_url: null,
|
|
51
|
+
auth: { permissions },
|
|
52
|
+
...over,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type PostOptions = { onSuccess: () => void; onError: () => void; preserveScroll: boolean };
|
|
57
|
+
|
|
58
|
+
const confirm = keys.feature_flags.confirm;
|
|
59
|
+
const table = keys.feature_flags.table;
|
|
60
|
+
const browse = keys.feature_flags.browse;
|
|
61
|
+
|
|
62
|
+
describe('feature_flags Browse', () => {
|
|
63
|
+
beforeEach(() => setProps());
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
vi.clearAllMocks();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('renders every flag with its description and override badge', () => {
|
|
69
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
70
|
+
expect(screen.getByText('beta_search')).toBeInTheDocument();
|
|
71
|
+
expect(screen.getByText('dark_mode')).toBeInTheDocument();
|
|
72
|
+
expect(screen.getByText('Dark theme')).toBeInTheDocument();
|
|
73
|
+
expect(screen.getAllByText(table.overridden)).toHaveLength(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('shows the empty state when no module registers flags', () => {
|
|
77
|
+
setProps({ flags: [] });
|
|
78
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
79
|
+
expect(screen.getByText(browse.empty_title)).toBeInTheDocument();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('links to the audit history only when an audit log is installed', () => {
|
|
83
|
+
const { unmount } = render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
84
|
+
expect(screen.queryByText(browse.view_history)).toBeNull();
|
|
85
|
+
unmount();
|
|
86
|
+
setProps({ audit_log_url: '/admin/audit?x=1' });
|
|
87
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
88
|
+
expect(screen.getByText(browse.view_history)).toHaveAttribute('href', '/admin/audit?x=1');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('makes the switches read-only without the manage permission', () => {
|
|
92
|
+
setProps({}, []);
|
|
93
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
94
|
+
expect(screen.getByRole('switch', { name: 'beta_search' })).toBeDisabled();
|
|
95
|
+
expect(screen.queryByText(table.clear_override)).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('stages a toggle behind a confirm and writes nothing until confirmed', async () => {
|
|
99
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
100
|
+
fireEvent.click(screen.getByRole('switch', { name: 'beta_search' }));
|
|
101
|
+
expect(await screen.findByText(confirm.scope_system)).toBeInTheDocument();
|
|
102
|
+
expect(post).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('cancelling the confirm leaves the flag and the server untouched', async () => {
|
|
106
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
107
|
+
fireEvent.click(screen.getByRole('switch', { name: 'beta_search' }));
|
|
108
|
+
fireEvent.click(await screen.findByRole('button', { name: confirm.cancel }));
|
|
109
|
+
await waitFor(() => expect(screen.queryByText(confirm.scope_system)).toBeNull());
|
|
110
|
+
expect(post).not.toHaveBeenCalled();
|
|
111
|
+
expect(screen.getByRole('switch', { name: 'beta_search' })).not.toBeChecked();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('enables a flag on confirm, posting enabled=true to the system scope', async () => {
|
|
115
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
116
|
+
fireEvent.click(screen.getByRole('switch', { name: 'beta_search' }));
|
|
117
|
+
fireEvent.click(await screen.findByRole('button', { name: confirm.enable_action }));
|
|
118
|
+
|
|
119
|
+
expect(post).toHaveBeenCalledTimes(1);
|
|
120
|
+
const [url, data, options] = post.mock.calls[0] as [string, unknown, PostOptions];
|
|
121
|
+
expect(url).toBe('/admin/feature-flags/beta_search/toggle');
|
|
122
|
+
expect(data).toEqual({ enabled: true });
|
|
123
|
+
expect(options.preserveScroll).toBe(true);
|
|
124
|
+
options.onSuccess();
|
|
125
|
+
expect(toast.success).toHaveBeenCalledWith(keys.feature_flags.toasts.enabled);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('disables a flag on confirm and toasts a failure from the server', async () => {
|
|
129
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
130
|
+
fireEvent.click(screen.getByRole('switch', { name: 'dark_mode' }));
|
|
131
|
+
fireEvent.click(await screen.findByRole('button', { name: confirm.disable_action }));
|
|
132
|
+
|
|
133
|
+
const [, data, options] = post.mock.calls[0] as [string, unknown, PostOptions];
|
|
134
|
+
expect(data).toEqual({ enabled: false });
|
|
135
|
+
options.onSuccess();
|
|
136
|
+
expect(toast.success).toHaveBeenCalledWith(keys.feature_flags.toasts.disabled);
|
|
137
|
+
options.onError();
|
|
138
|
+
expect(toast.error).toHaveBeenCalledWith(keys.feature_flags.toasts.toggle_failed);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('scopes the toggle URL and the confirm text to the viewed tenant', async () => {
|
|
142
|
+
setProps({ tenant_id: 'acme corp' });
|
|
143
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
144
|
+
fireEvent.click(screen.getByRole('switch', { name: 'beta_search' }));
|
|
145
|
+
expect(await screen.findByText(confirm.scope_tenant)).toBeInTheDocument();
|
|
146
|
+
fireEvent.click(screen.getByRole('button', { name: confirm.enable_action }));
|
|
147
|
+
expect(post.mock.calls[0][0]).toBe(
|
|
148
|
+
'/admin/feature-flags/beta_search/toggle?tenant_id=acme%20corp',
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('clears an override without a confirm and reports the result', () => {
|
|
153
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
154
|
+
fireEvent.click(screen.getAllByText(table.clear_override)[0]);
|
|
155
|
+
const [url, data, options] = post.mock.calls[0] as [string, unknown, PostOptions];
|
|
156
|
+
expect(url).toBe('/admin/feature-flags/dark_mode/clear');
|
|
157
|
+
expect(data).toEqual({});
|
|
158
|
+
options.onSuccess();
|
|
159
|
+
expect(toast.success).toHaveBeenCalledWith(keys.feature_flags.toasts.cleared);
|
|
160
|
+
options.onError();
|
|
161
|
+
expect(toast.error).toHaveBeenCalledWith(keys.feature_flags.toasts.toggle_failed);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('explains which value a non-overridden flag follows', () => {
|
|
165
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
166
|
+
expect(screen.getAllByText(table.following_default).length).toBeGreaterThan(0);
|
|
167
|
+
cleanup();
|
|
168
|
+
setProps({ tenant_id: 'acme' });
|
|
169
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
170
|
+
expect(screen.getAllByText(table.following_system).length).toBeGreaterThan(0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('switches scope by visiting the tenant URL, or the system URL', () => {
|
|
174
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
175
|
+
fireEvent.click(screen.getByRole('radio', { name: 'acme' }));
|
|
176
|
+
expect(visit).toHaveBeenCalledWith('/admin/feature-flags/?tenant_id=acme');
|
|
177
|
+
cleanup();
|
|
178
|
+
setProps({ tenant_id: 'acme' });
|
|
179
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
180
|
+
fireEvent.click(screen.getByRole('radio', { name: browse.scope_system }));
|
|
181
|
+
expect(visit).toHaveBeenLastCalledWith('/admin/feature-flags/');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('lets an operator name a tenant with no overrides yet', () => {
|
|
185
|
+
render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
|
|
186
|
+
fireEvent.click(screen.getByRole('radio', { name: browse.scope_custom }));
|
|
187
|
+
const go = screen.getByRole('button', { name: browse.go });
|
|
188
|
+
expect(go).toBeDisabled();
|
|
189
|
+
fireEvent.change(screen.getByLabelText(browse.tenant_id_label), {
|
|
190
|
+
target: { value: ' new-co ' },
|
|
191
|
+
});
|
|
192
|
+
fireEvent.click(go);
|
|
193
|
+
expect(visit).toHaveBeenCalledWith('/admin/feature-flags/?tenant_id=new-co');
|
|
194
|
+
});
|
|
195
|
+
});
|
|
File without changes
|
{simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/__init__.py
RENAMED
|
File without changes
|
{simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/constants.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/deps.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/models.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/py.typed
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
|