hazo_config 2.0.2 → 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 CHANGED
@@ -270,6 +270,18 @@ The `AppConfigListEditor` is a generic, reusable CRUD list editor for arrays of
270
270
  - Save status indicators (saving/saved/error)
271
271
  - Custom item rendering (indicator, preview, full row override)
272
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
+ ```
273
285
 
274
286
  ## Development
275
287
 
@@ -1 +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"}
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"}
@@ -10,6 +10,7 @@ import { SearchBar } from './components/search_bar.js';
10
10
  import { EmptyState } from './components/empty_state.js';
11
11
  import { DeleteDialog } from './components/delete_dialog.js';
12
12
  import { SaveStatusIndicator } from './components/save_status_indicator.js';
13
+ import { ImportExportButtons } from './components/import_export_buttons.js';
13
14
  /**
14
15
  * AppConfigListEditor - A polished CRUD list editor for config item arrays.
15
16
  * Purely callback-based: no API calls, no database access.
@@ -122,7 +123,7 @@ export function AppConfigListEditor({ items, on_items_change, columns, id_field,
122
123
  }
123
124
  return String(delete_state.item[id_field] ?? '');
124
125
  }, [delete_state, columns, id_field]);
125
- 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("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
126
- ? 'bg-gray-100 text-gray-400 cursor-not-allowed'
127
- : '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 }))] }));
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 }))] }));
128
129
  }
@@ -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
+ }
@@ -1,3 +1,4 @@
1
1
  export { AppConfigListEditor } from './app_config_list_editor.js';
2
- export type { AppConfigListEditorProps, ColumnDef } from './types.js';
2
+ export type { AppConfigListEditorProps, ColumnDef, ImportResult } from './types.js';
3
+ export { validate_import_data, merge_items, export_items_to_json, generate_export_filename, } from './utils/json_import_export.js';
3
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/app_config_list_editor/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAA;AACjE,YAAY,EAAE,wBAAwB,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/app_config_list_editor/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAA;AACjE,YAAY,EAAE,wBAAwB,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AACnF,OAAO,EACL,oBAAoB,EACpB,WAAW,EACX,oBAAoB,EACpB,wBAAwB,GACzB,MAAM,+BAA+B,CAAA"}
@@ -1,2 +1,3 @@
1
1
  // Public exports for AppConfigListEditor
2
2
  export { AppConfigListEditor } from './app_config_list_editor.js';
3
+ export { validate_import_data, merge_items, export_items_to_json, generate_export_filename, } from './utils/json_import_export.js';
@@ -84,6 +84,15 @@ export interface EditModalState<T> {
84
84
  export interface DeleteDialogState<T> {
85
85
  item: T;
86
86
  }
87
+ /**
88
+ * Result of a JSON import merge operation
89
+ */
90
+ export interface ImportResult<T> {
91
+ merged_items: T[];
92
+ added_count: number;
93
+ skipped_count: number;
94
+ errors: string[];
95
+ }
87
96
  /**
88
97
  * Slugify a string for auto-ID generation.
89
98
  * Converts to lowercase, replaces non-alphanumeric chars with underscores,
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/components/app_config_list_editor/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/components/app_config_list_editor/types.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,26 @@
1
+ import type { ColumnDef, ImportResult } from '../types.js';
2
+ export type { ColumnDef, ImportResult };
3
+ /**
4
+ * Validate parsed JSON data against column definitions.
5
+ * Returns valid items and a list of error messages.
6
+ */
7
+ export declare function validate_import_data<T extends Record<string, unknown>>(data: unknown, columns: ColumnDef<T>[], id_field: keyof T & string): {
8
+ valid_items: T[];
9
+ errors: string[];
10
+ };
11
+ /**
12
+ * Merge incoming items with existing items.
13
+ * Skips items whose ID already exists in the existing list.
14
+ * Respects max_items cap.
15
+ */
16
+ export declare function merge_items<T extends Record<string, unknown>>(existing: T[], incoming: T[], id_field: keyof T & string, max_items?: number): ImportResult<T>;
17
+ /**
18
+ * Trigger a browser download of items as a JSON file.
19
+ */
20
+ export declare function export_items_to_json<T>(items: T[], filename: string): void;
21
+ /**
22
+ * Generate a snake_case filename from a title string.
23
+ * e.g. "Classification Tags" -> "classification_tags.json"
24
+ */
25
+ export declare function generate_export_filename(title?: string): string;
26
+ //# sourceMappingURL=json_import_export.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"json_import_export.d.ts","sourceRoot":"","sources":["../../../../src/components/app_config_list_editor/utils/json_import_export.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAE1D,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,CAAA;AAEvC;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACpE,IAAI,EAAE,OAAO,EACb,OAAO,EAAE,SAAS,CAAC,CAAC,CAAC,EAAE,EACvB,QAAQ,EAAE,MAAM,CAAC,GAAG,MAAM,GACzB;IAAE,WAAW,EAAE,CAAC,EAAE,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAA;CAAE,CAwDxC;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3D,QAAQ,EAAE,CAAC,EAAE,EACb,QAAQ,EAAE,CAAC,EAAE,EACb,QAAQ,EAAE,MAAM,CAAC,GAAG,MAAM,EAC1B,SAAS,CAAC,EAAE,MAAM,GACjB,YAAY,CAAC,CAAC,CAAC,CA0BjB;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAW1E;AAED;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAQ/D"}
@@ -0,0 +1,112 @@
1
+ // JSON import/export utilities for AppConfigListEditor
2
+ // Pure functions for validating, merging, and exporting config item arrays
3
+ /**
4
+ * Validate parsed JSON data against column definitions.
5
+ * Returns valid items and a list of error messages.
6
+ */
7
+ export function validate_import_data(data, columns, id_field) {
8
+ const errors = [];
9
+ const valid_items = [];
10
+ if (!Array.isArray(data)) {
11
+ return { valid_items: [], errors: ['Expected a JSON array'] };
12
+ }
13
+ if (data.length === 0) {
14
+ return { valid_items: [], errors: ['JSON array is empty'] };
15
+ }
16
+ for (let i = 0; i < data.length; i++) {
17
+ const item = data[i];
18
+ const item_errors = [];
19
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) {
20
+ errors.push(`Item ${i + 1}: not a valid object`);
21
+ continue;
22
+ }
23
+ const record = item;
24
+ // Check id_field exists and is non-empty
25
+ if (!(id_field in record) || record[id_field] === '' || record[id_field] === null || record[id_field] === undefined) {
26
+ item_errors.push(`missing required field "${id_field}"`);
27
+ }
28
+ // Check required fields and basic type compatibility
29
+ for (const col of columns) {
30
+ const val = record[col.field];
31
+ if (col.required && (val === undefined || val === null || val === '')) {
32
+ item_errors.push(`missing required field "${col.field}"`);
33
+ continue;
34
+ }
35
+ if (val === undefined || val === null)
36
+ continue;
37
+ // Basic type checks
38
+ if (col.type === 'number' && typeof val !== 'number') {
39
+ item_errors.push(`"${col.field}" should be a number`);
40
+ }
41
+ if (col.type === 'toggle' && typeof val !== 'boolean') {
42
+ item_errors.push(`"${col.field}" should be a boolean`);
43
+ }
44
+ }
45
+ if (item_errors.length > 0) {
46
+ errors.push(`Item ${i + 1}: ${item_errors.join(', ')}`);
47
+ }
48
+ else {
49
+ valid_items.push(record);
50
+ }
51
+ }
52
+ return { valid_items, errors };
53
+ }
54
+ /**
55
+ * Merge incoming items with existing items.
56
+ * Skips items whose ID already exists in the existing list.
57
+ * Respects max_items cap.
58
+ */
59
+ export function merge_items(existing, incoming, id_field, max_items) {
60
+ const existing_ids = new Set(existing.map((item) => String(item[id_field])));
61
+ let skipped_count = 0;
62
+ const to_add = [];
63
+ for (const item of incoming) {
64
+ const id = String(item[id_field]);
65
+ if (existing_ids.has(id)) {
66
+ skipped_count++;
67
+ continue;
68
+ }
69
+ // Respect max_items cap
70
+ if (max_items !== undefined && max_items !== null && existing.length + to_add.length >= max_items) {
71
+ skipped_count++;
72
+ continue;
73
+ }
74
+ to_add.push(item);
75
+ existing_ids.add(id);
76
+ }
77
+ return {
78
+ merged_items: [...existing, ...to_add],
79
+ added_count: to_add.length,
80
+ skipped_count,
81
+ errors: [],
82
+ };
83
+ }
84
+ /**
85
+ * Trigger a browser download of items as a JSON file.
86
+ */
87
+ export function export_items_to_json(items, filename) {
88
+ const json = JSON.stringify(items, null, 2);
89
+ const blob = new Blob([json], { type: 'application/json' });
90
+ const url = URL.createObjectURL(blob);
91
+ const a = document.createElement('a');
92
+ a.href = url;
93
+ a.download = filename;
94
+ document.body.appendChild(a);
95
+ a.click();
96
+ document.body.removeChild(a);
97
+ URL.revokeObjectURL(url);
98
+ }
99
+ /**
100
+ * Generate a snake_case filename from a title string.
101
+ * e.g. "Classification Tags" -> "classification_tags.json"
102
+ */
103
+ export function generate_export_filename(title) {
104
+ if (!title)
105
+ return 'config_items.json';
106
+ const slug = title
107
+ .toLowerCase()
108
+ .replace(/[^a-z0-9]+/g, '_')
109
+ .replace(/^_+|_+$/g, '')
110
+ .replace(/_+/g, '_');
111
+ return `${slug}.json`;
112
+ }
@@ -9,5 +9,6 @@ export type { UseConfigSectionsResult } from './use_config_sections.js';
9
9
  export { useAppConfig } from './use_app_config.js';
10
10
  export type { UseAppConfigResult } from '../lib/app_config_types.js';
11
11
  export { AppConfigListEditor } from './app_config_list_editor/index.js';
12
- export type { AppConfigListEditorProps, ColumnDef } from './app_config_list_editor/index.js';
12
+ export type { AppConfigListEditorProps, ColumnDef, ImportResult } from './app_config_list_editor/index.js';
13
+ export { validate_import_data, merge_items, export_items_to_json, generate_export_filename, } from './app_config_list_editor/index.js';
13
14
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AACjD,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AACjD,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAC3C,YAAY,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AAC3D,YAAY,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AAC3D,YAAY,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAA;AAGhE,OAAO,EACL,iBAAiB,EACjB,kBAAkB,EAClB,UAAU,EACV,0BAA0B,EAC3B,MAAM,0BAA0B,CAAA;AACjC,YAAY,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAA;AAEvE,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAA;AAClD,YAAY,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAA;AAGpE,OAAO,EAAE,mBAAmB,EAAE,MAAM,mCAAmC,CAAA;AACvE,YAAY,EAAE,wBAAwB,EAAE,SAAS,EAAE,MAAM,mCAAmC,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AACjD,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AACjD,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAC3C,YAAY,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AAC3D,YAAY,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AAC3D,YAAY,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAA;AAGhE,OAAO,EACL,iBAAiB,EACjB,kBAAkB,EAClB,UAAU,EACV,0BAA0B,EAC3B,MAAM,0BAA0B,CAAA;AACjC,YAAY,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAA;AAEvE,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAA;AAClD,YAAY,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAA;AAGpE,OAAO,EAAE,mBAAmB,EAAE,MAAM,mCAAmC,CAAA;AACvE,YAAY,EAAE,wBAAwB,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,mCAAmC,CAAA;AAC1G,OAAO,EACL,oBAAoB,EACpB,WAAW,EACX,oBAAoB,EACpB,wBAAwB,GACzB,MAAM,mCAAmC,CAAA"}
@@ -8,3 +8,4 @@ export { useConfigSections, is_sensitive_field, mask_value, DEFAULT_SENSITIVE_PA
8
8
  export { useAppConfig } from './use_app_config.js';
9
9
  // AppConfigListEditor exports
10
10
  export { AppConfigListEditor } from './app_config_list_editor/index.js';
11
+ export { validate_import_data, merge_items, export_items_to_json, generate_export_filename, } from './app_config_list_editor/index.js';
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "hazo_config",
3
- "version": "2.0.2",
3
+ "version": "2.1.0",
4
4
  "description": "Config wrapper with error handling",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
8
+ "files": [
9
+ "dist"
10
+ ],
8
11
  "exports": {
9
12
  ".": {
10
13
  "types": "./dist/index.d.ts",
package/MIGRATION_V2.md DELETED
@@ -1,531 +0,0 @@
1
- # Migration Guide: hazo_config v1 → v2
2
-
3
- ## Breaking Changes Summary
4
-
5
- Version 2.0.0 introduces a major schema change to support JSON configuration values and replace `org_id` with `scope_id`. This is a **BREAKING CHANGE** requiring code updates and database migration.
6
-
7
- ## What Changed
8
-
9
- ### 1. Database Schema
10
-
11
- **Old Schema:**
12
- ```sql
13
- CREATE TABLE hazo_app_config (
14
- id TEXT PRIMARY KEY,
15
- org_id TEXT,
16
- user_id TEXT,
17
- config_section TEXT NOT NULL,
18
- config_name TEXT NOT NULL,
19
- config_value TEXT NOT NULL,
20
- created_at TEXT,
21
- changed_at TEXT
22
- )
23
- ```
24
-
25
- **New Schema:**
26
- ```sql
27
- CREATE TABLE hazo_app_config (
28
- id TEXT PRIMARY KEY,
29
- scope_id TEXT, -- ← CHANGED: org_id → scope_id
30
- user_id TEXT,
31
- config_section TEXT NOT NULL,
32
- config_name TEXT NOT NULL,
33
- config_value_text TEXT, -- ← NEW: for general type
34
- config_value_json TEXT, -- ← NEW: for json type (JSONB in PostgreSQL)
35
- config_type TEXT NOT NULL, -- ← NEW: 'general' or 'json'
36
- created_at TEXT,
37
- changed_at TEXT,
38
- CHECK (
39
- (config_type = 'general' AND config_value_text IS NOT NULL AND config_value_json IS NULL) OR
40
- (config_type = 'json' AND config_value_json IS NOT NULL AND config_value_text IS NULL)
41
- )
42
- )
43
- ```
44
-
45
- ### 2. Type Definitions
46
-
47
- **Removed:**
48
- - `ConfigLevel` type ('org' | 'user')
49
-
50
- **Added:**
51
- - `ConfigType` type ('json' | 'general')
52
-
53
- **Changed Interface:**
54
- ```typescript
55
- // OLD
56
- interface AppConfigItem {
57
- org_id: string | null
58
- config_value: string
59
- }
60
-
61
- // NEW
62
- interface AppConfigItem {
63
- scope_id: string | null // ← org_id renamed
64
- config_value_text: string // ← new field
65
- config_value_json: object // ← new field
66
- config_type: ConfigType // ← new field
67
- }
68
-
69
- // OLD
70
- interface AppConfigContext {
71
- org_id: string
72
- user_id?: string
73
- }
74
-
75
- // NEW
76
- interface AppConfigContext {
77
- scope_id: string // ← org_id renamed
78
- user_id?: string
79
- }
80
- ```
81
-
82
- ### 3. Component API Changes
83
-
84
- **AppConfig Component:**
85
- ```typescript
86
- // OLD
87
- <AppConfig
88
- level="org" // ← REMOVED
89
- context={{ org_id: '...' }}
90
- fetch_config={fetch_config}
91
- save_config={save_config}
92
- delete_config={delete_config}
93
- />
94
-
95
- // NEW
96
- <AppConfig
97
- title="Scope Settings" // ← Now required (no default from level)
98
- context={{ scope_id: '...' }}
99
- fetch_config={fetch_config}
100
- save_config={save_config}
101
- delete_config={delete_config}
102
- />
103
- ```
104
-
105
- **Hook Signature:**
106
- ```typescript
107
- // OLD
108
- useAppConfig(
109
- level: ConfigLevel,
110
- context: AppConfigContext,
111
- fetch_config: (level, context) => Promise<AppConfigItem[]>,
112
- save_config: (item) => Promise<void>,
113
- delete_config: (section, name, level, context) => Promise<void>
114
- )
115
-
116
- // NEW
117
- useAppConfig(
118
- context: AppConfigContext,
119
- fetch_config: (context) => Promise<AppConfigItem[]>,
120
- save_config: (item) => Promise<void>,
121
- delete_config: (section, name, context) => Promise<void>
122
- )
123
- ```
124
-
125
- **Hook Return Value:**
126
- ```typescript
127
- // OLD
128
- sections: Record<string, Record<string, string>>
129
- set_value: (section, key, value: string) => Promise<void>
130
-
131
- // NEW
132
- sections: Record<string, Record<string, AppConfigItem>> // Full items
133
- set_value: (section, key, value: string | object, type: ConfigType) => Promise<void>
134
- change_type: (section, key, new_type: ConfigType) => Promise<void> // NEW
135
- ```
136
-
137
- ### 4. Server Action Changes
138
-
139
- **Fetch Config:**
140
- ```typescript
141
- // OLD
142
- async function fetch_config(
143
- level: ConfigLevel,
144
- context: AppConfigContext
145
- ): Promise<AppConfigItem[]>
146
-
147
- // NEW
148
- async function fetch_config(
149
- context: AppConfigContext
150
- ): Promise<AppConfigItem[]>
151
- ```
152
-
153
- **Save Config:**
154
- ```typescript
155
- // OLD
156
- const item = {
157
- org_id: context.org_id,
158
- user_id: level === 'user' ? context.user_id : null,
159
- config_value: value
160
- }
161
-
162
- // NEW
163
- const item = {
164
- scope_id: context.scope_id,
165
- user_id: context.user_id ?? null,
166
- config_value_text: type === 'general' ? value : '',
167
- config_value_json: type === 'json' ? value : {},
168
- config_type: type
169
- }
170
- ```
171
-
172
- **Delete Config:**
173
- ```typescript
174
- // OLD
175
- async function delete_config(
176
- section: string,
177
- name: string,
178
- level: ConfigLevel,
179
- context: AppConfigContext
180
- )
181
-
182
- // NEW
183
- async function delete_config(
184
- section: string,
185
- name: string,
186
- context: AppConfigContext
187
- )
188
- ```
189
-
190
- ## Migration Steps
191
-
192
- ### 1. Database Migration
193
-
194
- **PostgreSQL:**
195
- ```sql
196
- -- Add new columns
197
- ALTER TABLE hazo_app_config
198
- ADD COLUMN scope_id TEXT,
199
- ADD COLUMN config_value_text TEXT,
200
- ADD COLUMN config_value_json JSONB,
201
- ADD COLUMN config_type TEXT;
202
-
203
- -- Migrate existing data
204
- UPDATE hazo_app_config SET
205
- scope_id = org_id,
206
- config_value_text = config_value,
207
- config_value_json = '{}'::jsonb,
208
- config_type = 'general';
209
-
210
- -- Drop old columns
211
- ALTER TABLE hazo_app_config
212
- DROP COLUMN org_id,
213
- DROP COLUMN config_value;
214
-
215
- -- Add constraints
216
- ALTER TABLE hazo_app_config
217
- ALTER COLUMN config_type SET NOT NULL,
218
- ADD CONSTRAINT check_type_values CHECK (config_type IN ('json', 'general')),
219
- ADD CONSTRAINT check_value_exclusivity CHECK (
220
- (config_type = 'general' AND config_value_text IS NOT NULL AND config_value_json IS NULL) OR
221
- (config_type = 'json' AND config_value_json IS NOT NULL AND config_value_text IS NULL)
222
- );
223
-
224
- -- Update indexes
225
- DROP INDEX IF EXISTS idx_hazo_app_config_org;
226
- DROP INDEX IF EXISTS idx_hazo_app_config_unique;
227
-
228
- CREATE INDEX idx_hazo_app_config_scope ON hazo_app_config(scope_id);
229
- CREATE UNIQUE INDEX idx_hazo_app_config_unique ON hazo_app_config(scope_id, user_id, config_section, config_name);
230
- ```
231
-
232
- **SQLite:**
233
- ```sql
234
- -- SQLite doesn't support dropping columns, so recreate the table
235
- CREATE TABLE hazo_app_config_new (
236
- id TEXT PRIMARY KEY,
237
- scope_id TEXT,
238
- user_id TEXT,
239
- config_section TEXT NOT NULL,
240
- config_name TEXT NOT NULL,
241
- config_value_text TEXT,
242
- config_value_json TEXT,
243
- config_type TEXT NOT NULL CHECK (config_type IN ('json', 'general')),
244
- created_at TEXT DEFAULT (datetime('now')),
245
- changed_at TEXT,
246
- CHECK (
247
- (config_type = 'general' AND config_value_text IS NOT NULL AND config_value_json IS NULL) OR
248
- (config_type = 'json' AND config_value_json IS NOT NULL AND config_value_text IS NULL)
249
- )
250
- );
251
-
252
- -- Migrate data
253
- INSERT INTO hazo_app_config_new (id, scope_id, user_id, config_section, config_name, config_value_text, config_value_json, config_type, created_at, changed_at)
254
- SELECT id, org_id, user_id, config_section, config_name, config_value, NULL, 'general', created_at, changed_at
255
- FROM hazo_app_config;
256
-
257
- -- Replace old table
258
- DROP TABLE hazo_app_config;
259
- ALTER TABLE hazo_app_config_new RENAME TO hazo_app_config;
260
-
261
- -- Recreate indexes
262
- CREATE INDEX idx_hazo_app_config_scope ON hazo_app_config(scope_id);
263
- CREATE INDEX idx_hazo_app_config_user ON hazo_app_config(user_id);
264
- CREATE UNIQUE INDEX idx_hazo_app_config_unique ON hazo_app_config(scope_id, user_id, config_section, config_name);
265
- ```
266
-
267
- ### 2. Code Migration
268
-
269
- **Update Component Usage:**
270
- ```typescript
271
- // OLD
272
- import { AppConfig } from 'hazo_config/components'
273
-
274
- <AppConfig
275
- level="org"
276
- context={{ org_id: DEMO_ORG_ID }}
277
- fetch_config={fetch_config}
278
- save_config={save_config}
279
- delete_config={delete_config}
280
- />
281
-
282
- // NEW
283
- import { AppConfig } from 'hazo_config/components'
284
-
285
- <AppConfig
286
- title="Scope Settings"
287
- context={{ scope_id: DEMO_SCOPE_ID }}
288
- fetch_config={fetch_config}
289
- save_config={save_config}
290
- delete_config={delete_config}
291
- />
292
- ```
293
-
294
- **Update Server Actions:**
295
- ```typescript
296
- // OLD
297
- export async function fetch_config(
298
- level: ConfigLevel,
299
- context: AppConfigContext
300
- ): Promise<AppConfigItem[]> {
301
- const query = new QueryBuilder()
302
- .from('hazo_app_config')
303
- .where('org_id', 'eq', context.org_id)
304
-
305
- if (level === 'org') {
306
- query.where('user_id', 'is', null)
307
- } else {
308
- query.where('user_id', 'eq', context.user_id)
309
- }
310
-
311
- return await hazo.query(query)
312
- }
313
-
314
- // NEW
315
- export async function fetch_config(
316
- context: AppConfigContext
317
- ): Promise<AppConfigItem[]> {
318
- const query = new QueryBuilder()
319
- .from('hazo_app_config')
320
- .where('scope_id', 'eq', context.scope_id)
321
-
322
- if (context.user_id) {
323
- query.where('user_id', 'eq', context.user_id)
324
- } else {
325
- query.where('user_id', 'is', null)
326
- }
327
-
328
- const result = await hazo.query(query)
329
-
330
- // Parse JSON for SQLite (stored as TEXT)
331
- return result.map(item => ({
332
- ...item,
333
- config_value_json: item.config_value_json
334
- ? JSON.parse(item.config_value_json)
335
- : {}
336
- }))
337
- }
338
-
339
- export async function save_config(
340
- item: Omit<AppConfigItem, 'id' | 'created_at' | 'changed_at'>
341
- ): Promise<void> {
342
- // Validate mutual exclusivity
343
- if (item.config_type === 'general' && !item.config_value_text) {
344
- throw new Error('General type requires config_value_text')
345
- }
346
- if (item.config_type === 'json' && !item.config_value_json) {
347
- throw new Error('JSON type requires config_value_json')
348
- }
349
-
350
- // For SQLite: stringify JSON
351
- const db_data = {
352
- config_value_text: item.config_type === 'general' ? item.config_value_text : null,
353
- config_value_json: item.config_type === 'json'
354
- ? JSON.stringify(item.config_value_json)
355
- : null,
356
- config_type: item.config_type,
357
- }
358
-
359
- // ... rest of save logic
360
- }
361
-
362
- export async function delete_config(
363
- section: string,
364
- name: string,
365
- context: AppConfigContext
366
- ): Promise<void> {
367
- const query = new QueryBuilder()
368
- .from('hazo_app_config')
369
- .where('scope_id', 'eq', context.scope_id)
370
- .where('config_section', 'eq', section)
371
- .where('config_name', 'eq', name)
372
-
373
- if (context.user_id) {
374
- query.where('user_id', 'eq', context.user_id)
375
- } else {
376
- query.where('user_id', 'is', null)
377
- }
378
-
379
- await hazo.query(query, 'DELETE')
380
- }
381
- ```
382
-
383
- **Update Hook Usage:**
384
- ```typescript
385
- // OLD
386
- const {
387
- sections,
388
- set_value,
389
- delete_value
390
- } = useAppConfig('org', context, fetch_config, save_config, delete_config)
391
-
392
- // Access value
393
- const value = sections.general.company_name // string
394
-
395
- // Set value
396
- await set_value('general', 'company_name', 'New Name')
397
-
398
- // NEW
399
- const {
400
- sections,
401
- set_value,
402
- delete_value,
403
- change_type
404
- } = useAppConfig(context, fetch_config, save_config, delete_config)
405
-
406
- // Access value
407
- const item = sections.general.company_name // AppConfigItem
408
- const value = item.config_type === 'json'
409
- ? item.config_value_json
410
- : item.config_value_text
411
-
412
- // Set general value
413
- await set_value('general', 'company_name', 'New Name', 'general')
414
-
415
- // Set JSON value
416
- await set_value('features', 'flags', { beta: true }, 'json')
417
-
418
- // Change type
419
- await change_type('general', 'company_name', 'json')
420
- ```
421
-
422
- ### 3. Import Updates
423
-
424
- **Type Imports:**
425
- ```typescript
426
- // OLD
427
- import type { ConfigLevel, AppConfigItem } from 'hazo_config'
428
-
429
- // NEW
430
- import type { ConfigType, AppConfigItem } from 'hazo_config'
431
- ```
432
-
433
- ## New Features in v2
434
-
435
- ### 1. JSON Configuration Values
436
-
437
- You can now store structured configuration as JSON:
438
-
439
- ```typescript
440
- await set_value('features', 'enabled_features', {
441
- analytics: true,
442
- notifications: false,
443
- api_access: true
444
- }, 'json')
445
-
446
- await set_value('branding', 'theme_config', {
447
- dark_mode: true,
448
- accent_color: '#3B82F6',
449
- font: 'Inter'
450
- }, 'json')
451
- ```
452
-
453
- ### 2. Type Conversion
454
-
455
- Convert between general and JSON types:
456
-
457
- ```typescript
458
- // Convert text to JSON
459
- await change_type('general', 'settings', 'json')
460
- // Will try JSON.parse(), fallback to { value: text }
461
-
462
- // Convert JSON to text
463
- await change_type('features', 'flags', 'general')
464
- // Will JSON.stringify() the object
465
- ```
466
-
467
- ### 3. UI Enhancements
468
-
469
- - Type badge showing 'json' or 'general'
470
- - JSON editor with syntax validation
471
- - Type change button with confirmation
472
- - Type selector when adding new keys
473
-
474
- ## Testing Migration
475
-
476
- Before migrating production:
477
-
478
- 1. **Backup your database**
479
- 2. **Test migration on a copy:**
480
- ```bash
481
- # PostgreSQL
482
- pg_dump production_db > backup.sql
483
- createdb test_db
484
- psql test_db < backup.sql
485
- # Run migration scripts on test_db
486
- ```
487
-
488
- 3. **Update and test your application:**
489
- ```bash
490
- npm install hazo_config@^2.0.0
491
- npm run build
492
- npm test
493
- ```
494
-
495
- 4. **Verify data integrity:**
496
- - All `config_value` migrated to `config_value_text`
497
- - All items have `config_type = 'general'`
498
- - All items have `scope_id` matching old `org_id`
499
- - Unique constraints still enforced
500
-
501
- ## Rollback Plan
502
-
503
- If you need to rollback:
504
-
505
- ```sql
506
- -- PostgreSQL
507
- ALTER TABLE hazo_app_config
508
- ADD COLUMN org_id TEXT,
509
- ADD COLUMN config_value TEXT;
510
-
511
- UPDATE hazo_app_config SET
512
- org_id = scope_id,
513
- config_value = COALESCE(config_value_text, config_value_json::text);
514
-
515
- ALTER TABLE hazo_app_config
516
- DROP COLUMN scope_id,
517
- DROP COLUMN config_value_text,
518
- DROP COLUMN config_value_json,
519
- DROP COLUMN config_type;
520
- ```
521
-
522
- ## Support
523
-
524
- For migration assistance:
525
- - File an issue: https://github.com/pub12/hazo_config/issues
526
- - Email: support@example.com
527
-
528
- ## Version History
529
-
530
- - **v2.0.0**: Schema migration (scope_id, JSON support)
531
- - **v1.4.2**: Last version before breaking changes