mgv-backoffice 1.29.0 → 1.31.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 +113 -0
- package/dist/components/BaseCredentialsForm.vue.d.ts +80 -0
- package/dist/components/BaseFilterChip.vue.d.ts +31 -0
- package/dist/composables/useFieldClasses.d.ts +29 -0
- package/dist/composables/useQueryParamSync.d.ts +11 -0
- package/dist/index.d.ts +11 -2
- package/dist/types/specField.d.ts +5 -0
- package/dist/ui-lib.css +1 -1
- package/dist/ui-lib.js +933 -617
- package/dist/ui-lib.umd.cjs +1 -1
- package/dist/utils/format.d.ts +30 -0
- package/dist/utils/httpColors.d.ts +13 -0
- package/dist/utils/kvRows.d.ts +21 -0
- package/dist/utils/specForm.d.ts +20 -0
- package/package.json +1 -1
- package/src/components/BaseCredentialsForm.test.ts +108 -0
- package/src/components/BaseCredentialsForm.vue +241 -0
- package/src/components/BaseFilterChip.test.ts +44 -0
- package/src/components/BaseFilterChip.vue +67 -0
- package/src/composables/useFieldClasses.test.ts +55 -0
- package/src/composables/useFieldClasses.ts +56 -0
- package/src/composables/useQueryParamSync.test.ts +56 -0
- package/src/composables/useQueryParamSync.ts +42 -0
- package/src/index.ts +16 -0
- package/src/types/specField.ts +5 -0
- package/src/utils/format.test.ts +71 -0
- package/src/utils/format.ts +80 -0
- package/src/utils/httpColors.test.ts +38 -0
- package/src/utils/httpColors.ts +31 -0
- package/src/utils/kvRows.test.ts +47 -0
- package/src/utils/kvRows.ts +34 -0
- package/src/utils/specForm.test.ts +53 -0
- package/src/utils/specForm.ts +48 -0
package/dist/utils/format.d.ts
CHANGED
|
@@ -25,6 +25,13 @@ export declare function fmtDateTime(ms: number): string;
|
|
|
25
25
|
* Format an epoch-millis timestamp as a short "Mon D" calendar label.
|
|
26
26
|
*/
|
|
27
27
|
export declare function fmtDateShort(ms: number): string;
|
|
28
|
+
/**
|
|
29
|
+
* Format an ISO string / epoch-millis as a full 24-hour locale date-time WITH
|
|
30
|
+
* the millisecond fraction — for dense feeds where several rows can land in
|
|
31
|
+
* the same second and must stay distinguishable. Falls back to the raw value
|
|
32
|
+
* when the input can't be parsed.
|
|
33
|
+
*/
|
|
34
|
+
export declare function fmtDateTimeMs(s: string | number): string;
|
|
28
35
|
/**
|
|
29
36
|
* Format a price with precision that scales to magnitude: penny stocks and
|
|
30
37
|
* sub-cent crypto get more decimals so the value never collapses to "0.00".
|
|
@@ -38,6 +45,29 @@ export declare function fmtPct(n: number, digits?: number): string;
|
|
|
38
45
|
* Format a signed USD amount with an explicit leading sign (e.g. "+$5.00").
|
|
39
46
|
*/
|
|
40
47
|
export declare function fmtUsd(v: number): string;
|
|
48
|
+
/**
|
|
49
|
+
* Format an ISO date string / epoch as a "Mon D, YYYY" calendar label
|
|
50
|
+
* (en-US, e.g. "Jan 5, 2026"). Falls back to the raw value on parse
|
|
51
|
+
* failure and an em-dash on empty input.
|
|
52
|
+
*/
|
|
53
|
+
export declare function fmtCalendarDate(s: string | number | null | undefined): string;
|
|
54
|
+
/**
|
|
55
|
+
* Format an ISO date string / epoch as a "Mon D, YYYY, HH:MM" calendar
|
|
56
|
+
* label with the time of day (en-US). Same fallbacks as fmtCalendarDate.
|
|
57
|
+
*/
|
|
58
|
+
export declare function fmtCalendarDateTime(s: string | number | null | undefined): string;
|
|
59
|
+
/**
|
|
60
|
+
* Render a millisecond count as a "= 1.50 s" hint so users can
|
|
61
|
+
* sanity-check magnitudes without mental math. Sub-second values get 3
|
|
62
|
+
* decimals ("= 0.250 s"), everything else 2. Returns '' for
|
|
63
|
+
* non-positive / non-finite input so callers can `v-if` it away.
|
|
64
|
+
*/
|
|
65
|
+
export declare function fmtMsAsSeconds(ms: number | null | undefined): string;
|
|
66
|
+
/**
|
|
67
|
+
* Pretty-print a byte count with KB/MB granularity ("512 B", "1.5 KB",
|
|
68
|
+
* "2.0 MB"). Returns '' for zero/falsy input.
|
|
69
|
+
*/
|
|
70
|
+
export declare function fmtBytes(bytes: number | null | undefined): string;
|
|
41
71
|
/**
|
|
42
72
|
* Format a millisecond duration as compact day/hour/minute units, e.g.
|
|
43
73
|
* "3d 5h", "5h 12m" or "12m". Zero-value leading units are dropped;
|
|
@@ -15,5 +15,18 @@ export declare function methodBadgeSolid(method?: string): string;
|
|
|
15
15
|
export declare function methodBadgeBright(method?: string): string;
|
|
16
16
|
/** Solid 600-weight status badge with white text. */
|
|
17
17
|
export declare function statusBadgeSolid(status?: number): string;
|
|
18
|
+
/**
|
|
19
|
+
* Dark/light-aware tinted method badge — per-method hue on a soft
|
|
20
|
+
* `*-500/15` (dark) / `*-100` (light) surface, for card-surface chips.
|
|
21
|
+
* (Extracted from WireMate's mock/stub cards; GET is blue here, unlike
|
|
22
|
+
* the green-leaning bright family above.)
|
|
23
|
+
*/
|
|
24
|
+
export declare function methodBadgeTinted(method: string | undefined, isDark: boolean): string;
|
|
25
|
+
/**
|
|
26
|
+
* Dark/light-aware soft status badge keyed by status class — emerald 2xx,
|
|
27
|
+
* sky 3xx, amber 4xx, red 5xx, gray otherwise. Softer companion to
|
|
28
|
+
* `statusBadgeTinted` (which uses the green/blue/yellow palette).
|
|
29
|
+
*/
|
|
30
|
+
export declare function statusBadgeSoft(status: number | undefined, isDark: boolean): string;
|
|
18
31
|
/** Dark/light-aware tinted status badge. */
|
|
19
32
|
export declare function statusBadgeTinted(status: number | undefined, isDark: boolean): string;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validators for dynamic key/value row grids (header lists, query-param
|
|
3
|
+
* lists, metadata grids, …). A row is inconsistent when one side is
|
|
4
|
+
* filled in and the other is blank:
|
|
5
|
+
*
|
|
6
|
+
* • key present, value blank → rowValueMissing
|
|
7
|
+
* • value present, key blank → rowKeyMissing
|
|
8
|
+
*
|
|
9
|
+
* Rows whose `matcherType` is `'absent'` are exempt — an absent matcher
|
|
10
|
+
* intentionally carries no value (it asserts the key is NOT present).
|
|
11
|
+
* Rows without a matcherType (plain key/value pairs) validate normally.
|
|
12
|
+
*/
|
|
13
|
+
export interface KeyValueRowLike {
|
|
14
|
+
key?: string;
|
|
15
|
+
value?: string;
|
|
16
|
+
matcherType?: string;
|
|
17
|
+
}
|
|
18
|
+
/** True when the row has a (non-absent) key but no value. */
|
|
19
|
+
export declare function rowValueMissing(row: KeyValueRowLike): boolean;
|
|
20
|
+
/** True when the row has a value but no key to bind it to. */
|
|
21
|
+
export declare function rowKeyMissing(row: KeyValueRowLike): boolean;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { SpecField, SpecFieldValue } from '../types/specField';
|
|
2
|
+
/**
|
|
3
|
+
* Helpers for spec-driven forms (the value maps behind BaseSpecFields), so
|
|
4
|
+
* param seeding and numeric validation stay single-sourced across consumers.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Build the value map for a spec set: keep any overlapping values the caller
|
|
8
|
+
* already has (e.g. a shared amount field carries across spec sets), otherwise
|
|
9
|
+
* fall back to the spec's default. No specs → empty map. A `default: null`
|
|
10
|
+
* seeds the field with null so it renders as an empty (optional) input.
|
|
11
|
+
*/
|
|
12
|
+
export declare function buildSpecParams(specs: readonly SpecField[] | undefined, existing: Record<string, SpecFieldValue>): Record<string, SpecFieldValue>;
|
|
13
|
+
/**
|
|
14
|
+
* Label of the first blank / NaN numeric field (a cleared number input becomes
|
|
15
|
+
* '' under number coercion), or null when every numeric field holds a number.
|
|
16
|
+
* A spec whose `default` is null is OPTIONAL: blank means "knob disabled" and
|
|
17
|
+
* must pass; a non-blank value on an optional field is still validated as a
|
|
18
|
+
* number.
|
|
19
|
+
*/
|
|
20
|
+
export declare function firstInvalidNumericSpec(specs: readonly SpecField[] | undefined, params: Record<string, SpecFieldValue>): string | null;
|
package/package.json
CHANGED
|
@@ -0,0 +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
|
+
})
|
|
@@ -0,0 +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>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BaseFilterChip from './BaseFilterChip.vue'
|
|
4
|
+
|
|
5
|
+
describe('BaseFilterChip', () => {
|
|
6
|
+
it('renders the label prop, overridden by the default slot', () => {
|
|
7
|
+
expect(mount(BaseFilterChip, { props: { label: 'Buy' } }).text()).toBe('Buy')
|
|
8
|
+
expect(
|
|
9
|
+
mount(BaseFilterChip, { props: { label: 'x' }, slots: { default: 'Sell' } }).text(),
|
|
10
|
+
).toBe('Sell')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('switches from the tinted idle skin to the solid active fill', () => {
|
|
14
|
+
const idle = mount(BaseFilterChip, { props: { color: 'sky', active: false } })
|
|
15
|
+
expect(idle.get('button').classes()).toContain('bg-sky-50')
|
|
16
|
+
expect(idle.get('button').attributes('aria-pressed')).toBe('false')
|
|
17
|
+
|
|
18
|
+
const active = mount(BaseFilterChip, { props: { color: 'sky', active: true } })
|
|
19
|
+
expect(active.get('button').classes()).toContain('bg-sky-500')
|
|
20
|
+
expect(active.get('button').classes()).toContain('text-white')
|
|
21
|
+
expect(active.get('button').attributes('aria-pressed')).toBe('true')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('lets native click listeners through, blocked when disabled', async () => {
|
|
25
|
+
let clicks = 0
|
|
26
|
+
const chip = mount(BaseFilterChip, {
|
|
27
|
+
props: { label: 'Buy' },
|
|
28
|
+
attrs: { onClick: () => clicks++ },
|
|
29
|
+
})
|
|
30
|
+
await chip.get('button').trigger('click')
|
|
31
|
+
expect(clicks).toBe(1)
|
|
32
|
+
|
|
33
|
+
const off = mount(BaseFilterChip, {
|
|
34
|
+
props: { label: 'Buy', disabled: true },
|
|
35
|
+
attrs: { onClick: () => clicks++ },
|
|
36
|
+
})
|
|
37
|
+
expect(off.get('button').attributes('disabled')).toBeDefined()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('forwards the title tooltip', () => {
|
|
41
|
+
const chip = mount(BaseFilterChip, { props: { label: 'Buy', title: 'Show buys' } })
|
|
42
|
+
expect(chip.get('button').attributes('title')).toBe('Show buys')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Colour-coded toggleable filter chip — the one-click event/category filter
|
|
4
|
+
buttons above a data feed (extracted from TradeAutomation's Trade Log).
|
|
5
|
+
Idle: tinted border/background in the semantic colour. Active: solid fill
|
|
6
|
+
with white text. `aria-pressed` reflects the toggle state.
|
|
7
|
+
|
|
8
|
+
Layout (height, flex sizing) is the caller's: pass classes like
|
|
9
|
+
`h-9 flex-1` through the class attribute.
|
|
10
|
+
|
|
11
|
+
The click handler falls through natively to the root <button> — bind
|
|
12
|
+
@click on the component as usual; a disabled chip blocks it.
|
|
13
|
+
-->
|
|
14
|
+
<button
|
|
15
|
+
type="button"
|
|
16
|
+
:disabled="disabled"
|
|
17
|
+
:title="title"
|
|
18
|
+
:aria-pressed="active"
|
|
19
|
+
class="whitespace-nowrap rounded-md border px-3 text-sm font-semibold text-center transition-colors focus:outline-none focus-visible:ring-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
20
|
+
:class="active ? ACTIVE[color] : IDLE[color]"
|
|
21
|
+
>
|
|
22
|
+
<slot>{{ label }}</slot>
|
|
23
|
+
</button>
|
|
24
|
+
</template>
|
|
25
|
+
|
|
26
|
+
<script setup lang="ts">
|
|
27
|
+
type FilterChipColor = 'emerald' | 'sky' | 'amber' | 'red' | 'slate'
|
|
28
|
+
|
|
29
|
+
withDefaults(
|
|
30
|
+
defineProps<{
|
|
31
|
+
/** Chip text; the default slot overrides it. */
|
|
32
|
+
label?: string
|
|
33
|
+
/** Semantic colour of both the idle tint and the active solid fill. */
|
|
34
|
+
color?: FilterChipColor
|
|
35
|
+
/** Whether the chip's filter is currently applied (solid fill). */
|
|
36
|
+
active?: boolean
|
|
37
|
+
disabled?: boolean
|
|
38
|
+
/** Native tooltip. */
|
|
39
|
+
title?: string
|
|
40
|
+
}>(),
|
|
41
|
+
{ label: '', color: 'slate', active: false, disabled: false, title: undefined },
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
// Full literal class strings (no dynamic concatenation) so Tailwind detects
|
|
45
|
+
// them at build time. Idle: tinted; active: solid with white text.
|
|
46
|
+
const IDLE: Record<FilterChipColor, string> = {
|
|
47
|
+
emerald:
|
|
48
|
+
'border-emerald-300 dark:border-emerald-700/60 bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/30 focus-visible:ring-emerald-500',
|
|
49
|
+
sky:
|
|
50
|
+
'border-sky-300 dark:border-sky-700/60 bg-sky-50 dark:bg-sky-900/20 text-sky-700 dark:text-sky-400 hover:bg-sky-100 dark:hover:bg-sky-900/30 focus-visible:ring-sky-500',
|
|
51
|
+
amber:
|
|
52
|
+
'border-amber-300 dark:border-amber-700/60 bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-900/30 focus-visible:ring-amber-500',
|
|
53
|
+
red:
|
|
54
|
+
'border-red-300 dark:border-red-700/60 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 focus-visible:ring-red-500',
|
|
55
|
+
slate:
|
|
56
|
+
'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 focus-visible:ring-emerald-500',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ACTIVE: Record<FilterChipColor, string> = {
|
|
60
|
+
emerald:
|
|
61
|
+
'border-emerald-500 bg-emerald-500 text-white hover:bg-emerald-600 focus-visible:ring-emerald-500',
|
|
62
|
+
sky: 'border-sky-500 bg-sky-500 text-white hover:bg-sky-600 focus-visible:ring-sky-500',
|
|
63
|
+
amber: 'border-amber-500 bg-amber-500 text-white hover:bg-amber-600 focus-visible:ring-amber-500',
|
|
64
|
+
red: 'border-red-500 bg-red-500 text-white hover:bg-red-600 focus-visible:ring-red-500',
|
|
65
|
+
slate: 'border-slate-600 bg-slate-600 text-white hover:bg-slate-700 focus-visible:ring-emerald-500',
|
|
66
|
+
}
|
|
67
|
+
</script>
|
|
@@ -0,0 +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
|
+
})
|