mgv-backoffice 1.44.0 → 1.45.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 +2016 -2000
- package/dist/components/Pagination.vue.d.ts +9 -0
- package/dist/composables/useLatestRequest.d.ts +51 -0
- package/dist/index.d.ts +3 -1
- package/dist/ui-lib.js +942 -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/Pagination.test.ts +39 -0
- package/src/components/Pagination.vue +102 -87
- package/src/composables/useLatestRequest.test.ts +61 -0
- package/src/composables/useLatestRequest.ts +81 -0
- package/src/index.ts +3 -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
|
@@ -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,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
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { getCurrentScope, onScopeDispose } from 'vue'
|
|
2
|
+
|
|
3
|
+
/** One begun request: its abort signal and whether it is still the latest. */
|
|
4
|
+
export interface LatestRequestToken {
|
|
5
|
+
/** Pass to fetch(); aborted as soon as a newer request begins (or on cancel). */
|
|
6
|
+
signal: AbortSignal
|
|
7
|
+
/**
|
|
8
|
+
* True while no newer begin() / cancel() has happened. Check it after every
|
|
9
|
+
* await before writing state, so a superseded response can never clobber
|
|
10
|
+
* the newer one's — and only the latest call clears its loading flag.
|
|
11
|
+
*/
|
|
12
|
+
isLatest: () => boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* "Latest request wins" guard for views that refetch on every filter change,
|
|
17
|
+
* poll, or Refresh click. Each `begin()` aborts the previous in-flight request
|
|
18
|
+
* on the wire and hands back a fresh AbortSignal plus an `isLatest()` check
|
|
19
|
+
* (a monotonic request id), so a slow, stale response that still lands can be
|
|
20
|
+
* dropped instead of racing the fresh one.
|
|
21
|
+
*
|
|
22
|
+
* `cancel()` aborts the in-flight request and invalidates it without starting
|
|
23
|
+
* a new one (e.g. the selection was cleared). When called inside a component
|
|
24
|
+
* setup / effect scope, the in-flight request is cancelled automatically on
|
|
25
|
+
* unmount.
|
|
26
|
+
*
|
|
27
|
+
* ```ts
|
|
28
|
+
* const latest = useLatestRequest()
|
|
29
|
+
* async function load() {
|
|
30
|
+
* const req = latest.begin()
|
|
31
|
+
* loading.value = true
|
|
32
|
+
* try {
|
|
33
|
+
* const res = await fetchThings(req.signal)
|
|
34
|
+
* if (!req.isLatest()) return
|
|
35
|
+
* rows.value = res
|
|
36
|
+
* } catch (e) {
|
|
37
|
+
* if (!req.isLatest() || isAbortError(e)) return
|
|
38
|
+
* error.value = String(e)
|
|
39
|
+
* } finally {
|
|
40
|
+
* if (req.isLatest()) loading.value = false
|
|
41
|
+
* }
|
|
42
|
+
* }
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export function useLatestRequest(): {
|
|
46
|
+
begin: () => LatestRequestToken
|
|
47
|
+
cancel: () => void
|
|
48
|
+
} {
|
|
49
|
+
let seq = 0
|
|
50
|
+
let ctrl: AbortController | null = null
|
|
51
|
+
|
|
52
|
+
function begin(): LatestRequestToken {
|
|
53
|
+
const mine = ++seq
|
|
54
|
+
ctrl?.abort()
|
|
55
|
+
const c = new AbortController()
|
|
56
|
+
ctrl = c
|
|
57
|
+
return { signal: c.signal, isLatest: () => mine === seq }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function cancel(): void {
|
|
61
|
+
++seq
|
|
62
|
+
ctrl?.abort()
|
|
63
|
+
ctrl = null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (getCurrentScope()) onScopeDispose(cancel)
|
|
67
|
+
|
|
68
|
+
return { begin, cancel }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* True for the rejection an aborted fetch() produces (a DOMException — or, in
|
|
73
|
+
* some runtimes, a plain Error — named "AbortError"). Superseded / cancelled
|
|
74
|
+
* requests should be swallowed silently rather than shown as an error.
|
|
75
|
+
*/
|
|
76
|
+
export function isAbortError(e: unknown): boolean {
|
|
77
|
+
return (
|
|
78
|
+
(typeof DOMException !== 'undefined' && e instanceof DOMException && e.name === 'AbortError') ||
|
|
79
|
+
(e instanceof Error && e.name === 'AbortError')
|
|
80
|
+
)
|
|
81
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -85,6 +85,8 @@ export { useFieldClasses } from './composables/useFieldClasses'
|
|
|
85
85
|
export type { FieldClasses } from './composables/useFieldClasses'
|
|
86
86
|
export { usePolling } from './composables/usePolling'
|
|
87
87
|
export type { UsePollingOptions } from './composables/usePolling'
|
|
88
|
+
export { useLatestRequest, isAbortError } from './composables/useLatestRequest'
|
|
89
|
+
export type { LatestRequestToken } from './composables/useLatestRequest'
|
|
88
90
|
|
|
89
91
|
// Enums
|
|
90
92
|
export { AlertEnum } from './enums/AlertEnum'
|
|
@@ -138,6 +140,7 @@ export {
|
|
|
138
140
|
fmtPrice,
|
|
139
141
|
fmtPct,
|
|
140
142
|
fmtUsd,
|
|
143
|
+
fmtMoney,
|
|
141
144
|
fmtDuration,
|
|
142
145
|
formatJson,
|
|
143
146
|
stringifyValue,
|
package/src/utils/format.test.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
fmtPrice,
|
|
9
9
|
fmtPct,
|
|
10
10
|
fmtUsd,
|
|
11
|
+
fmtMoney,
|
|
11
12
|
fmtDuration,
|
|
12
13
|
formatJson,
|
|
13
14
|
stringifyValue,
|
|
@@ -262,3 +263,28 @@ describe('stringifyValue', () => {
|
|
|
262
263
|
expect(stringifyValue(1n)).toBe('1')
|
|
263
264
|
})
|
|
264
265
|
})
|
|
266
|
+
|
|
267
|
+
describe('fmtMoney', () => {
|
|
268
|
+
it('puts the minus before the currency and groups digits', () => {
|
|
269
|
+
expect(fmtMoney(-2.26)).toBe('-$2.26')
|
|
270
|
+
expect(fmtMoney(1234.5)).toBe(`$${(1234.5).toLocaleString(undefined, { minimumFractionDigits: 2 })}`)
|
|
271
|
+
expect(fmtMoney(0)).toBe('$0.00')
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('honours digits and currency', () => {
|
|
275
|
+
expect(fmtMoney(1.5, 3)).toBe('$1.500')
|
|
276
|
+
expect(fmtMoney(-3, 2, '€')).toBe('-€3.00')
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('does not sign a value that rounds to zero', () => {
|
|
280
|
+
expect(fmtMoney(-0.001)).toBe('$0.00')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('renders an em-dash for missing input and accepts numeric strings', () => {
|
|
284
|
+
expect(fmtMoney(null)).toBe('—')
|
|
285
|
+
expect(fmtMoney(undefined)).toBe('—')
|
|
286
|
+
expect(fmtMoney('')).toBe('—')
|
|
287
|
+
expect(fmtMoney(NaN)).toBe('—')
|
|
288
|
+
expect(fmtMoney('-4.5')).toBe('-$4.50')
|
|
289
|
+
})
|
|
290
|
+
})
|
package/src/utils/format.ts
CHANGED
|
@@ -111,6 +111,31 @@ export function fmtUsd(v: number): string {
|
|
|
111
111
|
return `${sign}$${Math.abs(v).toFixed(2)}`
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Format a money amount with the sign BEFORE the currency symbol ("-$2.26",
|
|
116
|
+
* never "$-2.26") and locale digit grouping ("$1,234.50"). Positive values
|
|
117
|
+
* carry no sign — prefix an explicit '+' where a gain should read signed.
|
|
118
|
+
* Renders an em-dash for null/undefined/non-finite input; accepts numeric
|
|
119
|
+
* strings so BigDecimal-as-string payloads pass through.
|
|
120
|
+
*/
|
|
121
|
+
export function fmtMoney(
|
|
122
|
+
n: number | string | null | undefined,
|
|
123
|
+
digits = 2,
|
|
124
|
+
currency = '$',
|
|
125
|
+
): string {
|
|
126
|
+
if (n === null || n === undefined) return EM_DASH
|
|
127
|
+
if (typeof n === 'string' && n.trim() === '') return EM_DASH
|
|
128
|
+
const v = typeof n === 'string' ? Number(n) : n
|
|
129
|
+
if (!isFinite(v)) return EM_DASH
|
|
130
|
+
const abs = Math.abs(v).toLocaleString(undefined, {
|
|
131
|
+
minimumFractionDigits: digits,
|
|
132
|
+
maximumFractionDigits: digits,
|
|
133
|
+
})
|
|
134
|
+
// A value that rounds to zero ("-0.00") reads unsigned.
|
|
135
|
+
const negative = v < 0 && Number(Math.abs(v).toFixed(digits)) !== 0
|
|
136
|
+
return `${negative ? '-' : ''}${currency}${abs}`
|
|
137
|
+
}
|
|
138
|
+
|
|
114
139
|
/**
|
|
115
140
|
* Format an ISO date string / epoch as a "Mon D, YYYY" calendar label
|
|
116
141
|
* (en-US, e.g. "Jan 5, 2026"). Falls back to the raw value on parse
|