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,261 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react"
|
|
4
|
+
import { isAncestor } from "./treeFolderUtils"
|
|
5
|
+
import type {
|
|
6
|
+
TreeFolderDropPosition,
|
|
7
|
+
TreeFolderDropTarget,
|
|
8
|
+
TreeFolderMoveArgs,
|
|
9
|
+
TreeFolderNode,
|
|
10
|
+
TreeFolderVisibleRow,
|
|
11
|
+
} from "./types"
|
|
12
|
+
|
|
13
|
+
type RowLookup = Map<string, TreeFolderVisibleRow>
|
|
14
|
+
|
|
15
|
+
const SCROLL_EDGE_PX = 32
|
|
16
|
+
const SCROLL_SPEED_PX_PER_FRAME = 8
|
|
17
|
+
const DRAG_DATA_MIME = "application/x-treefolder-ids"
|
|
18
|
+
|
|
19
|
+
export type UseTreeFolderDnDOptions = {
|
|
20
|
+
data: TreeFolderNode[]
|
|
21
|
+
rowsById: RowLookup
|
|
22
|
+
scrollContainerRef: React.RefObject<HTMLDivElement | null>
|
|
23
|
+
enabled: boolean
|
|
24
|
+
onMove?: (args: TreeFolderMoveArgs) => void
|
|
25
|
+
/**
|
|
26
|
+
* Predicate. Return false to forbid the drop. Defaults to allow except for
|
|
27
|
+
* dropping a node into itself or a descendant.
|
|
28
|
+
*/
|
|
29
|
+
canDrop?: (args: {
|
|
30
|
+
dragIds: string[]
|
|
31
|
+
parentId: string | null
|
|
32
|
+
index: number
|
|
33
|
+
}) => boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type UseTreeFolderDnDResult = {
|
|
37
|
+
dragIds: string[]
|
|
38
|
+
dropTarget: TreeFolderDropTarget | null
|
|
39
|
+
getRowDragHandlers: (rowId: string) => {
|
|
40
|
+
draggable: boolean
|
|
41
|
+
onDragStart: (e: React.DragEvent<HTMLElement>) => void
|
|
42
|
+
onDragEnd: (e: React.DragEvent<HTMLElement>) => void
|
|
43
|
+
onDragOver: (e: React.DragEvent<HTMLElement>) => void
|
|
44
|
+
onDragLeave: (e: React.DragEvent<HTMLElement>) => void
|
|
45
|
+
onDrop: (e: React.DragEvent<HTMLElement>) => void
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useTreeFolderDnD({
|
|
50
|
+
data,
|
|
51
|
+
rowsById,
|
|
52
|
+
scrollContainerRef,
|
|
53
|
+
enabled,
|
|
54
|
+
onMove,
|
|
55
|
+
canDrop,
|
|
56
|
+
}: UseTreeFolderDnDOptions): UseTreeFolderDnDResult {
|
|
57
|
+
const [dragIds, setDragIds] = useState<string[]>([])
|
|
58
|
+
const [dropTarget, setDropTarget] = useState<TreeFolderDropTarget | null>(null)
|
|
59
|
+
|
|
60
|
+
// Auto-scroll loop while the user is dragging near a container edge.
|
|
61
|
+
const autoScrollDeltaRef = useRef(0)
|
|
62
|
+
const autoScrollRafRef = useRef<number | null>(null)
|
|
63
|
+
|
|
64
|
+
const stopAutoScroll = useCallback(() => {
|
|
65
|
+
autoScrollDeltaRef.current = 0
|
|
66
|
+
if (autoScrollRafRef.current != null) {
|
|
67
|
+
cancelAnimationFrame(autoScrollRafRef.current)
|
|
68
|
+
autoScrollRafRef.current = null
|
|
69
|
+
}
|
|
70
|
+
}, [])
|
|
71
|
+
|
|
72
|
+
const tickAutoScroll = useCallback(() => {
|
|
73
|
+
const el = scrollContainerRef.current
|
|
74
|
+
const delta = autoScrollDeltaRef.current
|
|
75
|
+
if (!el || delta === 0) {
|
|
76
|
+
autoScrollRafRef.current = null
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
el.scrollTop += delta
|
|
80
|
+
autoScrollRafRef.current = requestAnimationFrame(tickAutoScroll)
|
|
81
|
+
}, [scrollContainerRef])
|
|
82
|
+
|
|
83
|
+
const maybeAutoScroll = useCallback(
|
|
84
|
+
(clientY: number) => {
|
|
85
|
+
const el = scrollContainerRef.current
|
|
86
|
+
if (!el) return
|
|
87
|
+
const rect = el.getBoundingClientRect()
|
|
88
|
+
let delta = 0
|
|
89
|
+
if (clientY - rect.top < SCROLL_EDGE_PX) delta = -SCROLL_SPEED_PX_PER_FRAME
|
|
90
|
+
else if (rect.bottom - clientY < SCROLL_EDGE_PX) delta = SCROLL_SPEED_PX_PER_FRAME
|
|
91
|
+
autoScrollDeltaRef.current = delta
|
|
92
|
+
if (delta !== 0 && autoScrollRafRef.current == null) {
|
|
93
|
+
autoScrollRafRef.current = requestAnimationFrame(tickAutoScroll)
|
|
94
|
+
} else if (delta === 0) {
|
|
95
|
+
stopAutoScroll()
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
[scrollContainerRef, stopAutoScroll, tickAutoScroll],
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
useEffect(() => stopAutoScroll, [stopAutoScroll])
|
|
102
|
+
|
|
103
|
+
const computePosition = useCallback(
|
|
104
|
+
(target: HTMLElement, clientY: number, isInternal: boolean): TreeFolderDropPosition => {
|
|
105
|
+
const rect = target.getBoundingClientRect()
|
|
106
|
+
const offset = clientY - rect.top
|
|
107
|
+
const ratio = offset / rect.height
|
|
108
|
+
// Leaves can't accept "inside" drops — squash that zone into before/after.
|
|
109
|
+
if (!isInternal) return ratio < 0.5 ? "before" : "after"
|
|
110
|
+
if (ratio < 0.25) return "before"
|
|
111
|
+
if (ratio > 0.75) return "after"
|
|
112
|
+
return "inside"
|
|
113
|
+
},
|
|
114
|
+
[],
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
const resolveMoveArgs = useCallback(
|
|
118
|
+
(drag: string[], drop: TreeFolderDropTarget): TreeFolderMoveArgs | null => {
|
|
119
|
+
const targetRow = rowsById.get(drop.rowId)
|
|
120
|
+
if (!targetRow) return null
|
|
121
|
+
|
|
122
|
+
let parentId: string | null
|
|
123
|
+
let index: number
|
|
124
|
+
|
|
125
|
+
if (drop.position === "inside") {
|
|
126
|
+
parentId = targetRow.node.id
|
|
127
|
+
// Drop at the end of the target's children.
|
|
128
|
+
index = targetRow.node.children?.length ?? 0
|
|
129
|
+
} else {
|
|
130
|
+
parentId = targetRow.parentId
|
|
131
|
+
index = drop.position === "before" ? targetRow.childIndex : targetRow.childIndex + 1
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Reject: dropping into self or any descendant.
|
|
135
|
+
for (const id of drag) {
|
|
136
|
+
const node = rowsById.get(id)?.node
|
|
137
|
+
if (!node) continue
|
|
138
|
+
if (parentId && (node.id === parentId || isAncestor(node, parentId))) return null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (canDrop && !canDrop({ dragIds: drag, parentId, index })) return null
|
|
142
|
+
|
|
143
|
+
// No-op safety: skipping the equivalent of "drop where you already are".
|
|
144
|
+
if (drag.length === 1) {
|
|
145
|
+
const onlyDrag = rowsById.get(drag[0])
|
|
146
|
+
if (
|
|
147
|
+
onlyDrag &&
|
|
148
|
+
onlyDrag.parentId === parentId &&
|
|
149
|
+
(onlyDrag.childIndex === index || onlyDrag.childIndex + 1 === index)
|
|
150
|
+
) {
|
|
151
|
+
return null
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { dragIds: drag, parentId, index }
|
|
156
|
+
},
|
|
157
|
+
[rowsById, canDrop],
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
const getRowDragHandlers = useCallback(
|
|
161
|
+
(rowId: string) => {
|
|
162
|
+
if (!enabled) {
|
|
163
|
+
return {
|
|
164
|
+
draggable: false,
|
|
165
|
+
onDragStart: () => {},
|
|
166
|
+
onDragEnd: () => {},
|
|
167
|
+
onDragOver: () => {},
|
|
168
|
+
onDragLeave: () => {},
|
|
169
|
+
onDrop: () => {},
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
draggable: true,
|
|
174
|
+
onDragStart: (e: React.DragEvent<HTMLElement>) => {
|
|
175
|
+
const ids = [rowId]
|
|
176
|
+
setDragIds(ids)
|
|
177
|
+
e.dataTransfer.effectAllowed = "move"
|
|
178
|
+
try {
|
|
179
|
+
e.dataTransfer.setData(DRAG_DATA_MIME, ids.join(","))
|
|
180
|
+
// Required for Firefox to initiate the drag at all.
|
|
181
|
+
e.dataTransfer.setData("text/plain", ids.join(","))
|
|
182
|
+
} catch {
|
|
183
|
+
// setData can throw in some sandboxed contexts; ignore.
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
onDragEnd: () => {
|
|
187
|
+
setDragIds([])
|
|
188
|
+
setDropTarget(null)
|
|
189
|
+
stopAutoScroll()
|
|
190
|
+
},
|
|
191
|
+
onDragOver: (e: React.DragEvent<HTMLElement>) => {
|
|
192
|
+
if (dragIds.length === 0) return
|
|
193
|
+
// Reject self-drops upfront so the cursor reflects "no drop" outside the tree.
|
|
194
|
+
if (dragIds.includes(rowId)) {
|
|
195
|
+
e.dataTransfer.dropEffect = "none"
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
const row = rowsById.get(rowId)
|
|
199
|
+
if (!row) return
|
|
200
|
+
// Reject dropping onto a descendant of any dragged node.
|
|
201
|
+
for (const dragId of dragIds) {
|
|
202
|
+
const dragNode = rowsById.get(dragId)?.node
|
|
203
|
+
if (dragNode && isAncestor(dragNode, rowId)) {
|
|
204
|
+
e.dataTransfer.dropEffect = "none"
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
e.preventDefault()
|
|
209
|
+
e.dataTransfer.dropEffect = "move"
|
|
210
|
+
const position = computePosition(e.currentTarget, e.clientY, row.isInternal)
|
|
211
|
+
// Only setState when something actually changed (avoids re-render storms).
|
|
212
|
+
setDropTarget((prev) =>
|
|
213
|
+
prev && prev.rowId === rowId && prev.position === position
|
|
214
|
+
? prev
|
|
215
|
+
: { rowId, position },
|
|
216
|
+
)
|
|
217
|
+
maybeAutoScroll(e.clientY)
|
|
218
|
+
},
|
|
219
|
+
onDragLeave: () => {
|
|
220
|
+
// No-op: onDragOver on the next row will overwrite dropTarget. Clearing
|
|
221
|
+
// here would flicker the indicator during normal row-to-row hovering.
|
|
222
|
+
},
|
|
223
|
+
onDrop: (e: React.DragEvent<HTMLElement>) => {
|
|
224
|
+
if (dragIds.length === 0) return
|
|
225
|
+
e.preventDefault()
|
|
226
|
+
const row = rowsById.get(rowId)
|
|
227
|
+
if (!row) {
|
|
228
|
+
setDragIds([])
|
|
229
|
+
setDropTarget(null)
|
|
230
|
+
stopAutoScroll()
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
const position = computePosition(e.currentTarget, e.clientY, row.isInternal)
|
|
234
|
+
const moveArgs = resolveMoveArgs(dragIds, { rowId, position })
|
|
235
|
+
if (moveArgs) onMove?.(moveArgs)
|
|
236
|
+
setDragIds([])
|
|
237
|
+
setDropTarget(null)
|
|
238
|
+
stopAutoScroll()
|
|
239
|
+
},
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
[
|
|
243
|
+
enabled,
|
|
244
|
+
dragIds,
|
|
245
|
+
rowsById,
|
|
246
|
+
computePosition,
|
|
247
|
+
resolveMoveArgs,
|
|
248
|
+
onMove,
|
|
249
|
+
maybeAutoScroll,
|
|
250
|
+
stopAutoScroll,
|
|
251
|
+
],
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
// If the data tree changes mid-drag, clear stale targets.
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
if (dragIds.length === 0) return
|
|
257
|
+
setDropTarget((prev) => (prev && rowsById.has(prev.rowId) ? prev : null))
|
|
258
|
+
}, [data, rowsById, dragIds.length])
|
|
259
|
+
|
|
260
|
+
return { dragIds, dropTarget, getRowDragHandlers }
|
|
261
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from "react"
|
|
4
|
+
import type {
|
|
5
|
+
DynamicRecord,
|
|
6
|
+
DynamicColumnConfig,
|
|
7
|
+
FieldConfig,
|
|
8
|
+
FilterState,
|
|
9
|
+
TreeConfig,
|
|
10
|
+
ViewConfig,
|
|
11
|
+
ViewType,
|
|
12
|
+
ViewVisibility,
|
|
13
|
+
} from "../components/DataViews/types"
|
|
14
|
+
import { defaultConfig } from "../components/DataViews/types"
|
|
15
|
+
import { detectColumns, mergeColumns } from "../utils/dataViews/columnUtils"
|
|
16
|
+
import { detectFields, mergeFields } from "../utils/dataViews/fieldUtils"
|
|
17
|
+
import { autoDetectTreeShape, flattenAll } from "../utils/dataViews/treeUtils"
|
|
18
|
+
|
|
19
|
+
export type UseDataViewsStateOptions = {
|
|
20
|
+
data?: DynamicRecord[]
|
|
21
|
+
fields?: FieldConfig[]
|
|
22
|
+
columns?: Partial<DynamicColumnConfig>[]
|
|
23
|
+
config?: Partial<ViewConfig>
|
|
24
|
+
treeConfig?: TreeConfig
|
|
25
|
+
views?: ViewVisibility
|
|
26
|
+
filterState?: FilterState
|
|
27
|
+
onFilterChange?: (filters: FilterState) => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useDataViewsState({
|
|
31
|
+
data,
|
|
32
|
+
fields,
|
|
33
|
+
columns,
|
|
34
|
+
config: initialConfig,
|
|
35
|
+
treeConfig,
|
|
36
|
+
views,
|
|
37
|
+
filterState: externalFilterState,
|
|
38
|
+
onFilterChange,
|
|
39
|
+
}: UseDataViewsStateOptions) {
|
|
40
|
+
const [currentView, setCurrentView] = useState<ViewType>(
|
|
41
|
+
initialConfig?.defaultView || defaultConfig.defaultView,
|
|
42
|
+
)
|
|
43
|
+
const [config, setConfig] = useState<ViewConfig>({ ...defaultConfig, ...initialConfig })
|
|
44
|
+
const [items, setItems] = useState<DynamicRecord[]>(data || [])
|
|
45
|
+
const [internalFilterState, setInternalFilterState] = useState<FilterState>({})
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
setItems(data || [])
|
|
49
|
+
}, [data])
|
|
50
|
+
|
|
51
|
+
const activeFilterState = externalFilterState ?? internalFilterState
|
|
52
|
+
|
|
53
|
+
const treeShape = useMemo(
|
|
54
|
+
() => autoDetectTreeShape(items, treeConfig ?? {}),
|
|
55
|
+
[items, treeConfig],
|
|
56
|
+
)
|
|
57
|
+
const treeAutoAvailable =
|
|
58
|
+
!!treeConfig || !!treeShape.childrenField || !!treeShape.parentField
|
|
59
|
+
|
|
60
|
+
const enabledViews = useMemo<Record<ViewType, boolean>>(() => {
|
|
61
|
+
const v = views ?? {}
|
|
62
|
+
return {
|
|
63
|
+
table: v.table ?? true,
|
|
64
|
+
kanban: v.kanban ?? true,
|
|
65
|
+
inbox: v.inbox ?? true,
|
|
66
|
+
tree: v.tree ?? treeAutoAvailable,
|
|
67
|
+
}
|
|
68
|
+
}, [views, treeAutoAvailable])
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!enabledViews[currentView]) {
|
|
72
|
+
const fallback = (Object.entries(enabledViews) as Array<[ViewType, boolean]>)
|
|
73
|
+
.find(([, on]) => on)?.[0]
|
|
74
|
+
if (fallback) setCurrentView(fallback)
|
|
75
|
+
}
|
|
76
|
+
}, [enabledViews, currentView])
|
|
77
|
+
|
|
78
|
+
const flatItems = useMemo<DynamicRecord[]>(() => {
|
|
79
|
+
const cf = treeConfig?.childrenField ?? treeShape.childrenField
|
|
80
|
+
if (!cf) return items
|
|
81
|
+
return flattenAll(items, cf)
|
|
82
|
+
}, [items, treeConfig?.childrenField, treeShape.childrenField])
|
|
83
|
+
|
|
84
|
+
const detectedFields = useMemo<FieldConfig[]>(() => {
|
|
85
|
+
if (!flatItems || flatItems.length === 0) return []
|
|
86
|
+
const detected = detectFields(flatItems)
|
|
87
|
+
return mergeFields(detected, fields)
|
|
88
|
+
}, [flatItems, fields])
|
|
89
|
+
|
|
90
|
+
const resolvedFields = useMemo<FieldConfig[]>(() => {
|
|
91
|
+
if (detectedFields.length === 0) return detectedFields
|
|
92
|
+
const overrides = new Map(config.tableColumns.map((c) => [c.id, c]))
|
|
93
|
+
return detectedFields
|
|
94
|
+
.map((f) => {
|
|
95
|
+
const o = overrides.get(f.path)
|
|
96
|
+
if (!o) return f
|
|
97
|
+
return { ...f, visible: o.visible, order: o.order }
|
|
98
|
+
})
|
|
99
|
+
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
100
|
+
}, [detectedFields, config.tableColumns])
|
|
101
|
+
|
|
102
|
+
const detectedColumns = useMemo<DynamicColumnConfig[]>(() => {
|
|
103
|
+
if (!flatItems || flatItems.length === 0) return []
|
|
104
|
+
const detected = detectColumns(flatItems)
|
|
105
|
+
return mergeColumns(detected, columns)
|
|
106
|
+
}, [flatItems, columns])
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (detectedFields.length === 0) return
|
|
110
|
+
setConfig((prev) => {
|
|
111
|
+
const prevByPath = new Map(prev.tableColumns.map((c) => [c.id, c]))
|
|
112
|
+
const next: typeof prev.tableColumns = []
|
|
113
|
+
let order = 0
|
|
114
|
+
for (const f of detectedFields) {
|
|
115
|
+
if (f.type === "hidden") continue
|
|
116
|
+
const existing = prevByPath.get(f.path)
|
|
117
|
+
next.push({
|
|
118
|
+
id: f.path,
|
|
119
|
+
label: f.label ?? f.path,
|
|
120
|
+
visible: existing?.visible ?? f.visible !== false,
|
|
121
|
+
order: existing?.order ?? order,
|
|
122
|
+
})
|
|
123
|
+
order++
|
|
124
|
+
prevByPath.delete(f.path)
|
|
125
|
+
}
|
|
126
|
+
const stale = Array.from(prevByPath.values())
|
|
127
|
+
const carried = [...next, ...stale].slice(0, 100)
|
|
128
|
+
if (
|
|
129
|
+
carried.length === prev.tableColumns.length &&
|
|
130
|
+
carried.every((c, i) => {
|
|
131
|
+
const p = prev.tableColumns[i]
|
|
132
|
+
return p && p.id === c.id && p.label === c.label && p.visible === c.visible && p.order === c.order
|
|
133
|
+
})
|
|
134
|
+
) {
|
|
135
|
+
return prev
|
|
136
|
+
}
|
|
137
|
+
return { ...prev, tableColumns: carried }
|
|
138
|
+
})
|
|
139
|
+
}, [detectedFields])
|
|
140
|
+
|
|
141
|
+
const handleConfigChange = (newConfig: Partial<ViewConfig>) => {
|
|
142
|
+
setConfig((prev) => ({ ...prev, ...newConfig }))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const handleDataUpdate = (updatedData: DynamicRecord[]) => {
|
|
146
|
+
setItems(updatedData)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const handleFilterChange = (newFilters: FilterState) => {
|
|
150
|
+
if (onFilterChange) onFilterChange(newFilters)
|
|
151
|
+
else setInternalFilterState(newFilters)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
items,
|
|
156
|
+
flatItems,
|
|
157
|
+
resolvedFields,
|
|
158
|
+
detectedColumns,
|
|
159
|
+
config,
|
|
160
|
+
setConfig: handleConfigChange,
|
|
161
|
+
currentView,
|
|
162
|
+
setCurrentView,
|
|
163
|
+
filterState: activeFilterState,
|
|
164
|
+
setFilterState: handleFilterChange,
|
|
165
|
+
onDataUpdate: handleDataUpdate,
|
|
166
|
+
treeShape,
|
|
167
|
+
enabledViews,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
|
|
5
|
+
const MOBILE_BREAKPOINT = 768
|
|
6
|
+
|
|
7
|
+
export function useIsMobile() {
|
|
8
|
+
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
|
9
|
+
|
|
10
|
+
React.useEffect(() => {
|
|
11
|
+
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
|
12
|
+
const onChange = () => {
|
|
13
|
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
14
|
+
}
|
|
15
|
+
mql.addEventListener("change", onChange)
|
|
16
|
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
17
|
+
return () => mql.removeEventListener("change", onChange)
|
|
18
|
+
}, [])
|
|
19
|
+
|
|
20
|
+
return !!isMobile
|
|
21
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { DynamicColumnConfig, DynamicRecord } from "../../components/DataViews/types"
|
|
2
|
+
import { isPlainObject } from "./nestedDataUtils"
|
|
3
|
+
|
|
4
|
+
export function detectColumns(data: DynamicRecord[]): DynamicColumnConfig[] {
|
|
5
|
+
if (!data || data.length === 0) {
|
|
6
|
+
return []
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const allKeys = new Set<string>()
|
|
10
|
+
data.forEach((item) => {
|
|
11
|
+
Object.keys(item).forEach((key) => allKeys.add(key))
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const columns: DynamicColumnConfig[] = []
|
|
15
|
+
let order = 0
|
|
16
|
+
|
|
17
|
+
allKeys.forEach((key) => {
|
|
18
|
+
if (key.startsWith("_")) return
|
|
19
|
+
|
|
20
|
+
const firstValue = data.find((item) => item[key] != null)?.[key]
|
|
21
|
+
|
|
22
|
+
if (isPlainObject(firstValue)) return
|
|
23
|
+
if (Array.isArray(firstValue) && firstValue.length > 0 && isPlainObject(firstValue[0])) return
|
|
24
|
+
|
|
25
|
+
const type = inferColumnType(key, firstValue)
|
|
26
|
+
|
|
27
|
+
columns.push({
|
|
28
|
+
id: key,
|
|
29
|
+
label: formatLabel(key),
|
|
30
|
+
visible: true,
|
|
31
|
+
order: order++,
|
|
32
|
+
type,
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
return columns.sort((a, b) => {
|
|
37
|
+
if (a.id === "id") return -1
|
|
38
|
+
if (b.id === "id") return 1
|
|
39
|
+
return a.label.localeCompare(b.label)
|
|
40
|
+
}).map((col, idx) => ({ ...col, order: idx }))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function inferColumnType(
|
|
44
|
+
key: string,
|
|
45
|
+
value: any
|
|
46
|
+
): "text" | "number" | "date" | "badge" | "array" | "boolean" {
|
|
47
|
+
if (key.toLowerCase().includes("date") || key.toLowerCase().includes("time")) {
|
|
48
|
+
return "date"
|
|
49
|
+
}
|
|
50
|
+
if (key.toLowerCase().includes("status") || key.toLowerCase().includes("priority")) {
|
|
51
|
+
return "badge"
|
|
52
|
+
}
|
|
53
|
+
if (key.toLowerCase().includes("tag") || key.toLowerCase().includes("label")) {
|
|
54
|
+
return "array"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (value === null || value === undefined) {
|
|
58
|
+
return "text"
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (typeof value === "boolean") {
|
|
62
|
+
return "boolean"
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (typeof value === "number") {
|
|
66
|
+
return "number"
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (Array.isArray(value)) {
|
|
70
|
+
return "array"
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (typeof value === "string") {
|
|
74
|
+
if (!isNaN(Date.parse(value)) && /^\d{4}-\d{2}-\d{2}/.test(value)) {
|
|
75
|
+
return "date"
|
|
76
|
+
}
|
|
77
|
+
return "text"
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return "text"
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function formatLabel(key: string): string {
|
|
84
|
+
if (key.includes("_")) {
|
|
85
|
+
return key
|
|
86
|
+
.split("_")
|
|
87
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
88
|
+
.join(" ")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return key
|
|
92
|
+
.replace(/([A-Z])/g, " $1")
|
|
93
|
+
.replace(/^./, (str) => str.toUpperCase())
|
|
94
|
+
.trim()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function mergeColumns(
|
|
98
|
+
detected: DynamicColumnConfig[],
|
|
99
|
+
custom?: Partial<DynamicColumnConfig>[]
|
|
100
|
+
): DynamicColumnConfig[] {
|
|
101
|
+
if (!custom || custom.length === 0) {
|
|
102
|
+
return detected
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const merged = [...detected]
|
|
106
|
+
const customMap = new Map(custom.map((col) => [col.id, col]))
|
|
107
|
+
|
|
108
|
+
merged.forEach((col, idx) => {
|
|
109
|
+
const customCol = customMap.get(col.id)
|
|
110
|
+
if (customCol) {
|
|
111
|
+
merged[idx] = { ...col, ...customCol }
|
|
112
|
+
customMap.delete(col.id)
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
customMap.forEach((customCol) => {
|
|
117
|
+
if (customCol.id) {
|
|
118
|
+
merged.push({
|
|
119
|
+
id: customCol.id,
|
|
120
|
+
label: customCol.label || formatLabel(customCol.id),
|
|
121
|
+
visible: customCol.visible ?? true,
|
|
122
|
+
order: customCol.order ?? merged.length,
|
|
123
|
+
type: customCol.type || "text",
|
|
124
|
+
render: customCol.render,
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
return merged.sort((a, b) => a.order - b.order)
|
|
130
|
+
}
|