@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,191 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed } from 'vue'
|
|
3
|
+
import { TabBar, type TabDef } from '@greendrake/ui'
|
|
4
|
+
|
|
5
|
+
const props = withDefaults(
|
|
6
|
+
defineProps<{
|
|
7
|
+
initialWidth?: number
|
|
8
|
+
minWidth?: number
|
|
9
|
+
fullWidthUntilTab?: boolean
|
|
10
|
+
}>(),
|
|
11
|
+
{
|
|
12
|
+
initialWidth: 150,
|
|
13
|
+
minWidth: 100,
|
|
14
|
+
fullWidthUntilTab: false
|
|
15
|
+
}
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
const emit = defineEmits<{
|
|
19
|
+
'tab-open': [tab: TabDef]
|
|
20
|
+
'tab-close': [tab: TabDef]
|
|
21
|
+
'tab-activate': [tab: TabDef]
|
|
22
|
+
}>()
|
|
23
|
+
|
|
24
|
+
const tabs = ref<TabDef[]>([])
|
|
25
|
+
const activeTabId = ref<string | null>(null)
|
|
26
|
+
const masterWidth = ref(props.initialWidth)
|
|
27
|
+
|
|
28
|
+
const activeTab = computed(() => tabs.value.find(t => t.id === activeTabId.value) ?? null)
|
|
29
|
+
|
|
30
|
+
const hasTabs = computed(() => tabs.value.length > 0)
|
|
31
|
+
|
|
32
|
+
const masterStyle = computed(() => {
|
|
33
|
+
if (props.fullWidthUntilTab && !hasTabs.value) {
|
|
34
|
+
return { flex: '1 1 0', width: 0 }
|
|
35
|
+
}
|
|
36
|
+
return { width: masterWidth.value + 'px' }
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
function openTab(id: string, label?: string): void {
|
|
40
|
+
let tab = tabs.value.find(t => t.id === id)
|
|
41
|
+
if (!tab) {
|
|
42
|
+
tab = { id, label: label ?? id }
|
|
43
|
+
tabs.value.push(tab)
|
|
44
|
+
emit('tab-open', tab)
|
|
45
|
+
}
|
|
46
|
+
activeTabId.value = id
|
|
47
|
+
emit('tab-activate', tab)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function closeTab(id: string): void {
|
|
51
|
+
const index = tabs.value.findIndex(t => t.id === id)
|
|
52
|
+
if (index === -1) return
|
|
53
|
+
|
|
54
|
+
const tab = tabs.value[index]
|
|
55
|
+
tabs.value.splice(index, 1)
|
|
56
|
+
emit('tab-close', tab)
|
|
57
|
+
|
|
58
|
+
if (activeTabId.value === id) {
|
|
59
|
+
activeTabId.value = tabs.value[Math.min(index, tabs.value.length - 1)]?.id ?? null
|
|
60
|
+
if (activeTabId.value) {
|
|
61
|
+
emit('tab-activate', tabs.value.find(t => t.id === activeTabId.value)!)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function activateTab(id: string): void {
|
|
67
|
+
const tab = tabs.value.find(t => t.id === id)
|
|
68
|
+
if (tab) {
|
|
69
|
+
activeTabId.value = id
|
|
70
|
+
emit('tab-activate', tab)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function updateTab(id: string, newId?: string, newLabel?: string): void {
|
|
75
|
+
const tab = tabs.value.find(t => t.id === id)
|
|
76
|
+
if (!tab) return
|
|
77
|
+
if (newLabel !== undefined) tab.label = newLabel
|
|
78
|
+
if (newId !== undefined && newId !== id) {
|
|
79
|
+
tab.id = newId
|
|
80
|
+
if (activeTabId.value === id) activeTabId.value = newId
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function onResizeStart(e: MouseEvent): void {
|
|
85
|
+
const startX = e.clientX
|
|
86
|
+
const startWidth = masterWidth.value
|
|
87
|
+
|
|
88
|
+
function onMouseMove(e: MouseEvent): void {
|
|
89
|
+
masterWidth.value = Math.max(props.minWidth, startWidth + e.clientX - startX)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function onMouseUp(): void {
|
|
93
|
+
document.removeEventListener('mousemove', onMouseMove)
|
|
94
|
+
document.removeEventListener('mouseup', onMouseUp)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
document.addEventListener('mousemove', onMouseMove)
|
|
98
|
+
document.addEventListener('mouseup', onMouseUp)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
defineExpose({
|
|
102
|
+
openTab,
|
|
103
|
+
closeTab,
|
|
104
|
+
activateTab,
|
|
105
|
+
updateTab,
|
|
106
|
+
tabs,
|
|
107
|
+
activeTab,
|
|
108
|
+
activeTabId
|
|
109
|
+
})
|
|
110
|
+
</script>
|
|
111
|
+
<template>
|
|
112
|
+
<div class="MasterDetail">
|
|
113
|
+
<div class="MasterDetail__master" :style="masterStyle">
|
|
114
|
+
<slot name="master" :open-tab="openTab"></slot>
|
|
115
|
+
</div>
|
|
116
|
+
<template v-if="hasTabs || !fullWidthUntilTab">
|
|
117
|
+
<div class="MasterDetail__resize" @mousedown="onResizeStart"></div>
|
|
118
|
+
<div class="MasterDetail__detail">
|
|
119
|
+
<TabBar :tabs="tabs" :model-value="activeTabId" closable @update:model-value="activateTab" @close="closeTab" />
|
|
120
|
+
<div class="MasterDetail__content">
|
|
121
|
+
<template v-for="tab in tabs" :key="tab.id">
|
|
122
|
+
<div v-show="tab.id === activeTabId" class="MasterDetail__pane">
|
|
123
|
+
<slot name="detail" :tab="tab" :is-active="tab.id === activeTabId"></slot>
|
|
124
|
+
</div>
|
|
125
|
+
</template>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
</template>
|
|
129
|
+
</div>
|
|
130
|
+
</template>
|
|
131
|
+
<style lang="scss">
|
|
132
|
+
.MasterDetail {
|
|
133
|
+
display: flex;
|
|
134
|
+
min-width: 0;
|
|
135
|
+
// Flex item in a column parent: without this the automatic min-height (auto)
|
|
136
|
+
// keeps it as tall as its content, so it overgrows the viewport and the
|
|
137
|
+
// master's overflow:auto has nothing to scroll within.
|
|
138
|
+
min-height: 0;
|
|
139
|
+
|
|
140
|
+
&__master {
|
|
141
|
+
flex-shrink: 0;
|
|
142
|
+
min-width: v-bind('minWidth + "px"');
|
|
143
|
+
overflow: auto;
|
|
144
|
+
display: flex;
|
|
145
|
+
flex-direction: column;
|
|
146
|
+
|
|
147
|
+
> * {
|
|
148
|
+
flex-grow: 1;
|
|
149
|
+
min-height: 0;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
&__resize {
|
|
154
|
+
width: 4px;
|
|
155
|
+
flex-shrink: 0;
|
|
156
|
+
cursor: col-resize;
|
|
157
|
+
background-color: var(--border-color-light);
|
|
158
|
+
&:hover {
|
|
159
|
+
background-color: var(--accent-color);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
&__detail {
|
|
164
|
+
flex-grow: 1;
|
|
165
|
+
min-width: 0;
|
|
166
|
+
min-height: 0;
|
|
167
|
+
overflow: hidden;
|
|
168
|
+
display: flex;
|
|
169
|
+
flex-direction: column;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
&__content {
|
|
173
|
+
flex-grow: 1;
|
|
174
|
+
min-height: 0;
|
|
175
|
+
min-width: 0;
|
|
176
|
+
display: flex;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
&__pane {
|
|
180
|
+
flex-grow: 1;
|
|
181
|
+
display: flex;
|
|
182
|
+
min-height: 0;
|
|
183
|
+
min-width: 0;
|
|
184
|
+
|
|
185
|
+
> * {
|
|
186
|
+
flex-grow: 1;
|
|
187
|
+
min-width: 0;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
</style>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Wire timestamps drift between carrying an explicit zone ('Z' or ±hh:mm) and
|
|
2
|
+
// omitting it. JavaScript parses a zoneless date-time string as LOCAL time, so
|
|
3
|
+
// a missing zone is normalized to UTC before parsing. Output is always
|
|
4
|
+
// 'YYYY-MM-DD HH:MM:SSZ' — the value IS UTC and the suffix says so.
|
|
5
|
+
const HAS_ZONE = /(?:Z|[+-]\d{2}:?\d{2})$/i
|
|
6
|
+
|
|
7
|
+
export const fmtDate = (v: unknown): string => {
|
|
8
|
+
if (v == null || v === '') return ''
|
|
9
|
+
let d: Date
|
|
10
|
+
if (typeof v === 'string') {
|
|
11
|
+
const iso = v.replace(' ', 'T')
|
|
12
|
+
d = new Date(iso.includes('T') && !HAS_ZONE.test(iso) ? iso + 'Z' : iso)
|
|
13
|
+
} else if (typeof v === 'number' || v instanceof Date) {
|
|
14
|
+
d = new Date(v)
|
|
15
|
+
} else {
|
|
16
|
+
throw new TypeError(`fmtDate: unsupported value of type ${typeof v}`)
|
|
17
|
+
}
|
|
18
|
+
return d.toISOString().replace('T', ' ').slice(0, 19) + 'Z'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Table-cell boolean: a tick for true, blank for false. */
|
|
22
|
+
export const fmtBool = (v: unknown): string => (v ? '✓' : '')
|
|
23
|
+
|
|
24
|
+
/** Prose boolean for detail views. */
|
|
25
|
+
export const fmtYesNo = (v: unknown): string => (v ? 'yes' : 'no')
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Ref } from 'vue'
|
|
2
|
+
import type { LoaderParams, LoaderResponse } from './InfiniteScrollTable.types'
|
|
3
|
+
import type { SortState } from './useSortState'
|
|
4
|
+
|
|
5
|
+
// The standard admin list-method argument shape (q, limit, offset, sort_by,
|
|
6
|
+
// sort_dir) — the wire counterpart of LoaderParams + SortState.
|
|
7
|
+
export type PageArgs = {
|
|
8
|
+
q?: string
|
|
9
|
+
offset?: number
|
|
10
|
+
limit?: number
|
|
11
|
+
sort_by?: string
|
|
12
|
+
sort_dir?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Adapts InfiniteScrollTable's LoaderParams to the admin list-method argument
|
|
16
|
+
// shape. Passing `sort` includes the search box's `q` and the sort filters —
|
|
17
|
+
// exactly the methods with server-side search/sort support; the rest get the
|
|
18
|
+
// bare offset/limit window. Extra filter fields ride the `fetch` closure, and
|
|
19
|
+
// so does what a background read costs — the flag is handed on verbatim:
|
|
20
|
+
// listLoader((args, background) => api.call('admin.thread.list', [{ ...args, type }], backgroundCall(background)))
|
|
21
|
+
export const listLoader =
|
|
22
|
+
<T>(fetch: (args: PageArgs, background: boolean) => Promise<LoaderResponse<T>>, sort?: SortState) =>
|
|
23
|
+
(params: LoaderParams, _loading: Ref<boolean>, background: boolean): Promise<LoaderResponse<T>> =>
|
|
24
|
+
fetch(
|
|
25
|
+
{
|
|
26
|
+
offset: params.offset,
|
|
27
|
+
limit: params.limit,
|
|
28
|
+
...(sort ? { q: typeof params.q === 'string' ? params.q : undefined, ...sort.sortFilters.value } : {})
|
|
29
|
+
},
|
|
30
|
+
background
|
|
31
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
// A SearchField filtering an InfiniteScrollTable should react swiftly when the
|
|
4
|
+
// table can satisfy the change from its in-memory set (no API call), but coalesce
|
|
5
|
+
// keystrokes when the change triggers a remote fetch. These are the two delays.
|
|
6
|
+
const LOCAL_MS = 250
|
|
7
|
+
const REMOTE_MS = 600
|
|
8
|
+
|
|
9
|
+
// The slice of an InfiniteScrollTable's exposed surface this helper needs: its
|
|
10
|
+
// own answer to whether a prospective text filter resolves locally.
|
|
11
|
+
export interface LocalFilterable {
|
|
12
|
+
willFilterLocally(value: string | undefined): boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Builds SearchField's per-keystroke `debounce` resolver from the table's template
|
|
16
|
+
// ref. Before the table mounts (ref still empty), and whenever the table reports it
|
|
17
|
+
// can't serve the change locally, it yields the longer remote delay.
|
|
18
|
+
export const localFilterDebounce =
|
|
19
|
+
<T extends LocalFilterable>(table: Ref<T | undefined>) =>
|
|
20
|
+
(value: string | undefined): number =>
|
|
21
|
+
table.value?.willFilterLocally(value) ? LOCAL_MS : REMOTE_MS
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { default as InfiniteScrollTable } from './InfiniteScrollTable.vue'
|
|
2
|
+
export type { ColumnDefinition, LoaderParams, LoaderResponse, LoaderFunction, Filters, RowKey, DropTarget, RowPosition, VisibleRange, InfiniteScrollTableExposed } from './InfiniteScrollTable.types'
|
|
3
|
+
export { localFilterDebounce, type LocalFilterable } from './localFilterDebounce'
|
|
4
|
+
export { default as MasterDetail } from './MasterDetail.vue'
|
|
5
|
+
export { default as CrudPanel } from './CrudPanel.vue'
|
|
6
|
+
export type { CrudPanelExposed } from './CrudPanel.vue'
|
|
7
|
+
export { default as useRecordEditor, type RecordEditor, type RecordEditorOptions } from './useRecordEditor'
|
|
8
|
+
export { useSortState, type SortState, type SortDir, type ColumnOptions } from './useSortState'
|
|
9
|
+
export { listLoader, type PageArgs } from './listLoader'
|
|
10
|
+
export { fmtDate, fmtBool, fmtYesNo } from './formatters'
|
|
11
|
+
export { useDetailLoader, type DetailLoader } from './useDetailLoader'
|
|
12
|
+
export { useAsyncAction, type AsyncAction } from './useAsyncAction'
|
|
13
|
+
export { errorMessage } from './errorMessage'
|
|
14
|
+
export { default as DescriptionList } from './DescriptionList.vue'
|
|
15
|
+
export type { DescriptionItem } from './DescriptionList.vue'
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { ref, type Ref } from 'vue'
|
|
2
|
+
import { errorMessage } from './errorMessage'
|
|
3
|
+
|
|
4
|
+
export interface AsyncAction<Args extends unknown[]> {
|
|
5
|
+
/** True while the action runs; `run` is re-entry-guarded on it. */
|
|
6
|
+
acting: Ref<boolean>
|
|
7
|
+
error: Ref<string | null>
|
|
8
|
+
/** Success notice — the string the action resolved to, if any. */
|
|
9
|
+
notice: Ref<string | null>
|
|
10
|
+
run: (...args: Args) => Promise<void>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* In-flight/error/notice bookkeeping around an async action — the repeated
|
|
15
|
+
* action-button skeleton. `run` ignores re-entry while acting, clears
|
|
16
|
+
* error/notice, and captures a rejection's message locally (no global
|
|
17
|
+
* handling), so pass a silent api call inside `fn`. When `fn` resolves to a
|
|
18
|
+
* string, that string becomes the success notice. Buttons that share one
|
|
19
|
+
* in-flight state parameterise `fn` and pass the variant to `run`.
|
|
20
|
+
*/
|
|
21
|
+
export const useAsyncAction = <Args extends unknown[]>(fn: (...args: Args) => Promise<string | void>): AsyncAction<Args> => {
|
|
22
|
+
const acting = ref(false)
|
|
23
|
+
const error = ref<string | null>(null)
|
|
24
|
+
const notice = ref<string | null>(null)
|
|
25
|
+
|
|
26
|
+
const run = async (...args: Args): Promise<void> => {
|
|
27
|
+
if (acting.value) return
|
|
28
|
+
acting.value = true
|
|
29
|
+
error.value = null
|
|
30
|
+
notice.value = null
|
|
31
|
+
try {
|
|
32
|
+
const result = await fn(...args)
|
|
33
|
+
if (typeof result === 'string') notice.value = result
|
|
34
|
+
} catch (e) {
|
|
35
|
+
error.value = errorMessage(e)
|
|
36
|
+
} finally {
|
|
37
|
+
acting.value = false
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
acting,
|
|
43
|
+
error,
|
|
44
|
+
notice,
|
|
45
|
+
run
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { ref, watch, toValue, type Ref, type WatchSource } from 'vue'
|
|
2
|
+
import { errorMessage } from './errorMessage'
|
|
3
|
+
|
|
4
|
+
export interface DetailLoader<T> {
|
|
5
|
+
data: Ref<T | null>
|
|
6
|
+
loading: Ref<boolean>
|
|
7
|
+
error: Ref<string | null>
|
|
8
|
+
reload: () => Promise<void>
|
|
9
|
+
// Patch the loaded value in place (a no-op before the first load). Lets a
|
|
10
|
+
// caller fold a known local change — an appended item, a status flip — into
|
|
11
|
+
// the pane without a full re-fetch, while the loader stays the sole writer.
|
|
12
|
+
mutate: (fn: (cur: T) => void) => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The detail-pane fetch skeleton: load on mount, re-load whenever the id
|
|
17
|
+
* changes, expose data/loading/error. Error handling stays local — the
|
|
18
|
+
* fetcher's rejection message lands in the `error` ref for in-pane display,
|
|
19
|
+
* never in a global toast — so pass a silent api call as the fetcher.
|
|
20
|
+
* Responses that arrive after the id has moved on are discarded.
|
|
21
|
+
*/
|
|
22
|
+
export const useDetailLoader = <T, Id>(id: WatchSource<Id>, fetcher: (id: Id) => Promise<T>): DetailLoader<T> => {
|
|
23
|
+
const data = ref(null) as Ref<T | null>
|
|
24
|
+
const loading = ref(false)
|
|
25
|
+
const error = ref<string | null>(null)
|
|
26
|
+
let seq = 0
|
|
27
|
+
|
|
28
|
+
const reload = async (): Promise<void> => {
|
|
29
|
+
const token = ++seq
|
|
30
|
+
loading.value = true
|
|
31
|
+
error.value = null
|
|
32
|
+
try {
|
|
33
|
+
const result = await fetcher(toValue(id))
|
|
34
|
+
if (token !== seq) return
|
|
35
|
+
data.value = result
|
|
36
|
+
} catch (e) {
|
|
37
|
+
if (token !== seq) return
|
|
38
|
+
error.value = errorMessage(e)
|
|
39
|
+
} finally {
|
|
40
|
+
if (token === seq) loading.value = false
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const mutate = (fn: (cur: T) => void): void => {
|
|
45
|
+
if (data.value !== null) fn(data.value)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
watch(id, reload, { immediate: true })
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
data,
|
|
52
|
+
loading,
|
|
53
|
+
error,
|
|
54
|
+
reload,
|
|
55
|
+
mutate
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { reactive, computed, ref, type Ref, type ComputedRef } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Record-edit pattern for CRUD detail editors.
|
|
5
|
+
*
|
|
6
|
+
* Convention (matches what CrudPanel and patchRow expect):
|
|
7
|
+
*
|
|
8
|
+
* 1. The detail editor receives the full record it's editing (loaded by the
|
|
9
|
+
* caller from a list row, an API call, etc.).
|
|
10
|
+
* 2. It keeps two parallel objects:
|
|
11
|
+
* - `data` — the canonical state acknowledged by the backend.
|
|
12
|
+
* - `workData` — a reactive working copy bound to inputs.
|
|
13
|
+
* 3. On save, only fields that differ between `workData` and `data` are
|
|
14
|
+
* sent. Sending the entire record on every save is wasteful and risks
|
|
15
|
+
* clobbering server-side changes to fields the user didn't touch.
|
|
16
|
+
* 4. The `save(patch)` callback returns the FULL updated record. The
|
|
17
|
+
* backend may modify fields beyond what the user changed (timestamps,
|
|
18
|
+
* derived fields, server-side defaults); the editor must acknowledge
|
|
19
|
+
* those by overwriting its local state with the response. This makes
|
|
20
|
+
* the next diff correct and surfaces server changes in the UI.
|
|
21
|
+
* 5. The caller wires `('saved', key, record)` → `CrudPanel.applySaved(key,
|
|
22
|
+
* record, tab.id)` to update the row in place — no list re-fetch.
|
|
23
|
+
*
|
|
24
|
+
* Field shapes supported: strings, numbers, booleans, arrays, plain objects.
|
|
25
|
+
* Equality is by canonical JSON — sufficient for typical CRUD payloads.
|
|
26
|
+
*
|
|
27
|
+
* What this composable does NOT do:
|
|
28
|
+
* - HTTP. The caller supplies `save(patch)`.
|
|
29
|
+
* - Validation. Caller-side concern; gate `submit()` behind your own checks.
|
|
30
|
+
* - File uploads, multi-step save flows. Build those around `changes` /
|
|
31
|
+
* `data` / `workData` and call `applyResponse(updated)` manually instead
|
|
32
|
+
* of `submit()` for those cases.
|
|
33
|
+
*/
|
|
34
|
+
export interface RecordEditor<T extends Record<keyof T & string, unknown>> {
|
|
35
|
+
/** Canonical state acknowledged by the backend. Mutates after each save. */
|
|
36
|
+
data: T
|
|
37
|
+
/** Reactive working copy bound to inputs. Edit this freely. */
|
|
38
|
+
workData: T
|
|
39
|
+
/** Diff of workData vs data, or null when nothing has changed. */
|
|
40
|
+
changes: ComputedRef<Partial<T> | null>
|
|
41
|
+
/** True when there are unsaved changes. */
|
|
42
|
+
dirty: ComputedRef<boolean>
|
|
43
|
+
/** True while a `submit()` call is in flight. */
|
|
44
|
+
saving: Ref<boolean>
|
|
45
|
+
/**
|
|
46
|
+
* Send the current diff via `save(patch)`, then assign the response onto
|
|
47
|
+
* both `data` and `workData`. No-op (returns null) when not dirty.
|
|
48
|
+
*/
|
|
49
|
+
submit: () => Promise<T | null>
|
|
50
|
+
/** Discard local edits, reverting workData to data. */
|
|
51
|
+
reset: () => void
|
|
52
|
+
/**
|
|
53
|
+
* Manually acknowledge a server-side update (useful when the save flow
|
|
54
|
+
* is custom — e.g. file uploads — and `submit()` isn't called).
|
|
55
|
+
*/
|
|
56
|
+
applyResponse: (updated: T) => void
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface RecordEditorOptions<T extends Record<keyof T & string, unknown>> {
|
|
60
|
+
/** Initial record snapshot. Deep-cloned into both data and workData. */
|
|
61
|
+
initial: T
|
|
62
|
+
/** Sends the diff to the backend; must resolve to the full updated record. */
|
|
63
|
+
save: (patch: Partial<T>) => Promise<T>
|
|
64
|
+
/**
|
|
65
|
+
* Restrict which keys are considered editable / diffed. Defaults to the
|
|
66
|
+
* keys present on `initial`. Useful when the record contains read-only
|
|
67
|
+
* fields (e.g. id, created_at) that should never be sent in a patch.
|
|
68
|
+
*/
|
|
69
|
+
fields?: readonly (keyof T)[]
|
|
70
|
+
/**
|
|
71
|
+
* Canonicalise a record before it enters the editor — applied to `initial`
|
|
72
|
+
* and to every save response. Use it to collapse irrelevant wire
|
|
73
|
+
* differences (`undefined` vs `null`, absent vs empty string) so the diff
|
|
74
|
+
* doesn't oscillate on them.
|
|
75
|
+
*/
|
|
76
|
+
normalise?: (record: T) => T
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export default function useRecordEditor<T extends Record<keyof T & string, unknown>>(options: RecordEditorOptions<T>): RecordEditor<T> {
|
|
80
|
+
const fields = (options.fields ?? Object.keys(options.initial)) as (keyof T)[]
|
|
81
|
+
const normalise = options.normalise ?? ((record: T): T => record)
|
|
82
|
+
const pick = (src: T): T => {
|
|
83
|
+
const out = {} as T
|
|
84
|
+
for (const k of fields) out[k] = src[k]
|
|
85
|
+
return out
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// JSON clone — matches the diff equality function below and handles Vue
|
|
89
|
+
// proxies (structuredClone chokes on them) without an extra `toRaw` step.
|
|
90
|
+
const clone = <V>(v: V): V => JSON.parse(JSON.stringify(v)) as V
|
|
91
|
+
const initial = normalise(clone(options.initial))
|
|
92
|
+
const data = reactive(initial) as T
|
|
93
|
+
const workData = reactive(clone(pick(initial))) as T
|
|
94
|
+
const saving = ref(false)
|
|
95
|
+
|
|
96
|
+
const changes = computed<Partial<T> | null>(() => {
|
|
97
|
+
const diff: Partial<T> = {}
|
|
98
|
+
let any = false
|
|
99
|
+
for (const k of fields) {
|
|
100
|
+
if (!equal(workData[k], data[k])) {
|
|
101
|
+
diff[k] = workData[k]
|
|
102
|
+
any = true
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return any ? diff : null
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const dirty = computed(() => changes.value !== null)
|
|
109
|
+
|
|
110
|
+
function applyResponse(updated: T): void {
|
|
111
|
+
const normalised = normalise(updated)
|
|
112
|
+
Object.assign(data, normalised)
|
|
113
|
+
Object.assign(workData, pick(normalised))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function submit(): Promise<T | null> {
|
|
117
|
+
const patch = changes.value
|
|
118
|
+
if (!patch) return null
|
|
119
|
+
saving.value = true
|
|
120
|
+
try {
|
|
121
|
+
const updated = await options.save(patch)
|
|
122
|
+
applyResponse(updated)
|
|
123
|
+
return updated
|
|
124
|
+
} finally {
|
|
125
|
+
saving.value = false
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function reset(): void {
|
|
130
|
+
Object.assign(workData, pick(data))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
data,
|
|
135
|
+
workData,
|
|
136
|
+
changes,
|
|
137
|
+
dirty,
|
|
138
|
+
saving,
|
|
139
|
+
submit,
|
|
140
|
+
reset,
|
|
141
|
+
applyResponse
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function equal(a: unknown, b: unknown): boolean {
|
|
146
|
+
if (a === b) return true
|
|
147
|
+
return JSON.stringify(a) === JSON.stringify(b)
|
|
148
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { ref, computed, type Ref, type ComputedRef } from 'vue'
|
|
2
|
+
import type { ColumnDefinition } from './InfiniteScrollTable.types'
|
|
3
|
+
|
|
4
|
+
export type SortDir = 'asc' | 'desc'
|
|
5
|
+
|
|
6
|
+
export type ColumnOptions<T = Record<string, unknown>> = Omit<ColumnDefinition<T>, 'field' | 'label' | 'sortable' | 'sortIcon' | 'onHeaderClick'> & {
|
|
7
|
+
/**
|
|
8
|
+
* Sort field sent to the backend when it differs from `field`
|
|
9
|
+
* (e.g. field 'asking_price' sorting by 'price'); `false` makes the
|
|
10
|
+
* column unsortable. Defaults to `field`.
|
|
11
|
+
*/
|
|
12
|
+
sortKey?: string | false
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SortState {
|
|
16
|
+
sortBy: Ref<string>
|
|
17
|
+
sortDir: Ref<SortDir>
|
|
18
|
+
/** Same field flips direction; a new field takes over with its default direction. */
|
|
19
|
+
toggleSort: (field: string) => void
|
|
20
|
+
sortIcon: (field: string) => string | undefined
|
|
21
|
+
/** `{ sort_by, sort_dir }` in the wire shape InfiniteScrollTable filters / LoaderParams carry. */
|
|
22
|
+
sortFilters: ComputedRef<{ sort_by: string; sort_dir: SortDir }>
|
|
23
|
+
/**
|
|
24
|
+
* ColumnDefinition factory wired to this sort state (header click toggles,
|
|
25
|
+
* icon reflects). Build the columns array inside a `computed` so the icons
|
|
26
|
+
* track state changes.
|
|
27
|
+
*/
|
|
28
|
+
column: <T = Record<string, unknown>>(field: string, label: string, options?: ColumnOptions<T>) => ColumnDefinition<T>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const useSortState = (initialBy: string, initialDir: SortDir = 'desc', defaultDir: SortDir | ((field: string) => SortDir) = 'asc'): SortState => {
|
|
32
|
+
const sortBy = ref(initialBy)
|
|
33
|
+
const sortDir = ref<SortDir>(initialDir)
|
|
34
|
+
|
|
35
|
+
const toggleSort = (field: string): void => {
|
|
36
|
+
if (sortBy.value === field) {
|
|
37
|
+
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
|
|
38
|
+
} else {
|
|
39
|
+
sortBy.value = field
|
|
40
|
+
sortDir.value = typeof defaultDir === 'function' ? defaultDir(field) : defaultDir
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const sortIcon = (field: string): string | undefined => (sortBy.value === field ? (sortDir.value === 'asc' ? '▲' : '▼') : undefined)
|
|
45
|
+
|
|
46
|
+
const sortFilters = computed(() => ({ sort_by: sortBy.value, sort_dir: sortDir.value }))
|
|
47
|
+
|
|
48
|
+
const column = <T = Record<string, unknown>>(field: string, label: string, options: ColumnOptions<T> = {}): ColumnDefinition<T> => {
|
|
49
|
+
const { sortKey = field, ...rest } = options
|
|
50
|
+
return {
|
|
51
|
+
field,
|
|
52
|
+
label,
|
|
53
|
+
sortable: sortKey !== false,
|
|
54
|
+
...(sortKey === false ? {} : { onHeaderClick: () => toggleSort(sortKey), sortIcon: sortIcon(sortKey) }),
|
|
55
|
+
...rest
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
sortBy,
|
|
61
|
+
sortDir,
|
|
62
|
+
toggleSort,
|
|
63
|
+
sortIcon,
|
|
64
|
+
sortFilters,
|
|
65
|
+
column
|
|
66
|
+
}
|
|
67
|
+
}
|