mgv-backoffice 1.36.0 → 1.36.2
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 +1794 -1719
- package/dist/components/BaseBadge.vue.d.ts +1 -11
- package/dist/components/BaseDropdown.vue.d.ts +1 -1
- package/dist/ui-lib.js +942 -925
- package/dist/ui-lib.umd.cjs +1 -1
- package/dist/utils/specForm.d.ts +1 -1
- package/package.json +54 -54
- package/src/components/BaseAlert.vue +104 -104
- package/src/components/BaseBadge.vue +12 -2
- package/src/components/BaseBarDistribution.test.ts +51 -51
- package/src/components/BaseBarDistribution.vue +73 -73
- package/src/components/BaseChipButton.vue +42 -42
- package/src/components/BaseCodeBlock.test.ts +70 -70
- package/src/components/BaseCodeBlock.vue +74 -74
- package/src/components/BaseCollapsibleSection.vue +113 -113
- package/src/components/BaseConfirmModal.vue +114 -114
- package/src/components/BaseCredentialsForm.test.ts +108 -108
- package/src/components/BaseCredentialsForm.vue +241 -241
- package/src/components/BaseDropdown.test.ts +87 -87
- package/src/components/BaseDropdown.vue +172 -172
- package/src/components/BaseEntityPickerModal.vue +330 -330
- package/src/components/BaseFileDropzone.vue +94 -94
- package/src/components/BaseFilterChip.test.ts +44 -44
- package/src/components/BaseFilterChip.vue +67 -67
- package/src/components/BaseModalShell.vue +209 -209
- package/src/components/BasePageHeader.vue +120 -120
- package/src/components/BasePillPickerModal.test.ts +95 -95
- package/src/components/BaseRemoveButton.vue +22 -22
- package/src/components/BaseSpecFields.test.ts +39 -0
- package/src/components/BaseSpecFields.vue +27 -3
- package/src/components/BaseStatBreakdown.test.ts +56 -56
- package/src/components/BaseStatBreakdown.vue +49 -49
- package/src/components/BaseStatusPill.vue +54 -54
- package/src/components/BaseTextInputModal.vue +140 -140
- package/src/components/BaseToolbarButton.test.ts +48 -48
- package/src/components/BaseToolbarButton.vue +94 -94
- package/src/composables/useFieldClasses.test.ts +55 -55
- package/src/composables/useFieldClasses.ts +56 -56
- package/src/composables/usePolling.test.ts +112 -112
- package/src/composables/usePolling.ts +102 -102
- package/src/composables/useQueryParamSync.test.ts +56 -56
- package/src/composables/useQueryParamSync.ts +42 -42
- package/src/composables/useThemeClasses.test.ts +34 -34
- package/src/composables/useThemeClasses.ts +137 -137
- package/src/index.ts +138 -138
- package/src/style.css +32 -32
- package/src/types/distribution.ts +7 -7
- package/src/types/pillPicker.ts +14 -14
- package/src/types/specField.ts +30 -30
- package/src/types/statBreakdown.ts +6 -6
- package/src/utils/format.test.ts +264 -264
- package/src/utils/format.ts +216 -216
- package/src/utils/httpColors.test.ts +100 -100
- package/src/utils/httpColors.ts +99 -99
- package/src/utils/kvRows.test.ts +47 -47
- package/src/utils/kvRows.ts +34 -34
- package/src/utils/specForm.test.ts +64 -53
- package/src/utils/specForm.ts +49 -48
- package/src/utils/validate.test.ts +82 -82
- package/src/utils/validate.ts +76 -76
|
@@ -1,95 +1,95 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { mount } from '@vue/test-utils'
|
|
3
|
-
import BasePillPickerModal from './BasePillPickerModal.vue'
|
|
4
|
-
import type { PillPickerItem } from '../types/pillPicker'
|
|
5
|
-
|
|
6
|
-
const items: PillPickerItem[] = [
|
|
7
|
-
{ id: 'STOCK:AAPL', label: 'AAPL', group: 'STOCK' },
|
|
8
|
-
{ id: 'STOCK:MSFT', label: 'MSFT', group: 'STOCK', title: 'Microsoft' },
|
|
9
|
-
{ id: 'CRYPTO:BTC/USD', label: 'BTC/USD', group: 'CRYPTO' },
|
|
10
|
-
]
|
|
11
|
-
|
|
12
|
-
const groups = [
|
|
13
|
-
{ value: 'STOCK', label: 'Stock' },
|
|
14
|
-
{ value: 'CRYPTO', label: 'Crypto' },
|
|
15
|
-
]
|
|
16
|
-
|
|
17
|
-
function mountPicker(props: Record<string, unknown> = {}) {
|
|
18
|
-
return mount(BasePillPickerModal, {
|
|
19
|
-
props: { title: 'Pick one', items, ...props },
|
|
20
|
-
global: { stubs: { teleport: true } },
|
|
21
|
-
})
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// Pills are the buttons inside the flex-wrap grid — not the segmented
|
|
25
|
-
// control's buttons and not the footer Close.
|
|
26
|
-
function pillLabels(wrapper: ReturnType<typeof mountPicker>): string[] {
|
|
27
|
-
return wrapper
|
|
28
|
-
.findAll('.flex.flex-wrap.gap-2 > button')
|
|
29
|
-
.map((b) => b.text())
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
describe('BasePillPickerModal', () => {
|
|
33
|
-
it('renders one pill per item and emits pick with the clicked item', async () => {
|
|
34
|
-
const wrapper = mountPicker()
|
|
35
|
-
expect(pillLabels(wrapper)).toEqual(['AAPL', 'MSFT', 'BTC/USD'])
|
|
36
|
-
await wrapper.findAll('.flex.flex-wrap.gap-2 > button')[1].trigger('click')
|
|
37
|
-
expect(wrapper.emitted('pick')).toEqual([[items[1]]])
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
it('narrows by free text, case-insensitively, and shows the no-match message', async () => {
|
|
41
|
-
const wrapper = mountPicker({ noMatchMessage: 'nothing here' })
|
|
42
|
-
await wrapper.find('input').setValue('btc')
|
|
43
|
-
expect(pillLabels(wrapper)).toEqual(['BTC/USD'])
|
|
44
|
-
await wrapper.find('input').setValue('zzz')
|
|
45
|
-
expect(wrapper.text()).toContain('nothing here')
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
it('renders the group toggle with All prepended and narrows by group', async () => {
|
|
49
|
-
const wrapper = mountPicker({ groups })
|
|
50
|
-
// Re-query before each click — the grid re-render replaces the buttons,
|
|
51
|
-
// so a wrapper captured before a click goes stale.
|
|
52
|
-
const seg = () => wrapper.findAll('[role="group"] button')
|
|
53
|
-
expect(seg().map((b) => b.text())).toEqual(['All', 'Stock', 'Crypto'])
|
|
54
|
-
await seg()[2].trigger('click')
|
|
55
|
-
expect(pillLabels(wrapper)).toEqual(['BTC/USD'])
|
|
56
|
-
await seg()[0].trigger('click')
|
|
57
|
-
expect(pillLabels(wrapper)).toEqual(['AAPL', 'MSFT', 'BTC/USD'])
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
it('hides the group toggle when no groups are passed', () => {
|
|
61
|
-
const wrapper = mountPicker()
|
|
62
|
-
expect(wrapper.find('[role="group"]').exists()).toBe(false)
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
it('shows the empty message instead of the filter row when there are no items', () => {
|
|
66
|
-
const wrapper = mountPicker({ items: [], emptyMessage: 'nothing configured' })
|
|
67
|
-
expect(wrapper.text()).toContain('nothing configured')
|
|
68
|
-
expect(wrapper.find('input').exists()).toBe(false)
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
it('applies the mono font to the input and pills only when asked', () => {
|
|
72
|
-
const mono = mountPicker({ mono: true })
|
|
73
|
-
expect(mono.find('input').classes()).toContain('font-mono')
|
|
74
|
-
expect(mono.findAll('.flex.flex-wrap.gap-2 > button')[0].classes()).toContain('font-mono')
|
|
75
|
-
|
|
76
|
-
const plain = mountPicker()
|
|
77
|
-
expect(plain.find('input').classes()).not.toContain('font-mono')
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
it('forwards an item title to the pill tooltip', () => {
|
|
81
|
-
const wrapper = mountPicker()
|
|
82
|
-
expect(
|
|
83
|
-
wrapper.findAll('.flex.flex-wrap.gap-2 > button')[1].attributes('title'),
|
|
84
|
-
).toBe('Microsoft')
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
it('emits close from the footer button', async () => {
|
|
88
|
-
const wrapper = mountPicker({ closeText: 'Dismiss' })
|
|
89
|
-
const buttons = wrapper.findAll('button')
|
|
90
|
-
const closeBtn = buttons[buttons.length - 1]
|
|
91
|
-
expect(closeBtn.text()).toBe('Dismiss')
|
|
92
|
-
await closeBtn.trigger('click')
|
|
93
|
-
expect(wrapper.emitted('close')).toHaveLength(1)
|
|
94
|
-
})
|
|
95
|
-
})
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BasePillPickerModal from './BasePillPickerModal.vue'
|
|
4
|
+
import type { PillPickerItem } from '../types/pillPicker'
|
|
5
|
+
|
|
6
|
+
const items: PillPickerItem[] = [
|
|
7
|
+
{ id: 'STOCK:AAPL', label: 'AAPL', group: 'STOCK' },
|
|
8
|
+
{ id: 'STOCK:MSFT', label: 'MSFT', group: 'STOCK', title: 'Microsoft' },
|
|
9
|
+
{ id: 'CRYPTO:BTC/USD', label: 'BTC/USD', group: 'CRYPTO' },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
const groups = [
|
|
13
|
+
{ value: 'STOCK', label: 'Stock' },
|
|
14
|
+
{ value: 'CRYPTO', label: 'Crypto' },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
function mountPicker(props: Record<string, unknown> = {}) {
|
|
18
|
+
return mount(BasePillPickerModal, {
|
|
19
|
+
props: { title: 'Pick one', items, ...props },
|
|
20
|
+
global: { stubs: { teleport: true } },
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Pills are the buttons inside the flex-wrap grid — not the segmented
|
|
25
|
+
// control's buttons and not the footer Close.
|
|
26
|
+
function pillLabels(wrapper: ReturnType<typeof mountPicker>): string[] {
|
|
27
|
+
return wrapper
|
|
28
|
+
.findAll('.flex.flex-wrap.gap-2 > button')
|
|
29
|
+
.map((b) => b.text())
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('BasePillPickerModal', () => {
|
|
33
|
+
it('renders one pill per item and emits pick with the clicked item', async () => {
|
|
34
|
+
const wrapper = mountPicker()
|
|
35
|
+
expect(pillLabels(wrapper)).toEqual(['AAPL', 'MSFT', 'BTC/USD'])
|
|
36
|
+
await wrapper.findAll('.flex.flex-wrap.gap-2 > button')[1].trigger('click')
|
|
37
|
+
expect(wrapper.emitted('pick')).toEqual([[items[1]]])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('narrows by free text, case-insensitively, and shows the no-match message', async () => {
|
|
41
|
+
const wrapper = mountPicker({ noMatchMessage: 'nothing here' })
|
|
42
|
+
await wrapper.find('input').setValue('btc')
|
|
43
|
+
expect(pillLabels(wrapper)).toEqual(['BTC/USD'])
|
|
44
|
+
await wrapper.find('input').setValue('zzz')
|
|
45
|
+
expect(wrapper.text()).toContain('nothing here')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('renders the group toggle with All prepended and narrows by group', async () => {
|
|
49
|
+
const wrapper = mountPicker({ groups })
|
|
50
|
+
// Re-query before each click — the grid re-render replaces the buttons,
|
|
51
|
+
// so a wrapper captured before a click goes stale.
|
|
52
|
+
const seg = () => wrapper.findAll('[role="group"] button')
|
|
53
|
+
expect(seg().map((b) => b.text())).toEqual(['All', 'Stock', 'Crypto'])
|
|
54
|
+
await seg()[2].trigger('click')
|
|
55
|
+
expect(pillLabels(wrapper)).toEqual(['BTC/USD'])
|
|
56
|
+
await seg()[0].trigger('click')
|
|
57
|
+
expect(pillLabels(wrapper)).toEqual(['AAPL', 'MSFT', 'BTC/USD'])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('hides the group toggle when no groups are passed', () => {
|
|
61
|
+
const wrapper = mountPicker()
|
|
62
|
+
expect(wrapper.find('[role="group"]').exists()).toBe(false)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('shows the empty message instead of the filter row when there are no items', () => {
|
|
66
|
+
const wrapper = mountPicker({ items: [], emptyMessage: 'nothing configured' })
|
|
67
|
+
expect(wrapper.text()).toContain('nothing configured')
|
|
68
|
+
expect(wrapper.find('input').exists()).toBe(false)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('applies the mono font to the input and pills only when asked', () => {
|
|
72
|
+
const mono = mountPicker({ mono: true })
|
|
73
|
+
expect(mono.find('input').classes()).toContain('font-mono')
|
|
74
|
+
expect(mono.findAll('.flex.flex-wrap.gap-2 > button')[0].classes()).toContain('font-mono')
|
|
75
|
+
|
|
76
|
+
const plain = mountPicker()
|
|
77
|
+
expect(plain.find('input').classes()).not.toContain('font-mono')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('forwards an item title to the pill tooltip', () => {
|
|
81
|
+
const wrapper = mountPicker()
|
|
82
|
+
expect(
|
|
83
|
+
wrapper.findAll('.flex.flex-wrap.gap-2 > button')[1].attributes('title'),
|
|
84
|
+
).toBe('Microsoft')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('emits close from the footer button', async () => {
|
|
88
|
+
const wrapper = mountPicker({ closeText: 'Dismiss' })
|
|
89
|
+
const buttons = wrapper.findAll('button')
|
|
90
|
+
const closeBtn = buttons[buttons.length - 1]
|
|
91
|
+
expect(closeBtn.text()).toBe('Dismiss')
|
|
92
|
+
await closeBtn.trigger('click')
|
|
93
|
+
expect(wrapper.emitted('close')).toHaveLength(1)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<!--
|
|
3
|
-
The "×" remove-row affordance used beside repeatable form rows
|
|
4
|
-
(extracted from WireMate's CreateMock, where the identical red ×
|
|
5
|
-
button appeared on every key/value, matcher, and cookie row).
|
|
6
|
-
|
|
7
|
-
Renders a multiplication sign; name it for screen readers via
|
|
8
|
-
`aria-label` (falls through as an attr, as do `title`, `disabled`,
|
|
9
|
-
and any extra classes like `pt-1` / `self-start`).
|
|
10
|
-
-->
|
|
11
|
-
<button
|
|
12
|
-
type="button"
|
|
13
|
-
class="px-2 text-lg leading-none text-red-400 hover:text-red-600 transition-colors cursor-pointer rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400 disabled:cursor-not-allowed disabled:text-gray-400 disabled:hover:text-gray-400"
|
|
14
|
-
@click="emit('click')"
|
|
15
|
-
>
|
|
16
|
-
×
|
|
17
|
-
</button>
|
|
18
|
-
</template>
|
|
19
|
-
|
|
20
|
-
<script lang="ts" setup>
|
|
21
|
-
const emit = defineEmits<{ click: [] }>()
|
|
22
|
-
</script>
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
The "×" remove-row affordance used beside repeatable form rows
|
|
4
|
+
(extracted from WireMate's CreateMock, where the identical red ×
|
|
5
|
+
button appeared on every key/value, matcher, and cookie row).
|
|
6
|
+
|
|
7
|
+
Renders a multiplication sign; name it for screen readers via
|
|
8
|
+
`aria-label` (falls through as an attr, as do `title`, `disabled`,
|
|
9
|
+
and any extra classes like `pt-1` / `self-start`).
|
|
10
|
+
-->
|
|
11
|
+
<button
|
|
12
|
+
type="button"
|
|
13
|
+
class="px-2 text-lg leading-none text-red-400 hover:text-red-600 transition-colors cursor-pointer rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400 disabled:cursor-not-allowed disabled:text-gray-400 disabled:hover:text-gray-400"
|
|
14
|
+
@click="emit('click')"
|
|
15
|
+
>
|
|
16
|
+
×
|
|
17
|
+
</button>
|
|
18
|
+
</template>
|
|
19
|
+
|
|
20
|
+
<script lang="ts" setup>
|
|
21
|
+
const emit = defineEmits<{ click: [] }>()
|
|
22
|
+
</script>
|
|
@@ -55,6 +55,45 @@ describe('BaseSpecFields', () => {
|
|
|
55
55
|
])
|
|
56
56
|
})
|
|
57
57
|
|
|
58
|
+
it('truncates fractional input on integer fields (e.g. a pasted decimal)', async () => {
|
|
59
|
+
const wrapper = mountFields()
|
|
60
|
+
const lookback = wrapper.findAll('input[type="number"]')[1]
|
|
61
|
+
await lookback.setValue('2.5')
|
|
62
|
+
expect(wrapper.emitted('update')).toEqual([['lookback', 2]])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('blocks decimal-separator and exponent keys on integer fields only', () => {
|
|
66
|
+
const wrapper = mountFields()
|
|
67
|
+
const [threshold, lookback] = wrapper.findAll('input[type="number"]')
|
|
68
|
+
const press = (input: typeof lookback, key: string) => {
|
|
69
|
+
const ev = new KeyboardEvent('keydown', { key, cancelable: true })
|
|
70
|
+
input.element.dispatchEvent(ev)
|
|
71
|
+
return ev.defaultPrevented
|
|
72
|
+
}
|
|
73
|
+
for (const key of ['.', ',', 'e', 'E']) {
|
|
74
|
+
expect(press(lookback, key)).toBe(true)
|
|
75
|
+
expect(press(threshold, key)).toBe(false)
|
|
76
|
+
}
|
|
77
|
+
expect(press(lookback, '5')).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('refuses pastes with non-integer content on integer fields', () => {
|
|
81
|
+
const wrapper = mountFields()
|
|
82
|
+
const lookback = wrapper.findAll('input[type="number"]')[1]
|
|
83
|
+
const paste = (text: string) => {
|
|
84
|
+
// jsdom has no ClipboardEvent constructor — a plain cancelable Event
|
|
85
|
+
// with a stubbed clipboardData exercises the same handler path.
|
|
86
|
+
const ev = new Event('paste', { cancelable: true })
|
|
87
|
+
Object.defineProperty(ev, 'clipboardData', { value: { getData: () => text } })
|
|
88
|
+
lookback.element.dispatchEvent(ev)
|
|
89
|
+
return ev.defaultPrevented
|
|
90
|
+
}
|
|
91
|
+
expect(paste('0.5')).toBe(true)
|
|
92
|
+
expect(paste('2e3')).toBe(true)
|
|
93
|
+
expect(paste('12')).toBe(false)
|
|
94
|
+
expect(paste('-3')).toBe(false)
|
|
95
|
+
})
|
|
96
|
+
|
|
58
97
|
it('never mutates params directly', async () => {
|
|
59
98
|
const frozen = Object.freeze({ ...params })
|
|
60
99
|
const wrapper = mount(BaseSpecFields, { props: { specs, params: frozen } })
|
|
@@ -32,9 +32,30 @@ function onCheckbox(key: string, e: Event) {
|
|
|
32
32
|
// Mirror v-model.number: a parseable value becomes a number, anything else
|
|
33
33
|
// (including a cleared field, which becomes '') passes through as the raw
|
|
34
34
|
// string so the owner's "enter a valid number" validation can catch it.
|
|
35
|
-
|
|
35
|
+
// Integer specs truncate any fractional value (a pasted "2.5" becomes 2 —
|
|
36
|
+
// typed decimals never get this far, see onIntegerKeydown).
|
|
37
|
+
function onNumber(spec: SpecField, raw: string) {
|
|
36
38
|
const n = parseFloat(raw)
|
|
37
|
-
|
|
39
|
+
if (Number.isNaN(n)) {
|
|
40
|
+
emit('update', spec.key, raw)
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
emit('update', spec.key, spec.type === 'integer' ? Math.trunc(n) : n)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// type="number" step=1 only flags decimals as :invalid — it doesn't stop them
|
|
47
|
+
// being typed — so integer fields swallow the decimal separators (and the
|
|
48
|
+
// exponent key) at the keyboard, and refuse pastes carrying anything beyond
|
|
49
|
+
// an optional sign and digits.
|
|
50
|
+
function onIntegerKeydown(e: KeyboardEvent) {
|
|
51
|
+
if (e.key === '.' || e.key === ',' || e.key === 'e' || e.key === 'E') {
|
|
52
|
+
e.preventDefault()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function onIntegerPaste(e: ClipboardEvent) {
|
|
57
|
+
const text = e.clipboardData?.getData('text') ?? ''
|
|
58
|
+
if (!/^-?\d*$/.test(text.trim())) e.preventDefault()
|
|
38
59
|
}
|
|
39
60
|
</script>
|
|
40
61
|
|
|
@@ -72,7 +93,10 @@ function onNumber(key: string, raw: string) {
|
|
|
72
93
|
type="number"
|
|
73
94
|
:step="spec.step ?? (spec.type === 'integer' ? 1 : 0.01)"
|
|
74
95
|
:min="spec.min ?? undefined"
|
|
75
|
-
|
|
96
|
+
:inputmode="spec.type === 'integer' ? 'numeric' : undefined"
|
|
97
|
+
@keydown="spec.type === 'integer' && onIntegerKeydown($event)"
|
|
98
|
+
@paste="spec.type === 'integer' && onIntegerPaste($event)"
|
|
99
|
+
@update:modelValue="(v: string) => onNumber(spec, v)"
|
|
76
100
|
/>
|
|
77
101
|
|
|
78
102
|
<p v-if="spec.help" class="text-[11px] text-slate-400 mt-1">{{ spec.help }}</p>
|
|
@@ -1,56 +1,56 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { mount } from '@vue/test-utils'
|
|
3
|
-
import BaseStatBreakdown from './BaseStatBreakdown.vue'
|
|
4
|
-
import type { StatBreakdownItem } from '../types/statBreakdown'
|
|
5
|
-
|
|
6
|
-
const items: StatBreakdownItem[] = [
|
|
7
|
-
{ label: 'Alpaca', value: 12.5 },
|
|
8
|
-
{ label: 'Kraken', value: -3 },
|
|
9
|
-
{ label: 'Binance', value: null },
|
|
10
|
-
]
|
|
11
|
-
|
|
12
|
-
describe('BaseStatBreakdown', () => {
|
|
13
|
-
it('renders one row per item with label and formatted value', () => {
|
|
14
|
-
const wrapper = mount(BaseStatBreakdown, {
|
|
15
|
-
props: { items, currency: '$' },
|
|
16
|
-
})
|
|
17
|
-
const rows = wrapper.findAll('.flex')
|
|
18
|
-
expect(rows).toHaveLength(3)
|
|
19
|
-
expect(rows[0].text()).toContain('Alpaca')
|
|
20
|
-
expect(rows[0].text()).toContain('$12.50')
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
it('renders an em-dash for null values instead of a misleading 0', () => {
|
|
24
|
-
const wrapper = mount(BaseStatBreakdown, { props: { items } })
|
|
25
|
-
const rows = wrapper.findAll('.flex')
|
|
26
|
-
expect(rows[2].text()).toContain('—')
|
|
27
|
-
expect(rows[2].text()).not.toContain('0.00')
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it('honours the decimals prop', () => {
|
|
31
|
-
const wrapper = mount(BaseStatBreakdown, {
|
|
32
|
-
props: { items: [{ label: 'BTC', value: 0.5 }], decimals: 8 },
|
|
33
|
-
})
|
|
34
|
-
expect(wrapper.text()).toContain('0.50000000')
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
it('prefixes a plus and colours by sign when signed', () => {
|
|
38
|
-
const wrapper = mount(BaseStatBreakdown, {
|
|
39
|
-
props: { items, currency: '$', signed: true },
|
|
40
|
-
})
|
|
41
|
-
const values = wrapper.findAll('.font-mono')
|
|
42
|
-
expect(values[0].text()).toBe('+$12.50')
|
|
43
|
-
expect(values[0].classes().join(' ')).toContain('text-emerald-600')
|
|
44
|
-
expect(values[1].text()).toBe('-$3.00')
|
|
45
|
-
expect(values[1].classes().join(' ')).toContain('text-red-600')
|
|
46
|
-
// Null rows stay neutral even in signed mode.
|
|
47
|
-
expect(values[2].classes().join(' ')).toContain('text-slate-500')
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
it('stays neutral-coloured when signed is off', () => {
|
|
51
|
-
const wrapper = mount(BaseStatBreakdown, {
|
|
52
|
-
props: { items: [{ label: 'Alpaca', value: 12.5 }] },
|
|
53
|
-
})
|
|
54
|
-
expect(wrapper.find('.font-mono').classes().join(' ')).toContain('text-slate-500')
|
|
55
|
-
})
|
|
56
|
-
})
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BaseStatBreakdown from './BaseStatBreakdown.vue'
|
|
4
|
+
import type { StatBreakdownItem } from '../types/statBreakdown'
|
|
5
|
+
|
|
6
|
+
const items: StatBreakdownItem[] = [
|
|
7
|
+
{ label: 'Alpaca', value: 12.5 },
|
|
8
|
+
{ label: 'Kraken', value: -3 },
|
|
9
|
+
{ label: 'Binance', value: null },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
describe('BaseStatBreakdown', () => {
|
|
13
|
+
it('renders one row per item with label and formatted value', () => {
|
|
14
|
+
const wrapper = mount(BaseStatBreakdown, {
|
|
15
|
+
props: { items, currency: '$' },
|
|
16
|
+
})
|
|
17
|
+
const rows = wrapper.findAll('.flex')
|
|
18
|
+
expect(rows).toHaveLength(3)
|
|
19
|
+
expect(rows[0].text()).toContain('Alpaca')
|
|
20
|
+
expect(rows[0].text()).toContain('$12.50')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('renders an em-dash for null values instead of a misleading 0', () => {
|
|
24
|
+
const wrapper = mount(BaseStatBreakdown, { props: { items } })
|
|
25
|
+
const rows = wrapper.findAll('.flex')
|
|
26
|
+
expect(rows[2].text()).toContain('—')
|
|
27
|
+
expect(rows[2].text()).not.toContain('0.00')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('honours the decimals prop', () => {
|
|
31
|
+
const wrapper = mount(BaseStatBreakdown, {
|
|
32
|
+
props: { items: [{ label: 'BTC', value: 0.5 }], decimals: 8 },
|
|
33
|
+
})
|
|
34
|
+
expect(wrapper.text()).toContain('0.50000000')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('prefixes a plus and colours by sign when signed', () => {
|
|
38
|
+
const wrapper = mount(BaseStatBreakdown, {
|
|
39
|
+
props: { items, currency: '$', signed: true },
|
|
40
|
+
})
|
|
41
|
+
const values = wrapper.findAll('.font-mono')
|
|
42
|
+
expect(values[0].text()).toBe('+$12.50')
|
|
43
|
+
expect(values[0].classes().join(' ')).toContain('text-emerald-600')
|
|
44
|
+
expect(values[1].text()).toBe('-$3.00')
|
|
45
|
+
expect(values[1].classes().join(' ')).toContain('text-red-600')
|
|
46
|
+
// Null rows stay neutral even in signed mode.
|
|
47
|
+
expect(values[2].classes().join(' ')).toContain('text-slate-500')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('stays neutral-coloured when signed is off', () => {
|
|
51
|
+
const wrapper = mount(BaseStatBreakdown, {
|
|
52
|
+
props: { items: [{ label: 'Alpaca', value: 12.5 }] },
|
|
53
|
+
})
|
|
54
|
+
expect(wrapper.find('.font-mono').classes().join(' ')).toContain('text-slate-500')
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -1,49 +1,49 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
// Compact per-item breakdown meant to sit under a summary/stat card (pairs
|
|
3
|
-
// with EarningsCard). Each item renders on its own line — label left, value
|
|
4
|
-
// right in monospace. A null/undefined value (a source that is unconfigured,
|
|
5
|
-
// unreachable, or has no matching rows) shows a dash rather than a
|
|
6
|
-
// misleading 0. With `signed`, values gain an explicit "+" and are coloured
|
|
7
|
-
// green/red by sign.
|
|
8
|
-
import type { StatBreakdownItem } from '../types/statBreakdown'
|
|
9
|
-
|
|
10
|
-
const props = withDefaults(
|
|
11
|
-
defineProps<{
|
|
12
|
-
items: StatBreakdownItem[]
|
|
13
|
-
currency?: string
|
|
14
|
-
decimals?: number
|
|
15
|
-
signed?: boolean
|
|
16
|
-
}>(),
|
|
17
|
-
{ currency: '', decimals: 2, signed: false },
|
|
18
|
-
)
|
|
19
|
-
|
|
20
|
-
function fmt(v: number | null | undefined): string {
|
|
21
|
-
if (v === null || v === undefined) return '—'
|
|
22
|
-
// Sign sits before the currency symbol ("-$3.00", "+$5.00" — same shape
|
|
23
|
-
// as fmtUsd); the plus only appears in signed mode.
|
|
24
|
-
const sign = v < 0 ? '-' : props.signed ? '+' : ''
|
|
25
|
-
return `${sign}${props.currency}${Math.abs(v).toFixed(props.decimals)}`
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function color(v: number | null | undefined): string {
|
|
29
|
-
if (!props.signed || v === null || v === undefined) {
|
|
30
|
-
return 'text-slate-500 dark:text-slate-400'
|
|
31
|
-
}
|
|
32
|
-
return v >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'
|
|
33
|
-
}
|
|
34
|
-
</script>
|
|
35
|
-
|
|
36
|
-
<template>
|
|
37
|
-
<div class="mt-1.5 space-y-0.5 px-1">
|
|
38
|
-
<div
|
|
39
|
-
v-for="item in items"
|
|
40
|
-
:key="item.label"
|
|
41
|
-
class="flex items-center justify-between text-[11px]"
|
|
42
|
-
>
|
|
43
|
-
<span class="text-slate-400 dark:text-slate-500">{{ item.label }}</span>
|
|
44
|
-
<span class="font-mono tabular-nums font-medium" :class="color(item.value)">
|
|
45
|
-
{{ fmt(item.value) }}
|
|
46
|
-
</span>
|
|
47
|
-
</div>
|
|
48
|
-
</div>
|
|
49
|
-
</template>
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Compact per-item breakdown meant to sit under a summary/stat card (pairs
|
|
3
|
+
// with EarningsCard). Each item renders on its own line — label left, value
|
|
4
|
+
// right in monospace. A null/undefined value (a source that is unconfigured,
|
|
5
|
+
// unreachable, or has no matching rows) shows a dash rather than a
|
|
6
|
+
// misleading 0. With `signed`, values gain an explicit "+" and are coloured
|
|
7
|
+
// green/red by sign.
|
|
8
|
+
import type { StatBreakdownItem } from '../types/statBreakdown'
|
|
9
|
+
|
|
10
|
+
const props = withDefaults(
|
|
11
|
+
defineProps<{
|
|
12
|
+
items: StatBreakdownItem[]
|
|
13
|
+
currency?: string
|
|
14
|
+
decimals?: number
|
|
15
|
+
signed?: boolean
|
|
16
|
+
}>(),
|
|
17
|
+
{ currency: '', decimals: 2, signed: false },
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
function fmt(v: number | null | undefined): string {
|
|
21
|
+
if (v === null || v === undefined) return '—'
|
|
22
|
+
// Sign sits before the currency symbol ("-$3.00", "+$5.00" — same shape
|
|
23
|
+
// as fmtUsd); the plus only appears in signed mode.
|
|
24
|
+
const sign = v < 0 ? '-' : props.signed ? '+' : ''
|
|
25
|
+
return `${sign}${props.currency}${Math.abs(v).toFixed(props.decimals)}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function color(v: number | null | undefined): string {
|
|
29
|
+
if (!props.signed || v === null || v === undefined) {
|
|
30
|
+
return 'text-slate-500 dark:text-slate-400'
|
|
31
|
+
}
|
|
32
|
+
return v >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'
|
|
33
|
+
}
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<div class="mt-1.5 space-y-0.5 px-1">
|
|
38
|
+
<div
|
|
39
|
+
v-for="item in items"
|
|
40
|
+
:key="item.label"
|
|
41
|
+
class="flex items-center justify-between text-[11px]"
|
|
42
|
+
>
|
|
43
|
+
<span class="text-slate-400 dark:text-slate-500">{{ item.label }}</span>
|
|
44
|
+
<span class="font-mono tabular-nums font-medium" :class="color(item.value)">
|
|
45
|
+
{{ fmt(item.value) }}
|
|
46
|
+
</span>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</template>
|
|
@@ -1,54 +1,54 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<!--
|
|
3
|
-
Connection/health status pill: a colored dot (pulsing while ok) next
|
|
4
|
-
to a short label, on a tinted rounded background. Extracted from
|
|
5
|
-
WireMate's sidebar WireMock health indicator.
|
|
6
|
-
|
|
7
|
-
status: 'ok' → emerald + pulse, 'error' → red, 'unknown' → gray.
|
|
8
|
-
-->
|
|
9
|
-
<div class="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs" :class="pillClass">
|
|
10
|
-
<span class="relative flex h-2 w-2">
|
|
11
|
-
<span
|
|
12
|
-
v-if="status === 'ok'"
|
|
13
|
-
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"
|
|
14
|
-
aria-hidden="true"
|
|
15
|
-
></span>
|
|
16
|
-
<span class="relative inline-flex rounded-full h-2 w-2" :class="dotClass"></span>
|
|
17
|
-
</span>
|
|
18
|
-
{{ label }}
|
|
19
|
-
</div>
|
|
20
|
-
</template>
|
|
21
|
-
|
|
22
|
-
<script lang="ts" setup>
|
|
23
|
-
import { computed } from 'vue'
|
|
24
|
-
import { useTheme } from '../composables/useTheme'
|
|
25
|
-
|
|
26
|
-
interface Props {
|
|
27
|
-
status: 'ok' | 'error' | 'unknown'
|
|
28
|
-
/** Short text next to the dot, e.g. "WireMock Connected". */
|
|
29
|
-
label: string
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const props = defineProps<Props>()
|
|
33
|
-
|
|
34
|
-
const { isDark } = useTheme()
|
|
35
|
-
|
|
36
|
-
const pillClass = computed(() => {
|
|
37
|
-
switch (props.status) {
|
|
38
|
-
case 'ok':
|
|
39
|
-
return isDark.value ? 'bg-emerald-500/10 text-emerald-400' : 'bg-emerald-50 text-emerald-700'
|
|
40
|
-
case 'error':
|
|
41
|
-
return isDark.value ? 'bg-red-500/10 text-red-400' : 'bg-red-50 text-red-700'
|
|
42
|
-
default:
|
|
43
|
-
return isDark.value ? 'bg-gray-700 text-gray-400' : 'bg-gray-100 text-gray-500'
|
|
44
|
-
}
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
const dotClass = computed(() => {
|
|
48
|
-
switch (props.status) {
|
|
49
|
-
case 'ok': return 'bg-emerald-500'
|
|
50
|
-
case 'error': return 'bg-red-500'
|
|
51
|
-
default: return 'bg-gray-400'
|
|
52
|
-
}
|
|
53
|
-
})
|
|
54
|
-
</script>
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Connection/health status pill: a colored dot (pulsing while ok) next
|
|
4
|
+
to a short label, on a tinted rounded background. Extracted from
|
|
5
|
+
WireMate's sidebar WireMock health indicator.
|
|
6
|
+
|
|
7
|
+
status: 'ok' → emerald + pulse, 'error' → red, 'unknown' → gray.
|
|
8
|
+
-->
|
|
9
|
+
<div class="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs" :class="pillClass">
|
|
10
|
+
<span class="relative flex h-2 w-2">
|
|
11
|
+
<span
|
|
12
|
+
v-if="status === 'ok'"
|
|
13
|
+
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"
|
|
14
|
+
aria-hidden="true"
|
|
15
|
+
></span>
|
|
16
|
+
<span class="relative inline-flex rounded-full h-2 w-2" :class="dotClass"></span>
|
|
17
|
+
</span>
|
|
18
|
+
{{ label }}
|
|
19
|
+
</div>
|
|
20
|
+
</template>
|
|
21
|
+
|
|
22
|
+
<script lang="ts" setup>
|
|
23
|
+
import { computed } from 'vue'
|
|
24
|
+
import { useTheme } from '../composables/useTheme'
|
|
25
|
+
|
|
26
|
+
interface Props {
|
|
27
|
+
status: 'ok' | 'error' | 'unknown'
|
|
28
|
+
/** Short text next to the dot, e.g. "WireMock Connected". */
|
|
29
|
+
label: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const props = defineProps<Props>()
|
|
33
|
+
|
|
34
|
+
const { isDark } = useTheme()
|
|
35
|
+
|
|
36
|
+
const pillClass = computed(() => {
|
|
37
|
+
switch (props.status) {
|
|
38
|
+
case 'ok':
|
|
39
|
+
return isDark.value ? 'bg-emerald-500/10 text-emerald-400' : 'bg-emerald-50 text-emerald-700'
|
|
40
|
+
case 'error':
|
|
41
|
+
return isDark.value ? 'bg-red-500/10 text-red-400' : 'bg-red-50 text-red-700'
|
|
42
|
+
default:
|
|
43
|
+
return isDark.value ? 'bg-gray-700 text-gray-400' : 'bg-gray-100 text-gray-500'
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const dotClass = computed(() => {
|
|
48
|
+
switch (props.status) {
|
|
49
|
+
case 'ok': return 'bg-emerald-500'
|
|
50
|
+
case 'error': return 'bg-red-500'
|
|
51
|
+
default: return 'bg-gray-400'
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
</script>
|