mgv-backoffice 1.44.0 → 1.46.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/README.md +2048 -2000
- package/dist/components/BaseGroupedChecklist.vue.d.ts +26 -0
- package/dist/components/Pagination.vue.d.ts +9 -0
- package/dist/composables/useDirtyState.d.ts +24 -0
- package/dist/composables/useLatestRequest.d.ts +51 -0
- package/dist/index.d.ts +6 -1
- package/dist/types/checklist.d.ts +6 -0
- package/dist/ui-lib.css +1 -1
- package/dist/ui-lib.js +1033 -899
- package/dist/ui-lib.umd.cjs +1 -1
- package/dist/utils/format.d.ts +8 -0
- package/package.json +1 -1
- package/src/components/BaseGroupedChecklist.test.ts +73 -0
- package/src/components/BaseGroupedChecklist.vue +122 -0
- package/src/components/Pagination.test.ts +39 -0
- package/src/components/Pagination.vue +102 -87
- package/src/composables/useDirtyState.test.ts +38 -0
- package/src/composables/useDirtyState.ts +44 -0
- package/src/composables/useLatestRequest.test.ts +61 -0
- package/src/composables/useLatestRequest.ts +81 -0
- package/src/index.ts +6 -0
- package/src/types/checklist.ts +6 -0
- package/src/utils/format.test.ts +26 -0
- package/src/utils/format.ts +25 -0
package/dist/utils/format.d.ts
CHANGED
|
@@ -45,6 +45,14 @@ export declare function fmtPct(n: number, digits?: number): string;
|
|
|
45
45
|
* Format a signed USD amount with an explicit leading sign (e.g. "+$5.00").
|
|
46
46
|
*/
|
|
47
47
|
export declare function fmtUsd(v: number): string;
|
|
48
|
+
/**
|
|
49
|
+
* Format a money amount with the sign BEFORE the currency symbol ("-$2.26",
|
|
50
|
+
* never "$-2.26") and locale digit grouping ("$1,234.50"). Positive values
|
|
51
|
+
* carry no sign — prefix an explicit '+' where a gain should read signed.
|
|
52
|
+
* Renders an em-dash for null/undefined/non-finite input; accepts numeric
|
|
53
|
+
* strings so BigDecimal-as-string payloads pass through.
|
|
54
|
+
*/
|
|
55
|
+
export declare function fmtMoney(n: number | string | null | undefined, digits?: number, currency?: string): string;
|
|
48
56
|
/**
|
|
49
57
|
* Format an ISO date string / epoch as a "Mon D, YYYY" calendar label
|
|
50
58
|
* (en-US, e.g. "Jan 5, 2026"). Falls back to the raw value on parse
|
package/package.json
CHANGED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BaseGroupedChecklist from './BaseGroupedChecklist.vue'
|
|
4
|
+
|
|
5
|
+
const GROUPS = [
|
|
6
|
+
{ key: 'STOCK', label: 'Stocks', items: ['AMD', 'NVDA'] },
|
|
7
|
+
{ key: 'ETF', label: 'ETFs', items: [] },
|
|
8
|
+
{ key: 'CRYPTO', label: 'Crypto', items: ['BTC/USD'] },
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
function mountList(modelValue: string[] = []) {
|
|
12
|
+
return mount(BaseGroupedChecklist, {
|
|
13
|
+
props: {
|
|
14
|
+
groups: GROUPS,
|
|
15
|
+
modelValue,
|
|
16
|
+
allAriaLabel: 'Select all symbols',
|
|
17
|
+
itemAriaLabel: (s: string) => `Pick ${s}`,
|
|
18
|
+
'onUpdate:modelValue': () => {},
|
|
19
|
+
},
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const box = (w: ReturnType<typeof mountList>, label: string) =>
|
|
24
|
+
w.find<HTMLInputElement>(`input[aria-label="${label}"]`)
|
|
25
|
+
|
|
26
|
+
const lastEmit = (w: ReturnType<typeof mountList>) =>
|
|
27
|
+
(w.emitted('update:modelValue') as string[][][]).at(-1)![0]
|
|
28
|
+
|
|
29
|
+
describe('BaseGroupedChecklist', () => {
|
|
30
|
+
it('renders non-empty groups with counts and reflects the model', () => {
|
|
31
|
+
const w = mountList(['AMD'])
|
|
32
|
+
expect(w.text()).toContain('Select all (3)')
|
|
33
|
+
expect(w.text()).toContain('Stocks (2)')
|
|
34
|
+
expect(w.text()).not.toContain('ETFs')
|
|
35
|
+
expect(box(w, 'Pick AMD').element.checked).toBe(true)
|
|
36
|
+
expect(box(w, 'Pick NVDA').element.checked).toBe(false)
|
|
37
|
+
expect(box(w, 'Select all Stocks').element.checked).toBe(false)
|
|
38
|
+
expect(box(w, 'Select all symbols').element.checked).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('toggles one item', async () => {
|
|
42
|
+
const w = mountList(['AMD'])
|
|
43
|
+
await box(w, 'Pick BTC/USD').trigger('change')
|
|
44
|
+
expect(lastEmit(w)).toEqual(['AMD', 'BTC/USD'])
|
|
45
|
+
await box(w, 'Pick AMD').trigger('change')
|
|
46
|
+
expect(lastEmit(w)).toEqual([])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('selects and clears a whole group, leaving others alone', async () => {
|
|
50
|
+
const w = mountList(['BTC/USD'])
|
|
51
|
+
await box(w, 'Select all Stocks').trigger('change')
|
|
52
|
+
expect(lastEmit(w)).toEqual(['AMD', 'NVDA', 'BTC/USD'])
|
|
53
|
+
await w.setProps({ modelValue: ['AMD', 'NVDA', 'BTC/USD'] })
|
|
54
|
+
await box(w, 'Select all Stocks').trigger('change')
|
|
55
|
+
expect(lastEmit(w)).toEqual(['BTC/USD'])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('master checkbox selects everything, then clears everything', async () => {
|
|
59
|
+
const w = mountList([])
|
|
60
|
+
await box(w, 'Select all symbols').trigger('change')
|
|
61
|
+
expect(lastEmit(w)).toEqual(['AMD', 'NVDA', 'BTC/USD'])
|
|
62
|
+
await w.setProps({ modelValue: ['AMD', 'NVDA', 'BTC/USD'] })
|
|
63
|
+
expect(box(w, 'Select all symbols').element.checked).toBe(true)
|
|
64
|
+
await box(w, 'Select all symbols').trigger('change')
|
|
65
|
+
expect(lastEmit(w)).toEqual([])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('emits in group order regardless of click order', async () => {
|
|
69
|
+
const w = mountList(['BTC/USD'])
|
|
70
|
+
await box(w, 'Pick AMD').trigger('change')
|
|
71
|
+
expect(lastEmit(w)).toEqual(['AMD', 'BTC/USD'])
|
|
72
|
+
})
|
|
73
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<label class="flex items-center gap-2 border-b border-slate-200 dark:border-slate-700 pb-2">
|
|
4
|
+
<input
|
|
5
|
+
type="checkbox"
|
|
6
|
+
class="h-4 w-4"
|
|
7
|
+
:checked="allSelected"
|
|
8
|
+
:aria-label="allAriaLabel"
|
|
9
|
+
@change="toggleAll"
|
|
10
|
+
/>
|
|
11
|
+
<span class="text-sm font-medium text-slate-700 dark:text-slate-200">
|
|
12
|
+
{{ allLabel }} ({{ allItems.length }})
|
|
13
|
+
</span>
|
|
14
|
+
</label>
|
|
15
|
+
<div class="overflow-y-auto space-y-3 mt-3" :class="maxHeightClass">
|
|
16
|
+
<div v-for="g in visibleGroups" :key="g.key">
|
|
17
|
+
<label class="flex items-center gap-2 pb-1.5">
|
|
18
|
+
<input
|
|
19
|
+
type="checkbox"
|
|
20
|
+
class="h-4 w-4"
|
|
21
|
+
:checked="groupSelected(g)"
|
|
22
|
+
:aria-label="`${allLabel} ${g.label}`"
|
|
23
|
+
@change="toggleGroup(g)"
|
|
24
|
+
/>
|
|
25
|
+
<span class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
|
26
|
+
{{ g.label }} ({{ g.items.length }})
|
|
27
|
+
</span>
|
|
28
|
+
</label>
|
|
29
|
+
<div class="grid grid-cols-[repeat(2,minmax(0,1fr))] sm:grid-cols-[repeat(3,minmax(0,1fr))] gap-x-3 gap-y-1.5">
|
|
30
|
+
<label v-for="item in g.items" :key="item" class="flex items-center gap-2">
|
|
31
|
+
<input
|
|
32
|
+
type="checkbox"
|
|
33
|
+
class="h-4 w-4"
|
|
34
|
+
:checked="selected.has(item)"
|
|
35
|
+
:aria-label="itemAriaLabel ? itemAriaLabel(item) : item"
|
|
36
|
+
@change="toggleItem(item)"
|
|
37
|
+
/>
|
|
38
|
+
<span
|
|
39
|
+
class="text-sm text-slate-700 dark:text-slate-200"
|
|
40
|
+
:class="{ 'font-mono': mono }"
|
|
41
|
+
>{{ item }}</span>
|
|
42
|
+
</label>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</template>
|
|
48
|
+
|
|
49
|
+
<script setup lang="ts">
|
|
50
|
+
/**
|
|
51
|
+
* Multi-select checklist in groups: a master "Select all (N)" checkbox, a
|
|
52
|
+
* per-group select-all, and one checkbox per item. `v-model` is the array of
|
|
53
|
+
* selected item ids (order follows the groups). Items are plain strings and
|
|
54
|
+
* must be unique across groups; empty groups are not rendered.
|
|
55
|
+
*
|
|
56
|
+
* Typical use: "pick which symbols / users / records an action applies to"
|
|
57
|
+
* inside a confirmation modal.
|
|
58
|
+
*/
|
|
59
|
+
import { computed } from 'vue'
|
|
60
|
+
import type { ChecklistGroup } from '../types/checklist'
|
|
61
|
+
|
|
62
|
+
interface Props {
|
|
63
|
+
groups: ChecklistGroup[]
|
|
64
|
+
modelValue: string[]
|
|
65
|
+
/** Text of the master checkbox; group checkboxes read "<allLabel> <group>". */
|
|
66
|
+
allLabel?: string
|
|
67
|
+
allAriaLabel?: string
|
|
68
|
+
/** Accessible name per item checkbox (defaults to the item itself). */
|
|
69
|
+
itemAriaLabel?: (item: string) => string
|
|
70
|
+
/** Render item labels in a monospace font (tickers, ids). */
|
|
71
|
+
mono?: boolean
|
|
72
|
+
maxHeightClass?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
76
|
+
allLabel: 'Select all',
|
|
77
|
+
allAriaLabel: 'Select all items',
|
|
78
|
+
itemAriaLabel: undefined,
|
|
79
|
+
mono: false,
|
|
80
|
+
maxHeightClass: 'max-h-72',
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const emit = defineEmits<{ (e: 'update:modelValue', value: string[]): void }>()
|
|
84
|
+
|
|
85
|
+
const visibleGroups = computed(() => props.groups.filter((g) => g.items.length > 0))
|
|
86
|
+
const allItems = computed(() => visibleGroups.value.flatMap((g) => g.items))
|
|
87
|
+
const selected = computed(() => new Set(props.modelValue))
|
|
88
|
+
|
|
89
|
+
const allSelected = computed(
|
|
90
|
+
() => allItems.value.length > 0 && allItems.value.every((i) => selected.value.has(i)),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
function groupSelected(g: ChecklistGroup): boolean {
|
|
94
|
+
return g.items.every((i) => selected.value.has(i))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Emit in group order so the model is stable regardless of click order.
|
|
98
|
+
function commit(next: Set<string>) {
|
|
99
|
+
emit('update:modelValue', allItems.value.filter((i) => next.has(i)))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function toggleAll() {
|
|
103
|
+
commit(allSelected.value ? new Set() : new Set(allItems.value))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function toggleGroup(g: ChecklistGroup) {
|
|
107
|
+
const next = new Set(selected.value)
|
|
108
|
+
const on = !groupSelected(g)
|
|
109
|
+
for (const i of g.items) {
|
|
110
|
+
if (on) next.add(i)
|
|
111
|
+
else next.delete(i)
|
|
112
|
+
}
|
|
113
|
+
commit(next)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function toggleItem(item: string) {
|
|
117
|
+
const next = new Set(selected.value)
|
|
118
|
+
if (next.has(item)) next.delete(item)
|
|
119
|
+
else next.add(item)
|
|
120
|
+
commit(next)
|
|
121
|
+
}
|
|
122
|
+
</script>
|
|
@@ -90,4 +90,43 @@ describe('Pagination', () => {
|
|
|
90
90
|
expect(wrapper.findAll('button').map((b) => b.text()).filter((t) => /^\d+$/.test(t))).toEqual(['1'])
|
|
91
91
|
}
|
|
92
92
|
})
|
|
93
|
+
|
|
94
|
+
describe('controlled (page prop)', () => {
|
|
95
|
+
it('highlights the page passed in, e.g. one restored from the URL', () => {
|
|
96
|
+
const wrapper = mount(Pagination, {
|
|
97
|
+
props: { totalItems: 200, itemsPerPage: 20, page: 5 },
|
|
98
|
+
})
|
|
99
|
+
expect(wrapper.find('button.active').text()).toBe('5')
|
|
100
|
+
expect(cellLabels(wrapper)).toEqual(['1', '...', '3', '4', '5', '6', '7', '...', '10'])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('lets the user go back to page 1 from a restored page', async () => {
|
|
104
|
+
// Regression: the uncontrolled widget always started on page 1, so with
|
|
105
|
+
// a restored ?page=3 clicking "1" was swallowed as "already active".
|
|
106
|
+
const wrapper = mount(Pagination, {
|
|
107
|
+
props: { totalItems: 200, itemsPerPage: 20, page: 3 },
|
|
108
|
+
})
|
|
109
|
+
await clickPage(wrapper, 1)
|
|
110
|
+
expect(wrapper.emitted('page-changed')).toEqual([[1]])
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('follows the prop rather than its own clicks', async () => {
|
|
114
|
+
const wrapper = mount(Pagination, {
|
|
115
|
+
props: { totalItems: 200, itemsPerPage: 20, page: 2 },
|
|
116
|
+
})
|
|
117
|
+
await clickPage(wrapper, 4)
|
|
118
|
+
expect(wrapper.emitted('page-changed')).toEqual([[4]])
|
|
119
|
+
// The parent hasn't accepted the change yet — the highlight stays put.
|
|
120
|
+
expect(wrapper.find('button.active').text()).toBe('2')
|
|
121
|
+
await wrapper.setProps({ page: 4 })
|
|
122
|
+
expect(wrapper.find('button.active').text()).toBe('4')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('clamps an out-of-range page to the last page', () => {
|
|
126
|
+
const wrapper = mount(Pagination, {
|
|
127
|
+
props: { totalItems: 40, itemsPerPage: 20, page: 9 },
|
|
128
|
+
})
|
|
129
|
+
expect(wrapper.find('button.active').text()).toBe('2')
|
|
130
|
+
})
|
|
131
|
+
})
|
|
93
132
|
})
|
|
@@ -1,87 +1,102 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<nav aria-label="Pagination">
|
|
3
|
-
<ul class="flex list-none p-0 m-0">
|
|
4
|
-
<li v-for="(cell, index) in cells" :key="index" class="mr-1">
|
|
5
|
-
<button
|
|
6
|
-
v-if="cell !== 0"
|
|
7
|
-
type="button"
|
|
8
|
-
:aria-current="cell === currentPage ? 'page' : undefined"
|
|
9
|
-
:aria-label="`Go to page ${cell}`"
|
|
10
|
-
@click="changePage(cell)"
|
|
11
|
-
class="inline-block min-w-[44px] border border-gray-300 dark:border-gray-600 rounded-lg p-3 text-black dark:text-gray-200 cursor-pointer transition-colors hover:bg-gray-300 dark:hover:bg-gray-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
|
|
12
|
-
:class="{ 'active font-bold bg-gray-200 dark:bg-gray-700': cell === currentPage }"
|
|
13
|
-
>{{ cell }}</button>
|
|
14
|
-
<span v-else aria-hidden="true" class="inline-block border border-gray-300 dark:border-gray-600 rounded-lg p-3 text-black dark:text-gray-200 cursor-not-allowed">...</span>
|
|
15
|
-
</li>
|
|
16
|
-
</ul>
|
|
17
|
-
</nav>
|
|
18
|
-
</template>
|
|
19
|
-
|
|
20
|
-
<script lang="ts" setup>
|
|
21
|
-
import { ref, watch } from "vue";
|
|
22
|
-
|
|
23
|
-
const props = defineProps({
|
|
24
|
-
totalItems: {
|
|
25
|
-
type: Number,
|
|
26
|
-
required: true
|
|
27
|
-
},
|
|
28
|
-
itemsPerPage: {
|
|
29
|
-
type: Number,
|
|
30
|
-
default: 20
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
1
|
+
<template>
|
|
2
|
+
<nav aria-label="Pagination">
|
|
3
|
+
<ul class="flex list-none p-0 m-0">
|
|
4
|
+
<li v-for="(cell, index) in cells" :key="index" class="mr-1">
|
|
5
|
+
<button
|
|
6
|
+
v-if="cell !== 0"
|
|
7
|
+
type="button"
|
|
8
|
+
:aria-current="cell === currentPage ? 'page' : undefined"
|
|
9
|
+
:aria-label="`Go to page ${cell}`"
|
|
10
|
+
@click="changePage(cell)"
|
|
11
|
+
class="inline-block min-w-[44px] border border-gray-300 dark:border-gray-600 rounded-lg p-3 text-black dark:text-gray-200 cursor-pointer transition-colors hover:bg-gray-300 dark:hover:bg-gray-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
|
|
12
|
+
:class="{ 'active font-bold bg-gray-200 dark:bg-gray-700': cell === currentPage }"
|
|
13
|
+
>{{ cell }}</button>
|
|
14
|
+
<span v-else aria-hidden="true" class="inline-block border border-gray-300 dark:border-gray-600 rounded-lg p-3 text-black dark:text-gray-200 cursor-not-allowed">...</span>
|
|
15
|
+
</li>
|
|
16
|
+
</ul>
|
|
17
|
+
</nav>
|
|
18
|
+
</template>
|
|
19
|
+
|
|
20
|
+
<script lang="ts" setup>
|
|
21
|
+
import { computed, ref, watch } from "vue";
|
|
22
|
+
|
|
23
|
+
const props = defineProps({
|
|
24
|
+
totalItems: {
|
|
25
|
+
type: Number,
|
|
26
|
+
required: true
|
|
27
|
+
},
|
|
28
|
+
itemsPerPage: {
|
|
29
|
+
type: Number,
|
|
30
|
+
default: 20
|
|
31
|
+
},
|
|
32
|
+
// Optional CONTROLLED current page (1-based). When set, the highlighted
|
|
33
|
+
// page follows this prop instead of internal state, so a page restored
|
|
34
|
+
// from the URL (?page=N) renders as N rather than 1. Clicks still emit
|
|
35
|
+
// `page-changed`; the parent is expected to feed the new page back in.
|
|
36
|
+
// Left undefined, the component keeps its own state (uncontrolled).
|
|
37
|
+
page: {
|
|
38
|
+
type: Number,
|
|
39
|
+
default: undefined
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const emit = defineEmits(['page-changed']);
|
|
44
|
+
|
|
45
|
+
// Guard against a missing/zero page size or total (e.g. a reset form that
|
|
46
|
+
// nulled them): fall back to 20 per page / 0 items instead of rendering
|
|
47
|
+
// "Infinity" or "NaN" pages.
|
|
48
|
+
const totalPages = computed(() => {
|
|
49
|
+
const perPage = Number(props.itemsPerPage) > 0 ? Number(props.itemsPerPage) : 20;
|
|
50
|
+
const total = Number(props.totalItems) > 0 ? Number(props.totalItems) : 0;
|
|
51
|
+
return Math.ceil(total / perPage);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function clamp(p: number): number {
|
|
55
|
+
return Math.min(Math.max(p, 1), Math.max(totalPages.value, 1));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const internalPage = ref(1);
|
|
59
|
+
// The total shrinking under the current page snaps it to the last page.
|
|
60
|
+
watch(totalPages, () => {
|
|
61
|
+
internalPage.value = clamp(internalPage.value);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const currentPage = computed(() =>
|
|
65
|
+
props.page != null && Number.isFinite(props.page) ? clamp(props.page) : internalPage.value
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// The visible cells around the current page. A cell value of 0 renders as
|
|
69
|
+
// an ellipsis. The window is the current page ± 2 neighbours, plus the
|
|
70
|
+
// first/last page with an ellipsis only when pages are actually hidden.
|
|
71
|
+
const cells = computed(() => {
|
|
72
|
+
const total = totalPages.value;
|
|
73
|
+
if (total <= 5) {
|
|
74
|
+
return Array.from({ length: total }, (_, i) => i + 1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const out: number[] = [];
|
|
78
|
+
const start = Math.max(1, currentPage.value - 2);
|
|
79
|
+
const end = Math.min(total, currentPage.value + 2);
|
|
80
|
+
|
|
81
|
+
if (start > 1) {
|
|
82
|
+
out.push(1);
|
|
83
|
+
if (start > 2) out.push(0);
|
|
84
|
+
}
|
|
85
|
+
for (let i = start; i <= end; i++) {
|
|
86
|
+
out.push(i);
|
|
87
|
+
}
|
|
88
|
+
if (end < total) {
|
|
89
|
+
if (end < total - 1) out.push(0);
|
|
90
|
+
out.push(total);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
function changePage(pageNumber: number) {
|
|
96
|
+
if (pageNumber === currentPage.value || pageNumber < 1 || pageNumber > totalPages.value) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
internalPage.value = pageNumber;
|
|
100
|
+
emit('page-changed', pageNumber);
|
|
101
|
+
}
|
|
102
|
+
</script>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { reactive } from 'vue'
|
|
3
|
+
import { stableStringify, useDirtyState } from './useDirtyState'
|
|
4
|
+
|
|
5
|
+
describe('stableStringify', () => {
|
|
6
|
+
it('ignores key order at every depth but keeps array order', () => {
|
|
7
|
+
expect(stableStringify({ b: 1, a: { d: 2, c: 3 } })).toBe(stableStringify({ a: { c: 3, d: 2 }, b: 1 }))
|
|
8
|
+
expect(stableStringify([1, 2])).not.toBe(stableStringify([2, 1]))
|
|
9
|
+
})
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
describe('useDirtyState', () => {
|
|
13
|
+
it('starts clean, turns dirty on an edit and clean again when reverted', () => {
|
|
14
|
+
const form = reactive({ name: 'a', params: { x: 1 } as Record<string, number> })
|
|
15
|
+
const { dirty } = useDirtyState(() => form)
|
|
16
|
+
expect(dirty.value).toBe(false)
|
|
17
|
+
form.params.x = 2
|
|
18
|
+
expect(dirty.value).toBe(true)
|
|
19
|
+
form.params.x = 1
|
|
20
|
+
expect(dirty.value).toBe(false)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('markClean() adopts the current state as the baseline', () => {
|
|
24
|
+
const form = reactive({ name: 'a' })
|
|
25
|
+
const { dirty, markClean } = useDirtyState(() => form)
|
|
26
|
+
form.name = 'b'
|
|
27
|
+
expect(dirty.value).toBe(true)
|
|
28
|
+
markClean()
|
|
29
|
+
expect(dirty.value).toBe(false)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('a reordered but equal object is not an edit', () => {
|
|
33
|
+
const form = reactive<{ params: Record<string, number> }>({ params: { a: 1, b: 2 } })
|
|
34
|
+
const { dirty } = useDirtyState(() => form)
|
|
35
|
+
form.params = { b: 2, a: 1 }
|
|
36
|
+
expect(dirty.value).toBe(false)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { computed, ref, type ComputedRef } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* JSON-serialise a value with object keys sorted at every depth, so two
|
|
5
|
+
* structurally equal values always produce the same string regardless of
|
|
6
|
+
* key insertion order (arrays keep their order).
|
|
7
|
+
*/
|
|
8
|
+
export function stableStringify(value: unknown): string {
|
|
9
|
+
return JSON.stringify(value, (_key, v: unknown) => {
|
|
10
|
+
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
|
|
11
|
+
const sorted: Record<string, unknown> = {}
|
|
12
|
+
for (const k of Object.keys(v as Record<string, unknown>).sort()) {
|
|
13
|
+
sorted[k] = (v as Record<string, unknown>)[k]
|
|
14
|
+
}
|
|
15
|
+
return sorted
|
|
16
|
+
}
|
|
17
|
+
return v
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Unsaved-changes tracking for an edit form. `snapshot` returns the form's
|
|
23
|
+
* current state (read reactively); `dirty` is true whenever it differs from
|
|
24
|
+
* the last clean baseline. Call `markClean()` after loading or saving to make
|
|
25
|
+
* the current state the new baseline. Comparison is structural (key order is
|
|
26
|
+
* ignored), so a server echo that reorders keys never reads as an edit.
|
|
27
|
+
*
|
|
28
|
+
* ```ts
|
|
29
|
+
* const form = reactive({ name: '', params: {} })
|
|
30
|
+
* const { dirty, markClean } = useDirtyState(() => form)
|
|
31
|
+
* // …load → assign form → markClean(); Save enabled while dirty.value
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function useDirtyState(snapshot: () => unknown): {
|
|
35
|
+
dirty: ComputedRef<boolean>
|
|
36
|
+
markClean: () => void
|
|
37
|
+
} {
|
|
38
|
+
const baseline = ref(stableStringify(snapshot()))
|
|
39
|
+
const dirty = computed(() => stableStringify(snapshot()) !== baseline.value)
|
|
40
|
+
function markClean() {
|
|
41
|
+
baseline.value = stableStringify(snapshot())
|
|
42
|
+
}
|
|
43
|
+
return { dirty, markClean }
|
|
44
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { defineComponent, h } from 'vue'
|
|
3
|
+
import { mount } from '@vue/test-utils'
|
|
4
|
+
import { useLatestRequest, isAbortError, type LatestRequestToken } from './useLatestRequest'
|
|
5
|
+
|
|
6
|
+
describe('useLatestRequest', () => {
|
|
7
|
+
it('aborts and invalidates the previous request when a new one begins', () => {
|
|
8
|
+
const latest = useLatestRequest()
|
|
9
|
+
const a = latest.begin()
|
|
10
|
+
expect(a.isLatest()).toBe(true)
|
|
11
|
+
expect(a.signal.aborted).toBe(false)
|
|
12
|
+
|
|
13
|
+
const b = latest.begin()
|
|
14
|
+
expect(a.signal.aborted).toBe(true)
|
|
15
|
+
expect(a.isLatest()).toBe(false)
|
|
16
|
+
expect(b.isLatest()).toBe(true)
|
|
17
|
+
expect(b.signal.aborted).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('cancel() aborts the in-flight request without starting another', () => {
|
|
21
|
+
const latest = useLatestRequest()
|
|
22
|
+
const a = latest.begin()
|
|
23
|
+
latest.cancel()
|
|
24
|
+
expect(a.signal.aborted).toBe(true)
|
|
25
|
+
expect(a.isLatest()).toBe(false)
|
|
26
|
+
// A later begin() works normally.
|
|
27
|
+
expect(latest.begin().isLatest()).toBe(true)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('cancels the in-flight request when the owning component unmounts', () => {
|
|
31
|
+
let token!: LatestRequestToken
|
|
32
|
+
const wrapper = mount(
|
|
33
|
+
defineComponent({
|
|
34
|
+
setup() {
|
|
35
|
+
token = useLatestRequest().begin()
|
|
36
|
+
return () => h('div')
|
|
37
|
+
},
|
|
38
|
+
}),
|
|
39
|
+
)
|
|
40
|
+
expect(token.signal.aborted).toBe(false)
|
|
41
|
+
wrapper.unmount()
|
|
42
|
+
expect(token.signal.aborted).toBe(true)
|
|
43
|
+
expect(token.isLatest()).toBe(false)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('isAbortError', () => {
|
|
48
|
+
it('recognises DOMException and Error abort rejections', () => {
|
|
49
|
+
expect(isAbortError(new DOMException('aborted', 'AbortError'))).toBe(true)
|
|
50
|
+
const e = new Error('aborted')
|
|
51
|
+
e.name = 'AbortError'
|
|
52
|
+
expect(isAbortError(e)).toBe(true)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('rejects everything else', () => {
|
|
56
|
+
expect(isAbortError(new Error('boom'))).toBe(false)
|
|
57
|
+
expect(isAbortError(new DOMException('x', 'TimeoutError'))).toBe(false)
|
|
58
|
+
expect(isAbortError('AbortError')).toBe(false)
|
|
59
|
+
expect(isAbortError(null)).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
})
|