@airoom/nextmin-react 1.4.6 → 2.0.1
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 +29 -3
- package/dist/auth/SignInForm.js +4 -2
- package/dist/components/AdminApp.js +15 -38
- package/dist/components/ArchitectureDemo.d.ts +1 -0
- package/dist/components/ArchitectureDemo.js +45 -0
- package/dist/components/PhoneInput.d.ts +3 -0
- package/dist/components/PhoneInput.js +23 -19
- package/dist/components/RefSelect.d.ts +16 -0
- package/dist/components/RefSelect.js +225 -0
- package/dist/components/SchemaForm.js +125 -50
- package/dist/components/Sidebar.js +6 -13
- package/dist/components/TableFilters.js +2 -0
- package/dist/components/editor/TiptapEditor.js +1 -1
- package/dist/components/editor/Toolbar.js +13 -2
- package/dist/components/editor/components/DistrictGridModal.js +2 -3
- package/dist/components/editor/components/SchemaInsertionModal.js +2 -2
- package/dist/components/viewer/DynamicViewer.js +70 -9
- package/dist/hooks/useRealtime.d.ts +8 -0
- package/dist/hooks/useRealtime.js +30 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/lib/AuthClient.d.ts +15 -0
- package/dist/lib/AuthClient.js +63 -0
- package/dist/lib/QueryBuilder.d.ts +29 -0
- package/dist/lib/QueryBuilder.js +74 -0
- package/dist/lib/RealtimeClient.d.ts +16 -0
- package/dist/lib/RealtimeClient.js +56 -0
- package/dist/lib/api.d.ts +15 -3
- package/dist/lib/api.js +71 -58
- package/dist/lib/auth.js +7 -2
- package/dist/lib/types.d.ts +16 -0
- package/dist/nextmin.css +1 -1
- package/dist/providers/NextMinProvider.d.ts +8 -1
- package/dist/providers/NextMinProvider.js +40 -8
- package/dist/router/NextMinRouter.d.ts +1 -1
- package/dist/router/NextMinRouter.js +1 -1
- package/dist/state/schemasSlice.js +4 -27
- package/dist/views/DashboardPage.js +56 -42
- package/dist/views/ListPage.js +34 -4
- package/dist/views/SettingsEdit.js +25 -2
- package/dist/views/list/DataTableHero.js +103 -46
- package/dist/views/list/ListHeader.d.ts +3 -1
- package/dist/views/list/ListHeader.js +2 -2
- package/dist/views/list/jsonSummary.d.ts +3 -3
- package/dist/views/list/jsonSummary.js +47 -20
- package/dist/views/list/useListData.js +5 -1
- package/package.json +8 -4
- package/dist/components/RefMultiSelect.d.ts +0 -22
- package/dist/components/RefMultiSelect.js +0 -113
- package/dist/components/RefSingleSelect.d.ts +0 -17
- package/dist/components/RefSingleSelect.js +0 -110
- package/dist/lib/schemaService.d.ts +0 -2
- package/dist/lib/schemaService.js +0 -39
- package/dist/state/schemaLive.d.ts +0 -2
- package/dist/state/schemaLive.js +0 -19
- /package/dist/{editor.css → components/editor/editor.css} +0 -0
package/README.md
CHANGED
|
@@ -12,9 +12,11 @@ Read the full documentation at: https://nextmin.gscodes.dev/
|
|
|
12
12
|
|
|
13
13
|
## Features
|
|
14
14
|
|
|
15
|
-
- `<NextMinProvider>`: initializes store, loads schemas, and
|
|
15
|
+
- `<NextMinProvider>`: initializes store, loads schemas, and **automatically** manages live socket connections
|
|
16
16
|
- `<AdminApp>`: full admin shell with auth, sidebar, dashboard, list/create/edit, profile, settings
|
|
17
|
-
-
|
|
17
|
+
- **Realtime Hooks**: Use `useRealtime()` to consume live backend emissions in any custom component
|
|
18
|
+
- **Rich Editor Support**: Native Tiptap integration via `"rich": true` in schemas
|
|
19
|
+
- **Meta Tags & SEO**: Built-in support for metadata fields within auto-generated forms
|
|
18
20
|
- Components: Sidebar, SchemaForm, FileUploader, reference selectors, phone/password inputs, table & filters
|
|
19
21
|
- Hooks/utils: Google address autocomplete, list data helpers, formatters
|
|
20
22
|
- Styling included; no extra CSS import needed
|
|
@@ -63,7 +65,31 @@ Wrap only the admin area with the provider and render the AdminApp. Mark these f
|
|
|
63
65
|
import { NextMinProvider } from '@airoom/nextmin-react';
|
|
64
66
|
|
|
65
67
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
|
66
|
-
return
|
|
68
|
+
return (
|
|
69
|
+
<NextMinProvider
|
|
70
|
+
apiUrl={process.env.NEXT_PUBLIC_NEXTMIN_API_URL}
|
|
71
|
+
apiKey={process.env.NEXT_PUBLIC_NEXTMIN_API_KEY}
|
|
72
|
+
>
|
|
73
|
+
{children}
|
|
74
|
+
</NextMinProvider>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Realtime Hook Example
|
|
80
|
+
|
|
81
|
+
```tsx
|
|
82
|
+
import { useRealtime } from '@airoom/nextmin-react';
|
|
83
|
+
|
|
84
|
+
export function MyLiveComponent() {
|
|
85
|
+
const { isConnected, lastEvent } = useRealtime();
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div>
|
|
89
|
+
Status: {isConnected ? 'Online' : 'Offline'}
|
|
90
|
+
{lastEvent && <p>Latest Activity: {lastEvent.event}</p>}
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
67
93
|
}
|
|
68
94
|
```
|
|
69
95
|
|
package/dist/auth/SignInForm.js
CHANGED
|
@@ -5,6 +5,8 @@ import { Card, CardHeader, CardBody, Input, Button, Link, Form, } from '@heroui/
|
|
|
5
5
|
import { useDispatch } from 'react-redux';
|
|
6
6
|
import { setSession } from '../state/sessionSlice';
|
|
7
7
|
import { login as loginApi } from '../lib/auth';
|
|
8
|
+
import { AuthClient } from '../lib/AuthClient';
|
|
9
|
+
import { RealtimeClient } from '../lib/RealtimeClient';
|
|
8
10
|
export function SignInForm({ onSuccess }) {
|
|
9
11
|
const [loading, setLoading] = useState(false);
|
|
10
12
|
const [err, setErr] = useState(null);
|
|
@@ -22,8 +24,8 @@ export function SignInForm({ onSuccess }) {
|
|
|
22
24
|
try {
|
|
23
25
|
const { token, user } = await doLogin(email, password);
|
|
24
26
|
// persist + redux
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
AuthClient.setSession(token, user);
|
|
28
|
+
RealtimeClient.getInstance().connect();
|
|
27
29
|
dispatch(setSession({ token, user }));
|
|
28
30
|
// optional callback
|
|
29
31
|
onSuccess?.(token, user);
|
|
@@ -10,24 +10,9 @@ import { usePathname, useRouter } from 'next/navigation';
|
|
|
10
10
|
import { AdminRouteNormalizer } from '../router/AdminRouteNormalizer';
|
|
11
11
|
import { SectionLoader } from './SectionLoader';
|
|
12
12
|
import { api } from '../lib/api';
|
|
13
|
+
import { AuthClient } from '../lib/AuthClient';
|
|
14
|
+
import { RealtimeClient } from '../lib/RealtimeClient';
|
|
13
15
|
import { systemLoading, systemLoaded, systemFailed, } from '../state/nextMinSlice';
|
|
14
|
-
function isJwtValid(token) {
|
|
15
|
-
if (!token)
|
|
16
|
-
return false;
|
|
17
|
-
const parts = token.split('.');
|
|
18
|
-
if (parts.length !== 3)
|
|
19
|
-
return true; // non-JWT token: treat as present
|
|
20
|
-
try {
|
|
21
|
-
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
|
|
22
|
-
if (typeof payload?.exp === 'number') {
|
|
23
|
-
return payload.exp * 1000 > Date.now();
|
|
24
|
-
}
|
|
25
|
-
return true;
|
|
26
|
-
}
|
|
27
|
-
catch {
|
|
28
|
-
return true;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
16
|
export function AdminApp() {
|
|
32
17
|
const dispatch = useDispatch();
|
|
33
18
|
const router = useRouter();
|
|
@@ -36,24 +21,18 @@ export function AdminApp() {
|
|
|
36
21
|
const [hydrated, setHydrated] = useState(false);
|
|
37
22
|
// Hydrate session exactly once
|
|
38
23
|
useEffect(() => {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
// clear expired
|
|
50
|
-
localStorage.removeItem('nextmin.token');
|
|
51
|
-
localStorage.removeItem('nextmin.user');
|
|
52
|
-
dispatch(clearSession());
|
|
53
|
-
}
|
|
24
|
+
const t = AuthClient.getToken();
|
|
25
|
+
const u = AuthClient.getUser();
|
|
26
|
+
if (t && u) {
|
|
27
|
+
if (AuthClient.isValidToken(t)) {
|
|
28
|
+
dispatch(setSession({ token: t, user: u }));
|
|
29
|
+
RealtimeClient.getInstance().connect();
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
AuthClient.clearSession();
|
|
33
|
+
dispatch(clearSession());
|
|
54
34
|
}
|
|
55
35
|
}
|
|
56
|
-
catch { }
|
|
57
36
|
setHydrated(true);
|
|
58
37
|
}, [dispatch]);
|
|
59
38
|
useEffect(() => {
|
|
@@ -64,7 +43,7 @@ export function AdminApp() {
|
|
|
64
43
|
try {
|
|
65
44
|
dispatch(systemLoading());
|
|
66
45
|
// take first Settings row (singleton)
|
|
67
|
-
const res = await api.list('settings', 0, 1);
|
|
46
|
+
const res = await api.list('settings', { page: 0, limit: 1 });
|
|
68
47
|
const first = res?.data?.[0] ?? null;
|
|
69
48
|
const normalized = first == null
|
|
70
49
|
? null
|
|
@@ -91,10 +70,8 @@ export function AdminApp() {
|
|
|
91
70
|
};
|
|
92
71
|
}, [hydrated, dispatch]);
|
|
93
72
|
const localToken = useMemo(() => {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const t = localStorage.getItem('nextmin.token');
|
|
97
|
-
return isJwtValid(t) ? t : null;
|
|
73
|
+
const t = AuthClient.getToken();
|
|
74
|
+
return AuthClient.isValidToken(t) ? t : null;
|
|
98
75
|
}, [hydrated]); // re-read after hydration
|
|
99
76
|
const haveToken = Boolean(token || localToken);
|
|
100
77
|
const onAuthRoute = pathname?.startsWith('/admin/auth') ?? false;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function ArchitectureDemo(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { useNextMin } from '../providers/NextMinProvider';
|
|
5
|
+
import { QueryBuilder } from '../lib/QueryBuilder';
|
|
6
|
+
import { useRealtime } from '../hooks/useRealtime';
|
|
7
|
+
export function ArchitectureDemo() {
|
|
8
|
+
const { client } = useNextMin();
|
|
9
|
+
const { isConnected, lastEvent } = useRealtime();
|
|
10
|
+
const [posts, setPosts] = useState([]);
|
|
11
|
+
const [logs, setLogs] = useState([]);
|
|
12
|
+
// 1. Demonstrate QueryBuilder (New Architecture)
|
|
13
|
+
const fetchFilteredPosts = async () => {
|
|
14
|
+
const qb = new QueryBuilder()
|
|
15
|
+
.where('title', 'contains', 'NextMin')
|
|
16
|
+
.orWhere('views', 'gt', 100)
|
|
17
|
+
.sort('createdAt', 'desc')
|
|
18
|
+
.limit(5);
|
|
19
|
+
try {
|
|
20
|
+
const res = await client.request('/posts', {
|
|
21
|
+
method: 'GET',
|
|
22
|
+
query: qb.build(),
|
|
23
|
+
});
|
|
24
|
+
setPosts(res.data || []);
|
|
25
|
+
addLog('Fetched posts using QueryBuilder');
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
addLog(`Fetch error: ${err}`);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
// 2. Demonstrate Realtime Events (New Architecture)
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (lastEvent) {
|
|
34
|
+
addLog(`Realtime Event: ${lastEvent.event} on ${lastEvent.payload.modelName}`);
|
|
35
|
+
if (lastEvent.event === 'doc:create' && lastEvent.payload.modelName === 'posts') {
|
|
36
|
+
// Optionally refresh list
|
|
37
|
+
fetchFilteredPosts();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}, [lastEvent]);
|
|
41
|
+
const addLog = (msg) => {
|
|
42
|
+
setLogs((prev) => [`[${new Date().toLocaleTimeString()}] ${msg}`, ...prev].slice(0, 10));
|
|
43
|
+
};
|
|
44
|
+
return (_jsxs("div", { className: "flex flex-col gap-6 p-6 border rounded-xl bg-white dark:bg-black/20 shadow-sm", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("h2", { className: "text-xl font-bold", children: "New Architecture Demo" }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: `w-3 h-3 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}` }), _jsx("span", { className: "text-sm font-medium", children: isConnected ? 'Realtime Connected' : 'Disconnected' })] })] }), _jsxs("div", { className: "grid grid-cols-1 md:grid-cols-2 gap-6", children: [_jsxs("div", { className: "flex flex-col gap-4", children: [_jsx("h3", { className: "font-semibold text-gray-600 dark:text-gray-400", children: "QueryBuilder & Filtering" }), _jsx("button", { onClick: fetchFilteredPosts, className: "bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition-colors text-sm font-medium w-fit", children: "Fetch Filtered Posts" }), _jsx("div", { className: "flex flex-col gap-2", children: posts.length > 0 ? (posts.map((p, i) => (_jsxs("div", { className: "p-3 border rounded bg-gray-50 dark:bg-white/5 text-sm", children: [_jsx("span", { className: "font-bold", children: p.title }), " \u2014 ", p.views, " views"] }, i)))) : (_jsx("p", { className: "text-sm text-gray-500 italic", children: "No posts found matching filter." })) })] }), _jsxs("div", { className: "flex flex-col gap-4", children: [_jsx("h3", { className: "font-semibold text-gray-600 dark:text-gray-400", children: "Realtime Event Log" }), _jsx("div", { className: "flex flex-col gap-2 h-[200px] overflow-y-auto border rounded bg-black/5 dark:bg-white/5 p-3 font-mono text-xs", children: logs.map((log, i) => (_jsx("div", { className: "border-b last:border-0 pb-1 border-gray-200 dark:border-white/10 uppercase", children: log }, i))) })] })] })] }));
|
|
45
|
+
}
|
|
@@ -6,7 +6,10 @@ const SLOT = 'x';
|
|
|
6
6
|
const SLOT_PATTERN = /[Xx9#_]/g; // accept these as "digit slot" placeholders
|
|
7
7
|
const normalizeMaskSlots = (m) => m.replace(SLOT_PATTERN, SLOT);
|
|
8
8
|
// handle string | number | null | undefined safely
|
|
9
|
-
const onlyDigits = (s) =>
|
|
9
|
+
const onlyDigits = (s, regex) => {
|
|
10
|
+
const filter = regex ? new RegExp(regex, 'g') : /\D/g;
|
|
11
|
+
return String(s ?? '').replace(filter, '');
|
|
12
|
+
};
|
|
10
13
|
const clamp = (n, min, max) => Math.max(min, Math.min(max, n));
|
|
11
14
|
/** Indices in the mask where a digit goes (SLOT) */
|
|
12
15
|
const getSlotIndices = (mask) => {
|
|
@@ -55,8 +58,8 @@ const caretPosForDigitCount = (normMask, digitCount) => {
|
|
|
55
58
|
return slots[0]; // first slot (skip prefix like + or ()
|
|
56
59
|
return clamp(slots[k - 1] + 1, 0, normMask.length);
|
|
57
60
|
};
|
|
58
|
-
const digitsBeforeCaret = (maskedValue, caret) => onlyDigits(maskedValue.slice(0, clamp(caret, 0, maskedValue.length))).length;
|
|
59
|
-
export const PhoneInput = ({ id, name, label, mask, value, onChange, disabled, required, description, className, classNames, }) => {
|
|
61
|
+
const digitsBeforeCaret = (maskedValue, caret, regex) => onlyDigits(maskedValue.slice(0, clamp(caret, 0, maskedValue.length)), regex).length;
|
|
62
|
+
export const PhoneInput = ({ id, name, label, mask, value, onChange, disabled, required, description, className, classNames, minLength, maxLength, regex, }) => {
|
|
60
63
|
const inputRef = useRef(null);
|
|
61
64
|
// Normalize the mask to use lowercase 'x' as slots for internal logic
|
|
62
65
|
const { normMask, maxDigits, placeholder, pattern } = useMemo(() => {
|
|
@@ -69,8 +72,8 @@ export const PhoneInput = ({ id, name, label, mask, value, onChange, disabled, r
|
|
|
69
72
|
placeholder: nm.replace(/x/g, 'X'),
|
|
70
73
|
pattern: toRegexFromMask(nm),
|
|
71
74
|
};
|
|
72
|
-
}, [mask]);
|
|
73
|
-
const raw = onlyDigits(value).slice(0, maxDigits);
|
|
75
|
+
}, [mask, minLength]);
|
|
76
|
+
const raw = onlyDigits(value, regex).slice(0, maxLength ?? maxDigits);
|
|
74
77
|
const masked = formatDisplay(raw, normMask);
|
|
75
78
|
const setCaret = (pos) => {
|
|
76
79
|
const el = inputRef.current;
|
|
@@ -86,8 +89,8 @@ export const PhoneInput = ({ id, name, label, mask, value, onChange, disabled, r
|
|
|
86
89
|
const handleChange = (e) => {
|
|
87
90
|
const inputVal = e.currentTarget.value;
|
|
88
91
|
const caret = e.currentTarget.selectionStart ?? inputVal.length;
|
|
89
|
-
const nextRaw = onlyDigits(inputVal).slice(0, maxDigits);
|
|
90
|
-
const digitsBefore = digitsBeforeCaret(inputVal, caret);
|
|
92
|
+
const nextRaw = onlyDigits(inputVal, regex).slice(0, maxLength ?? maxDigits);
|
|
93
|
+
const digitsBefore = digitsBeforeCaret(inputVal, caret, regex);
|
|
91
94
|
const nextMasked = formatDisplay(nextRaw, normMask);
|
|
92
95
|
const nextCaret = caretPosForDigitCount(normMask, digitsBefore);
|
|
93
96
|
onChange(nextRaw);
|
|
@@ -103,14 +106,14 @@ export const PhoneInput = ({ id, name, label, mask, value, onChange, disabled, r
|
|
|
103
106
|
if (selStart <= 0)
|
|
104
107
|
return;
|
|
105
108
|
const leftChar = el.value[selStart - 1];
|
|
106
|
-
if (leftChar && /\D/.test(leftChar)) {
|
|
109
|
+
if (leftChar && (regex ? new RegExp(regex).test(leftChar) : /\D/.test(leftChar))) {
|
|
107
110
|
e.preventDefault();
|
|
108
|
-
const currRaw = onlyDigits(el.value);
|
|
109
|
-
const countBefore = digitsBeforeCaret(el.value, selStart - 1);
|
|
111
|
+
const currRaw = onlyDigits(el.value, regex);
|
|
112
|
+
const countBefore = digitsBeforeCaret(el.value, selStart - 1, regex);
|
|
110
113
|
const idxToRemove = countBefore - 1;
|
|
111
114
|
if (idxToRemove >= 0) {
|
|
112
115
|
const nextRaw = currRaw.slice(0, idxToRemove) + currRaw.slice(idxToRemove + 1);
|
|
113
|
-
onChange(nextRaw.slice(0, maxDigits));
|
|
116
|
+
onChange(nextRaw.slice(0, maxLength ?? maxDigits));
|
|
114
117
|
const nextCaret = caretPosForDigitCount(normMask, countBefore - 1);
|
|
115
118
|
setCaret(nextCaret);
|
|
116
119
|
}
|
|
@@ -119,14 +122,14 @@ export const PhoneInput = ({ id, name, label, mask, value, onChange, disabled, r
|
|
|
119
122
|
}
|
|
120
123
|
if (e.key === 'Delete') {
|
|
121
124
|
const rightChar = el.value[selStart];
|
|
122
|
-
if (rightChar && /\D/.test(rightChar)) {
|
|
125
|
+
if (rightChar && (regex ? new RegExp(regex).test(rightChar) : /\D/.test(rightChar))) {
|
|
123
126
|
e.preventDefault();
|
|
124
|
-
const currRaw = onlyDigits(el.value);
|
|
125
|
-
const countBefore = digitsBeforeCaret(el.value, selStart);
|
|
127
|
+
const currRaw = onlyDigits(el.value, regex);
|
|
128
|
+
const countBefore = digitsBeforeCaret(el.value, selStart, regex);
|
|
126
129
|
const idxToRemove = countBefore;
|
|
127
130
|
if (idxToRemove < currRaw.length) {
|
|
128
131
|
const nextRaw = currRaw.slice(0, idxToRemove) + currRaw.slice(idxToRemove + 1);
|
|
129
|
-
onChange(nextRaw.slice(0, maxDigits));
|
|
132
|
+
onChange(nextRaw.slice(0, maxLength ?? maxDigits));
|
|
130
133
|
const nextCaret = caretPosForDigitCount(normMask, countBefore);
|
|
131
134
|
setCaret(nextCaret);
|
|
132
135
|
}
|
|
@@ -135,13 +138,14 @@ export const PhoneInput = ({ id, name, label, mask, value, onChange, disabled, r
|
|
|
135
138
|
};
|
|
136
139
|
const handlePaste = (e) => {
|
|
137
140
|
const text = e.clipboardData.getData('text') ?? '';
|
|
138
|
-
const pastedDigits = onlyDigits(text);
|
|
141
|
+
const pastedDigits = onlyDigits(text, regex);
|
|
139
142
|
if (!pastedDigits)
|
|
140
143
|
return;
|
|
141
144
|
e.preventDefault();
|
|
142
|
-
|
|
143
|
-
|
|
145
|
+
const finalMax = maxLength ?? maxDigits;
|
|
146
|
+
onChange(pastedDigits.slice(0, finalMax));
|
|
147
|
+
const nextCaret = caretPosForDigitCount(normMask, Math.min(pastedDigits.length, finalMax));
|
|
144
148
|
setCaret(nextCaret);
|
|
145
149
|
};
|
|
146
|
-
return (_jsx(Input, { ref: inputRef, variant: "bordered", classNames: classNames, id: id, name: name, label: label, labelPlacement: "outside-top", value: masked, onChange: handleChange, onKeyDown: handleKeyDown, onPaste: handlePaste, isDisabled: disabled, description: description ?? `Format: ${mask}`, className: className ?? 'w-full', isRequired: required, inputMode: "tel", pattern: pattern, maxLength: normMask.length, placeholder: placeholder }));
|
|
150
|
+
return (_jsx(Input, { ref: inputRef, variant: "bordered", classNames: classNames, id: id, name: name, label: label, labelPlacement: "outside-top", value: masked, onChange: handleChange, onKeyDown: handleKeyDown, onPaste: handlePaste, isDisabled: disabled, description: description ?? `Format: ${mask}`, className: className ?? 'w-full', isRequired: required, inputMode: "tel", pattern: pattern, minLength: minLength, maxLength: normMask.length, placeholder: placeholder }));
|
|
147
151
|
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type RefSelectProps = {
|
|
2
|
+
name: string;
|
|
3
|
+
label: string;
|
|
4
|
+
refModel: string;
|
|
5
|
+
showKey?: string;
|
|
6
|
+
value: string | string[] | null;
|
|
7
|
+
onChange: (value: string | string[] | null) => void;
|
|
8
|
+
multiple?: boolean;
|
|
9
|
+
description?: string;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
required?: boolean;
|
|
12
|
+
className?: string;
|
|
13
|
+
pageSize?: number;
|
|
14
|
+
where?: Record<string, any>;
|
|
15
|
+
};
|
|
16
|
+
export declare function RefSelect({ name, label, refModel, showKey, value, onChange, multiple, description, disabled, required, className, pageSize, where, }: RefSelectProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
4
|
+
import { Select, SelectItem, Chip, Spinner, Input } from '@heroui/react';
|
|
5
|
+
import { useInfiniteScroll } from '@heroui/use-infinite-scroll';
|
|
6
|
+
import { api } from '../lib/api';
|
|
7
|
+
export function RefSelect({ name, label, refModel, showKey = 'name', value, onChange, multiple = false, description, disabled, required, className, pageSize = 20, where, }) {
|
|
8
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
9
|
+
const [items, setItems] = useState([]);
|
|
10
|
+
const [hasMore, setHasMore] = useState(true);
|
|
11
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
12
|
+
const [offset, setOffset] = useState(0);
|
|
13
|
+
const [searchFilter, setSearchFilter] = useState('');
|
|
14
|
+
const [allSeenItems, setAllSeenItems] = useState({});
|
|
15
|
+
const refModelLC = useMemo(() => refModel.toLowerCase(), [refModel]);
|
|
16
|
+
// Normalize IDs to strings
|
|
17
|
+
const normalizeId = (it) => {
|
|
18
|
+
if (it == null)
|
|
19
|
+
return '';
|
|
20
|
+
if (typeof it === 'string')
|
|
21
|
+
return it;
|
|
22
|
+
if (typeof it === 'number')
|
|
23
|
+
return String(it);
|
|
24
|
+
// Handle objects like MongoDB ObjectIds
|
|
25
|
+
const id = it?.id ?? it?._id ?? it;
|
|
26
|
+
if (id == null)
|
|
27
|
+
return '';
|
|
28
|
+
if (typeof id === 'string')
|
|
29
|
+
return id;
|
|
30
|
+
if (typeof id.toHexString === 'function')
|
|
31
|
+
return id.toHexString();
|
|
32
|
+
if (typeof id.toString === 'function' && id !== it)
|
|
33
|
+
return id.toString();
|
|
34
|
+
return String(id);
|
|
35
|
+
};
|
|
36
|
+
const getLabel = useCallback((it) => {
|
|
37
|
+
return String(it?.[showKey] ?? it?.name ?? it?.title ?? normalizeId(it));
|
|
38
|
+
}, [showKey]);
|
|
39
|
+
// Hydration: Fetch labels for initial values if not in list
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const fetchLabels = async () => {
|
|
42
|
+
const ids = Array.isArray(value) ? value : value ? [value] : [];
|
|
43
|
+
if (ids.length === 0)
|
|
44
|
+
return;
|
|
45
|
+
// Filter out IDs we already have labels for
|
|
46
|
+
const missingIds = ids.filter(id => !items.some(it => it.id === id));
|
|
47
|
+
if (missingIds.length === 0)
|
|
48
|
+
return;
|
|
49
|
+
try {
|
|
50
|
+
const res = await api.list(refModelLC, {
|
|
51
|
+
where: { id: { $in: missingIds } },
|
|
52
|
+
limit: missingIds.length,
|
|
53
|
+
});
|
|
54
|
+
const docs = (res?.data ?? res);
|
|
55
|
+
if (Array.isArray(docs)) {
|
|
56
|
+
const newItems = docs.map(d => ({ id: normalizeId(d), label: getLabel(d), ...d }));
|
|
57
|
+
setAllSeenItems(prev => {
|
|
58
|
+
const next = { ...prev };
|
|
59
|
+
for (const item of newItems) {
|
|
60
|
+
next[item.id] = item;
|
|
61
|
+
}
|
|
62
|
+
return next;
|
|
63
|
+
});
|
|
64
|
+
setItems(prev => {
|
|
65
|
+
const existingIds = new Set(prev.map(i => i.id));
|
|
66
|
+
const uniqueNew = newItems.filter(i => !existingIds.has(i.id));
|
|
67
|
+
return [...prev, ...uniqueNew];
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
console.error('Failed to hydrate RefSelect labels', err);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
if (refModelLC) {
|
|
76
|
+
fetchLabels();
|
|
77
|
+
}
|
|
78
|
+
}, [value, refModelLC, getLabel]); // Run once on value change if items missing
|
|
79
|
+
const loadMore = useCallback(async (isSearch = false) => {
|
|
80
|
+
if (isLoading || (!hasMore && !isSearch))
|
|
81
|
+
return;
|
|
82
|
+
setIsLoading(true);
|
|
83
|
+
try {
|
|
84
|
+
const effectivePageSize = Number(pageSize) || 20;
|
|
85
|
+
const currentOffset = isSearch ? 0 : offset;
|
|
86
|
+
const params = {
|
|
87
|
+
limit: effectivePageSize,
|
|
88
|
+
page: Math.floor(currentOffset / effectivePageSize) + 1,
|
|
89
|
+
sort: showKey,
|
|
90
|
+
sortType: 'asc',
|
|
91
|
+
};
|
|
92
|
+
if (searchFilter.length >= 1) {
|
|
93
|
+
params.where = {
|
|
94
|
+
...(params.where || {}),
|
|
95
|
+
[showKey]: { $regex: `^${searchFilter}`, $options: 'i' }
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (where) {
|
|
99
|
+
params.where = { ...(params.where || {}), ...where };
|
|
100
|
+
}
|
|
101
|
+
const res = await api.list(refModelLC, params);
|
|
102
|
+
const docs = (res?.data ?? res);
|
|
103
|
+
const normalized = Array.isArray(docs)
|
|
104
|
+
? docs.map(d => ({ id: normalizeId(d), label: getLabel(d), ...d }))
|
|
105
|
+
: [];
|
|
106
|
+
setAllSeenItems(prev => {
|
|
107
|
+
const next = { ...prev };
|
|
108
|
+
for (const item of normalized) {
|
|
109
|
+
next[item.id] = item;
|
|
110
|
+
}
|
|
111
|
+
return next;
|
|
112
|
+
});
|
|
113
|
+
setItems(prev => {
|
|
114
|
+
if (isSearch)
|
|
115
|
+
return normalized;
|
|
116
|
+
const existingIds = new Set(prev.map(i => i.id));
|
|
117
|
+
const uniqueNew = normalized.filter(i => !existingIds.has(i.id));
|
|
118
|
+
return [...prev, ...uniqueNew];
|
|
119
|
+
});
|
|
120
|
+
setHasMore(normalized.length === effectivePageSize);
|
|
121
|
+
setOffset(prev => isSearch ? effectivePageSize : prev + effectivePageSize);
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
console.error('Failed to load RefSelect items', err);
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
setIsLoading(false);
|
|
128
|
+
}
|
|
129
|
+
}, [isLoading, hasMore, offset, pageSize, searchFilter, refModelLC, showKey, getLabel]);
|
|
130
|
+
const [, scrollerRef] = useInfiniteScroll({
|
|
131
|
+
hasMore,
|
|
132
|
+
isEnabled: isOpen,
|
|
133
|
+
shouldUseLoader: true,
|
|
134
|
+
onLoadMore: () => loadMore(false),
|
|
135
|
+
});
|
|
136
|
+
const onOpenChange = (open) => {
|
|
137
|
+
setIsOpen(open);
|
|
138
|
+
if (open && items.length === 0) {
|
|
139
|
+
loadMore(true);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
const onInputChange = (e) => {
|
|
143
|
+
setSearchFilter(e.target.value);
|
|
144
|
+
};
|
|
145
|
+
// Debounced search
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (!isOpen)
|
|
148
|
+
return;
|
|
149
|
+
const timer = setTimeout(() => {
|
|
150
|
+
loadMore(true);
|
|
151
|
+
}, 300);
|
|
152
|
+
return () => clearTimeout(timer);
|
|
153
|
+
}, [searchFilter, isOpen]);
|
|
154
|
+
const onSelectionChange = (keys) => {
|
|
155
|
+
if (keys === 'all')
|
|
156
|
+
return;
|
|
157
|
+
const selectedIds = Array.from(keys).map(String);
|
|
158
|
+
if (multiple) {
|
|
159
|
+
onChange(selectedIds);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
onChange(selectedIds[0] || null);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
const removeValue = (idToRemove) => {
|
|
166
|
+
if (Array.isArray(value)) {
|
|
167
|
+
onChange(value.filter(id => id !== idToRemove));
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
const selectedKeys = useMemo(() => {
|
|
171
|
+
if (multiple) {
|
|
172
|
+
return new Set(Array.isArray(value) ? value.map(String) : []);
|
|
173
|
+
}
|
|
174
|
+
return (value ? new Set([String(value)]) : new Set());
|
|
175
|
+
}, [value, multiple]);
|
|
176
|
+
const selectedItems = useMemo(() => {
|
|
177
|
+
const ids = Array.isArray(value) ? value : value ? [value] : [];
|
|
178
|
+
return ids.map(id => {
|
|
179
|
+
const found = items.find(it => it.id === id);
|
|
180
|
+
if (found)
|
|
181
|
+
return found;
|
|
182
|
+
return { id: String(id), label: String(id) };
|
|
183
|
+
});
|
|
184
|
+
}, [value, items]);
|
|
185
|
+
const isValueEmpty = useMemo(() => {
|
|
186
|
+
if (multiple)
|
|
187
|
+
return !Array.isArray(value) || value.length === 0;
|
|
188
|
+
return !value;
|
|
189
|
+
}, [value, multiple]);
|
|
190
|
+
// Merge current search/paginated items with already selected items to ensure labels persist
|
|
191
|
+
const mergedDisplayItems = useMemo(() => {
|
|
192
|
+
const itemMap = new Map();
|
|
193
|
+
// 1. Add currently selected items (from allSeenItems cache)
|
|
194
|
+
const selectedIds = Array.isArray(value) ? value : value ? [value] : [];
|
|
195
|
+
for (const id of selectedIds) {
|
|
196
|
+
const stringId = String(id);
|
|
197
|
+
if (allSeenItems[stringId]) {
|
|
198
|
+
itemMap.set(stringId, allSeenItems[stringId]);
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
// Fallback for cases where label isn't loaded yet
|
|
202
|
+
itemMap.set(stringId, { id: stringId, label: stringId });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// 2. Add current results (highest priority for labels/data)
|
|
206
|
+
for (const item of items) {
|
|
207
|
+
itemMap.set(item.id, item);
|
|
208
|
+
}
|
|
209
|
+
return Array.from(itemMap.values());
|
|
210
|
+
}, [items, value, allSeenItems]);
|
|
211
|
+
return (_jsx("div", { className: `flex flex-col gap-2 ${className}`, children: _jsx(Select, { label: label, placeholder: `Select ${refModel}...`, variant: "bordered", labelPlacement: "outside", isMultiline: multiple, items: mergedDisplayItems, isLoading: isLoading, scrollRef: scrollerRef, selectionMode: multiple ? 'multiple' : 'single', selectedKeys: selectedKeys, onSelectionChange: onSelectionChange, onOpenChange: onOpenChange, isDisabled: disabled, isRequired: required, isInvalid: required && isValueEmpty, validationBehavior: "aria", description: description, className: "w-full", classNames: {
|
|
212
|
+
trigger: 'min-h-12 py-2',
|
|
213
|
+
}, scrollShadowProps: {
|
|
214
|
+
isEnabled: false,
|
|
215
|
+
}, listboxProps: {
|
|
216
|
+
topContent: (_jsx("div", { className: "px-2 pt-2 pb-1 sticky top-0 z-20 bg-content1 shadow-sm pb-2 -mt-2 -mx-1", children: _jsx(Input, { placeholder: "Search...", size: "sm", variant: "bordered", value: searchFilter, onChange: onInputChange, onClear: () => setSearchFilter(''), isClearable: true,
|
|
217
|
+
// Prevent closing when clicking search
|
|
218
|
+
onClick: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation(), endContent: isLoading ? _jsx(Spinner, { size: "sm" }) : null }) })),
|
|
219
|
+
}, renderValue: (items) => {
|
|
220
|
+
if (multiple) {
|
|
221
|
+
return (_jsx("div", { className: "flex flex-wrap gap-1", children: items.map((item) => (_jsx(Chip, { size: "sm", variant: "flat", children: item.textValue }, item.key))) }));
|
|
222
|
+
}
|
|
223
|
+
return items[0].textValue;
|
|
224
|
+
}, children: (item) => (_jsx(SelectItem, { textValue: item.label, children: item.label }, item.id)) }) }));
|
|
225
|
+
}
|