@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.
@@ -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
+ }