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,55 +1,55 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
-
import { useFieldClasses } from './useFieldClasses'
|
|
3
|
-
import { useTheme } from './useTheme'
|
|
4
|
-
|
|
5
|
-
// useTheme is a module-level singleton — grab its ref once and flip it
|
|
6
|
-
// per test. localStorage is cleared so a previous test's persisted theme
|
|
7
|
-
// can't leak into the initial value.
|
|
8
|
-
beforeEach(() => {
|
|
9
|
-
localStorage.clear()
|
|
10
|
-
})
|
|
11
|
-
|
|
12
|
-
describe('useFieldClasses', () => {
|
|
13
|
-
it('returns the light-mode strings when isDark is false', () => {
|
|
14
|
-
const { isDark } = useTheme()
|
|
15
|
-
isDark.value = false
|
|
16
|
-
const f = useFieldClasses()
|
|
17
|
-
expect(f.label.value).toBe('text-gray-600')
|
|
18
|
-
expect(f.input.value).toContain('bg-white')
|
|
19
|
-
expect(f.input.value).toContain('focus:ring-emerald-500')
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
it('returns the dark-mode strings when isDark is true', () => {
|
|
23
|
-
const { isDark } = useTheme()
|
|
24
|
-
isDark.value = true
|
|
25
|
-
const f = useFieldClasses()
|
|
26
|
-
expect(f.label.value).toBe('text-gray-300')
|
|
27
|
-
expect(f.input.value).toContain('bg-gray-800')
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it('requiredInput flags empty values with the red skin', () => {
|
|
31
|
-
const { isDark } = useTheme()
|
|
32
|
-
isDark.value = false
|
|
33
|
-
const f = useFieldClasses()
|
|
34
|
-
expect(f.requiredInput('')).toContain('border-red-400')
|
|
35
|
-
expect(f.requiredInput(' ')).toContain('border-red-400')
|
|
36
|
-
expect(f.requiredInput(undefined)).toContain('border-red-400')
|
|
37
|
-
expect(f.requiredInput(null)).toContain('border-red-400')
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
it('requiredInput falls back to the standard skin for filled values', () => {
|
|
41
|
-
const { isDark } = useTheme()
|
|
42
|
-
isDark.value = false
|
|
43
|
-
const f = useFieldClasses()
|
|
44
|
-
expect(f.requiredInput('hello')).toBe(f.input.value)
|
|
45
|
-
expect(f.requiredInput(0)).toBe(f.input.value) // 0 is a real value, not empty
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
it('requiredInput follows the active theme', () => {
|
|
49
|
-
const { isDark } = useTheme()
|
|
50
|
-
isDark.value = true
|
|
51
|
-
const f = useFieldClasses()
|
|
52
|
-
expect(f.requiredInput('')).toContain('bg-gray-800')
|
|
53
|
-
expect(f.requiredInput('')).toContain('border-red-500')
|
|
54
|
-
})
|
|
55
|
-
})
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import { useFieldClasses } from './useFieldClasses'
|
|
3
|
+
import { useTheme } from './useTheme'
|
|
4
|
+
|
|
5
|
+
// useTheme is a module-level singleton — grab its ref once and flip it
|
|
6
|
+
// per test. localStorage is cleared so a previous test's persisted theme
|
|
7
|
+
// can't leak into the initial value.
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
localStorage.clear()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
describe('useFieldClasses', () => {
|
|
13
|
+
it('returns the light-mode strings when isDark is false', () => {
|
|
14
|
+
const { isDark } = useTheme()
|
|
15
|
+
isDark.value = false
|
|
16
|
+
const f = useFieldClasses()
|
|
17
|
+
expect(f.label.value).toBe('text-gray-600')
|
|
18
|
+
expect(f.input.value).toContain('bg-white')
|
|
19
|
+
expect(f.input.value).toContain('focus:ring-emerald-500')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('returns the dark-mode strings when isDark is true', () => {
|
|
23
|
+
const { isDark } = useTheme()
|
|
24
|
+
isDark.value = true
|
|
25
|
+
const f = useFieldClasses()
|
|
26
|
+
expect(f.label.value).toBe('text-gray-300')
|
|
27
|
+
expect(f.input.value).toContain('bg-gray-800')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('requiredInput flags empty values with the red skin', () => {
|
|
31
|
+
const { isDark } = useTheme()
|
|
32
|
+
isDark.value = false
|
|
33
|
+
const f = useFieldClasses()
|
|
34
|
+
expect(f.requiredInput('')).toContain('border-red-400')
|
|
35
|
+
expect(f.requiredInput(' ')).toContain('border-red-400')
|
|
36
|
+
expect(f.requiredInput(undefined)).toContain('border-red-400')
|
|
37
|
+
expect(f.requiredInput(null)).toContain('border-red-400')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('requiredInput falls back to the standard skin for filled values', () => {
|
|
41
|
+
const { isDark } = useTheme()
|
|
42
|
+
isDark.value = false
|
|
43
|
+
const f = useFieldClasses()
|
|
44
|
+
expect(f.requiredInput('hello')).toBe(f.input.value)
|
|
45
|
+
expect(f.requiredInput(0)).toBe(f.input.value) // 0 is a real value, not empty
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('requiredInput follows the active theme', () => {
|
|
49
|
+
const { isDark } = useTheme()
|
|
50
|
+
isDark.value = true
|
|
51
|
+
const f = useFieldClasses()
|
|
52
|
+
expect(f.requiredInput('')).toContain('bg-gray-800')
|
|
53
|
+
expect(f.requiredInput('')).toContain('border-red-500')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -1,56 +1,56 @@
|
|
|
1
|
-
import { computed, type ComputedRef } from 'vue'
|
|
2
|
-
import { useTheme } from './useTheme'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Shared form-field class strings for the gray/emerald form skin
|
|
6
|
-
* (the WireMate mock-form family). Extracted because the exact same
|
|
7
|
-
* `labelClasses` / `inputClasses` / `requiredInputClasses` trio was
|
|
8
|
-
* copy-pasted across every form section component.
|
|
9
|
-
*
|
|
10
|
-
* Usage:
|
|
11
|
-
*
|
|
12
|
-
* const f = useFieldClasses()
|
|
13
|
-
* <label :class="f.label">Name</label>
|
|
14
|
-
* <input :class="f.input" />
|
|
15
|
-
* <input :class="f.requiredInput(value)" /> // red skin while empty
|
|
16
|
-
*
|
|
17
|
-
* Vue auto-unwraps the computed refs in template bindings; `requiredInput`
|
|
18
|
-
* is a plain function and is called with the current field value.
|
|
19
|
-
*/
|
|
20
|
-
export interface FieldClasses {
|
|
21
|
-
/** Form label: gray-300 / gray-600. */
|
|
22
|
-
label: ComputedRef<string>
|
|
23
|
-
/** Standard input skin: gray-800 dark surface / white light surface, emerald focus. */
|
|
24
|
-
input: ComputedRef<string>
|
|
25
|
-
/**
|
|
26
|
-
* Input skin for required fields: red border + ring while the value is
|
|
27
|
-
* empty (undefined / null / blank after trim), the standard skin otherwise.
|
|
28
|
-
*/
|
|
29
|
-
requiredInput: (value: string | number | undefined | null) => string
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function useFieldClasses(): FieldClasses {
|
|
33
|
-
const { isDark } = useTheme()
|
|
34
|
-
|
|
35
|
-
const label = computed(() =>
|
|
36
|
-
isDark.value ? 'text-gray-300' : 'text-gray-600',
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
const input = computed(() =>
|
|
40
|
-
isDark.value
|
|
41
|
-
? 'bg-gray-800 border-gray-600 text-gray-100 placeholder-gray-500 focus:ring-emerald-500 focus:border-emerald-500'
|
|
42
|
-
: 'bg-white border-gray-300 text-gray-800 focus:ring-emerald-500 focus:border-emerald-500',
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
function requiredInput(value: string | number | undefined | null): string {
|
|
46
|
-
const isEmpty = value === undefined || value === null || String(value).trim() === ''
|
|
47
|
-
if (isEmpty) {
|
|
48
|
-
return isDark.value
|
|
49
|
-
? 'bg-gray-800 border-red-500 text-gray-100 placeholder-gray-500 focus:ring-red-500 focus:border-red-500 ring-1 ring-red-500/30'
|
|
50
|
-
: 'bg-white border-red-400 text-gray-800 focus:ring-red-500 focus:border-red-500 ring-1 ring-red-400/30'
|
|
51
|
-
}
|
|
52
|
-
return input.value
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
return { label, input, requiredInput }
|
|
56
|
-
}
|
|
1
|
+
import { computed, type ComputedRef } from 'vue'
|
|
2
|
+
import { useTheme } from './useTheme'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared form-field class strings for the gray/emerald form skin
|
|
6
|
+
* (the WireMate mock-form family). Extracted because the exact same
|
|
7
|
+
* `labelClasses` / `inputClasses` / `requiredInputClasses` trio was
|
|
8
|
+
* copy-pasted across every form section component.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
*
|
|
12
|
+
* const f = useFieldClasses()
|
|
13
|
+
* <label :class="f.label">Name</label>
|
|
14
|
+
* <input :class="f.input" />
|
|
15
|
+
* <input :class="f.requiredInput(value)" /> // red skin while empty
|
|
16
|
+
*
|
|
17
|
+
* Vue auto-unwraps the computed refs in template bindings; `requiredInput`
|
|
18
|
+
* is a plain function and is called with the current field value.
|
|
19
|
+
*/
|
|
20
|
+
export interface FieldClasses {
|
|
21
|
+
/** Form label: gray-300 / gray-600. */
|
|
22
|
+
label: ComputedRef<string>
|
|
23
|
+
/** Standard input skin: gray-800 dark surface / white light surface, emerald focus. */
|
|
24
|
+
input: ComputedRef<string>
|
|
25
|
+
/**
|
|
26
|
+
* Input skin for required fields: red border + ring while the value is
|
|
27
|
+
* empty (undefined / null / blank after trim), the standard skin otherwise.
|
|
28
|
+
*/
|
|
29
|
+
requiredInput: (value: string | number | undefined | null) => string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function useFieldClasses(): FieldClasses {
|
|
33
|
+
const { isDark } = useTheme()
|
|
34
|
+
|
|
35
|
+
const label = computed(() =>
|
|
36
|
+
isDark.value ? 'text-gray-300' : 'text-gray-600',
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const input = computed(() =>
|
|
40
|
+
isDark.value
|
|
41
|
+
? 'bg-gray-800 border-gray-600 text-gray-100 placeholder-gray-500 focus:ring-emerald-500 focus:border-emerald-500'
|
|
42
|
+
: 'bg-white border-gray-300 text-gray-800 focus:ring-emerald-500 focus:border-emerald-500',
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
function requiredInput(value: string | number | undefined | null): string {
|
|
46
|
+
const isEmpty = value === undefined || value === null || String(value).trim() === ''
|
|
47
|
+
if (isEmpty) {
|
|
48
|
+
return isDark.value
|
|
49
|
+
? 'bg-gray-800 border-red-500 text-gray-100 placeholder-gray-500 focus:ring-red-500 focus:border-red-500 ring-1 ring-red-500/30'
|
|
50
|
+
: 'bg-white border-red-400 text-gray-800 focus:ring-red-500 focus:border-red-500 ring-1 ring-red-400/30'
|
|
51
|
+
}
|
|
52
|
+
return input.value
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { label, input, requiredInput }
|
|
56
|
+
}
|
|
@@ -1,112 +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
|
-
})
|
|
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
|
+
})
|
|
@@ -1,102 +1,102 @@
|
|
|
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
|
-
// True only when the visibility handler paused an ACTIVE loop. A loop the
|
|
39
|
-
// caller stopped explicitly (stop(), e.g. an auto-refresh toggle) must stay
|
|
40
|
-
// stopped across hide/show cycles instead of being silently restarted.
|
|
41
|
-
let suspendedByVisibility = false
|
|
42
|
-
|
|
43
|
-
function startTimer() {
|
|
44
|
-
if (timer !== null || intervalMs === null) return
|
|
45
|
-
timer = setInterval(() => void fn(), intervalMs)
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function stopTimer() {
|
|
49
|
-
if (timer !== null) {
|
|
50
|
-
clearInterval(timer)
|
|
51
|
-
timer = null
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function start() {
|
|
56
|
-
suspendedByVisibility = false
|
|
57
|
-
if (active.value) return
|
|
58
|
-
active.value = true
|
|
59
|
-
if (immediate) void fn()
|
|
60
|
-
startTimer()
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function stop() {
|
|
64
|
-
suspendedByVisibility = false
|
|
65
|
-
active.value = false
|
|
66
|
-
stopTimer()
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function onVisibilityChange() {
|
|
70
|
-
if (document.visibilityState === 'visible') {
|
|
71
|
-
// Only resume a loop THIS handler paused — a caller-stopped loop
|
|
72
|
-
// stays stopped. Refresh right away so stale data doesn't linger
|
|
73
|
-
// for a full interval after the user returns, then re-arm the timer.
|
|
74
|
-
if (!suspendedByVisibility) return
|
|
75
|
-
suspendedByVisibility = false
|
|
76
|
-
active.value = true
|
|
77
|
-
void fn()
|
|
78
|
-
startTimer()
|
|
79
|
-
} else {
|
|
80
|
-
if (!active.value) return
|
|
81
|
-
active.value = false
|
|
82
|
-
stopTimer()
|
|
83
|
-
suspendedByVisibility = true
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
onMounted(() => {
|
|
88
|
-
start()
|
|
89
|
-
if (pauseWhenHidden) {
|
|
90
|
-
document.addEventListener('visibilitychange', onVisibilityChange)
|
|
91
|
-
}
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
onUnmounted(() => {
|
|
95
|
-
stop()
|
|
96
|
-
if (pauseWhenHidden) {
|
|
97
|
-
document.removeEventListener('visibilitychange', onVisibilityChange)
|
|
98
|
-
}
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
return { start, stop, active: readonly(active) }
|
|
102
|
-
}
|
|
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
|
+
// True only when the visibility handler paused an ACTIVE loop. A loop the
|
|
39
|
+
// caller stopped explicitly (stop(), e.g. an auto-refresh toggle) must stay
|
|
40
|
+
// stopped across hide/show cycles instead of being silently restarted.
|
|
41
|
+
let suspendedByVisibility = false
|
|
42
|
+
|
|
43
|
+
function startTimer() {
|
|
44
|
+
if (timer !== null || intervalMs === null) return
|
|
45
|
+
timer = setInterval(() => void fn(), intervalMs)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function stopTimer() {
|
|
49
|
+
if (timer !== null) {
|
|
50
|
+
clearInterval(timer)
|
|
51
|
+
timer = null
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function start() {
|
|
56
|
+
suspendedByVisibility = false
|
|
57
|
+
if (active.value) return
|
|
58
|
+
active.value = true
|
|
59
|
+
if (immediate) void fn()
|
|
60
|
+
startTimer()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function stop() {
|
|
64
|
+
suspendedByVisibility = false
|
|
65
|
+
active.value = false
|
|
66
|
+
stopTimer()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function onVisibilityChange() {
|
|
70
|
+
if (document.visibilityState === 'visible') {
|
|
71
|
+
// Only resume a loop THIS handler paused — a caller-stopped loop
|
|
72
|
+
// stays stopped. Refresh right away so stale data doesn't linger
|
|
73
|
+
// for a full interval after the user returns, then re-arm the timer.
|
|
74
|
+
if (!suspendedByVisibility) return
|
|
75
|
+
suspendedByVisibility = false
|
|
76
|
+
active.value = true
|
|
77
|
+
void fn()
|
|
78
|
+
startTimer()
|
|
79
|
+
} else {
|
|
80
|
+
if (!active.value) return
|
|
81
|
+
active.value = false
|
|
82
|
+
stopTimer()
|
|
83
|
+
suspendedByVisibility = true
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
onMounted(() => {
|
|
88
|
+
start()
|
|
89
|
+
if (pauseWhenHidden) {
|
|
90
|
+
document.addEventListener('visibilitychange', onVisibilityChange)
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
onUnmounted(() => {
|
|
95
|
+
stop()
|
|
96
|
+
if (pauseWhenHidden) {
|
|
97
|
+
document.removeEventListener('visibilitychange', onVisibilityChange)
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
return { start, stop, active: readonly(active) }
|
|
102
|
+
}
|