datool 0.0.4 → 0.0.6

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.
@@ -2,6 +2,7 @@ import * as React from "react"
2
2
  import {
3
3
  DownloadIcon,
4
4
  EllipsisIcon,
5
+ LayoutGridIcon,
5
6
  MoonIcon,
6
7
  Settings2Icon,
7
8
  SunIcon,
@@ -38,9 +39,12 @@ type ViewerSettingsColumn = {
38
39
 
39
40
  type ViewerSettingsProps = {
40
41
  columns: ViewerSettingsColumn[]
42
+ groupedColumnIds: string[]
41
43
  isDisabled?: boolean
42
44
  onExportCsv: () => void
43
45
  onExportMarkdown: () => void
46
+ onClearGrouping: () => void
47
+ onToggleGrouping: (columnId: string, grouped: boolean) => void
44
48
  onToggleColumn: (columnId: string, visible: boolean) => void
45
49
  className?: string
46
50
  }
@@ -69,14 +73,26 @@ const THEME_OPTIONS: Array<{
69
73
 
70
74
  export function ViewerSettings({
71
75
  columns,
76
+ groupedColumnIds,
72
77
  isDisabled = false,
73
78
  onExportCsv,
74
79
  onExportMarkdown,
80
+ onClearGrouping,
81
+ onToggleGrouping,
75
82
  onToggleColumn,
76
83
  className,
77
84
  }: ViewerSettingsProps) {
78
85
  const { theme, setTheme } = useTheme()
79
86
  const canExport = !isDisabled && columns.length > 0
87
+ const groupedLabels = React.useMemo(
88
+ () =>
89
+ groupedColumnIds.flatMap((columnId) => {
90
+ const column = columns.find((candidate) => candidate.id === columnId)
91
+
92
+ return column ? [column.label] : []
93
+ }),
94
+ [columns, groupedColumnIds]
95
+ )
80
96
 
81
97
  return (
82
98
  <DropdownMenu>
@@ -131,6 +147,57 @@ export function ViewerSettings({
131
147
  ))}
132
148
  </DropdownMenuSubContent>
133
149
  </DropdownMenuSub>
150
+ <DropdownMenuSub>
151
+ <DropdownMenuSubTrigger
152
+ className="min-h-9 text-sm"
153
+ disabled={isDisabled || columns.length === 0}
154
+ >
155
+ <LayoutGridIcon className="size-4 text-muted-foreground" />
156
+ Group rows
157
+ </DropdownMenuSubTrigger>
158
+ <DropdownMenuSubContent className="max-h-80 w-64 overflow-y-auto">
159
+ <DropdownMenuLabel>
160
+ {groupedLabels.length > 0
161
+ ? `Grouped by ${groupedLabels.join(", ")}`
162
+ : "Group rows by field"}
163
+ </DropdownMenuLabel>
164
+ <DropdownMenuSeparator />
165
+ <DropdownMenuItem
166
+ className="min-h-9 text-sm"
167
+ disabled={groupedColumnIds.length === 0}
168
+ onSelect={(event) => {
169
+ event.preventDefault()
170
+ onClearGrouping()
171
+ }}
172
+ >
173
+ Clear grouping
174
+ </DropdownMenuItem>
175
+ <DropdownMenuSeparator />
176
+ {columns.map((column) => (
177
+ <DropdownMenuCheckboxItem
178
+ key={column.id}
179
+ checked={groupedColumnIds.includes(column.id)}
180
+ className="min-h-9 text-sm"
181
+ onSelect={(event) => {
182
+ event.preventDefault()
183
+ }}
184
+ onCheckedChange={(checked) =>
185
+ onToggleGrouping(column.id, checked === true)
186
+ }
187
+ >
188
+ <span className="flex min-w-0 items-center gap-2 pr-4">
189
+ {column.kind ? (
190
+ <DataTableColIcon
191
+ kind={column.kind}
192
+ className="size-4 shrink-0 text-muted-foreground"
193
+ />
194
+ ) : null}
195
+ <span className="truncate">{column.label}</span>
196
+ </span>
197
+ </DropdownMenuCheckboxItem>
198
+ ))}
199
+ </DropdownMenuSubContent>
200
+ </DropdownMenuSub>
134
201
  <DropdownMenuSub>
135
202
  <DropdownMenuSubTrigger className="min-h-9 text-sm">
136
203
  <SunIcon className="size-4 text-muted-foreground dark:hidden" />
@@ -17,6 +17,7 @@
17
17
  --secondary: oklch(0.967 0.001 286.375);
18
18
  --secondary-foreground: oklch(0.21 0.006 285.885);
19
19
  --muted: oklch(0.967 0.001 286.375);
20
+ --table-gap: oklch(0.967 0.001 286.375);
20
21
  --muted-foreground: oklch(0.552 0.016 285.938);
21
22
  --accent: oklch(0.967 0.001 286.375);
22
23
  --accent-foreground: oklch(0.21 0.006 285.885);
@@ -52,6 +53,7 @@
52
53
  --secondary: oklch(0.274 0.006 286.033);
53
54
  --secondary-foreground: oklch(0.985 0 0);
54
55
  --muted: oklch(0.274 0.006 286.033);
56
+ --table-gap: oklch(0.180 0.005 285.823);
55
57
  --muted-foreground: oklch(0.705 0.015 286.067);
56
58
  --accent: oklch(0.274 0.006 286.033);
57
59
  --accent-foreground: oklch(0.985 0 0);
@@ -97,6 +99,7 @@
97
99
  --color-accent: var(--accent);
98
100
  --color-muted-foreground: var(--muted-foreground);
99
101
  --color-muted: var(--muted);
102
+ --color-table-gap: var(--table-gap);
100
103
  --color-secondary-foreground: var(--secondary-foreground);
101
104
  --color-secondary: var(--secondary);
102
105
  --color-primary-foreground: var(--primary-foreground);
@@ -5,6 +5,7 @@ type PersistedTableState = {
5
5
  columnSizing?: Record<string, number>
6
6
  columnVisibility?: VisibilityState
7
7
  globalFilter?: string
8
+ groupBy?: string[]
8
9
  highlightedColumns?: Record<string, boolean>
9
10
  sorting?: unknown[]
10
11
  }
@@ -23,7 +24,9 @@ function getTableUrlParam(tableId: string) {
23
24
 
24
25
  function isDatoolUrlParam(key: string) {
25
26
  return (
26
- key.startsWith(`${DATA_TABLE_URL_PARAM_PREFIX}${LOG_VIEWER_TABLE_ID_PREFIX}`) ||
27
+ key.startsWith(
28
+ `${DATA_TABLE_URL_PARAM_PREFIX}${LOG_VIEWER_TABLE_ID_PREFIX}`
29
+ ) ||
27
30
  (key.startsWith(`${LOG_VIEWER_TABLE_ID_PREFIX}-`) && key.endsWith("-search"))
28
31
  )
29
32
  }
@@ -57,6 +60,14 @@ function sanitizeColumnVisibility(
57
60
  )
58
61
  }
59
62
 
63
+ function sanitizeGroupBy(groupBy: string[] | undefined, columnIds: string[]) {
64
+ const validIds = new Set(columnIds)
65
+
66
+ return (groupBy ?? []).filter((columnId, index, values) => {
67
+ return validIds.has(columnId) && values.indexOf(columnId) === index
68
+ })
69
+ }
70
+
60
71
  function cleanUpDatoolParams(url: URL, tableId: string) {
61
72
  const activeSearchParam = getSearchUrlParam(tableId)
62
73
  const activeTableParam = getTableUrlParam(tableId)
@@ -89,7 +100,10 @@ export function readDatoolSearch(tableId: string) {
89
100
  return ""
90
101
  }
91
102
 
92
- return new URL(window.location.href).searchParams.get(getSearchUrlParam(tableId)) ?? ""
103
+ return (
104
+ new URL(window.location.href).searchParams.get(getSearchUrlParam(tableId)) ??
105
+ ""
106
+ )
93
107
  }
94
108
 
95
109
  export function readDatoolColumnVisibility(
@@ -102,15 +116,21 @@ export function readDatoolColumnVisibility(
102
116
  )
103
117
  }
104
118
 
119
+ export function readDatoolGrouping(tableId: string, columnIds: string[]) {
120
+ return sanitizeGroupBy(readPersistedTableState(tableId)?.groupBy, columnIds)
121
+ }
122
+
105
123
  export function writeDatoolUrlState({
106
124
  columnIds,
107
125
  columnVisibility,
126
+ groupBy,
108
127
  search,
109
128
  selectedStreamId,
110
129
  tableId,
111
130
  }: {
112
131
  columnIds: string[]
113
132
  columnVisibility: VisibilityState
133
+ groupBy: string[]
114
134
  search: string
115
135
  selectedStreamId: string | null
116
136
  tableId: string
@@ -125,6 +145,7 @@ export function writeDatoolUrlState({
125
145
  columnVisibility,
126
146
  columnIds
127
147
  )
148
+ const nextGroupBy = sanitizeGroupBy(groupBy, columnIds)
128
149
  const nextTableState = {
129
150
  ...readPersistedTableState(tableId),
130
151
  } satisfies PersistedTableState
@@ -149,6 +170,12 @@ export function writeDatoolUrlState({
149
170
  delete nextTableState.columnVisibility
150
171
  }
151
172
 
173
+ if (nextGroupBy.length > 0) {
174
+ nextTableState.groupBy = nextGroupBy
175
+ } else {
176
+ delete nextTableState.groupBy
177
+ }
178
+
152
179
  if (Object.keys(nextTableState).length > 0) {
153
180
  url.searchParams.set(getTableUrlParam(tableId), JSON.stringify(nextTableState))
154
181
  } else {
@@ -124,6 +124,25 @@ function assertActionShape(
124
124
  assertActionButtonShape(streamId, actionId, value.button)
125
125
  }
126
126
 
127
+ function assertDateFormatShape(value: unknown) {
128
+ if (value === undefined) {
129
+ return
130
+ }
131
+
132
+ if (!isRecord(value) || Array.isArray(value)) {
133
+ throw new Error("datool.config.ts dateFormat must be an object.")
134
+ }
135
+
136
+ try {
137
+ new Intl.DateTimeFormat(
138
+ undefined,
139
+ value as Intl.DateTimeFormatOptions
140
+ )
141
+ } catch {
142
+ throw new Error("datool.config.ts defines an invalid dateFormat.")
143
+ }
144
+ }
145
+
127
146
  function assertStreamShape(streamId: string, value: unknown) {
128
147
  if (!isRecord(value)) {
129
148
  throw new Error(`Stream "${streamId}" must be an object.`)
@@ -198,6 +217,8 @@ export function validateDatoolConfig(
198
217
  throw new Error("datool.config.ts must export an object.")
199
218
  }
200
219
 
220
+ assertDateFormatShape(config.dateFormat)
221
+
201
222
  if (!isRecord(config.streams) || Object.keys(config.streams).length === 0) {
202
223
  throw new Error("datool.config.ts must define at least one stream.")
203
224
  }
@@ -209,6 +230,7 @@ export function validateDatoolConfig(
209
230
 
210
231
  export function toClientConfig(config: DatoolConfig): DatoolClientConfig {
211
232
  return {
233
+ dateFormat: config.dateFormat,
212
234
  streams: Object.entries(config.streams).map(([id, stream]) => ({
213
235
  actions: Object.entries(stream.actions ?? {}).map(([actionId, action]) => ({
214
236
  button: action.button,
@@ -89,6 +89,8 @@ export type DatoolActionButtonConfig =
89
89
  variant?: DatoolActionButtonVariant
90
90
  }
91
91
 
92
+ export type DatoolDateFormat = Intl.DateTimeFormatOptions
93
+
92
94
  export type DatoolColumn = {
93
95
  accessorKey: string
94
96
  align?: "left" | "center" | "right"
@@ -168,6 +170,7 @@ export type DatoolStream<Row extends Record<string, unknown>> = {
168
170
  }
169
171
 
170
172
  export type DatoolConfig = {
173
+ dateFormat?: DatoolDateFormat
171
174
  server?: {
172
175
  host?: string
173
176
  port?: number
@@ -195,6 +198,7 @@ export type DatoolClientAction = {
195
198
  }
196
199
 
197
200
  export type DatoolClientConfig = {
201
+ dateFormat?: DatoolDateFormat
198
202
  streams: DatoolClientStream[]
199
203
  }
200
204