@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.
Files changed (56) hide show
  1. package/README.md +29 -3
  2. package/dist/auth/SignInForm.js +4 -2
  3. package/dist/components/AdminApp.js +15 -38
  4. package/dist/components/ArchitectureDemo.d.ts +1 -0
  5. package/dist/components/ArchitectureDemo.js +45 -0
  6. package/dist/components/PhoneInput.d.ts +3 -0
  7. package/dist/components/PhoneInput.js +23 -19
  8. package/dist/components/RefSelect.d.ts +16 -0
  9. package/dist/components/RefSelect.js +225 -0
  10. package/dist/components/SchemaForm.js +125 -50
  11. package/dist/components/Sidebar.js +6 -13
  12. package/dist/components/TableFilters.js +2 -0
  13. package/dist/components/editor/TiptapEditor.js +1 -1
  14. package/dist/components/editor/Toolbar.js +13 -2
  15. package/dist/components/editor/components/DistrictGridModal.js +2 -3
  16. package/dist/components/editor/components/SchemaInsertionModal.js +2 -2
  17. package/dist/components/viewer/DynamicViewer.js +70 -9
  18. package/dist/hooks/useRealtime.d.ts +8 -0
  19. package/dist/hooks/useRealtime.js +30 -0
  20. package/dist/index.d.ts +6 -0
  21. package/dist/index.js +6 -0
  22. package/dist/lib/AuthClient.d.ts +15 -0
  23. package/dist/lib/AuthClient.js +63 -0
  24. package/dist/lib/QueryBuilder.d.ts +29 -0
  25. package/dist/lib/QueryBuilder.js +74 -0
  26. package/dist/lib/RealtimeClient.d.ts +16 -0
  27. package/dist/lib/RealtimeClient.js +56 -0
  28. package/dist/lib/api.d.ts +15 -3
  29. package/dist/lib/api.js +71 -58
  30. package/dist/lib/auth.js +7 -2
  31. package/dist/lib/types.d.ts +16 -0
  32. package/dist/nextmin.css +1 -1
  33. package/dist/providers/NextMinProvider.d.ts +8 -1
  34. package/dist/providers/NextMinProvider.js +40 -8
  35. package/dist/router/NextMinRouter.d.ts +1 -1
  36. package/dist/router/NextMinRouter.js +1 -1
  37. package/dist/state/schemasSlice.js +4 -27
  38. package/dist/views/DashboardPage.js +56 -42
  39. package/dist/views/ListPage.js +34 -4
  40. package/dist/views/SettingsEdit.js +25 -2
  41. package/dist/views/list/DataTableHero.js +103 -46
  42. package/dist/views/list/ListHeader.d.ts +3 -1
  43. package/dist/views/list/ListHeader.js +2 -2
  44. package/dist/views/list/jsonSummary.d.ts +3 -3
  45. package/dist/views/list/jsonSummary.js +47 -20
  46. package/dist/views/list/useListData.js +5 -1
  47. package/package.json +8 -4
  48. package/dist/components/RefMultiSelect.d.ts +0 -22
  49. package/dist/components/RefMultiSelect.js +0 -113
  50. package/dist/components/RefSingleSelect.d.ts +0 -17
  51. package/dist/components/RefSingleSelect.js +0 -110
  52. package/dist/lib/schemaService.d.ts +0 -2
  53. package/dist/lib/schemaService.js +0 -39
  54. package/dist/state/schemaLive.d.ts +0 -2
  55. package/dist/state/schemaLive.js +0 -19
  56. /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 opens a live socket
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
- - Built‑in router: `<NextMinRouter>` and `<AdminRouteNormalizer>` for admin routes
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 <NextMinProvider>{children}</NextMinProvider>;
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
 
@@ -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
- localStorage.setItem('nextmin.token', token);
26
- localStorage.setItem('nextmin.user', JSON.stringify(user));
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
- if (typeof window === 'undefined')
40
- return;
41
- try {
42
- const t = localStorage.getItem('nextmin.token');
43
- const u = localStorage.getItem('nextmin.user');
44
- if (t && u) {
45
- if (isJwtValid(t)) {
46
- dispatch(setSession({ token: t, user: JSON.parse(u) }));
47
- }
48
- else {
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
- if (typeof window === 'undefined')
95
- return null;
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
+ }
@@ -19,5 +19,8 @@ export interface PhoneInputProps {
19
19
  classNames?: {
20
20
  inputWrapper?: string;
21
21
  };
22
+ minLength?: number;
23
+ maxLength?: number;
24
+ regex?: string;
22
25
  }
23
26
  export declare const PhoneInput: React.FC<PhoneInputProps>;
@@ -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) => String(s ?? '').replace(/\D/g, '');
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
- onChange(pastedDigits.slice(0, maxDigits));
143
- const nextCaret = caretPosForDigitCount(normMask, Math.min(pastedDigits.length, maxDigits));
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
+ }