@airoom/nextmin-react 1.1.0 → 1.3.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 +274 -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 +53 -3
- package/dist/views/list/formatters.js +0 -29
- 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 ---------- */
|
|
@@ -258,8 +259,45 @@ function formatShowValue(val) {
|
|
|
258
259
|
}
|
|
259
260
|
return null;
|
|
260
261
|
}
|
|
262
|
+
/** ---------- Date formatting for createdAt/updatedAt ---------- */
|
|
263
|
+
function isDateField(key) {
|
|
264
|
+
const k = key.toLowerCase().replace(/[^a-z]/g, '');
|
|
265
|
+
return k === 'createdat' || k === 'updatedat' || k === 'deletedat';
|
|
266
|
+
}
|
|
267
|
+
function tryParseDate(val) {
|
|
268
|
+
if (val == null)
|
|
269
|
+
return null;
|
|
270
|
+
if (val instanceof Date)
|
|
271
|
+
return isNaN(val.getTime()) ? null : val;
|
|
272
|
+
// ONLY parse ISO-like strings, NOT numbers (to avoid phone numbers, experience years, etc.)
|
|
273
|
+
if (typeof val === 'string') {
|
|
274
|
+
const s = val.trim();
|
|
275
|
+
// Must look like an ISO date: YYYY-MM-DD or contain 'T' or end with 'Z'
|
|
276
|
+
const looksIso = /^\d{4}-\d{2}-\d{2}/.test(s) || s.includes('T') || s.endsWith('Z');
|
|
277
|
+
if (!looksIso)
|
|
278
|
+
return null;
|
|
279
|
+
const d = new Date(s);
|
|
280
|
+
return isNaN(d.getTime()) ? null : d;
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
function formatDateLocal(d) {
|
|
285
|
+
try {
|
|
286
|
+
return d.toLocaleString(undefined, {
|
|
287
|
+
year: 'numeric',
|
|
288
|
+
month: 'short',
|
|
289
|
+
day: '2-digit',
|
|
290
|
+
hour: '2-digit',
|
|
291
|
+
minute: '2-digit',
|
|
292
|
+
hour12: true,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
return d.toString();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
261
299
|
/** ---------- component ---------- */
|
|
262
|
-
export function DataTableHero({ modelName, columns, rows, total, page, pageSize, onPageChange, onPageSizeChange, baseHref, loading, error, onDeleted, topContent, }) {
|
|
300
|
+
export function DataTableHero({ modelName, columns, rows, total, page, pageSize, onPageChange, onPageSizeChange, baseHref, loading, error, onDeleted, topContent, schema, onRequestView, }) {
|
|
263
301
|
const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
|
|
264
302
|
const [pendingDeleteId, setPendingDeleteId] = React.useState(null);
|
|
265
303
|
const [isDeleting, setIsDeleting] = React.useState(false);
|
|
@@ -312,7 +350,7 @@ export function DataTableHero({ modelName, columns, rows, total, page, pageSize,
|
|
|
312
350
|
? typeVal.name
|
|
313
351
|
: '';
|
|
314
352
|
const isSystemRow = String(typeString ?? '').toLowerCase() === 'system';
|
|
315
|
-
return (_jsx(TableCell, { children: _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Tooltip, { content: isSystemRow
|
|
353
|
+
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
354
|
? 'System record — editing disabled'
|
|
317
355
|
: '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
356
|
? 'System record — deletion disabled'
|
|
@@ -328,6 +366,13 @@ export function DataTableHero({ modelName, columns, rows, total, page, pageSize,
|
|
|
328
366
|
// Prepare raw value (parse JSON-like strings first)
|
|
329
367
|
const rawRawVal = item[key];
|
|
330
368
|
const rawVal = maybeParseJson(rawRawVal);
|
|
369
|
+
// 0) Date fields (createdAt, updatedAt, deletedAt)
|
|
370
|
+
if (isDateField(key)) {
|
|
371
|
+
const d = tryParseDate(rawVal);
|
|
372
|
+
if (d) {
|
|
373
|
+
return (_jsx(TableCell, { children: formatDateLocal(d) }, `${item.id ?? item._id}-${key}`));
|
|
374
|
+
}
|
|
375
|
+
}
|
|
331
376
|
// 1) Time-only ranges like "15:00..00:00 17:00..23:00"
|
|
332
377
|
const timeRangePretty = formatTimeOnlyRanges(rawVal);
|
|
333
378
|
if (timeRangePretty) {
|
|
@@ -338,7 +383,12 @@ export function DataTableHero({ modelName, columns, rows, total, page, pageSize,
|
|
|
338
383
|
if (showPretty) {
|
|
339
384
|
return (_jsx(TableCell, { children: truncateText(showPretty, 35) }, `${item.id ?? item._id}-${key}`));
|
|
340
385
|
}
|
|
341
|
-
// 3)
|
|
386
|
+
// 3) Generic JSON/array/object summarizer (supports raw JSON string too)
|
|
387
|
+
const jsonSummary = summarizeAny(rawVal);
|
|
388
|
+
if (typeof jsonSummary === 'string' && jsonSummary.length) {
|
|
389
|
+
return (_jsx(TableCell, { children: truncateText(jsonSummary, 35) }, `${item.id ?? item._id}-${key}`));
|
|
390
|
+
}
|
|
391
|
+
// 4) Existing formatter pipeline
|
|
342
392
|
const formatted = formatCell(rawVal, key);
|
|
343
393
|
const isSimple = typeof formatted === 'string' ||
|
|
344
394
|
typeof formatted === 'number';
|
|
@@ -1,26 +1,3 @@
|
|
|
1
|
-
function isCreatedOrUpdated(key) {
|
|
2
|
-
const k = (key || '').toLowerCase().replace(/[^a-z]/g, '');
|
|
3
|
-
return k === 'createdat' || k === 'updatedat';
|
|
4
|
-
}
|
|
5
|
-
function tryParseDate(val) {
|
|
6
|
-
if (val == null)
|
|
7
|
-
return null;
|
|
8
|
-
// Handle numeric timestamps (ms or s)
|
|
9
|
-
if (typeof val === 'number') {
|
|
10
|
-
const ms = val < 1e11 ? val * 1000 : val; // heuristic: 10/13-digit
|
|
11
|
-
const d = new Date(ms);
|
|
12
|
-
return isNaN(d.getTime()) ? null : d;
|
|
13
|
-
}
|
|
14
|
-
// ISO string / Date
|
|
15
|
-
const d = new Date(val);
|
|
16
|
-
return isNaN(d.getTime()) ? null : d;
|
|
17
|
-
}
|
|
18
|
-
function formatLongDate(d) {
|
|
19
|
-
const day = d.getDate(); // no leading zero: "1 August, 2025"
|
|
20
|
-
const month = d.toLocaleString('en', { month: 'long' });
|
|
21
|
-
const year = d.getFullYear();
|
|
22
|
-
return `${day} ${month}, ${year}`; // "13 August, 2025"
|
|
23
|
-
}
|
|
24
1
|
export function formatAtom(v) {
|
|
25
2
|
if (v == null)
|
|
26
3
|
return '';
|
|
@@ -48,12 +25,6 @@ export function formatAtom(v) {
|
|
|
48
25
|
export function formatCell(val, key) {
|
|
49
26
|
if (val == null)
|
|
50
27
|
return '';
|
|
51
|
-
// Only format dates for createdAt / updatedAt columns
|
|
52
|
-
if (isCreatedOrUpdated(key)) {
|
|
53
|
-
const d = tryParseDate(val);
|
|
54
|
-
if (d)
|
|
55
|
-
return formatLongDate(d);
|
|
56
|
-
}
|
|
57
28
|
if (Array.isArray(val))
|
|
58
29
|
return val.map(formatAtom).join(', ');
|
|
59
30
|
if (typeof val === 'object')
|
|
@@ -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', 'createdAt', 'updatedAt', 'deletedAt']);
|
|
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
|
+
}
|