simple-module-feature-flags 0.0.30__tar.gz → 0.0.32__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 (26) hide show
  1. {simple_module_feature_flags-0.0.30 → simple_module_feature_flags-0.0.32}/.gitignore +1 -0
  2. {simple_module_feature_flags-0.0.30 → simple_module_feature_flags-0.0.32}/PKG-INFO +5 -5
  3. {simple_module_feature_flags-0.0.30 → simple_module_feature_flags-0.0.32}/feature_flags/locales/en.json +12 -4
  4. {simple_module_feature_flags-0.0.30 → simple_module_feature_flags-0.0.32}/feature_flags/module.py +3 -0
  5. {simple_module_feature_flags-0.0.30 → simple_module_feature_flags-0.0.32}/feature_flags/pages/Browse.tsx +27 -56
  6. simple_module_feature_flags-0.0.32/feature_flags/pages/components/TenantPicker.tsx +112 -0
  7. simple_module_feature_flags-0.0.32/feature_flags/pages/components/ToggleConfirmDialog.tsx +69 -0
  8. {simple_module_feature_flags-0.0.30 → simple_module_feature_flags-0.0.32}/pyproject.toml +4 -4
  9. {simple_module_feature_flags-0.0.30 → simple_module_feature_flags-0.0.32}/LICENSE +0 -0
  10. {simple_module_feature_flags-0.0.30 → simple_module_feature_flags-0.0.32}/README.md +0 -0
  11. {simple_module_feature_flags-0.0.30 → simple_module_feature_flags-0.0.32}/feature_flags/__init__.py +0 -0
  12. {simple_module_feature_flags-0.0.30 → simple_module_feature_flags-0.0.32}/feature_flags/constants.py +0 -0
  13. {simple_module_feature_flags-0.0.30 → simple_module_feature_flags-0.0.32}/feature_flags/contracts/__init__.py +0 -0
  14. {simple_module_feature_flags-0.0.30 → simple_module_feature_flags-0.0.32}/feature_flags/contracts/schemas.py +0 -0
  15. {simple_module_feature_flags-0.0.30 → simple_module_feature_flags-0.0.32}/feature_flags/deps.py +0 -0
  16. {simple_module_feature_flags-0.0.30 → simple_module_feature_flags-0.0.32}/feature_flags/endpoints/__init__.py +0 -0
  17. {simple_module_feature_flags-0.0.30 → simple_module_feature_flags-0.0.32}/feature_flags/endpoints/api.py +0 -0
  18. {simple_module_feature_flags-0.0.30 → simple_module_feature_flags-0.0.32}/feature_flags/endpoints/views.py +0 -0
  19. {simple_module_feature_flags-0.0.30 → simple_module_feature_flags-0.0.32}/feature_flags/models.py +0 -0
  20. {simple_module_feature_flags-0.0.30 → simple_module_feature_flags-0.0.32}/feature_flags/py.typed +0 -0
  21. {simple_module_feature_flags-0.0.30 → simple_module_feature_flags-0.0.32}/feature_flags/service.py +0 -0
  22. {simple_module_feature_flags-0.0.30 → simple_module_feature_flags-0.0.32}/package.json +0 -0
  23. {simple_module_feature_flags-0.0.30 → simple_module_feature_flags-0.0.32}/tests/test_feature_flags_api.py +0 -0
  24. {simple_module_feature_flags-0.0.30 → simple_module_feature_flags-0.0.32}/tests/test_feature_flags_service.py +0 -0
  25. {simple_module_feature_flags-0.0.30 → simple_module_feature_flags-0.0.32}/tests/test_store_outage_fallback.py +0 -0
  26. {simple_module_feature_flags-0.0.30 → simple_module_feature_flags-0.0.32}/tsconfig.json +0 -0
@@ -63,3 +63,4 @@ Thumbs.db
63
63
  host/client_app/.playwright-cli/*
64
64
  .superpowers/
65
65
  .qa/
66
+ .verify/
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: simple_module_feature_flags
3
- Version: 0.0.30
3
+ Version: 0.0.32
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.30
25
- Requires-Dist: simple-module-db==0.0.30
26
- Requires-Dist: simple-module-hosting==0.0.30
24
+ Requires-Dist: simple-module-core==0.0.32
25
+ Requires-Dist: simple-module-db==0.0.32
26
+ Requires-Dist: simple-module-hosting==0.0.32
27
27
  Description-Content-Type: text/markdown
28
28
 
29
29
  # simple_module_feature_flags
@@ -10,12 +10,11 @@
10
10
  "viewing_tenant": "Viewing overrides for tenant \"{tenant_id}\". Tenant overrides beat the system value.",
11
11
  "tenant_id_label": "Tenant ID",
12
12
  "tenant_id_placeholder": "e.g. acme",
13
- "scope_label": "Scope",
14
13
  "scope_system": "System (all tenants)",
15
- "scope_tenant": "Specific tenant",
16
14
  "go": "View",
17
- "back_to_system": "Back to system view",
18
- "tenants_with_overrides": "Tenants with overrides"
15
+ "viewing_label": "Viewing",
16
+ "scope_custom": "Other tenant…",
17
+ "cancel": "Cancel"
19
18
  },
20
19
  "table": {
21
20
  "name": "Flag",
@@ -31,6 +30,15 @@
31
30
  "system_value": "System: {value}",
32
31
  "clear_override": "Clear override"
33
32
  },
33
+ "confirm": {
34
+ "enable_title": "Enable \"{name}\"?",
35
+ "disable_title": "Disable \"{name}\"?",
36
+ "scope_system": "This changes the value for every tenant, immediately.",
37
+ "scope_tenant": "This sets an override for tenant \"{tenant_id}\" only. Other tenants keep following the system value.",
38
+ "enable_action": "Enable",
39
+ "disable_action": "Disable",
40
+ "cancel": "Cancel"
41
+ },
34
42
  "toasts": {
35
43
  "enabled": "\"{name}\" enabled",
36
44
  "disabled": "\"{name}\" disabled",
@@ -48,6 +48,9 @@ class FeatureFlagsModule(ModuleBase):
48
48
  order=MENU_ORDER,
49
49
  section=MenuSection.SIDEBAR,
50
50
  group="Administration",
51
+ # Mirrors the view router's guard. Without it the entry shows
52
+ # for every signed-in account and 403s on click.
53
+ permissions=[PERM_FEATURE_FLAGS_VIEW],
51
54
  )
52
55
  )
53
56
 
@@ -10,7 +10,6 @@ import {
10
10
  EmptyMedia,
11
11
  EmptyTitle,
12
12
  } from '@simple-module-py/ui/components/ui/empty';
13
- import { Input } from '@simple-module-py/ui/components/ui/input';
14
13
  import { Switch } from '@simple-module-py/ui/components/ui/switch';
15
14
  import {
16
15
  Table,
@@ -25,6 +24,8 @@ import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedL
25
24
  import { Flag, RotateCcw } from 'lucide-react';
26
25
  import { useState } from 'react';
27
26
  import { toast } from 'sonner';
27
+ import { TenantPicker } from './components/TenantPicker';
28
+ import { type PendingToggle, ToggleConfirmDialog } from './components/ToggleConfirmDialog';
28
29
 
29
30
  interface FeatureFlag {
30
31
  name: string;
@@ -55,19 +56,24 @@ function Browse() {
55
56
  const { t } = useT();
56
57
  const { can } = usePermissions();
57
58
  const canManage = can('feature_flags.manage');
58
- const [tenantInput, setTenantInput] = useState(tenant_id ?? '');
59
59
 
60
- function handleToggle(flag: FeatureFlag, next: boolean) {
60
+ // The switch stages the change; the confirm is what writes it. The Switch
61
+ // stays bound to the server value, so cancelling leaves it where it was
62
+ // without needing to undo anything.
63
+ const [pending, setPending] = useState<PendingToggle | null>(null);
64
+
65
+ function handleToggle({ name, next }: PendingToggle) {
66
+ setPending(null);
61
67
  router.post(
62
- actionUrl(flag.name, 'toggle', tenant_id),
68
+ actionUrl(name, 'toggle', tenant_id),
63
69
  { enabled: next },
64
70
  {
65
71
  preserveScroll: true,
66
72
  onSuccess: () =>
67
73
  toast.success(
68
74
  next
69
- ? t(keys.feature_flags.toasts.enabled, { name: flag.name })
70
- : t(keys.feature_flags.toasts.disabled, { name: flag.name }),
75
+ ? t(keys.feature_flags.toasts.enabled, { name })
76
+ : t(keys.feature_flags.toasts.disabled, { name }),
71
77
  ),
72
78
  onError: () => toast.error(t(keys.feature_flags.toasts.toggle_failed)),
73
79
  },
@@ -86,10 +92,6 @@ function Browse() {
86
92
  );
87
93
  }
88
94
 
89
- function visitTenant(value: string) {
90
- router.visit(buildPath(value.trim() || null));
91
- }
92
-
93
95
  return (
94
96
  <>
95
97
  <Head title="Feature Flags" />
@@ -98,51 +100,11 @@ function Browse() {
98
100
  description={t(keys.feature_flags.browse.description)}
99
101
  >
100
102
  <Card className="mb-4 p-4">
101
- <form
102
- className="flex flex-wrap items-end gap-3"
103
- onSubmit={(e) => {
104
- e.preventDefault();
105
- visitTenant(tenantInput);
106
- }}
107
- >
108
- <div className="flex-1 min-w-[200px]">
109
- <label className="block text-sm font-medium mb-1" htmlFor="tenant_id">
110
- {t(keys.feature_flags.browse.tenant_id_label)}
111
- </label>
112
- <Input
113
- id="tenant_id"
114
- value={tenantInput}
115
- onChange={(e) => setTenantInput(e.target.value)}
116
- placeholder={t(keys.feature_flags.browse.tenant_id_placeholder)}
117
- />
118
- </div>
119
- <Button type="submit" variant="default">
120
- {t(keys.feature_flags.browse.go)}
121
- </Button>
122
- {tenant_id && (
123
- <Button type="button" variant="ghost" onClick={() => visitTenant('')}>
124
- {t(keys.feature_flags.browse.back_to_system)}
125
- </Button>
126
- )}
127
- </form>
128
- {tenants.length > 0 && (
129
- <div className="mt-3 flex flex-wrap items-center gap-2 text-sm">
130
- <span className="text-muted-foreground">
131
- {t(keys.feature_flags.browse.tenants_with_overrides)}:
132
- </span>
133
- {tenants.map((tid) => (
134
- <Button
135
- key={tid}
136
- type="button"
137
- size="sm"
138
- variant={tid === tenant_id ? 'default' : 'outline'}
139
- onClick={() => visitTenant(tid)}
140
- >
141
- {tid}
142
- </Button>
143
- ))}
144
- </div>
145
- )}
103
+ <TenantPicker
104
+ tenantId={tenant_id}
105
+ tenants={tenants}
106
+ onSelect={(next) => router.visit(buildPath(next))}
107
+ />
146
108
  <p className="mt-3 text-sm text-muted-foreground">
147
109
  {tenant_id
148
110
  ? t(keys.feature_flags.browse.viewing_tenant, { tenant_id })
@@ -212,7 +174,9 @@ function Browse() {
212
174
  <div className="flex items-center gap-3">
213
175
  <Switch
214
176
  checked={flag.enabled}
215
- onCheckedChange={(checked) => handleToggle(flag, checked === true)}
177
+ onCheckedChange={(checked) =>
178
+ setPending({ name: flag.name, next: checked === true })
179
+ }
216
180
  disabled={!canManage}
217
181
  aria-label={flag.name}
218
182
  />
@@ -277,6 +241,13 @@ function Browse() {
277
241
  </TableBody>
278
242
  </Table>
279
243
  </Card>
244
+
245
+ <ToggleConfirmDialog
246
+ pending={pending}
247
+ tenantId={tenant_id}
248
+ onConfirm={handleToggle}
249
+ onCancel={() => setPending(null)}
250
+ />
280
251
  </PageShell>
281
252
  </>
282
253
  );
@@ -0,0 +1,112 @@
1
+ import { keys, useT } from '@simple-module-py/i18n';
2
+ import { Button } from '@simple-module-py/ui/components/ui/button';
3
+ import { Input } from '@simple-module-py/ui/components/ui/input';
4
+ import {
5
+ Select,
6
+ SelectContent,
7
+ SelectItem,
8
+ SelectSeparator,
9
+ SelectTrigger,
10
+ SelectValue,
11
+ } from '@simple-module-py/ui/components/ui/select';
12
+ import { useState } from 'react';
13
+
14
+ interface Props {
15
+ /** Currently-viewed tenant, or null for the system scope. */
16
+ tenantId: string | null;
17
+ /** Tenants that already carry at least one override. */
18
+ tenants: string[];
19
+ onSelect: (tenantId: string | null) => void;
20
+ }
21
+
22
+ const SYSTEM = '__system__';
23
+ const CUSTOM = '__custom__';
24
+
25
+ /**
26
+ * Scope picker for the flags table.
27
+ *
28
+ * This was a free-text box, so viewing a tenant meant knowing and correctly
29
+ * typing its id — and a typo silently showed a scope with no overrides
30
+ * instead of an error.
31
+ *
32
+ * The list cannot be closed: there is no tenant registry in the framework
33
+ * (ids arrive on auth claims), so the only tenants this app can enumerate are
34
+ * those that already have an override. Creating the *first* override for a
35
+ * tenant therefore still needs a way to name one by hand, which is what the
36
+ * "other tenant" branch is for.
37
+ */
38
+ export function TenantPicker({ tenantId, tenants, onSelect }: Props) {
39
+ const { t } = useT();
40
+ const [custom, setCustom] = useState(false);
41
+ const [draft, setDraft] = useState('');
42
+
43
+ // The tenant being viewed may not have overrides yet, so it can be absent
44
+ // from `tenants` — without this it would vanish from its own picker.
45
+ const options = [...new Set(tenantId ? [...tenants, tenantId] : tenants)].sort();
46
+
47
+ function handleChange(value: string) {
48
+ if (value === CUSTOM) {
49
+ setCustom(true);
50
+ setDraft('');
51
+ return;
52
+ }
53
+ setCustom(false);
54
+ onSelect(value === SYSTEM ? null : value);
55
+ }
56
+
57
+ return (
58
+ <div className="flex flex-wrap items-end gap-3">
59
+ <div className="min-w-[240px]">
60
+ <span className="mb-1 block text-sm font-medium">
61
+ {t(keys.feature_flags.browse.viewing_label)}
62
+ </span>
63
+ <Select value={custom ? CUSTOM : (tenantId ?? SYSTEM)} onValueChange={handleChange}>
64
+ <SelectTrigger className="w-full">
65
+ <SelectValue />
66
+ </SelectTrigger>
67
+ <SelectContent>
68
+ <SelectItem value={SYSTEM}>{t(keys.feature_flags.browse.scope_system)}</SelectItem>
69
+ {options.length > 0 && <SelectSeparator />}
70
+ {options.map((tid) => (
71
+ <SelectItem key={tid} value={tid}>
72
+ {tid}
73
+ </SelectItem>
74
+ ))}
75
+ <SelectSeparator />
76
+ <SelectItem value={CUSTOM}>{t(keys.feature_flags.browse.scope_custom)}</SelectItem>
77
+ </SelectContent>
78
+ </Select>
79
+ </div>
80
+
81
+ {custom && (
82
+ <form
83
+ className="flex items-end gap-2"
84
+ onSubmit={(e) => {
85
+ e.preventDefault();
86
+ const trimmed = draft.trim();
87
+ if (trimmed) onSelect(trimmed);
88
+ }}
89
+ >
90
+ <div className="min-w-[200px]">
91
+ <label className="mb-1 block text-sm font-medium" htmlFor="tenant_id">
92
+ {t(keys.feature_flags.browse.tenant_id_label)}
93
+ </label>
94
+ <Input
95
+ id="tenant_id"
96
+ autoFocus
97
+ value={draft}
98
+ onChange={(e) => setDraft(e.target.value)}
99
+ placeholder={t(keys.feature_flags.browse.tenant_id_placeholder)}
100
+ />
101
+ </div>
102
+ <Button type="submit" disabled={!draft.trim()}>
103
+ {t(keys.feature_flags.browse.go)}
104
+ </Button>
105
+ <Button type="button" variant="ghost" onClick={() => setCustom(false)}>
106
+ {t(keys.feature_flags.browse.cancel)}
107
+ </Button>
108
+ </form>
109
+ )}
110
+ </div>
111
+ );
112
+ }
@@ -0,0 +1,69 @@
1
+ import { keys, useT } from '@simple-module-py/i18n';
2
+ import {
3
+ AlertDialog,
4
+ AlertDialogAction,
5
+ AlertDialogCancel,
6
+ AlertDialogContent,
7
+ AlertDialogDescription,
8
+ AlertDialogFooter,
9
+ AlertDialogHeader,
10
+ AlertDialogTitle,
11
+ } from '@simple-module-py/ui/components/ui/alert-dialog';
12
+
13
+ export interface PendingToggle {
14
+ name: string;
15
+ next: boolean;
16
+ }
17
+
18
+ interface Props {
19
+ pending: PendingToggle | null;
20
+ tenantId: string | null;
21
+ onConfirm: (pending: PendingToggle) => void;
22
+ onCancel: () => void;
23
+ }
24
+
25
+ /**
26
+ * Confirms a flag flip before it is written.
27
+ *
28
+ * A flag toggle takes effect the instant the switch moves, with no undo and no
29
+ * local record of who moved it — and the same switch means "for this tenant" or
30
+ * "for everyone" depending on a scope selector further up the page. That scope
31
+ * is the part worth restating: it is the difference between an experiment and
32
+ * an outage, and it is the piece you cannot see from the switch itself.
33
+ */
34
+ export function ToggleConfirmDialog({ pending, tenantId, onConfirm, onCancel }: Props) {
35
+ const { t } = useT();
36
+ if (!pending) return null;
37
+
38
+ // Spelled out per branch — `t` needs a literal key, not one built at runtime.
39
+ const title = pending.next
40
+ ? t(keys.feature_flags.confirm.enable_title, { name: pending.name })
41
+ : t(keys.feature_flags.confirm.disable_title, { name: pending.name });
42
+ const scope = tenantId
43
+ ? t(keys.feature_flags.confirm.scope_tenant, { tenant_id: tenantId })
44
+ : t(keys.feature_flags.confirm.scope_system);
45
+
46
+ return (
47
+ <AlertDialog open onOpenChange={(open) => !open && onCancel()}>
48
+ <AlertDialogContent>
49
+ <AlertDialogHeader>
50
+ <AlertDialogTitle>{title}</AlertDialogTitle>
51
+ <AlertDialogDescription>{scope}</AlertDialogDescription>
52
+ </AlertDialogHeader>
53
+ <AlertDialogFooter>
54
+ <AlertDialogCancel onClick={onCancel}>
55
+ {t(keys.feature_flags.confirm.cancel)}
56
+ </AlertDialogCancel>
57
+ <AlertDialogAction
58
+ variant={pending.next ? 'default' : 'destructive'}
59
+ onClick={() => onConfirm(pending)}
60
+ >
61
+ {pending.next
62
+ ? t(keys.feature_flags.confirm.enable_action)
63
+ : t(keys.feature_flags.confirm.disable_action)}
64
+ </AlertDialogAction>
65
+ </AlertDialogFooter>
66
+ </AlertDialogContent>
67
+ </AlertDialog>
68
+ );
69
+ }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_feature_flags"
3
- version = "0.0.30"
3
+ version = "0.0.32"
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.30",
25
- "simple_module_db==0.0.30",
26
- "simple_module_hosting==0.0.30",
24
+ "simple_module_core==0.0.32",
25
+ "simple_module_db==0.0.32",
26
+ "simple_module_hosting==0.0.32",
27
27
  ]
28
28
 
29
29
  [project.entry-points.simple_module]