hazo_config 2.0.0 → 2.1.0
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.
- package/README.md +62 -0
- package/dist/components/app_config_list_editor/app_config_list_editor.d.ts +7 -0
- package/dist/components/app_config_list_editor/app_config_list_editor.d.ts.map +1 -0
- package/dist/components/app_config_list_editor/app_config_list_editor.js +129 -0
- package/dist/components/app_config_list_editor/components/color_swatch_picker.d.ts +9 -0
- package/dist/components/app_config_list_editor/components/color_swatch_picker.d.ts.map +1 -0
- package/dist/components/app_config_list_editor/components/color_swatch_picker.js +15 -0
- package/dist/components/app_config_list_editor/components/delete_dialog.d.ts +10 -0
- package/dist/components/app_config_list_editor/components/delete_dialog.d.ts.map +1 -0
- package/dist/components/app_config_list_editor/components/delete_dialog.js +9 -0
- package/dist/components/app_config_list_editor/components/edit_modal.d.ts +19 -0
- package/dist/components/app_config_list_editor/components/edit_modal.d.ts.map +1 -0
- package/dist/components/app_config_list_editor/components/edit_modal.js +97 -0
- package/dist/components/app_config_list_editor/components/empty_state.d.ts +8 -0
- package/dist/components/app_config_list_editor/components/empty_state.d.ts.map +1 -0
- package/dist/components/app_config_list_editor/components/empty_state.js +8 -0
- package/dist/components/app_config_list_editor/components/import_export_buttons.d.ts +12 -0
- package/dist/components/app_config_list_editor/components/import_export_buttons.d.ts.map +1 -0
- package/dist/components/app_config_list_editor/components/import_export_buttons.js +74 -0
- package/dist/components/app_config_list_editor/components/list_item_row.d.ts +14 -0
- package/dist/components/app_config_list_editor/components/list_item_row.d.ts.map +1 -0
- package/dist/components/app_config_list_editor/components/list_item_row.js +14 -0
- package/dist/components/app_config_list_editor/components/save_status_indicator.d.ts +7 -0
- package/dist/components/app_config_list_editor/components/save_status_indicator.d.ts.map +1 -0
- package/dist/components/app_config_list_editor/components/save_status_indicator.js +25 -0
- package/dist/components/app_config_list_editor/components/search_bar.d.ts +10 -0
- package/dist/components/app_config_list_editor/components/search_bar.d.ts.map +1 -0
- package/dist/components/app_config_list_editor/components/search_bar.js +8 -0
- package/dist/components/app_config_list_editor/index.d.ts +4 -0
- package/dist/components/app_config_list_editor/index.d.ts.map +1 -0
- package/dist/components/app_config_list_editor/index.js +3 -0
- package/dist/components/app_config_list_editor/types.d.ts +102 -0
- package/dist/components/app_config_list_editor/types.d.ts.map +1 -0
- package/dist/components/app_config_list_editor/types.js +14 -0
- package/dist/components/app_config_list_editor/utils/json_import_export.d.ts +26 -0
- package/dist/components/app_config_list_editor/utils/json_import_export.d.ts.map +1 -0
- package/dist/components/app_config_list_editor/utils/json_import_export.js +112 -0
- package/dist/components/index.d.ts +3 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +3 -0
- package/dist/components/ui/alert-dialog.d.ts +21 -0
- package/dist/components/ui/alert-dialog.d.ts.map +1 -0
- package/dist/components/ui/alert-dialog.js +26 -0
- package/dist/components/ui/button.d.ts +12 -0
- package/dist/components/ui/button.d.ts.map +1 -0
- package/dist/components/ui/button.js +33 -0
- package/dist/components/ui/dialog.d.ts +20 -0
- package/dist/components/ui/dialog.d.ts.map +1 -0
- package/dist/components/ui/dialog.js +22 -0
- package/dist/components/ui/input.d.ts +4 -0
- package/dist/components/ui/input.d.ts.map +1 -0
- package/dist/components/ui/input.js +8 -0
- package/package.json +7 -1
- package/MIGRATION_V2.md +0 -531
package/README.md
CHANGED
|
@@ -223,6 +223,66 @@ The AppConfig component supports:
|
|
|
223
223
|
- Section-based organization
|
|
224
224
|
- Type conversion between general and JSON
|
|
225
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
|
+
- **JSON import/export** — export items as `.json` file, import from file with validation and duplicate skipping
|
|
274
|
+
|
|
275
|
+
#### JSON Import/Export
|
|
276
|
+
|
|
277
|
+
The list editor includes built-in Export and Import buttons in the section bar. Export downloads the current items as a formatted JSON file. Import opens a file picker, validates the JSON against column definitions, and merges new items into the list (skipping duplicates by ID).
|
|
278
|
+
|
|
279
|
+
Utility functions are also exported for programmatic use:
|
|
280
|
+
|
|
281
|
+
```typescript
|
|
282
|
+
import { validate_import_data, merge_items, export_items_to_json, generate_export_filename } from 'hazo_config'
|
|
283
|
+
import type { ImportResult } from 'hazo_config'
|
|
284
|
+
```
|
|
285
|
+
|
|
226
286
|
## Development
|
|
227
287
|
|
|
228
288
|
### Local Setup
|
|
@@ -266,6 +326,8 @@ hazo_config/
|
|
|
266
326
|
│ │ ├── config_viewer.tsx # INI config viewer
|
|
267
327
|
│ │ ├── config_editor.tsx # INI config editor
|
|
268
328
|
│ │ ├── app_config.tsx # Database-backed config (v2.0+)
|
|
329
|
+
│ │ ├── app_config_list_editor/ # Generic CRUD list editor (v2.0.2+)
|
|
330
|
+
│ │ ├── ui/ # shadcn/ui primitives (Dialog, Button, etc.)
|
|
269
331
|
│ │ ├── use_app_config.ts # Hook for app config
|
|
270
332
|
│ │ └── *.stories.tsx
|
|
271
333
|
│ ├── lib/ # Core config management
|
|
@@ -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;AAS7F;;;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,2CAgQ7B"}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// AppConfigListEditor - Main component
|
|
3
|
+
// Generic CRUD list editor for arrays of structured objects stored as JSON config
|
|
4
|
+
import { useState, useCallback, useMemo } from 'react';
|
|
5
|
+
import { Plus } from 'lucide-react';
|
|
6
|
+
import { cn } from '../../lib/utils.js';
|
|
7
|
+
import { ListItemRow } from './components/list_item_row.js';
|
|
8
|
+
import { EditModal } from './components/edit_modal.js';
|
|
9
|
+
import { SearchBar } from './components/search_bar.js';
|
|
10
|
+
import { EmptyState } from './components/empty_state.js';
|
|
11
|
+
import { DeleteDialog } from './components/delete_dialog.js';
|
|
12
|
+
import { SaveStatusIndicator } from './components/save_status_indicator.js';
|
|
13
|
+
import { ImportExportButtons } from './components/import_export_buttons.js';
|
|
14
|
+
/**
|
|
15
|
+
* AppConfigListEditor - A polished CRUD list editor for config item arrays.
|
|
16
|
+
* Purely callback-based: no API calls, no database access.
|
|
17
|
+
*/
|
|
18
|
+
export function AppConfigListEditor({ items, on_items_change, columns, id_field, auto_id_from, title, description, enable_search = false, search_threshold = 8, render_item, render_item_indicator, render_preview, delete_confirmation, max_items, id_editable_after_create = false, className, save_status = 'idle', }) {
|
|
19
|
+
const [search_term, set_search_term] = useState('');
|
|
20
|
+
const [edit_state, set_edit_state] = useState(null);
|
|
21
|
+
const [delete_state, set_delete_state] = useState(null);
|
|
22
|
+
// Derive a friendly item label from the title (e.g., "Classification Tags" -> "tags")
|
|
23
|
+
const item_label = useMemo(() => {
|
|
24
|
+
if (!title)
|
|
25
|
+
return 'items';
|
|
26
|
+
const words = title.toLowerCase().split(/\s+/);
|
|
27
|
+
return words[words.length - 1] || 'items';
|
|
28
|
+
}, [title]);
|
|
29
|
+
// Derive singular item type label (e.g., "tags" -> "Tag")
|
|
30
|
+
const item_type_label = useMemo(() => {
|
|
31
|
+
const singular = item_label.replace(/s$/, '');
|
|
32
|
+
return singular.charAt(0).toUpperCase() + singular.slice(1);
|
|
33
|
+
}, [item_label]);
|
|
34
|
+
// Derive section header label (uppercase, e.g., "TAGS")
|
|
35
|
+
const section_label = useMemo(() => item_label.toUpperCase(), [item_label]);
|
|
36
|
+
// Get all existing IDs for duplicate checking
|
|
37
|
+
const existing_ids = useMemo(() => items.map((item) => String(item[id_field])), [items, id_field]);
|
|
38
|
+
// Filter items by search term
|
|
39
|
+
const filtered_items = useMemo(() => {
|
|
40
|
+
if (!search_term.trim())
|
|
41
|
+
return items;
|
|
42
|
+
const term = search_term.toLowerCase();
|
|
43
|
+
return items.filter((item) => columns.some((col) => {
|
|
44
|
+
if (col.list_display === 'hidden' && col.show_in_list === false)
|
|
45
|
+
return false;
|
|
46
|
+
const val = item[col.field];
|
|
47
|
+
return val !== undefined && val !== null && String(val).toLowerCase().includes(term);
|
|
48
|
+
}));
|
|
49
|
+
}, [items, search_term, columns]);
|
|
50
|
+
const show_search = enable_search && items.length > search_threshold;
|
|
51
|
+
const is_max_reached = max_items !== undefined && max_items !== null && items.length >= max_items;
|
|
52
|
+
// CRUD handlers
|
|
53
|
+
const handle_add = useCallback(() => {
|
|
54
|
+
const empty_item = {};
|
|
55
|
+
// Initialize with defaults for toggle fields
|
|
56
|
+
for (const col of columns) {
|
|
57
|
+
if (col.type === 'toggle') {
|
|
58
|
+
empty_item[col.field] = false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
set_edit_state({
|
|
62
|
+
mode: 'create',
|
|
63
|
+
item: empty_item,
|
|
64
|
+
errors: new Map(),
|
|
65
|
+
user_touched_id: false,
|
|
66
|
+
});
|
|
67
|
+
}, [columns]);
|
|
68
|
+
const handle_edit = useCallback((item) => {
|
|
69
|
+
set_edit_state({
|
|
70
|
+
mode: 'edit',
|
|
71
|
+
item: { ...item },
|
|
72
|
+
original_item: item,
|
|
73
|
+
errors: new Map(),
|
|
74
|
+
user_touched_id: true,
|
|
75
|
+
});
|
|
76
|
+
}, []);
|
|
77
|
+
const handle_delete_request = useCallback((item) => {
|
|
78
|
+
if (!delete_confirmation) {
|
|
79
|
+
// No confirmation needed, delete immediately
|
|
80
|
+
on_items_change(items.filter((i) => i[id_field] !== item[id_field]));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
set_delete_state({ item });
|
|
84
|
+
}, [delete_confirmation, items, id_field, on_items_change]);
|
|
85
|
+
const handle_delete_confirm = useCallback(() => {
|
|
86
|
+
if (!delete_state)
|
|
87
|
+
return;
|
|
88
|
+
on_items_change(items.filter((i) => i[id_field] !== delete_state.item[id_field]));
|
|
89
|
+
set_delete_state(null);
|
|
90
|
+
}, [delete_state, items, id_field, on_items_change]);
|
|
91
|
+
const handle_save = useCallback((saved_item) => {
|
|
92
|
+
if (edit_state?.mode === 'create') {
|
|
93
|
+
on_items_change([...items, saved_item]);
|
|
94
|
+
}
|
|
95
|
+
else if (edit_state?.mode === 'edit') {
|
|
96
|
+
const original_id = edit_state.original_item?.[id_field];
|
|
97
|
+
on_items_change(items.map((i) => (i[id_field] === original_id ? saved_item : i)));
|
|
98
|
+
}
|
|
99
|
+
set_edit_state(null);
|
|
100
|
+
}, [edit_state, items, id_field, on_items_change]);
|
|
101
|
+
const handle_cancel_edit = useCallback(() => {
|
|
102
|
+
set_edit_state(null);
|
|
103
|
+
}, []);
|
|
104
|
+
const handle_cancel_delete = useCallback(() => {
|
|
105
|
+
set_delete_state(null);
|
|
106
|
+
}, []);
|
|
107
|
+
// Get delete confirmation message
|
|
108
|
+
const delete_message = useMemo(() => {
|
|
109
|
+
if (!delete_state || !delete_confirmation)
|
|
110
|
+
return '';
|
|
111
|
+
if (typeof delete_confirmation === 'function') {
|
|
112
|
+
return delete_confirmation(delete_state.item);
|
|
113
|
+
}
|
|
114
|
+
return delete_confirmation;
|
|
115
|
+
}, [delete_state, delete_confirmation]);
|
|
116
|
+
// Get delete item name for dialog title
|
|
117
|
+
const delete_item_name = useMemo(() => {
|
|
118
|
+
if (!delete_state)
|
|
119
|
+
return '';
|
|
120
|
+
const primary_col = columns.find((c) => c.list_display === 'primary');
|
|
121
|
+
if (primary_col) {
|
|
122
|
+
return String(delete_state.item[primary_col.field] ?? '');
|
|
123
|
+
}
|
|
124
|
+
return String(delete_state.item[id_field] ?? '');
|
|
125
|
+
}, [delete_state, columns, id_field]);
|
|
126
|
+
return (_jsxs("div", { className: cn('cls_list_editor', className), children: [(title || description) && (_jsxs("div", { className: "cls_list_editor_header mb-6", children: [title && (_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("h2", { className: "text-2xl font-bold text-gray-900", children: title }), _jsx(SaveStatusIndicator, { status: save_status })] })), description && (_jsx("p", { className: "text-sm text-gray-500 mt-1", children: description }))] })), _jsxs("div", { className: "cls_list_editor_section_bar flex items-center justify-between mb-3", children: [_jsx("div", { className: "flex items-center gap-2", children: _jsx("span", { className: "text-xs font-semibold uppercase tracking-wider text-gray-500", children: section_label }) }), _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx(ImportExportButtons, { items: items, on_items_change: on_items_change, columns: columns, id_field: id_field, title: title, max_items: max_items }), _jsxs("button", { type: "button", onClick: handle_add, disabled: is_max_reached, className: cn('inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-full transition-colors', is_max_reached
|
|
127
|
+
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
|
128
|
+
: 'bg-violet-600 hover:bg-violet-700 text-white'), title: is_max_reached ? `Maximum of ${max_items} items reached` : undefined, children: [_jsx(Plus, { className: "w-4 h-4" }), "Add"] })] })] }), show_search && (_jsx("div", { className: "mb-3", children: _jsx(SearchBar, { value: search_term, on_change: set_search_term, item_count: filtered_items.length, item_label: item_label }) })), _jsx("div", { className: "cls_list_editor_card rounded-xl border border-gray-200 bg-white overflow-hidden", children: items.length === 0 ? (_jsx(EmptyState, { item_label: item_label, on_add: handle_add })) : filtered_items.length === 0 ? (_jsxs("div", { className: "py-8 text-center text-sm text-gray-500", children: ["No ", item_label, " matching \u201C", search_term, "\u201D"] })) : (_jsx("div", { className: "divide-y divide-gray-100", children: filtered_items.map((item, index) => (_jsx(ListItemRow, { item: item, index: index, columns: columns, render_item: render_item, render_item_indicator: render_item_indicator, on_edit: () => handle_edit(item), on_delete: () => handle_delete_request(item) }, String(item[id_field])))) })) }), is_max_reached && (_jsxs("p", { className: "text-xs text-gray-400 mt-2", children: ["Maximum of ", max_items, " ", item_label, " reached."] })), edit_state && (_jsx(EditModal, { open: true, mode: edit_state.mode, item: edit_state.item, columns: columns, id_field: id_field, auto_id_from: auto_id_from, id_editable_after_create: id_editable_after_create, existing_ids: existing_ids, item_type_label: item_type_label, render_preview: render_preview, on_save: handle_save, on_cancel: handle_cancel_edit })), delete_state && (_jsx(DeleteDialog, { open: true, item_name: delete_item_name, message: delete_message, on_confirm: handle_delete_confirm, on_cancel: handle_cancel_delete }))] }));
|
|
129
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
interface ColorSwatchPickerProps {
|
|
2
|
+
value: string;
|
|
3
|
+
options: string[];
|
|
4
|
+
on_change: (color: string) => void;
|
|
5
|
+
className?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function ColorSwatchPicker({ value, options, on_change, className, }: ColorSwatchPickerProps): import("react/jsx-runtime").JSX.Element;
|
|
8
|
+
export {};
|
|
9
|
+
//# sourceMappingURL=color_swatch_picker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"color_swatch_picker.d.ts","sourceRoot":"","sources":["../../../../src/components/app_config_list_editor/components/color_swatch_picker.tsx"],"names":[],"mappings":"AAMA,UAAU,sBAAsB;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,MAAM,EAAE,CAAA;IACjB,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IAClC,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,wBAAgB,iBAAiB,CAAC,EAChC,KAAK,EACL,OAAO,EACP,SAAS,EACT,SAAS,GACV,EAAE,sBAAsB,2CA+BxB"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// Color swatch picker component
|
|
3
|
+
// Displays a row of circular color swatches for selection
|
|
4
|
+
import { Check } from 'lucide-react';
|
|
5
|
+
import { cn } from '../../../lib/utils.js';
|
|
6
|
+
export function ColorSwatchPicker({ value, options, on_change, className, }) {
|
|
7
|
+
return (_jsx("div", { className: cn('cls_color_swatch_picker flex flex-wrap gap-2', className), children: options.map((color) => {
|
|
8
|
+
const is_selected = value === color;
|
|
9
|
+
// Extract the bg class for the swatch display
|
|
10
|
+
const bg_class = color.split(' ').find(c => c.startsWith('bg-')) || 'bg-gray-200';
|
|
11
|
+
return (_jsx("button", { type: "button", onClick: () => on_change(color), className: cn('cls_color_swatch w-7 h-7 rounded-full flex items-center justify-center transition-transform hover:scale-110', bg_class, is_selected
|
|
12
|
+
? 'ring-2 ring-violet-600 ring-offset-2'
|
|
13
|
+
: 'ring-1 ring-gray-200'), "aria-label": `Select color ${color}`, "aria-pressed": is_selected, children: is_selected && (_jsx(Check, { className: "w-3.5 h-3.5 text-current opacity-80" })) }, color));
|
|
14
|
+
}) }));
|
|
15
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
interface DeleteDialogProps {
|
|
2
|
+
open: boolean;
|
|
3
|
+
item_name: string;
|
|
4
|
+
message: string;
|
|
5
|
+
on_confirm: () => void;
|
|
6
|
+
on_cancel: () => void;
|
|
7
|
+
}
|
|
8
|
+
export declare function DeleteDialog({ open, item_name, message, on_confirm, on_cancel, }: DeleteDialogProps): import("react/jsx-runtime").JSX.Element;
|
|
9
|
+
export {};
|
|
10
|
+
//# sourceMappingURL=delete_dialog.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"delete_dialog.d.ts","sourceRoot":"","sources":["../../../../src/components/app_config_list_editor/components/delete_dialog.tsx"],"names":[],"mappings":"AAeA,UAAU,iBAAiB;IACzB,IAAI,EAAE,OAAO,CAAA;IACb,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,MAAM,CAAA;IACf,UAAU,EAAE,MAAM,IAAI,CAAA;IACtB,SAAS,EAAE,MAAM,IAAI,CAAA;CACtB;AAED,wBAAgB,YAAY,CAAC,EAC3B,IAAI,EACJ,SAAS,EACT,OAAO,EACP,UAAU,EACV,SAAS,GACV,EAAE,iBAAiB,2CAgCnB"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Delete confirmation dialog component
|
|
3
|
+
// Uses shadcn AlertDialog with red warning styling
|
|
4
|
+
import { AlertTriangle } from 'lucide-react';
|
|
5
|
+
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, } from '../../ui/alert-dialog.js';
|
|
6
|
+
export function DeleteDialog({ open, item_name, message, on_confirm, on_cancel, }) {
|
|
7
|
+
return (_jsx(AlertDialog, { open: open, onOpenChange: (is_open) => { if (!is_open)
|
|
8
|
+
on_cancel(); }, children: _jsxs(AlertDialogContent, { className: "cls_delete_dialog max-w-sm rounded-2xl", children: [_jsxs(AlertDialogHeader, { className: "items-center sm:items-center", children: [_jsx("div", { className: "w-12 h-12 rounded-full bg-red-100 flex items-center justify-center mb-2", children: _jsx(AlertTriangle, { className: "w-6 h-6 text-red-600" }) }), _jsxs(AlertDialogTitle, { className: "text-center", children: ["Delete \u201C", item_name, "\u201D?"] }), _jsx(AlertDialogDescription, { className: "text-center", children: message })] }), _jsxs(AlertDialogFooter, { className: "sm:justify-center gap-2", children: [_jsx(AlertDialogCancel, { onClick: on_cancel, className: "rounded-full", children: "Cancel" }), _jsx(AlertDialogAction, { onClick: on_confirm, className: "rounded-full bg-red-600 text-white hover:bg-red-700", children: "Delete" })] })] }) }));
|
|
9
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { ColumnDef } from '../types.js';
|
|
3
|
+
interface EditModalProps<T extends Record<string, unknown>> {
|
|
4
|
+
open: boolean;
|
|
5
|
+
mode: 'create' | 'edit';
|
|
6
|
+
item: Partial<T>;
|
|
7
|
+
columns: ColumnDef<T>[];
|
|
8
|
+
id_field: keyof T & string;
|
|
9
|
+
auto_id_from?: keyof T & string;
|
|
10
|
+
id_editable_after_create?: boolean;
|
|
11
|
+
existing_ids: string[];
|
|
12
|
+
item_type_label: string;
|
|
13
|
+
render_preview?: (item: T) => React.ReactNode;
|
|
14
|
+
on_save: (item: T) => void;
|
|
15
|
+
on_cancel: () => void;
|
|
16
|
+
}
|
|
17
|
+
export declare function EditModal<T extends Record<string, unknown>>({ open, mode, item: initial_item, columns, id_field, auto_id_from, id_editable_after_create, existing_ids, item_type_label, render_preview, on_save, on_cancel, }: EditModalProps<T>): import("react/jsx-runtime").JSX.Element;
|
|
18
|
+
export {};
|
|
19
|
+
//# sourceMappingURL=edit_modal.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"edit_modal.d.ts","sourceRoot":"","sources":["../../../../src/components/app_config_list_editor/components/edit_modal.tsx"],"names":[],"mappings":"AAGA,OAAO,KAA2C,MAAM,OAAO,CAAA;AAY/D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAG5C,UAAU,cAAc,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IACxD,IAAI,EAAE,OAAO,CAAA;IACb,IAAI,EAAE,QAAQ,GAAG,MAAM,CAAA;IACvB,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC,CAAA;IAChB,OAAO,EAAE,SAAS,CAAC,CAAC,CAAC,EAAE,CAAA;IACvB,QAAQ,EAAE,MAAM,CAAC,GAAG,MAAM,CAAA;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC,GAAG,MAAM,CAAA;IAC/B,wBAAwB,CAAC,EAAE,OAAO,CAAA;IAClC,YAAY,EAAE,MAAM,EAAE,CAAA;IACtB,eAAe,EAAE,MAAM,CAAA;IACvB,cAAc,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,KAAK,CAAC,SAAS,CAAA;IAC7C,OAAO,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,IAAI,CAAA;IAC1B,SAAS,EAAE,MAAM,IAAI,CAAA;CACtB;AAED,wBAAgB,SAAS,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAC3D,IAAI,EACJ,IAAI,EACJ,IAAI,EAAE,YAAY,EAClB,OAAO,EACP,QAAQ,EACR,YAAY,EACZ,wBAAwB,EACxB,YAAY,EACZ,eAAe,EACf,cAAc,EACd,OAAO,EACP,SAAS,GACV,EAAE,cAAc,CAAC,CAAC,CAAC,2CA2PnB"}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Edit modal component
|
|
3
|
+
// Centered dialog for creating/editing items with dynamic form fields
|
|
4
|
+
import { useState, useCallback, useEffect } from 'react';
|
|
5
|
+
import { cn } from '../../../lib/utils.js';
|
|
6
|
+
import { Input } from '../../ui/input.js';
|
|
7
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '../../ui/dialog.js';
|
|
8
|
+
import { Button } from '../../ui/button.js';
|
|
9
|
+
import { ColorSwatchPicker } from './color_swatch_picker.js';
|
|
10
|
+
import { slugify } from '../types.js';
|
|
11
|
+
export function EditModal({ open, mode, item: initial_item, columns, id_field, auto_id_from, id_editable_after_create, existing_ids, item_type_label, render_preview, on_save, on_cancel, }) {
|
|
12
|
+
const [form_data, set_form_data] = useState({});
|
|
13
|
+
const [errors, set_errors] = useState(new Map());
|
|
14
|
+
const [user_touched_id, set_user_touched_id] = useState(false);
|
|
15
|
+
// Reset form when modal opens
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (open) {
|
|
18
|
+
set_form_data({ ...initial_item });
|
|
19
|
+
set_errors(new Map());
|
|
20
|
+
set_user_touched_id(mode === 'edit');
|
|
21
|
+
}
|
|
22
|
+
}, [open, initial_item, mode]);
|
|
23
|
+
const update_field = useCallback((field, value) => {
|
|
24
|
+
set_form_data((prev) => {
|
|
25
|
+
const updated = { ...prev, [field]: value };
|
|
26
|
+
// Auto-generate ID from source field when creating
|
|
27
|
+
if (auto_id_from &&
|
|
28
|
+
field === auto_id_from &&
|
|
29
|
+
!user_touched_id &&
|
|
30
|
+
mode === 'create') {
|
|
31
|
+
updated[id_field] = slugify(String(value ?? ''));
|
|
32
|
+
}
|
|
33
|
+
return updated;
|
|
34
|
+
});
|
|
35
|
+
// Clear error for this field on change
|
|
36
|
+
set_errors((prev) => {
|
|
37
|
+
const next = new Map(prev);
|
|
38
|
+
next.delete(field);
|
|
39
|
+
return next;
|
|
40
|
+
});
|
|
41
|
+
}, [auto_id_from, id_field, mode, user_touched_id]);
|
|
42
|
+
const handle_id_change = useCallback((value) => {
|
|
43
|
+
set_user_touched_id(true);
|
|
44
|
+
update_field(id_field, value);
|
|
45
|
+
}, [id_field, update_field]);
|
|
46
|
+
const validate_form = useCallback(() => {
|
|
47
|
+
const new_errors = new Map();
|
|
48
|
+
for (const col of columns) {
|
|
49
|
+
const value = form_data[col.field];
|
|
50
|
+
// Required check
|
|
51
|
+
if (col.required && (value === undefined || value === null || value === '')) {
|
|
52
|
+
new_errors.set(col.field, `${col.label} is required`);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
// Custom validation
|
|
56
|
+
if (col.validate && value !== undefined && value !== null && value !== '') {
|
|
57
|
+
const error = col.validate(value, form_data);
|
|
58
|
+
if (error) {
|
|
59
|
+
new_errors.set(col.field, error);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Check ID field is filled
|
|
64
|
+
const id_value = form_data[id_field];
|
|
65
|
+
if (!id_value || String(id_value).trim() === '') {
|
|
66
|
+
new_errors.set(id_field, 'ID is required');
|
|
67
|
+
}
|
|
68
|
+
// Check for duplicate ID
|
|
69
|
+
if (id_value) {
|
|
70
|
+
const id_str = String(id_value);
|
|
71
|
+
const is_duplicate = mode === 'create'
|
|
72
|
+
? existing_ids.includes(id_str)
|
|
73
|
+
: existing_ids.filter(id => id !== String(initial_item[id_field])).includes(id_str);
|
|
74
|
+
if (is_duplicate) {
|
|
75
|
+
new_errors.set(id_field, 'This ID already exists');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
set_errors(new_errors);
|
|
79
|
+
return new_errors.size === 0;
|
|
80
|
+
}, [columns, form_data, id_field, existing_ids, mode, initial_item]);
|
|
81
|
+
const handle_save = useCallback(() => {
|
|
82
|
+
if (validate_form()) {
|
|
83
|
+
on_save(form_data);
|
|
84
|
+
}
|
|
85
|
+
}, [validate_form, form_data, on_save]);
|
|
86
|
+
const render_field = (col) => {
|
|
87
|
+
const value = form_data[col.field];
|
|
88
|
+
const error = errors.get(col.field);
|
|
89
|
+
const is_id_field = col.field === id_field;
|
|
90
|
+
const is_disabled = is_id_field && mode === 'edit' && !id_editable_after_create;
|
|
91
|
+
return (_jsxs("div", { className: "cls_edit_field space-y-1.5", children: [_jsxs("label", { className: "text-sm font-medium text-gray-700", children: [col.label, col.required && _jsx("span", { className: "text-red-500 ml-0.5", children: "*" })] }), col.type === 'text' && (_jsx(Input, { type: "text", value: String(value ?? ''), onChange: (e) => is_id_field
|
|
92
|
+
? handle_id_change(e.target.value)
|
|
93
|
+
: update_field(col.field, e.target.value), placeholder: col.placeholder, disabled: is_disabled, className: cn('rounded-lg focus-visible:ring-violet-500/20 focus-visible:ring-offset-0', is_disabled && 'bg-gray-50 text-gray-500', error && 'border-red-400 focus-visible:ring-red-500/20') })), col.type === 'number' && (_jsx(Input, { type: "number", value: value !== undefined && value !== null ? String(value) : '', onChange: (e) => update_field(col.field, e.target.value === '' ? '' : Number(e.target.value)), placeholder: col.placeholder, className: cn('rounded-lg focus-visible:ring-violet-500/20 focus-visible:ring-offset-0', error && 'border-red-400 focus-visible:ring-red-500/20') })), col.type === 'textarea' && (_jsx("textarea", { value: String(value ?? ''), onChange: (e) => update_field(col.field, e.target.value), placeholder: col.placeholder, rows: 3, className: cn('flex w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500/20 focus-visible:border-violet-300 disabled:cursor-not-allowed disabled:opacity-50', error && 'border-red-400 focus-visible:ring-red-500/20') })), col.type === 'select' && col.options && (_jsxs("select", { value: String(value ?? ''), onChange: (e) => update_field(col.field, e.target.value), className: cn('flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500/20 focus-visible:border-violet-300', error && 'border-red-400 focus-visible:ring-red-500/20'), children: [_jsx("option", { value: "", children: col.placeholder || 'Select...' }), col.options.map((opt) => (_jsx("option", { value: opt.value, children: opt.label }, opt.value)))] })), col.type === 'color_swatch' && col.color_options && (_jsx(ColorSwatchPicker, { value: String(value ?? ''), options: col.color_options, on_change: (color) => update_field(col.field, color) })), col.type === 'toggle' && (_jsx("button", { type: "button", role: "switch", "aria-checked": Boolean(value), onClick: () => update_field(col.field, !value), className: cn('relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors', value ? 'bg-violet-600' : 'bg-gray-200'), children: _jsx("span", { className: cn('pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg ring-0 transition-transform', value ? 'translate-x-5' : 'translate-x-0') }) })), is_id_field && auto_id_from && mode === 'create' && !user_touched_id && (_jsxs("p", { className: "text-xs text-gray-400", children: ["Auto-generated from ", columns.find(c => c.field === auto_id_from)?.label?.toLowerCase() || auto_id_from] })), error && (_jsx("p", { className: "text-xs text-red-600", children: error }))] }, col.field));
|
|
94
|
+
};
|
|
95
|
+
return (_jsx(Dialog, { open: open, onOpenChange: (is_open) => { if (!is_open)
|
|
96
|
+
on_cancel(); }, children: _jsxs(DialogContent, { className: "cls_edit_modal max-w-[460px] rounded-2xl p-0 gap-0 overflow-hidden", children: [_jsx(DialogHeader, { className: "px-6 pt-6 pb-4", children: _jsx(DialogTitle, { children: mode === 'create' ? `New ${item_type_label}` : `Edit ${item_type_label}` }) }), _jsxs("div", { className: "px-6 pb-4 space-y-4", children: [render_preview && Object.keys(form_data).length > 0 && (_jsx("div", { className: "p-3 bg-gray-50 rounded-lg border border-gray-100", children: render_preview(form_data) })), columns.map((col) => render_field(col))] }), _jsxs(DialogFooter, { className: "bg-gray-50 px-6 py-4 border-t border-gray-100 sm:justify-between", children: [_jsx(Button, { type: "button", variant: "ghost", onClick: on_cancel, className: "rounded-full", children: "Cancel" }), _jsx(Button, { type: "button", onClick: handle_save, className: "rounded-full bg-violet-600 hover:bg-violet-700 text-white", children: mode === 'create' ? `Add ${item_type_label}` : 'Save Changes' })] })] }) }));
|
|
97
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
interface EmptyStateProps {
|
|
2
|
+
item_label: string;
|
|
3
|
+
on_add: () => void;
|
|
4
|
+
className?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function EmptyState({ item_label, on_add, className, }: EmptyStateProps): import("react/jsx-runtime").JSX.Element;
|
|
7
|
+
export {};
|
|
8
|
+
//# sourceMappingURL=empty_state.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"empty_state.d.ts","sourceRoot":"","sources":["../../../../src/components/app_config_list_editor/components/empty_state.tsx"],"names":[],"mappings":"AAMA,UAAU,eAAe;IACvB,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,wBAAgB,UAAU,CAAC,EACzB,UAAU,EACV,MAAM,EACN,SAAS,GACV,EAAE,eAAe,2CAsBjB"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Empty state component
|
|
3
|
+
// Displayed when the list has no items
|
|
4
|
+
import { Inbox, Plus } from 'lucide-react';
|
|
5
|
+
import { cn } from '../../../lib/utils.js';
|
|
6
|
+
export function EmptyState({ item_label, on_add, className, }) {
|
|
7
|
+
return (_jsxs("div", { className: cn('cls_empty_state flex flex-col items-center justify-center py-12 px-4', className), children: [_jsx("div", { className: "w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center mb-3", children: _jsx(Inbox, { className: "w-6 h-6 text-gray-400" }) }), _jsxs("p", { className: "text-sm font-medium text-gray-900 mb-1", children: ["No ", item_label, " yet"] }), _jsxs("p", { className: "text-sm text-gray-500 mb-4", children: ["Get started by adding your first ", item_label.replace(/s$/, ''), "."] }), _jsxs("button", { type: "button", onClick: on_add, className: "inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-violet-600 hover:bg-violet-700 rounded-full transition-colors", children: [_jsx(Plus, { className: "w-4 h-4" }), "Add your first ", item_label.replace(/s$/, '')] })] }));
|
|
8
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ColumnDef } from '../types.js';
|
|
2
|
+
interface ImportExportButtonsProps<T extends Record<string, unknown>> {
|
|
3
|
+
items: T[];
|
|
4
|
+
on_items_change: (items: T[]) => void;
|
|
5
|
+
columns: ColumnDef<T>[];
|
|
6
|
+
id_field: keyof T & string;
|
|
7
|
+
title?: string;
|
|
8
|
+
max_items?: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function ImportExportButtons<T extends Record<string, unknown>>({ items, on_items_change, columns, id_field, title, max_items, }: ImportExportButtonsProps<T>): import("react/jsx-runtime").JSX.Element;
|
|
11
|
+
export {};
|
|
12
|
+
//# sourceMappingURL=import_export_buttons.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"import_export_buttons.d.ts","sourceRoot":"","sources":["../../../../src/components/app_config_list_editor/components/import_export_buttons.tsx"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAQ5C,UAAU,wBAAwB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAClE,KAAK,EAAE,CAAC,EAAE,CAAA;IACV,eAAe,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,KAAK,IAAI,CAAA;IACrC,OAAO,EAAE,SAAS,CAAC,CAAC,CAAC,EAAE,CAAA;IACvB,QAAQ,EAAE,MAAM,CAAC,GAAG,MAAM,CAAA;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,wBAAgB,mBAAmB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EACrE,KAAK,EACL,eAAe,EACf,OAAO,EACP,QAAQ,EACR,KAAK,EACL,SAAS,GACV,EAAE,wBAAwB,CAAC,CAAC,CAAC,2CA0H7B"}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Import/Export buttons for AppConfigListEditor
|
|
3
|
+
// Provides JSON export (download) and import (file picker) with transient status messages
|
|
4
|
+
import { useRef, useState, useCallback, useEffect } from 'react';
|
|
5
|
+
import { Download, Upload } from 'lucide-react';
|
|
6
|
+
import { cn } from '../../../lib/utils.js';
|
|
7
|
+
import { validate_import_data, merge_items, export_items_to_json, generate_export_filename, } from '../utils/json_import_export.js';
|
|
8
|
+
export function ImportExportButtons({ items, on_items_change, columns, id_field, title, max_items, }) {
|
|
9
|
+
const file_input_ref = useRef(null);
|
|
10
|
+
const [status_message, set_status_message] = useState(null);
|
|
11
|
+
// Auto-fade status message after 3 seconds
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (!status_message)
|
|
14
|
+
return;
|
|
15
|
+
const timer = setTimeout(() => set_status_message(null), 3000);
|
|
16
|
+
return () => clearTimeout(timer);
|
|
17
|
+
}, [status_message]);
|
|
18
|
+
const handle_export = useCallback(() => {
|
|
19
|
+
const filename = generate_export_filename(title);
|
|
20
|
+
export_items_to_json(items, filename);
|
|
21
|
+
}, [items, title]);
|
|
22
|
+
const handle_import_click = useCallback(() => {
|
|
23
|
+
// Reset input so the same file can be re-imported
|
|
24
|
+
if (file_input_ref.current) {
|
|
25
|
+
file_input_ref.current.value = '';
|
|
26
|
+
}
|
|
27
|
+
file_input_ref.current?.click();
|
|
28
|
+
}, []);
|
|
29
|
+
const handle_file_selected = useCallback((event) => {
|
|
30
|
+
const file = event.target.files?.[0];
|
|
31
|
+
if (!file)
|
|
32
|
+
return;
|
|
33
|
+
const reader = new FileReader();
|
|
34
|
+
reader.onload = (e) => {
|
|
35
|
+
const text = e.target?.result;
|
|
36
|
+
if (typeof text !== 'string') {
|
|
37
|
+
set_status_message({ text: 'Failed to read file', is_error: true });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
let data;
|
|
41
|
+
try {
|
|
42
|
+
data = JSON.parse(text);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
set_status_message({ text: 'Invalid JSON file', is_error: true });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const { valid_items, errors } = validate_import_data(data, columns, id_field);
|
|
49
|
+
if (valid_items.length === 0) {
|
|
50
|
+
const msg = errors.length > 0 ? errors[0] : 'No valid items found';
|
|
51
|
+
set_status_message({ text: msg, is_error: true });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const result = merge_items(items, valid_items, id_field, max_items);
|
|
55
|
+
on_items_change(result.merged_items);
|
|
56
|
+
const parts = [];
|
|
57
|
+
if (result.added_count > 0) {
|
|
58
|
+
parts.push(`Added ${result.added_count}`);
|
|
59
|
+
}
|
|
60
|
+
if (result.skipped_count > 0) {
|
|
61
|
+
parts.push(`${result.skipped_count} skipped`);
|
|
62
|
+
}
|
|
63
|
+
if (errors.length > 0) {
|
|
64
|
+
parts.push(`${errors.length} invalid`);
|
|
65
|
+
}
|
|
66
|
+
set_status_message({
|
|
67
|
+
text: parts.join(', '),
|
|
68
|
+
is_error: result.added_count === 0,
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
reader.readAsText(file);
|
|
72
|
+
}, [items, columns, id_field, max_items, on_items_change]);
|
|
73
|
+
return (_jsxs("div", { className: "cls_import_export flex items-center gap-1.5", children: [status_message && (_jsx("span", { className: cn('cls_import_status text-xs transition-opacity duration-300', status_message.is_error ? 'text-red-600' : 'text-green-600'), children: status_message.text })), _jsxs("button", { type: "button", onClick: handle_export, className: "inline-flex items-center gap-1 px-2 py-1.5 text-xs font-medium text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors", title: "Export as JSON", children: [_jsx(Download, { className: "w-3.5 h-3.5" }), "Export"] }), _jsxs("button", { type: "button", onClick: handle_import_click, className: "inline-flex items-center gap-1 px-2 py-1.5 text-xs font-medium text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors", title: "Import from JSON", children: [_jsx(Upload, { className: "w-3.5 h-3.5" }), "Import"] }), _jsx("input", { ref: file_input_ref, type: "file", accept: ".json", onChange: handle_file_selected, className: "hidden" })] }));
|
|
74
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { ColumnDef } from '../types.js';
|
|
3
|
+
interface ListItemRowProps<T extends Record<string, unknown>> {
|
|
4
|
+
item: T;
|
|
5
|
+
index: number;
|
|
6
|
+
columns: ColumnDef<T>[];
|
|
7
|
+
render_item?: (item: T, index: number) => React.ReactNode;
|
|
8
|
+
render_item_indicator?: (item: T) => React.ReactNode;
|
|
9
|
+
on_edit: () => void;
|
|
10
|
+
on_delete: () => void;
|
|
11
|
+
}
|
|
12
|
+
export declare function ListItemRow<T extends Record<string, unknown>>({ item, index, columns, render_item, render_item_indicator, on_edit, on_delete, }: ListItemRowProps<T>): import("react/jsx-runtime").JSX.Element;
|
|
13
|
+
export {};
|
|
14
|
+
//# sourceMappingURL=list_item_row.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"list_item_row.d.ts","sourceRoot":"","sources":["../../../../src/components/app_config_list_editor/components/list_item_row.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,MAAM,OAAO,CAAA;AAGzB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAE5C,UAAU,gBAAgB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAC1D,IAAI,EAAE,CAAC,CAAA;IACP,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,SAAS,CAAC,CAAC,CAAC,EAAE,CAAA;IACvB,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,KAAK,KAAK,CAAC,SAAS,CAAA;IACzD,qBAAqB,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,KAAK,CAAC,SAAS,CAAA;IACpD,OAAO,EAAE,MAAM,IAAI,CAAA;IACnB,SAAS,EAAE,MAAM,IAAI,CAAA;CACtB;AAED,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAC7D,IAAI,EACJ,KAAK,EACL,OAAO,EACP,WAAW,EACX,qBAAqB,EACrB,OAAO,EACP,SAAS,GACV,EAAE,gBAAgB,CAAC,CAAC,CAAC,2CAgGrB"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Pencil, Trash2 } from 'lucide-react';
|
|
3
|
+
import { cn } from '../../../lib/utils.js';
|
|
4
|
+
export function ListItemRow({ item, index, columns, render_item, render_item_indicator, on_edit, on_delete, }) {
|
|
5
|
+
// Custom render overrides everything
|
|
6
|
+
if (render_item) {
|
|
7
|
+
return (_jsxs("div", { className: "cls_list_editor_row flex items-center gap-3 px-4 py-3 hover:bg-gray-50 transition-colors", children: [_jsx("div", { className: "flex-1 min-w-0", children: render_item(item, index) }), _jsxs("div", { className: "flex items-center gap-1 shrink-0", children: [_jsx("button", { type: "button", onClick: on_edit, className: "w-8 h-8 rounded-full flex items-center justify-center text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors", "aria-label": "Edit item", children: _jsx(Pencil, { className: "w-4 h-4" }) }), _jsx("button", { type: "button", onClick: on_delete, className: "w-8 h-8 rounded-full flex items-center justify-center text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors", "aria-label": "Delete item", children: _jsx(Trash2, { className: "w-4 h-4" }) })] })] }));
|
|
8
|
+
}
|
|
9
|
+
// Default rendering based on column definitions
|
|
10
|
+
const primary_cols = columns.filter(c => c.list_display === 'primary');
|
|
11
|
+
const secondary_cols = columns.filter(c => c.list_display === 'secondary');
|
|
12
|
+
const badge_cols = columns.filter(c => c.list_display === 'badge');
|
|
13
|
+
return (_jsxs("div", { className: "cls_list_editor_row flex items-center gap-3 px-4 py-3 hover:bg-gray-50 transition-colors", children: [render_item_indicator && (_jsx("div", { className: "shrink-0", children: render_item_indicator(item) })), _jsxs("div", { className: "flex-1 min-w-0", children: [primary_cols.map((col) => (_jsx("div", { className: "text-sm font-medium text-gray-900 truncate", children: String(item[col.field] ?? '') }, col.field))), secondary_cols.map((col) => (_jsx("div", { className: "text-xs text-gray-500 truncate mt-0.5", children: String(item[col.field] ?? '') }, col.field)))] }), badge_cols.length > 0 && (_jsx("div", { className: "flex items-center gap-2 shrink-0", children: badge_cols.map((col) => (_jsx("span", { className: cn('inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-mono', 'bg-gray-100 text-gray-600'), children: String(item[col.field] ?? '') }, col.field))) })), _jsxs("div", { className: "flex items-center gap-1 shrink-0", children: [_jsx("button", { type: "button", onClick: on_edit, className: "w-8 h-8 rounded-full flex items-center justify-center text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors", "aria-label": "Edit item", children: _jsx(Pencil, { className: "w-4 h-4" }) }), _jsx("button", { type: "button", onClick: on_delete, className: "w-8 h-8 rounded-full flex items-center justify-center text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors", "aria-label": "Delete item", children: _jsx(Trash2, { className: "w-4 h-4" }) })] })] }));
|
|
14
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
interface SaveStatusIndicatorProps {
|
|
2
|
+
status: 'idle' | 'saving' | 'saved' | 'error';
|
|
3
|
+
className?: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function SaveStatusIndicator({ status, className, }: SaveStatusIndicatorProps): import("react/jsx-runtime").JSX.Element | null;
|
|
6
|
+
export {};
|
|
7
|
+
//# sourceMappingURL=save_status_indicator.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"save_status_indicator.d.ts","sourceRoot":"","sources":["../../../../src/components/app_config_list_editor/components/save_status_indicator.tsx"],"names":[],"mappings":"AAOA,UAAU,wBAAwB;IAChC,MAAM,EAAE,MAAM,GAAG,QAAQ,GAAG,OAAO,GAAG,OAAO,CAAA;IAC7C,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,wBAAgB,mBAAmB,CAAC,EAClC,MAAM,EACN,SAAS,GACV,EAAE,wBAAwB,kDA+C1B"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Save status indicator component
|
|
3
|
+
// Shows saving/saved/error states inline
|
|
4
|
+
import { useEffect, useState } from 'react';
|
|
5
|
+
import { Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
|
|
6
|
+
import { cn } from '../../../lib/utils.js';
|
|
7
|
+
export function SaveStatusIndicator({ status, className, }) {
|
|
8
|
+
const [visible, set_visible] = useState(false);
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (status === 'saved') {
|
|
11
|
+
set_visible(true);
|
|
12
|
+
const timer = setTimeout(() => set_visible(false), 2000);
|
|
13
|
+
return () => clearTimeout(timer);
|
|
14
|
+
}
|
|
15
|
+
if (status === 'saving' || status === 'error') {
|
|
16
|
+
set_visible(true);
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
set_visible(false);
|
|
20
|
+
}
|
|
21
|
+
}, [status]);
|
|
22
|
+
if (!visible && status === 'idle')
|
|
23
|
+
return null;
|
|
24
|
+
return (_jsxs("div", { className: cn('cls_save_status flex items-center gap-1.5 text-xs transition-opacity duration-300', !visible && 'opacity-0', visible && 'opacity-100', className), children: [status === 'saving' && (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "w-3.5 h-3.5 text-gray-500 animate-spin" }), _jsx("span", { className: "text-gray-500", children: "Saving..." })] })), status === 'saved' && (_jsxs(_Fragment, { children: [_jsx(CheckCircle2, { className: "w-3.5 h-3.5 text-green-600" }), _jsx("span", { className: "text-green-600", children: "Saved" })] })), status === 'error' && (_jsxs(_Fragment, { children: [_jsx(AlertCircle, { className: "w-3.5 h-3.5 text-red-600" }), _jsx("span", { className: "text-red-600", children: "Error saving" })] }))] }));
|
|
25
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
interface SearchBarProps {
|
|
2
|
+
value: string;
|
|
3
|
+
on_change: (value: string) => void;
|
|
4
|
+
item_count: number;
|
|
5
|
+
item_label: string;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function SearchBar({ value, on_change, item_count, item_label, className, }: SearchBarProps): import("react/jsx-runtime").JSX.Element;
|
|
9
|
+
export {};
|
|
10
|
+
//# sourceMappingURL=search_bar.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"search_bar.d.ts","sourceRoot":"","sources":["../../../../src/components/app_config_list_editor/components/search_bar.tsx"],"names":[],"mappings":"AAMA,UAAU,cAAc;IACtB,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IAClC,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,wBAAgB,SAAS,CAAC,EACxB,KAAK,EACL,SAAS,EACT,UAAU,EACV,UAAU,EACV,SAAS,GACV,EAAE,cAAc,2CA6BhB"}
|