@airoom/nextmin-react 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/viewer/DynamicViewer.d.ts +7 -0
- package/dist/components/viewer/DynamicViewer.js +223 -0
- package/dist/components/viewer/useViewDrawer.d.ts +12 -0
- package/dist/components/viewer/useViewDrawer.js +24 -0
- package/dist/nextmin.css +1 -1
- package/dist/views/ListPage.js +61 -7
- package/dist/views/list/DataTableHero.d.ts +6 -1
- package/dist/views/list/DataTableHero.js +9 -3
- package/dist/views/list/jsonSummary.d.ts +6 -0
- package/dist/views/list/jsonSummary.js +165 -0
- package/package.json +1 -1
package/dist/views/ListPage.js
CHANGED
|
@@ -7,6 +7,7 @@ import { ListHeader } from './list/ListHeader';
|
|
|
7
7
|
import { DataTableHero } from './list/DataTableHero';
|
|
8
8
|
import { TableFilters } from '../components/TableFilters';
|
|
9
9
|
import { NoAccess } from '../components/NoAccess';
|
|
10
|
+
import { useViewDrawer } from '../components/viewer/useViewDrawer';
|
|
10
11
|
export function ListPage({ model }) {
|
|
11
12
|
// paging + filters
|
|
12
13
|
const [page, setPage] = useState(1); // 1-based
|
|
@@ -15,6 +16,25 @@ export function ListPage({ model }) {
|
|
|
15
16
|
// schema
|
|
16
17
|
const { items } = useSelector((s) => s.schemas);
|
|
17
18
|
const schema = useMemo(() => items.find((s) => s.modelName.toLowerCase() === model.toLowerCase()), [items, model]);
|
|
19
|
+
// If this model extends a base, build a merged view schema (base + child)
|
|
20
|
+
const viewSchema = useMemo(() => {
|
|
21
|
+
if (!schema)
|
|
22
|
+
return undefined;
|
|
23
|
+
const baseName = schema?.extends;
|
|
24
|
+
if (!baseName)
|
|
25
|
+
return schema;
|
|
26
|
+
const base = items.find((s) => s.modelName.toLowerCase() === baseName.toLowerCase());
|
|
27
|
+
if (!base)
|
|
28
|
+
return schema;
|
|
29
|
+
// Merge attributes with base first so child overrides win
|
|
30
|
+
return {
|
|
31
|
+
...schema,
|
|
32
|
+
attributes: {
|
|
33
|
+
...(base.attributes || {}),
|
|
34
|
+
...(schema.attributes || {}),
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}, [schema, items]);
|
|
18
38
|
// reset on model change
|
|
19
39
|
useEffect(() => {
|
|
20
40
|
setPage(1);
|
|
@@ -28,8 +48,24 @@ export function ListPage({ model }) {
|
|
|
28
48
|
});
|
|
29
49
|
// columns (ALWAYS call hooks; derive from schema or empty object)
|
|
30
50
|
const attributes = (schema?.attributes ?? {});
|
|
51
|
+
// Generic password detectors (hide from table columns as well)
|
|
52
|
+
const normKey = (k) => k.toLowerCase().replace(/[^a-z]/g, '');
|
|
53
|
+
const isPasswordKeyRaw = (k) => {
|
|
54
|
+
const n = normKey(k);
|
|
55
|
+
return n === 'password' || n === 'pwd' || n === 'passcode';
|
|
56
|
+
};
|
|
57
|
+
const isPasswordByAttr = (attr) => {
|
|
58
|
+
const a = (Array.isArray(attr) ? attr[0] : attr);
|
|
59
|
+
const t = String(a?.type ?? '').toLowerCase();
|
|
60
|
+
const f = String(a?.format ?? '').toLowerCase();
|
|
61
|
+
return t === 'password' || f === 'password';
|
|
62
|
+
};
|
|
31
63
|
const allColumns = useMemo(() => Object.entries(attributes)
|
|
32
|
-
.filter(([, a]) =>
|
|
64
|
+
.filter(([k, a]) => {
|
|
65
|
+
const base = (Array.isArray(a) ? a[0] : a);
|
|
66
|
+
const isPriv = !!base?.private;
|
|
67
|
+
return !isPriv && !isPasswordKeyRaw(k) && !isPasswordByAttr(a);
|
|
68
|
+
})
|
|
33
69
|
.map(([k]) => k), [attributes]);
|
|
34
70
|
// default visible: first 4 + 'createdAt' if present (stable via useMemo)
|
|
35
71
|
const defaultVisible = useMemo(() => {
|
|
@@ -55,6 +91,8 @@ export function ListPage({ model }) {
|
|
|
55
91
|
const handleRefetch = useCallback(async () => {
|
|
56
92
|
await refetch();
|
|
57
93
|
}, [refetch]);
|
|
94
|
+
// View drawer hook (render portal once here)
|
|
95
|
+
const { open: openView, ViewDrawerPortal } = useViewDrawer();
|
|
58
96
|
// Choose content WITHOUT early-returning before hooks
|
|
59
97
|
let content = null;
|
|
60
98
|
if (!schema) {
|
|
@@ -64,13 +102,29 @@ export function ListPage({ model }) {
|
|
|
64
102
|
content = (_jsx(NoAccess, { message: err ?? 'You are not permitted to view this resource.' }));
|
|
65
103
|
}
|
|
66
104
|
else {
|
|
67
|
-
content = (
|
|
68
|
-
|
|
105
|
+
content = (_jsxs("div", { className: "overflow-hidden", children: [_jsx(ViewDrawerPortal, {}), _jsx(DataTableHero, { topContent: _jsx(TableFilters, { model: model, value: filters, busy: loading, onChange: (v) => {
|
|
106
|
+
setFilters(v);
|
|
107
|
+
setPage(1);
|
|
108
|
+
}, columns: allColumns, visibleColumns: visibleColumns, onVisibleColumnsChange: (keys) => setVisibleColumns(new Set([...keys])) }), modelName: model, columns: tableColumns, rows: rows, total: total, page: page, pageSize: pageSize, onPageChange: setPage, onDeleted: handleRefetch, onPageSizeChange: (n) => {
|
|
109
|
+
setPageSize(n);
|
|
69
110
|
setPage(1);
|
|
70
|
-
},
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
111
|
+
}, baseHref: baseHref, loading: loading, error: err ?? undefined, schema: viewSchema, onRequestView: (row) => {
|
|
112
|
+
// If baseId is an object (already hydrated), merge its fields into the row for viewing
|
|
113
|
+
let dataToView = row;
|
|
114
|
+
const baseObj = row?.baseId;
|
|
115
|
+
if (baseObj && typeof baseObj === 'object') {
|
|
116
|
+
const merged = { ...baseObj, ...row };
|
|
117
|
+
// ensure linkage field doesn't show up
|
|
118
|
+
delete merged.baseId;
|
|
119
|
+
dataToView = merged;
|
|
120
|
+
}
|
|
121
|
+
openView({
|
|
122
|
+
title: viewSchema?.modelName ?? model,
|
|
123
|
+
model,
|
|
124
|
+
schema: viewSchema,
|
|
125
|
+
data: dataToView,
|
|
126
|
+
});
|
|
127
|
+
} })] }));
|
|
74
128
|
}
|
|
75
129
|
return (_jsxs("div", { className: "grid gap-3 px-4", children: [_jsx(ListHeader, { title: schema?.modelName ?? model, createHref: `${baseHref}/create`, loading: loading }), content] }));
|
|
76
130
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
+
import { SchemaDef } from '../../lib/types';
|
|
2
3
|
type Props = {
|
|
3
4
|
modelName: string;
|
|
4
5
|
columns: string[];
|
|
@@ -14,9 +15,13 @@ type Props = {
|
|
|
14
15
|
error?: string | null;
|
|
15
16
|
/** optional hook so parent can refetch or optimistically remove */
|
|
16
17
|
onDeleted?: (id: string) => void;
|
|
18
|
+
/** optional schema for dynamic viewing */
|
|
19
|
+
schema?: SchemaDef;
|
|
20
|
+
/** request to view a row in a drawer */
|
|
21
|
+
onRequestView?: (row: any) => void;
|
|
17
22
|
};
|
|
18
23
|
/** ---------- truncation helper ---------- */
|
|
19
24
|
export declare function truncateText(text: unknown, maxLength?: number): string;
|
|
20
25
|
/** ---------- component ---------- */
|
|
21
|
-
export declare function DataTableHero({ modelName, columns, rows, total, page, pageSize, onPageChange, onPageSizeChange, baseHref, loading, error, onDeleted, topContent, }: Props): import("react/jsx-runtime").JSX.Element;
|
|
26
|
+
export declare function DataTableHero({ modelName, columns, rows, total, page, pageSize, onPageChange, onPageSizeChange, baseHref, loading, error, onDeleted, topContent, schema, onRequestView, }: Props): import("react/jsx-runtime").JSX.Element;
|
|
22
27
|
export {};
|
|
@@ -4,6 +4,7 @@ import React from 'react';
|
|
|
4
4
|
import Link from 'next/link';
|
|
5
5
|
import { Table, TableHeader, TableBody, TableColumn, TableRow, TableCell, Pagination, Select, SelectItem, Skeleton, Button, Tooltip, Spinner, } from '@heroui/react';
|
|
6
6
|
import { formatCell } from './formatters';
|
|
7
|
+
import { summarizeAny } from './jsonSummary';
|
|
7
8
|
import { api } from '../../lib/api';
|
|
8
9
|
import { ConfirmDialog } from '../../components/ConfirmDialog';
|
|
9
10
|
/** ---------- helpers: image detection/rendering ---------- */
|
|
@@ -259,7 +260,7 @@ function formatShowValue(val) {
|
|
|
259
260
|
return null;
|
|
260
261
|
}
|
|
261
262
|
/** ---------- component ---------- */
|
|
262
|
-
export function DataTableHero({ modelName, columns, rows, total, page, pageSize, onPageChange, onPageSizeChange, baseHref, loading, error, onDeleted, topContent, }) {
|
|
263
|
+
export function DataTableHero({ modelName, columns, rows, total, page, pageSize, onPageChange, onPageSizeChange, baseHref, loading, error, onDeleted, topContent, schema, onRequestView, }) {
|
|
263
264
|
const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
|
|
264
265
|
const [pendingDeleteId, setPendingDeleteId] = React.useState(null);
|
|
265
266
|
const [isDeleting, setIsDeleting] = React.useState(false);
|
|
@@ -312,7 +313,7 @@ export function DataTableHero({ modelName, columns, rows, total, page, pageSize,
|
|
|
312
313
|
? typeVal.name
|
|
313
314
|
: '';
|
|
314
315
|
const isSystemRow = String(typeString ?? '').toLowerCase() === 'system';
|
|
315
|
-
return (_jsx(TableCell, { children: _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Tooltip, { content: isSystemRow
|
|
316
|
+
return (_jsx(TableCell, { children: _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Tooltip, { content: "View", children: _jsx(Button, { isIconOnly: true, size: "sm", variant: "light", "aria-label": "View", onPress: () => onRequestView?.(item), children: _jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false", children: [_jsx("path", { d: "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" }), _jsx("circle", { cx: "12", cy: "12", r: "3" })] }) }) }), _jsx(Tooltip, { content: isSystemRow
|
|
316
317
|
? 'System record — editing disabled'
|
|
317
318
|
: 'Edit', children: _jsx(Button, { as: isSystemRow ? undefined : Link, href: isSystemRow ? undefined : `${baseHref}/${item.id}`, isIconOnly: true, size: "sm", variant: "light", isDisabled: isSystemRow, "aria-disabled": isSystemRow, children: _jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false", children: [_jsx("path", { d: "M12 20h9" }), _jsx("path", { d: "M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" })] }) }) }), _jsx(Tooltip, { content: isSystemRow
|
|
318
319
|
? 'System record — deletion disabled'
|
|
@@ -338,7 +339,12 @@ export function DataTableHero({ modelName, columns, rows, total, page, pageSize,
|
|
|
338
339
|
if (showPretty) {
|
|
339
340
|
return (_jsx(TableCell, { children: truncateText(showPretty, 35) }, `${item.id ?? item._id}-${key}`));
|
|
340
341
|
}
|
|
341
|
-
// 3)
|
|
342
|
+
// 3) Generic JSON/array/object summarizer (supports raw JSON string too)
|
|
343
|
+
const jsonSummary = summarizeAny(rawVal);
|
|
344
|
+
if (typeof jsonSummary === 'string' && jsonSummary.length) {
|
|
345
|
+
return (_jsx(TableCell, { children: truncateText(jsonSummary, 35) }, `${item.id ?? item._id}-${key}`));
|
|
346
|
+
}
|
|
347
|
+
// 4) Existing formatter pipeline
|
|
342
348
|
const formatted = formatCell(rawVal, key);
|
|
343
349
|
const isSimple = typeof formatted === 'string' ||
|
|
344
350
|
typeof formatted === 'number';
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Generic JSON value summarizer for table cells and viewer
|
|
2
|
+
const HIDDEN_KEYS = new Set(['id', '_id', 'v', '__v', 'baseId', 'exId', '__childId']);
|
|
3
|
+
function normKey(k) {
|
|
4
|
+
return k.toLowerCase();
|
|
5
|
+
}
|
|
6
|
+
function isSecretKey(k) {
|
|
7
|
+
const n = normKey(k);
|
|
8
|
+
return (n === 'password' ||
|
|
9
|
+
n === 'pwd' ||
|
|
10
|
+
n.includes('password') ||
|
|
11
|
+
n.includes('passwd') ||
|
|
12
|
+
n.includes('passcode') ||
|
|
13
|
+
n.includes('secret'));
|
|
14
|
+
}
|
|
15
|
+
function toLabel(v) {
|
|
16
|
+
if (v == null)
|
|
17
|
+
return null;
|
|
18
|
+
if (typeof v === 'string')
|
|
19
|
+
return v.trim() || null;
|
|
20
|
+
if (typeof v === 'number' || typeof v === 'boolean')
|
|
21
|
+
return String(v);
|
|
22
|
+
if (typeof v === 'object') {
|
|
23
|
+
// common label keys
|
|
24
|
+
const candidates = ['name', 'title', 'label', 'display', 'show', 'value'];
|
|
25
|
+
for (const k of candidates) {
|
|
26
|
+
const val = v[k];
|
|
27
|
+
if (typeof val === 'string' && val.trim())
|
|
28
|
+
return val;
|
|
29
|
+
}
|
|
30
|
+
// keys ending with "name" (e.g., chamberName, specialityName)
|
|
31
|
+
for (const [k, val] of Object.entries(v)) {
|
|
32
|
+
if (typeof val !== 'string')
|
|
33
|
+
continue;
|
|
34
|
+
const nk = normKey(k);
|
|
35
|
+
if (nk.endsWith('name') && val.trim())
|
|
36
|
+
return val;
|
|
37
|
+
}
|
|
38
|
+
// id last
|
|
39
|
+
if (typeof v.id === 'string')
|
|
40
|
+
return v.id;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
function listLabels(arr) {
|
|
45
|
+
if (!Array.isArray(arr))
|
|
46
|
+
return null;
|
|
47
|
+
const labels = arr
|
|
48
|
+
.map((it) => toLabel(it))
|
|
49
|
+
.filter((s) => !!s && !!s.trim());
|
|
50
|
+
return labels.length ? labels : null;
|
|
51
|
+
}
|
|
52
|
+
function summarizeList(labels, max = 10) {
|
|
53
|
+
if (labels.length <= max)
|
|
54
|
+
return labels.join(', ');
|
|
55
|
+
const shown = labels.slice(0, max).join(', ');
|
|
56
|
+
const more = labels.length - max;
|
|
57
|
+
return `${shown}, +${more} more`;
|
|
58
|
+
}
|
|
59
|
+
const SUPPORT_PRIORITY = [
|
|
60
|
+
'address',
|
|
61
|
+
'location',
|
|
62
|
+
'district',
|
|
63
|
+
'area',
|
|
64
|
+
'city',
|
|
65
|
+
'visiting',
|
|
66
|
+
'hour',
|
|
67
|
+
'time',
|
|
68
|
+
'phone',
|
|
69
|
+
'mobile',
|
|
70
|
+
'number',
|
|
71
|
+
'fee',
|
|
72
|
+
'email',
|
|
73
|
+
'type',
|
|
74
|
+
];
|
|
75
|
+
function pickSupportingValues(obj, primaryKey) {
|
|
76
|
+
const out = [];
|
|
77
|
+
const entries = Object.entries(obj).filter(([k, v]) => {
|
|
78
|
+
if (HIDDEN_KEYS.has(k))
|
|
79
|
+
return false;
|
|
80
|
+
if (isSecretKey(k))
|
|
81
|
+
return false;
|
|
82
|
+
if (primaryKey && k === primaryKey)
|
|
83
|
+
return false;
|
|
84
|
+
return v != null && v !== '';
|
|
85
|
+
});
|
|
86
|
+
// rank by priority substring match, then by string length desc
|
|
87
|
+
const scored = entries
|
|
88
|
+
.map(([k, v]) => {
|
|
89
|
+
const nk = normKey(k);
|
|
90
|
+
const pri = SUPPORT_PRIORITY.findIndex((p) => nk.includes(p));
|
|
91
|
+
const score = pri >= 0 ? 100 - pri : 0; // higher better
|
|
92
|
+
const str = Array.isArray(v)
|
|
93
|
+
? (listLabels(v) || []).join(', ')
|
|
94
|
+
: typeof v === 'object'
|
|
95
|
+
? toLabel(v) || ''
|
|
96
|
+
: String(v);
|
|
97
|
+
return { k, str: str.trim(), score, len: str.length };
|
|
98
|
+
})
|
|
99
|
+
.filter((x) => !!x.str);
|
|
100
|
+
scored.sort((a, b) => {
|
|
101
|
+
if (b.score !== a.score)
|
|
102
|
+
return b.score - a.score;
|
|
103
|
+
return b.len - a.len;
|
|
104
|
+
});
|
|
105
|
+
for (const s of scored) {
|
|
106
|
+
out.push(s.str);
|
|
107
|
+
if (out.length >= 2)
|
|
108
|
+
break; // pick up to two
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
export function summarizeObject(obj) {
|
|
113
|
+
if (!obj || typeof obj !== 'object')
|
|
114
|
+
return null;
|
|
115
|
+
const primary = toLabel(obj);
|
|
116
|
+
if (!primary)
|
|
117
|
+
return null;
|
|
118
|
+
// try to find the actual key that produced primary
|
|
119
|
+
let primaryKey;
|
|
120
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
121
|
+
if (typeof v === 'string' && v === primary) {
|
|
122
|
+
primaryKey = k;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const supports = pickSupportingValues(obj, primaryKey);
|
|
127
|
+
const text = supports.length ? `${primary} — ${supports.join(', ')}` : primary;
|
|
128
|
+
return { text, primary: primaryKey };
|
|
129
|
+
}
|
|
130
|
+
export function summarizeArrayOfObjects(arr, maxItems = 10) {
|
|
131
|
+
const parts = [];
|
|
132
|
+
for (const it of arr) {
|
|
133
|
+
if (!it || typeof it !== 'object')
|
|
134
|
+
return null; // not uniform
|
|
135
|
+
const s = summarizeObject(it);
|
|
136
|
+
if (!s)
|
|
137
|
+
return null;
|
|
138
|
+
parts.push(s.text);
|
|
139
|
+
if (parts.length >= maxItems)
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
if (!parts.length)
|
|
143
|
+
return null;
|
|
144
|
+
const extra = arr.length - parts.length;
|
|
145
|
+
return extra > 0 ? `${parts.join(', ')}, +${extra} more` : parts.join(', ');
|
|
146
|
+
}
|
|
147
|
+
export function summarizeAny(val) {
|
|
148
|
+
if (val == null)
|
|
149
|
+
return null;
|
|
150
|
+
if (Array.isArray(val)) {
|
|
151
|
+
if (val.length === 0)
|
|
152
|
+
return '';
|
|
153
|
+
if (typeof val[0] === 'object') {
|
|
154
|
+
return summarizeArrayOfObjects(val);
|
|
155
|
+
}
|
|
156
|
+
// primitives
|
|
157
|
+
const labels = val.map((v) => (typeof v === 'object' ? toLabel(v) : String(v))).filter(Boolean);
|
|
158
|
+
return labels.length ? summarizeList(labels) : null;
|
|
159
|
+
}
|
|
160
|
+
if (typeof val === 'object') {
|
|
161
|
+
const s = summarizeObject(val);
|
|
162
|
+
return s?.text ?? null;
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|