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,241 +1,241 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import { onMounted, reactive, ref } from 'vue'
|
|
3
|
-
import BaseButton from './BaseButton.vue'
|
|
4
|
-
import BaseInput from './BaseInput.vue'
|
|
5
|
-
import BaseRow from './BaseRow.vue'
|
|
6
|
-
import BaseSpinner from './BaseSpinner.vue'
|
|
7
|
-
import { BaseButtonEnum } from '../enums/BaseButtonEnum'
|
|
8
|
-
|
|
9
|
-
// One service's API-credentials card (extracted from TradeAutomation's
|
|
10
|
-
// Settings page, where three venue blocks shared this exact shape). Owns the
|
|
11
|
-
// form state, the has-secret handling, the save-validation ladder and the
|
|
12
|
-
// template; parameterised by the service-specific bits (labels, copy,
|
|
13
|
-
// endpoints, default URLs). Load/save results are EMITTED — the parent owns
|
|
14
|
-
// toasts / error banners.
|
|
15
|
-
|
|
16
|
-
// The non-secret credentials view the service's config endpoint returns. The
|
|
17
|
-
// secret is never returned — `hasSecret` reports whether one is stored.
|
|
18
|
-
export interface CredentialsView {
|
|
19
|
-
keyId: string | null
|
|
20
|
-
baseUrl: string
|
|
21
|
-
dataUrl: string
|
|
22
|
-
hasSecret: boolean
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface CredentialsUpdate {
|
|
26
|
-
keyId: string
|
|
27
|
-
// Omitted when blank so the server keeps the currently-stored secret.
|
|
28
|
-
secretKey?: string
|
|
29
|
-
baseUrl: string
|
|
30
|
-
dataUrl: string
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const props = withDefaults(
|
|
34
|
-
defineProps<{
|
|
35
|
-
// Card heading, e.g. "Alpaca API"; subtitle renders under it when set.
|
|
36
|
-
title: string
|
|
37
|
-
subtitle?: string
|
|
38
|
-
// Unique prefix for the input ids so the label/for pairs stay unambiguous
|
|
39
|
-
// when several of these cards render on one page.
|
|
40
|
-
idPrefix: string
|
|
41
|
-
// Service-specific labels / placeholders / messages.
|
|
42
|
-
keyLabel?: string
|
|
43
|
-
keyPlaceholder?: string
|
|
44
|
-
secretLabel?: string
|
|
45
|
-
// Placeholder while NO secret is stored (a stored one shows the dots).
|
|
46
|
-
secretPlaceholder?: string
|
|
47
|
-
// Help line while a secret IS stored.
|
|
48
|
-
secretSetHint?: string
|
|
49
|
-
// Help line while no secret is stored (e.g. which API permissions the key
|
|
50
|
-
// needs). Override the `no-secret-hint` slot instead when markup is needed.
|
|
51
|
-
permissionsHint?: string
|
|
52
|
-
requiredKeyMessage?: string
|
|
53
|
-
requiredSecretMessage?: string
|
|
54
|
-
savedMessage?: string
|
|
55
|
-
saveLabel?: string
|
|
56
|
-
// Service endpoints + the URLs the form falls back to when the server has
|
|
57
|
-
// none stored yet.
|
|
58
|
-
fetchFn: () => Promise<CredentialsView>
|
|
59
|
-
updateFn: (body: CredentialsUpdate) => Promise<CredentialsView>
|
|
60
|
-
defaults: { baseUrl: string; dataUrl: string }
|
|
61
|
-
}>(),
|
|
62
|
-
{
|
|
63
|
-
subtitle: '',
|
|
64
|
-
keyLabel: 'API Key',
|
|
65
|
-
keyPlaceholder: '',
|
|
66
|
-
secretLabel: 'Secret Key',
|
|
67
|
-
secretPlaceholder: 'Enter secret key',
|
|
68
|
-
secretSetHint: 'A secret is currently set. Leave blank to keep it.',
|
|
69
|
-
permissionsHint: '',
|
|
70
|
-
requiredKeyMessage: 'API key is required.',
|
|
71
|
-
requiredSecretMessage: 'Secret key is required.',
|
|
72
|
-
savedMessage: 'Credentials saved.',
|
|
73
|
-
saveLabel: 'Save credentials',
|
|
74
|
-
},
|
|
75
|
-
)
|
|
76
|
-
|
|
77
|
-
const emit = defineEmits<{
|
|
78
|
-
// Save outcomes → parent toasts.
|
|
79
|
-
(e: 'saved', message: string): void
|
|
80
|
-
(e: 'error', message: string): void
|
|
81
|
-
// Initial/re-load failure. Payload is the raw error message ('' when there
|
|
82
|
-
// is none) — the parent decides how loudly to surface it.
|
|
83
|
-
(e: 'load-error', message: string): void
|
|
84
|
-
}>()
|
|
85
|
-
|
|
86
|
-
const saving = ref(false)
|
|
87
|
-
const hasSecret = ref(false)
|
|
88
|
-
|
|
89
|
-
const form = reactive({
|
|
90
|
-
keyId: '',
|
|
91
|
-
secretKey: '',
|
|
92
|
-
baseUrl: props.defaults.baseUrl,
|
|
93
|
-
dataUrl: props.defaults.dataUrl,
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
async function load() {
|
|
97
|
-
try {
|
|
98
|
-
const cfg = await props.fetchFn()
|
|
99
|
-
form.keyId = cfg.keyId ?? ''
|
|
100
|
-
form.baseUrl = cfg.baseUrl || props.defaults.baseUrl
|
|
101
|
-
form.dataUrl = cfg.dataUrl || props.defaults.dataUrl
|
|
102
|
-
form.secretKey = '' // never returned by the API
|
|
103
|
-
hasSecret.value = cfg.hasSecret
|
|
104
|
-
} catch (e: unknown) {
|
|
105
|
-
emit('load-error', e instanceof Error ? e.message : '')
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function isBlank(v: string): boolean {
|
|
110
|
-
return !v || !v.trim()
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
async function save() {
|
|
114
|
-
// Re-entrancy guard: a save may trigger a LIVE credential validation
|
|
115
|
-
// against the service, so a double-click must not fire two concurrent PUTs.
|
|
116
|
-
// Paired with the button's isDisable below — this is the belt to its braces.
|
|
117
|
-
if (saving.value) return
|
|
118
|
-
if (isBlank(form.keyId)) {
|
|
119
|
-
emit('error', props.requiredKeyMessage)
|
|
120
|
-
return
|
|
121
|
-
}
|
|
122
|
-
if (!hasSecret.value && isBlank(form.secretKey)) {
|
|
123
|
-
emit('error', props.requiredSecretMessage)
|
|
124
|
-
return
|
|
125
|
-
}
|
|
126
|
-
if (isBlank(form.baseUrl) || isBlank(form.dataUrl)) {
|
|
127
|
-
emit('error', 'Base URL and Data URL are required.')
|
|
128
|
-
return
|
|
129
|
-
}
|
|
130
|
-
saving.value = true
|
|
131
|
-
try {
|
|
132
|
-
const applied = await props.updateFn({
|
|
133
|
-
keyId: form.keyId.trim(),
|
|
134
|
-
// Omit when blank so the server keeps the stored secret.
|
|
135
|
-
secretKey: isBlank(form.secretKey) ? undefined : form.secretKey.trim(),
|
|
136
|
-
baseUrl: form.baseUrl.trim(),
|
|
137
|
-
dataUrl: form.dataUrl.trim(),
|
|
138
|
-
})
|
|
139
|
-
hasSecret.value = applied.hasSecret
|
|
140
|
-
form.secretKey = ''
|
|
141
|
-
emit('saved', props.savedMessage)
|
|
142
|
-
} catch (e: unknown) {
|
|
143
|
-
emit('error', e instanceof Error ? e.message : 'Failed to save')
|
|
144
|
-
} finally {
|
|
145
|
-
saving.value = false
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
onMounted(load)
|
|
150
|
-
|
|
151
|
-
// The parent's Reload button can re-pull every card in parallel via this.
|
|
152
|
-
defineExpose({ load })
|
|
153
|
-
|
|
154
|
-
// One-off styling on top of BaseInput's shared field skin.
|
|
155
|
-
const inputClass = 'placeholder:text-slate-400 dark:placeholder:text-slate-500 font-mono'
|
|
156
|
-
</script>
|
|
157
|
-
|
|
158
|
-
<template>
|
|
159
|
-
<BaseRow class="!p-6">
|
|
160
|
-
<div class="flex items-center justify-between mb-4">
|
|
161
|
-
<div>
|
|
162
|
-
<h2 class="text-lg font-semibold text-slate-900 dark:text-slate-100">{{ title }}</h2>
|
|
163
|
-
<p v-if="subtitle" class="text-xs text-slate-500 dark:text-slate-400">
|
|
164
|
-
{{ subtitle }}
|
|
165
|
-
</p>
|
|
166
|
-
</div>
|
|
167
|
-
<span v-if="saving" class="inline-flex items-center gap-2 text-xs text-slate-500">
|
|
168
|
-
<BaseSpinner class="!h-4 !w-4" />
|
|
169
|
-
Saving…
|
|
170
|
-
</span>
|
|
171
|
-
</div>
|
|
172
|
-
|
|
173
|
-
<div class="grid gap-4 md:grid-cols-2">
|
|
174
|
-
<div class="md:col-span-2">
|
|
175
|
-
<label
|
|
176
|
-
:for="`${idPrefix}-key-id`"
|
|
177
|
-
class="block text-xs uppercase tracking-wide text-slate-500 mb-1"
|
|
178
|
-
>{{ keyLabel }}</label>
|
|
179
|
-
<BaseInput
|
|
180
|
-
:id="`${idPrefix}-key-id`"
|
|
181
|
-
v-model="form.keyId"
|
|
182
|
-
type="text"
|
|
183
|
-
autocomplete="off"
|
|
184
|
-
:placeholder="keyPlaceholder"
|
|
185
|
-
:class="inputClass"
|
|
186
|
-
/>
|
|
187
|
-
</div>
|
|
188
|
-
|
|
189
|
-
<div class="md:col-span-2">
|
|
190
|
-
<label
|
|
191
|
-
:for="`${idPrefix}-secret`"
|
|
192
|
-
class="block text-xs uppercase tracking-wide text-slate-500 mb-1"
|
|
193
|
-
>{{ secretLabel }}</label>
|
|
194
|
-
<BaseInput
|
|
195
|
-
:id="`${idPrefix}-secret`"
|
|
196
|
-
v-model="form.secretKey"
|
|
197
|
-
type="password"
|
|
198
|
-
autocomplete="new-password"
|
|
199
|
-
:placeholder="hasSecret ? '•••••••• (unchanged — type to replace)' : secretPlaceholder"
|
|
200
|
-
:class="inputClass"
|
|
201
|
-
/>
|
|
202
|
-
<p class="text-[11px] text-slate-500 mt-1">
|
|
203
|
-
<template v-if="hasSecret">{{ secretSetHint }}</template>
|
|
204
|
-
<template v-else><slot name="no-secret-hint">{{ permissionsHint }}</slot></template>
|
|
205
|
-
</p>
|
|
206
|
-
</div>
|
|
207
|
-
|
|
208
|
-
<div class="md:col-span-2">
|
|
209
|
-
<label
|
|
210
|
-
:for="`${idPrefix}-base-url`"
|
|
211
|
-
class="block text-xs uppercase tracking-wide text-slate-500 mb-1"
|
|
212
|
-
>Base URL (trading)</label>
|
|
213
|
-
<BaseInput :id="`${idPrefix}-base-url`" v-model="form.baseUrl" type="text" :class="inputClass" />
|
|
214
|
-
<!-- Service-specific extras under the Base URL field (e.g. live/paper
|
|
215
|
-
shortcut buttons). Scoped so the parent can write into the form. -->
|
|
216
|
-
<slot name="base-url-extra" :form="form" />
|
|
217
|
-
</div>
|
|
218
|
-
|
|
219
|
-
<div class="md:col-span-2">
|
|
220
|
-
<label
|
|
221
|
-
:for="`${idPrefix}-data-url`"
|
|
222
|
-
class="block text-xs uppercase tracking-wide text-slate-500 mb-1"
|
|
223
|
-
>Data URL (market data)</label>
|
|
224
|
-
<BaseInput :id="`${idPrefix}-data-url`" v-model="form.dataUrl" type="text" :class="inputClass" />
|
|
225
|
-
</div>
|
|
226
|
-
|
|
227
|
-
<div class="md:col-span-2">
|
|
228
|
-
<BaseButton
|
|
229
|
-
:color="BaseButtonEnum.GREEN"
|
|
230
|
-
:description="saveLabel"
|
|
231
|
-
:isDisable="saving"
|
|
232
|
-
:isLoading="saving"
|
|
233
|
-
@click="save"
|
|
234
|
-
/>
|
|
235
|
-
</div>
|
|
236
|
-
</div>
|
|
237
|
-
|
|
238
|
-
<!-- Service-specific extras below the credentials form. -->
|
|
239
|
-
<slot name="footer" />
|
|
240
|
-
</BaseRow>
|
|
241
|
-
</template>
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onMounted, reactive, ref } from 'vue'
|
|
3
|
+
import BaseButton from './BaseButton.vue'
|
|
4
|
+
import BaseInput from './BaseInput.vue'
|
|
5
|
+
import BaseRow from './BaseRow.vue'
|
|
6
|
+
import BaseSpinner from './BaseSpinner.vue'
|
|
7
|
+
import { BaseButtonEnum } from '../enums/BaseButtonEnum'
|
|
8
|
+
|
|
9
|
+
// One service's API-credentials card (extracted from TradeAutomation's
|
|
10
|
+
// Settings page, where three venue blocks shared this exact shape). Owns the
|
|
11
|
+
// form state, the has-secret handling, the save-validation ladder and the
|
|
12
|
+
// template; parameterised by the service-specific bits (labels, copy,
|
|
13
|
+
// endpoints, default URLs). Load/save results are EMITTED — the parent owns
|
|
14
|
+
// toasts / error banners.
|
|
15
|
+
|
|
16
|
+
// The non-secret credentials view the service's config endpoint returns. The
|
|
17
|
+
// secret is never returned — `hasSecret` reports whether one is stored.
|
|
18
|
+
export interface CredentialsView {
|
|
19
|
+
keyId: string | null
|
|
20
|
+
baseUrl: string
|
|
21
|
+
dataUrl: string
|
|
22
|
+
hasSecret: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CredentialsUpdate {
|
|
26
|
+
keyId: string
|
|
27
|
+
// Omitted when blank so the server keeps the currently-stored secret.
|
|
28
|
+
secretKey?: string
|
|
29
|
+
baseUrl: string
|
|
30
|
+
dataUrl: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const props = withDefaults(
|
|
34
|
+
defineProps<{
|
|
35
|
+
// Card heading, e.g. "Alpaca API"; subtitle renders under it when set.
|
|
36
|
+
title: string
|
|
37
|
+
subtitle?: string
|
|
38
|
+
// Unique prefix for the input ids so the label/for pairs stay unambiguous
|
|
39
|
+
// when several of these cards render on one page.
|
|
40
|
+
idPrefix: string
|
|
41
|
+
// Service-specific labels / placeholders / messages.
|
|
42
|
+
keyLabel?: string
|
|
43
|
+
keyPlaceholder?: string
|
|
44
|
+
secretLabel?: string
|
|
45
|
+
// Placeholder while NO secret is stored (a stored one shows the dots).
|
|
46
|
+
secretPlaceholder?: string
|
|
47
|
+
// Help line while a secret IS stored.
|
|
48
|
+
secretSetHint?: string
|
|
49
|
+
// Help line while no secret is stored (e.g. which API permissions the key
|
|
50
|
+
// needs). Override the `no-secret-hint` slot instead when markup is needed.
|
|
51
|
+
permissionsHint?: string
|
|
52
|
+
requiredKeyMessage?: string
|
|
53
|
+
requiredSecretMessage?: string
|
|
54
|
+
savedMessage?: string
|
|
55
|
+
saveLabel?: string
|
|
56
|
+
// Service endpoints + the URLs the form falls back to when the server has
|
|
57
|
+
// none stored yet.
|
|
58
|
+
fetchFn: () => Promise<CredentialsView>
|
|
59
|
+
updateFn: (body: CredentialsUpdate) => Promise<CredentialsView>
|
|
60
|
+
defaults: { baseUrl: string; dataUrl: string }
|
|
61
|
+
}>(),
|
|
62
|
+
{
|
|
63
|
+
subtitle: '',
|
|
64
|
+
keyLabel: 'API Key',
|
|
65
|
+
keyPlaceholder: '',
|
|
66
|
+
secretLabel: 'Secret Key',
|
|
67
|
+
secretPlaceholder: 'Enter secret key',
|
|
68
|
+
secretSetHint: 'A secret is currently set. Leave blank to keep it.',
|
|
69
|
+
permissionsHint: '',
|
|
70
|
+
requiredKeyMessage: 'API key is required.',
|
|
71
|
+
requiredSecretMessage: 'Secret key is required.',
|
|
72
|
+
savedMessage: 'Credentials saved.',
|
|
73
|
+
saveLabel: 'Save credentials',
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
const emit = defineEmits<{
|
|
78
|
+
// Save outcomes → parent toasts.
|
|
79
|
+
(e: 'saved', message: string): void
|
|
80
|
+
(e: 'error', message: string): void
|
|
81
|
+
// Initial/re-load failure. Payload is the raw error message ('' when there
|
|
82
|
+
// is none) — the parent decides how loudly to surface it.
|
|
83
|
+
(e: 'load-error', message: string): void
|
|
84
|
+
}>()
|
|
85
|
+
|
|
86
|
+
const saving = ref(false)
|
|
87
|
+
const hasSecret = ref(false)
|
|
88
|
+
|
|
89
|
+
const form = reactive({
|
|
90
|
+
keyId: '',
|
|
91
|
+
secretKey: '',
|
|
92
|
+
baseUrl: props.defaults.baseUrl,
|
|
93
|
+
dataUrl: props.defaults.dataUrl,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
async function load() {
|
|
97
|
+
try {
|
|
98
|
+
const cfg = await props.fetchFn()
|
|
99
|
+
form.keyId = cfg.keyId ?? ''
|
|
100
|
+
form.baseUrl = cfg.baseUrl || props.defaults.baseUrl
|
|
101
|
+
form.dataUrl = cfg.dataUrl || props.defaults.dataUrl
|
|
102
|
+
form.secretKey = '' // never returned by the API
|
|
103
|
+
hasSecret.value = cfg.hasSecret
|
|
104
|
+
} catch (e: unknown) {
|
|
105
|
+
emit('load-error', e instanceof Error ? e.message : '')
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isBlank(v: string): boolean {
|
|
110
|
+
return !v || !v.trim()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function save() {
|
|
114
|
+
// Re-entrancy guard: a save may trigger a LIVE credential validation
|
|
115
|
+
// against the service, so a double-click must not fire two concurrent PUTs.
|
|
116
|
+
// Paired with the button's isDisable below — this is the belt to its braces.
|
|
117
|
+
if (saving.value) return
|
|
118
|
+
if (isBlank(form.keyId)) {
|
|
119
|
+
emit('error', props.requiredKeyMessage)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
if (!hasSecret.value && isBlank(form.secretKey)) {
|
|
123
|
+
emit('error', props.requiredSecretMessage)
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
if (isBlank(form.baseUrl) || isBlank(form.dataUrl)) {
|
|
127
|
+
emit('error', 'Base URL and Data URL are required.')
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
saving.value = true
|
|
131
|
+
try {
|
|
132
|
+
const applied = await props.updateFn({
|
|
133
|
+
keyId: form.keyId.trim(),
|
|
134
|
+
// Omit when blank so the server keeps the stored secret.
|
|
135
|
+
secretKey: isBlank(form.secretKey) ? undefined : form.secretKey.trim(),
|
|
136
|
+
baseUrl: form.baseUrl.trim(),
|
|
137
|
+
dataUrl: form.dataUrl.trim(),
|
|
138
|
+
})
|
|
139
|
+
hasSecret.value = applied.hasSecret
|
|
140
|
+
form.secretKey = ''
|
|
141
|
+
emit('saved', props.savedMessage)
|
|
142
|
+
} catch (e: unknown) {
|
|
143
|
+
emit('error', e instanceof Error ? e.message : 'Failed to save')
|
|
144
|
+
} finally {
|
|
145
|
+
saving.value = false
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
onMounted(load)
|
|
150
|
+
|
|
151
|
+
// The parent's Reload button can re-pull every card in parallel via this.
|
|
152
|
+
defineExpose({ load })
|
|
153
|
+
|
|
154
|
+
// One-off styling on top of BaseInput's shared field skin.
|
|
155
|
+
const inputClass = 'placeholder:text-slate-400 dark:placeholder:text-slate-500 font-mono'
|
|
156
|
+
</script>
|
|
157
|
+
|
|
158
|
+
<template>
|
|
159
|
+
<BaseRow class="!p-6">
|
|
160
|
+
<div class="flex items-center justify-between mb-4">
|
|
161
|
+
<div>
|
|
162
|
+
<h2 class="text-lg font-semibold text-slate-900 dark:text-slate-100">{{ title }}</h2>
|
|
163
|
+
<p v-if="subtitle" class="text-xs text-slate-500 dark:text-slate-400">
|
|
164
|
+
{{ subtitle }}
|
|
165
|
+
</p>
|
|
166
|
+
</div>
|
|
167
|
+
<span v-if="saving" class="inline-flex items-center gap-2 text-xs text-slate-500">
|
|
168
|
+
<BaseSpinner class="!h-4 !w-4" />
|
|
169
|
+
Saving…
|
|
170
|
+
</span>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<div class="grid gap-4 md:grid-cols-2">
|
|
174
|
+
<div class="md:col-span-2">
|
|
175
|
+
<label
|
|
176
|
+
:for="`${idPrefix}-key-id`"
|
|
177
|
+
class="block text-xs uppercase tracking-wide text-slate-500 mb-1"
|
|
178
|
+
>{{ keyLabel }}</label>
|
|
179
|
+
<BaseInput
|
|
180
|
+
:id="`${idPrefix}-key-id`"
|
|
181
|
+
v-model="form.keyId"
|
|
182
|
+
type="text"
|
|
183
|
+
autocomplete="off"
|
|
184
|
+
:placeholder="keyPlaceholder"
|
|
185
|
+
:class="inputClass"
|
|
186
|
+
/>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<div class="md:col-span-2">
|
|
190
|
+
<label
|
|
191
|
+
:for="`${idPrefix}-secret`"
|
|
192
|
+
class="block text-xs uppercase tracking-wide text-slate-500 mb-1"
|
|
193
|
+
>{{ secretLabel }}</label>
|
|
194
|
+
<BaseInput
|
|
195
|
+
:id="`${idPrefix}-secret`"
|
|
196
|
+
v-model="form.secretKey"
|
|
197
|
+
type="password"
|
|
198
|
+
autocomplete="new-password"
|
|
199
|
+
:placeholder="hasSecret ? '•••••••• (unchanged — type to replace)' : secretPlaceholder"
|
|
200
|
+
:class="inputClass"
|
|
201
|
+
/>
|
|
202
|
+
<p class="text-[11px] text-slate-500 mt-1">
|
|
203
|
+
<template v-if="hasSecret">{{ secretSetHint }}</template>
|
|
204
|
+
<template v-else><slot name="no-secret-hint">{{ permissionsHint }}</slot></template>
|
|
205
|
+
</p>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
<div class="md:col-span-2">
|
|
209
|
+
<label
|
|
210
|
+
:for="`${idPrefix}-base-url`"
|
|
211
|
+
class="block text-xs uppercase tracking-wide text-slate-500 mb-1"
|
|
212
|
+
>Base URL (trading)</label>
|
|
213
|
+
<BaseInput :id="`${idPrefix}-base-url`" v-model="form.baseUrl" type="text" :class="inputClass" />
|
|
214
|
+
<!-- Service-specific extras under the Base URL field (e.g. live/paper
|
|
215
|
+
shortcut buttons). Scoped so the parent can write into the form. -->
|
|
216
|
+
<slot name="base-url-extra" :form="form" />
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
<div class="md:col-span-2">
|
|
220
|
+
<label
|
|
221
|
+
:for="`${idPrefix}-data-url`"
|
|
222
|
+
class="block text-xs uppercase tracking-wide text-slate-500 mb-1"
|
|
223
|
+
>Data URL (market data)</label>
|
|
224
|
+
<BaseInput :id="`${idPrefix}-data-url`" v-model="form.dataUrl" type="text" :class="inputClass" />
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
<div class="md:col-span-2">
|
|
228
|
+
<BaseButton
|
|
229
|
+
:color="BaseButtonEnum.GREEN"
|
|
230
|
+
:description="saveLabel"
|
|
231
|
+
:isDisable="saving"
|
|
232
|
+
:isLoading="saving"
|
|
233
|
+
@click="save"
|
|
234
|
+
/>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
<!-- Service-specific extras below the credentials form. -->
|
|
239
|
+
<slot name="footer" />
|
|
240
|
+
</BaseRow>
|
|
241
|
+
</template>
|
|
@@ -1,87 +1,87 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { mount } from '@vue/test-utils'
|
|
3
|
-
import BaseDropdown from './BaseDropdown.vue'
|
|
4
|
-
import type { DropdownOption } from '../types/dropdown'
|
|
5
|
-
|
|
6
|
-
const options: DropdownOption[] = [
|
|
7
|
-
{ value: '1', label: 'Alice' },
|
|
8
|
-
{ value: '2', label: 'Bob', title: 'Bob the builder' },
|
|
9
|
-
{ value: '3', label: 'Carol', disabled: true },
|
|
10
|
-
]
|
|
11
|
-
|
|
12
|
-
describe('BaseDropdown', () => {
|
|
13
|
-
it('shows the placeholder when nothing is selected', () => {
|
|
14
|
-
const wrapper = mount(BaseDropdown, {
|
|
15
|
-
props: { options, modelValue: null, placeholder: 'Select Social User' },
|
|
16
|
-
})
|
|
17
|
-
const trigger = wrapper.get('button')
|
|
18
|
-
expect(trigger.text()).toContain('Select Social User')
|
|
19
|
-
expect(trigger.attributes('aria-expanded')).toBe('false')
|
|
20
|
-
// Menu is closed until the trigger is clicked.
|
|
21
|
-
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it('shows the selected option label instead of the placeholder', () => {
|
|
25
|
-
const wrapper = mount(BaseDropdown, {
|
|
26
|
-
props: { options, modelValue: '2', placeholder: 'Select Social User' },
|
|
27
|
-
})
|
|
28
|
-
expect(wrapper.get('button').text()).toContain('Bob')
|
|
29
|
-
expect(wrapper.get('button').text()).not.toContain('Select Social User')
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
it('opens the menu and renders one row per option on click', async () => {
|
|
33
|
-
const wrapper = mount(BaseDropdown, { props: { options, modelValue: null } })
|
|
34
|
-
await wrapper.get('button').trigger('click')
|
|
35
|
-
expect(wrapper.get('button').attributes('aria-expanded')).toBe('true')
|
|
36
|
-
const rows = wrapper.findAll('[role="option"]')
|
|
37
|
-
expect(rows.map((r) => r.text())).toEqual(['Alice', 'Bob', 'Carol'])
|
|
38
|
-
expect(rows[1].attributes('title')).toBe('Bob the builder')
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('emits update:modelValue and closes when a row is chosen', async () => {
|
|
42
|
-
const wrapper = mount(BaseDropdown, { props: { options, modelValue: null } })
|
|
43
|
-
await wrapper.get('button').trigger('click')
|
|
44
|
-
await wrapper.findAll('[role="option"] button')[1].trigger('click')
|
|
45
|
-
expect(wrapper.emitted('update:modelValue')).toEqual([['2']])
|
|
46
|
-
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
it('does not emit for a disabled row', async () => {
|
|
50
|
-
const wrapper = mount(BaseDropdown, { props: { options, modelValue: null } })
|
|
51
|
-
await wrapper.get('button').trigger('click')
|
|
52
|
-
await wrapper.findAll('[role="option"] button')[2].trigger('click')
|
|
53
|
-
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
it('marks the selected row with aria-selected and the active fill', async () => {
|
|
57
|
-
const wrapper = mount(BaseDropdown, { props: { options, modelValue: '1' } })
|
|
58
|
-
await wrapper.get('button').trigger('click')
|
|
59
|
-
const rows = wrapper.findAll('[role="option"]')
|
|
60
|
-
expect(rows[0].attributes('aria-selected')).toBe('true')
|
|
61
|
-
expect(rows[1].attributes('aria-selected')).toBe('false')
|
|
62
|
-
expect(rows[0].get('button').classes()).toContain('bg-emerald-500')
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
it('replaces the default trigger skin when triggerClass is set', () => {
|
|
66
|
-
const wrapper = mount(BaseDropdown, {
|
|
67
|
-
props: { options, modelValue: '1', triggerClass: 'rounded border bg-red-100 text-red-700 px-1.5 py-0.5' },
|
|
68
|
-
})
|
|
69
|
-
const classes = wrapper.get('button').classes()
|
|
70
|
-
// Custom skin applied…
|
|
71
|
-
expect(classes).toContain('bg-red-100')
|
|
72
|
-
expect(classes).toContain('text-red-700')
|
|
73
|
-
// …and the default slate skin dropped (layout/focus classes remain).
|
|
74
|
-
expect(classes).not.toContain('border-slate-300')
|
|
75
|
-
expect(classes).not.toContain('bg-white')
|
|
76
|
-
expect(classes).toContain('cursor-pointer')
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
it('sizes the chevron via chevronClass', () => {
|
|
80
|
-
const wrapper = mount(BaseDropdown, {
|
|
81
|
-
props: { options, modelValue: '1', chevronClass: 'w-3 h-3' },
|
|
82
|
-
})
|
|
83
|
-
const chevron = wrapper.get('button svg')
|
|
84
|
-
expect(chevron.classes()).toContain('w-3')
|
|
85
|
-
expect(chevron.classes()).not.toContain('w-5')
|
|
86
|
-
})
|
|
87
|
-
})
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BaseDropdown from './BaseDropdown.vue'
|
|
4
|
+
import type { DropdownOption } from '../types/dropdown'
|
|
5
|
+
|
|
6
|
+
const options: DropdownOption[] = [
|
|
7
|
+
{ value: '1', label: 'Alice' },
|
|
8
|
+
{ value: '2', label: 'Bob', title: 'Bob the builder' },
|
|
9
|
+
{ value: '3', label: 'Carol', disabled: true },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
describe('BaseDropdown', () => {
|
|
13
|
+
it('shows the placeholder when nothing is selected', () => {
|
|
14
|
+
const wrapper = mount(BaseDropdown, {
|
|
15
|
+
props: { options, modelValue: null, placeholder: 'Select Social User' },
|
|
16
|
+
})
|
|
17
|
+
const trigger = wrapper.get('button')
|
|
18
|
+
expect(trigger.text()).toContain('Select Social User')
|
|
19
|
+
expect(trigger.attributes('aria-expanded')).toBe('false')
|
|
20
|
+
// Menu is closed until the trigger is clicked.
|
|
21
|
+
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('shows the selected option label instead of the placeholder', () => {
|
|
25
|
+
const wrapper = mount(BaseDropdown, {
|
|
26
|
+
props: { options, modelValue: '2', placeholder: 'Select Social User' },
|
|
27
|
+
})
|
|
28
|
+
expect(wrapper.get('button').text()).toContain('Bob')
|
|
29
|
+
expect(wrapper.get('button').text()).not.toContain('Select Social User')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('opens the menu and renders one row per option on click', async () => {
|
|
33
|
+
const wrapper = mount(BaseDropdown, { props: { options, modelValue: null } })
|
|
34
|
+
await wrapper.get('button').trigger('click')
|
|
35
|
+
expect(wrapper.get('button').attributes('aria-expanded')).toBe('true')
|
|
36
|
+
const rows = wrapper.findAll('[role="option"]')
|
|
37
|
+
expect(rows.map((r) => r.text())).toEqual(['Alice', 'Bob', 'Carol'])
|
|
38
|
+
expect(rows[1].attributes('title')).toBe('Bob the builder')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('emits update:modelValue and closes when a row is chosen', async () => {
|
|
42
|
+
const wrapper = mount(BaseDropdown, { props: { options, modelValue: null } })
|
|
43
|
+
await wrapper.get('button').trigger('click')
|
|
44
|
+
await wrapper.findAll('[role="option"] button')[1].trigger('click')
|
|
45
|
+
expect(wrapper.emitted('update:modelValue')).toEqual([['2']])
|
|
46
|
+
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('does not emit for a disabled row', async () => {
|
|
50
|
+
const wrapper = mount(BaseDropdown, { props: { options, modelValue: null } })
|
|
51
|
+
await wrapper.get('button').trigger('click')
|
|
52
|
+
await wrapper.findAll('[role="option"] button')[2].trigger('click')
|
|
53
|
+
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('marks the selected row with aria-selected and the active fill', async () => {
|
|
57
|
+
const wrapper = mount(BaseDropdown, { props: { options, modelValue: '1' } })
|
|
58
|
+
await wrapper.get('button').trigger('click')
|
|
59
|
+
const rows = wrapper.findAll('[role="option"]')
|
|
60
|
+
expect(rows[0].attributes('aria-selected')).toBe('true')
|
|
61
|
+
expect(rows[1].attributes('aria-selected')).toBe('false')
|
|
62
|
+
expect(rows[0].get('button').classes()).toContain('bg-emerald-500')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('replaces the default trigger skin when triggerClass is set', () => {
|
|
66
|
+
const wrapper = mount(BaseDropdown, {
|
|
67
|
+
props: { options, modelValue: '1', triggerClass: 'rounded border bg-red-100 text-red-700 px-1.5 py-0.5' },
|
|
68
|
+
})
|
|
69
|
+
const classes = wrapper.get('button').classes()
|
|
70
|
+
// Custom skin applied…
|
|
71
|
+
expect(classes).toContain('bg-red-100')
|
|
72
|
+
expect(classes).toContain('text-red-700')
|
|
73
|
+
// …and the default slate skin dropped (layout/focus classes remain).
|
|
74
|
+
expect(classes).not.toContain('border-slate-300')
|
|
75
|
+
expect(classes).not.toContain('bg-white')
|
|
76
|
+
expect(classes).toContain('cursor-pointer')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('sizes the chevron via chevronClass', () => {
|
|
80
|
+
const wrapper = mount(BaseDropdown, {
|
|
81
|
+
props: { options, modelValue: '1', chevronClass: 'w-3 h-3' },
|
|
82
|
+
})
|
|
83
|
+
const chevron = wrapper.get('button svg')
|
|
84
|
+
expect(chevron.classes()).toContain('w-3')
|
|
85
|
+
expect(chevron.classes()).not.toContain('w-5')
|
|
86
|
+
})
|
|
87
|
+
})
|