@greendrake/ui-data 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +334 -0
- package/package.json +48 -0
- package/src/CrudPanel.vue +305 -0
- package/src/DescriptionList.vue +53 -0
- package/src/InfiniteScrollTable.types.ts +105 -0
- package/src/InfiniteScrollTable.vue +1085 -0
- package/src/MasterDetail.vue +191 -0
- package/src/errorMessage.ts +2 -0
- package/src/formatters.ts +25 -0
- package/src/listLoader.ts +31 -0
- package/src/localFilterDebounce.ts +21 -0
- package/src/main.ts +15 -0
- package/src/useAsyncAction.ts +47 -0
- package/src/useDetailLoader.ts +57 -0
- package/src/useRecordEditor.ts +148 -0
- package/src/useSortState.ts +67 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { RowPosition, VisibleRange } from './InfiniteScrollTable.types'
|
|
3
|
+
|
|
4
|
+
type Key = string | number
|
|
5
|
+
|
|
6
|
+
// The consumer-facing contract of CrudPanel's template-ref instance. Generic
|
|
7
|
+
// SFC instances can't be named via InstanceType (vue-tsc models them as call
|
|
8
|
+
// signatures), so consumers type their refs with this instead:
|
|
9
|
+
// `ref<CrudPanelExposed<Host>>()`.
|
|
10
|
+
export interface CrudPanelExposed<T = Record<string, unknown>> {
|
|
11
|
+
openTab: (id: string, label?: string) => void
|
|
12
|
+
closeTab: (id: string) => void
|
|
13
|
+
updateTab: (id: string, newId?: string, newLabel?: string) => void
|
|
14
|
+
removeRows: (keys: Key[]) => void
|
|
15
|
+
addRow: (data: T, position?: RowPosition) => void
|
|
16
|
+
patchRow: (key: Key, data: Partial<T>) => void
|
|
17
|
+
getRow: (key: Key) => T | undefined
|
|
18
|
+
refresh: (background?: boolean) => Promise<void>
|
|
19
|
+
applySaved: (key: Key, record: T, tabId?: string) => void
|
|
20
|
+
readonly selected: Set<Key> | undefined
|
|
21
|
+
readonly range: VisibleRange
|
|
22
|
+
readonly deleting: boolean
|
|
23
|
+
}
|
|
24
|
+
</script>
|
|
25
|
+
<script setup lang="ts" generic="T extends Record<string, unknown>">
|
|
26
|
+
import { ref, computed, useSlots } from 'vue'
|
|
27
|
+
import { SearchField, Modal, messages } from '@greendrake/ui'
|
|
28
|
+
import InfiniteScrollTable from './InfiniteScrollTable.vue'
|
|
29
|
+
import MasterDetail from './MasterDetail.vue'
|
|
30
|
+
import { localFilterDebounce } from './localFilterDebounce'
|
|
31
|
+
import { rowKeyOf, type ColumnDefinition, type Filters, type LoaderFunction, type RowKey, type InfiniteScrollTableExposed } from './InfiniteScrollTable.types'
|
|
32
|
+
|
|
33
|
+
// Master/detail CRUD shell: search box + InfiniteScrollTable on the left,
|
|
34
|
+
// tabbed detail panes on the right. Opt-in Create / Delete affordances live
|
|
35
|
+
// here so dashboards don't reimplement them.
|
|
36
|
+
//
|
|
37
|
+
// Detail editors follow the record-edit convention (see useRecordEditor):
|
|
38
|
+
// 1. The editor receives the row it's editing (via slot prop `record` —
|
|
39
|
+
// sourced from the active table row).
|
|
40
|
+
// 2. On save it sends only the diff, gets the full updated record back,
|
|
41
|
+
// and emits ('saved', key, record).
|
|
42
|
+
// 3. The parent wires that event to `crudRef.applySaved(key, record,
|
|
43
|
+
// tab.id)` — patching the row in place (or adding a freshly created
|
|
44
|
+
// one). NEVER call `refresh()` after a single-row edit, that's a
|
|
45
|
+
// wasteful round trip.
|
|
46
|
+
//
|
|
47
|
+
// Slots:
|
|
48
|
+
// - controls-left — appended to the left of the controls bar, after the
|
|
49
|
+
// search field and the built-in Create button (if any).
|
|
50
|
+
// Useful for filters or extra action buttons.
|
|
51
|
+
// - controls-middle — placed in the middle. Receives { range }.
|
|
52
|
+
// - controls-right — appended to the right of the controls bar, after the
|
|
53
|
+
// built-in Delete button (if any). Receives
|
|
54
|
+
// { selected } for custom selection-aware buttons.
|
|
55
|
+
// - detail — receives { tab, isNew, record, isDupe }.
|
|
56
|
+
// isNew — tab.id starts with `new:`.
|
|
57
|
+
// record — the active table row (undefined when
|
|
58
|
+
// isNew, or when the table hasn't seen
|
|
59
|
+
// the row yet).
|
|
60
|
+
// isDupe — `(original, candidate) => boolean`,
|
|
61
|
+
// backed by the `keyExists` prop, for
|
|
62
|
+
// rename / new-tab key collision checks.
|
|
63
|
+
const props = withDefaults(
|
|
64
|
+
defineProps<{
|
|
65
|
+
columns: ColumnDefinition<T>[]
|
|
66
|
+
loader: LoaderFunction<T>
|
|
67
|
+
rowKey?: RowKey<T>
|
|
68
|
+
rowHeight?: number
|
|
69
|
+
filters?: Filters
|
|
70
|
+
initialWidth?: number
|
|
71
|
+
minWidth?: number
|
|
72
|
+
fullWidthUntilTab?: boolean
|
|
73
|
+
showRange?: boolean
|
|
74
|
+
// null = table default; false = always filter remotely (see table prop).
|
|
75
|
+
localSearchFields?: string[] | false | null
|
|
76
|
+
// --- Create / Delete affordances (all opt-in) ---
|
|
77
|
+
creatable?: boolean
|
|
78
|
+
newTabLabel?: string
|
|
79
|
+
onDelete?: ((keys: Key[]) => Promise<unknown>) | null
|
|
80
|
+
deleteConfirmMessage?: string | null
|
|
81
|
+
keyExists?: ((key: Key) => boolean) | null
|
|
82
|
+
}>(),
|
|
83
|
+
{
|
|
84
|
+
rowKey: 'id',
|
|
85
|
+
rowHeight: 28,
|
|
86
|
+
filters: () => ({}),
|
|
87
|
+
initialWidth: 200,
|
|
88
|
+
minWidth: 120,
|
|
89
|
+
fullWidthUntilTab: true,
|
|
90
|
+
showRange: true,
|
|
91
|
+
localSearchFields: null,
|
|
92
|
+
creatable: false,
|
|
93
|
+
newTabLabel: undefined,
|
|
94
|
+
onDelete: null,
|
|
95
|
+
deleteConfirmMessage: null,
|
|
96
|
+
keyExists: null
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
const slots = useSlots()
|
|
101
|
+
const masterDetailRef = ref<InstanceType<typeof MasterDetail>>()
|
|
102
|
+
// Typed against the table's exposed contract rather than its (generic) instance
|
|
103
|
+
// type, so T keeps flowing through patchRow/addRow/getRow.
|
|
104
|
+
const tableRef = ref<InfiniteScrollTableExposed<T>>()
|
|
105
|
+
const filterDebounce = localFilterDebounce(tableRef)
|
|
106
|
+
|
|
107
|
+
const newTabLabelText = computed(() => props.newTabLabel ?? messages.newTab)
|
|
108
|
+
|
|
109
|
+
// Activating a row opens a detail tab — but only when there's a detail to show.
|
|
110
|
+
// A consumer that provides no `detail` slot (e.g. a glance-and-delete table) gets
|
|
111
|
+
// a table-only panel: rows select for deletion and never open an empty pane.
|
|
112
|
+
function onRowActivate(record: T): void {
|
|
113
|
+
if (!slots.detail) return
|
|
114
|
+
const key = String(rowKeyOf(record, props.rowKey))
|
|
115
|
+
masterDetailRef.value!.openTab(key, key)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const searchQuery = ref<string | undefined>('')
|
|
119
|
+
const visibleRange = ref({
|
|
120
|
+
start: 0,
|
|
121
|
+
end: 0,
|
|
122
|
+
total: 0
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const mergedFilters = computed<Filters>(() => ({
|
|
126
|
+
...props.filters,
|
|
127
|
+
q: searchQuery.value || undefined
|
|
128
|
+
}))
|
|
129
|
+
const singleColumn = computed(() => props.columns.length === 1)
|
|
130
|
+
|
|
131
|
+
function onRangeChanged(range: VisibleRange): void {
|
|
132
|
+
visibleRange.value = range
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let newTabSeq = 0
|
|
136
|
+
const deleting = ref(false)
|
|
137
|
+
const deleteConfirm = ref(false)
|
|
138
|
+
|
|
139
|
+
const canDelete = computed(() => !!props.onDelete && (tableRef.value?.selected?.size ?? 0) > 0)
|
|
140
|
+
|
|
141
|
+
function onCreate(): void {
|
|
142
|
+
masterDetailRef.value!.openTab(`new:${++newTabSeq}`, newTabLabelText.value)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function onRequestDelete(): void {
|
|
146
|
+
if (!canDelete.value || deleting.value) return
|
|
147
|
+
if (props.deleteConfirmMessage) {
|
|
148
|
+
deleteConfirm.value = true
|
|
149
|
+
} else {
|
|
150
|
+
doDelete()
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function doDelete(): Promise<void> {
|
|
155
|
+
const sel = tableRef.value?.selected
|
|
156
|
+
if (!sel?.size) return
|
|
157
|
+
const keys = [...sel]
|
|
158
|
+
deleting.value = true
|
|
159
|
+
deleteConfirm.value = false
|
|
160
|
+
try {
|
|
161
|
+
await props.onDelete!(keys)
|
|
162
|
+
for (const k of keys) masterDetailRef.value?.closeTab(String(k))
|
|
163
|
+
tableRef.value?.removeRows(keys)
|
|
164
|
+
tableRef.value?.selected?.clear()
|
|
165
|
+
} finally {
|
|
166
|
+
deleting.value = false
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Key collision check used by detail editors when renaming a record or
|
|
171
|
+
// creating a new one. `original` is the current key (or '' for new); we
|
|
172
|
+
// don't flag a match against the same value.
|
|
173
|
+
function isDupe(original: Key, candidate: Key): boolean {
|
|
174
|
+
if (!props.keyExists) return false
|
|
175
|
+
const trimmed = typeof candidate === 'string' ? candidate.trim() : candidate
|
|
176
|
+
return trimmed !== '' && trimmed !== original && props.keyExists(trimmed)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Default post-save bookkeeping for the record-edit convention: patch the row
|
|
180
|
+
// in place when it already exists; otherwise add the freshly created row and,
|
|
181
|
+
// given the originating `new:` tab's id, rename that tab to the persisted key
|
|
182
|
+
// so the editor's `record` slot prop resolves and subsequent saves route to
|
|
183
|
+
// update instead of create.
|
|
184
|
+
function applySaved(key: Key, record: T, tabId?: string): void {
|
|
185
|
+
if (tableRef.value?.getRow(key)) {
|
|
186
|
+
tableRef.value.patchRow(key, record)
|
|
187
|
+
} else {
|
|
188
|
+
tableRef.value?.addRow(record)
|
|
189
|
+
if (tabId && tabId !== String(key)) {
|
|
190
|
+
masterDetailRef.value?.updateTab(tabId, String(key), String(key))
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const exposed: CrudPanelExposed<T> = {
|
|
196
|
+
openTab: (id, label) => masterDetailRef.value!.openTab(id, label),
|
|
197
|
+
closeTab: id => masterDetailRef.value!.closeTab(id),
|
|
198
|
+
updateTab: (id, newId, newLabel) => masterDetailRef.value!.updateTab(id, newId, newLabel),
|
|
199
|
+
removeRows: keys => {
|
|
200
|
+
tableRef.value?.removeRows(keys)
|
|
201
|
+
tableRef.value?.selected?.clear()
|
|
202
|
+
},
|
|
203
|
+
addRow: (data, position) => tableRef.value?.addRow(data, position),
|
|
204
|
+
patchRow: (key, data) => tableRef.value?.patchRow(key, data),
|
|
205
|
+
getRow: key => tableRef.value?.getRow(key),
|
|
206
|
+
refresh: background => tableRef.value?.refresh(background) ?? Promise.resolve(),
|
|
207
|
+
applySaved,
|
|
208
|
+
get selected() {
|
|
209
|
+
return tableRef.value?.selected
|
|
210
|
+
},
|
|
211
|
+
get range() {
|
|
212
|
+
return visibleRange.value
|
|
213
|
+
},
|
|
214
|
+
get deleting() {
|
|
215
|
+
return deleting.value
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
defineExpose(exposed)
|
|
219
|
+
</script>
|
|
220
|
+
<template>
|
|
221
|
+
<div class="CrudPanel">
|
|
222
|
+
<MasterDetail ref="masterDetailRef" :initial-width="initialWidth" :min-width="minWidth" :full-width-until-tab="fullWidthUntilTab">
|
|
223
|
+
<template #master>
|
|
224
|
+
<div class="CrudPanel__controls">
|
|
225
|
+
<SearchField v-model="searchQuery" :debounce="filterDebounce" :placeholder="messages.search" />
|
|
226
|
+
<button v-if="creatable" type="button" class="CrudPanel__create" @click="onCreate">{{ messages.create }}</button>
|
|
227
|
+
<slot name="controls-left" />
|
|
228
|
+
<div class="CrudPanel__controls__gap" />
|
|
229
|
+
<slot name="controls-middle" :range="visibleRange" />
|
|
230
|
+
<button type="button" class="CrudPanel__refresh" :title="messages.refresh" @click="tableRef?.refresh()">↻</button>
|
|
231
|
+
<div v-if="showRange && visibleRange.total" class="CrudPanel__range">{{ visibleRange.start }}-{{ visibleRange.end }}/{{ visibleRange.total }}</div>
|
|
232
|
+
<button v-if="onDelete" type="button" class="CrudPanel__delete" :class="{ disabled: !canDelete || deleting, spinner: deleting }" @click="onRequestDelete">{{ messages.delete }}</button>
|
|
233
|
+
<slot name="controls-right" :selected="tableRef?.selected" />
|
|
234
|
+
<Modal v-if="deleteConfirm" class="CrudPanel__delete-modal" :title="messages.delete" :ok-label="deleting ? messages.deleting : messages.delete" :ok-disabled="deleting" @ok="doDelete" @cancel="deleteConfirm = false">
|
|
235
|
+
<p>{{ deleteConfirmMessage }}</p>
|
|
236
|
+
</Modal>
|
|
237
|
+
</div>
|
|
238
|
+
<InfiniteScrollTable ref="tableRef" :loader="loader" :columns="columns" :row-height="rowHeight" :filters="mergedFilters" :row-key="rowKey" :hide-header="singleColumn" v-bind="localSearchFields !== null ? { localSearchFields } : {}" selectable @rowactivate="onRowActivate" @range-changed="onRangeChanged" />
|
|
239
|
+
</template>
|
|
240
|
+
<template #detail="{ tab }">
|
|
241
|
+
<slot name="detail" :tab="tab" :is-new="tab.id.startsWith('new:')" :record="tab.id.startsWith('new:') ? undefined : tableRef?.getRow(tab.id)" :is-dupe="isDupe" />
|
|
242
|
+
</template>
|
|
243
|
+
</MasterDetail>
|
|
244
|
+
</div>
|
|
245
|
+
</template>
|
|
246
|
+
<style lang="scss">
|
|
247
|
+
.CrudPanel {
|
|
248
|
+
display: flex;
|
|
249
|
+
flex-direction: column;
|
|
250
|
+
flex-grow: 1;
|
|
251
|
+
min-height: 0;
|
|
252
|
+
|
|
253
|
+
.MasterDetail {
|
|
254
|
+
flex-grow: 1;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
&__controls {
|
|
258
|
+
display: flex;
|
|
259
|
+
justify-content: space-between;
|
|
260
|
+
align-items: center;
|
|
261
|
+
gap: 8px;
|
|
262
|
+
padding: 8px;
|
|
263
|
+
flex-grow: 0;
|
|
264
|
+
border-bottom: 1px solid var(--border-color-light);
|
|
265
|
+
&__gap {
|
|
266
|
+
flex-grow: 1;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
&__range {
|
|
270
|
+
font-size: 13px;
|
|
271
|
+
opacity: 0.8;
|
|
272
|
+
white-space: nowrap;
|
|
273
|
+
}
|
|
274
|
+
&__refresh {
|
|
275
|
+
cursor: default;
|
|
276
|
+
background: transparent;
|
|
277
|
+
border: 1px solid var(--border-color-light);
|
|
278
|
+
border-radius: var(--border-radius, 4px);
|
|
279
|
+
padding: 2px 8px;
|
|
280
|
+
font-size: 14px;
|
|
281
|
+
line-height: 1;
|
|
282
|
+
opacity: 0.7;
|
|
283
|
+
&:hover {
|
|
284
|
+
opacity: 1;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
.data-table td .cell-content {
|
|
288
|
+
padding: var(--list-row-padding, 3px 6px);
|
|
289
|
+
box-sizing: border-box;
|
|
290
|
+
display: flex;
|
|
291
|
+
align-items: center;
|
|
292
|
+
}
|
|
293
|
+
.data-table tbody {
|
|
294
|
+
--row-border-bottom: 1px solid var(--border-color-light);
|
|
295
|
+
}
|
|
296
|
+
&__delete-modal {
|
|
297
|
+
.Modal__window {
|
|
298
|
+
max-width: 400px;
|
|
299
|
+
}
|
|
300
|
+
p {
|
|
301
|
+
margin: 0;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
</style>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
export interface DescriptionItem {
|
|
3
|
+
label: string
|
|
4
|
+
value: string | number | null | undefined
|
|
5
|
+
/** Render the value in a <code> element. */
|
|
6
|
+
code?: boolean
|
|
7
|
+
}
|
|
8
|
+
</script>
|
|
9
|
+
<script setup lang="ts">
|
|
10
|
+
import { computed } from 'vue'
|
|
11
|
+
|
|
12
|
+
// The label/value grid repeated across detail panes. Values arrive
|
|
13
|
+
// display-ready (formatted, with any placeholder text already applied);
|
|
14
|
+
// conditional rows are inlined as falsy entries: `[cond && { … }]`.
|
|
15
|
+
const props = defineProps<{
|
|
16
|
+
items: (DescriptionItem | false | null | undefined)[]
|
|
17
|
+
}>()
|
|
18
|
+
|
|
19
|
+
const rows = computed(() => props.items.filter((item): item is DescriptionItem => !!item))
|
|
20
|
+
</script>
|
|
21
|
+
<template>
|
|
22
|
+
<dl class="DescriptionList">
|
|
23
|
+
<template v-for="(item, i) in rows" :key="i">
|
|
24
|
+
<dt>{{ item.label }}</dt>
|
|
25
|
+
<dd>
|
|
26
|
+
<code v-if="item.code">{{ item.value ?? '' }}</code>
|
|
27
|
+
<template v-else>{{ item.value ?? '' }}</template>
|
|
28
|
+
</dd>
|
|
29
|
+
</template>
|
|
30
|
+
</dl>
|
|
31
|
+
</template>
|
|
32
|
+
<style lang="scss" scoped>
|
|
33
|
+
.DescriptionList {
|
|
34
|
+
display: grid;
|
|
35
|
+
grid-template-columns: max-content 1fr;
|
|
36
|
+
gap: var(--space-1) var(--space-3);
|
|
37
|
+
margin: 0;
|
|
38
|
+
|
|
39
|
+
dt {
|
|
40
|
+
font-weight: 600;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
dd {
|
|
44
|
+
margin: 0;
|
|
45
|
+
word-break: break-word;
|
|
46
|
+
|
|
47
|
+
code {
|
|
48
|
+
background: var(--form-background);
|
|
49
|
+
padding: 0 var(--space-1);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
</style>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { Component, Ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
export interface ColumnDefinition<T = Record<string, unknown>> {
|
|
4
|
+
field: string
|
|
5
|
+
label: string
|
|
6
|
+
className?: string
|
|
7
|
+
cellClass?: (value: unknown, record: T) => string | undefined
|
|
8
|
+
sortable?: boolean
|
|
9
|
+
sortType?: 'string' | 'number' | 'date'
|
|
10
|
+
sortIcon?: string
|
|
11
|
+
onHeaderClick?: () => void
|
|
12
|
+
onCellClick?: (value: unknown, record: T) => void
|
|
13
|
+
formatter?: (value: unknown, record: T) => string
|
|
14
|
+
wrap?: boolean
|
|
15
|
+
component?: Component
|
|
16
|
+
componentProps?: (record: T) => Record<string, unknown>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LoaderParams {
|
|
20
|
+
offset: number
|
|
21
|
+
limit: number
|
|
22
|
+
[key: string]: string | number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface LoaderResponse<T> {
|
|
26
|
+
data: T[]
|
|
27
|
+
total: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// `background` is true when the load was not asked for by the person looking
|
|
31
|
+
// at the table — a live binding re-reading after a server push or a dropped
|
|
32
|
+
// connection. What that means for the request is the caller's: typically a
|
|
33
|
+
// quiet call that shows no spinner and raises no error prompt.
|
|
34
|
+
export type LoaderFunction<T> = (params: LoaderParams, loading: Ref<boolean>, background: boolean) => Promise<LoaderResponse<T>>
|
|
35
|
+
|
|
36
|
+
export interface Filters {
|
|
37
|
+
[key: string]: string | null | undefined
|
|
38
|
+
sort_by?: string
|
|
39
|
+
sort_dir?: 'asc' | 'desc'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A row's identity: either the name of the field holding it, or a function
|
|
43
|
+
// deriving it (for composite keys — e.g. an email+name pair joined into one
|
|
44
|
+
// string — where no single field is unique).
|
|
45
|
+
export type RowKey<T = Record<string, unknown>> = string | ((row: T) => string | number)
|
|
46
|
+
|
|
47
|
+
export const rowKeyOf = <T>(row: T, key: RowKey<T>): string | number => (typeof key === 'function' ? key(row) : ((row as Record<string, unknown>)[key] as string | number))
|
|
48
|
+
|
|
49
|
+
export interface Props<T = Record<string, unknown>> {
|
|
50
|
+
loader: LoaderFunction<T>
|
|
51
|
+
columns: ColumnDefinition<T>[]
|
|
52
|
+
rowHeight?: number
|
|
53
|
+
visibleHeight?: number | null
|
|
54
|
+
filters?: Filters
|
|
55
|
+
textFilterKey?: string
|
|
56
|
+
// Fields the local text filter matches against when the whole dataset is
|
|
57
|
+
// in memory. `false` opts out of local filter routing entirely — every
|
|
58
|
+
// text-filter change goes to the server (for search semantics the client
|
|
59
|
+
// cannot mirror, e.g. full-text over fields absent from the rows).
|
|
60
|
+
localSearchFields?: string[] | false
|
|
61
|
+
rowKey?: RowKey<T>
|
|
62
|
+
selectable?: boolean
|
|
63
|
+
rowDraggable?: boolean
|
|
64
|
+
hideHeader?: boolean
|
|
65
|
+
onRowClick?: (record: T) => void
|
|
66
|
+
/** Rows fetched per remote request — the offset/limit window held in memory. */
|
|
67
|
+
bufferSize?: number
|
|
68
|
+
/**
|
|
69
|
+
* Testability affordance: render every buffered row instead of only the
|
|
70
|
+
* viewport window, so E2E locators can reach rows below the fold without a
|
|
71
|
+
* tall viewport. Only meaningful for datasets that fit one buffer
|
|
72
|
+
* (`total <= bufferSize`); not for production use.
|
|
73
|
+
*/
|
|
74
|
+
renderAll?: boolean
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface DropTarget<T = Record<string, unknown>> {
|
|
78
|
+
record: T
|
|
79
|
+
position: 'before' | 'after'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type RowPosition = 'start' | 'end' | { after: string | number } | { before: string | number }
|
|
83
|
+
|
|
84
|
+
// The 1-based fully-visible row window, as reported by the `range-changed`
|
|
85
|
+
// emit and CrudPanel's range readout / `range` expose.
|
|
86
|
+
export interface VisibleRange {
|
|
87
|
+
start: number
|
|
88
|
+
end: number
|
|
89
|
+
total: number
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The imperative row-surgery surface InfiniteScrollTable exposes. CrudPanel
|
|
93
|
+
// consumes it internally; consumers type their template refs with it to keep
|
|
94
|
+
// row types flowing (`ref<InfiniteScrollTableExposed<Host>>()`).
|
|
95
|
+
export interface InfiniteScrollTableExposed<T = Record<string, unknown>> {
|
|
96
|
+
refresh: (background?: boolean) => Promise<void>
|
|
97
|
+
patchRow: (keyValue: string | number, data: Partial<T>) => boolean
|
|
98
|
+
addRow: (data: T, position?: RowPosition) => void
|
|
99
|
+
removeRow: (keyValue: string | number) => void
|
|
100
|
+
removeRows: (keyValues: (string | number)[]) => void
|
|
101
|
+
resort: () => void
|
|
102
|
+
getRow: (keyValue: string | number) => T | undefined
|
|
103
|
+
willFilterLocally: (value: string | undefined) => boolean
|
|
104
|
+
selected: Set<string | number> | undefined
|
|
105
|
+
}
|