simple-module-settings 0.0.1__py3-none-any.whl
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.
- settings/__init__.py +1 -0
- settings/_module_settings.py +169 -0
- settings/cli.py +78 -0
- settings/constants.py +115 -0
- settings/contracts/__init__.py +24 -0
- settings/contracts/accessor.py +236 -0
- settings/contracts/events.py +20 -0
- settings/contracts/registry.py +66 -0
- settings/contracts/schemas.py +154 -0
- settings/deps.py +56 -0
- settings/endpoints/__init__.py +0 -0
- settings/endpoints/api.py +202 -0
- settings/endpoints/module_api.py +102 -0
- settings/endpoints/views.py +120 -0
- settings/env_vars.py +18 -0
- settings/hydrate.py +63 -0
- settings/locales/en.json +71 -0
- settings/models.py +50 -0
- settings/module.py +76 -0
- settings/module_registry.py +33 -0
- settings/package.json +16 -0
- settings/pages/Browse.tsx +103 -0
- settings/pages/Create.tsx +113 -0
- settings/pages/Edit.tsx +102 -0
- settings/pages/ModulesEdit.tsx +74 -0
- settings/pages/components/FieldInput.tsx +96 -0
- settings/pages/components/ModuleForm.tsx +157 -0
- settings/pages/components/ValueInput.tsx +92 -0
- settings/pages/routes.ts +7 -0
- settings/py.typed +0 -0
- settings/registration.py +34 -0
- settings/reload.py +58 -0
- settings/service.py +162 -0
- settings/services.py +36 -0
- settings/settings.py +11 -0
- settings/store.py +61 -0
- simple_module_settings-0.0.1.dist-info/METADATA +69 -0
- simple_module_settings-0.0.1.dist-info/RECORD +41 -0
- simple_module_settings-0.0.1.dist-info/WHEEL +4 -0
- simple_module_settings-0.0.1.dist-info/entry_points.txt +5 -0
- simple_module_settings-0.0.1.dist-info/licenses/LICENSE +21 -0
settings/env_vars.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Map package names to their historical ``SM_*`` env-var prefix.
|
|
2
|
+
|
|
3
|
+
Used by ``_module_settings`` to label fields in the admin UI and by the
|
|
4
|
+
``sm-settings import-from-env`` CLI to locate legacy env values. Most
|
|
5
|
+
packages follow ``SM_<PACKAGE_UPPER>_``; the exceptions are listed below.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
_PACKAGE_ENV_PREFIX: dict[str, str] = {
|
|
11
|
+
"background_tasks": "SM_BG_TASKS_",
|
|
12
|
+
"file_storage": "SM_FILE_STORAGE_",
|
|
13
|
+
"host": "SM_",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def env_prefix_for(package: str) -> str:
|
|
18
|
+
return _PACKAGE_ENV_PREFIX.get(package, f"SM_{package.upper()}_")
|
settings/hydrate.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Resolve a module's BaseSettings from DB overrides + pydantic defaults.
|
|
2
|
+
|
|
3
|
+
A field's declared Python type maps to one of the five ``value_type`` labels
|
|
4
|
+
understood by SettingsStore (``string | bool | int | float | json``). The
|
|
5
|
+
hydrator reads overrides, parses each according to its stored ``value_type``,
|
|
6
|
+
and constructs the BaseSettings — pydantic enforces field validators and any
|
|
7
|
+
``@model_validator`` hooks.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from typing import get_origin
|
|
14
|
+
|
|
15
|
+
from pydantic_settings import BaseSettings
|
|
16
|
+
|
|
17
|
+
from settings.store import SettingsStore
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def value_type_for_field(cls: type[BaseSettings], field_name: str) -> str:
|
|
21
|
+
"""Return the ``value_type`` label for a field based on its annotation.
|
|
22
|
+
|
|
23
|
+
- ``bool`` → ``"bool"``
|
|
24
|
+
- ``int`` → ``"int"``
|
|
25
|
+
- ``float`` → ``"float"``
|
|
26
|
+
- ``str`` and enums → ``"string"``
|
|
27
|
+
- ``list``, ``dict``, and other container types → ``"json"``
|
|
28
|
+
"""
|
|
29
|
+
info = cls.model_fields[field_name]
|
|
30
|
+
ann = info.annotation
|
|
31
|
+
origin = get_origin(ann)
|
|
32
|
+
if origin is not None:
|
|
33
|
+
return "json"
|
|
34
|
+
if ann is bool:
|
|
35
|
+
return "bool"
|
|
36
|
+
if ann is int:
|
|
37
|
+
return "int"
|
|
38
|
+
if ann is float:
|
|
39
|
+
return "float"
|
|
40
|
+
return "string"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse(raw: str, value_type: str):
|
|
44
|
+
if value_type == "bool":
|
|
45
|
+
return raw.lower() in ("1", "true", "yes", "on")
|
|
46
|
+
if value_type == "int":
|
|
47
|
+
return int(raw)
|
|
48
|
+
if value_type == "float":
|
|
49
|
+
return float(raw)
|
|
50
|
+
if value_type == "json":
|
|
51
|
+
return json.loads(raw)
|
|
52
|
+
return raw
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def hydrate_settings[T: BaseSettings](cls: type[T], store: SettingsStore, package: str) -> T:
|
|
56
|
+
"""Construct ``cls`` with DB overrides merged over pydantic defaults."""
|
|
57
|
+
raw_overrides = await store.get_overrides(package)
|
|
58
|
+
parsed: dict[str, object] = {}
|
|
59
|
+
for field_name, (raw, vtype) in raw_overrides.items():
|
|
60
|
+
if field_name not in cls.model_fields:
|
|
61
|
+
continue
|
|
62
|
+
parsed[field_name] = _parse(raw, vtype)
|
|
63
|
+
return cls(**parsed)
|
settings/locales/en.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"browse": {
|
|
3
|
+
"title": "Settings",
|
|
4
|
+
"new_button": "New Setting",
|
|
5
|
+
"empty_title": "No settings yet",
|
|
6
|
+
"empty_description": "Get started by creating your first setting.",
|
|
7
|
+
"edit_link": "Edit",
|
|
8
|
+
"delete_link": "Delete",
|
|
9
|
+
"delete_confirm": "Delete setting \"{key}\"? This cannot be undone."
|
|
10
|
+
},
|
|
11
|
+
"table": {
|
|
12
|
+
"scope": "Scope",
|
|
13
|
+
"scope_id": "Scope ID",
|
|
14
|
+
"key": "Key",
|
|
15
|
+
"value": "Value",
|
|
16
|
+
"value_type": "Type",
|
|
17
|
+
"description": "Description",
|
|
18
|
+
"actions": "Actions"
|
|
19
|
+
},
|
|
20
|
+
"scopes": {
|
|
21
|
+
"system": "System",
|
|
22
|
+
"tenant": "Tenant",
|
|
23
|
+
"user": "User"
|
|
24
|
+
},
|
|
25
|
+
"value_types": {
|
|
26
|
+
"string": "String",
|
|
27
|
+
"bool": "Boolean",
|
|
28
|
+
"int": "Integer",
|
|
29
|
+
"float": "Float",
|
|
30
|
+
"json": "JSON"
|
|
31
|
+
},
|
|
32
|
+
"form": {
|
|
33
|
+
"scope_label": "Scope",
|
|
34
|
+
"scope_id_label": "Scope ID",
|
|
35
|
+
"scope_id_placeholder": "Tenant or user id (empty for system)",
|
|
36
|
+
"key_label": "Key",
|
|
37
|
+
"key_placeholder": "e.g. feature.enabled",
|
|
38
|
+
"value_type_label": "Type",
|
|
39
|
+
"value_label": "Value",
|
|
40
|
+
"value_placeholder": "Enter a value",
|
|
41
|
+
"description_label": "Description",
|
|
42
|
+
"description_placeholder": "Optional description"
|
|
43
|
+
},
|
|
44
|
+
"create": {
|
|
45
|
+
"title": "New Setting",
|
|
46
|
+
"submit_button": "Create"
|
|
47
|
+
},
|
|
48
|
+
"edit": {
|
|
49
|
+
"title": "Edit Setting",
|
|
50
|
+
"submit_button": "Save"
|
|
51
|
+
},
|
|
52
|
+
"modules": {
|
|
53
|
+
"title": "Module Settings",
|
|
54
|
+
"description": "Read-only view of every module's runtime configuration, autodiscovered from the app. Secrets are masked.",
|
|
55
|
+
"back_link": "Back to overrides",
|
|
56
|
+
"empty_title": "No modules with settings",
|
|
57
|
+
"no_fields": "No fields declared.",
|
|
58
|
+
"secret_badge": "secret",
|
|
59
|
+
"browse_link": "View module settings",
|
|
60
|
+
"search_placeholder": "Search modules or fields…",
|
|
61
|
+
"field_count_suffix": "fields",
|
|
62
|
+
"browse_free_form_link": "View free-form settings",
|
|
63
|
+
"table": {
|
|
64
|
+
"field": "Field",
|
|
65
|
+
"env_var": "Env var",
|
|
66
|
+
"value": "Value",
|
|
67
|
+
"default": "Default",
|
|
68
|
+
"description": "Description"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
settings/models.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""SQLModel tables for the Settings module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from simple_module_db.base import create_module_base
|
|
6
|
+
from simple_module_db.mixins import AuditMixin
|
|
7
|
+
from sqlalchemy import UniqueConstraint
|
|
8
|
+
from sqlmodel import Field
|
|
9
|
+
|
|
10
|
+
from settings.constants import (
|
|
11
|
+
DESCRIPTION_MAX_LENGTH,
|
|
12
|
+
KEY_MAX_LENGTH,
|
|
13
|
+
MODULE_PACKAGE,
|
|
14
|
+
SCOPE_ID_MAX_LENGTH,
|
|
15
|
+
SCOPE_MAX_LENGTH,
|
|
16
|
+
SCOPE_SYSTEM,
|
|
17
|
+
SYSTEM_SCOPE_ID,
|
|
18
|
+
TABLE_SETTING,
|
|
19
|
+
UQ_SCOPE_KEY,
|
|
20
|
+
VALUE_MAX_LENGTH,
|
|
21
|
+
VALUE_TYPE_MAX_LENGTH,
|
|
22
|
+
VALUE_TYPE_STRING,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
Base = create_module_base(MODULE_PACKAGE)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Setting(Base, AuditMixin, table=True): # ty: ignore[unsupported-base]
|
|
29
|
+
"""A typed key/value configuration entry scoped to system, tenant, or user.
|
|
30
|
+
|
|
31
|
+
Resolution precedence when a consumer asks for a key: USER > TENANT >
|
|
32
|
+
SYSTEM. Uniqueness is enforced across the (scope, scope_id, key) tuple
|
|
33
|
+
so the same key can live at different scopes without collision.
|
|
34
|
+
|
|
35
|
+
``value`` is always stored as a string. ``value_type`` advertises how
|
|
36
|
+
the bytes should be interpreted ("string", "bool", "int", "float",
|
|
37
|
+
"json") so UIs pick the right input control and pydantic can reject
|
|
38
|
+
inputs that don't parse.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
__tablename__ = TABLE_SETTING
|
|
42
|
+
__table_args__ = (UniqueConstraint("scope", "scope_id", "key", name=UQ_SCOPE_KEY),)
|
|
43
|
+
|
|
44
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
45
|
+
scope: str = Field(default=SCOPE_SYSTEM, max_length=SCOPE_MAX_LENGTH, index=True)
|
|
46
|
+
scope_id: str = Field(default=SYSTEM_SCOPE_ID, max_length=SCOPE_ID_MAX_LENGTH, index=True)
|
|
47
|
+
key: str = Field(max_length=KEY_MAX_LENGTH, index=True)
|
|
48
|
+
value: str = Field(max_length=VALUE_MAX_LENGTH)
|
|
49
|
+
value_type: str = Field(default=VALUE_TYPE_STRING, max_length=VALUE_TYPE_MAX_LENGTH)
|
|
50
|
+
description: str | None = Field(default=None, max_length=DESCRIPTION_MAX_LENGTH)
|
settings/module.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Settings module definition."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.resources
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, FastAPI
|
|
9
|
+
from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
|
|
10
|
+
from simple_module_core.module import ModuleBase, ModuleMeta
|
|
11
|
+
from simple_module_core.permissions import PermissionRegistry
|
|
12
|
+
|
|
13
|
+
from settings.constants import (
|
|
14
|
+
ALL_PERMISSIONS,
|
|
15
|
+
API_PREFIX,
|
|
16
|
+
LOCALE_NAMESPACE,
|
|
17
|
+
MENU_ICON,
|
|
18
|
+
MENU_LABEL,
|
|
19
|
+
MENU_ORDER,
|
|
20
|
+
MENU_URL,
|
|
21
|
+
MODULE_NAME,
|
|
22
|
+
MODULE_PACKAGE,
|
|
23
|
+
PERM_GROUP,
|
|
24
|
+
VIEW_PREFIX,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SettingsModule(ModuleBase):
|
|
29
|
+
meta = ModuleMeta(
|
|
30
|
+
name=MODULE_NAME,
|
|
31
|
+
route_prefix=API_PREFIX,
|
|
32
|
+
view_prefix=VIEW_PREFIX,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def register_settings(self, app: FastAPI) -> None:
|
|
36
|
+
from settings.contracts.registry import SettingsRegistry
|
|
37
|
+
from settings.module_registry import ModuleSettingsRegistry
|
|
38
|
+
from settings.services import SettingsServices
|
|
39
|
+
from settings.settings import SettingsSettings
|
|
40
|
+
|
|
41
|
+
services = SettingsServices(
|
|
42
|
+
settings=SettingsSettings(),
|
|
43
|
+
registry=SettingsRegistry(),
|
|
44
|
+
module_registry=ModuleSettingsRegistry(),
|
|
45
|
+
)
|
|
46
|
+
setattr(app.state, MODULE_PACKAGE, services)
|
|
47
|
+
|
|
48
|
+
# Self-register so the UI lists our own settings alongside other modules.
|
|
49
|
+
services.module_registry.register("settings", SettingsSettings)
|
|
50
|
+
|
|
51
|
+
def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
|
|
52
|
+
from settings.endpoints.api import router as api
|
|
53
|
+
from settings.endpoints.module_api import router as module_api
|
|
54
|
+
from settings.endpoints.views import router as views
|
|
55
|
+
|
|
56
|
+
api_router.include_router(module_api)
|
|
57
|
+
api_router.include_router(api)
|
|
58
|
+
view_router.include_router(views)
|
|
59
|
+
|
|
60
|
+
def register_menu_items(self, registry: MenuRegistry) -> None:
|
|
61
|
+
registry.add(
|
|
62
|
+
MenuItem(
|
|
63
|
+
label=MENU_LABEL,
|
|
64
|
+
url=MENU_URL,
|
|
65
|
+
icon=MENU_ICON,
|
|
66
|
+
order=MENU_ORDER,
|
|
67
|
+
section=MenuSection.SIDEBAR,
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def register_permissions(self, registry: PermissionRegistry) -> None:
|
|
72
|
+
registry.add_group(PERM_GROUP, list(ALL_PERMISSIONS))
|
|
73
|
+
|
|
74
|
+
def locale_dirs(self) -> dict[str, Path]:
|
|
75
|
+
base = Path(str(importlib.resources.files(__package__) / "locales"))
|
|
76
|
+
return {LOCALE_NAMESPACE: base}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Registry of per-module pydantic BaseSettings classes.
|
|
2
|
+
|
|
3
|
+
Populated during each module's ``register_settings`` via
|
|
4
|
+
``register_module_settings``. The hosting lifespan reads this at startup
|
|
5
|
+
to hydrate every module's effective settings from the DB.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from pydantic_settings import BaseSettings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class ModuleSettingsRegistry:
|
|
17
|
+
"""In-memory map of ``package`` → ``BaseSettings`` subclass."""
|
|
18
|
+
|
|
19
|
+
_classes: dict[str, type[BaseSettings]] = field(default_factory=dict)
|
|
20
|
+
|
|
21
|
+
def register(self, package: str, cls: type[BaseSettings]) -> None:
|
|
22
|
+
if package in self._classes:
|
|
23
|
+
raise ValueError(f"{package!r} already registered")
|
|
24
|
+
self._classes[package] = cls
|
|
25
|
+
|
|
26
|
+
def get(self, package: str) -> type[BaseSettings] | None:
|
|
27
|
+
return self._classes.get(package)
|
|
28
|
+
|
|
29
|
+
def all_packages(self) -> list[str]:
|
|
30
|
+
return sorted(self._classes)
|
|
31
|
+
|
|
32
|
+
def items(self) -> list[tuple[str, type[BaseSettings]]]:
|
|
33
|
+
return sorted(self._classes.items())
|
settings/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@simple-module-py/settings",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Frontend assets for the Settings module",
|
|
6
|
+
"peerDependencies": {
|
|
7
|
+
"react": "^19.0.0",
|
|
8
|
+
"react-dom": "^19.0.0",
|
|
9
|
+
"@inertiajs/react": "^2.0.0",
|
|
10
|
+
"@simple-module-py/ui": "*"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@simple-module-py/tsconfig": "*"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {}
|
|
16
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { router } from '@inertiajs/react';
|
|
2
|
+
import { keys, useT } from '@simple-module-py/i18n';
|
|
3
|
+
import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
|
|
4
|
+
import type React from 'react';
|
|
5
|
+
import type { ValueType } from './components/ValueInput';
|
|
6
|
+
import { ROUTES } from './routes';
|
|
7
|
+
|
|
8
|
+
type Scope = 'system' | 'tenant' | 'user';
|
|
9
|
+
|
|
10
|
+
type Setting = {
|
|
11
|
+
id: number;
|
|
12
|
+
scope: Scope;
|
|
13
|
+
scope_id: string;
|
|
14
|
+
key: string;
|
|
15
|
+
value: string;
|
|
16
|
+
value_type: ValueType;
|
|
17
|
+
description: string | null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type Props = { settings: Setting[] };
|
|
21
|
+
|
|
22
|
+
function Browse({ settings }: Props) {
|
|
23
|
+
const { t } = useT();
|
|
24
|
+
|
|
25
|
+
function handleDelete(setting: Setting) {
|
|
26
|
+
if (!window.confirm(t(keys.settings.browse.delete_confirm, { key: setting.key }))) return;
|
|
27
|
+
router.delete(ROUTES.byId(setting.id));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="p-6">
|
|
32
|
+
<div className="flex items-center justify-between mb-4">
|
|
33
|
+
<h1 className="text-2xl font-semibold">{t(keys.settings.browse.title)}</h1>
|
|
34
|
+
<div className="flex items-center gap-3">
|
|
35
|
+
<a href={ROUTES.modules} className="text-sm text-primary hover:underline">
|
|
36
|
+
{t(keys.settings.modules.browse_link)}
|
|
37
|
+
</a>
|
|
38
|
+
<a
|
|
39
|
+
href={ROUTES.create}
|
|
40
|
+
className="rounded bg-primary px-3 py-1.5 text-primary-foreground"
|
|
41
|
+
>
|
|
42
|
+
{t(keys.settings.browse.new_button)}
|
|
43
|
+
</a>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
{settings.length === 0 ? (
|
|
47
|
+
<div className="py-12 text-center">
|
|
48
|
+
<h2 className="text-lg font-medium">{t(keys.settings.browse.empty_title)}</h2>
|
|
49
|
+
<p className="text-sm text-muted-foreground mt-1">
|
|
50
|
+
{t(keys.settings.browse.empty_description)}
|
|
51
|
+
</p>
|
|
52
|
+
</div>
|
|
53
|
+
) : (
|
|
54
|
+
<table className="w-full text-left border-collapse">
|
|
55
|
+
<thead>
|
|
56
|
+
<tr className="border-b">
|
|
57
|
+
<th className="py-2 pr-4">{t(keys.settings.table.scope)}</th>
|
|
58
|
+
<th className="py-2 pr-4">{t(keys.settings.table.scope_id)}</th>
|
|
59
|
+
<th className="py-2 pr-4">{t(keys.settings.table.key)}</th>
|
|
60
|
+
<th className="py-2 pr-4">{t(keys.settings.table.value_type)}</th>
|
|
61
|
+
<th className="py-2 pr-4">{t(keys.settings.table.value)}</th>
|
|
62
|
+
<th className="py-2 pr-4">{t(keys.settings.table.description)}</th>
|
|
63
|
+
<th className="py-2">{t(keys.settings.table.actions)}</th>
|
|
64
|
+
</tr>
|
|
65
|
+
</thead>
|
|
66
|
+
<tbody>
|
|
67
|
+
{settings.map((setting) => (
|
|
68
|
+
<tr key={setting.id} className="border-b">
|
|
69
|
+
<td className="py-2 pr-4">{t(keys.settings.scopes[setting.scope])}</td>
|
|
70
|
+
<td className="py-2 pr-4 font-mono text-sm text-muted-foreground">
|
|
71
|
+
{setting.scope_id || '—'}
|
|
72
|
+
</td>
|
|
73
|
+
<td className="py-2 pr-4 font-mono text-sm">{setting.key}</td>
|
|
74
|
+
<td className="py-2 pr-4 text-xs uppercase text-muted-foreground">
|
|
75
|
+
{t(keys.settings.value_types[setting.value_type])}
|
|
76
|
+
</td>
|
|
77
|
+
<td className="py-2 pr-4 font-mono text-sm">{setting.value}</td>
|
|
78
|
+
<td className="py-2 pr-4 text-muted-foreground">{setting.description ?? ''}</td>
|
|
79
|
+
<td className="py-2">
|
|
80
|
+
<div className="flex gap-3">
|
|
81
|
+
<a href={ROUTES.edit(setting.id)} className="text-primary hover:underline">
|
|
82
|
+
{t(keys.settings.browse.edit_link)}
|
|
83
|
+
</a>
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
onClick={() => handleDelete(setting)}
|
|
87
|
+
className="text-destructive hover:underline"
|
|
88
|
+
>
|
|
89
|
+
{t(keys.settings.browse.delete_link)}
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
</td>
|
|
93
|
+
</tr>
|
|
94
|
+
))}
|
|
95
|
+
</tbody>
|
|
96
|
+
</table>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
Browse.layout = (page: React.ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>;
|
|
103
|
+
export default Browse;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useForm } from '@inertiajs/react';
|
|
2
|
+
import { keys, useT } from '@simple-module-py/i18n';
|
|
3
|
+
import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
|
|
4
|
+
import type React from 'react';
|
|
5
|
+
import ValueInput, { VALUE_TYPES, type ValueType } from './components/ValueInput';
|
|
6
|
+
import { ROUTES } from './routes';
|
|
7
|
+
|
|
8
|
+
const SCOPES = ['system', 'tenant', 'user'] as const;
|
|
9
|
+
type Scope = (typeof SCOPES)[number];
|
|
10
|
+
|
|
11
|
+
function Create() {
|
|
12
|
+
const { t } = useT();
|
|
13
|
+
const { data, setData, post, processing, errors } = useForm({
|
|
14
|
+
scope: 'system' as Scope,
|
|
15
|
+
scope_id: '',
|
|
16
|
+
key: '',
|
|
17
|
+
value_type: 'string' as ValueType,
|
|
18
|
+
value: '',
|
|
19
|
+
description: '',
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function handleSubmit(e: React.FormEvent) {
|
|
23
|
+
e.preventDefault();
|
|
24
|
+
post(ROUTES.browse);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className="p-6 max-w-xl">
|
|
29
|
+
<h1 className="text-2xl font-semibold mb-4">{t(keys.settings.create.title)}</h1>
|
|
30
|
+
<form onSubmit={handleSubmit} className="space-y-3">
|
|
31
|
+
<label className="block">
|
|
32
|
+
<span className="block text-sm">{t(keys.settings.form.scope_label)}</span>
|
|
33
|
+
<select
|
|
34
|
+
value={data.scope}
|
|
35
|
+
onChange={(e) => setData('scope', e.target.value as Scope)}
|
|
36
|
+
className="border rounded w-full p-2"
|
|
37
|
+
>
|
|
38
|
+
{SCOPES.map((s) => (
|
|
39
|
+
<option key={s} value={s}>
|
|
40
|
+
{t(keys.settings.scopes[s])}
|
|
41
|
+
</option>
|
|
42
|
+
))}
|
|
43
|
+
</select>
|
|
44
|
+
{errors.scope && <p className="text-sm text-destructive">{errors.scope}</p>}
|
|
45
|
+
</label>
|
|
46
|
+
<label className="block">
|
|
47
|
+
<span className="block text-sm">{t(keys.settings.form.scope_id_label)}</span>
|
|
48
|
+
<input
|
|
49
|
+
value={data.scope_id}
|
|
50
|
+
onChange={(e) => setData('scope_id', e.target.value)}
|
|
51
|
+
placeholder={t(keys.settings.form.scope_id_placeholder)}
|
|
52
|
+
className="border rounded w-full p-2 font-mono"
|
|
53
|
+
/>
|
|
54
|
+
{errors.scope_id && <p className="text-sm text-destructive">{errors.scope_id}</p>}
|
|
55
|
+
</label>
|
|
56
|
+
<label className="block">
|
|
57
|
+
<span className="block text-sm">{t(keys.settings.form.key_label)}</span>
|
|
58
|
+
<input
|
|
59
|
+
value={data.key}
|
|
60
|
+
onChange={(e) => setData('key', e.target.value)}
|
|
61
|
+
required
|
|
62
|
+
placeholder={t(keys.settings.form.key_placeholder)}
|
|
63
|
+
className="border rounded w-full p-2 font-mono"
|
|
64
|
+
/>
|
|
65
|
+
{errors.key && <p className="text-sm text-destructive">{errors.key}</p>}
|
|
66
|
+
</label>
|
|
67
|
+
<label className="block">
|
|
68
|
+
<span className="block text-sm">{t(keys.settings.form.value_type_label)}</span>
|
|
69
|
+
<select
|
|
70
|
+
value={data.value_type}
|
|
71
|
+
onChange={(e) => setData('value_type', e.target.value as ValueType)}
|
|
72
|
+
className="border rounded w-full p-2"
|
|
73
|
+
>
|
|
74
|
+
{VALUE_TYPES.map((vt) => (
|
|
75
|
+
<option key={vt} value={vt}>
|
|
76
|
+
{t(keys.settings.value_types[vt])}
|
|
77
|
+
</option>
|
|
78
|
+
))}
|
|
79
|
+
</select>
|
|
80
|
+
</label>
|
|
81
|
+
<div className="block">
|
|
82
|
+
<span className="block text-sm">{t(keys.settings.form.value_label)}</span>
|
|
83
|
+
<ValueInput
|
|
84
|
+
valueType={data.value_type}
|
|
85
|
+
value={data.value}
|
|
86
|
+
onValueChange={(v) => setData('value', v)}
|
|
87
|
+
required
|
|
88
|
+
/>
|
|
89
|
+
{errors.value && <p className="text-sm text-destructive">{errors.value}</p>}
|
|
90
|
+
</div>
|
|
91
|
+
<label className="block">
|
|
92
|
+
<span className="block text-sm">{t(keys.settings.form.description_label)}</span>
|
|
93
|
+
<textarea
|
|
94
|
+
value={data.description}
|
|
95
|
+
onChange={(e) => setData('description', e.target.value)}
|
|
96
|
+
placeholder={t(keys.settings.form.description_placeholder)}
|
|
97
|
+
className="border rounded w-full p-2"
|
|
98
|
+
/>
|
|
99
|
+
</label>
|
|
100
|
+
<button
|
|
101
|
+
type="submit"
|
|
102
|
+
disabled={processing}
|
|
103
|
+
className="rounded bg-primary px-3 py-1.5 text-primary-foreground disabled:opacity-50"
|
|
104
|
+
>
|
|
105
|
+
{t(keys.settings.create.submit_button)}
|
|
106
|
+
</button>
|
|
107
|
+
</form>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
Create.layout = (page: React.ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>;
|
|
113
|
+
export default Create;
|
settings/pages/Edit.tsx
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useForm } from '@inertiajs/react';
|
|
2
|
+
import { keys, useT } from '@simple-module-py/i18n';
|
|
3
|
+
import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
|
|
4
|
+
import type React from 'react';
|
|
5
|
+
import ValueInput, { type ValueType } from './components/ValueInput';
|
|
6
|
+
import { ROUTES } from './routes';
|
|
7
|
+
|
|
8
|
+
type Scope = 'system' | 'tenant' | 'user';
|
|
9
|
+
|
|
10
|
+
type Setting = {
|
|
11
|
+
id: number;
|
|
12
|
+
scope: Scope;
|
|
13
|
+
scope_id: string;
|
|
14
|
+
key: string;
|
|
15
|
+
value: string;
|
|
16
|
+
value_type: ValueType;
|
|
17
|
+
description: string | null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type Props = { setting: Setting };
|
|
21
|
+
|
|
22
|
+
function Edit({ setting }: Props) {
|
|
23
|
+
const { t } = useT();
|
|
24
|
+
const { data, setData, put, processing, errors } = useForm({
|
|
25
|
+
value: setting.value,
|
|
26
|
+
value_type: setting.value_type,
|
|
27
|
+
description: setting.description ?? '',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function handleSubmit(e: React.FormEvent) {
|
|
31
|
+
e.preventDefault();
|
|
32
|
+
put(ROUTES.byId(setting.id));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="p-6 max-w-xl">
|
|
37
|
+
<h1 className="text-2xl font-semibold mb-4">{t(keys.settings.edit.title)}</h1>
|
|
38
|
+
<form onSubmit={handleSubmit} className="space-y-3">
|
|
39
|
+
<label className="block">
|
|
40
|
+
<span className="block text-sm">{t(keys.settings.form.scope_label)}</span>
|
|
41
|
+
<input
|
|
42
|
+
value={t(keys.settings.scopes[setting.scope])}
|
|
43
|
+
disabled
|
|
44
|
+
className="border rounded w-full p-2 bg-muted"
|
|
45
|
+
/>
|
|
46
|
+
</label>
|
|
47
|
+
<label className="block">
|
|
48
|
+
<span className="block text-sm">{t(keys.settings.form.scope_id_label)}</span>
|
|
49
|
+
<input
|
|
50
|
+
value={setting.scope_id}
|
|
51
|
+
disabled
|
|
52
|
+
className="border rounded w-full p-2 font-mono bg-muted"
|
|
53
|
+
/>
|
|
54
|
+
</label>
|
|
55
|
+
<label className="block">
|
|
56
|
+
<span className="block text-sm">{t(keys.settings.form.key_label)}</span>
|
|
57
|
+
<input
|
|
58
|
+
defaultValue={setting.key}
|
|
59
|
+
disabled
|
|
60
|
+
className="border rounded w-full p-2 font-mono bg-muted"
|
|
61
|
+
/>
|
|
62
|
+
</label>
|
|
63
|
+
<label className="block">
|
|
64
|
+
<span className="block text-sm">{t(keys.settings.form.value_type_label)}</span>
|
|
65
|
+
<input
|
|
66
|
+
value={t(keys.settings.value_types[setting.value_type])}
|
|
67
|
+
disabled
|
|
68
|
+
className="border rounded w-full p-2 bg-muted"
|
|
69
|
+
/>
|
|
70
|
+
</label>
|
|
71
|
+
<div className="block">
|
|
72
|
+
<span className="block text-sm">{t(keys.settings.form.value_label)}</span>
|
|
73
|
+
<ValueInput
|
|
74
|
+
valueType={data.value_type}
|
|
75
|
+
value={data.value}
|
|
76
|
+
onValueChange={(v) => setData('value', v)}
|
|
77
|
+
required
|
|
78
|
+
/>
|
|
79
|
+
{errors.value && <p className="text-sm text-destructive">{errors.value}</p>}
|
|
80
|
+
</div>
|
|
81
|
+
<label className="block">
|
|
82
|
+
<span className="block text-sm">{t(keys.settings.form.description_label)}</span>
|
|
83
|
+
<textarea
|
|
84
|
+
value={data.description}
|
|
85
|
+
onChange={(e) => setData('description', e.target.value)}
|
|
86
|
+
className="border rounded w-full p-2"
|
|
87
|
+
/>
|
|
88
|
+
</label>
|
|
89
|
+
<button
|
|
90
|
+
type="submit"
|
|
91
|
+
disabled={processing}
|
|
92
|
+
className="rounded bg-primary px-3 py-1.5 text-primary-foreground disabled:opacity-50"
|
|
93
|
+
>
|
|
94
|
+
{t(keys.settings.edit.submit_button)}
|
|
95
|
+
</button>
|
|
96
|
+
</form>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
Edit.layout = (page: React.ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>;
|
|
102
|
+
export default Edit;
|