mgv-backoffice 1.20.0 → 1.22.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 +145 -2
- package/dist/components/BaseInput.vue.d.ts +21 -0
- package/dist/components/BaseSegmentedControl.vue.d.ts +25 -0
- package/dist/components/BaseSelect.vue.d.ts +28 -0
- package/dist/components/BaseSpecFields.vue.d.ts +23 -0
- package/dist/components/BaseTable.vue.d.ts +26 -0
- package/dist/components/EarningsCard.vue.d.ts +17 -6
- package/dist/index.d.ts +8 -0
- package/dist/types/segmented.d.ts +11 -0
- package/dist/types/specField.d.ts +23 -0
- package/dist/types/table.d.ts +8 -0
- package/dist/ui-lib.css +1 -1
- package/dist/ui-lib.js +442 -270
- package/dist/ui-lib.umd.cjs +1 -1
- package/package.json +1 -1
- package/src/components/BaseInput.test.ts +40 -0
- package/src/components/BaseInput.vue +46 -0
- package/src/components/BaseSegmentedControl.test.ts +78 -0
- package/src/components/BaseSegmentedControl.vue +98 -0
- package/src/components/BaseSelect.test.ts +37 -0
- package/src/components/BaseSelect.vue +46 -0
- package/src/components/BaseSpecFields.test.ts +78 -0
- package/src/components/BaseSpecFields.vue +83 -0
- package/src/components/BaseTable.test.ts +55 -0
- package/src/components/BaseTable.vue +50 -0
- package/src/components/EarningsCard.test.ts +57 -0
- package/src/components/EarningsCard.vue +55 -18
- package/src/index.ts +10 -0
- package/src/types/segmented.ts +11 -0
- package/src/types/specField.ts +25 -0
- package/src/types/table.ts +8 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BaseSegmentedControl from './BaseSegmentedControl.vue'
|
|
4
|
+
import type { SegmentedOption } from '../types/segmented'
|
|
5
|
+
|
|
6
|
+
const options: SegmentedOption[] = [
|
|
7
|
+
{ value: 'ALL', label: 'All' },
|
|
8
|
+
{ value: 'STOCK', label: 'Stock' },
|
|
9
|
+
{ value: 'CRYPTO', label: 'Crypto', title: 'Crypto pairs' },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
describe('BaseSegmentedControl', () => {
|
|
13
|
+
it('renders one button per option and marks the selected one', () => {
|
|
14
|
+
const wrapper = mount(BaseSegmentedControl, {
|
|
15
|
+
props: { options, modelValue: 'STOCK' },
|
|
16
|
+
})
|
|
17
|
+
const buttons = wrapper.findAll('button')
|
|
18
|
+
expect(buttons.map((b) => b.text())).toEqual(['All', 'Stock', 'Crypto'])
|
|
19
|
+
expect(buttons[1].attributes('aria-pressed')).toBe('true')
|
|
20
|
+
expect(buttons[0].attributes('aria-pressed')).toBe('false')
|
|
21
|
+
expect(buttons[1].classes()).toContain('bg-emerald-500')
|
|
22
|
+
expect(buttons[0].classes()).toContain('bg-slate-50')
|
|
23
|
+
expect(buttons[2].attributes('title')).toBe('Crypto pairs')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('emits update:modelValue with the clicked option value', async () => {
|
|
27
|
+
const wrapper = mount(BaseSegmentedControl, {
|
|
28
|
+
props: { options, modelValue: 'ALL' },
|
|
29
|
+
})
|
|
30
|
+
await wrapper.findAll('button')[2].trigger('click')
|
|
31
|
+
expect(wrapper.emitted('update:modelValue')).toEqual([['CRYPTO']])
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('uses the emerald-600 fill and wider padding in the wide variant', () => {
|
|
35
|
+
const wrapper = mount(BaseSegmentedControl, {
|
|
36
|
+
props: { options, modelValue: 'ALL', variant: 'wide' },
|
|
37
|
+
})
|
|
38
|
+
const active = wrapper.findAll('button')[0]
|
|
39
|
+
expect(active.classes()).toContain('bg-emerald-600')
|
|
40
|
+
expect(active.classes()).toContain('px-4')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('renders the toolbar variant with h-9 divide-x wrapper and uppercase buttons', () => {
|
|
44
|
+
const wrapper = mount(BaseSegmentedControl, {
|
|
45
|
+
props: { options, modelValue: 'ALL', variant: 'toolbar' },
|
|
46
|
+
})
|
|
47
|
+
expect(wrapper.find('div').classes()).toContain('h-9')
|
|
48
|
+
expect(wrapper.find('div').classes()).toContain('divide-x')
|
|
49
|
+
expect(wrapper.findAll('button')[0].classes()).toContain('uppercase')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('announces itself as a labelled group only when ariaLabel is set', () => {
|
|
53
|
+
const labelled = mount(BaseSegmentedControl, {
|
|
54
|
+
props: { options, modelValue: 'ALL', ariaLabel: 'Exchange' },
|
|
55
|
+
})
|
|
56
|
+
expect(labelled.find('div').attributes('role')).toBe('group')
|
|
57
|
+
expect(labelled.find('div').attributes('aria-label')).toBe('Exchange')
|
|
58
|
+
|
|
59
|
+
const bare = mount(BaseSegmentedControl, { props: { options, modelValue: 'ALL' } })
|
|
60
|
+
expect(bare.find('div').attributes('role')).toBeUndefined()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('lets optionClass override the fill classes per option', () => {
|
|
64
|
+
const wrapper = mount(BaseSegmentedControl, {
|
|
65
|
+
props: {
|
|
66
|
+
options,
|
|
67
|
+
modelValue: 'ALL',
|
|
68
|
+
optionClass: (opt: SegmentedOption, active: boolean) =>
|
|
69
|
+
active ? 'bg-red-500 text-white' : 'text-red-600',
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
const buttons = wrapper.findAll('button')
|
|
73
|
+
expect(buttons[0].classes()).toContain('bg-red-500')
|
|
74
|
+
expect(buttons[1].classes()).toContain('text-red-600')
|
|
75
|
+
// Layout classes stay owned by the variant.
|
|
76
|
+
expect(buttons[0].classes()).toContain('px-3')
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Segmented button group ("Class: All | Stock | Crypto"). One button per
|
|
4
|
+
option; the selected one gets the filled treatment and aria-pressed.
|
|
5
|
+
|
|
6
|
+
Variants:
|
|
7
|
+
• base — px-3 py-2 text-sm buttons, emerald-500 active fill
|
|
8
|
+
• wide — px-4 py-2 buttons, emerald-600 active fill
|
|
9
|
+
• toolbar — h-9 uppercase text-xs buttons with focus-visible rings
|
|
10
|
+
(divide-x separators instead of border-l)
|
|
11
|
+
|
|
12
|
+
`optionClass` overrides the per-button fill classes (e.g. severity
|
|
13
|
+
colours per option); layout classes stay owned by the variant.
|
|
14
|
+
Same `dark:`-variant note as BaseInput.
|
|
15
|
+
-->
|
|
16
|
+
<div
|
|
17
|
+
:role="ariaLabel ? 'group' : undefined"
|
|
18
|
+
:aria-label="ariaLabel || undefined"
|
|
19
|
+
:class="wrapClass"
|
|
20
|
+
>
|
|
21
|
+
<button
|
|
22
|
+
v-for="opt in options"
|
|
23
|
+
:key="String(opt.value)"
|
|
24
|
+
type="button"
|
|
25
|
+
:class="[buttonClass, fillClass(opt)]"
|
|
26
|
+
:aria-pressed="opt.value === modelValue"
|
|
27
|
+
:title="opt.title"
|
|
28
|
+
@click="emit('update:modelValue', opt.value)"
|
|
29
|
+
>
|
|
30
|
+
{{ opt.label }}
|
|
31
|
+
</button>
|
|
32
|
+
</div>
|
|
33
|
+
</template>
|
|
34
|
+
|
|
35
|
+
<script setup lang="ts">
|
|
36
|
+
import { computed } from 'vue'
|
|
37
|
+
import type { SegmentedOption } from '../types/segmented'
|
|
38
|
+
|
|
39
|
+
// NOT generic on purpose: vite-plugin-dts flattens SFC generics out of the
|
|
40
|
+
// emitted declarations, so consumers would see the erased constraint anyway.
|
|
41
|
+
// The update:modelValue payload is `any` so `v-model` on a union-typed ref
|
|
42
|
+
// ('all' | 'STOCK' | …) type-checks; the actual payload is always one of the
|
|
43
|
+
// options' values.
|
|
44
|
+
interface Props {
|
|
45
|
+
options: readonly SegmentedOption[]
|
|
46
|
+
modelValue: string | number
|
|
47
|
+
variant?: 'base' | 'wide' | 'toolbar'
|
|
48
|
+
/** When set, the wrapper announces itself as a labelled group. */
|
|
49
|
+
ariaLabel?: string
|
|
50
|
+
/**
|
|
51
|
+
* Override the fill (colour) classes per button — receives the option and
|
|
52
|
+
* whether it is the selected one. Defaults to the variant's emerald/slate
|
|
53
|
+
* treatment.
|
|
54
|
+
*/
|
|
55
|
+
optionClass?: (option: SegmentedOption, active: boolean) => string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
59
|
+
variant: 'base',
|
|
60
|
+
ariaLabel: '',
|
|
61
|
+
optionClass: undefined,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const emit = defineEmits<{
|
|
65
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
66
|
+
(e: 'update:modelValue', value: any): void
|
|
67
|
+
}>()
|
|
68
|
+
|
|
69
|
+
const wrapClass = computed(() =>
|
|
70
|
+
props.variant === 'toolbar'
|
|
71
|
+
? 'inline-flex h-9 divide-x divide-slate-300 dark:divide-slate-600 rounded-md border border-slate-300 dark:border-slate-600 overflow-hidden'
|
|
72
|
+
: 'inline-flex rounded border border-slate-300 dark:border-slate-600 overflow-hidden',
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
const buttonClass = computed(() => {
|
|
76
|
+
if (props.variant === 'toolbar') {
|
|
77
|
+
return (
|
|
78
|
+
'flex items-center px-3.5 text-xs font-semibold uppercase tracking-wide transition-colors ' +
|
|
79
|
+
'focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-500'
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
const padding = props.variant === 'wide' ? 'px-4 py-2' : 'px-3 py-2'
|
|
83
|
+
return `${padding} text-sm font-medium transition-colors border-l first:border-l-0 border-slate-300 dark:border-slate-600`
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const activeFill = computed(() =>
|
|
87
|
+
props.variant === 'wide' ? 'bg-emerald-600 text-white' : 'bg-emerald-500 text-white',
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
const idleFill =
|
|
91
|
+
'bg-slate-50 dark:bg-slate-900 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
|
|
92
|
+
|
|
93
|
+
function fillClass(opt: SegmentedOption): string {
|
|
94
|
+
const active = opt.value === props.modelValue
|
|
95
|
+
if (props.optionClass) return props.optionClass(opt, active)
|
|
96
|
+
return active ? activeFill.value : idleFill
|
|
97
|
+
}
|
|
98
|
+
</script>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BaseSelect from './BaseSelect.vue'
|
|
4
|
+
|
|
5
|
+
const slots = {
|
|
6
|
+
default: '<option value="a">A</option><option value="b">B</option>',
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('BaseSelect', () => {
|
|
10
|
+
it('renders the field skin, full-width compact by default', () => {
|
|
11
|
+
const wrapper = mount(BaseSelect, { slots })
|
|
12
|
+
const select = wrapper.find('select')
|
|
13
|
+
expect(select.classes()).toEqual(
|
|
14
|
+
expect.arrayContaining(['rounded', 'border', 'w-full', 'px-2', 'py-1.5', 'text-sm']),
|
|
15
|
+
)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('supports md size and inline (non-block) layout', () => {
|
|
19
|
+
const wrapper = mount(BaseSelect, { props: { size: 'md', block: false }, slots })
|
|
20
|
+
const select = wrapper.find('select')
|
|
21
|
+
expect(select.classes()).toEqual(expect.arrayContaining(['px-3', 'py-2']))
|
|
22
|
+
expect(select.classes()).not.toContain('w-full')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('reflects modelValue and emits the selected value on change', async () => {
|
|
26
|
+
const wrapper = mount(BaseSelect, { props: { modelValue: 'a' }, slots })
|
|
27
|
+
const select = wrapper.find('select')
|
|
28
|
+
expect((select.element as HTMLSelectElement).value).toBe('a')
|
|
29
|
+
await select.setValue('b')
|
|
30
|
+
expect(wrapper.emitted('update:modelValue')).toEqual([['b']])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('renders caller-provided options via the default slot', () => {
|
|
34
|
+
const wrapper = mount(BaseSelect, { slots })
|
|
35
|
+
expect(wrapper.findAll('option').map((o) => o.text())).toEqual(['A', 'B'])
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Themed select sharing BaseInput's field skin. Options come from the
|
|
4
|
+
default slot so callers keep full control of <option> rendering (values,
|
|
5
|
+
titles, disabled entries). Attrs (id, disabled, extra classes) fall
|
|
6
|
+
through. Same `dark:`-variant note as BaseInput.
|
|
7
|
+
-->
|
|
8
|
+
<select
|
|
9
|
+
:value="modelValue"
|
|
10
|
+
class="rounded border border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-900 text-slate-900 dark:text-slate-100 text-sm"
|
|
11
|
+
:class="[block ? 'w-full' : '', size === 'md' ? 'px-3 py-2' : 'px-2 py-1.5']"
|
|
12
|
+
@change="onChange"
|
|
13
|
+
>
|
|
14
|
+
<slot />
|
|
15
|
+
</select>
|
|
16
|
+
</template>
|
|
17
|
+
|
|
18
|
+
<script setup lang="ts">
|
|
19
|
+
// String-valued only — a DOM select always yields strings; use v-model.number
|
|
20
|
+
// semantics in the owner if needed. The update:modelValue payload is `any`
|
|
21
|
+
// (not generic: vite-plugin-dts flattens SFC generics out of the emitted
|
|
22
|
+
// declarations) so `v-model` on a union-typed ref ('7D' | '30D' | …)
|
|
23
|
+
// type-checks; the actual payload is always the selected option's string.
|
|
24
|
+
interface Props {
|
|
25
|
+
modelValue?: string | null
|
|
26
|
+
/** 'sm' = px-2 py-1.5 (default, the usual select height); 'md' = px-3 py-2. */
|
|
27
|
+
size?: 'sm' | 'md'
|
|
28
|
+
/** Full-width (w-full). Set false for inline selects. */
|
|
29
|
+
block?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
withDefaults(defineProps<Props>(), {
|
|
33
|
+
modelValue: undefined,
|
|
34
|
+
size: 'sm',
|
|
35
|
+
block: true,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const emit = defineEmits<{
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
40
|
+
(e: 'update:modelValue', value: any): void
|
|
41
|
+
}>()
|
|
42
|
+
|
|
43
|
+
function onChange(e: Event) {
|
|
44
|
+
emit('update:modelValue', (e.target as HTMLSelectElement).value)
|
|
45
|
+
}
|
|
46
|
+
</script>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BaseSpecFields from './BaseSpecFields.vue'
|
|
4
|
+
import type { SpecField } from '../types/specField'
|
|
5
|
+
|
|
6
|
+
const specs: SpecField[] = [
|
|
7
|
+
{ key: 'mode', label: 'Mode', type: 'select', options: ['fast', 'slow'] },
|
|
8
|
+
{ key: 'enabled', label: 'Enabled', type: 'boolean' },
|
|
9
|
+
{ key: 'threshold', label: 'Threshold', type: 'decimal', min: 0, help: 'Trigger level.' },
|
|
10
|
+
{ key: 'lookback', label: 'Lookback', type: 'integer' },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
const params = { mode: 'fast', enabled: true, threshold: 1.5, lookback: 10 }
|
|
14
|
+
|
|
15
|
+
function mountFields(extra = {}) {
|
|
16
|
+
return mount(BaseSpecFields, { props: { specs, params }, ...extra })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('BaseSpecFields', () => {
|
|
20
|
+
it('renders a select, a checkbox and number inputs per spec type', () => {
|
|
21
|
+
const wrapper = mountFields()
|
|
22
|
+
expect(wrapper.findAll('select')).toHaveLength(1)
|
|
23
|
+
expect(wrapper.findAll('input[type="checkbox"]')).toHaveLength(1)
|
|
24
|
+
expect(wrapper.findAll('input[type="number"]')).toHaveLength(2)
|
|
25
|
+
expect(wrapper.text()).toContain('Trigger level.')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('defaults numeric steps to 1 for integers and 0.01 for decimals', () => {
|
|
29
|
+
const inputs = mountFields().findAll('input[type="number"]')
|
|
30
|
+
expect(inputs[0].attributes('step')).toBe('0.01')
|
|
31
|
+
expect(inputs[1].attributes('step')).toBe('1')
|
|
32
|
+
expect(inputs[0].attributes('min')).toBe('0')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('emits update(key, value) for select changes', async () => {
|
|
36
|
+
const wrapper = mountFields()
|
|
37
|
+
await wrapper.find('select').setValue('slow')
|
|
38
|
+
expect(wrapper.emitted('update')).toEqual([['mode', 'slow']])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('emits update(key, checked) for checkbox toggles', async () => {
|
|
42
|
+
const wrapper = mountFields()
|
|
43
|
+
await wrapper.find('input[type="checkbox"]').setValue(false)
|
|
44
|
+
expect(wrapper.emitted('update')).toEqual([['enabled', false]])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('parses numeric input, passing unparseable values through raw', async () => {
|
|
48
|
+
const wrapper = mountFields()
|
|
49
|
+
const threshold = wrapper.findAll('input[type="number"]')[0]
|
|
50
|
+
await threshold.setValue('2.25')
|
|
51
|
+
await threshold.setValue('')
|
|
52
|
+
expect(wrapper.emitted('update')).toEqual([
|
|
53
|
+
['threshold', 2.25],
|
|
54
|
+
['threshold', ''],
|
|
55
|
+
])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('never mutates params directly', async () => {
|
|
59
|
+
const frozen = Object.freeze({ ...params })
|
|
60
|
+
const wrapper = mount(BaseSpecFields, { props: { specs, params: frozen } })
|
|
61
|
+
await wrapper.find('select').setValue('slow')
|
|
62
|
+
expect(frozen.mode).toBe('fast')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('exposes each spec to the after slot', () => {
|
|
66
|
+
const wrapper = mountFields({
|
|
67
|
+
slots: {
|
|
68
|
+
after: `<template #after="{ spec }"><p class="after-probe">{{ spec.key }}</p></template>`,
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
expect(wrapper.findAll('.after-probe').map((p) => p.text())).toEqual([
|
|
72
|
+
'mode',
|
|
73
|
+
'enabled',
|
|
74
|
+
'threshold',
|
|
75
|
+
'lookback',
|
|
76
|
+
])
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import BaseInput from './BaseInput.vue'
|
|
3
|
+
import BaseSelect from './BaseSelect.vue'
|
|
4
|
+
import type { SpecField, SpecFieldValue } from '../types/specField'
|
|
5
|
+
|
|
6
|
+
// Spec-driven form fields: renders a select / checkbox / number input per
|
|
7
|
+
// field spec, with labels and help text, in a responsive two-column grid.
|
|
8
|
+
// Feed it a backend-described catalogue (or any SpecField[]) and every form
|
|
9
|
+
// that edits those values stays in lockstep.
|
|
10
|
+
//
|
|
11
|
+
// The component never mutates `params`: every edit is emitted as
|
|
12
|
+
// (key, value) and the owner writes it back into its own form state.
|
|
13
|
+
defineProps<{
|
|
14
|
+
specs: SpecField[]
|
|
15
|
+
params: Record<string, SpecFieldValue>
|
|
16
|
+
}>()
|
|
17
|
+
|
|
18
|
+
const emit = defineEmits<{
|
|
19
|
+
(e: 'update', key: string, value: SpecFieldValue): void
|
|
20
|
+
}>()
|
|
21
|
+
|
|
22
|
+
// Per-spec extra content rendered under a field (e.g. a live preview a
|
|
23
|
+
// caller attaches to one specific key).
|
|
24
|
+
defineSlots<{
|
|
25
|
+
after?: (props: { spec: SpecField }) => unknown
|
|
26
|
+
}>()
|
|
27
|
+
|
|
28
|
+
function onCheckbox(key: string, e: Event) {
|
|
29
|
+
emit('update', key, (e.target as HTMLInputElement).checked)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Mirror v-model.number: a parseable value becomes a number, anything else
|
|
33
|
+
// (including a cleared field, which becomes '') passes through as the raw
|
|
34
|
+
// string so the owner's "enter a valid number" validation can catch it.
|
|
35
|
+
function onNumber(key: string, raw: string) {
|
|
36
|
+
const n = parseFloat(raw)
|
|
37
|
+
emit('update', key, Number.isNaN(n) ? raw : n)
|
|
38
|
+
}
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<template>
|
|
42
|
+
<div class="grid gap-3 md:grid-cols-2">
|
|
43
|
+
<div v-for="spec in specs" :key="spec.key">
|
|
44
|
+
<label
|
|
45
|
+
v-if="spec.type !== 'boolean'"
|
|
46
|
+
class="block text-[11px] uppercase tracking-wide text-slate-500 mb-1"
|
|
47
|
+
>
|
|
48
|
+
{{ spec.label }}
|
|
49
|
+
</label>
|
|
50
|
+
|
|
51
|
+
<BaseSelect
|
|
52
|
+
v-if="spec.type === 'select'"
|
|
53
|
+
:modelValue="String(params[spec.key] ?? '')"
|
|
54
|
+
@update:modelValue="(v: string) => emit('update', spec.key, v)"
|
|
55
|
+
>
|
|
56
|
+
<option v-for="opt in spec.options ?? []" :key="opt" :value="opt">{{ opt }}</option>
|
|
57
|
+
</BaseSelect>
|
|
58
|
+
|
|
59
|
+
<label v-else-if="spec.type === 'boolean'" class="flex items-center gap-2 mt-4">
|
|
60
|
+
<input
|
|
61
|
+
:checked="Boolean(params[spec.key])"
|
|
62
|
+
type="checkbox"
|
|
63
|
+
class="h-4 w-4"
|
|
64
|
+
@change="onCheckbox(spec.key, $event)"
|
|
65
|
+
/>
|
|
66
|
+
<span class="text-sm text-slate-900 dark:text-slate-100">{{ spec.label }}</span>
|
|
67
|
+
</label>
|
|
68
|
+
|
|
69
|
+
<BaseInput
|
|
70
|
+
v-else
|
|
71
|
+
:modelValue="params[spec.key] as string | number"
|
|
72
|
+
type="number"
|
|
73
|
+
:step="spec.step ?? (spec.type === 'integer' ? 1 : 0.01)"
|
|
74
|
+
:min="spec.min ?? undefined"
|
|
75
|
+
@update:modelValue="(v: string) => onNumber(spec.key, v)"
|
|
76
|
+
/>
|
|
77
|
+
|
|
78
|
+
<p v-if="spec.help" class="text-[11px] text-slate-400 mt-1">{{ spec.help }}</p>
|
|
79
|
+
|
|
80
|
+
<slot name="after" :spec="spec" />
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</template>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BaseTable from './BaseTable.vue'
|
|
4
|
+
import type { TableColumn } from '../types/table'
|
|
5
|
+
|
|
6
|
+
const columns: TableColumn[] = [
|
|
7
|
+
{ label: 'Symbol' },
|
|
8
|
+
{ label: 'Qty', align: 'right' },
|
|
9
|
+
{ label: 'Bought' },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
describe('BaseTable', () => {
|
|
13
|
+
it('renders header cells with right-alignment where requested', () => {
|
|
14
|
+
const wrapper = mount(BaseTable, { props: { columns } })
|
|
15
|
+
const ths = wrapper.findAll('th')
|
|
16
|
+
expect(ths.map((th) => th.text())).toEqual(['Symbol', 'Qty', 'Bought'])
|
|
17
|
+
expect(ths[1].classes()).toContain('text-right')
|
|
18
|
+
expect(ths[0].classes()).not.toContain('text-right')
|
|
19
|
+
expect(ths[0].classes()).toEqual(expect.arrayContaining(['px-4', 'py-3']))
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('renders caller rows through the default slot', () => {
|
|
23
|
+
const wrapper = mount(BaseTable, {
|
|
24
|
+
props: { columns },
|
|
25
|
+
slots: { default: '<tr><td>AMD</td><td>3</td><td>today</td></tr>' },
|
|
26
|
+
})
|
|
27
|
+
expect(wrapper.find('tbody td').text()).toBe('AMD')
|
|
28
|
+
expect(wrapper.find('table').classes()).toEqual(expect.arrayContaining(['w-full', 'text-sm']))
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('renders the empty-state row spanning every column when empty', () => {
|
|
32
|
+
const wrapper = mount(BaseTable, {
|
|
33
|
+
props: { columns, empty: true, emptyText: 'Nothing here.' },
|
|
34
|
+
})
|
|
35
|
+
const td = wrapper.find('tbody td')
|
|
36
|
+
expect(td.attributes('colspan')).toBe('3')
|
|
37
|
+
expect(td.text()).toBe('Nothing here.')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('prefers the empty slot over emptyText', () => {
|
|
41
|
+
const wrapper = mount(BaseTable, {
|
|
42
|
+
props: { columns, empty: true, emptyText: 'fallback' },
|
|
43
|
+
slots: { empty: 'No trades match your filters.' },
|
|
44
|
+
})
|
|
45
|
+
expect(wrapper.find('tbody td').text()).toBe('No trades match your filters.')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('renders no empty-state row when not empty', () => {
|
|
49
|
+
const wrapper = mount(BaseTable, {
|
|
50
|
+
props: { columns, empty: false },
|
|
51
|
+
slots: { default: '<tr><td>row</td></tr>' },
|
|
52
|
+
})
|
|
53
|
+
expect(wrapper.findAll('tbody tr')).toHaveLength(1)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Styling shell for data tables — NOT a data grid. Owns the table skin
|
|
4
|
+
(slate header band, px-4 py-3 header cells, empty-state row); rows are
|
|
5
|
+
the caller's own <tr> markup via the default slot, so cell content and
|
|
6
|
+
per-cell styling stay fully in the caller's hands.
|
|
7
|
+
|
|
8
|
+
Wrap it yourself for scrolling/card chrome (e.g. inside a BaseRow with
|
|
9
|
+
overflow-x-auto). Same `dark:`-variant note as BaseInput.
|
|
10
|
+
-->
|
|
11
|
+
<table class="w-full text-sm">
|
|
12
|
+
<thead class="bg-slate-50 dark:bg-slate-800/60 text-slate-600 dark:text-slate-300">
|
|
13
|
+
<tr class="text-left">
|
|
14
|
+
<th
|
|
15
|
+
v-for="(col, i) in columns"
|
|
16
|
+
:key="i"
|
|
17
|
+
class="px-4 py-3"
|
|
18
|
+
:class="col.align === 'right' ? 'text-right' : ''"
|
|
19
|
+
>
|
|
20
|
+
{{ col.label }}
|
|
21
|
+
</th>
|
|
22
|
+
</tr>
|
|
23
|
+
</thead>
|
|
24
|
+
<tbody>
|
|
25
|
+
<tr v-if="empty">
|
|
26
|
+
<td :colspan="columns.length" class="px-4 py-12 text-center text-slate-500">
|
|
27
|
+
<slot name="empty">{{ emptyText }}</slot>
|
|
28
|
+
</td>
|
|
29
|
+
</tr>
|
|
30
|
+
<slot />
|
|
31
|
+
</tbody>
|
|
32
|
+
</table>
|
|
33
|
+
</template>
|
|
34
|
+
|
|
35
|
+
<script setup lang="ts">
|
|
36
|
+
import type { TableColumn } from '../types/table'
|
|
37
|
+
|
|
38
|
+
interface Props {
|
|
39
|
+
columns: TableColumn[]
|
|
40
|
+
/** True when there are no rows — renders the empty-state row. */
|
|
41
|
+
empty?: boolean
|
|
42
|
+
/** Fallback empty-state text; the `empty` slot overrides it. */
|
|
43
|
+
emptyText?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
withDefaults(defineProps<Props>(), {
|
|
47
|
+
empty: false,
|
|
48
|
+
emptyText: 'No rows.',
|
|
49
|
+
})
|
|
50
|
+
</script>
|
|
@@ -30,4 +30,61 @@ describe('EarningsCard', () => {
|
|
|
30
30
|
expect(w.html()).toContain('border-emerald-300')
|
|
31
31
|
expect(w.html()).not.toContain('border-orange-300')
|
|
32
32
|
})
|
|
33
|
+
|
|
34
|
+
it('applies the red accent when requested explicitly', () => {
|
|
35
|
+
const w = mount(EarningsCard, { props: { amount: 1, accent: 'red' } })
|
|
36
|
+
expect(w.html()).toContain('border-red-300')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('signed (P&L) mode', () => {
|
|
40
|
+
// The "up-trend" arrowhead the default glyph uses; absent in the loss glyph.
|
|
41
|
+
const UP_GLYPH = 'points="16 7 22 7 22 13"'
|
|
42
|
+
const DOWN_GLYPH = 'points="16 17 22 17 22 11"'
|
|
43
|
+
|
|
44
|
+
it('renders the red loss theme and a downward glyph for a negative amount', () => {
|
|
45
|
+
const w = mount(EarningsCard, {
|
|
46
|
+
props: { amount: -5, signed: true, accent: 'emerald' },
|
|
47
|
+
})
|
|
48
|
+
const html = w.html()
|
|
49
|
+
expect(html).toContain('border-red-300') // loss recolour overrides emerald
|
|
50
|
+
expect(html).not.toContain('border-emerald-300')
|
|
51
|
+
expect(html).toContain(DOWN_GLYPH)
|
|
52
|
+
expect(html).not.toContain(UP_GLYPH)
|
|
53
|
+
// Existing convention is currency-then-value, so a negative reads "$-5.00".
|
|
54
|
+
expect(w.text()).toContain('$-5.00')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('keeps the chosen accent and upward glyph for a non-negative amount', () => {
|
|
58
|
+
const w = mount(EarningsCard, {
|
|
59
|
+
props: { amount: 5, signed: true, accent: 'emerald' },
|
|
60
|
+
})
|
|
61
|
+
const html = w.html()
|
|
62
|
+
expect(html).toContain('border-emerald-300')
|
|
63
|
+
expect(html).not.toContain('border-red-300')
|
|
64
|
+
expect(html).toContain(UP_GLYPH)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('treats exactly zero as non-negative (no loss theme)', () => {
|
|
68
|
+
const w = mount(EarningsCard, { props: { amount: 0, signed: true, accent: 'emerald' } })
|
|
69
|
+
expect(w.html()).not.toContain('border-red-300')
|
|
70
|
+
expect(w.html()).toContain('border-emerald-300')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('does NOT apply the loss theme to a negative amount unless signed is set', () => {
|
|
74
|
+
// Backward compatibility: without `signed`, a negative amount is just a
|
|
75
|
+
// number rendered in the normal accent (no auto-red).
|
|
76
|
+
const w = mount(EarningsCard, { props: { amount: -5, accent: 'emerald' } })
|
|
77
|
+
expect(w.html()).not.toContain('border-red-300')
|
|
78
|
+
expect(w.html()).toContain('border-emerald-300')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('coerces a stringified negative amount before the sign check', () => {
|
|
82
|
+
const w = mount(EarningsCard, {
|
|
83
|
+
// The API can hand back numbers as JSON strings.
|
|
84
|
+
props: { amount: '-0.01' as unknown as number, signed: true, accent: 'emerald' },
|
|
85
|
+
})
|
|
86
|
+
expect(w.html()).toContain('border-red-300')
|
|
87
|
+
expect(w.text()).toContain('$-0.01')
|
|
88
|
+
})
|
|
89
|
+
})
|
|
33
90
|
})
|