@airoom/nextmin-react 0.1.7 → 0.1.8
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.d.ts +10 -2
- package/dist/components/RefMultiSelect.js +78 -45
- package/dist/components/RefSingleSelect.js +11 -11
- package/dist/components/SchemaForm.d.ts +7 -1
- package/dist/components/SchemaForm.js +3 -3
- package/dist/lib/api.js +22 -15
- package/dist/nextmin.css +1 -1
- package/dist/views/CreateEditPage.js +15 -1
- package/package.json +1 -1
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
+
type RefOption = {
|
|
3
|
+
id?: string;
|
|
4
|
+
_id?: string;
|
|
5
|
+
[k: string]: any;
|
|
6
|
+
};
|
|
2
7
|
export type RefMultiSelectProps = {
|
|
3
8
|
name: string;
|
|
4
9
|
label: string;
|
|
5
10
|
refModel: string;
|
|
6
11
|
showKey?: string;
|
|
7
|
-
value: string[];
|
|
12
|
+
value: RefOption[] | string[];
|
|
8
13
|
onChange: (ids: string[]) => void;
|
|
9
14
|
description?: string;
|
|
10
15
|
disabled?: boolean;
|
|
11
16
|
required?: boolean;
|
|
12
|
-
pageSize?: number;
|
|
17
|
+
pageSize?: number | undefined;
|
|
18
|
+
error?: boolean | undefined;
|
|
19
|
+
errorMessage?: string | undefined;
|
|
13
20
|
};
|
|
14
21
|
export declare const RefMultiSelect: React.FC<RefMultiSelectProps>;
|
|
22
|
+
export {};
|
|
@@ -1,38 +1,61 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import React from 'react';
|
|
4
|
-
import {
|
|
4
|
+
import { Select, SelectItem, Input, Chip } from '@heroui/react';
|
|
5
5
|
import { api } from '../lib/api';
|
|
6
|
-
export const RefMultiSelect = ({ name, label, refModel, showKey = 'name', value, onChange, description, disabled, required, pageSize =
|
|
7
|
-
const [open, setOpen] = React.useState(false);
|
|
8
|
-
const [query, setQuery] = React.useState('');
|
|
9
|
-
const [loading, setLoading] = React.useState(false);
|
|
10
|
-
const [options, setOptions] = React.useState([]);
|
|
6
|
+
export const RefMultiSelect = ({ name, label, refModel, showKey = 'name', value, onChange, description, disabled, required, pageSize = 10000, error, errorMessage, }) => {
|
|
11
7
|
const modelSlug = React.useMemo(() => String(refModel).trim().toLowerCase(), [refModel]);
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
8
|
+
// normalize an option to always have string `id`
|
|
9
|
+
const normalize = React.useCallback((it) => {
|
|
10
|
+
const { id: rawId, _id: raw_Id, ...rest } = it || {};
|
|
11
|
+
const id = (typeof rawId === 'string' && rawId) ||
|
|
12
|
+
(typeof raw_Id === 'string' && raw_Id) ||
|
|
13
|
+
String(rawId ?? raw_Id ?? '');
|
|
14
|
+
return { id, ...rest };
|
|
15
|
+
}, []);
|
|
16
|
+
// from props.value (objects or ids) build selected ids + pre-seeded options
|
|
17
|
+
const preselected = React.useMemo(() => {
|
|
18
|
+
if (Array.isArray(value) && value.length && typeof value[0] === 'object') {
|
|
19
|
+
return value.map(normalize);
|
|
20
|
+
}
|
|
21
|
+
return [];
|
|
22
|
+
}, [value, normalize]);
|
|
23
|
+
const selectedIds = React.useMemo(() => {
|
|
24
|
+
if (preselected.length)
|
|
25
|
+
return preselected.map((o) => o.id);
|
|
26
|
+
return (value ?? []).map(String);
|
|
27
|
+
}, [value, preselected]);
|
|
28
|
+
// options start with preselected objects so chips show immediately & no warning
|
|
29
|
+
const [options, setOptions] = React.useState(preselected);
|
|
30
|
+
// keep options in sync if parent passes new objects later
|
|
31
|
+
React.useEffect(() => {
|
|
32
|
+
if (!preselected.length)
|
|
33
|
+
return;
|
|
34
|
+
setOptions((prev) => {
|
|
35
|
+
const map = new Map(prev.map((o) => [o.id, o]));
|
|
36
|
+
preselected.forEach((o) => map.set(o.id, o));
|
|
37
|
+
return Array.from(map.values());
|
|
38
|
+
});
|
|
39
|
+
}, [preselected]);
|
|
40
|
+
const selectedKeys = React.useMemo(() => new Set(selectedIds), [selectedIds]);
|
|
41
|
+
const labelOf = React.useCallback((opt) => String(opt?.[showKey] ??
|
|
18
42
|
opt?.name ??
|
|
19
43
|
opt?.title ??
|
|
20
44
|
opt?.key ??
|
|
21
45
|
opt?.id ??
|
|
22
46
|
opt?._id ??
|
|
23
47
|
''), [showKey]);
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const loadOptions = React.useCallback(async (q) => {
|
|
48
|
+
const [open, setOpen] = React.useState(false);
|
|
49
|
+
const [query, setQuery] = React.useState('');
|
|
50
|
+
const [loading, setLoading] = React.useState(false);
|
|
51
|
+
// fetch list (large page) and merge with preseeded
|
|
52
|
+
const load = React.useCallback(async (q) => {
|
|
30
53
|
setLoading(true);
|
|
31
54
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
? { q, searchKey: showKey
|
|
35
|
-
|
|
55
|
+
const params = {
|
|
56
|
+
limit: pageSize,
|
|
57
|
+
...(q ? { q, searchKey: showKey } : {}),
|
|
58
|
+
};
|
|
36
59
|
const res = await api.list?.(modelSlug, 0, pageSize, params);
|
|
37
60
|
const payload = res?.data ?? res;
|
|
38
61
|
const list = payload?.items ??
|
|
@@ -40,37 +63,47 @@ export const RefMultiSelect = ({ name, label, refModel, showKey = 'name', value,
|
|
|
40
63
|
payload?.results ??
|
|
41
64
|
payload?.list ??
|
|
42
65
|
(Array.isArray(payload) ? payload : []);
|
|
43
|
-
const normalized =
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
...
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
catch {
|
|
52
|
-
setOptions([]);
|
|
66
|
+
const normalized = list.map(normalize);
|
|
67
|
+
// merge by id (keep preselected if present)
|
|
68
|
+
setOptions((prev) => {
|
|
69
|
+
const map = new Map(prev.map((o) => [o.id, o]));
|
|
70
|
+
normalized.forEach((o) => map.set(o.id, { ...(map.get(o.id) || {}), ...o }));
|
|
71
|
+
return Array.from(map.values());
|
|
72
|
+
});
|
|
53
73
|
}
|
|
54
74
|
finally {
|
|
55
75
|
setLoading(false);
|
|
56
76
|
}
|
|
57
|
-
}, [modelSlug, showKey]);
|
|
58
|
-
//
|
|
77
|
+
}, [modelSlug, pageSize, showKey, normalize]);
|
|
78
|
+
// load on open + debounced search
|
|
59
79
|
React.useEffect(() => {
|
|
60
80
|
if (open)
|
|
61
|
-
void
|
|
81
|
+
void load(query);
|
|
62
82
|
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
63
|
-
// Debounced search
|
|
64
83
|
React.useEffect(() => {
|
|
65
84
|
if (!open)
|
|
66
85
|
return;
|
|
67
|
-
const t = setTimeout(() => void
|
|
86
|
+
const t = setTimeout(() => void load(query), 250);
|
|
68
87
|
return () => clearTimeout(t);
|
|
69
|
-
}, [
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
88
|
+
}, [query, open, load]);
|
|
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), autoFocus: true }) }));
|
|
90
|
+
return (_jsx("div", { className: "w-full", children: _jsx(Select, { name: name, label: label, "aria-label": label, isDisabled: disabled, selectionMode: "multiple", selectedKeys: selectedKeys, onSelectionChange: (keys) => {
|
|
91
|
+
if (keys === 'all')
|
|
92
|
+
return;
|
|
93
|
+
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: {
|
|
95
|
+
topContent: topSearch,
|
|
96
|
+
emptyContent: loading ? undefined : 'No results',
|
|
97
|
+
className: '',
|
|
98
|
+
},
|
|
99
|
+
// chips like docs; items is an array (SelectedItems<T>)
|
|
100
|
+
renderValue: (items) => {
|
|
101
|
+
const arr = Array.isArray(items) ? items : [];
|
|
102
|
+
// first render: items may be empty → fall back to selectedIds mapped from preseeded options
|
|
103
|
+
if (!arr.length && selectedIds.length) {
|
|
104
|
+
const byId = new Map(options.map((o) => [String(o.id), o]));
|
|
105
|
+
return (_jsx("div", { className: "flex flex-wrap gap-1", children: selectedIds.map((id) => (_jsx(Chip, { size: "sm", variant: "flat", children: labelOf(byId.get(String(id))) }, id))) }));
|
|
106
|
+
}
|
|
107
|
+
return (_jsx("div", { className: "flex flex-wrap gap-1", children: arr.map((it) => (_jsx(Chip, { size: "sm", variant: "flat", children: String(it?.props?.textValue ?? it?.props?.children ?? '') }, String(it?.key)))) }));
|
|
108
|
+
}, children: (item) => (_jsx(SelectItem, { textValue: labelOf(item), children: labelOf(item) }, item.id)) }) }));
|
|
76
109
|
};
|
|
@@ -3,7 +3,10 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
3
3
|
import * as React from 'react';
|
|
4
4
|
import { Select, SelectItem } from '@heroui/react';
|
|
5
5
|
import { api } from '../lib/api';
|
|
6
|
-
|
|
6
|
+
function stripPointerNone(cls) {
|
|
7
|
+
return (cls ?? '').replace(/\bpointer-events-none\b/g, '').trim();
|
|
8
|
+
}
|
|
9
|
+
export function RefSingleSelect({ name, label, refModel, showKey = 'name', description, value, onChange, disabled, required, className, classNames, pageSize = 50, }) {
|
|
7
10
|
const [loading, setLoading] = React.useState(false);
|
|
8
11
|
const [items, setItems] = React.useState([]);
|
|
9
12
|
const [error, setError] = React.useState(null);
|
|
@@ -12,7 +15,6 @@ export function RefSingleSelect({ name, label, refModel, showKey = 'name', descr
|
|
|
12
15
|
try {
|
|
13
16
|
setLoading(true);
|
|
14
17
|
setError(null);
|
|
15
|
-
// small page size is enough for pickers; adjust if needed
|
|
16
18
|
const res = await api.list(refModelLC, 0, pageSize, {});
|
|
17
19
|
setItems(Array.isArray(res.data) ? res.data : []);
|
|
18
20
|
}
|
|
@@ -23,9 +25,9 @@ export function RefSingleSelect({ name, label, refModel, showKey = 'name', descr
|
|
|
23
25
|
finally {
|
|
24
26
|
setLoading(false);
|
|
25
27
|
}
|
|
26
|
-
}, [refModelLC]);
|
|
28
|
+
}, [refModelLC, pageSize]);
|
|
27
29
|
React.useEffect(() => {
|
|
28
|
-
load();
|
|
30
|
+
void load();
|
|
29
31
|
}, [load]);
|
|
30
32
|
const getId = (r) => (typeof r.id === 'string' && r.id) ||
|
|
31
33
|
(typeof r._id === 'string' && r._id) ||
|
|
@@ -34,17 +36,15 @@ export function RefSingleSelect({ name, label, refModel, showKey = 'name', descr
|
|
|
34
36
|
const v = r?.[showKey];
|
|
35
37
|
return v == null ? getId(r) : String(v);
|
|
36
38
|
};
|
|
37
|
-
return (_jsx("div", { className: "w-full", children: _jsx(Select, { name: name, label: label, placeholder: label, labelPlacement: "outside", className: className, classNames:
|
|
39
|
+
return (_jsx("div", { className: "w-full", children: _jsx(Select, { name: name, label: label, placeholder: label, labelPlacement: "outside", className: className, classNames: {
|
|
40
|
+
// Make sure the whole trigger is clickable
|
|
41
|
+
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) => {
|
|
38
43
|
if (keys === 'all')
|
|
39
44
|
return;
|
|
40
45
|
const v = Array.from(keys)[0];
|
|
41
46
|
onChange(v ?? null);
|
|
42
|
-
},
|
|
43
|
-
// Re-fetch on open to ensure newest data (e.g., newly created roles)
|
|
44
|
-
onOpenChange: (open) => {
|
|
45
|
-
if (open)
|
|
46
|
-
void load();
|
|
47
|
-
}, disallowEmptySelection: false, "aria-label": label, children: (opt) => {
|
|
47
|
+
}, children: (opt) => {
|
|
48
48
|
const key = getId(opt);
|
|
49
49
|
const text = getLabel(opt);
|
|
50
50
|
return (_jsx(SelectItem, { textValue: text, children: text }, key));
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { SchemaDef } from '../lib/types';
|
|
2
|
+
type FieldErrorItems = {
|
|
3
|
+
error: boolean;
|
|
4
|
+
field: string;
|
|
5
|
+
message: string;
|
|
6
|
+
};
|
|
2
7
|
type SchemaFormProps = {
|
|
3
8
|
model: string;
|
|
4
9
|
schemaOverride?: SchemaDef;
|
|
@@ -7,7 +12,8 @@ type SchemaFormProps = {
|
|
|
7
12
|
busy?: boolean;
|
|
8
13
|
showReset?: boolean;
|
|
9
14
|
onSubmit: (values: Record<string, any>) => void | Promise<void>;
|
|
15
|
+
fieldErrors?: FieldErrorItems[] | undefined;
|
|
10
16
|
};
|
|
11
17
|
/** --------------------------------------------------------------------------------------- **/
|
|
12
|
-
export declare function SchemaForm({ model, schemaOverride, initialValues, submitLabel, busy, showReset, onSubmit, }: SchemaFormProps): import("react/jsx-runtime").JSX.Element;
|
|
18
|
+
export declare function SchemaForm({ model, schemaOverride, initialValues, submitLabel, busy, showReset, onSubmit, fieldErrors, }: SchemaFormProps): import("react/jsx-runtime").JSX.Element;
|
|
13
19
|
export {};
|
|
@@ -181,7 +181,7 @@ function normalizeTimeRangeLoose(value) {
|
|
|
181
181
|
return {};
|
|
182
182
|
}
|
|
183
183
|
/** --------------------------------------------------------------------------------------- **/
|
|
184
|
-
export function SchemaForm({ model, schemaOverride, initialValues, submitLabel = 'Save', busy = false, showReset = true, onSubmit, }) {
|
|
184
|
+
export function SchemaForm({ model, schemaOverride, initialValues, submitLabel = 'Save', busy = false, showReset = true, onSubmit, fieldErrors, }) {
|
|
185
185
|
const mapsKey = useGoogleMapsKey();
|
|
186
186
|
const formUid = useId();
|
|
187
187
|
const { items } = useSelector((s) => s.schemas);
|
|
@@ -282,7 +282,7 @@ export function SchemaForm({ model, schemaOverride, initialValues, submitLabel =
|
|
|
282
282
|
// --- 1) Array of references → multi select (1 column)
|
|
283
283
|
const refArray = getRefArraySpec(attr);
|
|
284
284
|
if (refArray) {
|
|
285
|
-
return (_jsx("div", { className: colClass, children: _jsx(RefMultiSelect, { name: name, label: attr?.label ?? formatLabel(name), refModel: refArray.ref, showKey: refArray.show ?? 'name', description: attr?.description, value:
|
|
285
|
+
return (_jsx("div", { className: colClass, children: _jsx(RefMultiSelect, { name: name, label: attr[0]?.label ?? formatLabel(name), refModel: refArray.ref, showKey: refArray.show ?? 'name', description: attr?.description, value: form[name], onChange: (ids) => handleChange(name, ids), disabled: busy, required: attr[0]?.required, pageSize: attr[0]?.pageSize }) }, name));
|
|
286
286
|
}
|
|
287
287
|
// --- 2) Single reference → single select (1 column)
|
|
288
288
|
const refSingle = getRefSingleSpec(attr);
|
|
@@ -433,7 +433,7 @@ function SchemaField({ uid, name, attr, value, onChange, disabled, inputClassNam
|
|
|
433
433
|
return (_jsx(Radio, { value: opt, children: opt }, opt));
|
|
434
434
|
}) }));
|
|
435
435
|
}
|
|
436
|
-
return (_jsx(Select, { variant: "bordered", classNames: selectClassNames, labelPlacement: "outside", id: id, name: name, label: label, selectedKeys: value ? new Set([String(value)]) : new Set(), onSelectionChange: (keys) => {
|
|
436
|
+
return (_jsx(Select, { isVirtualized: true, variant: "bordered", classNames: selectClassNames, labelPlacement: "outside", id: id, name: name, label: label, selectedKeys: value ? new Set([String(value)]) : new Set(), onSelectionChange: (keys) => {
|
|
437
437
|
if (keys === 'all')
|
|
438
438
|
return;
|
|
439
439
|
const v = Array.from(keys)[0];
|
package/dist/lib/api.js
CHANGED
|
@@ -28,16 +28,29 @@ function modelPath(modelName) {
|
|
|
28
28
|
async function request(path, opts = {}) {
|
|
29
29
|
const { method = 'GET', body, token, json = true, auth = true } = opts;
|
|
30
30
|
const headers = { 'x-api-key': API_KEY };
|
|
31
|
-
|
|
31
|
+
// Don't force JSON when sending FormData/Blob
|
|
32
|
+
const isForm = typeof FormData !== 'undefined' && body instanceof FormData;
|
|
33
|
+
if (json && !isForm)
|
|
32
34
|
headers['Content-Type'] = 'application/json';
|
|
33
35
|
const bearer = token ?? tokenFromStorage();
|
|
34
36
|
if (auth && bearer)
|
|
35
37
|
headers.Authorization = `Bearer ${bearer}`;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
let res;
|
|
39
|
+
try {
|
|
40
|
+
res = await fetch(`${API_BASE}${path}`, {
|
|
41
|
+
method,
|
|
42
|
+
headers,
|
|
43
|
+
body: body && json && !isForm
|
|
44
|
+
? JSON.stringify(body)
|
|
45
|
+
: body,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
catch (networkErr) {
|
|
49
|
+
// Network/DNS/CORS timeouts etc.
|
|
50
|
+
const err = new ApiError(networkErr?.message || 'Network error', 0);
|
|
51
|
+
err.request = { url: `${API_BASE}${path}`, method, headers };
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
41
54
|
const raw = await res.text();
|
|
42
55
|
let payload = {};
|
|
43
56
|
try {
|
|
@@ -50,15 +63,9 @@ async function request(path, opts = {}) {
|
|
|
50
63
|
const status = res.status;
|
|
51
64
|
const serverMsg = payload?.message ??
|
|
52
65
|
payload?.error ??
|
|
53
|
-
res.statusText
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
throw new ApiError('Not authenticated', 401, payload);
|
|
57
|
-
if (status === 403)
|
|
58
|
-
throw new ApiError('Not permitted', 403, payload);
|
|
59
|
-
throw new ApiError(typeof serverMsg === 'string' && serverMsg.trim()
|
|
60
|
-
? serverMsg
|
|
61
|
-
: 'API error', status, payload);
|
|
66
|
+
res.statusText ??
|
|
67
|
+
'API error';
|
|
68
|
+
throw payload;
|
|
62
69
|
}
|
|
63
70
|
return payload;
|
|
64
71
|
}
|