mgv-backoffice 1.45.0 → 1.47.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 +56 -0
- package/dist/components/BaseGroupedChecklist.vue.d.ts +26 -0
- package/dist/components/BaseIdChip.vue.d.ts +24 -0
- package/dist/composables/useDirtyState.d.ts +24 -0
- package/dist/index.d.ts +5 -1
- package/dist/types/checklist.d.ts +6 -0
- package/dist/ui-lib.css +1 -1
- package/dist/ui-lib.js +341 -189
- package/dist/ui-lib.umd.cjs +1 -1
- package/dist/utils/format.d.ts +15 -0
- package/package.json +1 -1
- package/src/components/BaseGroupedChecklist.test.ts +73 -0
- package/src/components/BaseGroupedChecklist.vue +122 -0
- package/src/components/BaseIdChip.test.ts +26 -0
- package/src/components/BaseIdChip.vue +51 -0
- package/src/composables/useDirtyState.test.ts +38 -0
- package/src/composables/useDirtyState.ts +44 -0
- package/src/index.ts +6 -0
- package/src/types/checklist.ts +6 -0
- package/src/utils/format.test.ts +322 -290
- package/src/utils/format.ts +42 -0
package/dist/utils/format.d.ts
CHANGED
|
@@ -53,6 +53,21 @@ export declare function fmtUsd(v: number): string;
|
|
|
53
53
|
* strings so BigDecimal-as-string payloads pass through.
|
|
54
54
|
*/
|
|
55
55
|
export declare function fmtMoney(n: number | string | null | undefined, digits?: number, currency?: string): string;
|
|
56
|
+
/**
|
|
57
|
+
* Display symbol for an ISO / ticker currency code: "€" for EUR, "£" for
|
|
58
|
+
* GBP, "$" for USD and the USD stablecoins — and for a missing code, so
|
|
59
|
+
* rows with no currency keep reading as dollars. Any other code (BTC,
|
|
60
|
+
* ETH, …) has no symbol and returns null; callers suffix the code instead.
|
|
61
|
+
*/
|
|
62
|
+
export declare function currencySymbol(code: string | null | undefined): string | null;
|
|
63
|
+
/**
|
|
64
|
+
* Format an amount in its own currency: fiat with a symbol goes through
|
|
65
|
+
* fmtMoney ("€20.00", "-$1.50"), a USD stablecoin keeps its ticker after
|
|
66
|
+
* the dollar figure ("$25.00 USDT") so it isn't mistaken for cash, and any
|
|
67
|
+
* other asset prints as a plain quantity with its code ("0.5 BTC", up to 8
|
|
68
|
+
* decimals). Em-dash for missing / non-finite amounts, like fmtMoney.
|
|
69
|
+
*/
|
|
70
|
+
export declare function fmtCurrencyAmount(n: number | string | null | undefined, code: string | null | undefined, digits?: number): string;
|
|
56
71
|
/**
|
|
57
72
|
* Format an ISO date string / epoch as a "Mon D, YYYY" calendar label
|
|
58
73
|
* (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>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BaseIdChip from './BaseIdChip.vue'
|
|
4
|
+
|
|
5
|
+
describe('BaseIdChip', () => {
|
|
6
|
+
it('shows the label and the full id in the tooltip', () => {
|
|
7
|
+
const w = mount(BaseIdChip, { props: { label: 'ord', value: 'abc-123-def' } })
|
|
8
|
+
expect(w.text()).toContain('ord')
|
|
9
|
+
expect(w.get('code').text()).toBe('abc-123-def')
|
|
10
|
+
expect(w.get('code').attributes('title')).toBe('abc-123-def')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('labels the copy button from label + value, or copyLabel when given', () => {
|
|
14
|
+
const plain = mount(BaseIdChip, { props: { label: 'id', value: 'x1' } })
|
|
15
|
+
expect(plain.get('button[aria-label="Copy id x1"]').exists()).toBe(true)
|
|
16
|
+
const custom = mount(BaseIdChip, { props: { label: 'id', value: 'x1', copyLabel: 'tx id' } })
|
|
17
|
+
expect(custom.get('button[aria-label="Copy tx id"]').exists()).toBe(true)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('renders the action button only with actionLabel, emitting the value', async () => {
|
|
21
|
+
expect(mount(BaseIdChip, { props: { value: 'x1' } }).findAll('button')).toHaveLength(1)
|
|
22
|
+
const w = mount(BaseIdChip, { props: { value: 'x1', actionLabel: 'Search this id' } })
|
|
23
|
+
await w.get('button[aria-label="Search this id"]').trigger('click')
|
|
24
|
+
expect(w.emitted('action')).toEqual([['x1']])
|
|
25
|
+
})
|
|
26
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Compact id cell: a small uppercase label, the id truncated in a <code>
|
|
4
|
+
pill (full value in the tooltip), a copy button, and — when
|
|
5
|
+
`actionLabel` is set — a magnifier button that emits `action` (e.g.
|
|
6
|
+
"search the log for this id"). Built for dense rows that carry several
|
|
7
|
+
ids side by side (transaction / buy-order / sell-order).
|
|
8
|
+
-->
|
|
9
|
+
<div class="inline-flex items-center gap-1 font-mono text-[11px]">
|
|
10
|
+
<span v-if="label" class="text-slate-500 dark:text-slate-400 uppercase tracking-wide">{{ label }}</span>
|
|
11
|
+
<code
|
|
12
|
+
class="truncate rounded bg-slate-100 px-1 text-slate-600 dark:bg-slate-800 dark:text-slate-300"
|
|
13
|
+
:class="maxWidthClass"
|
|
14
|
+
:title="value"
|
|
15
|
+
>{{ value }}</code>
|
|
16
|
+
<BaseCopyButton :text="value" :label="copyLabel ?? `${label} ${value}`.trim()" icon-class="w-3.5 h-3.5" />
|
|
17
|
+
<button
|
|
18
|
+
v-if="actionLabel"
|
|
19
|
+
type="button"
|
|
20
|
+
:title="actionLabel"
|
|
21
|
+
:aria-label="actionLabel"
|
|
22
|
+
class="rounded px-0.5 text-slate-400 hover:bg-slate-100 hover:text-emerald-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 dark:hover:bg-slate-800 dark:hover:text-emerald-400"
|
|
23
|
+
@click="emit('action', value)"
|
|
24
|
+
>
|
|
25
|
+
<MagnifyingGlassIcon class="h-3.5 w-3.5" aria-hidden="true" />
|
|
26
|
+
</button>
|
|
27
|
+
</div>
|
|
28
|
+
</template>
|
|
29
|
+
|
|
30
|
+
<script setup lang="ts">
|
|
31
|
+
import { MagnifyingGlassIcon } from '@heroicons/vue/24/outline'
|
|
32
|
+
import BaseCopyButton from './BaseCopyButton.vue'
|
|
33
|
+
|
|
34
|
+
withDefaults(
|
|
35
|
+
defineProps<{
|
|
36
|
+
/** The id itself — shown truncated, copied in full. */
|
|
37
|
+
value: string
|
|
38
|
+
/** Short uppercase prefix ("id", "ord", "sell"). Omit for no prefix. */
|
|
39
|
+
label?: string
|
|
40
|
+
/** Copy button's tooltip / aria noun; defaults to "<label> <value>". */
|
|
41
|
+
copyLabel?: string
|
|
42
|
+
/** Tooltip / aria-label of the action button; the button only renders when set. */
|
|
43
|
+
actionLabel?: string
|
|
44
|
+
/** Width cap of the truncated id. */
|
|
45
|
+
maxWidthClass?: string
|
|
46
|
+
}>(),
|
|
47
|
+
{ label: '', copyLabel: undefined, actionLabel: undefined, maxWidthClass: 'max-w-[90px]' },
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
const emit = defineEmits<{ action: [value: string] }>()
|
|
51
|
+
</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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -51,6 +51,8 @@ export { default as BaseFilterChip } from './components/BaseFilterChip.vue'
|
|
|
51
51
|
export { default as BaseCredentialsForm } from './components/BaseCredentialsForm.vue'
|
|
52
52
|
export { default as BasePillPickerModal } from './components/BasePillPickerModal.vue'
|
|
53
53
|
export { default as BaseBarDistribution } from './components/BaseBarDistribution.vue'
|
|
54
|
+
export { default as BaseGroupedChecklist } from './components/BaseGroupedChecklist.vue'
|
|
55
|
+
export { default as BaseIdChip } from './components/BaseIdChip.vue'
|
|
54
56
|
export type { CredentialsView, CredentialsUpdate } from './components/BaseCredentialsForm.vue'
|
|
55
57
|
|
|
56
58
|
// Components — third-party wrappers
|
|
@@ -86,6 +88,7 @@ export type { FieldClasses } from './composables/useFieldClasses'
|
|
|
86
88
|
export { usePolling } from './composables/usePolling'
|
|
87
89
|
export type { UsePollingOptions } from './composables/usePolling'
|
|
88
90
|
export { useLatestRequest, isAbortError } from './composables/useLatestRequest'
|
|
91
|
+
export { useDirtyState, stableStringify } from './composables/useDirtyState'
|
|
89
92
|
export type { LatestRequestToken } from './composables/useLatestRequest'
|
|
90
93
|
|
|
91
94
|
// Enums
|
|
@@ -113,6 +116,7 @@ export type { SpecField, SpecFieldType, SpecFieldValue } from './types/specField
|
|
|
113
116
|
export type { StatBreakdownItem } from './types/statBreakdown'
|
|
114
117
|
export type { PillPickerItem } from './types/pillPicker'
|
|
115
118
|
export type { DistributionBar } from './types/distribution'
|
|
119
|
+
export type { ChecklistGroup } from './types/checklist'
|
|
116
120
|
|
|
117
121
|
// Utils
|
|
118
122
|
export { getBaseColor, getBaseColorOf } from './utils/util'
|
|
@@ -141,6 +145,8 @@ export {
|
|
|
141
145
|
fmtPct,
|
|
142
146
|
fmtUsd,
|
|
143
147
|
fmtMoney,
|
|
148
|
+
currencySymbol,
|
|
149
|
+
fmtCurrencyAmount,
|
|
144
150
|
fmtDuration,
|
|
145
151
|
formatJson,
|
|
146
152
|
stringifyValue,
|