torch-glare 2.1.0 → 2.1.2
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/apps/lib/components/Badge.tsx +34 -137
- package/apps/lib/components/BadgeField.tsx +4 -4
- package/apps/lib/components/DataViews/ARCHITECTURE.md +439 -0
- package/apps/lib/components/DataViews/DataViewsConfigPanel.tsx +416 -0
- package/apps/lib/components/DataViews/DataViewsHeader.tsx +126 -0
- package/apps/lib/components/DataViews/DataViewsLayout.tsx +300 -0
- package/apps/lib/components/DataViews/FilterPanel.tsx +324 -0
- package/apps/lib/components/DataViews/InboxView.tsx +514 -0
- package/apps/lib/components/DataViews/KanbanView.tsx +242 -0
- package/apps/lib/components/DataViews/PanelControls.tsx +80 -0
- package/apps/lib/components/DataViews/SettingsPanel.tsx +285 -0
- package/apps/lib/components/DataViews/TableView.tsx +232 -0
- package/apps/lib/components/DataViews/TreeView.tsx +363 -0
- package/apps/lib/components/DataViews/badgeAdapter.ts +45 -0
- package/apps/lib/components/DataViews/fieldRenderers.tsx +334 -0
- package/apps/lib/components/DataViews/filters/DateRangePopover.tsx +113 -0
- package/apps/lib/components/DataViews/filters/PresetChips.tsx +45 -0
- package/apps/lib/components/DataViews/filters/RangeSliderWithInputs.tsx +154 -0
- package/apps/lib/components/DataViews/index.ts +30 -0
- package/apps/lib/components/DataViews/tree/TreeDrawer.tsx +54 -0
- package/apps/lib/components/DataViews/tree/TreeSidebar.tsx +77 -0
- package/apps/lib/components/DataViews/types.ts +177 -0
- package/apps/lib/components/TreeFolder/TreeFolder.tsx +387 -0
- package/apps/lib/components/TreeFolder/TreeFolderBreadcrumb.tsx +80 -0
- package/apps/lib/components/TreeFolder/TreeFolderRow.tsx +235 -0
- package/apps/lib/components/TreeFolder/TreeFolderStyles.tsx +60 -0
- package/apps/lib/components/TreeFolder/icons.tsx +63 -0
- package/apps/lib/components/TreeFolder/index.ts +17 -0
- package/apps/lib/components/TreeFolder/treeFolderUtils.ts +114 -0
- package/apps/lib/components/TreeFolder/types.ts +68 -0
- package/apps/lib/components/TreeFolder/useTreeFolderDnD.ts +261 -0
- package/apps/lib/hooks/useDataViewsState.ts +169 -0
- package/apps/lib/hooks/useIsMobile.ts +21 -0
- package/apps/lib/utils/dataViews/columnUtils.ts +130 -0
- package/apps/lib/utils/dataViews/fieldUtils.ts +198 -0
- package/apps/lib/utils/dataViews/nestedDataUtils.tsx +364 -0
- package/apps/lib/utils/dataViews/pathUtils.ts +132 -0
- package/apps/lib/utils/dataViews/rangeUtils.ts +225 -0
- package/apps/lib/utils/dataViews/treeUtils.ts +403 -0
- package/dist/bin/index.js +3 -3
- package/dist/bin/index.js.map +1 -1
- package/dist/src/commands/add.d.ts.map +1 -1
- package/dist/src/commands/add.js +29 -6
- package/dist/src/commands/add.js.map +1 -1
- package/dist/src/commands/utils.d.ts.map +1 -1
- package/dist/src/commands/utils.js +22 -2
- package/dist/src/commands/utils.js.map +1 -1
- package/dist/src/shared/copyComponentsRecursively.d.ts.map +1 -1
- package/dist/src/shared/copyComponentsRecursively.js +8 -1
- package/dist/src/shared/copyComponentsRecursively.js.map +1 -1
- package/dist/src/shared/getDependenciesAndInstallNestedComponents.d.ts +18 -4
- package/dist/src/shared/getDependenciesAndInstallNestedComponents.d.ts.map +1 -1
- package/dist/src/shared/getDependenciesAndInstallNestedComponents.js +110 -40
- package/dist/src/shared/getDependenciesAndInstallNestedComponents.js.map +1 -1
- package/docs/components/badge-field.md +21 -21
- package/docs/components/badge.md +156 -483
- package/docs/components/form-stepper.md +244 -0
- package/docs/components/stepper.md +215 -0
- package/docs/components/timeline.md +248 -0
- package/docs/reference/components.md +8 -7
- package/docs/reference/types.md +34 -26
- package/docs/tutorials/theming-basics.md +30 -27
- package/package.json +1 -1
- /package/docs/components/{labeled-checkbox.md → labeled-check-box.md} +0 -0
- /package/docs/components/{tree-dropdown.md → tree-drop-down.md} +0 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DynamicRecord,
|
|
3
|
+
FieldConfig,
|
|
4
|
+
FieldType,
|
|
5
|
+
DynamicColumnConfig,
|
|
6
|
+
InboxConfig,
|
|
7
|
+
} from "../../components/DataViews/types"
|
|
8
|
+
import { findFirstDefined, formatPathLabel, getByPath } from "./pathUtils"
|
|
9
|
+
import { isPlainObject, isCurrencyField, isRatingField } from "./nestedDataUtils"
|
|
10
|
+
|
|
11
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}/
|
|
12
|
+
|
|
13
|
+
const KEY_HINTS: Array<[RegExp, FieldType]> = [
|
|
14
|
+
[/(^|[._])status$/i, "enum-badge"],
|
|
15
|
+
[/(^|[._])priority$/i, "enum-badge"],
|
|
16
|
+
[/(^|[._])avatar(url)?$/i, "avatar"],
|
|
17
|
+
[/(^|[._])email$/i, "link"],
|
|
18
|
+
[/(^|[._])phone$/i, "link"],
|
|
19
|
+
[/(^|[._])url$|website$|homepage$/i, "link"],
|
|
20
|
+
[/(^|[._])(image|thumbnail|photo|picture)$/i, "image"],
|
|
21
|
+
[/(^|[._])tags?$|labels?$/i, "badge-array"],
|
|
22
|
+
[/(date|time)$/i, "date-format"],
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
export function inferFieldType(path: string, value: any): FieldType {
|
|
26
|
+
for (const [re, type] of KEY_HINTS) {
|
|
27
|
+
if (re.test(path)) return type
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (isCurrencyField(path)) return "currency"
|
|
31
|
+
if (isRatingField(path) && typeof value === "number" && value <= 5) return "star-rating"
|
|
32
|
+
|
|
33
|
+
if (value === null || value === undefined) return "text"
|
|
34
|
+
if (typeof value === "boolean") return "boolean"
|
|
35
|
+
if (typeof value === "number") return "number"
|
|
36
|
+
if (Array.isArray(value)) return "badge-array"
|
|
37
|
+
if (typeof value === "string") {
|
|
38
|
+
if (ISO_DATE.test(value) && !isNaN(Date.parse(value))) return "date-format"
|
|
39
|
+
return "text"
|
|
40
|
+
}
|
|
41
|
+
return "text"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function detectFields(data: DynamicRecord[]): FieldConfig[] {
|
|
45
|
+
if (!data || data.length === 0) return []
|
|
46
|
+
|
|
47
|
+
const allKeys = new Set<string>()
|
|
48
|
+
for (const item of data) {
|
|
49
|
+
if (item && typeof item === "object") {
|
|
50
|
+
for (const k of Object.keys(item)) allKeys.add(k)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const fields: FieldConfig[] = []
|
|
55
|
+
let order = 0
|
|
56
|
+
|
|
57
|
+
for (const key of allKeys) {
|
|
58
|
+
if (key.startsWith("_")) continue
|
|
59
|
+
|
|
60
|
+
const sample = findFirstDefined(data, key)
|
|
61
|
+
if (isPlainObject(sample)) continue
|
|
62
|
+
if (Array.isArray(sample) && sample.length > 0 && isPlainObject(sample[0])) continue
|
|
63
|
+
|
|
64
|
+
fields.push({
|
|
65
|
+
path: key,
|
|
66
|
+
label: formatPathLabel(key),
|
|
67
|
+
type: inferFieldType(key, sample),
|
|
68
|
+
visible: true,
|
|
69
|
+
order: order++,
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return fields
|
|
74
|
+
.sort((a, b) => {
|
|
75
|
+
if (a.path === "id") return -1
|
|
76
|
+
if (b.path === "id") return 1
|
|
77
|
+
return (a.label || "").localeCompare(b.label || "")
|
|
78
|
+
})
|
|
79
|
+
.map((f, i) => ({ ...f, order: i }))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function mergeFields(
|
|
83
|
+
detected: FieldConfig[],
|
|
84
|
+
custom?: FieldConfig[],
|
|
85
|
+
): FieldConfig[] {
|
|
86
|
+
if (!custom || custom.length === 0) return detected
|
|
87
|
+
|
|
88
|
+
const out = [...detected]
|
|
89
|
+
const byPath = new Map(out.map((f, i) => [f.path, i]))
|
|
90
|
+
|
|
91
|
+
for (const c of custom) {
|
|
92
|
+
if (!c.path) continue
|
|
93
|
+
const existing = byPath.get(c.path)
|
|
94
|
+
if (existing != null) {
|
|
95
|
+
out[existing] = { ...out[existing], ...c }
|
|
96
|
+
} else {
|
|
97
|
+
out.push({
|
|
98
|
+
...c,
|
|
99
|
+
path: c.path,
|
|
100
|
+
label: c.label ?? formatPathLabel(c.path),
|
|
101
|
+
visible: c.visible ?? true,
|
|
102
|
+
order: c.order ?? out.length,
|
|
103
|
+
})
|
|
104
|
+
byPath.set(c.path, out.length - 1)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return out
|
|
109
|
+
.map((f, i) => ({ ...f, order: f.order ?? i }))
|
|
110
|
+
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function visibleFields(fields: FieldConfig[]): FieldConfig[] {
|
|
114
|
+
return fields.filter((f) => f.type !== "hidden" && f.visible !== false)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function fieldToColumn(field: FieldConfig, idx = 0): DynamicColumnConfig & {
|
|
118
|
+
__field: FieldConfig
|
|
119
|
+
} {
|
|
120
|
+
const legacyType = mapFieldTypeToColumnType(field.type)
|
|
121
|
+
return {
|
|
122
|
+
id: field.path,
|
|
123
|
+
label: field.label ?? formatPathLabel(field.path),
|
|
124
|
+
visible: field.visible !== false && field.type !== "hidden",
|
|
125
|
+
order: field.order ?? idx,
|
|
126
|
+
type: legacyType,
|
|
127
|
+
render: field.render,
|
|
128
|
+
__field: field,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function mapFieldTypeToColumnType(t?: FieldType): DynamicColumnConfig["type"] {
|
|
133
|
+
switch (t) {
|
|
134
|
+
case "number":
|
|
135
|
+
case "currency":
|
|
136
|
+
case "number-format":
|
|
137
|
+
case "progress-bar":
|
|
138
|
+
case "star-rating":
|
|
139
|
+
return "number"
|
|
140
|
+
case "date":
|
|
141
|
+
case "date-format":
|
|
142
|
+
return "date"
|
|
143
|
+
case "enum-badge":
|
|
144
|
+
case "icon-text":
|
|
145
|
+
case "two-line":
|
|
146
|
+
case "link":
|
|
147
|
+
return "badge"
|
|
148
|
+
case "badge-array":
|
|
149
|
+
return "array"
|
|
150
|
+
case "boolean":
|
|
151
|
+
return "boolean"
|
|
152
|
+
default:
|
|
153
|
+
return "text"
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const STARRED_PATTERNS = ["isStarred", "starred", "favorite", "isFavorite", "pinned"]
|
|
158
|
+
const READ_PATTERNS = ["isRead", "read", "seen", "viewed"]
|
|
159
|
+
const ATTACHMENT_PATTERNS = ["hasAttachment", "hasAttachments", "attachments", "files"]
|
|
160
|
+
const PRIORITY_PATTERNS = ["priority", "urgency", "level", "importance"]
|
|
161
|
+
|
|
162
|
+
function pickField(sample: DynamicRecord, patterns: string[]): string | null {
|
|
163
|
+
for (const p of patterns) {
|
|
164
|
+
if (p in sample) return p
|
|
165
|
+
}
|
|
166
|
+
return null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function resolveInboxConfig(
|
|
170
|
+
data: DynamicRecord[],
|
|
171
|
+
user?: InboxConfig,
|
|
172
|
+
): Required<Omit<InboxConfig, "titlePath" | "previewPath">> & Pick<InboxConfig, "titlePath" | "previewPath"> {
|
|
173
|
+
const sample = data?.[0] && typeof data[0] === "object" ? data[0] : {}
|
|
174
|
+
|
|
175
|
+
const auto = {
|
|
176
|
+
starredField: pickField(sample, STARRED_PATTERNS),
|
|
177
|
+
readField: pickField(sample, READ_PATTERNS),
|
|
178
|
+
attachmentField: pickField(sample, ATTACHMENT_PATTERNS),
|
|
179
|
+
priorityField: pickField(sample, PRIORITY_PATTERNS),
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
starredField: user?.starredField !== undefined ? user.starredField : auto.starredField,
|
|
184
|
+
readField: user?.readField !== undefined ? user.readField : auto.readField,
|
|
185
|
+
attachmentField: user?.attachmentField !== undefined ? user.attachmentField : auto.attachmentField,
|
|
186
|
+
priorityField: user?.priorityField !== undefined ? user.priorityField : auto.priorityField,
|
|
187
|
+
titlePath: user?.titlePath,
|
|
188
|
+
previewPath: user?.previewPath,
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function readInboxField(
|
|
193
|
+
item: DynamicRecord,
|
|
194
|
+
field: string | null | undefined,
|
|
195
|
+
): any {
|
|
196
|
+
if (!field) return undefined
|
|
197
|
+
return getByPath(item, field)
|
|
198
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import type { ReactElement } from "react"
|
|
2
|
+
import { Badge } from "../../components/Badge"
|
|
3
|
+
import { Divider } from "../../components/Divider"
|
|
4
|
+
import { cn } from "../cn"
|
|
5
|
+
import type { DynamicRecord, DynamicColumnConfig } from "../../components/DataViews/types"
|
|
6
|
+
|
|
7
|
+
export type NestedFieldMetadata = {
|
|
8
|
+
key: string
|
|
9
|
+
label: string
|
|
10
|
+
type: 'object' | 'array' | 'primitive'
|
|
11
|
+
valueType?: 'string' | 'number' | 'boolean' | 'date'
|
|
12
|
+
isArrayOfObjects?: boolean
|
|
13
|
+
depth: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isPlainObject(value: any): boolean {
|
|
17
|
+
return value !== null &&
|
|
18
|
+
typeof value === 'object' &&
|
|
19
|
+
!Array.isArray(value) &&
|
|
20
|
+
!(value instanceof Date) &&
|
|
21
|
+
Object.keys(value).length > 0
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function formatFieldName(fieldName: string): string {
|
|
25
|
+
if (fieldName.includes("_")) {
|
|
26
|
+
return fieldName
|
|
27
|
+
.split("_")
|
|
28
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
29
|
+
.join(" ")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return fieldName
|
|
33
|
+
.replace(/([A-Z])/g, " $1")
|
|
34
|
+
.replace(/^./, (str) => str.toUpperCase())
|
|
35
|
+
.trim()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function analyzeNestedFields(
|
|
39
|
+
item: DynamicRecord,
|
|
40
|
+
visibleColumnIds: Set<string> = new Set(),
|
|
41
|
+
excludeKeys: string[] = ['id', 'isRead', 'isStarred', 'hasAttachment', 'priority']
|
|
42
|
+
): NestedFieldMetadata[] {
|
|
43
|
+
const nestedFields: NestedFieldMetadata[] = []
|
|
44
|
+
|
|
45
|
+
Object.entries(item).forEach(([key, value]) => {
|
|
46
|
+
if (visibleColumnIds.has(key) || excludeKeys.includes(key)) {
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (isPlainObject(value)) {
|
|
51
|
+
nestedFields.push({
|
|
52
|
+
key,
|
|
53
|
+
label: formatFieldName(key),
|
|
54
|
+
type: 'object',
|
|
55
|
+
depth: 0
|
|
56
|
+
})
|
|
57
|
+
} else if (Array.isArray(value) && value.length > 0) {
|
|
58
|
+
nestedFields.push({
|
|
59
|
+
key,
|
|
60
|
+
label: formatFieldName(key),
|
|
61
|
+
type: 'array',
|
|
62
|
+
isArrayOfObjects: isPlainObject(value[0]),
|
|
63
|
+
depth: 0
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
return nestedFields
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function isCurrencyField(key: string): boolean {
|
|
72
|
+
const lowerKey = key.toLowerCase()
|
|
73
|
+
return lowerKey.includes('salary') ||
|
|
74
|
+
lowerKey.includes('price') ||
|
|
75
|
+
lowerKey.includes('cost') ||
|
|
76
|
+
lowerKey.includes('amount') ||
|
|
77
|
+
lowerKey.includes('pay') ||
|
|
78
|
+
lowerKey.includes('fee')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function isRatingField(key: string): boolean {
|
|
82
|
+
const lowerKey = key.toLowerCase()
|
|
83
|
+
return lowerKey.includes('rating') ||
|
|
84
|
+
lowerKey.includes('score')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function renderPrimitiveValue(
|
|
88
|
+
key: string,
|
|
89
|
+
value: any,
|
|
90
|
+
options: {
|
|
91
|
+
showLabel?: boolean
|
|
92
|
+
labelClassName?: string
|
|
93
|
+
valueClassName?: string
|
|
94
|
+
} = {}
|
|
95
|
+
): ReactElement | null {
|
|
96
|
+
const {
|
|
97
|
+
showLabel = true,
|
|
98
|
+
labelClassName = "text-content-presentation-global-tertiary",
|
|
99
|
+
valueClassName = "text-content-presentation-global-primary"
|
|
100
|
+
} = options
|
|
101
|
+
|
|
102
|
+
if (value == null) return null
|
|
103
|
+
|
|
104
|
+
const label = showLabel ? formatFieldName(key) : null
|
|
105
|
+
|
|
106
|
+
if (typeof value === 'boolean') {
|
|
107
|
+
return (
|
|
108
|
+
<div className="flex items-center gap-2">
|
|
109
|
+
{label && <span className={labelClassName}>{label}:</span>}
|
|
110
|
+
<Badge
|
|
111
|
+
color={value ? "green" : "gray"}
|
|
112
|
+
badgeStyle={value ? "solid" : "subtle"}
|
|
113
|
+
label={value ? "Yes" : "No"}
|
|
114
|
+
size="XS"
|
|
115
|
+
|
|
116
|
+
/>
|
|
117
|
+
</div>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (typeof value === 'number') {
|
|
122
|
+
const isCurrency = isCurrencyField(key)
|
|
123
|
+
const isRating = isRatingField(key)
|
|
124
|
+
|
|
125
|
+
if (isRating && value <= 5) {
|
|
126
|
+
return (
|
|
127
|
+
<div className="flex items-center gap-2">
|
|
128
|
+
{label && <span className={labelClassName}>{label}:</span>}
|
|
129
|
+
<div className="flex items-center gap-2">
|
|
130
|
+
<div className="flex">
|
|
131
|
+
{[...Array(5)].map((_, i) => (
|
|
132
|
+
<span key={i} className={i < Math.floor(value) ? 'text-yellow-500' : 'text-gray-300'}>
|
|
133
|
+
⭐
|
|
134
|
+
</span>
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
<span className="font-semibold text-sm">{value}</span>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<div className="flex items-center gap-2">
|
|
145
|
+
{label && <span className={labelClassName}>{label}:</span>}
|
|
146
|
+
<span className={cn(
|
|
147
|
+
valueClassName,
|
|
148
|
+
isCurrency && "font-semibold text-green-600"
|
|
149
|
+
)}>
|
|
150
|
+
{isCurrency ? `$${value.toLocaleString()}` : value.toLocaleString()}
|
|
151
|
+
</span>
|
|
152
|
+
</div>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<div className="flex items-center gap-2">
|
|
158
|
+
{label && <span className={labelClassName}>{label}:</span>}
|
|
159
|
+
<span className={valueClassName}>{String(value)}</span>
|
|
160
|
+
</div>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function renderArrayValue(
|
|
165
|
+
key: string,
|
|
166
|
+
value: any[],
|
|
167
|
+
options: {
|
|
168
|
+
showLabel?: boolean
|
|
169
|
+
maxItems?: number
|
|
170
|
+
renderItem?: (item: any, index: number) => ReactElement
|
|
171
|
+
} = {}
|
|
172
|
+
): ReactElement | null {
|
|
173
|
+
const { showLabel = true, maxItems, renderItem } = options
|
|
174
|
+
|
|
175
|
+
if (!Array.isArray(value) || value.length === 0) return null
|
|
176
|
+
|
|
177
|
+
const label = showLabel ? formatFieldName(key) : null
|
|
178
|
+
const displayItems = maxItems ? value.slice(0, maxItems) : value
|
|
179
|
+
|
|
180
|
+
if (isPlainObject(value[0])) {
|
|
181
|
+
return (
|
|
182
|
+
<div className="space-y-2">
|
|
183
|
+
{label && (
|
|
184
|
+
<div className="text-xs font-semibold text-content-presentation-global-primary uppercase tracking-wide">
|
|
185
|
+
{label} ({value.length})
|
|
186
|
+
</div>
|
|
187
|
+
)}
|
|
188
|
+
<div className="space-y-2">
|
|
189
|
+
{displayItems.map((item: any, idx: number) => {
|
|
190
|
+
if (renderItem) {
|
|
191
|
+
return renderItem(item, idx)
|
|
192
|
+
}
|
|
193
|
+
return (
|
|
194
|
+
<div key={idx} className="p-2 rounded bg-background-presentation-form-field-primary">
|
|
195
|
+
{renderNestedObject(item, 1)}
|
|
196
|
+
</div>
|
|
197
|
+
)
|
|
198
|
+
})}
|
|
199
|
+
{maxItems && value.length > maxItems && (
|
|
200
|
+
<div className="text-xs text-content-presentation-global-tertiary">
|
|
201
|
+
+{value.length - maxItems} more
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<div className="flex items-start gap-2">
|
|
211
|
+
{label && <span className="text-content-presentation-global-tertiary">{label}:</span>}
|
|
212
|
+
<div className="flex flex-wrap gap-1">
|
|
213
|
+
{displayItems.map((item: any, idx: number) => (
|
|
214
|
+
<Badge key={idx} color="blue" badgeStyle="solid" label={String(item)} size="XS" />
|
|
215
|
+
))}
|
|
216
|
+
{maxItems && value.length > maxItems && (
|
|
217
|
+
<Badge color="gray" badgeStyle="subtle" label={`+${value.length - maxItems}`} size="XS" />
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function renderNestedObject(
|
|
225
|
+
obj: any,
|
|
226
|
+
depth: number = 0,
|
|
227
|
+
options: {
|
|
228
|
+
maxDepth?: number
|
|
229
|
+
showSeparators?: boolean
|
|
230
|
+
} = {}
|
|
231
|
+
): ReactElement {
|
|
232
|
+
const { maxDepth = 3 } = options
|
|
233
|
+
|
|
234
|
+
if (depth >= maxDepth) {
|
|
235
|
+
return <div className="text-xs text-content-presentation-global-tertiary">...</div>
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<div className="space-y-2 text-sm">
|
|
240
|
+
{Object.entries(obj).map(([key, value]) => {
|
|
241
|
+
if (isPlainObject(value)) {
|
|
242
|
+
return (
|
|
243
|
+
<div key={key} className="space-y-1">
|
|
244
|
+
<div className="text-xs font-semibold text-content-presentation-global-primary uppercase tracking-wide">
|
|
245
|
+
{formatFieldName(key)}
|
|
246
|
+
</div>
|
|
247
|
+
<div className="pl-3 border-l-2 border-border-presentation-global-primary">
|
|
248
|
+
{renderNestedObject(value, depth + 1, options)}
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (Array.isArray(value)) {
|
|
255
|
+
return <div key={key}>{renderArrayValue(key, value)}</div>
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return <div key={key}>{renderPrimitiveValue(key, value)}</div>
|
|
259
|
+
})}
|
|
260
|
+
</div>
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function renderDetailView(
|
|
265
|
+
selectedItem: DynamicRecord,
|
|
266
|
+
visibleColumns: DynamicColumnConfig[],
|
|
267
|
+
renderCellValue: (value: any, column: DynamicColumnConfig, row: DynamicRecord) => React.ReactNode
|
|
268
|
+
): ReactElement {
|
|
269
|
+
const visibleColumnIds = new Set(visibleColumns.map(col => col.id))
|
|
270
|
+
const nestedFields = analyzeNestedFields(selectedItem, visibleColumnIds)
|
|
271
|
+
|
|
272
|
+
return (
|
|
273
|
+
<div className="space-y-6">
|
|
274
|
+
{visibleColumns.slice(2).length > 0 && (
|
|
275
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
276
|
+
{visibleColumns.slice(2).map((col) => {
|
|
277
|
+
const value = selectedItem[col.id]
|
|
278
|
+
if (value == null && value !== 0 && value !== false) return null
|
|
279
|
+
return (
|
|
280
|
+
<div key={col.id} className="space-y-1">
|
|
281
|
+
<dt className="text-xs font-medium text-content-presentation-global-tertiary uppercase tracking-wide">
|
|
282
|
+
{col.label}
|
|
283
|
+
</dt>
|
|
284
|
+
<dd className="text-sm text-content-presentation-global-primary">
|
|
285
|
+
{renderCellValue(value, col, selectedItem)}
|
|
286
|
+
</dd>
|
|
287
|
+
</div>
|
|
288
|
+
)
|
|
289
|
+
})}
|
|
290
|
+
</div>
|
|
291
|
+
)}
|
|
292
|
+
|
|
293
|
+
{nestedFields.map((field, index) => {
|
|
294
|
+
const value = selectedItem[field.key]
|
|
295
|
+
|
|
296
|
+
if (field.type === 'object' && isPlainObject(value)) {
|
|
297
|
+
return (
|
|
298
|
+
<div key={field.key}>
|
|
299
|
+
{index > 0 && <Divider />}
|
|
300
|
+
<div>
|
|
301
|
+
<h3 className="text-sm font-semibold text-content-presentation-global-primary mb-3">
|
|
302
|
+
{field.label}
|
|
303
|
+
</h3>
|
|
304
|
+
{renderNestedObject(value)}
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (field.type === 'array' && Array.isArray(value)) {
|
|
311
|
+
return (
|
|
312
|
+
<div key={field.key}>
|
|
313
|
+
{index > 0 && <Divider />}
|
|
314
|
+
<div>
|
|
315
|
+
<h3 className="text-sm font-semibold text-content-presentation-global-primary mb-3">
|
|
316
|
+
{field.label}
|
|
317
|
+
</h3>
|
|
318
|
+
{renderArrayValue(field.key, value, { showLabel: false })}
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return null
|
|
325
|
+
})}
|
|
326
|
+
</div>
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function getDataStructureSummary(data: DynamicRecord[]): {
|
|
331
|
+
totalFields: number
|
|
332
|
+
nestedFields: string[]
|
|
333
|
+
arrayFields: string[]
|
|
334
|
+
primitiveFields: string[]
|
|
335
|
+
} {
|
|
336
|
+
if (!data || data.length === 0) {
|
|
337
|
+
return { totalFields: 0, nestedFields: [], arrayFields: [], primitiveFields: [] }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const allKeys = new Set<string>()
|
|
341
|
+
const nestedKeys = new Set<string>()
|
|
342
|
+
const arrayKeys = new Set<string>()
|
|
343
|
+
const primitiveKeys = new Set<string>()
|
|
344
|
+
|
|
345
|
+
data.forEach(item => {
|
|
346
|
+
Object.entries(item).forEach(([key, value]) => {
|
|
347
|
+
allKeys.add(key)
|
|
348
|
+
if (isPlainObject(value)) {
|
|
349
|
+
nestedKeys.add(key)
|
|
350
|
+
} else if (Array.isArray(value)) {
|
|
351
|
+
arrayKeys.add(key)
|
|
352
|
+
} else {
|
|
353
|
+
primitiveKeys.add(key)
|
|
354
|
+
}
|
|
355
|
+
})
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
totalFields: allKeys.size,
|
|
360
|
+
nestedFields: Array.from(nestedKeys),
|
|
361
|
+
arrayFields: Array.from(arrayKeys),
|
|
362
|
+
primitiveFields: Array.from(primitiveKeys)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
export type Path = string
|
|
2
|
+
|
|
3
|
+
const PATH_CACHE = new Map<string, string[]>()
|
|
4
|
+
|
|
5
|
+
function splitPath(path: Path): string[] {
|
|
6
|
+
const cached = PATH_CACHE.get(path)
|
|
7
|
+
if (cached) return cached
|
|
8
|
+
const parts = path.split(".")
|
|
9
|
+
PATH_CACHE.set(path, parts)
|
|
10
|
+
return parts
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getByPath(obj: unknown, path: Path | undefined | null): any {
|
|
14
|
+
if (obj == null || path == null || path === "") return undefined
|
|
15
|
+
if (typeof obj !== "object") return undefined
|
|
16
|
+
|
|
17
|
+
const parts = splitPath(path)
|
|
18
|
+
let cur: any = obj
|
|
19
|
+
for (let i = 0; i < parts.length; i++) {
|
|
20
|
+
if (cur == null) return undefined
|
|
21
|
+
cur = cur[parts[i]]
|
|
22
|
+
}
|
|
23
|
+
return cur
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function setByPath<T extends Record<string, any>>(
|
|
27
|
+
obj: T,
|
|
28
|
+
path: Path,
|
|
29
|
+
value: any,
|
|
30
|
+
): T {
|
|
31
|
+
if (!path) return obj
|
|
32
|
+
const parts = splitPath(path)
|
|
33
|
+
if (parts.length === 0) return obj
|
|
34
|
+
|
|
35
|
+
const root: any = Array.isArray(obj) ? [...obj] : { ...obj }
|
|
36
|
+
let cur: any = root
|
|
37
|
+
|
|
38
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
39
|
+
const key = parts[i]
|
|
40
|
+
const next = cur[key]
|
|
41
|
+
const cloned = next != null && typeof next === "object" && !Array.isArray(next)
|
|
42
|
+
? { ...next }
|
|
43
|
+
: Array.isArray(next)
|
|
44
|
+
? [...next]
|
|
45
|
+
: {}
|
|
46
|
+
cur[key] = cloned
|
|
47
|
+
cur = cloned
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
cur[parts[parts.length - 1]] = value
|
|
51
|
+
return root
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function hasPath(obj: unknown, path: Path | undefined | null): boolean {
|
|
55
|
+
return getByPath(obj, path) !== undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function formatPathLabel(path: Path): string {
|
|
59
|
+
if (!path) return ""
|
|
60
|
+
const tail = path.includes(".") ? path.split(".").pop()! : path
|
|
61
|
+
|
|
62
|
+
if (tail.includes("_")) {
|
|
63
|
+
return tail
|
|
64
|
+
.split("_")
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
67
|
+
.join(" ")
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return tail
|
|
71
|
+
.replace(/([A-Z])/g, " $1")
|
|
72
|
+
.replace(/^./, (s) => s.toUpperCase())
|
|
73
|
+
.trim()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function findFirstDefined(data: any[], path: Path): any {
|
|
77
|
+
for (const item of data) {
|
|
78
|
+
const v = getByPath(item, path)
|
|
79
|
+
if (v != null) return v
|
|
80
|
+
}
|
|
81
|
+
return undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function matchesFilterValues(
|
|
85
|
+
item: unknown,
|
|
86
|
+
path: Path,
|
|
87
|
+
filter: string[] | { kind: "number"; min?: number; max?: number } | { kind: "date"; from?: string; to?: string } | undefined,
|
|
88
|
+
): boolean {
|
|
89
|
+
if (filter == null) return true
|
|
90
|
+
|
|
91
|
+
if (Array.isArray(filter)) {
|
|
92
|
+
if (filter.length === 0) return true
|
|
93
|
+
const value = getByPath(item, path)
|
|
94
|
+
if (Array.isArray(value)) {
|
|
95
|
+
const set = new Set(value.map((v) => String(v)))
|
|
96
|
+
return filter.some((s) => set.has(s))
|
|
97
|
+
}
|
|
98
|
+
if (typeof value === "boolean") {
|
|
99
|
+
return filter.includes(value ? "true" : "false")
|
|
100
|
+
}
|
|
101
|
+
return filter.includes(String(value ?? ""))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (filter.kind === "number") {
|
|
105
|
+
if (filter.min == null && filter.max == null) return true
|
|
106
|
+
const raw = getByPath(item, path)
|
|
107
|
+
const n = typeof raw === "number" ? raw : Number(raw)
|
|
108
|
+
if (!Number.isFinite(n)) return false
|
|
109
|
+
if (filter.min != null && n < filter.min) return false
|
|
110
|
+
if (filter.max != null && n > filter.max) return false
|
|
111
|
+
return true
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (filter.kind === "date") {
|
|
115
|
+
if (filter.from == null && filter.to == null) return true
|
|
116
|
+
const raw = getByPath(item, path)
|
|
117
|
+
if (raw == null) return false
|
|
118
|
+
const ms = raw instanceof Date ? raw.getTime() : Date.parse(String(raw))
|
|
119
|
+
if (!Number.isFinite(ms)) return false
|
|
120
|
+
if (filter.from != null) {
|
|
121
|
+
const fromMs = Date.parse(filter.from)
|
|
122
|
+
if (Number.isFinite(fromMs) && ms < fromMs) return false
|
|
123
|
+
}
|
|
124
|
+
if (filter.to != null) {
|
|
125
|
+
const toMs = Date.parse(filter.to)
|
|
126
|
+
if (Number.isFinite(toMs) && ms > toMs + 24 * 60 * 60 * 1000 - 1) return false
|
|
127
|
+
}
|
|
128
|
+
return true
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return true
|
|
132
|
+
}
|