mgv-backoffice 1.33.0 → 1.35.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 +84 -2
- package/dist/components/BaseCodeBlock.vue.d.ts +17 -0
- package/dist/components/BasePillPickerModal.vue.d.ts +43 -0
- package/dist/components/BaseToolbarButton.vue.d.ts +1 -1
- package/dist/composables/usePolling.d.ts +31 -0
- package/dist/composables/useThemeClasses.d.ts +27 -24
- package/dist/index.d.ts +7 -1
- package/dist/types/pillPicker.d.ts +14 -0
- package/dist/ui-lib.css +1 -1
- package/dist/ui-lib.js +825 -609
- package/dist/ui-lib.umd.cjs +1 -1
- package/dist/utils/format.d.ts +12 -0
- package/dist/utils/validate.d.ts +35 -0
- package/package.json +1 -1
- package/src/components/BaseCodeBlock.test.ts +70 -0
- package/src/components/BaseCodeBlock.vue +74 -0
- package/src/components/BasePillPickerModal.test.ts +95 -0
- package/src/components/BasePillPickerModal.vue +0 -0
- package/src/components/BaseToolbarButton.test.ts +48 -0
- package/src/components/BaseToolbarButton.vue +21 -6
- package/src/composables/usePolling.test.ts +112 -0
- package/src/composables/usePolling.ts +90 -0
- package/src/composables/useThemeClasses.test.ts +34 -0
- package/src/composables/useThemeClasses.ts +30 -26
- package/src/index.ts +13 -0
- package/src/types/pillPicker.ts +14 -0
- package/src/utils/format.test.ts +29 -0
- package/src/utils/format.ts +28 -0
- package/src/utils/validate.test.ts +82 -0
- package/src/utils/validate.ts +76 -0
package/dist/utils/format.d.ts
CHANGED
|
@@ -68,6 +68,18 @@ export declare function fmtMsAsSeconds(ms: number | null | undefined): string;
|
|
|
68
68
|
* "2.0 MB"). Returns '' for zero/falsy input.
|
|
69
69
|
*/
|
|
70
70
|
export declare function fmtBytes(bytes: number | null | undefined): string;
|
|
71
|
+
/**
|
|
72
|
+
* Pretty-print a string that may contain JSON: parseable input is
|
|
73
|
+
* re-serialized with 2-space indentation, anything else is returned
|
|
74
|
+
* verbatim. Handy for request/response bodies of unknown content type.
|
|
75
|
+
*/
|
|
76
|
+
export declare function formatJson(content: string): string;
|
|
77
|
+
/**
|
|
78
|
+
* Render an unknown value as a display string: strings pass through,
|
|
79
|
+
* null/undefined become '', everything else is JSON-serialized (falling
|
|
80
|
+
* back to String() for values JSON can't handle, e.g. cycles or BigInt).
|
|
81
|
+
*/
|
|
82
|
+
export declare function stringifyValue(value: unknown): string;
|
|
71
83
|
/**
|
|
72
84
|
* Format a millisecond duration as compact day/hour/minute units, e.g.
|
|
73
85
|
* "3d 5h", "5h 12m" or "12m". Zero-value leading units are dropped;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, dependency-free input validators shared across back-office forms —
|
|
3
|
+
* extracted from WireMate's mock form.
|
|
4
|
+
*
|
|
5
|
+
* Semantics note: the payload validators (`isValidJson`, `isValidXml`,
|
|
6
|
+
* `isValidBase64`) treat empty/whitespace-only input as VALID — callers
|
|
7
|
+
* gate on "is there something to check" separately (required-ness is a
|
|
8
|
+
* different rule from well-formedness). `isValidAbsoluteUrl` is the
|
|
9
|
+
* opposite: it validates a value that must exist, so empty is invalid.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* `true` when `value` parses as an absolute http:// or https:// URL.
|
|
13
|
+
* Deliberately tightens the scheme list — backends' HTTP clients won't
|
|
14
|
+
* follow ftp:// or mailto: anyway, so a stray scheme is a typo.
|
|
15
|
+
*/
|
|
16
|
+
export declare function isValidAbsoluteUrl(value: string): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* `true` when `str` is empty or parses as JSON.
|
|
19
|
+
*/
|
|
20
|
+
export declare function isValidJson(str: string): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* `true` when `str` is empty or is well-formed XML. Runs the browser's
|
|
23
|
+
* DOMParser and checks for a `<parsererror>` node — the only reliable
|
|
24
|
+
* signal DOMParser provides, since it never throws on bad input.
|
|
25
|
+
*/
|
|
26
|
+
export declare function isValidXml(str: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* `true` when `str` is empty or is well-formed base64 (after stripping
|
|
29
|
+
* whitespace/newlines, which are harmless in both RFC 4648 and most
|
|
30
|
+
* real-world payloads). Rejects lengths that can't possibly decode
|
|
31
|
+
* (`length % 4 !== 0` after padding) — the common "off by a character"
|
|
32
|
+
* copy/paste issue — then defers to the native decoder as the final
|
|
33
|
+
* authority.
|
|
34
|
+
*/
|
|
35
|
+
export declare function isValidBase64(str: string): boolean;
|
package/package.json
CHANGED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BaseCodeBlock from './BaseCodeBlock.vue'
|
|
4
|
+
import { useTheme } from '../composables/useTheme'
|
|
5
|
+
|
|
6
|
+
function ensureLight() {
|
|
7
|
+
const { isDark, toggleTheme } = useTheme()
|
|
8
|
+
if (isDark.value) toggleTheme()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('BaseCodeBlock', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
ensureLight()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('renders the code verbatim inside a <pre>', () => {
|
|
17
|
+
const code = '{\n "a": 1\n}'
|
|
18
|
+
const wrapper = mount(BaseCodeBlock, { props: { code } })
|
|
19
|
+
const pre = wrapper.find('pre')
|
|
20
|
+
expect(pre.exists()).toBe(true)
|
|
21
|
+
expect(pre.text()).toBe(code)
|
|
22
|
+
expect(pre.classes()).toContain('font-mono')
|
|
23
|
+
expect(pre.classes()).toContain('overflow-auto')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('defaults to the soft variant at size sm', () => {
|
|
27
|
+
const wrapper = mount(BaseCodeBlock, { props: { code: 'x' } })
|
|
28
|
+
const classes = wrapper.find('pre').classes()
|
|
29
|
+
expect(classes).toContain('bg-gray-100')
|
|
30
|
+
expect(classes).toContain('text-sm')
|
|
31
|
+
expect(classes).toContain('px-5')
|
|
32
|
+
expect(classes).not.toContain('border')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('renders the bordered variant with a border and card fill', () => {
|
|
36
|
+
const wrapper = mount(BaseCodeBlock, {
|
|
37
|
+
props: { code: 'x', variant: 'bordered' },
|
|
38
|
+
})
|
|
39
|
+
const classes = wrapper.find('pre').classes()
|
|
40
|
+
expect(classes).toContain('border')
|
|
41
|
+
expect(classes).toContain('bg-white')
|
|
42
|
+
expect(classes).toContain('border-gray-200')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('renders dense padding at size xs', () => {
|
|
46
|
+
const wrapper = mount(BaseCodeBlock, { props: { code: 'x', size: 'xs' } })
|
|
47
|
+
const classes = wrapper.find('pre').classes()
|
|
48
|
+
expect(classes).toContain('text-xs')
|
|
49
|
+
expect(classes).toContain('p-3')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('applies maxHeightClass and merges fallthrough classes', () => {
|
|
53
|
+
const wrapper = mount(BaseCodeBlock, {
|
|
54
|
+
props: { code: 'x', maxHeightClass: 'max-h-96' },
|
|
55
|
+
attrs: { class: 'mx-4' },
|
|
56
|
+
})
|
|
57
|
+
const classes = wrapper.find('pre').classes()
|
|
58
|
+
expect(classes).toContain('max-h-96')
|
|
59
|
+
expect(classes).toContain('mx-4')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('switches palette when the theme flips', async () => {
|
|
63
|
+
const wrapper = mount(BaseCodeBlock, { props: { code: 'x' } })
|
|
64
|
+
const { toggleTheme } = useTheme()
|
|
65
|
+
toggleTheme()
|
|
66
|
+
await wrapper.vm.$nextTick()
|
|
67
|
+
expect(wrapper.find('pre').classes()).toContain('bg-gray-800/70')
|
|
68
|
+
toggleTheme()
|
|
69
|
+
})
|
|
70
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Themed monospace <pre> for JSON payloads, request dumps and code
|
|
4
|
+
snippets — extracted from WireMate, where the same class soup was
|
|
5
|
+
hand-rolled on every stub/request detail view.
|
|
6
|
+
|
|
7
|
+
Variants:
|
|
8
|
+
• soft — tinted fill, no border (bg-gray-800/70 / bg-gray-100).
|
|
9
|
+
The in-card look used under a section heading.
|
|
10
|
+
• bordered — bordered card fill (bg-gray-900 / bg-white). The
|
|
11
|
+
standalone look used when the block sits directly on the page.
|
|
12
|
+
|
|
13
|
+
Sizing:
|
|
14
|
+
• sm — text-sm with roomy padding, for primary content blocks.
|
|
15
|
+
• xs — text-xs with tight padding, for dense/secondary dumps.
|
|
16
|
+
|
|
17
|
+
`max-height-class` caps tall payloads (e.g. 'max-h-96'); the block
|
|
18
|
+
scrolls both axes. Extra classes (margins etc.) fall through via the
|
|
19
|
+
normal class attr merge.
|
|
20
|
+
-->
|
|
21
|
+
<pre
|
|
22
|
+
class="font-mono overflow-auto"
|
|
23
|
+
:class="[sizeClass, paletteClass, maxHeightClass]"
|
|
24
|
+
>{{ code }}</pre>
|
|
25
|
+
</template>
|
|
26
|
+
|
|
27
|
+
<script setup lang="ts">
|
|
28
|
+
import { computed } from 'vue'
|
|
29
|
+
import { useTheme } from '../composables/useTheme'
|
|
30
|
+
|
|
31
|
+
const props = withDefaults(
|
|
32
|
+
defineProps<{
|
|
33
|
+
/** The raw text to render. Whitespace is preserved verbatim. */
|
|
34
|
+
code: string
|
|
35
|
+
/** Visual style: tinted fill ('soft') or bordered card ('bordered'). */
|
|
36
|
+
variant?: 'soft' | 'bordered'
|
|
37
|
+
/** Text size + padding: 'sm' (roomy) or 'xs' (dense). */
|
|
38
|
+
size?: 'sm' | 'xs'
|
|
39
|
+
/** Optional Tailwind max-height utility, e.g. 'max-h-96'. */
|
|
40
|
+
maxHeightClass?: string
|
|
41
|
+
}>(),
|
|
42
|
+
{
|
|
43
|
+
variant: 'soft',
|
|
44
|
+
size: 'sm',
|
|
45
|
+
maxHeightClass: '',
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
const { isDark } = useTheme()
|
|
50
|
+
|
|
51
|
+
// Full literal class strings so Tailwind detects them at build time.
|
|
52
|
+
const PALETTES: Record<'soft' | 'bordered', { dark: string; light: string }> = {
|
|
53
|
+
soft: {
|
|
54
|
+
dark: 'bg-gray-800/70 text-gray-300',
|
|
55
|
+
light: 'bg-gray-100 text-gray-700',
|
|
56
|
+
},
|
|
57
|
+
bordered: {
|
|
58
|
+
dark: 'border bg-gray-900 border-gray-700 text-gray-300',
|
|
59
|
+
light: 'border bg-white border-gray-200 text-gray-700',
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const SIZES: Record<'sm' | 'xs', string> = {
|
|
64
|
+
sm: 'text-sm px-5 py-4 rounded-lg',
|
|
65
|
+
xs: 'text-xs p-3 rounded-lg',
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const paletteClass = computed(() => {
|
|
69
|
+
const entry = PALETTES[props.variant]
|
|
70
|
+
return isDark.value ? entry.dark : entry.light
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const sizeClass = computed(() => SIZES[props.size])
|
|
74
|
+
</script>
|
|
@@ -0,0 +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
|
+
})
|
|
Binary file
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BaseToolbarButton from './BaseToolbarButton.vue'
|
|
4
|
+
|
|
5
|
+
describe('BaseToolbarButton', () => {
|
|
6
|
+
it('renders the neutral pill shape by default', () => {
|
|
7
|
+
const wrapper = mount(BaseToolbarButton, { props: { label: 'Refresh' } })
|
|
8
|
+
const cls = wrapper.find('button').classes()
|
|
9
|
+
expect(cls).toContain('rounded-lg')
|
|
10
|
+
expect(cls).toContain('py-2')
|
|
11
|
+
expect(cls).toContain('font-medium')
|
|
12
|
+
expect(cls).toContain('disabled:opacity-50')
|
|
13
|
+
expect(wrapper.text()).toBe('Refresh')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('keeps the danger variant on the pill shape with the red palette', () => {
|
|
17
|
+
const wrapper = mount(BaseToolbarButton, {
|
|
18
|
+
props: { label: 'Delete', variant: 'danger' },
|
|
19
|
+
})
|
|
20
|
+
const cls = wrapper.find('button').classes()
|
|
21
|
+
expect(cls).toContain('rounded-lg')
|
|
22
|
+
expect(cls).toContain('bg-red-600')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('renders the ghost variant as the slate h-9 rounded-md outline button', () => {
|
|
26
|
+
const wrapper = mount(BaseToolbarButton, {
|
|
27
|
+
props: { label: 'Close', variant: 'ghost' },
|
|
28
|
+
})
|
|
29
|
+
const cls = wrapper.find('button').classes()
|
|
30
|
+
expect(cls).toContain('h-9')
|
|
31
|
+
expect(cls).toContain('rounded-md')
|
|
32
|
+
expect(cls).toContain('px-4')
|
|
33
|
+
expect(cls).toContain('font-semibold')
|
|
34
|
+
expect(cls).toContain('border-slate-300')
|
|
35
|
+
expect(cls).toContain('disabled:opacity-40')
|
|
36
|
+
expect(cls).not.toContain('rounded-lg')
|
|
37
|
+
expect(cls).not.toContain('py-2')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('emits click and honours disabled', async () => {
|
|
41
|
+
const wrapper = mount(BaseToolbarButton, { props: { label: 'Go' } })
|
|
42
|
+
await wrapper.find('button').trigger('click')
|
|
43
|
+
expect(wrapper.emitted('click')).toHaveLength(1)
|
|
44
|
+
|
|
45
|
+
const disabled = mount(BaseToolbarButton, { props: { label: 'Go', disabled: true } })
|
|
46
|
+
expect(disabled.find('button').attributes('disabled')).toBeDefined()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
Props:
|
|
12
12
|
• label — button text (rendered in a <span>). Optional so an
|
|
13
13
|
icon-only toolbar button is possible.
|
|
14
|
-
• variant — 'neutral' (default grey)
|
|
14
|
+
• variant — 'neutral' (default grey), 'danger' (solid red) or
|
|
15
|
+
'ghost' (slate outline — TradeAutomation's toolbar/footer idiom).
|
|
15
16
|
• disabled — greys out + blocks the click.
|
|
16
17
|
• title — native tooltip / accessibility text.
|
|
17
18
|
• type — native button type. Defaults to 'button' so the
|
|
@@ -21,7 +22,7 @@
|
|
|
21
22
|
:type="type"
|
|
22
23
|
:disabled="disabled"
|
|
23
24
|
:title="title"
|
|
24
|
-
class="inline-flex shrink-0 items-center gap-2
|
|
25
|
+
class="inline-flex shrink-0 items-center gap-2 text-sm border transition-colors cursor-pointer disabled:cursor-not-allowed whitespace-nowrap focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
|
|
25
26
|
:class="variantClass"
|
|
26
27
|
@click="$emit('click', $event)"
|
|
27
28
|
>
|
|
@@ -34,7 +35,7 @@
|
|
|
34
35
|
import { computed } from 'vue'
|
|
35
36
|
import { useTheme } from '../composables/useTheme'
|
|
36
37
|
|
|
37
|
-
type ToolbarVariant = 'neutral' | 'danger'
|
|
38
|
+
type ToolbarVariant = 'neutral' | 'danger' | 'ghost'
|
|
38
39
|
|
|
39
40
|
const props = withDefaults(
|
|
40
41
|
defineProps<{
|
|
@@ -59,9 +60,19 @@ defineEmits<{
|
|
|
59
60
|
|
|
60
61
|
const { isDark } = useTheme()
|
|
61
62
|
|
|
62
|
-
// Resolves the variant + theme into
|
|
63
|
+
// Resolves the variant + theme into shape and colour classes. The danger
|
|
63
64
|
// style is normalised here so every destructive toolbar button reads
|
|
64
|
-
// identically (red-500 hover in dark, red-700 hover in light).
|
|
65
|
+
// identically (red-500 hover in dark, red-700 hover in light). Shape lives
|
|
66
|
+
// per-variant because 'ghost' — the slate outline button extracted from
|
|
67
|
+
// TradeAutomation's toolbars and modal footers — is a fixed-height
|
|
68
|
+
// rounded-md button, not the neutral/danger pill; it themes via dark:
|
|
69
|
+
// utilities so it renders the same markup in both themes.
|
|
70
|
+
const SHAPE: Record<ToolbarVariant, string> = {
|
|
71
|
+
neutral: 'px-3 py-2 rounded-lg font-medium disabled:opacity-50',
|
|
72
|
+
danger: 'px-3 py-2 rounded-lg font-medium disabled:opacity-50',
|
|
73
|
+
ghost: 'h-9 px-4 rounded-md font-semibold disabled:opacity-40 disabled:hover:bg-slate-50 dark:disabled:hover:bg-slate-900',
|
|
74
|
+
}
|
|
75
|
+
|
|
65
76
|
const variantClass = computed(() => {
|
|
66
77
|
const palette: Record<ToolbarVariant, { dark: string; light: string }> = {
|
|
67
78
|
neutral: {
|
|
@@ -72,8 +83,12 @@ const variantClass = computed(() => {
|
|
|
72
83
|
dark: 'bg-red-600 text-white border-red-600 hover:bg-red-500',
|
|
73
84
|
light: 'bg-red-600 text-white border-red-600 hover:bg-red-700',
|
|
74
85
|
},
|
|
86
|
+
ghost: {
|
|
87
|
+
dark: 'border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-900 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800',
|
|
88
|
+
light: 'border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-900 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800',
|
|
89
|
+
},
|
|
75
90
|
}
|
|
76
91
|
const entry = palette[props.variant]
|
|
77
|
-
return isDark.value ? entry.dark : entry.light
|
|
92
|
+
return [SHAPE[props.variant], isDark.value ? entry.dark : entry.light]
|
|
78
93
|
})
|
|
79
94
|
</script>
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { defineComponent, h, nextTick } from 'vue'
|
|
3
|
+
import { mount } from '@vue/test-utils'
|
|
4
|
+
import { usePolling, type UsePollingOptions } from './usePolling'
|
|
5
|
+
|
|
6
|
+
function mountWithPolling(
|
|
7
|
+
fn: () => void,
|
|
8
|
+
intervalMs: number | null,
|
|
9
|
+
options?: UsePollingOptions,
|
|
10
|
+
) {
|
|
11
|
+
let api!: ReturnType<typeof usePolling>
|
|
12
|
+
const wrapper = mount(
|
|
13
|
+
defineComponent({
|
|
14
|
+
setup() {
|
|
15
|
+
api = usePolling(fn, intervalMs, options)
|
|
16
|
+
return () => h('div')
|
|
17
|
+
},
|
|
18
|
+
}),
|
|
19
|
+
)
|
|
20
|
+
return { wrapper, api }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function setVisibility(state: 'visible' | 'hidden') {
|
|
24
|
+
Object.defineProperty(document, 'visibilityState', {
|
|
25
|
+
configurable: true,
|
|
26
|
+
get: () => state,
|
|
27
|
+
})
|
|
28
|
+
document.dispatchEvent(new Event('visibilitychange'))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('usePolling', () => {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
vi.useFakeTimers()
|
|
34
|
+
setVisibility('visible')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
vi.useRealTimers()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('runs immediately on mount and then on each interval', () => {
|
|
42
|
+
const fn = vi.fn()
|
|
43
|
+
mountWithPolling(fn, 1000)
|
|
44
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
45
|
+
vi.advanceTimersByTime(3000)
|
|
46
|
+
expect(fn).toHaveBeenCalledTimes(4)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('skips the initial call when immediate is false', () => {
|
|
50
|
+
const fn = vi.fn()
|
|
51
|
+
mountWithPolling(fn, 1000, { immediate: false })
|
|
52
|
+
expect(fn).not.toHaveBeenCalled()
|
|
53
|
+
vi.advanceTimersByTime(1000)
|
|
54
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('pauses while hidden and refreshes + resumes on return to visible', async () => {
|
|
58
|
+
const fn = vi.fn()
|
|
59
|
+
mountWithPolling(fn, 1000)
|
|
60
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
61
|
+
|
|
62
|
+
setVisibility('hidden')
|
|
63
|
+
await nextTick()
|
|
64
|
+
vi.advanceTimersByTime(5000)
|
|
65
|
+
expect(fn).toHaveBeenCalledTimes(1) // no ticks while hidden
|
|
66
|
+
|
|
67
|
+
setVisibility('visible')
|
|
68
|
+
expect(fn).toHaveBeenCalledTimes(2) // immediate refresh on resume
|
|
69
|
+
vi.advanceTimersByTime(1000)
|
|
70
|
+
expect(fn).toHaveBeenCalledTimes(3) // interval re-armed
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('with intervalMs null, runs on mount and on visibility resume only', () => {
|
|
74
|
+
const fn = vi.fn()
|
|
75
|
+
mountWithPolling(fn, null)
|
|
76
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
77
|
+
vi.advanceTimersByTime(60000)
|
|
78
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
79
|
+
|
|
80
|
+
setVisibility('hidden')
|
|
81
|
+
setVisibility('visible')
|
|
82
|
+
expect(fn).toHaveBeenCalledTimes(2)
|
|
83
|
+
vi.advanceTimersByTime(60000)
|
|
84
|
+
expect(fn).toHaveBeenCalledTimes(2)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('stops on unmount', () => {
|
|
88
|
+
const fn = vi.fn()
|
|
89
|
+
const { wrapper } = mountWithPolling(fn, 1000)
|
|
90
|
+
wrapper.unmount()
|
|
91
|
+
vi.advanceTimersByTime(5000)
|
|
92
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
93
|
+
// and the visibility listener is gone too
|
|
94
|
+
setVisibility('hidden')
|
|
95
|
+
setVisibility('visible')
|
|
96
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('exposes manual start/stop and an active flag', () => {
|
|
100
|
+
const fn = vi.fn()
|
|
101
|
+
const { api } = mountWithPolling(fn, 1000)
|
|
102
|
+
expect(api.active.value).toBe(true)
|
|
103
|
+
api.stop()
|
|
104
|
+
expect(api.active.value).toBe(false)
|
|
105
|
+
vi.advanceTimersByTime(3000)
|
|
106
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
107
|
+
api.start()
|
|
108
|
+
expect(fn).toHaveBeenCalledTimes(2)
|
|
109
|
+
vi.advanceTimersByTime(1000)
|
|
110
|
+
expect(fn).toHaveBeenCalledTimes(3)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { onMounted, onUnmounted, readonly, ref, type Ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
export interface UsePollingOptions {
|
|
4
|
+
/** Run `fn` immediately when polling starts (mount / resume). Default true. */
|
|
5
|
+
immediate?: boolean
|
|
6
|
+
/**
|
|
7
|
+
* Pause the interval while the tab is hidden and refresh + resume when it
|
|
8
|
+
* becomes visible again — a hidden tab has no reason to keep waking the
|
|
9
|
+
* backend. Default true.
|
|
10
|
+
*/
|
|
11
|
+
pauseWhenHidden?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Visibility-gated polling loop bound to the component lifecycle.
|
|
16
|
+
*
|
|
17
|
+
* Starts on mount, stops on unmount. With `pauseWhenHidden` (the default)
|
|
18
|
+
* the interval is torn down on `document.visibilitychange` → hidden and
|
|
19
|
+
* re-armed — with an immediate refresh — when the tab is shown again.
|
|
20
|
+
*
|
|
21
|
+
* Pass `intervalMs: null` for a refresh-only mode: `fn` runs on mount and
|
|
22
|
+
* on every return-to-visible, but no interval is scheduled. Useful for
|
|
23
|
+
* "reload this badge when the user comes back" data that doesn't warrant
|
|
24
|
+
* a timer.
|
|
25
|
+
*
|
|
26
|
+
* Rejections from an async `fn` are the caller's to handle — catch inside
|
|
27
|
+
* `fn`; the loop itself never swallows or reports them.
|
|
28
|
+
*/
|
|
29
|
+
export function usePolling(
|
|
30
|
+
fn: () => void | Promise<void>,
|
|
31
|
+
intervalMs: number | null,
|
|
32
|
+
options: UsePollingOptions = {},
|
|
33
|
+
): { start: () => void; stop: () => void; active: Readonly<Ref<boolean>> } {
|
|
34
|
+
const { immediate = true, pauseWhenHidden = true } = options
|
|
35
|
+
|
|
36
|
+
let timer: ReturnType<typeof setInterval> | null = null
|
|
37
|
+
const active = ref(false)
|
|
38
|
+
|
|
39
|
+
function startTimer() {
|
|
40
|
+
if (timer !== null || intervalMs === null) return
|
|
41
|
+
timer = setInterval(() => void fn(), intervalMs)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function stopTimer() {
|
|
45
|
+
if (timer !== null) {
|
|
46
|
+
clearInterval(timer)
|
|
47
|
+
timer = null
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function start() {
|
|
52
|
+
if (active.value) return
|
|
53
|
+
active.value = true
|
|
54
|
+
if (immediate) void fn()
|
|
55
|
+
startTimer()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function stop() {
|
|
59
|
+
active.value = false
|
|
60
|
+
stopTimer()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function onVisibilityChange() {
|
|
64
|
+
if (document.visibilityState === 'visible') {
|
|
65
|
+
// Refresh right away so stale data doesn't linger for a full
|
|
66
|
+
// interval after the user returns, then re-arm the timer.
|
|
67
|
+
active.value = true
|
|
68
|
+
void fn()
|
|
69
|
+
startTimer()
|
|
70
|
+
} else {
|
|
71
|
+
stop()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
onMounted(() => {
|
|
76
|
+
start()
|
|
77
|
+
if (pauseWhenHidden) {
|
|
78
|
+
document.addEventListener('visibilitychange', onVisibilityChange)
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
onUnmounted(() => {
|
|
83
|
+
stop()
|
|
84
|
+
if (pauseWhenHidden) {
|
|
85
|
+
document.removeEventListener('visibilitychange', onVisibilityChange)
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
return { start, stop, active: readonly(active) }
|
|
90
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { isReactive } from 'vue'
|
|
3
|
+
import { normalizeClass } from '@vue/shared'
|
|
4
|
+
import { useThemeClasses } from './useThemeClasses'
|
|
5
|
+
import { useTheme } from './useTheme'
|
|
6
|
+
|
|
7
|
+
describe('useThemeClasses', () => {
|
|
8
|
+
it('returns a reactive object whose properties are plain class strings', () => {
|
|
9
|
+
const t = useThemeClasses()
|
|
10
|
+
expect(isReactive(t)).toBe(true)
|
|
11
|
+
expect(typeof t.primaryText).toBe('string')
|
|
12
|
+
expect(t.primaryText).toMatch(/^text-/)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('survives normalizeClass — the regression that leaked ref internals into class attrs', () => {
|
|
16
|
+
// Before the reactive() fix, `:class="t.primaryText"` handed a raw
|
|
17
|
+
// ComputedRef to normalizeClass, which rendered the ref's own keys
|
|
18
|
+
// ("fn dep __v_isRef …") instead of the theme classes.
|
|
19
|
+
const t = useThemeClasses()
|
|
20
|
+
const normalized = normalizeClass(t.primaryText)
|
|
21
|
+
expect(normalized).not.toContain('__v_isRef')
|
|
22
|
+
expect(normalized).toBe(t.primaryText)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('tracks theme flips', () => {
|
|
26
|
+
const { isDark, toggleTheme } = useTheme()
|
|
27
|
+
const t = useThemeClasses()
|
|
28
|
+
const before = t.primaryText
|
|
29
|
+
toggleTheme()
|
|
30
|
+
expect(t.primaryText).not.toBe(before)
|
|
31
|
+
expect(t.primaryText).toBe(isDark.value ? 'text-white' : 'text-gray-900')
|
|
32
|
+
toggleTheme() // restore for other tests sharing the singleton theme
|
|
33
|
+
})
|
|
34
|
+
})
|