@rebasepro/ui 0.6.1 → 0.7.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/icons/index.d.ts +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +2058 -16
- package/dist/index.es.js.map +1 -1
- package/dist/styles.d.ts +1 -1
- package/dist/views/CardView.d.ts +32 -0
- package/dist/views/CollectionView/CollectionCardView.d.ts +30 -0
- package/dist/views/CollectionView/CollectionKanbanView.d.ts +31 -0
- package/dist/views/CollectionView/CollectionListView.d.ts +30 -0
- package/dist/views/CollectionView/CollectionTableView.d.ts +37 -0
- package/dist/views/CollectionView/CollectionView.d.ts +121 -0
- package/dist/views/CollectionView/CollectionViewToolbar.d.ts +31 -0
- package/dist/views/CollectionView/CollectionViewTypes.d.ts +106 -0
- package/dist/views/CollectionView/DefaultCellRenderer.d.ts +9 -0
- package/dist/views/CollectionView/index.d.ts +24 -0
- package/dist/views/CollectionView/utils.d.ts +5 -0
- package/dist/views/Kanban/Board.d.ts +3 -0
- package/dist/views/Kanban/BoardColumn.d.ts +20 -0
- package/dist/views/Kanban/BoardColumnTitle.d.ts +8 -0
- package/dist/views/Kanban/BoardSortableList.d.ts +14 -0
- package/dist/views/Kanban/board_types.d.ts +61 -0
- package/dist/views/Kanban/index.d.ts +5 -0
- package/dist/views/KanbanView.d.ts +24 -0
- package/dist/views/ListView.d.ts +40 -0
- package/dist/views/TableView.d.ts +6 -0
- package/dist/views/index.d.ts +5 -0
- package/package.json +1 -1
- package/src/components/Sheet.tsx +8 -4
- package/src/icons/index.ts +3 -0
- package/src/index.ts +2 -0
- package/src/styles.ts +1 -1
- package/src/views/CardView.tsx +281 -0
- package/src/views/CollectionView/CollectionCardView.tsx +270 -0
- package/src/views/CollectionView/CollectionKanbanView.tsx +251 -0
- package/src/views/CollectionView/CollectionListView.tsx +245 -0
- package/src/views/CollectionView/CollectionTableView.tsx +229 -0
- package/src/views/CollectionView/CollectionView.tsx +388 -0
- package/src/views/CollectionView/CollectionViewToolbar.tsx +229 -0
- package/src/views/CollectionView/CollectionViewTypes.ts +137 -0
- package/src/views/CollectionView/DefaultCellRenderer.tsx +404 -0
- package/src/views/CollectionView/index.ts +44 -0
- package/src/views/CollectionView/utils.ts +14 -0
- package/src/views/Kanban/Board.tsx +421 -0
- package/src/views/Kanban/BoardColumn.tsx +135 -0
- package/src/views/Kanban/BoardColumnTitle.tsx +47 -0
- package/src/views/Kanban/BoardSortableList.tsx +170 -0
- package/src/views/Kanban/board_types.ts +70 -0
- package/src/views/Kanban/index.tsx +5 -0
- package/src/views/KanbanView.tsx +32 -0
- package/src/views/ListView.tsx +281 -0
- package/src/views/TableView.tsx +7 -0
- package/src/views/index.ts +5 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { ImageIcon } from "lucide-react";
|
|
3
|
+
import { cls } from "../../util";
|
|
4
|
+
import { Typography } from "../../components/Typography";
|
|
5
|
+
import { Chip } from "../../components/Chip";
|
|
6
|
+
import { BooleanSwitch } from "../../components/BooleanSwitch";
|
|
7
|
+
import { Markdown } from "../../components/Markdown";
|
|
8
|
+
import { Tooltip } from "../../components/Tooltip";
|
|
9
|
+
import { iconSize } from "../../icons";
|
|
10
|
+
import type { CellRendererProps, CollectionPropertyConfig, CollectionEnumValueConfig } from "./CollectionViewTypes";
|
|
11
|
+
|
|
12
|
+
// ——— Helpers ———
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolve an enum key to its display label and optional color.
|
|
16
|
+
*/
|
|
17
|
+
function resolveEnumLabel(
|
|
18
|
+
enumValues: CollectionPropertyConfig["enum"],
|
|
19
|
+
value: unknown
|
|
20
|
+
): { label: string; color?: string } | undefined {
|
|
21
|
+
if (!enumValues) return undefined;
|
|
22
|
+
const key = String(value);
|
|
23
|
+
if (enumValues instanceof Map) {
|
|
24
|
+
const config: CollectionEnumValueConfig | undefined = enumValues.get(value as string | number);
|
|
25
|
+
if (!config) return undefined;
|
|
26
|
+
if (typeof config === "string") return { label: config };
|
|
27
|
+
return { label: config.label, color: config.color };
|
|
28
|
+
}
|
|
29
|
+
const config: CollectionEnumValueConfig | undefined = enumValues[key];
|
|
30
|
+
if (!config) return undefined;
|
|
31
|
+
if (typeof config === "string") return { label: config };
|
|
32
|
+
return { label: config.label, color: config.color };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Format a date value using Intl.DateTimeFormat.
|
|
37
|
+
*/
|
|
38
|
+
function formatDate(value: unknown, includeTime: boolean): string {
|
|
39
|
+
if (value == null) return "—";
|
|
40
|
+
const date = value instanceof Date ? value : new Date(String(value));
|
|
41
|
+
if (isNaN(date.getTime())) return String(value);
|
|
42
|
+
|
|
43
|
+
const options: Intl.DateTimeFormatOptions = includeTime
|
|
44
|
+
? { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }
|
|
45
|
+
: { year: "numeric", month: "short", day: "numeric" };
|
|
46
|
+
|
|
47
|
+
return new Intl.DateTimeFormat(undefined, options).format(date);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Type guard for geopoint-like objects.
|
|
52
|
+
*/
|
|
53
|
+
function isGeoPoint(value: unknown): value is { lat: number; lng: number } {
|
|
54
|
+
if (typeof value !== "object" || value === null) return false;
|
|
55
|
+
const obj = value as Record<string, unknown>;
|
|
56
|
+
return typeof obj.lat === "number" && typeof obj.lng === "number";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ——— Component ———
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Default cell renderer for the headless CollectionView.
|
|
63
|
+
* Handles all built-in property types without depending on any Rebase core types.
|
|
64
|
+
*
|
|
65
|
+
* @group Preview components
|
|
66
|
+
*/
|
|
67
|
+
export function DefaultCellRenderer({
|
|
68
|
+
row,
|
|
69
|
+
propertyKey,
|
|
70
|
+
property,
|
|
71
|
+
value,
|
|
72
|
+
size,
|
|
73
|
+
width,
|
|
74
|
+
height
|
|
75
|
+
}: CellRendererProps) {
|
|
76
|
+
|
|
77
|
+
// 1. Null / undefined → em-dash
|
|
78
|
+
if (value === null || value === undefined) {
|
|
79
|
+
return (
|
|
80
|
+
<Typography variant="body2"
|
|
81
|
+
color="secondary"
|
|
82
|
+
className="px-1">
|
|
83
|
+
—
|
|
84
|
+
</Typography>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 2. Custom Preview component override
|
|
89
|
+
if (property.Preview) {
|
|
90
|
+
const PreviewComponent = property.Preview;
|
|
91
|
+
return (
|
|
92
|
+
<PreviewComponent
|
|
93
|
+
row={row}
|
|
94
|
+
propertyKey={propertyKey}
|
|
95
|
+
property={property}
|
|
96
|
+
value={value}
|
|
97
|
+
size={size}
|
|
98
|
+
width={width}
|
|
99
|
+
height={height}
|
|
100
|
+
/>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ——— Type-specific renderers ———
|
|
105
|
+
|
|
106
|
+
switch (property.type) {
|
|
107
|
+
case "string":
|
|
108
|
+
return renderString(property, value);
|
|
109
|
+
|
|
110
|
+
case "number":
|
|
111
|
+
return renderNumber(property, value);
|
|
112
|
+
|
|
113
|
+
case "boolean":
|
|
114
|
+
return renderBoolean(value);
|
|
115
|
+
|
|
116
|
+
case "date":
|
|
117
|
+
return renderDate(property, value);
|
|
118
|
+
|
|
119
|
+
case "array":
|
|
120
|
+
return renderArray(property, value);
|
|
121
|
+
|
|
122
|
+
case "map":
|
|
123
|
+
return renderMap(value);
|
|
124
|
+
|
|
125
|
+
case "reference":
|
|
126
|
+
case "relation":
|
|
127
|
+
return renderReference(value);
|
|
128
|
+
|
|
129
|
+
case "geopoint":
|
|
130
|
+
return renderGeopoint(value);
|
|
131
|
+
|
|
132
|
+
default:
|
|
133
|
+
return (
|
|
134
|
+
<Typography variant="body2"
|
|
135
|
+
color="secondary"
|
|
136
|
+
noWrap
|
|
137
|
+
className="px-1">
|
|
138
|
+
{JSON.stringify(value)}
|
|
139
|
+
</Typography>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ——— Per-type render functions ———
|
|
145
|
+
|
|
146
|
+
function renderString(property: CollectionPropertyConfig, value: unknown): React.ReactElement {
|
|
147
|
+
const strValue = String(value);
|
|
148
|
+
|
|
149
|
+
// Enum → Chip
|
|
150
|
+
if (property.enum) {
|
|
151
|
+
const resolved = resolveEnumLabel(property.enum, value);
|
|
152
|
+
return (
|
|
153
|
+
<Chip size="small"
|
|
154
|
+
colorScheme={resolved?.color ? { color: resolved.color, text: "#fff" } : undefined}>
|
|
155
|
+
{resolved?.label ?? strValue}
|
|
156
|
+
</Chip>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Preview as tag → Chip
|
|
161
|
+
if (property.previewAsTag) {
|
|
162
|
+
return (
|
|
163
|
+
<Chip size="small">
|
|
164
|
+
{strValue}
|
|
165
|
+
</Chip>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// URL → clickable link
|
|
170
|
+
if (property.url) {
|
|
171
|
+
const href = typeof property.url === "string" ? property.url : strValue;
|
|
172
|
+
return (
|
|
173
|
+
<Typography variant="body2"
|
|
174
|
+
noWrap
|
|
175
|
+
component="a"
|
|
176
|
+
href={href}
|
|
177
|
+
target="_blank"
|
|
178
|
+
rel="noopener noreferrer"
|
|
179
|
+
className={cls(
|
|
180
|
+
"px-1 text-primary hover:underline cursor-pointer"
|
|
181
|
+
)}>
|
|
182
|
+
{strValue}
|
|
183
|
+
</Typography>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Email → mailto link
|
|
188
|
+
if (property.email) {
|
|
189
|
+
return (
|
|
190
|
+
<Typography variant="body2"
|
|
191
|
+
noWrap
|
|
192
|
+
component="a"
|
|
193
|
+
href={`mailto:${strValue}`}
|
|
194
|
+
className={cls(
|
|
195
|
+
"px-1 text-primary hover:underline cursor-pointer"
|
|
196
|
+
)}>
|
|
197
|
+
{strValue}
|
|
198
|
+
</Typography>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Markdown → rendered markdown
|
|
203
|
+
if (property.markdown) {
|
|
204
|
+
return (
|
|
205
|
+
<div className="px-1 max-h-[100px] overflow-hidden">
|
|
206
|
+
<Markdown source={strValue} size="small" />
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Storage → image preview with fallback
|
|
212
|
+
if (property.storage) {
|
|
213
|
+
return (
|
|
214
|
+
<div className={cls(
|
|
215
|
+
"px-1 flex items-center justify-center",
|
|
216
|
+
"w-[40px] h-[40px] rounded overflow-hidden"
|
|
217
|
+
)}>
|
|
218
|
+
{strValue ? (
|
|
219
|
+
<img
|
|
220
|
+
src={strValue}
|
|
221
|
+
alt=""
|
|
222
|
+
className="w-full h-full object-cover"
|
|
223
|
+
onError={(e) => {
|
|
224
|
+
const target = e.currentTarget;
|
|
225
|
+
target.style.display = "none";
|
|
226
|
+
const fallback = target.nextElementSibling;
|
|
227
|
+
if (fallback instanceof HTMLElement) {
|
|
228
|
+
fallback.style.display = "flex";
|
|
229
|
+
}
|
|
230
|
+
}}
|
|
231
|
+
/>
|
|
232
|
+
) : null}
|
|
233
|
+
<div className={cls(
|
|
234
|
+
"w-full h-full items-center justify-center rounded",
|
|
235
|
+
"bg-surface-100 dark:bg-surface-800 text-text-secondary dark:text-text-secondary-dark",
|
|
236
|
+
strValue ? "hidden" : "flex"
|
|
237
|
+
)}>
|
|
238
|
+
<ImageIcon size={iconSize.small} />
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Default string → truncated text
|
|
245
|
+
return (
|
|
246
|
+
<Tooltip title={strValue.length > 50 ? strValue : undefined}
|
|
247
|
+
side="bottom">
|
|
248
|
+
<Typography variant="body2"
|
|
249
|
+
noWrap
|
|
250
|
+
className="px-1">
|
|
251
|
+
{strValue}
|
|
252
|
+
</Typography>
|
|
253
|
+
</Tooltip>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function renderNumber(property: CollectionPropertyConfig, value: unknown): React.ReactElement {
|
|
258
|
+
const numStr = String(value);
|
|
259
|
+
|
|
260
|
+
// Enum → Chip
|
|
261
|
+
if (property.enum) {
|
|
262
|
+
const resolved = resolveEnumLabel(property.enum, value);
|
|
263
|
+
return (
|
|
264
|
+
<Chip size="small"
|
|
265
|
+
colorScheme={resolved?.color ? { color: resolved.color, text: "#fff" } : undefined}>
|
|
266
|
+
{resolved?.label ?? numStr}
|
|
267
|
+
</Chip>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Default → right-aligned
|
|
272
|
+
return (
|
|
273
|
+
<Typography variant="body2"
|
|
274
|
+
noWrap
|
|
275
|
+
align="right"
|
|
276
|
+
className="px-1 tabular-nums">
|
|
277
|
+
{numStr}
|
|
278
|
+
</Typography>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function renderBoolean(value: unknown): React.ReactElement {
|
|
283
|
+
return (
|
|
284
|
+
<div className="px-1 flex items-center">
|
|
285
|
+
<BooleanSwitch value={!!value}
|
|
286
|
+
size="small"
|
|
287
|
+
disabled />
|
|
288
|
+
</div>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function renderDate(property: CollectionPropertyConfig, value: unknown): React.ReactElement {
|
|
293
|
+
const includeTime = property.mode === "date_time";
|
|
294
|
+
const formatted = formatDate(value, includeTime);
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
<Typography variant="body2"
|
|
298
|
+
noWrap
|
|
299
|
+
className="px-1 tabular-nums">
|
|
300
|
+
{formatted}
|
|
301
|
+
</Typography>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function renderArray(property: CollectionPropertyConfig, value: unknown): React.ReactElement {
|
|
306
|
+
if (!Array.isArray(value)) {
|
|
307
|
+
return (
|
|
308
|
+
<Typography variant="body2"
|
|
309
|
+
color="secondary"
|
|
310
|
+
className="px-1">
|
|
311
|
+
—
|
|
312
|
+
</Typography>
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Array of enum values → row of chips
|
|
317
|
+
if (property.of?.enum && value.length > 0) {
|
|
318
|
+
return (
|
|
319
|
+
<div className="px-1 flex flex-wrap gap-1 overflow-hidden">
|
|
320
|
+
{value.map((item, i) => {
|
|
321
|
+
const resolved = resolveEnumLabel(property.of!.enum, item);
|
|
322
|
+
return (
|
|
323
|
+
<Chip key={i}
|
|
324
|
+
size="smallest"
|
|
325
|
+
colorScheme={resolved?.color ? { color: resolved.color, text: "#fff" } : undefined}>
|
|
326
|
+
{resolved?.label ?? String(item)}
|
|
327
|
+
</Chip>
|
|
328
|
+
);
|
|
329
|
+
})}
|
|
330
|
+
</div>
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Array of strings → comma-separated
|
|
335
|
+
if (value.length > 0 && value.every((item) => typeof item === "string")) {
|
|
336
|
+
return (
|
|
337
|
+
<Typography variant="body2"
|
|
338
|
+
noWrap
|
|
339
|
+
className="px-1">
|
|
340
|
+
{(value as string[]).join(", ")}
|
|
341
|
+
</Typography>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Generic array → item count
|
|
346
|
+
return (
|
|
347
|
+
<Typography variant="body2"
|
|
348
|
+
color="secondary"
|
|
349
|
+
className="px-1">
|
|
350
|
+
{value.length} {value.length === 1 ? "item" : "items"}
|
|
351
|
+
</Typography>
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function renderMap(value: unknown): React.ReactElement {
|
|
356
|
+
if (typeof value !== "object" || value === null) {
|
|
357
|
+
return (
|
|
358
|
+
<Typography variant="body2"
|
|
359
|
+
color="secondary"
|
|
360
|
+
className="px-1">
|
|
361
|
+
—
|
|
362
|
+
</Typography>
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const fieldCount = Object.keys(value as Record<string, unknown>).length;
|
|
367
|
+
return (
|
|
368
|
+
<Typography variant="body2"
|
|
369
|
+
color="secondary"
|
|
370
|
+
className="px-1">
|
|
371
|
+
{fieldCount} {fieldCount === 1 ? "field" : "fields"}
|
|
372
|
+
</Typography>
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function renderReference(value: unknown): React.ReactElement {
|
|
377
|
+
return (
|
|
378
|
+
<Typography variant="body2"
|
|
379
|
+
noWrap
|
|
380
|
+
className="px-1">
|
|
381
|
+
{String(value)}
|
|
382
|
+
</Typography>
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function renderGeopoint(value: unknown): React.ReactElement {
|
|
387
|
+
if (isGeoPoint(value)) {
|
|
388
|
+
return (
|
|
389
|
+
<Typography variant="body2"
|
|
390
|
+
noWrap
|
|
391
|
+
className="px-1 tabular-nums">
|
|
392
|
+
{value.lat.toFixed(6)}, {value.lng.toFixed(6)}
|
|
393
|
+
</Typography>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return (
|
|
398
|
+
<Typography variant="body2"
|
|
399
|
+
color="secondary"
|
|
400
|
+
className="px-1">
|
|
401
|
+
{String(value)}
|
|
402
|
+
</Typography>
|
|
403
|
+
);
|
|
404
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless Collection View components.
|
|
3
|
+
*
|
|
4
|
+
* Data-agnostic collection rendering — table, card, list, and kanban views
|
|
5
|
+
* driven entirely by property configurations and callbacks.
|
|
6
|
+
*
|
|
7
|
+
* Zero imports from any entity or data layer.
|
|
8
|
+
*
|
|
9
|
+
* @module CollectionView
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Types
|
|
13
|
+
export type {
|
|
14
|
+
CollectionViewMode,
|
|
15
|
+
CollectionViewSize,
|
|
16
|
+
CollectionEnumValueConfig,
|
|
17
|
+
CollectionPropertyConfig,
|
|
18
|
+
CollectionDataController,
|
|
19
|
+
CellRendererProps,
|
|
20
|
+
CellRendererOverride,
|
|
21
|
+
CollectionSelectionController,
|
|
22
|
+
KanbanPropertyOption
|
|
23
|
+
} from "./CollectionViewTypes";
|
|
24
|
+
|
|
25
|
+
// Components
|
|
26
|
+
export { CollectionView } from "./CollectionView";
|
|
27
|
+
export type { CollectionViewProps } from "./CollectionView";
|
|
28
|
+
|
|
29
|
+
export { CollectionViewToolbar } from "./CollectionViewToolbar";
|
|
30
|
+
export type { CollectionViewToolbarProps } from "./CollectionViewToolbar";
|
|
31
|
+
|
|
32
|
+
export { CollectionTableView } from "./CollectionTableView";
|
|
33
|
+
export type { CollectionTableViewProps } from "./CollectionTableView";
|
|
34
|
+
|
|
35
|
+
export { CollectionCardView } from "./CollectionCardView";
|
|
36
|
+
export type { CollectionCardViewProps } from "./CollectionCardView";
|
|
37
|
+
|
|
38
|
+
export { CollectionListView } from "./CollectionListView";
|
|
39
|
+
export type { CollectionListViewProps } from "./CollectionListView";
|
|
40
|
+
|
|
41
|
+
export { CollectionKanbanView } from "./CollectionKanbanView";
|
|
42
|
+
export type { CollectionKanbanViewProps } from "./CollectionKanbanView";
|
|
43
|
+
|
|
44
|
+
export { DefaultCellRenderer } from "./DefaultCellRenderer";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Traverse an object by dot-separated path.
|
|
3
|
+
* Returns `undefined` if any segment is missing.
|
|
4
|
+
*/
|
|
5
|
+
export function getValueInPath(obj: Record<string, unknown> | undefined, path: string): unknown {
|
|
6
|
+
if (!obj) return undefined;
|
|
7
|
+
const parts = path.split(".");
|
|
8
|
+
let current: unknown = obj;
|
|
9
|
+
for (const part of parts) {
|
|
10
|
+
if (current === null || current === undefined || typeof current !== "object") return undefined;
|
|
11
|
+
current = (current as Record<string, unknown>)[part];
|
|
12
|
+
}
|
|
13
|
+
return current;
|
|
14
|
+
}
|