@airoom/nextmin-react 1.4.4 → 1.4.5
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/dist/components/RefMultiSelect.js +5 -1
- package/dist/components/RefSingleSelect.js +67 -9
- package/dist/components/SchemaForm.js +97 -2
- package/dist/components/editor/components/DistrictGridModal.js +1 -1
- package/dist/components/editor/components/SchemaInsertionModal.js +1 -1
- package/dist/lib/types.d.ts +1 -0
- package/package.json +1 -1
|
@@ -54,6 +54,8 @@ export const RefMultiSelect = ({ name, label, refModel, showKey = 'name', value,
|
|
|
54
54
|
try {
|
|
55
55
|
const params = {
|
|
56
56
|
limit: pageSize,
|
|
57
|
+
sort: showKey,
|
|
58
|
+
sortType: 'asc',
|
|
57
59
|
...(q ? { q, searchKey: showKey } : {}),
|
|
58
60
|
};
|
|
59
61
|
const res = await api.list?.(modelSlug, 0, pageSize, params);
|
|
@@ -91,10 +93,12 @@ export const RefMultiSelect = ({ name, label, refModel, showKey = 'name', value,
|
|
|
91
93
|
if (keys === 'all')
|
|
92
94
|
return;
|
|
93
95
|
onChange(Array.from(keys).map(String));
|
|
94
|
-
}, items: options, isVirtualized: true, isMultiline: true, isInvalid: !!error, errorMessage: errorMessage || '', variant: "bordered", maxListboxHeight: 288, itemHeight: 36, className: "w-full", placeholder: `Select ${refModel}`, onOpenChange: setOpen, isLoading: loading, description: description || null, listboxProps: {
|
|
96
|
+
}, isClearable: true, items: options, isVirtualized: true, isMultiline: true, isInvalid: !!error, errorMessage: errorMessage || '', variant: "bordered", maxListboxHeight: 288, itemHeight: 36, className: "w-full", placeholder: `Select ${refModel}`, onOpenChange: setOpen, isLoading: loading, description: description || null, listboxProps: {
|
|
95
97
|
topContent: topSearch,
|
|
96
98
|
emptyContent: loading ? undefined : 'No results',
|
|
97
99
|
className: '',
|
|
100
|
+
}, scrollShadowProps: {
|
|
101
|
+
isEnabled: false,
|
|
98
102
|
},
|
|
99
103
|
// chips like docs; items is an array (SelectedItems<T>)
|
|
100
104
|
renderValue: (items) => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import * as React from 'react';
|
|
4
|
-
import { Select, SelectItem } from '@heroui/react';
|
|
4
|
+
import { Select, SelectItem, Input } from '@heroui/react';
|
|
5
5
|
import { api } from '../lib/api';
|
|
6
6
|
function stripPointerNone(cls) {
|
|
7
7
|
return (cls ?? '').replace(/\bpointer-events-none\b/g, '').trim();
|
|
@@ -10,25 +10,75 @@ export function RefSingleSelect({ name, label, refModel, showKey = 'name', descr
|
|
|
10
10
|
const [loading, setLoading] = React.useState(false);
|
|
11
11
|
const [items, setItems] = React.useState([]);
|
|
12
12
|
const [error, setError] = React.useState(null);
|
|
13
|
+
const [query, setQuery] = React.useState('');
|
|
14
|
+
const [open, setOpen] = React.useState(false);
|
|
13
15
|
const refModelLC = React.useMemo(() => refModel.toLowerCase(), [refModel]);
|
|
14
|
-
|
|
16
|
+
// normalize an option to always have string `id`
|
|
17
|
+
const normalize = React.useCallback((it) => {
|
|
18
|
+
const { id: rawId, _id: raw_Id, ...rest } = it || {};
|
|
19
|
+
const id = (typeof rawId === 'string' && rawId) ||
|
|
20
|
+
(typeof raw_Id === 'string' && raw_Id) ||
|
|
21
|
+
String(rawId ?? raw_Id ?? '');
|
|
22
|
+
return { id, ...rest };
|
|
23
|
+
}, []);
|
|
24
|
+
const load = React.useCallback(async (q) => {
|
|
15
25
|
try {
|
|
16
26
|
setLoading(true);
|
|
17
27
|
setError(null);
|
|
18
|
-
const
|
|
19
|
-
|
|
28
|
+
const params = {
|
|
29
|
+
limit: pageSize,
|
|
30
|
+
sort: showKey,
|
|
31
|
+
sortType: 'asc',
|
|
32
|
+
...(q ? { q, searchKey: showKey } : {}),
|
|
33
|
+
};
|
|
34
|
+
const res = await api.list(refModelLC, 0, pageSize, params);
|
|
35
|
+
const payload = res?.data ?? res;
|
|
36
|
+
const list = Array.isArray(payload)
|
|
37
|
+
? payload
|
|
38
|
+
: payload?.items ?? payload?.docs ?? [];
|
|
39
|
+
const normalized = list.map(normalize);
|
|
40
|
+
setItems((prev) => {
|
|
41
|
+
// Find currently selected item in previous list to ensure we don't lose it
|
|
42
|
+
// (which would break the label display)
|
|
43
|
+
const selectedId = value ? String(value) : null;
|
|
44
|
+
const selectedItem = selectedId
|
|
45
|
+
? prev.find((p) => String(p.id) === selectedId)
|
|
46
|
+
: null;
|
|
47
|
+
// Start with the new list/search results
|
|
48
|
+
const newItems = [...normalized];
|
|
49
|
+
// If we have a selected item and it's not in the new results, add it back
|
|
50
|
+
if (selectedItem &&
|
|
51
|
+
!newItems.some((i) => String(i.id) === String(selectedItem.id))) {
|
|
52
|
+
newItems.push(selectedItem);
|
|
53
|
+
}
|
|
54
|
+
return newItems;
|
|
55
|
+
});
|
|
20
56
|
}
|
|
21
57
|
catch (e) {
|
|
22
58
|
setError(e?.message || 'Failed to load options');
|
|
23
|
-
setItems([]);
|
|
24
59
|
}
|
|
25
60
|
finally {
|
|
26
61
|
setLoading(false);
|
|
27
62
|
}
|
|
28
|
-
}, [refModelLC, pageSize]);
|
|
63
|
+
}, [refModelLC, pageSize, showKey, normalize]);
|
|
64
|
+
// load on open
|
|
29
65
|
React.useEffect(() => {
|
|
30
|
-
|
|
31
|
-
|
|
66
|
+
if (open)
|
|
67
|
+
void load(query);
|
|
68
|
+
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
69
|
+
// debounced search
|
|
70
|
+
React.useEffect(() => {
|
|
71
|
+
if (!open)
|
|
72
|
+
return;
|
|
73
|
+
const t = setTimeout(() => void load(query), 250);
|
|
74
|
+
return () => clearTimeout(t);
|
|
75
|
+
}, [query, open, load]);
|
|
76
|
+
// If we have a value but no items, try to load once to get the label
|
|
77
|
+
React.useEffect(() => {
|
|
78
|
+
if (value && items.length === 0 && !loading) {
|
|
79
|
+
void load('');
|
|
80
|
+
}
|
|
81
|
+
}, [value]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
32
82
|
const getId = (r) => (typeof r.id === 'string' && r.id) ||
|
|
33
83
|
(typeof r._id === 'string' && r._id) ||
|
|
34
84
|
'';
|
|
@@ -36,14 +86,22 @@ export function RefSingleSelect({ name, label, refModel, showKey = 'name', descr
|
|
|
36
86
|
const v = r?.[showKey];
|
|
37
87
|
return v == null ? getId(r) : String(v);
|
|
38
88
|
};
|
|
89
|
+
const topSearch = (_jsx("div", { className: "p-2", children: _jsx(Input, { size: "sm", variant: "bordered", "aria-label": `Search ${refModel}`, placeholder: `Search ${refModel}…`, value: query, onChange: (e) => setQuery(e.target.value), isClearable: true, onClear: () => setQuery(''), autoFocus: true, onKeyDown: (e) => e.stopPropagation() }) }));
|
|
39
90
|
return (_jsx("div", { className: "w-full", children: _jsx(Select, { name: name, label: label, placeholder: label, labelPlacement: "outside", className: className, classNames: {
|
|
40
91
|
// Make sure the whole trigger is clickable
|
|
41
92
|
trigger: `cursor-pointer ${stripPointerNone(classNames?.trigger)}`,
|
|
42
|
-
}, variant: "bordered", isDisabled: disabled || loading, isLoading: loading, isRequired: required, description: error || description, items: items, selectionMode: "single", selectedKeys: value ? new Set([String(value)]) : new Set(), onSelectionChange: (keys) => {
|
|
93
|
+
}, variant: "bordered", isDisabled: disabled || (loading && !items.length), isLoading: loading, isRequired: required, description: error || description, items: items, selectionMode: "single", selectedKeys: value ? new Set([String(value)]) : new Set(), onSelectionChange: (keys) => {
|
|
43
94
|
if (keys === 'all')
|
|
44
95
|
return;
|
|
45
96
|
const v = Array.from(keys)[0];
|
|
46
97
|
onChange(v ?? null);
|
|
98
|
+
}, scrollShadowProps: {
|
|
99
|
+
isEnabled: false,
|
|
100
|
+
}, onOpenChange: setOpen, listboxProps: {
|
|
101
|
+
topContent: topSearch,
|
|
102
|
+
emptyContent: loading ? undefined : 'No results',
|
|
103
|
+
}, renderValue: (items) => {
|
|
104
|
+
return items.map((item) => (_jsx("span", { children: item.textValue }, item.key)));
|
|
47
105
|
}, children: (opt) => {
|
|
48
106
|
const key = getId(opt);
|
|
49
107
|
const text = getLabel(opt);
|
|
@@ -11,6 +11,7 @@ import { PhoneInput } from './PhoneInput';
|
|
|
11
11
|
import { FileUploader } from './FileUploader';
|
|
12
12
|
import AddressAutocompleteGoogle from './AddressAutocomplete';
|
|
13
13
|
import { useGoogleMapsKey } from '../hooks/useGoogleMapsKey';
|
|
14
|
+
import { api } from '../lib/api';
|
|
14
15
|
import { parseDate, parseDateTime, parseTime, } from '@internationalized/date';
|
|
15
16
|
// import RichTextEditor from './editor/RichTextEditor';
|
|
16
17
|
// import ShadcnEditor from './editor/ShadcnEditor';
|
|
@@ -277,6 +278,30 @@ export function SchemaForm({ model, schemaOverride, initialValues, submitLabel =
|
|
|
277
278
|
const schema = useMemo(() => schemaOverride ??
|
|
278
279
|
items.find((s) => s.modelName.toLowerCase() === model.toLowerCase()), [items, model, schemaOverride]);
|
|
279
280
|
const [form, setForm] = useState(initialValues ?? {});
|
|
281
|
+
// For auto-slugging
|
|
282
|
+
const [manuallyEditedSlugs, setManuallyEditedSlugs] = useState(new Set());
|
|
283
|
+
const [pendingSlugChecks, setPendingSlugChecks] = useState({});
|
|
284
|
+
useEffect(() => {
|
|
285
|
+
const timer = setTimeout(async () => {
|
|
286
|
+
const entries = Object.entries(pendingSlugChecks);
|
|
287
|
+
if (entries.length === 0)
|
|
288
|
+
return;
|
|
289
|
+
const newValues = {};
|
|
290
|
+
for (const [targetName, baseSlug] of entries) {
|
|
291
|
+
if (!baseSlug)
|
|
292
|
+
continue;
|
|
293
|
+
const uniqueSlug = await findUniqueSlug(model, baseSlug, form.id || form._id);
|
|
294
|
+
if (uniqueSlug !== form[targetName]) {
|
|
295
|
+
newValues[targetName] = uniqueSlug;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (Object.keys(newValues).length > 0) {
|
|
299
|
+
setForm((f) => ({ ...f, ...newValues }));
|
|
300
|
+
}
|
|
301
|
+
setPendingSlugChecks({});
|
|
302
|
+
}, 600); // 600ms debounce
|
|
303
|
+
return () => clearTimeout(timer);
|
|
304
|
+
}, [pendingSlugChecks, model, form.id, form._id, form]);
|
|
280
305
|
const [error, setError] = useState();
|
|
281
306
|
const isEdit = useMemo(() => !!(initialValues &&
|
|
282
307
|
(initialValues.id || initialValues._id)), [initialValues]);
|
|
@@ -318,7 +343,44 @@ export function SchemaForm({ model, schemaOverride, initialValues, submitLabel =
|
|
|
318
343
|
.filter(([name, attr]) => !(isEdit && isPasswordAttr(name, attr)))
|
|
319
344
|
.map(([name, attr]) => ({ name, attr })), [schema, canBypassPrivacy, isEdit]);
|
|
320
345
|
const gridClass = 'grid gap-4 grid-cols-2';
|
|
321
|
-
const handleChange = (name, value) =>
|
|
346
|
+
const handleChange = (name, value) => {
|
|
347
|
+
setForm((f) => {
|
|
348
|
+
const next = { ...f, [name]: value };
|
|
349
|
+
if (schema) {
|
|
350
|
+
// 1. If we edited a target field directly, mark it as manually edited
|
|
351
|
+
const attr = schema.attributes[name];
|
|
352
|
+
const head = Array.isArray(attr) ? attr[0] : attr;
|
|
353
|
+
if (head && head.populateSlugFrom) {
|
|
354
|
+
setManuallyEditedSlugs((prev) => new Set(prev).add(name));
|
|
355
|
+
}
|
|
356
|
+
// 2. If we edited a source field, update its targets
|
|
357
|
+
for (const [fname, fattr] of Object.entries(schema.attributes)) {
|
|
358
|
+
const fhead = Array.isArray(fattr) ? fattr[0] : fattr;
|
|
359
|
+
const populateFrom = fhead?.populateSlugFrom;
|
|
360
|
+
if (populateFrom) {
|
|
361
|
+
const sources = String(populateFrom)
|
|
362
|
+
.split(',')
|
|
363
|
+
.map((s) => s.trim());
|
|
364
|
+
if (sources.includes(name) && !manuallyEditedSlugs.has(fname)) {
|
|
365
|
+
const baseText = sources
|
|
366
|
+
.map((s) => next[s] || '')
|
|
367
|
+
.filter(Boolean)
|
|
368
|
+
.join(' ');
|
|
369
|
+
if (baseText) {
|
|
370
|
+
const baseSlug = slugify(baseText);
|
|
371
|
+
next[fname] = baseSlug;
|
|
372
|
+
setPendingSlugChecks((prev) => ({ ...prev, [fname]: baseSlug }));
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
next[fname] = '';
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return next;
|
|
382
|
+
});
|
|
383
|
+
};
|
|
322
384
|
const handleSubmit = async (e) => {
|
|
323
385
|
e.preventDefault();
|
|
324
386
|
setError(undefined);
|
|
@@ -421,7 +483,9 @@ export function SchemaForm({ model, schemaOverride, initialValues, submitLabel =
|
|
|
421
483
|
function SchemaField({ uid, name, attr, value, onChange, disabled, inputClassNames, selectClassNames, mapsKey, availableSchemas, formState, }) {
|
|
422
484
|
const id = `${uid}-${name}`;
|
|
423
485
|
const label = attr?.label ?? formatLabel(name);
|
|
424
|
-
const required = !!attr?.required
|
|
486
|
+
const required = !!attr?.required ||
|
|
487
|
+
name.toLowerCase() === 'slug' ||
|
|
488
|
+
!!attr?.populateSlugFrom;
|
|
425
489
|
const description = attr?.description;
|
|
426
490
|
// Prefer ref over enum. Only use enum when there is NO ref.
|
|
427
491
|
const enumVals = attr?.ref
|
|
@@ -718,3 +782,34 @@ function getPopulateTarget(attr) {
|
|
|
718
782
|
const t = head?.populate ?? attr?.populate;
|
|
719
783
|
return typeof t === 'string' && t.trim() ? t.trim() : null;
|
|
720
784
|
}
|
|
785
|
+
function slugify(text) {
|
|
786
|
+
return text
|
|
787
|
+
.toLowerCase()
|
|
788
|
+
.trim()
|
|
789
|
+
.replace(/[\s_]+/g, '-')
|
|
790
|
+
.replace(/[^\w\-]+/g, '')
|
|
791
|
+
.replace(/-+/g, '-')
|
|
792
|
+
.replace(/^-+|-+$/g, '');
|
|
793
|
+
}
|
|
794
|
+
async function findUniqueSlug(model, baseSlug, currentId) {
|
|
795
|
+
let attempt = 0;
|
|
796
|
+
while (true) {
|
|
797
|
+
const checkSlug = attempt === 0 ? baseSlug : `${baseSlug}-${attempt}`;
|
|
798
|
+
try {
|
|
799
|
+
// Check if slug exists in the model
|
|
800
|
+
const res = await api.list(model, 0, 1, { slug: checkSlug });
|
|
801
|
+
const existing = res.data?.[0];
|
|
802
|
+
if (!existing ||
|
|
803
|
+
(currentId && String(existing.id || existing._id) === String(currentId))) {
|
|
804
|
+
return checkSlug;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
catch (e) {
|
|
808
|
+
console.error('Slug check failed', e);
|
|
809
|
+
return checkSlug; // Fallback to current if API fails
|
|
810
|
+
}
|
|
811
|
+
attempt++;
|
|
812
|
+
if (attempt > 100)
|
|
813
|
+
return checkSlug; // Safety break
|
|
814
|
+
}
|
|
815
|
+
}
|
|
@@ -68,5 +68,5 @@ export const DistrictGridModal = ({ isOpen, onClose, onInsert, currentSpeciality
|
|
|
68
68
|
? "bg-primary-50 border-primary-500 text-primary-700 dark:bg-primary-900/20 dark:text-primary-400"
|
|
69
69
|
: "bg-transparent border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900"), children: "Doctors" }), _jsx("button", { onClick: () => setBaseType('hospitals'), className: cn("px-4 py-2 rounded-lg border text-sm font-medium transition-colors", baseType === 'hospitals'
|
|
70
70
|
? "bg-primary-50 border-primary-500 text-primary-700 dark:bg-primary-900/20 dark:text-primary-400"
|
|
71
|
-
: "bg-transparent border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900"), children: "Hospitals" })] })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(
|
|
71
|
+
: "bg-transparent border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900"), children: "Hospitals" })] })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(RefMultiSelect, { name: "districts", label: "Search & Select Districts", refModel: "Districts", value: selectedDistricts, onChange: (ids) => setSelectedDistricts(ids), pageSize: 1000 }), _jsx("p", { className: "text-xs text-gray-400", children: "Select one or more districts to create grid items." })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(RefSingleSelect, { name: "speciality", label: "Search & Select Speciality", refModel: "Specialities", showKey: "name", value: selectedSpecialityId, onChange: (id) => setSelectedSpecialityId(id || ''), pageSize: 10000 }), _jsx("p", { className: "text-xs text-gray-400", children: "Leave empty to create URLs without speciality." }), specialitySlug && (_jsx("div", { className: "mt-1 px-2 py-1 bg-primary-50 dark:bg-primary-900/20 rounded border border-primary-200 dark:border-primary-800", children: _jsxs("p", { className: "text-xs text-primary-700 dark:text-primary-400", children: ["Slug: ", _jsx("code", { className: "font-mono", children: specialitySlug })] }) }))] }), selectedDistricts.length > 0 && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("label", { className: "text-sm font-medium", children: "URL Preview" }), _jsxs("div", { className: "px-3 py-2 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 max-h-32 overflow-y-auto", children: [_jsx("p", { className: "text-xs text-gray-500 mb-2", children: "Example URL pattern (actual district slugs will be used):" }), _jsx("code", { className: "text-xs text-gray-700 dark:text-gray-300", children: getPreviewUrl('[district-slug]') })] })] }))] }), _jsxs(ModalFooter, { children: [_jsx(Button, { variant: "flat", onPress: onClose, children: "Cancel" }), _jsx(Button, { color: "primary", onPress: handleInsert, isDisabled: selectedDistricts.length === 0, children: "Insert Grid" })] })] }) }) }));
|
|
72
72
|
};
|
|
@@ -83,7 +83,7 @@ export const SchemaInsertionModal = ({ isOpen, onClose, onInsert, schema, availa
|
|
|
83
83
|
base: "bg-white dark:bg-zinc-950 border border-gray-200 dark:border-gray-800",
|
|
84
84
|
header: "border-b border-gray-200 dark:border-gray-800",
|
|
85
85
|
footer: "border-t border-gray-200 dark:border-gray-800",
|
|
86
|
-
}, children: _jsx(ModalContent, { children: _jsxs(_Fragment, { children: [_jsxs(ModalHeader, { children: ["Insert ", schema.modelName] }), _jsxs(ModalBody, { className: "py-6 flex flex-col gap-6", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(
|
|
86
|
+
}, children: _jsx(ModalContent, { children: _jsxs(_Fragment, { children: [_jsxs(ModalHeader, { children: ["Insert ", schema.modelName] }), _jsxs(ModalBody, { className: "py-6 flex flex-col gap-6", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(RefMultiSelect, { name: "items", label: "Search & Select", refModel: schema.modelName, value: selectedIds, onChange: (ids) => setSelectedIds(ids), pageSize: 10000 }), _jsx("p", { className: "text-xs text-gray-400", children: "Leave empty to fetch latest items automatically." })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("label", { className: "text-sm font-medium", children: "View Type" }), _jsxs("div", { className: "flex gap-4", children: [_jsx("button", { onClick: () => setViewType('grid'), className: cn("px-4 py-2 rounded-lg border text-sm font-medium transition-colors", viewType === 'grid'
|
|
87
87
|
? "bg-primary-50 border-primary-500 text-primary-700 dark:bg-primary-900/20 dark:text-primary-400"
|
|
88
88
|
: "bg-transparent border-gray-200 dark:border-gray-800 hover:bg-gray-50"), children: "Grid (Pills)" }), _jsx("button", { onClick: () => setViewType('list'), className: cn("px-4 py-2 rounded-lg border text-sm font-medium transition-colors", viewType === 'list'
|
|
89
89
|
? "bg-primary-50 border-primary-500 text-primary-700 dark:bg-primary-900/20 dark:text-primary-400"
|
package/dist/lib/types.d.ts
CHANGED