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,114 +1,114 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<BaseModalShell
|
|
3
|
-
:title="title"
|
|
4
|
-
:icon="ExclamationTriangleIcon"
|
|
5
|
-
:icon-bg-class="variant === 'danger'
|
|
6
|
-
? isDark ? 'bg-red-900/30' : 'bg-red-50'
|
|
7
|
-
: isDark ? 'bg-amber-900/30' : 'bg-amber-50'"
|
|
8
|
-
:icon-class="variant === 'danger' ? 'text-red-600' : 'text-amber-600'"
|
|
9
|
-
:manual-close="submitting"
|
|
10
|
-
@cancel="handleCancel"
|
|
11
|
-
>
|
|
12
|
-
<!--
|
|
13
|
-
Default renders the plain `message` string. Callers that need rich
|
|
14
|
-
text (bold names, inline code, etc.) can override the `message` slot
|
|
15
|
-
and render their own markup — the `message` prop is still used as the
|
|
16
|
-
accessible/fallback text and by callers that don't override.
|
|
17
|
-
-->
|
|
18
|
-
<slot name="message">
|
|
19
|
-
<p class="text-sm" :class="isDark ? 'text-gray-300' : 'text-gray-600'">
|
|
20
|
-
{{ message }}
|
|
21
|
-
</p>
|
|
22
|
-
</slot>
|
|
23
|
-
|
|
24
|
-
<!--
|
|
25
|
-
Optional extra content rendered below the message — e.g. a warning
|
|
26
|
-
banner or an opt-in checkbox the caller wants the user to see before
|
|
27
|
-
confirming. Empty for the common confirm/cancel case.
|
|
28
|
-
-->
|
|
29
|
-
<slot />
|
|
30
|
-
|
|
31
|
-
<template #footer>
|
|
32
|
-
<button
|
|
33
|
-
type="button"
|
|
34
|
-
:disabled="submitting"
|
|
35
|
-
class="inline-flex items-center justify-center px-4 py-2 border rounded-md shadow-sm text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
|
|
36
|
-
:class="[
|
|
37
|
-
t.ghostButton,
|
|
38
|
-
submitting ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
|
|
39
|
-
]"
|
|
40
|
-
@click="handleCancel"
|
|
41
|
-
>
|
|
42
|
-
{{ cancelText }}
|
|
43
|
-
</button>
|
|
44
|
-
|
|
45
|
-
<button
|
|
46
|
-
type="button"
|
|
47
|
-
:disabled="submitting"
|
|
48
|
-
class="inline-flex items-center justify-center gap-2 px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
49
|
-
:class="[
|
|
50
|
-
variant === 'danger' ? 'focus-visible:ring-red-500' : 'focus-visible:ring-emerald-500',
|
|
51
|
-
variant === 'danger'
|
|
52
|
-
? submitting ? 'bg-red-400 cursor-not-allowed' : 'bg-red-600 hover:bg-red-700 cursor-pointer'
|
|
53
|
-
: submitting ? 'bg-emerald-400 cursor-not-allowed' : 'bg-emerald-600 hover:bg-emerald-700 cursor-pointer',
|
|
54
|
-
]"
|
|
55
|
-
@click="handleConfirm"
|
|
56
|
-
>
|
|
57
|
-
<svg
|
|
58
|
-
v-if="submitting"
|
|
59
|
-
class="w-4 h-4 animate-spin"
|
|
60
|
-
fill="none"
|
|
61
|
-
viewBox="0 0 24 24"
|
|
62
|
-
aria-hidden="true"
|
|
63
|
-
>
|
|
64
|
-
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
|
65
|
-
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
|
66
|
-
</svg>
|
|
67
|
-
{{ submitting ? submittingText : confirmText }}
|
|
68
|
-
</button>
|
|
69
|
-
</template>
|
|
70
|
-
</BaseModalShell>
|
|
71
|
-
</template>
|
|
72
|
-
|
|
73
|
-
<script setup lang="ts">
|
|
74
|
-
import { ExclamationTriangleIcon } from '@heroicons/vue/24/outline'
|
|
75
|
-
import BaseModalShell from './BaseModalShell.vue'
|
|
76
|
-
import { useTheme } from '../composables/useTheme'
|
|
77
|
-
import { useThemeClasses } from '../composables/useThemeClasses'
|
|
78
|
-
|
|
79
|
-
interface Props {
|
|
80
|
-
title: string
|
|
81
|
-
message: string
|
|
82
|
-
confirmText?: string
|
|
83
|
-
cancelText?: string
|
|
84
|
-
submittingText?: string
|
|
85
|
-
variant?: 'danger' | 'warning'
|
|
86
|
-
submitting?: boolean
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const props = withDefaults(defineProps<Props>(), {
|
|
90
|
-
confirmText: "Yes, I'm sure",
|
|
91
|
-
cancelText: 'No, cancel',
|
|
92
|
-
submittingText: 'Working...',
|
|
93
|
-
variant: 'danger',
|
|
94
|
-
submitting: false,
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
const emit = defineEmits<{
|
|
98
|
-
confirm: []
|
|
99
|
-
cancel: []
|
|
100
|
-
}>()
|
|
101
|
-
|
|
102
|
-
const { isDark } = useTheme()
|
|
103
|
-
const t = useThemeClasses()
|
|
104
|
-
|
|
105
|
-
function handleConfirm() {
|
|
106
|
-
if (props.submitting) return
|
|
107
|
-
emit('confirm')
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function handleCancel() {
|
|
111
|
-
if (props.submitting) return
|
|
112
|
-
emit('cancel')
|
|
113
|
-
}
|
|
114
|
-
</script>
|
|
1
|
+
<template>
|
|
2
|
+
<BaseModalShell
|
|
3
|
+
:title="title"
|
|
4
|
+
:icon="ExclamationTriangleIcon"
|
|
5
|
+
:icon-bg-class="variant === 'danger'
|
|
6
|
+
? isDark ? 'bg-red-900/30' : 'bg-red-50'
|
|
7
|
+
: isDark ? 'bg-amber-900/30' : 'bg-amber-50'"
|
|
8
|
+
:icon-class="variant === 'danger' ? 'text-red-600' : 'text-amber-600'"
|
|
9
|
+
:manual-close="submitting"
|
|
10
|
+
@cancel="handleCancel"
|
|
11
|
+
>
|
|
12
|
+
<!--
|
|
13
|
+
Default renders the plain `message` string. Callers that need rich
|
|
14
|
+
text (bold names, inline code, etc.) can override the `message` slot
|
|
15
|
+
and render their own markup — the `message` prop is still used as the
|
|
16
|
+
accessible/fallback text and by callers that don't override.
|
|
17
|
+
-->
|
|
18
|
+
<slot name="message">
|
|
19
|
+
<p class="text-sm" :class="isDark ? 'text-gray-300' : 'text-gray-600'">
|
|
20
|
+
{{ message }}
|
|
21
|
+
</p>
|
|
22
|
+
</slot>
|
|
23
|
+
|
|
24
|
+
<!--
|
|
25
|
+
Optional extra content rendered below the message — e.g. a warning
|
|
26
|
+
banner or an opt-in checkbox the caller wants the user to see before
|
|
27
|
+
confirming. Empty for the common confirm/cancel case.
|
|
28
|
+
-->
|
|
29
|
+
<slot />
|
|
30
|
+
|
|
31
|
+
<template #footer>
|
|
32
|
+
<button
|
|
33
|
+
type="button"
|
|
34
|
+
:disabled="submitting"
|
|
35
|
+
class="inline-flex items-center justify-center px-4 py-2 border rounded-md shadow-sm text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
|
|
36
|
+
:class="[
|
|
37
|
+
t.ghostButton,
|
|
38
|
+
submitting ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
|
|
39
|
+
]"
|
|
40
|
+
@click="handleCancel"
|
|
41
|
+
>
|
|
42
|
+
{{ cancelText }}
|
|
43
|
+
</button>
|
|
44
|
+
|
|
45
|
+
<button
|
|
46
|
+
type="button"
|
|
47
|
+
:disabled="submitting"
|
|
48
|
+
class="inline-flex items-center justify-center gap-2 px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
49
|
+
:class="[
|
|
50
|
+
variant === 'danger' ? 'focus-visible:ring-red-500' : 'focus-visible:ring-emerald-500',
|
|
51
|
+
variant === 'danger'
|
|
52
|
+
? submitting ? 'bg-red-400 cursor-not-allowed' : 'bg-red-600 hover:bg-red-700 cursor-pointer'
|
|
53
|
+
: submitting ? 'bg-emerald-400 cursor-not-allowed' : 'bg-emerald-600 hover:bg-emerald-700 cursor-pointer',
|
|
54
|
+
]"
|
|
55
|
+
@click="handleConfirm"
|
|
56
|
+
>
|
|
57
|
+
<svg
|
|
58
|
+
v-if="submitting"
|
|
59
|
+
class="w-4 h-4 animate-spin"
|
|
60
|
+
fill="none"
|
|
61
|
+
viewBox="0 0 24 24"
|
|
62
|
+
aria-hidden="true"
|
|
63
|
+
>
|
|
64
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
|
65
|
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
|
66
|
+
</svg>
|
|
67
|
+
{{ submitting ? submittingText : confirmText }}
|
|
68
|
+
</button>
|
|
69
|
+
</template>
|
|
70
|
+
</BaseModalShell>
|
|
71
|
+
</template>
|
|
72
|
+
|
|
73
|
+
<script setup lang="ts">
|
|
74
|
+
import { ExclamationTriangleIcon } from '@heroicons/vue/24/outline'
|
|
75
|
+
import BaseModalShell from './BaseModalShell.vue'
|
|
76
|
+
import { useTheme } from '../composables/useTheme'
|
|
77
|
+
import { useThemeClasses } from '../composables/useThemeClasses'
|
|
78
|
+
|
|
79
|
+
interface Props {
|
|
80
|
+
title: string
|
|
81
|
+
message: string
|
|
82
|
+
confirmText?: string
|
|
83
|
+
cancelText?: string
|
|
84
|
+
submittingText?: string
|
|
85
|
+
variant?: 'danger' | 'warning'
|
|
86
|
+
submitting?: boolean
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
90
|
+
confirmText: "Yes, I'm sure",
|
|
91
|
+
cancelText: 'No, cancel',
|
|
92
|
+
submittingText: 'Working...',
|
|
93
|
+
variant: 'danger',
|
|
94
|
+
submitting: false,
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const emit = defineEmits<{
|
|
98
|
+
confirm: []
|
|
99
|
+
cancel: []
|
|
100
|
+
}>()
|
|
101
|
+
|
|
102
|
+
const { isDark } = useTheme()
|
|
103
|
+
const t = useThemeClasses()
|
|
104
|
+
|
|
105
|
+
function handleConfirm() {
|
|
106
|
+
if (props.submitting) return
|
|
107
|
+
emit('confirm')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function handleCancel() {
|
|
111
|
+
if (props.submitting) return
|
|
112
|
+
emit('cancel')
|
|
113
|
+
}
|
|
114
|
+
</script>
|
|
@@ -1,108 +1,108 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
-
import { flushPromises, mount } from '@vue/test-utils'
|
|
3
|
-
import BaseCredentialsForm from './BaseCredentialsForm.vue'
|
|
4
|
-
import type { CredentialsView } from './BaseCredentialsForm.vue'
|
|
5
|
-
|
|
6
|
-
const view = (over: Partial<CredentialsView> = {}): CredentialsView => ({
|
|
7
|
-
keyId: 'PK123',
|
|
8
|
-
baseUrl: 'https://api.example.com',
|
|
9
|
-
dataUrl: 'https://data.example.com',
|
|
10
|
-
hasSecret: true,
|
|
11
|
-
...over,
|
|
12
|
-
})
|
|
13
|
-
|
|
14
|
-
const defaults = { baseUrl: 'https://default.base', dataUrl: 'https://default.data' }
|
|
15
|
-
|
|
16
|
-
function mountCard(fetchFn = vi.fn().mockResolvedValue(view()), updateFn = vi.fn()) {
|
|
17
|
-
const wrapper = mount(BaseCredentialsForm, {
|
|
18
|
-
props: { title: 'Alpaca API', idPrefix: 'alpaca', fetchFn, updateFn, defaults },
|
|
19
|
-
})
|
|
20
|
-
return { wrapper, fetchFn, updateFn }
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
beforeEach(() => {
|
|
24
|
-
localStorage.clear()
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
describe('BaseCredentialsForm', () => {
|
|
28
|
-
it('loads on mount and fills the form from the fetched view', async () => {
|
|
29
|
-
const { wrapper, fetchFn } = mountCard()
|
|
30
|
-
await flushPromises()
|
|
31
|
-
expect(fetchFn).toHaveBeenCalledTimes(1)
|
|
32
|
-
expect((wrapper.get('#alpaca-key-id').element as HTMLInputElement).value).toBe('PK123')
|
|
33
|
-
expect((wrapper.get('#alpaca-base-url').element as HTMLInputElement).value).toBe(
|
|
34
|
-
'https://api.example.com',
|
|
35
|
-
)
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
it('falls back to the defaults when the server has no URLs stored', async () => {
|
|
39
|
-
const { wrapper } = mountCard(
|
|
40
|
-
vi.fn().mockResolvedValue(view({ keyId: null, baseUrl: '', dataUrl: '', hasSecret: false })),
|
|
41
|
-
)
|
|
42
|
-
await flushPromises()
|
|
43
|
-
expect((wrapper.get('#alpaca-base-url').element as HTMLInputElement).value).toBe(
|
|
44
|
-
defaults.baseUrl,
|
|
45
|
-
)
|
|
46
|
-
expect((wrapper.get('#alpaca-data-url').element as HTMLInputElement).value).toBe(
|
|
47
|
-
defaults.dataUrl,
|
|
48
|
-
)
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
it('emits load-error when the fetch rejects', async () => {
|
|
52
|
-
const { wrapper } = mountCard(vi.fn().mockRejectedValue(new Error('boom')))
|
|
53
|
-
await flushPromises()
|
|
54
|
-
expect(wrapper.emitted('load-error')).toEqual([['boom']])
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
it('refuses to save without a key and emits the configured message', async () => {
|
|
58
|
-
const { wrapper, updateFn } = mountCard(
|
|
59
|
-
vi.fn().mockResolvedValue(view({ keyId: null, hasSecret: false })),
|
|
60
|
-
)
|
|
61
|
-
await flushPromises()
|
|
62
|
-
await wrapper.get('button').trigger('click')
|
|
63
|
-
expect(updateFn).not.toHaveBeenCalled()
|
|
64
|
-
expect(wrapper.emitted('error')?.[0]).toEqual(['API key is required.'])
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
it('requires a secret only while none is stored', async () => {
|
|
68
|
-
const { wrapper, updateFn } = mountCard(
|
|
69
|
-
vi.fn().mockResolvedValue(view({ hasSecret: false })),
|
|
70
|
-
)
|
|
71
|
-
await flushPromises()
|
|
72
|
-
await wrapper.get('button').trigger('click')
|
|
73
|
-
expect(updateFn).not.toHaveBeenCalled()
|
|
74
|
-
expect(wrapper.emitted('error')?.[0]).toEqual(['Secret key is required.'])
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
it('saves trimmed values, omits a blank secret, and emits saved', async () => {
|
|
78
|
-
const updateFn = vi.fn().mockResolvedValue(view())
|
|
79
|
-
const { wrapper } = mountCard(vi.fn().mockResolvedValue(view()), updateFn)
|
|
80
|
-
await flushPromises()
|
|
81
|
-
await wrapper.get('#alpaca-key-id').setValue(' PK999 ')
|
|
82
|
-
await wrapper.get('button').trigger('click')
|
|
83
|
-
await flushPromises()
|
|
84
|
-
expect(updateFn).toHaveBeenCalledWith({
|
|
85
|
-
keyId: 'PK999',
|
|
86
|
-
secretKey: undefined,
|
|
87
|
-
baseUrl: 'https://api.example.com',
|
|
88
|
-
dataUrl: 'https://data.example.com',
|
|
89
|
-
})
|
|
90
|
-
expect(wrapper.emitted('saved')).toEqual([['Credentials saved.']])
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
it('emits error with the server message when the update rejects', async () => {
|
|
94
|
-
const updateFn = vi.fn().mockRejectedValue(new Error('validation failed'))
|
|
95
|
-
const { wrapper } = mountCard(vi.fn().mockResolvedValue(view()), updateFn)
|
|
96
|
-
await flushPromises()
|
|
97
|
-
await wrapper.get('button').trigger('click')
|
|
98
|
-
await flushPromises()
|
|
99
|
-
expect(wrapper.emitted('error')?.[0]).toEqual(['validation failed'])
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
it('exposes load() so a parent Reload can re-pull the card', async () => {
|
|
103
|
-
const { wrapper, fetchFn } = mountCard()
|
|
104
|
-
await flushPromises()
|
|
105
|
-
await (wrapper.vm as unknown as { load: () => Promise<void> }).load()
|
|
106
|
-
expect(fetchFn).toHaveBeenCalledTimes(2)
|
|
107
|
-
})
|
|
108
|
-
})
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { flushPromises, mount } from '@vue/test-utils'
|
|
3
|
+
import BaseCredentialsForm from './BaseCredentialsForm.vue'
|
|
4
|
+
import type { CredentialsView } from './BaseCredentialsForm.vue'
|
|
5
|
+
|
|
6
|
+
const view = (over: Partial<CredentialsView> = {}): CredentialsView => ({
|
|
7
|
+
keyId: 'PK123',
|
|
8
|
+
baseUrl: 'https://api.example.com',
|
|
9
|
+
dataUrl: 'https://data.example.com',
|
|
10
|
+
hasSecret: true,
|
|
11
|
+
...over,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const defaults = { baseUrl: 'https://default.base', dataUrl: 'https://default.data' }
|
|
15
|
+
|
|
16
|
+
function mountCard(fetchFn = vi.fn().mockResolvedValue(view()), updateFn = vi.fn()) {
|
|
17
|
+
const wrapper = mount(BaseCredentialsForm, {
|
|
18
|
+
props: { title: 'Alpaca API', idPrefix: 'alpaca', fetchFn, updateFn, defaults },
|
|
19
|
+
})
|
|
20
|
+
return { wrapper, fetchFn, updateFn }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
localStorage.clear()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('BaseCredentialsForm', () => {
|
|
28
|
+
it('loads on mount and fills the form from the fetched view', async () => {
|
|
29
|
+
const { wrapper, fetchFn } = mountCard()
|
|
30
|
+
await flushPromises()
|
|
31
|
+
expect(fetchFn).toHaveBeenCalledTimes(1)
|
|
32
|
+
expect((wrapper.get('#alpaca-key-id').element as HTMLInputElement).value).toBe('PK123')
|
|
33
|
+
expect((wrapper.get('#alpaca-base-url').element as HTMLInputElement).value).toBe(
|
|
34
|
+
'https://api.example.com',
|
|
35
|
+
)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('falls back to the defaults when the server has no URLs stored', async () => {
|
|
39
|
+
const { wrapper } = mountCard(
|
|
40
|
+
vi.fn().mockResolvedValue(view({ keyId: null, baseUrl: '', dataUrl: '', hasSecret: false })),
|
|
41
|
+
)
|
|
42
|
+
await flushPromises()
|
|
43
|
+
expect((wrapper.get('#alpaca-base-url').element as HTMLInputElement).value).toBe(
|
|
44
|
+
defaults.baseUrl,
|
|
45
|
+
)
|
|
46
|
+
expect((wrapper.get('#alpaca-data-url').element as HTMLInputElement).value).toBe(
|
|
47
|
+
defaults.dataUrl,
|
|
48
|
+
)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('emits load-error when the fetch rejects', async () => {
|
|
52
|
+
const { wrapper } = mountCard(vi.fn().mockRejectedValue(new Error('boom')))
|
|
53
|
+
await flushPromises()
|
|
54
|
+
expect(wrapper.emitted('load-error')).toEqual([['boom']])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('refuses to save without a key and emits the configured message', async () => {
|
|
58
|
+
const { wrapper, updateFn } = mountCard(
|
|
59
|
+
vi.fn().mockResolvedValue(view({ keyId: null, hasSecret: false })),
|
|
60
|
+
)
|
|
61
|
+
await flushPromises()
|
|
62
|
+
await wrapper.get('button').trigger('click')
|
|
63
|
+
expect(updateFn).not.toHaveBeenCalled()
|
|
64
|
+
expect(wrapper.emitted('error')?.[0]).toEqual(['API key is required.'])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('requires a secret only while none is stored', async () => {
|
|
68
|
+
const { wrapper, updateFn } = mountCard(
|
|
69
|
+
vi.fn().mockResolvedValue(view({ hasSecret: false })),
|
|
70
|
+
)
|
|
71
|
+
await flushPromises()
|
|
72
|
+
await wrapper.get('button').trigger('click')
|
|
73
|
+
expect(updateFn).not.toHaveBeenCalled()
|
|
74
|
+
expect(wrapper.emitted('error')?.[0]).toEqual(['Secret key is required.'])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('saves trimmed values, omits a blank secret, and emits saved', async () => {
|
|
78
|
+
const updateFn = vi.fn().mockResolvedValue(view())
|
|
79
|
+
const { wrapper } = mountCard(vi.fn().mockResolvedValue(view()), updateFn)
|
|
80
|
+
await flushPromises()
|
|
81
|
+
await wrapper.get('#alpaca-key-id').setValue(' PK999 ')
|
|
82
|
+
await wrapper.get('button').trigger('click')
|
|
83
|
+
await flushPromises()
|
|
84
|
+
expect(updateFn).toHaveBeenCalledWith({
|
|
85
|
+
keyId: 'PK999',
|
|
86
|
+
secretKey: undefined,
|
|
87
|
+
baseUrl: 'https://api.example.com',
|
|
88
|
+
dataUrl: 'https://data.example.com',
|
|
89
|
+
})
|
|
90
|
+
expect(wrapper.emitted('saved')).toEqual([['Credentials saved.']])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('emits error with the server message when the update rejects', async () => {
|
|
94
|
+
const updateFn = vi.fn().mockRejectedValue(new Error('validation failed'))
|
|
95
|
+
const { wrapper } = mountCard(vi.fn().mockResolvedValue(view()), updateFn)
|
|
96
|
+
await flushPromises()
|
|
97
|
+
await wrapper.get('button').trigger('click')
|
|
98
|
+
await flushPromises()
|
|
99
|
+
expect(wrapper.emitted('error')?.[0]).toEqual(['validation failed'])
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('exposes load() so a parent Reload can re-pull the card', async () => {
|
|
103
|
+
const { wrapper, fetchFn } = mountCard()
|
|
104
|
+
await flushPromises()
|
|
105
|
+
await (wrapper.vm as unknown as { load: () => Promise<void> }).load()
|
|
106
|
+
expect(fetchFn).toHaveBeenCalledTimes(2)
|
|
107
|
+
})
|
|
108
|
+
})
|