@torch-ui/solid 0.1.3
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/README.md +166 -0
- package/package.json +67 -0
- package/src/components/actions/Button.tsx +612 -0
- package/src/components/actions/ButtonGroup.tsx +728 -0
- package/src/components/actions/Copy.tsx +98 -0
- package/src/components/actions/DarkModeToggle.tsx +80 -0
- package/src/components/actions/Link.tsx +37 -0
- package/src/components/actions/index.ts +19 -0
- package/src/components/actions/useCopyToClipboard.ts +90 -0
- package/src/components/charts/Chart.tsx +331 -0
- package/src/components/charts/Sparkline.tsx +156 -0
- package/src/components/charts/index.ts +13 -0
- package/src/components/data-display/Avatar.tsx +208 -0
- package/src/components/data-display/AvatarGroup.tsx +228 -0
- package/src/components/data-display/Badge.tsx +70 -0
- package/src/components/data-display/Carousel.tsx +214 -0
- package/src/components/data-display/ColorSwatch.tsx +56 -0
- package/src/components/data-display/DataTable.tsx +886 -0
- package/src/components/data-display/EmptyState.tsx +61 -0
- package/src/components/data-display/Image.tsx +277 -0
- package/src/components/data-display/Kbd.tsx +114 -0
- package/src/components/data-display/Persona.tsx +78 -0
- package/src/components/data-display/StatCard.tsx +338 -0
- package/src/components/data-display/Table.tsx +147 -0
- package/src/components/data-display/Tag.tsx +91 -0
- package/src/components/data-display/Timeline.tsx +200 -0
- package/src/components/data-display/TreeView.tsx +172 -0
- package/src/components/data-display/Video.tsx +95 -0
- package/src/components/data-display/avatar-utils.ts +32 -0
- package/src/components/data-display/index.ts +81 -0
- package/src/components/feedback/Loading.tsx +159 -0
- package/src/components/feedback/Progress.tsx +321 -0
- package/src/components/feedback/Skeleton.tsx +62 -0
- package/src/components/feedback/SkeletonBlocks.tsx +222 -0
- package/src/components/feedback/Toast.tsx +648 -0
- package/src/components/feedback/index.ts +44 -0
- package/src/components/feedback/password/PasswordStrengthIndicator.tsx +232 -0
- package/src/components/feedback/password/password-strength.ts +115 -0
- package/src/components/feedback/password/password-validation-data.ts +66 -0
- package/src/components/feedback/password/password-validation.ts +93 -0
- package/src/components/forms/Autocomplete.tsx +268 -0
- package/src/components/forms/Checkbox.tsx +155 -0
- package/src/components/forms/CodeInput.tsx +237 -0
- package/src/components/forms/ColorPicker/ColorPicker.tsx +469 -0
- package/src/components/forms/ColorPicker/color-utils.ts +75 -0
- package/src/components/forms/ColorPicker/index.ts +2 -0
- package/src/components/forms/DatePicker.tsx +516 -0
- package/src/components/forms/DateRangePicker.tsx +464 -0
- package/src/components/forms/FieldPicker.tsx +64 -0
- package/src/components/forms/FileUpload.tsx +614 -0
- package/src/components/forms/FilterBuilder/FilterGroupBlock.ts +6 -0
- package/src/components/forms/FilterBuilder.tsx +16 -0
- package/src/components/forms/FilterRuleRow.tsx +68 -0
- package/src/components/forms/Input.tsx +200 -0
- package/src/components/forms/MultiSelect.tsx +361 -0
- package/src/components/forms/NumberField.tsx +145 -0
- package/src/components/forms/RadioGroup.tsx +135 -0
- package/src/components/forms/RelativeDateDefaultInput.tsx +62 -0
- package/src/components/forms/ReorderableList.tsx +163 -0
- package/src/components/forms/Select.tsx +268 -0
- package/src/components/forms/Slider.tsx +260 -0
- package/src/components/forms/Switch.tsx +135 -0
- package/src/components/forms/TextArea.tsx +202 -0
- package/src/components/forms/ViewCustomizer.tsx +44 -0
- package/src/components/forms/index.ts +43 -0
- package/src/components/layout/Accordion.tsx +110 -0
- package/src/components/layout/Alert.tsx +156 -0
- package/src/components/layout/BlockQuote.tsx +70 -0
- package/src/components/layout/Card.tsx +166 -0
- package/src/components/layout/CodeBlock/CodeBlock.tsx +477 -0
- package/src/components/layout/CodeBlock/code-block-tokens.css +104 -0
- package/src/components/layout/CodeBlock/prism.ts +81 -0
- package/src/components/layout/Collapsible.tsx +84 -0
- package/src/components/layout/Container.tsx +55 -0
- package/src/components/layout/Divider.tsx +64 -0
- package/src/components/layout/Form.tsx +39 -0
- package/src/components/layout/FormActions.tsx +50 -0
- package/src/components/layout/Grid.tsx +53 -0
- package/src/components/layout/PageHeading.tsx +46 -0
- package/src/components/layout/PromptWithAction.tsx +49 -0
- package/src/components/layout/Section.tsx +60 -0
- package/src/components/layout/TablePanel.tsx +24 -0
- package/src/components/layout/TableView/TableView.tsx +1018 -0
- package/src/components/layout/TableView/index.ts +3 -0
- package/src/components/layout/TableView/types.ts +51 -0
- package/src/components/layout/WizardStep.tsx +40 -0
- package/src/components/layout/WizardStepper.tsx +173 -0
- package/src/components/layout/index.ts +96 -0
- package/src/components/navigation/Breadcrumbs.tsx +66 -0
- package/src/components/navigation/DropdownMenu.tsx +86 -0
- package/src/components/navigation/MegaMenu.tsx +480 -0
- package/src/components/navigation/NavigationMenu.tsx +305 -0
- package/src/components/navigation/Pagination.tsx +298 -0
- package/src/components/navigation/Sidebar.tsx +280 -0
- package/src/components/navigation/Tabs.tsx +122 -0
- package/src/components/navigation/ViewSwitcher.tsx +314 -0
- package/src/components/navigation/index.ts +66 -0
- package/src/components/overlays/AlertDialog.tsx +174 -0
- package/src/components/overlays/ContextMenu.tsx +65 -0
- package/src/components/overlays/Dialog.tsx +279 -0
- package/src/components/overlays/Drawer.tsx +370 -0
- package/src/components/overlays/HoverCard.tsx +107 -0
- package/src/components/overlays/Popover.tsx +73 -0
- package/src/components/overlays/Tooltip.tsx +31 -0
- package/src/components/overlays/index.ts +71 -0
- package/src/components/typography/Code.tsx +72 -0
- package/src/components/typography/Icon.tsx +36 -0
- package/src/components/typography/index.ts +10 -0
- package/src/env.d.ts +9 -0
- package/src/index.ts +13 -0
- package/src/styles/theme.css +226 -0
- package/src/types/avatar-types.ts +11 -0
- package/src/types/filter-types.ts +35 -0
- package/src/utilities/classNames.ts +6 -0
- package/src/utilities/componentSize.ts +46 -0
- package/src/utilities/i18n.tsx +60 -0
- package/src/utilities/mergeRefs.ts +12 -0
- package/src/utilities/relativeDateDefault.ts +14 -0
|
@@ -0,0 +1,1018 @@
|
|
|
1
|
+
import type { JSX } from 'solid-js'
|
|
2
|
+
|
|
3
|
+
import { createContext, createEffect, createSignal, on, onMount, Show, useContext } from 'solid-js'
|
|
4
|
+
|
|
5
|
+
import { Plus } from 'lucide-solid'
|
|
6
|
+
|
|
7
|
+
import { ViewCustomizer } from '../../forms'
|
|
8
|
+
|
|
9
|
+
import { FilterBuilder } from '../../forms/FilterBuilder'
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
|
|
13
|
+
formatFilterSummary,
|
|
14
|
+
|
|
15
|
+
formatFilterCode,
|
|
16
|
+
|
|
17
|
+
assignRuleNumbers,
|
|
18
|
+
|
|
19
|
+
hasRulesWithEmptyField,
|
|
20
|
+
|
|
21
|
+
type FilterGroup,
|
|
22
|
+
|
|
23
|
+
} from '../../../types/filter-types'
|
|
24
|
+
|
|
25
|
+
import { Drawer } from '../../overlays/Drawer'
|
|
26
|
+
|
|
27
|
+
import { ViewSwitcher } from '../../navigation/ViewSwitcher'
|
|
28
|
+
|
|
29
|
+
import type { TableViewConfig, TableViewsApi, ViewConfig } from './types'
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
const createFilterId = (prefix: string) =>
|
|
34
|
+
|
|
35
|
+
`${prefix}-${Math.random().toString(36).slice(2, 9)}`
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
export const emptyFilterGroup = (): FilterGroup => ({
|
|
40
|
+
|
|
41
|
+
id: createFilterId('group'),
|
|
42
|
+
|
|
43
|
+
type: 'and',
|
|
44
|
+
|
|
45
|
+
logic: 'and',
|
|
46
|
+
|
|
47
|
+
children: [],
|
|
48
|
+
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
function getDefaultViewId(views: ViewConfig[], systemDefaultId: string): string {
|
|
54
|
+
|
|
55
|
+
const pinned = views.find((v) => v.pinned)
|
|
56
|
+
|
|
57
|
+
return pinned?.id ?? systemDefaultId
|
|
58
|
+
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
export interface TableViewContextValue {
|
|
66
|
+
|
|
67
|
+
views: () => ViewConfig[]
|
|
68
|
+
|
|
69
|
+
activeView: () => string
|
|
70
|
+
|
|
71
|
+
setActiveView: (id: string) => void
|
|
72
|
+
|
|
73
|
+
activeViewConfig: () => ViewConfig | undefined
|
|
74
|
+
|
|
75
|
+
/** False when viewing the system default view (e.g. All contacts) - use to disable Customize button */
|
|
76
|
+
|
|
77
|
+
canCustomize: () => boolean
|
|
78
|
+
|
|
79
|
+
openCustomize: () => void
|
|
80
|
+
|
|
81
|
+
openFilters: () => void
|
|
82
|
+
|
|
83
|
+
openCustomizeForEdit: () => void
|
|
84
|
+
|
|
85
|
+
openCustomizeForCreate: () => void
|
|
86
|
+
|
|
87
|
+
/** Compact single-line summary of active filters (e.g. "Status is any of Active, Inactive") */
|
|
88
|
+
|
|
89
|
+
activeFilterSummary: () => string
|
|
90
|
+
|
|
91
|
+
/** Current filter group - use to filter displayed data */
|
|
92
|
+
|
|
93
|
+
activeFilterGroup: () => FilterGroup
|
|
94
|
+
|
|
95
|
+
/** Report filtered row count for badge display. Call from table/content when filters are applied. */
|
|
96
|
+
|
|
97
|
+
setFilteredRowCount: (count: number | null) => void
|
|
98
|
+
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
const TableViewContext = createContext<TableViewContextValue>()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
export function useTableView() {
|
|
108
|
+
|
|
109
|
+
const ctx = useContext(TableViewContext)
|
|
110
|
+
|
|
111
|
+
if (!ctx) {
|
|
112
|
+
|
|
113
|
+
throw new Error('useTableView must be used within TableView')
|
|
114
|
+
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return ctx
|
|
118
|
+
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
124
|
+
|
|
125
|
+
function isDbViewId(id: string) {
|
|
126
|
+
|
|
127
|
+
return UUID_REGEX.test(id)
|
|
128
|
+
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
export interface TableViewProps {
|
|
134
|
+
|
|
135
|
+
config: TableViewConfig
|
|
136
|
+
|
|
137
|
+
/** When provided, views are fetched from and saved to the API. Requires config.entityType. */
|
|
138
|
+
|
|
139
|
+
viewsApi?: TableViewsApi
|
|
140
|
+
|
|
141
|
+
/** Toolbar content. Receives API with openCustomizeForEdit, openFilters, etc. */
|
|
142
|
+
|
|
143
|
+
toolbar?: (api: TableViewContextValue) => JSX.Element
|
|
144
|
+
|
|
145
|
+
/** Main table/content area */
|
|
146
|
+
|
|
147
|
+
children: JSX.Element
|
|
148
|
+
|
|
149
|
+
class?: string
|
|
150
|
+
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
export function TableView(props: TableViewProps) {
|
|
156
|
+
|
|
157
|
+
const config = () => props.config
|
|
158
|
+
|
|
159
|
+
const [showCustomize, setShowCustomize] = createSignal(false)
|
|
160
|
+
|
|
161
|
+
const [customizeMode, setCustomizeMode] = createSignal<'edit' | 'create'>('edit')
|
|
162
|
+
|
|
163
|
+
const [showFilters, setShowFilters] = createSignal(false)
|
|
164
|
+
|
|
165
|
+
const [views, setViews] = createSignal(config().defaultViews)
|
|
166
|
+
|
|
167
|
+
const [activeView, setActiveView] = createSignal(
|
|
168
|
+
|
|
169
|
+
getDefaultViewId(config().defaultViews, config().systemDefaultViewId),
|
|
170
|
+
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
const [saveError, setSaveError] = createSignal<string | null>(null)
|
|
174
|
+
|
|
175
|
+
const [filterSaveError, setFilterSaveError] = createSignal<string | null>(null)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
onMount(() => {
|
|
180
|
+
|
|
181
|
+
const api = props.viewsApi
|
|
182
|
+
|
|
183
|
+
const entityType = config().entityType
|
|
184
|
+
|
|
185
|
+
if (!api || !entityType) return
|
|
186
|
+
|
|
187
|
+
api.fetchViews(entityType).then(
|
|
188
|
+
|
|
189
|
+
(fetched: ViewConfig[]) => {
|
|
190
|
+
|
|
191
|
+
const systemIds = new Set(config().defaultViews.map((v) => v.id))
|
|
192
|
+
|
|
193
|
+
const merged = [
|
|
194
|
+
|
|
195
|
+
...config().defaultViews,
|
|
196
|
+
|
|
197
|
+
...fetched.filter((v: ViewConfig) => !systemIds.has(v.id)),
|
|
198
|
+
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
setViews(merged)
|
|
202
|
+
|
|
203
|
+
setActiveView(getDefaultViewId(merged, config().systemDefaultViewId))
|
|
204
|
+
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
(err: unknown) => {
|
|
208
|
+
|
|
209
|
+
console.error('[TableView] Failed to fetch views:', err)
|
|
210
|
+
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
const [viewName, setViewName] = createSignal('')
|
|
218
|
+
|
|
219
|
+
const [usedFields, setUsedFields] = createSignal([...config().initialFields])
|
|
220
|
+
|
|
221
|
+
const [groupBy, setGroupBy] = createSignal('none')
|
|
222
|
+
|
|
223
|
+
const [filterGroup, setFilterGroup] = createSignal<FilterGroup>(emptyFilterGroup())
|
|
224
|
+
|
|
225
|
+
const [filteredRowCount, setFilteredRowCount] = createSignal<number | null>(null)
|
|
226
|
+
|
|
227
|
+
const [scope, setScope] = createSignal<'user' | 'tenant'>('user')
|
|
228
|
+
|
|
229
|
+
const [pinned, setPinned] = createSignal(false)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
const createViewId = (name: string) => {
|
|
234
|
+
|
|
235
|
+
const base = name.toLowerCase().replace(/\s+/g, '-')
|
|
236
|
+
|
|
237
|
+
let candidate = base
|
|
238
|
+
|
|
239
|
+
let counter = 2
|
|
240
|
+
|
|
241
|
+
while (views().some((view) => view.id === candidate)) {
|
|
242
|
+
|
|
243
|
+
candidate = `${base}-${counter}`
|
|
244
|
+
|
|
245
|
+
counter += 1
|
|
246
|
+
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return candidate
|
|
250
|
+
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
const updateView = async () => {
|
|
256
|
+
|
|
257
|
+
const name = viewName().trim()
|
|
258
|
+
|
|
259
|
+
if (!name) return
|
|
260
|
+
|
|
261
|
+
const entityType = config().entityType
|
|
262
|
+
|
|
263
|
+
const api = props.viewsApi
|
|
264
|
+
|
|
265
|
+
const viewId = activeView()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
if (api && entityType && isDbViewId(viewId)) {
|
|
270
|
+
|
|
271
|
+
setSaveError(null)
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
|
|
275
|
+
await api.updateView(entityType, {
|
|
276
|
+
|
|
277
|
+
id: viewId,
|
|
278
|
+
|
|
279
|
+
label: name,
|
|
280
|
+
|
|
281
|
+
fields: [...usedFields()],
|
|
282
|
+
|
|
283
|
+
groupBy: groupBy(),
|
|
284
|
+
|
|
285
|
+
filters: { ...filterGroup() },
|
|
286
|
+
|
|
287
|
+
scope: scope(),
|
|
288
|
+
|
|
289
|
+
pinned: pinned(),
|
|
290
|
+
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
} catch (err) {
|
|
294
|
+
|
|
295
|
+
setSaveError(err instanceof Error ? err.message : 'Failed to save view')
|
|
296
|
+
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
setViews((current) =>
|
|
306
|
+
|
|
307
|
+
current.map((view) =>
|
|
308
|
+
|
|
309
|
+
view.id === viewId
|
|
310
|
+
|
|
311
|
+
? {
|
|
312
|
+
|
|
313
|
+
...view,
|
|
314
|
+
|
|
315
|
+
label: name,
|
|
316
|
+
|
|
317
|
+
fields: [...usedFields()],
|
|
318
|
+
|
|
319
|
+
groupBy: groupBy(),
|
|
320
|
+
|
|
321
|
+
filters: { ...filterGroup() },
|
|
322
|
+
|
|
323
|
+
scope: scope(),
|
|
324
|
+
|
|
325
|
+
pinned: pinned(),
|
|
326
|
+
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
: view,
|
|
330
|
+
|
|
331
|
+
),
|
|
332
|
+
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
setShowCustomize(false)
|
|
336
|
+
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
const saveViewAsNew = async () => {
|
|
342
|
+
|
|
343
|
+
const name = viewName().trim()
|
|
344
|
+
|
|
345
|
+
if (!name) return
|
|
346
|
+
|
|
347
|
+
const entityType = config().entityType
|
|
348
|
+
|
|
349
|
+
const api = props.viewsApi
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
if (api && entityType) {
|
|
354
|
+
|
|
355
|
+
setSaveError(null)
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
|
|
359
|
+
const created = await api.createView(entityType, {
|
|
360
|
+
|
|
361
|
+
label: name,
|
|
362
|
+
|
|
363
|
+
fields: [...usedFields()],
|
|
364
|
+
|
|
365
|
+
groupBy: groupBy(),
|
|
366
|
+
|
|
367
|
+
filters: { ...filterGroup() },
|
|
368
|
+
|
|
369
|
+
scope: scope(),
|
|
370
|
+
|
|
371
|
+
pinned: pinned(),
|
|
372
|
+
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
setViews((current) => [...current, created])
|
|
376
|
+
|
|
377
|
+
setActiveView(created.id)
|
|
378
|
+
|
|
379
|
+
setShowCustomize(false)
|
|
380
|
+
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
} catch (err) {
|
|
384
|
+
|
|
385
|
+
setSaveError(err instanceof Error ? err.message : 'Failed to create view')
|
|
386
|
+
|
|
387
|
+
return
|
|
388
|
+
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
const id = createViewId(name)
|
|
396
|
+
|
|
397
|
+
setViews((current) => [
|
|
398
|
+
|
|
399
|
+
...current,
|
|
400
|
+
|
|
401
|
+
{
|
|
402
|
+
|
|
403
|
+
id,
|
|
404
|
+
|
|
405
|
+
label: name,
|
|
406
|
+
|
|
407
|
+
fields: [...usedFields()],
|
|
408
|
+
|
|
409
|
+
groupBy: groupBy(),
|
|
410
|
+
|
|
411
|
+
filters: { ...filterGroup() },
|
|
412
|
+
|
|
413
|
+
scope: scope(),
|
|
414
|
+
|
|
415
|
+
pinned: pinned(),
|
|
416
|
+
|
|
417
|
+
},
|
|
418
|
+
|
|
419
|
+
])
|
|
420
|
+
|
|
421
|
+
setActiveView(id)
|
|
422
|
+
|
|
423
|
+
setShowCustomize(false)
|
|
424
|
+
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
const openCustomizeForEdit = () => {
|
|
430
|
+
|
|
431
|
+
if (activeView() === config().systemDefaultViewId) return
|
|
432
|
+
|
|
433
|
+
setCustomizeMode('edit')
|
|
434
|
+
|
|
435
|
+
setShowCustomize(true)
|
|
436
|
+
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
const openCustomizeForCreate = () => {
|
|
442
|
+
|
|
443
|
+
setCustomizeMode('create')
|
|
444
|
+
|
|
445
|
+
setViewName('New view')
|
|
446
|
+
|
|
447
|
+
setUsedFields([...config().initialFields])
|
|
448
|
+
|
|
449
|
+
setGroupBy('none')
|
|
450
|
+
|
|
451
|
+
// Keep current filters so user can create a view from their filtered results (e.g. "Active contacts SMS")
|
|
452
|
+
|
|
453
|
+
// Use deep copy to avoid state mutation bugs
|
|
454
|
+
|
|
455
|
+
setFilterGroup(JSON.parse(JSON.stringify(filterGroup())))
|
|
456
|
+
|
|
457
|
+
setScope('user')
|
|
458
|
+
|
|
459
|
+
setPinned(false)
|
|
460
|
+
|
|
461
|
+
setShowCustomize(true)
|
|
462
|
+
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
const closeCustomize = () => {
|
|
468
|
+
|
|
469
|
+
setSaveError(null)
|
|
470
|
+
|
|
471
|
+
setShowCustomize(false)
|
|
472
|
+
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
/** Save editingFilterGroup to filterGroup and active view */
|
|
478
|
+
|
|
479
|
+
const saveFiltersToView = async (): Promise<boolean> => {
|
|
480
|
+
|
|
481
|
+
const group = editingFilterGroup()
|
|
482
|
+
|
|
483
|
+
if (!group) return true
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
const viewId = activeView()
|
|
488
|
+
|
|
489
|
+
const current = views().find((v) => v.id === viewId)
|
|
490
|
+
|
|
491
|
+
if (!current) return true
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
const filtersCopy = JSON.parse(JSON.stringify(group))
|
|
496
|
+
|
|
497
|
+
const originalFilters = current.filters // Store original for revert
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
// Update local views first for optimistic UI
|
|
502
|
+
|
|
503
|
+
setViews((prev) =>
|
|
504
|
+
|
|
505
|
+
prev.map((v) => (v.id === viewId ? { ...v, filters: filtersCopy } : v))
|
|
506
|
+
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
// Only commit to live filterGroup after successful API save
|
|
512
|
+
|
|
513
|
+
const api = props.viewsApi
|
|
514
|
+
|
|
515
|
+
const entityType = config().entityType
|
|
516
|
+
|
|
517
|
+
if (api && entityType && isDbViewId(viewId)) {
|
|
518
|
+
|
|
519
|
+
setFilterSaveError(null)
|
|
520
|
+
|
|
521
|
+
try {
|
|
522
|
+
|
|
523
|
+
await api.updateView(entityType, {
|
|
524
|
+
|
|
525
|
+
...current,
|
|
526
|
+
|
|
527
|
+
filters: filtersCopy,
|
|
528
|
+
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
// Success: now commit to live filterGroup
|
|
532
|
+
|
|
533
|
+
setFilterGroup(filtersCopy)
|
|
534
|
+
|
|
535
|
+
} catch (err) {
|
|
536
|
+
|
|
537
|
+
// Failure: revert views to original filters and show error
|
|
538
|
+
|
|
539
|
+
setViews((prev) =>
|
|
540
|
+
|
|
541
|
+
prev.map((v) => (v.id === viewId ? { ...v, filters: originalFilters } : v))
|
|
542
|
+
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
setFilterSaveError(err instanceof Error ? err.message : 'Failed to save filters')
|
|
546
|
+
|
|
547
|
+
return false
|
|
548
|
+
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
} else {
|
|
552
|
+
|
|
553
|
+
// Non-db view: commit immediately
|
|
554
|
+
|
|
555
|
+
setFilterGroup(filtersCopy)
|
|
556
|
+
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
return true
|
|
562
|
+
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
const saveFilters = async () => {
|
|
568
|
+
|
|
569
|
+
const group = editingFilterGroup()
|
|
570
|
+
|
|
571
|
+
if (!group) return
|
|
572
|
+
|
|
573
|
+
if (hasRulesWithEmptyField(group)) {
|
|
574
|
+
|
|
575
|
+
setFilterSaveError('Please select a field for each condition.')
|
|
576
|
+
|
|
577
|
+
return
|
|
578
|
+
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const ok = await saveFiltersToView()
|
|
582
|
+
|
|
583
|
+
if (ok) {
|
|
584
|
+
|
|
585
|
+
setFilterSaveError(null)
|
|
586
|
+
|
|
587
|
+
setEditingFilterGroup(null)
|
|
588
|
+
|
|
589
|
+
setShowFilters(false)
|
|
590
|
+
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
/** Local editing state for filter drawer - isolated from filterGroup to avoid sync/reactivity issues */
|
|
598
|
+
|
|
599
|
+
const [editingFilterGroup, setEditingFilterGroup] = createSignal<FilterGroup | null>(null)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
const cancelFilters = () => {
|
|
604
|
+
|
|
605
|
+
setEditingFilterGroup(null)
|
|
606
|
+
|
|
607
|
+
setFilterSaveError(null)
|
|
608
|
+
|
|
609
|
+
setTimeout(() => setShowFilters(false), 0)
|
|
610
|
+
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
// Initialize local editing state ONLY when drawer opens; never overwrite during editing
|
|
616
|
+
|
|
617
|
+
createEffect(
|
|
618
|
+
|
|
619
|
+
on(showFilters, (open) => {
|
|
620
|
+
|
|
621
|
+
if (open) {
|
|
622
|
+
|
|
623
|
+
setEditingFilterGroup(JSON.parse(JSON.stringify(filterGroup())))
|
|
624
|
+
|
|
625
|
+
} else {
|
|
626
|
+
|
|
627
|
+
setEditingFilterGroup(null)
|
|
628
|
+
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
}),
|
|
632
|
+
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
const deleteView = async () => {
|
|
638
|
+
|
|
639
|
+
const current = views().find((v) => v.id === activeView())
|
|
640
|
+
|
|
641
|
+
if (!current || current.scope !== 'user') return
|
|
642
|
+
|
|
643
|
+
const entityType = config().entityType
|
|
644
|
+
|
|
645
|
+
const api = props.viewsApi
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
if (api && entityType && isDbViewId(current.id)) {
|
|
650
|
+
|
|
651
|
+
setSaveError(null)
|
|
652
|
+
|
|
653
|
+
try {
|
|
654
|
+
|
|
655
|
+
await api.deleteView(entityType, current.id)
|
|
656
|
+
|
|
657
|
+
} catch (err) {
|
|
658
|
+
|
|
659
|
+
setSaveError(err instanceof Error ? err.message : 'Failed to delete view')
|
|
660
|
+
|
|
661
|
+
return
|
|
662
|
+
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
const remaining = views().filter((v) => v.id !== current.id)
|
|
670
|
+
|
|
671
|
+
setViews(remaining)
|
|
672
|
+
|
|
673
|
+
setActiveView(getDefaultViewId(remaining, config().systemDefaultViewId))
|
|
674
|
+
|
|
675
|
+
setShowCustomize(false)
|
|
676
|
+
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
// Sync filterGroup when active view changes (so Filter drawer shows correct filters)
|
|
682
|
+
|
|
683
|
+
createEffect(() => {
|
|
684
|
+
|
|
685
|
+
if (showFilters()) return // Don't overwrite while user is editing in the filter drawer
|
|
686
|
+
|
|
687
|
+
const viewId = activeView()
|
|
688
|
+
|
|
689
|
+
const current = views().find((view) => view.id === viewId)
|
|
690
|
+
|
|
691
|
+
if (!current) return
|
|
692
|
+
|
|
693
|
+
setFilterGroup(JSON.parse(JSON.stringify(current.filters)))
|
|
694
|
+
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
// Load view data into Customize form when opening for edit
|
|
702
|
+
|
|
703
|
+
createEffect(() => {
|
|
704
|
+
|
|
705
|
+
if (!showCustomize() || customizeMode() !== 'edit') return
|
|
706
|
+
|
|
707
|
+
const current = views().find((view) => view.id === activeView())
|
|
708
|
+
|
|
709
|
+
if (!current) return
|
|
710
|
+
|
|
711
|
+
setViewName(current.label)
|
|
712
|
+
|
|
713
|
+
setUsedFields([...current.fields])
|
|
714
|
+
|
|
715
|
+
setGroupBy(current.groupBy)
|
|
716
|
+
|
|
717
|
+
setScope(current.scope)
|
|
718
|
+
|
|
719
|
+
setPinned(current.pinned ?? false)
|
|
720
|
+
|
|
721
|
+
setFilterGroup(JSON.parse(JSON.stringify(current.filters)))
|
|
722
|
+
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
const activeViewConfig = () => views().find((v) => v.id === activeView())
|
|
728
|
+
|
|
729
|
+
const canCustomize = () => activeView() !== config().systemDefaultViewId
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
const activeFilterSummary = () => {
|
|
734
|
+
|
|
735
|
+
const group = filterGroup()
|
|
736
|
+
|
|
737
|
+
if (!group.children.length) return ''
|
|
738
|
+
|
|
739
|
+
return formatFilterSummary(group, {
|
|
740
|
+
|
|
741
|
+
fields: config().filterFields,
|
|
742
|
+
|
|
743
|
+
getOperators: config().getOperatorsForType,
|
|
744
|
+
|
|
745
|
+
})
|
|
746
|
+
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
const contextValue: TableViewContextValue = {
|
|
752
|
+
|
|
753
|
+
views,
|
|
754
|
+
|
|
755
|
+
activeView,
|
|
756
|
+
|
|
757
|
+
setActiveView,
|
|
758
|
+
|
|
759
|
+
activeViewConfig,
|
|
760
|
+
|
|
761
|
+
canCustomize,
|
|
762
|
+
|
|
763
|
+
openCustomize: openCustomizeForEdit,
|
|
764
|
+
|
|
765
|
+
openFilters: () => setShowFilters(true),
|
|
766
|
+
|
|
767
|
+
openCustomizeForEdit,
|
|
768
|
+
|
|
769
|
+
openCustomizeForCreate,
|
|
770
|
+
|
|
771
|
+
activeFilterSummary,
|
|
772
|
+
|
|
773
|
+
activeFilterGroup: filterGroup,
|
|
774
|
+
|
|
775
|
+
setFilteredRowCount,
|
|
776
|
+
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
const sortedViews = () =>
|
|
782
|
+
|
|
783
|
+
[...views()].sort((a, b) => {
|
|
784
|
+
|
|
785
|
+
if (a.pinned && !b.pinned) return -1
|
|
786
|
+
|
|
787
|
+
if (!a.pinned && b.pinned) return 1
|
|
788
|
+
|
|
789
|
+
return 0
|
|
790
|
+
|
|
791
|
+
})
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
return (
|
|
796
|
+
|
|
797
|
+
<TableViewContext.Provider value={contextValue}>
|
|
798
|
+
|
|
799
|
+
<div class={props.class}>
|
|
800
|
+
|
|
801
|
+
<div class="min-w-0 w-full bg-ink-100/60">
|
|
802
|
+
|
|
803
|
+
<ViewSwitcher
|
|
804
|
+
|
|
805
|
+
views={sortedViews().map((view) => ({
|
|
806
|
+
|
|
807
|
+
id: view.id,
|
|
808
|
+
|
|
809
|
+
label: view.label,
|
|
810
|
+
|
|
811
|
+
count:
|
|
812
|
+
|
|
813
|
+
config().getFilteredCountForView?.(view) ??
|
|
814
|
+
|
|
815
|
+
(activeView() === view.id ? filteredRowCount() : null) ??
|
|
816
|
+
|
|
817
|
+
config().rowCount ??
|
|
818
|
+
|
|
819
|
+
0,
|
|
820
|
+
|
|
821
|
+
scope: view.scope,
|
|
822
|
+
|
|
823
|
+
pinned: view.pinned,
|
|
824
|
+
|
|
825
|
+
}))}
|
|
826
|
+
|
|
827
|
+
activeId={activeView()}
|
|
828
|
+
|
|
829
|
+
onChange={setActiveView}
|
|
830
|
+
|
|
831
|
+
variant="embedded"
|
|
832
|
+
|
|
833
|
+
class="w-full"
|
|
834
|
+
|
|
835
|
+
addIcon={<Plus size={20} strokeWidth={2} />}
|
|
836
|
+
|
|
837
|
+
onAdd={openCustomizeForCreate}
|
|
838
|
+
|
|
839
|
+
/>
|
|
840
|
+
|
|
841
|
+
</div>
|
|
842
|
+
|
|
843
|
+
{props.toolbar && (
|
|
844
|
+
|
|
845
|
+
<div class="border-b border-surface-border bg-surface-raised px-4 py-4">
|
|
846
|
+
|
|
847
|
+
{props.toolbar(contextValue)}
|
|
848
|
+
|
|
849
|
+
</div>
|
|
850
|
+
|
|
851
|
+
)}
|
|
852
|
+
|
|
853
|
+
<div class={config().rowCount === 0 ? 'p-10' : 'p-0'}>{props.children}</div>
|
|
854
|
+
|
|
855
|
+
</div>
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
<Drawer open={showCustomize()} onClose={closeCustomize} size="xl">
|
|
860
|
+
|
|
861
|
+
<ViewCustomizer
|
|
862
|
+
|
|
863
|
+
fields={config().allFields}
|
|
864
|
+
|
|
865
|
+
value={usedFields()}
|
|
866
|
+
|
|
867
|
+
onChange={setUsedFields}
|
|
868
|
+
|
|
869
|
+
allFields={config().allFields}
|
|
870
|
+
|
|
871
|
+
viewName={viewName()}
|
|
872
|
+
|
|
873
|
+
onViewNameChange={setViewName}
|
|
874
|
+
|
|
875
|
+
usedFields={usedFields()}
|
|
876
|
+
|
|
877
|
+
onUsedFieldsChange={setUsedFields}
|
|
878
|
+
|
|
879
|
+
groupBy={groupBy()}
|
|
880
|
+
|
|
881
|
+
onGroupByChange={setGroupBy}
|
|
882
|
+
|
|
883
|
+
mode={customizeMode()}
|
|
884
|
+
|
|
885
|
+
editingViewLabel={activeViewConfig()?.label}
|
|
886
|
+
|
|
887
|
+
onSaveChanges={updateView}
|
|
888
|
+
|
|
889
|
+
onSaveAsNew={saveViewAsNew}
|
|
890
|
+
|
|
891
|
+
saveError={saveError()}
|
|
892
|
+
|
|
893
|
+
onCancel={closeCustomize}
|
|
894
|
+
|
|
895
|
+
onClose={closeCustomize}
|
|
896
|
+
|
|
897
|
+
canDelete={activeViewConfig()?.scope === 'user'}
|
|
898
|
+
|
|
899
|
+
onDelete={deleteView}
|
|
900
|
+
|
|
901
|
+
scope={scope()}
|
|
902
|
+
|
|
903
|
+
onScopeChange={setScope}
|
|
904
|
+
|
|
905
|
+
canSetGlobalScope={true}
|
|
906
|
+
|
|
907
|
+
pinned={pinned()}
|
|
908
|
+
|
|
909
|
+
onPinnedChange={setPinned}
|
|
910
|
+
|
|
911
|
+
title={config().customizeTitle ?? 'Customize table'}
|
|
912
|
+
|
|
913
|
+
description={config().customizeDescription ?? 'Manage fields, grouping, and saved views.'}
|
|
914
|
+
|
|
915
|
+
/>
|
|
916
|
+
|
|
917
|
+
</Drawer>
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
<Drawer
|
|
922
|
+
|
|
923
|
+
open={showFilters()}
|
|
924
|
+
|
|
925
|
+
onClose={cancelFilters}
|
|
926
|
+
|
|
927
|
+
onCancel={cancelFilters}
|
|
928
|
+
|
|
929
|
+
onSave={saveFilters}
|
|
930
|
+
|
|
931
|
+
size="2xl"
|
|
932
|
+
|
|
933
|
+
>
|
|
934
|
+
|
|
935
|
+
<div>
|
|
936
|
+
|
|
937
|
+
<h2 class="text-lg font-semibold text-ink-900">
|
|
938
|
+
|
|
939
|
+
{config().filterTitle ?? 'Filter'}
|
|
940
|
+
|
|
941
|
+
</h2>
|
|
942
|
+
|
|
943
|
+
<div class="mt-2 text-sm text-ink-500">
|
|
944
|
+
|
|
945
|
+
{config().filterDescription ?? 'Build filter rules with And/Or logic.'}
|
|
946
|
+
|
|
947
|
+
</div>
|
|
948
|
+
|
|
949
|
+
</div>
|
|
950
|
+
|
|
951
|
+
<Show when={filterSaveError()}>
|
|
952
|
+
|
|
953
|
+
<div role="alert" class="mt-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
|
|
954
|
+
|
|
955
|
+
{filterSaveError()}
|
|
956
|
+
|
|
957
|
+
</div>
|
|
958
|
+
|
|
959
|
+
</Show>
|
|
960
|
+
|
|
961
|
+
<div class="mt-6 flex-1 overflow-y-auto overflow-x-visible min-h-0">
|
|
962
|
+
|
|
963
|
+
<Show when={editingFilterGroup()}>
|
|
964
|
+
|
|
965
|
+
<FilterBuilder
|
|
966
|
+
|
|
967
|
+
value={() => editingFilterGroup()!}
|
|
968
|
+
|
|
969
|
+
onChange={(g: any) => setEditingFilterGroup(g)}
|
|
970
|
+
|
|
971
|
+
fields={config().filterFields}
|
|
972
|
+
|
|
973
|
+
getOperators={config().getOperatorsForType}
|
|
974
|
+
|
|
975
|
+
/>
|
|
976
|
+
|
|
977
|
+
</Show>
|
|
978
|
+
|
|
979
|
+
</div>
|
|
980
|
+
|
|
981
|
+
<Show when={editingFilterGroup() && editingFilterGroup()!.children.length > 0}>
|
|
982
|
+
|
|
983
|
+
<div class="mt-6 space-y-2 border-t border-ink-200 pt-4">
|
|
984
|
+
|
|
985
|
+
<div class="text-sm font-medium text-ink-700">Filter logic</div>
|
|
986
|
+
|
|
987
|
+
<code class="block rounded-md bg-ink-100 px-3 py-2 text-sm font-mono text-ink-800">
|
|
988
|
+
|
|
989
|
+
{formatFilterCode(editingFilterGroup()!, assignRuleNumbers(editingFilterGroup()!))}
|
|
990
|
+
|
|
991
|
+
</code>
|
|
992
|
+
|
|
993
|
+
<div class="text-sm font-medium text-ink-700">Summary</div>
|
|
994
|
+
|
|
995
|
+
<p class="text-sm text-ink-600">
|
|
996
|
+
|
|
997
|
+
{formatFilterSummary(editingFilterGroup()!, {
|
|
998
|
+
|
|
999
|
+
fields: config().filterFields,
|
|
1000
|
+
|
|
1001
|
+
getOperators: config().getOperatorsForType,
|
|
1002
|
+
|
|
1003
|
+
})}
|
|
1004
|
+
|
|
1005
|
+
</p>
|
|
1006
|
+
|
|
1007
|
+
</div>
|
|
1008
|
+
|
|
1009
|
+
</Show>
|
|
1010
|
+
|
|
1011
|
+
</Drawer>
|
|
1012
|
+
|
|
1013
|
+
</TableViewContext.Provider>
|
|
1014
|
+
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
}
|
|
1018
|
+
|