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.
Files changed (36) hide show
  1. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/.gitignore +1 -2
  2. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/PKG-INFO +5 -5
  3. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/README.md +1 -1
  4. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/contracts/schemas.py +1 -1
  5. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/endpoints/views.py +3 -1
  6. simple_module_feature_flags-0.0.38/feature_flags/locales/de.json +56 -0
  7. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/locales/en.json +2 -1
  8. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/module.py +3 -5
  9. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/pages/Browse.tsx +2 -4
  10. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/service.py +26 -6
  11. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/package.json +1 -1
  12. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/pyproject.toml +4 -4
  13. simple_module_feature_flags-0.0.38/tests/test_flag_description_i18n.py +38 -0
  14. simple_module_feature_flags-0.0.38/tests-js/feature_flags_Browse.test.tsx +195 -0
  15. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/LICENSE +0 -0
  16. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/__init__.py +0 -0
  17. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/constants.py +0 -0
  18. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/contracts/__init__.py +0 -0
  19. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/deps.py +0 -0
  20. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/endpoints/__init__.py +0 -0
  21. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/endpoints/api.py +0 -0
  22. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/models.py +0 -0
  23. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/pages/components/FlagTable.tsx +0 -0
  24. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/pages/components/ScopeCard.tsx +0 -0
  25. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/pages/components/TenantPicker.tsx +0 -0
  26. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/pages/components/ToggleConfirmDialog.tsx +0 -0
  27. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/pages/types.ts +0 -0
  28. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/feature_flags/py.typed +0 -0
  29. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/tests/conftest.py +0 -0
  30. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/tests/test_feature_flags_api.py +0 -0
  31. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/tests/test_feature_flags_audit_links.py +0 -0
  32. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/tests/test_feature_flags_menu_section.py +0 -0
  33. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/tests/test_feature_flags_service.py +0 -0
  34. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/tests/test_feature_flags_tenancy.py +0 -0
  35. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/tests/test_store_outage_fallback.py +0 -0
  36. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.38}/tsconfig.json +0 -0
@@ -7,8 +7,6 @@ build/
7
7
  .venv/
8
8
  *.egg
9
9
 
10
- # UV
11
- uv.lock
12
10
 
13
11
  # Node
14
12
  node_modules/
@@ -65,3 +63,4 @@ host/client_app/.playwright-cli/*
65
63
  .qa/
66
64
  .verify/
67
65
  /openapi.json
66
+ qa-shots/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_feature_flags
3
- Version: 0.0.37
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.37
25
- Requires-Dist: simple-module-db==0.0.37
26
- Requires-Dist: simple-module-hosting==0.0.37
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 # type: ignore[import-not-found]
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 # type: ignore[import-not-found]
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
+ }
@@ -51,5 +51,6 @@
51
51
  },
52
52
  "nav": {
53
53
  "feature_flags": "Feature flags"
54
- }
54
+ },
55
+ "permission_group": "Feature flags"
55
56
  }
@@ -103,14 +103,11 @@ class FeatureFlagsModule(ModuleBase):
103
103
  from feature_flags.models import FeatureFlagOverride
104
104
 
105
105
  registry.register(
106
- AuditLink(
107
- # Class name, not __tablename__ — see AuditLink.entity_type.
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, usePage } from '@inertiajs/react';
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');
@@ -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.description,
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, registry: FeatureFlagRegistry, tenant_id: str | None = None
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(flag, enabled=system_enabled, overridden=system_value is not None)
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
  {
2
2
  "name": "@simple-module-py/feature_flags",
3
- "version": "0.1.0",
3
+ "version": "0.0.38",
4
4
  "private": true,
5
5
  "description": "Frontend assets for the FeatureFlags module",
6
6
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_feature_flags"
3
- version = "0.0.37"
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.37",
25
- "simple_module_db==0.0.37",
26
- "simple_module_hosting==0.0.37",
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
+ });