hazo_config 1.4.2 → 2.0.2

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 (58) hide show
  1. package/MIGRATION_V2.md +531 -0
  2. package/README.md +201 -10
  3. package/dist/components/app_config.d.ts +1 -1
  4. package/dist/components/app_config.d.ts.map +1 -1
  5. package/dist/components/app_config.js +123 -86
  6. package/dist/components/app_config_list_editor/app_config_list_editor.d.ts +7 -0
  7. package/dist/components/app_config_list_editor/app_config_list_editor.d.ts.map +1 -0
  8. package/dist/components/app_config_list_editor/app_config_list_editor.js +128 -0
  9. package/dist/components/app_config_list_editor/components/color_swatch_picker.d.ts +9 -0
  10. package/dist/components/app_config_list_editor/components/color_swatch_picker.d.ts.map +1 -0
  11. package/dist/components/app_config_list_editor/components/color_swatch_picker.js +15 -0
  12. package/dist/components/app_config_list_editor/components/delete_dialog.d.ts +10 -0
  13. package/dist/components/app_config_list_editor/components/delete_dialog.d.ts.map +1 -0
  14. package/dist/components/app_config_list_editor/components/delete_dialog.js +9 -0
  15. package/dist/components/app_config_list_editor/components/edit_modal.d.ts +19 -0
  16. package/dist/components/app_config_list_editor/components/edit_modal.d.ts.map +1 -0
  17. package/dist/components/app_config_list_editor/components/edit_modal.js +97 -0
  18. package/dist/components/app_config_list_editor/components/empty_state.d.ts +8 -0
  19. package/dist/components/app_config_list_editor/components/empty_state.d.ts.map +1 -0
  20. package/dist/components/app_config_list_editor/components/empty_state.js +8 -0
  21. package/dist/components/app_config_list_editor/components/list_item_row.d.ts +14 -0
  22. package/dist/components/app_config_list_editor/components/list_item_row.d.ts.map +1 -0
  23. package/dist/components/app_config_list_editor/components/list_item_row.js +14 -0
  24. package/dist/components/app_config_list_editor/components/save_status_indicator.d.ts +7 -0
  25. package/dist/components/app_config_list_editor/components/save_status_indicator.d.ts.map +1 -0
  26. package/dist/components/app_config_list_editor/components/save_status_indicator.js +25 -0
  27. package/dist/components/app_config_list_editor/components/search_bar.d.ts +10 -0
  28. package/dist/components/app_config_list_editor/components/search_bar.d.ts.map +1 -0
  29. package/dist/components/app_config_list_editor/components/search_bar.js +8 -0
  30. package/dist/components/app_config_list_editor/index.d.ts +3 -0
  31. package/dist/components/app_config_list_editor/index.d.ts.map +1 -0
  32. package/dist/components/app_config_list_editor/index.js +2 -0
  33. package/dist/components/app_config_list_editor/types.d.ts +93 -0
  34. package/dist/components/app_config_list_editor/types.d.ts.map +1 -0
  35. package/dist/components/app_config_list_editor/types.js +14 -0
  36. package/dist/components/index.d.ts +2 -0
  37. package/dist/components/index.d.ts.map +1 -1
  38. package/dist/components/index.js +2 -0
  39. package/dist/components/ui/alert-dialog.d.ts +21 -0
  40. package/dist/components/ui/alert-dialog.d.ts.map +1 -0
  41. package/dist/components/ui/alert-dialog.js +26 -0
  42. package/dist/components/ui/button.d.ts +12 -0
  43. package/dist/components/ui/button.d.ts.map +1 -0
  44. package/dist/components/ui/button.js +33 -0
  45. package/dist/components/ui/dialog.d.ts +20 -0
  46. package/dist/components/ui/dialog.d.ts.map +1 -0
  47. package/dist/components/ui/dialog.js +22 -0
  48. package/dist/components/ui/input.d.ts +4 -0
  49. package/dist/components/ui/input.d.ts.map +1 -0
  50. package/dist/components/ui/input.js +8 -0
  51. package/dist/components/use_app_config.d.ts +3 -4
  52. package/dist/components/use_app_config.d.ts.map +1 -1
  53. package/dist/components/use_app_config.js +51 -17
  54. package/dist/lib/app_config_types.d.ts +19 -17
  55. package/dist/lib/app_config_types.d.ts.map +1 -1
  56. package/dist/lib/index.d.ts +1 -1
  57. package/dist/lib/index.d.ts.map +1 -1
  58. package/package.json +6 -3
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Hazo Config Component Library
2
2
 
3
- A React component library for managing configuration with Storybook for component testing and documentation.
3
+ A React component library for managing configuration with database-backed storage, supporting both INI files and app configuration. Includes React UI components with JSON editing capabilities.
4
4
 
5
5
  ## Tech Stack
6
6
 
@@ -11,6 +11,49 @@ A React component library for managing configuration with Storybook for componen
11
11
  - **Storybook** - Component development and testing
12
12
  - **Vite** - Build tool
13
13
 
14
+ ## Quick Start
15
+
16
+ 1. Install the package:
17
+ ```bash
18
+ npm install hazo_config
19
+ ```
20
+
21
+ 2. Add CSS custom properties to your `globals.css`:
22
+ ```css
23
+ @layer base {
24
+ :root {
25
+ --background: 0 0% 100%;
26
+ --foreground: 222.2 84% 4.9%;
27
+ --primary: 222.2 47.4% 11.2%;
28
+ --primary-foreground: 210 40% 98%;
29
+ --muted: 210 40% 96.1%;
30
+ --muted-foreground: 215.4 16.3% 46.9%;
31
+ --accent: 210 40% 96.1%;
32
+ --accent-foreground: 222.2 47.4% 11.2%;
33
+ --border: 214.3 31.8% 91.4%;
34
+ --input: 214.3 31.8% 91.4%;
35
+ }
36
+ }
37
+ ```
38
+
39
+ 3. If using Tailwind v4, add the `@source` directive to `globals.css`:
40
+ ```css
41
+ @import "tailwindcss";
42
+
43
+ @source "../node_modules/hazo_config/dist";
44
+ ```
45
+
46
+ 4. Import and use components:
47
+ ```typescript
48
+ // Client components
49
+ import { AppConfig, ConfigViewer, MockConfigProvider } from 'hazo_config'
50
+
51
+ // Server-side (API routes, server components)
52
+ import { HazoConfig } from 'hazo_config/server'
53
+ ```
54
+
55
+ See detailed setup instructions below.
56
+
14
57
  ## Usage
15
58
 
16
59
  ### Installation
@@ -19,13 +62,61 @@ A React component library for managing configuration with Storybook for componen
19
62
  npm install hazo_config
20
63
  ```
21
64
 
22
- ### Tailwind CSS Setup (Required)
65
+ ### Styling Setup (Required)
66
+
67
+ This package uses Tailwind CSS utility classes for styling. You must configure both Tailwind and CSS custom properties.
68
+
69
+ #### CSS Custom Properties (Required for All Versions)
70
+
71
+ Add these CSS custom properties to your `globals.css` or main CSS file. These are used for theming and dark mode support:
23
72
 
24
- This package uses Tailwind CSS utility classes for styling. You must configure Tailwind in your project.
73
+ ```css
74
+ @layer base {
75
+ :root {
76
+ --background: 0 0% 100%;
77
+ --foreground: 222.2 84% 4.9%;
78
+ --primary: 222.2 47.4% 11.2%;
79
+ --primary-foreground: 210 40% 98%;
80
+ --muted: 210 40% 96.1%;
81
+ --muted-foreground: 215.4 16.3% 46.9%;
82
+ --accent: 210 40% 96.1%;
83
+ --accent-foreground: 222.2 47.4% 11.2%;
84
+ --border: 214.3 31.8% 91.4%;
85
+ --input: 214.3 31.8% 91.4%;
86
+ --card: 0 0% 100%;
87
+ --card-foreground: 222.2 84% 4.9%;
88
+ }
89
+
90
+ .dark {
91
+ --background: 222.2 84% 4.9%;
92
+ --foreground: 210 40% 98%;
93
+ --primary: 210 40% 98%;
94
+ --primary-foreground: 222.2 47.4% 11.2%;
95
+ --muted: 217.2 32.6% 17.5%;
96
+ --muted-foreground: 215 20.2% 65.1%;
97
+ --accent: 217.2 32.6% 17.5%;
98
+ --accent-foreground: 210 40% 98%;
99
+ --border: 217.2 32.6% 17.5%;
100
+ --input: 217.2 32.6% 17.5%;
101
+ --card: 222.2 84% 4.9%;
102
+ --card-foreground: 210 40% 98%;
103
+ }
104
+ }
105
+ ```
25
106
 
26
107
  #### Tailwind v3 Setup
27
108
 
28
- If using Tailwind v3, no additional setup is required beyond having Tailwind installed.
109
+ If using Tailwind v3, ensure your `tailwind.config.js` includes:
110
+
111
+ ```js
112
+ module.exports = {
113
+ content: [
114
+ './app/**/*.{js,ts,jsx,tsx}',
115
+ './components/**/*.{js,ts,jsx,tsx}',
116
+ ],
117
+ // ... rest of config
118
+ }
119
+ ```
29
120
 
30
121
  #### Tailwind v4 Setup (Critical)
31
122
 
@@ -51,7 +142,8 @@ This package provides separate entry points for server and client code to preven
51
142
 
52
143
  ```typescript
53
144
  // Client code (React components, browser)
54
- import { ConfigViewer, ConfigEditor, MockConfigProvider } from 'hazo_config'
145
+ import { ConfigViewer, ConfigEditor, AppConfig, MockConfigProvider } from 'hazo_config'
146
+ import type { AppConfigItem, AppConfigContext, ConfigType } from 'hazo_config'
55
147
 
56
148
  // Server code (API routes, Next.js server components)
57
149
  import { HazoConfig } from 'hazo_config/server'
@@ -59,6 +151,17 @@ import { HazoConfig } from 'hazo_config/server'
59
151
 
60
152
  **Why?** The `HazoConfig` class uses Node.js `fs` and `path` modules. Importing it in client code causes "Module not found: Can't resolve 'fs'" errors. The `/server` entry point uses the `server-only` package to prevent accidental client imports.
61
153
 
154
+ ### Major Version 2.0 Changes
155
+
156
+ **v2.0.0** introduces breaking changes with database-backed configuration support:
157
+
158
+ - **New Component**: `AppConfig` for database-backed configuration with JSON value support
159
+ - **Schema Change**: Replaces `org_id` with `scope_id` for flexible scoping
160
+ - **JSON Support**: Store structured configuration as JSON with in-UI editing
161
+ - **Type System**: New `ConfigType` ('general' | 'json') for value type management
162
+
163
+ See [MIGRATION_V2.md](./MIGRATION_V2.md) for detailed migration guide from v1.x.
164
+
62
165
  ### Example: Server-side Config Loading
63
166
 
64
167
  ```typescript
@@ -69,7 +172,7 @@ const config = new HazoConfig({ filePath: './config.ini' })
69
172
  const dbHost = config.get('database', 'host')
70
173
  ```
71
174
 
72
- ### Example: Client-side Config Display
175
+ ### Example: Client-side Config Display (INI-based)
73
176
 
74
177
  ```typescript
75
178
  // components/settings.tsx (React component)
@@ -85,6 +188,89 @@ export function Settings() {
85
188
  }
86
189
  ```
87
190
 
191
+ ### Example: Database-backed App Config (v2.0+)
192
+
193
+ ```typescript
194
+ // app/settings/page.tsx (Next.js server component)
195
+ 'use client'
196
+
197
+ import { AppConfig } from 'hazo_config'
198
+ import type { AppConfigContext, AppConfigItem, ConfigType } from 'hazo_config'
199
+ import { fetch_config, save_config, delete_config } from '@/lib/config_actions'
200
+
201
+ export default function SettingsPage() {
202
+ const context: AppConfigContext = {
203
+ scope_id: 'your-scope-id',
204
+ user_id: 'optional-user-id'
205
+ }
206
+
207
+ return (
208
+ <AppConfig
209
+ title="Application Settings"
210
+ context={context}
211
+ fetch_config={fetch_config}
212
+ save_config={save_config}
213
+ delete_config={delete_config}
214
+ />
215
+ )
216
+ }
217
+ ```
218
+
219
+ The AppConfig component supports:
220
+ - JSON and text configuration values
221
+ - In-line editing with validation
222
+ - Sensitive field masking (passwords, tokens, keys)
223
+ - Section-based organization
224
+ - Type conversion between general and JSON
225
+
226
+ ### Example: Generic List Editor (v2.0.2+)
227
+
228
+ ```typescript
229
+ import { AppConfigListEditor } from 'hazo_config'
230
+ import type { ColumnDef } from 'hazo_config'
231
+
232
+ interface Tag {
233
+ [key: string]: unknown
234
+ tag_id: string
235
+ tag_label: string
236
+ color: string
237
+ description: string
238
+ }
239
+
240
+ const TAG_COLUMNS: ColumnDef<Tag>[] = [
241
+ { field: 'tag_label', label: 'Label', type: 'text', required: true, list_display: 'primary' },
242
+ { field: 'tag_id', label: 'Tag ID', type: 'text', required: true, list_display: 'badge' },
243
+ { field: 'color', label: 'Color', type: 'color_swatch', color_options: ['bg-blue-100 text-blue-800', 'bg-green-100 text-green-800'] },
244
+ { field: 'description', label: 'Description', type: 'textarea', list_display: 'secondary' },
245
+ ]
246
+
247
+ export function TagManager({ tags, onTagsChange }) {
248
+ return (
249
+ <AppConfigListEditor<Tag>
250
+ items={tags}
251
+ on_items_change={onTagsChange}
252
+ columns={TAG_COLUMNS}
253
+ id_field="tag_id"
254
+ auto_id_from="tag_label"
255
+ title="Classification Tags"
256
+ description="Manage tags for document categorization."
257
+ enable_search={true}
258
+ delete_confirmation={(tag) => `Delete "${tag.tag_label}"?`}
259
+ />
260
+ )
261
+ }
262
+ ```
263
+
264
+ The `AppConfigListEditor` is a generic, reusable CRUD list editor for arrays of structured objects. It supports:
265
+ - Dynamic form fields (text, textarea, number, select, color_swatch, toggle)
266
+ - Auto-ID generation from a source field
267
+ - Search/filter for long lists
268
+ - Delete confirmation dialogs
269
+ - Color swatch picker
270
+ - Save status indicators (saving/saved/error)
271
+ - Custom item rendering (indicator, preview, full row override)
272
+ - Field validation with inline error messages
273
+
88
274
  ## Development
89
275
 
90
276
  ### Local Setup
@@ -125,13 +311,18 @@ npm run build-storybook
125
311
  hazo_config/
126
312
  ├── src/
127
313
  │ ├── components/ # React components (client-safe)
128
- │ │ ├── config_viewer.tsx
129
- │ │ ├── config_editor.tsx
314
+ │ │ ├── config_viewer.tsx # INI config viewer
315
+ │ │ ├── config_editor.tsx # INI config editor
316
+ │ │ ├── app_config.tsx # Database-backed config (v2.0+)
317
+ │ │ ├── app_config_list_editor/ # Generic CRUD list editor (v2.0.2+)
318
+ │ │ ├── ui/ # shadcn/ui primitives (Dialog, Button, etc.)
319
+ │ │ ├── use_app_config.ts # Hook for app config
130
320
  │ │ └── *.stories.tsx
131
321
  │ ├── lib/ # Core config management
132
- │ │ ├── config_loader.ts # HazoConfig class (Node.js)
322
+ │ │ ├── config_loader.ts # HazoConfig class (Node.js)
133
323
  │ │ ├── mock_config_provider.ts # Client-safe mock
134
- │ │ └── types.ts
324
+ │ │ ├── types.ts # ConfigProvider interfaces
325
+ │ │ └── app_config_types.ts # AppConfig interfaces (v2.0+)
135
326
  │ ├── server/ # Server-only entry point
136
327
  │ │ └── index.ts
137
328
  │ ├── styles/ # Global styles
@@ -3,7 +3,7 @@ import type { AppConfigProps } from '../lib/app_config_types.js';
3
3
  /**
4
4
  * AppConfig component
5
5
  * Provides interface for viewing and editing database-backed configuration
6
- * Supports org-level and user-level configuration with sensitive field masking
6
+ * Supports sensitive field masking and both general (text) and json types
7
7
  * @param props - Component props
8
8
  * @returns React component
9
9
  */
@@ -1 +1 @@
1
- {"version":3,"file":"app_config.d.ts","sourceRoot":"","sources":["../../src/components/app_config.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAyC,MAAM,OAAO,CAAA;AAK7D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAA;AAWhE;;;;;;GAMG;AACH,eAAO,MAAM,SAAS,EAAE,KAAK,CAAC,EAAE,CAAC,cAAc,CAugB9C,CAAA"}
1
+ {"version":3,"file":"app_config.d.ts","sourceRoot":"","sources":["../../src/components/app_config.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAyC,MAAM,OAAO,CAAA;AAK7D,OAAO,KAAK,EAAE,cAAc,EAA6B,MAAM,4BAA4B,CAAA;AA0P3F;;;;;;GAMG;AACH,eAAO,MAAM,SAAS,EAAE,KAAK,CAAC,EAAE,CAAC,cAAc,CA6a9C,CAAA"}
@@ -1,29 +1,99 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  // AppConfig component for managing database-backed configuration
3
- // Provides UI for viewing and editing org-level or user-level configuration stored in hazo_app_config table
3
+ // Provides UI for viewing and editing configuration stored in hazo_app_config table
4
4
  import React, { useState, useCallback, useMemo } from 'react';
5
5
  import { cn } from '../lib/utils.js';
6
- import { RefreshCw, Save, Trash2, Eye, EyeOff, Plus } from 'lucide-react';
6
+ import { RefreshCw, Save, Trash2, Eye, EyeOff, Plus, X } from 'lucide-react';
7
7
  import { useAppConfig } from './use_app_config.js';
8
8
  import { is_sensitive_field, mask_value, DEFAULT_SENSITIVE_PATTERNS } from './use_config_sections.js';
9
+ const JsonEditor = ({ value, onChange, onValidationChange, placeholder, className }) => {
10
+ const [text_value, set_text_value] = useState(() => JSON.stringify(value, null, 2));
11
+ const [validation_error, set_validation_error] = useState();
12
+ const handle_change = (e) => {
13
+ const new_text = e.target.value;
14
+ set_text_value(new_text);
15
+ try {
16
+ const parsed = JSON.parse(new_text);
17
+ set_validation_error(undefined);
18
+ onChange(parsed);
19
+ onValidationChange?.(true);
20
+ }
21
+ catch (err) {
22
+ const error_msg = err instanceof Error ? err.message : 'Invalid JSON format';
23
+ set_validation_error(error_msg);
24
+ onValidationChange?.(false, error_msg);
25
+ }
26
+ };
27
+ return (_jsxs("div", { className: "space-y-2", children: [_jsx("textarea", { value: text_value, onChange: handle_change, className: cn('w-full px-3 py-2 border rounded-md font-mono text-sm min-h-[120px] focus:outline-none focus:ring-2 focus:ring-primary', validation_error ? 'border-red-500 focus:ring-red-500' : 'border-input', className), placeholder: placeholder }), validation_error && (_jsxs("div", { className: "flex items-start gap-2 text-sm text-red-600 bg-red-50 p-3 rounded-md", children: [_jsx(X, { className: "h-4 w-4 mt-0.5 flex-shrink-0" }), _jsx("span", { children: validation_error })] }))] }));
28
+ };
29
+ const KeyDialog = ({ mode, section_name, on_close, on_save }) => {
30
+ const [new_section, set_new_section] = useState('');
31
+ const [new_key, set_new_key] = useState('');
32
+ const [new_value, set_new_value] = useState('');
33
+ const [new_type, set_new_type] = useState('general');
34
+ const [is_saving, set_is_saving] = useState(false);
35
+ const [is_json_valid, set_is_json_valid] = useState(true);
36
+ const handle_save = async () => {
37
+ const target_section = mode === 'add-section' ? new_section : section_name;
38
+ if (!target_section || !new_key)
39
+ return;
40
+ if (new_type === 'json' && !is_json_valid) {
41
+ return;
42
+ }
43
+ set_is_saving(true);
44
+ try {
45
+ let value_to_save = new_value;
46
+ if (new_type === 'json') {
47
+ try {
48
+ value_to_save = JSON.parse(new_value);
49
+ }
50
+ catch {
51
+ value_to_save = {};
52
+ }
53
+ }
54
+ await on_save(target_section, new_key, value_to_save, new_type);
55
+ on_close();
56
+ }
57
+ catch (err) {
58
+ console.error('Failed to save:', err);
59
+ }
60
+ finally {
61
+ set_is_saving(false);
62
+ }
63
+ };
64
+ const can_save = mode === 'add-section'
65
+ ? new_section && new_key && new_value && is_json_valid
66
+ : new_key && new_value && is_json_valid;
67
+ if (!mode)
68
+ return null;
69
+ return (_jsx("div", { className: "fixed inset-0 bg-black/50 flex items-center justify-center z-50", children: _jsxs("div", { className: "bg-white rounded-lg shadow-xl max-w-md w-full mx-4", children: [_jsxs("div", { className: "flex items-center justify-between p-6 border-b", children: [_jsx("h3", { className: "text-lg font-semibold", children: mode === 'add-section' ? 'Add New Section' : 'Add New Key' }), _jsx("button", { onClick: on_close, className: "text-muted-foreground hover:text-foreground", type: "button", children: _jsx(X, { className: "h-5 w-5" }) })] }), _jsxs("div", { className: "p-6 space-y-4", children: [mode === 'add-section' && (_jsxs("div", { className: "space-y-2", children: [_jsx("label", { className: "text-sm font-medium", children: "Section Name" }), _jsx("input", { type: "text", placeholder: "e.g., integrations, notifications", value: new_section, onChange: (e) => set_new_section(e.target.value), className: "w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary", autoFocus: true })] })), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { className: "text-sm font-medium", children: "Key Name" }), _jsx("input", { type: "text", placeholder: "e.g., api_key, webhook_url", value: new_key, onChange: (e) => set_new_key(e.target.value), className: "w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary", autoFocus: mode !== 'add-section' })] }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { className: "text-sm font-medium", children: "Type" }), _jsxs("div", { className: "flex gap-4", children: [_jsxs("label", { className: "flex items-center gap-2 cursor-pointer", children: [_jsx("input", { type: "radio", name: "type", value: "general", checked: new_type === 'general', onChange: (e) => set_new_type(e.target.value), className: "w-4 h-4 text-primary" }), _jsx("span", { className: "text-sm", children: "General (Text)" })] }), _jsxs("label", { className: "flex items-center gap-2 cursor-pointer", children: [_jsx("input", { type: "radio", name: "type", value: "json", checked: new_type === 'json', onChange: (e) => set_new_type(e.target.value), className: "w-4 h-4 text-primary" }), _jsx("span", { className: "text-sm", children: "JSON (Object)" })] })] })] }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { className: "text-sm font-medium", children: "Value" }), new_type === 'json' ? (_jsx(JsonEditor, { value: new_value ? (() => {
70
+ try {
71
+ return JSON.parse(new_value);
72
+ }
73
+ catch {
74
+ return {};
75
+ }
76
+ })() : {}, onChange: (val) => set_new_value(JSON.stringify(val, null, 2)), onValidationChange: (isValid) => {
77
+ set_is_json_valid(isValid);
78
+ }, placeholder: '{"key": "value"}' })) : (_jsx("input", { type: "text", placeholder: "Enter value", value: new_value, onChange: (e) => set_new_value(e.target.value), className: "w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary" }))] })] }), _jsxs("div", { className: "flex items-center justify-end gap-3 p-6 border-t bg-muted/30", children: [_jsx("button", { onClick: on_close, className: "px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground", type: "button", children: "Cancel" }), _jsx("button", { onClick: handle_save, disabled: !can_save || is_saving, className: cn('px-4 py-2 text-sm font-medium rounded-md', can_save && !is_saving
79
+ ? 'bg-primary text-primary-foreground hover:bg-primary/90'
80
+ : 'bg-muted text-muted-foreground cursor-not-allowed'), type: "button", children: is_saving ? 'Saving...' : mode === 'add-section' ? 'Create Section' : 'Add Key' })] })] }) }));
81
+ };
9
82
  /**
10
83
  * AppConfig component
11
84
  * Provides interface for viewing and editing database-backed configuration
12
- * Supports org-level and user-level configuration with sensitive field masking
85
+ * Supports sensitive field masking and both general (text) and json types
13
86
  * @param props - Component props
14
87
  * @returns React component
15
88
  */
16
- export const AppConfig = ({ level, context, fetch_config, save_config, delete_config, className, on_update, on_error, sensitive_fields, sensitive_patterns = DEFAULT_SENSITIVE_PATTERNS, disable_auto_mask = false, title, }) => {
17
- const { sections, is_loading, error, reload, set_value, delete_value } = useAppConfig(level, context, fetch_config, save_config, delete_config);
89
+ export const AppConfig = ({ context, fetch_config, save_config, delete_config, className, on_update, on_error, sensitive_fields, sensitive_patterns = DEFAULT_SENSITIVE_PATTERNS, disable_auto_mask = false, title = 'Configuration', }) => {
90
+ const { sections, is_loading, error, reload, set_value, delete_value, } = useAppConfig(context, fetch_config, save_config, delete_config);
18
91
  const [selected_section, set_selected_section] = useState(null);
19
- const [new_section, set_new_section] = useState('');
20
- const [new_key, set_new_key] = useState('');
21
- const [new_value, set_new_value] = useState('');
22
92
  const [editing_fields, set_editing_fields] = useState(new Map());
23
93
  const [revealed_fields, set_revealed_fields] = useState(new Set());
24
94
  const [has_unsaved_changes, set_has_unsaved_changes] = useState(false);
25
95
  const [is_saving, set_is_saving] = useState(false);
26
- const [show_add_section, set_show_add_section] = useState(false);
96
+ const [dialog_mode, set_dialog_mode] = useState(null);
27
97
  // Report errors to parent
28
98
  React.useEffect(() => {
29
99
  if (error && on_error) {
@@ -36,12 +106,6 @@ export const AppConfig = ({ level, context, fetch_config, save_config, delete_co
36
106
  set_selected_section(Object.keys(sections)[0]);
37
107
  }
38
108
  }, [sections, selected_section]);
39
- /**
40
- * Default title based on level
41
- */
42
- const display_title = useMemo(() => {
43
- return title ?? (level === 'org' ? 'Organization Settings' : 'User Settings');
44
- }, [title, level]);
45
109
  /**
46
110
  * Check if a field should be masked
47
111
  */
@@ -82,10 +146,13 @@ export const AppConfig = ({ level, context, fetch_config, save_config, delete_co
82
146
  /**
83
147
  * Get the current editing value for a field, or the original value
84
148
  */
85
- const get_field_value = useCallback((section, key, original_value) => {
149
+ const get_field_value = useCallback((section, key, item) => {
86
150
  const field_id = get_field_id(section, key);
87
151
  const editing = editing_fields.get(field_id);
88
- return editing ? editing.value : original_value;
152
+ if (editing) {
153
+ return editing.value;
154
+ }
155
+ return item.config_type === 'json' ? item.config_value_json : item.config_value_text;
89
156
  }, [editing_fields, get_field_id]);
90
157
  /**
91
158
  * Handle field value change (stores in local state, not database)
@@ -125,7 +192,10 @@ export const AppConfig = ({ level, context, fetch_config, save_config, delete_co
125
192
  try {
126
193
  // Save all pending edits to the database
127
194
  for (const edit of editing_fields.values()) {
128
- await set_value(edit.section, edit.key, edit.value);
195
+ const item = sections[edit.section]?.[edit.key];
196
+ if (item) {
197
+ await set_value(edit.section, edit.key, edit.value, item.config_type);
198
+ }
129
199
  }
130
200
  set_editing_fields(new Map());
131
201
  set_has_unsaved_changes(false);
@@ -141,11 +211,13 @@ export const AppConfig = ({ level, context, fetch_config, save_config, delete_co
141
211
  finally {
142
212
  set_is_saving(false);
143
213
  }
144
- }, [editing_fields, set_value, on_update, on_error]);
214
+ }, [editing_fields, sections, set_value, on_update, on_error]);
145
215
  /**
146
216
  * Handle delete key
147
217
  */
148
218
  const handle_delete_key = useCallback(async (section, key) => {
219
+ if (!window.confirm(`Delete key "${key}"?`))
220
+ return;
149
221
  try {
150
222
  await delete_value(section, key);
151
223
  // Remove from editing state if present
@@ -166,63 +238,26 @@ export const AppConfig = ({ level, context, fetch_config, save_config, delete_co
166
238
  }
167
239
  }, [delete_value, get_field_id, on_update, on_error]);
168
240
  /**
169
- * Add a new key-value pair to selected section
241
+ * Handle adding a key from dialog
170
242
  */
171
- const handle_add_key = useCallback(async () => {
172
- const target_section = show_add_section ? new_section : selected_section;
173
- if (target_section && new_key && new_value) {
174
- try {
175
- await set_value(target_section, new_key, new_value);
176
- set_new_key('');
177
- set_new_value('');
178
- set_new_section('');
179
- set_show_add_section(false);
180
- // Select the new section if it was just created
181
- if (show_add_section) {
182
- set_selected_section(target_section);
183
- }
184
- if (on_update) {
185
- on_update();
186
- }
243
+ const handle_dialog_save = useCallback(async (section, key, value, type) => {
244
+ try {
245
+ await set_value(section, key, value, type);
246
+ // Select the section if it was just created
247
+ if (dialog_mode === 'add-section') {
248
+ set_selected_section(section);
187
249
  }
188
- catch (err) {
189
- if (on_error) {
190
- on_error(err instanceof Error ? err : new Error(String(err)));
191
- }
250
+ if (on_update) {
251
+ on_update();
192
252
  }
193
253
  }
194
- }, [selected_section, new_section, new_key, new_value, show_add_section, set_value, on_update, on_error]);
195
- /**
196
- * Handle new key input change
197
- */
198
- const handle_new_key_change = useCallback((e) => {
199
- set_new_key(e.target.value);
200
- }, []);
201
- /**
202
- * Handle new value input change
203
- */
204
- const handle_new_value_change = useCallback((e) => {
205
- set_new_value(e.target.value);
206
- }, []);
207
- /**
208
- * Handle new section input change
209
- */
210
- const handle_new_section_change = useCallback((e) => {
211
- set_new_section(e.target.value);
212
- }, []);
213
- /**
214
- * Handle section selection
215
- */
216
- const handle_section_select = useCallback((section_name) => {
217
- set_selected_section(section_name);
218
- }, []);
219
- /**
220
- * Toggle add new section mode
221
- */
222
- const toggle_add_section = useCallback(() => {
223
- set_show_add_section(prev => !prev);
224
- set_new_section('');
225
- }, []);
254
+ catch (err) {
255
+ if (on_error) {
256
+ on_error(err instanceof Error ? err : new Error(String(err)));
257
+ }
258
+ throw err;
259
+ }
260
+ }, [set_value, dialog_mode, on_update, on_error]);
226
261
  /**
227
262
  * Memoized section list
228
263
  */
@@ -236,18 +271,20 @@ export const AppConfig = ({ level, context, fetch_config, save_config, delete_co
236
271
  if (is_loading) {
237
272
  return (_jsx("div", { className: cn('cls_app_config flex items-center justify-center p-8', className), children: _jsx("div", { className: "text-muted-foreground", children: "Loading configuration..." }) }));
238
273
  }
239
- return (_jsxs("div", { className: cn('cls_app_config space-y-4', className), children: [_jsxs("div", { className: "cls_app_config_header flex items-center justify-between border-b pb-2", children: [_jsxs("div", { children: [_jsxs("h2", { className: "text-xl font-semibold", children: [display_title, has_unsaved_changes && (_jsx("span", { className: "ml-2 text-sm text-amber-600", children: "(unsaved changes)" }))] }), _jsx("p", { className: "text-sm text-muted-foreground", children: level === 'org' ? 'Settings for your organization' : 'Your personal settings' })] }), _jsxs("div", { className: "flex gap-2", children: [_jsxs("button", { onClick: handle_refresh, className: "px-3 py-1 border rounded hover:bg-muted flex items-center gap-2", "aria-label": "Refresh configuration", type: "button", disabled: is_saving, children: [_jsx(RefreshCw, { size: 16 }), "Refresh"] }), _jsxs("button", { onClick: handle_save, className: cn('px-3 py-1 rounded flex items-center gap-2', has_unsaved_changes
240
- ? 'bg-primary text-primary-foreground hover:bg-primary/90'
241
- : 'bg-muted text-muted-foreground'), "aria-label": "Save configuration", type: "button", disabled: !has_unsaved_changes || is_saving, children: [_jsx(Save, { size: 16 }), is_saving ? 'Saving...' : 'Save'] })] })] }), _jsxs("div", { className: "cls_app_config_content grid grid-cols-1 md:grid-cols-3 gap-4", children: [_jsxs("div", { className: "cls_app_config_sections_list border rounded p-4", children: [_jsxs("div", { className: "flex items-center justify-between mb-2", children: [_jsx("h3", { className: "font-semibold", children: "Sections" }), _jsx("button", { onClick: toggle_add_section, className: "text-primary hover:text-primary/80", "aria-label": "Add new section", type: "button", children: _jsx(Plus, { size: 18 }) })] }), _jsxs("div", { className: "space-y-1", role: "listbox", "aria-label": "Configuration sections", children: [section_list.map((section_name) => (_jsx("button", { onClick: () => handle_section_select(section_name), className: cn('w-full text-left px-2 py-1 rounded text-sm', selected_section === section_name
242
- ? 'bg-primary text-primary-foreground'
243
- : 'hover:bg-muted'), role: "option", "aria-selected": selected_section === section_name, type: "button", children: section_name }, section_name))), section_list.length === 0 && (_jsx("div", { className: "text-sm text-muted-foreground py-2", children: "No sections yet. Add your first configuration below." }))] })] }), _jsx("div", { className: "cls_app_config_section_content md:col-span-2 border rounded p-4", children: selected_section ? (_jsxs(_Fragment, { children: [_jsx("h3", { className: "font-semibold mb-4", children: selected_section }), _jsxs("div", { className: "space-y-3", children: [current_section_data &&
244
- Object.entries(current_section_data).map(([key, value]) => {
245
- const is_sensitive = should_mask_field(key);
246
- const field_id = `config-${selected_section}-${key}`;
247
- const show_value = !is_sensitive || is_revealed(selected_section, key);
248
- const display_value = get_field_value(selected_section, key, value);
249
- return (_jsxs("div", { className: "cls_app_config_item", children: [_jsx("label", { htmlFor: field_id, className: "block text-sm font-medium mb-1", children: key }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("input", { id: field_id, type: is_sensitive && !show_value ? 'password' : 'text', value: is_sensitive && !show_value ? mask_value(display_value) : display_value, onChange: (e) => handle_field_change(selected_section, key, e.target.value), className: "flex-1 px-2 py-1 border rounded", readOnly: is_sensitive && !show_value }), is_sensitive && (_jsx("button", { onClick: () => toggle_reveal(selected_section, key), className: "text-gray-500 hover:text-gray-700", "aria-label": show_value ? 'Hide value' : 'Show value', type: "button", children: show_value ? _jsx(EyeOff, { size: 18 }) : _jsx(Eye, { size: 18 }) })), _jsx("button", { onClick: () => handle_delete_key(selected_section, key), className: "text-red-500 hover:text-red-700", "aria-label": `Delete ${key}`, type: "button", children: _jsx(Trash2, { size: 18 }) })] })] }, key));
250
- }), _jsxs("div", { className: "cls_add_new_key border-t pt-3 mt-3", children: [_jsx("h4", { className: "text-sm font-medium mb-2", children: "Add New Key" }), _jsxs("div", { className: "space-y-2", children: [_jsxs("div", { children: [_jsx("label", { htmlFor: "new-key-name", className: "sr-only", children: "Key name" }), _jsx("input", { id: "new-key-name", type: "text", placeholder: "Key name", value: new_key, onChange: handle_new_key_change, className: "w-full px-2 py-1 border rounded text-sm" })] }), _jsxs("div", { children: [_jsx("label", { htmlFor: "new-key-value", className: "sr-only", children: "Value" }), _jsx("input", { id: "new-key-value", type: "text", placeholder: "Value", value: new_value, onChange: handle_new_value_change, className: "w-full px-2 py-1 border rounded text-sm" })] }), _jsx("button", { onClick: handle_add_key, className: "px-3 py-1 bg-primary text-primary-foreground rounded hover:bg-primary/90 text-sm", type: "button", disabled: !new_key || !new_value, children: "Add Key" })] })] })] })] })) : show_add_section ? (_jsxs("div", { className: "space-y-3", children: [_jsx("h3", { className: "font-semibold mb-4", children: "Add New Section" }), _jsxs("div", { className: "space-y-2", children: [_jsxs("div", { children: [_jsx("label", { htmlFor: "new-section-name", className: "block text-sm font-medium mb-1", children: "Section Name" }), _jsx("input", { id: "new-section-name", type: "text", placeholder: "Section name (e.g., 'general', 'notifications')", value: new_section, onChange: handle_new_section_change, className: "w-full px-2 py-1 border rounded text-sm" })] }), _jsxs("div", { children: [_jsx("label", { htmlFor: "new-section-key", className: "block text-sm font-medium mb-1", children: "First Key" }), _jsx("input", { id: "new-section-key", type: "text", placeholder: "Key name", value: new_key, onChange: handle_new_key_change, className: "w-full px-2 py-1 border rounded text-sm" })] }), _jsxs("div", { children: [_jsx("label", { htmlFor: "new-section-value", className: "block text-sm font-medium mb-1", children: "Value" }), _jsx("input", { id: "new-section-value", type: "text", placeholder: "Value", value: new_value, onChange: handle_new_value_change, className: "w-full px-2 py-1 border rounded text-sm" })] }), _jsxs("div", { className: "flex gap-2", children: [_jsx("button", { onClick: handle_add_key, className: "px-3 py-1 bg-primary text-primary-foreground rounded hover:bg-primary/90 text-sm", type: "button", disabled: !new_section || !new_key || !new_value, children: "Create Section" }), _jsx("button", { onClick: toggle_add_section, className: "px-3 py-1 border rounded hover:bg-muted text-sm", type: "button", children: "Cancel" })] })] })] })) : (_jsx("div", { className: "text-center text-muted-foreground py-8", children: section_list.length > 0
251
- ? 'Select a section to view/edit'
252
- : 'Click the + button to add your first configuration section' })) })] })] }));
274
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: cn('cls_app_config space-y-6', className), children: [_jsxs("div", { className: "cls_app_config_header flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("h2", { className: "text-2xl font-bold", children: title }), has_unsaved_changes && (_jsx("p", { className: "text-sm text-amber-600 mt-1", children: "You have unsaved changes" }))] }), _jsxs("div", { className: "flex gap-3", children: [_jsxs("button", { onClick: handle_refresh, className: "px-4 py-2 border border-input rounded-md hover:bg-accent hover:text-accent-foreground flex items-center gap-2 transition-colors", type: "button", disabled: is_saving, children: [_jsx(RefreshCw, { className: "h-4 w-4" }), "Refresh"] }), _jsxs("button", { onClick: handle_save, className: cn('px-4 py-2 rounded-md flex items-center gap-2 transition-colors', has_unsaved_changes
275
+ ? 'bg-primary text-primary-foreground hover:bg-primary/90'
276
+ : 'bg-muted text-muted-foreground cursor-not-allowed'), type: "button", disabled: !has_unsaved_changes || is_saving, children: [_jsx(Save, { className: "h-4 w-4" }), is_saving ? 'Saving...' : 'Save'] })] })] }), _jsxs("div", { className: "grid grid-cols-1 lg:grid-cols-4 gap-6", children: [_jsx("div", { className: "lg:col-span-1", children: _jsxs("div", { className: "bg-card border border-border rounded-lg overflow-hidden", children: [_jsx("div", { className: "p-4 border-b bg-muted/30", children: _jsxs("div", { className: "flex items-center justify-between mb-3", children: [_jsx("h3", { className: "font-semibold", children: "Sections" }), _jsx("button", { onClick: () => set_dialog_mode('add-section'), className: "text-primary hover:text-primary/80 transition-colors", type: "button", children: _jsx(Plus, { className: "h-5 w-5" }) })] }) }), _jsx("div", { className: "p-2", children: section_list.length === 0 ? (_jsx("div", { className: "text-sm text-muted-foreground text-center py-8 px-4", children: "No sections yet. Click + to add your first section." })) : (_jsx("div", { className: "space-y-1", children: section_list.map((section_name) => (_jsx("button", { onClick: () => set_selected_section(section_name), className: cn('w-full text-left px-3 py-2 rounded-md text-sm font-medium transition-colors', selected_section === section_name
277
+ ? 'bg-primary text-primary-foreground'
278
+ : 'hover:bg-muted'), type: "button", children: section_name }, section_name))) })) })] }) }), _jsx("div", { className: "lg:col-span-3", children: selected_section && current_section_data ? (_jsxs("div", { className: "space-y-4", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("h3", { className: "text-xl font-semibold", children: selected_section }), _jsxs("button", { onClick: () => set_dialog_mode('add'), className: "px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 flex items-center gap-2 transition-colors", type: "button", children: [_jsx(Plus, { className: "h-4 w-4" }), "Add Key"] })] }), _jsxs("div", { className: "space-y-3", children: [Object.entries(current_section_data).map(([key, item]) => {
279
+ const is_sensitive = should_mask_field(key);
280
+ const show_value = !is_sensitive || is_revealed(selected_section, key);
281
+ const display_value = get_field_value(selected_section, key, item);
282
+ return (_jsxs("div", { className: "bg-card border border-border rounded-lg p-4 hover:shadow-sm transition-shadow", children: [_jsxs("div", { className: "flex items-start justify-between mb-3", children: [_jsx("div", { className: "flex-1", children: _jsxs("div", { className: "flex items-center gap-2 mb-1", children: [_jsx("span", { className: "font-medium", children: key }), _jsx("span", { className: cn('text-xs px-2 py-0.5 rounded-full font-medium', item.config_type === 'json'
283
+ ? 'bg-purple-100 text-purple-700'
284
+ : 'bg-gray-100 text-gray-700'), children: item.config_type })] }) }), _jsxs("div", { className: "flex items-center gap-2", children: [is_sensitive && (_jsx("button", { onClick: () => toggle_reveal(selected_section, key), className: "text-muted-foreground hover:text-foreground transition-colors", type: "button", children: show_value ? _jsx(EyeOff, { className: "h-4 w-4" }) : _jsx(Eye, { className: "h-4 w-4" }) })), _jsx("button", { onClick: () => handle_delete_key(selected_section, key), className: "text-red-500 hover:text-red-700 transition-colors", type: "button", children: _jsx(Trash2, { className: "h-4 w-4" }) })] })] }), item.config_type === 'json' ? (_jsx(JsonEditor, { value: typeof display_value === 'object' ? display_value : {}, onChange: (val) => handle_field_change(selected_section, key, val), className: "bg-muted/30" })) : (_jsx("input", { type: is_sensitive && !show_value ? 'password' : 'text', value: is_sensitive && !show_value
285
+ ? mask_value(String(display_value))
286
+ : String(display_value), onChange: (e) => handle_field_change(selected_section, key, e.target.value), className: "w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary", readOnly: is_sensitive && !show_value }))] }, key));
287
+ }), Object.keys(current_section_data).length === 0 && (_jsx("div", { className: "text-center py-12 text-muted-foreground", children: "No keys in this section yet. Click \"Add Key\" to get started." }))] })] })) : (_jsx("div", { className: "flex items-center justify-center h-64 bg-muted/30 border border-dashed border-border rounded-lg", children: _jsx("div", { className: "text-center text-muted-foreground", children: section_list.length > 0
288
+ ? 'Select a section to view and edit keys'
289
+ : 'Click + to create your first configuration section' }) })) })] })] }), _jsx(KeyDialog, { mode: dialog_mode, section_name: selected_section || undefined, on_close: () => set_dialog_mode(null), on_save: handle_dialog_save })] }));
253
290
  };
@@ -0,0 +1,7 @@
1
+ import type { AppConfigListEditorProps } from './types.js';
2
+ /**
3
+ * AppConfigListEditor - A polished CRUD list editor for config item arrays.
4
+ * Purely callback-based: no API calls, no database access.
5
+ */
6
+ export declare function AppConfigListEditor<T extends Record<string, unknown>>({ items, on_items_change, columns, id_field, auto_id_from, title, description, enable_search, search_threshold, render_item, render_item_indicator, render_preview, delete_confirmation, max_items, id_editable_after_create, className, save_status, }: AppConfigListEditorProps<T>): import("react/jsx-runtime").JSX.Element;
7
+ //# sourceMappingURL=app_config_list_editor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app_config_list_editor.d.ts","sourceRoot":"","sources":["../../../src/components/app_config_list_editor/app_config_list_editor.tsx"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,wBAAwB,EAAqC,MAAM,YAAY,CAAA;AAQ7F;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EACrE,KAAK,EACL,eAAe,EACf,OAAO,EACP,QAAQ,EACR,YAAY,EACZ,KAAK,EACL,WAAW,EACX,aAAqB,EACrB,gBAAoB,EACpB,WAAW,EACX,qBAAqB,EACrB,cAAc,EACd,mBAAmB,EACnB,SAAS,EACT,wBAAgC,EAChC,SAAS,EACT,WAAoB,GACrB,EAAE,wBAAwB,CAAC,CAAC,CAAC,2CAsP7B"}