mgv-backoffice 1.28.0 → 1.30.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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mgv-backoffice",
3
- "version": "1.28.0",
3
+ "version": "1.30.0",
4
4
  "description": "Shared Vue 3 UI component library",
5
5
  "type": "module",
6
6
  "main": "./dist/ui-lib.umd.cjs",
@@ -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>
@@ -61,4 +61,27 @@ describe('BaseDropdown', () => {
61
61
  expect(rows[1].attributes('aria-selected')).toBe('false')
62
62
  expect(rows[0].get('button').classes()).toContain('bg-emerald-500')
63
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
+ })
64
87
  })
@@ -31,8 +31,8 @@
31
31
  {{ selectedOption ? selectedOption.label : placeholder }}
32
32
  </span>
33
33
  <ChevronDownIcon
34
- class="w-5 h-5 shrink-0 text-slate-500 dark:text-slate-400 transition-transform"
35
- :class="open ? 'rotate-180' : ''"
34
+ class="shrink-0 transition-transform"
35
+ :class="[chevronClass, open ? 'rotate-180' : '']"
36
36
  aria-hidden="true"
37
37
  />
38
38
  </button>
@@ -85,6 +85,15 @@ interface Props {
85
85
  disabled?: boolean
86
86
  /** Accessible name for the trigger/listbox when there is no visible label. */
87
87
  ariaLabel?: string
88
+ /**
89
+ * Replaces the trigger's default slate skin (border/background/text/padding)
90
+ * with the given classes — layout, focus ring and disabled classes are kept.
91
+ * Lets a consumer render the trigger as e.g. a coloured status pill while
92
+ * keeping the shared menu behaviour. `size` is ignored when this is set.
93
+ */
94
+ triggerClass?: string
95
+ /** Chevron sizing/colour classes; shrink for compact triggers. */
96
+ chevronClass?: string
88
97
  }
89
98
 
90
99
  const props = withDefaults(defineProps<Props>(), {
@@ -94,6 +103,8 @@ const props = withDefaults(defineProps<Props>(), {
94
103
  block: true,
95
104
  disabled: false,
96
105
  ariaLabel: '',
106
+ triggerClass: '',
107
+ chevronClass: 'w-5 h-5 text-slate-500 dark:text-slate-400',
97
108
  })
98
109
 
99
110
  const emit = defineEmits<{
@@ -109,12 +120,17 @@ const selectedOption = computed(() =>
109
120
  )
110
121
 
111
122
  const triggerClass = computed(() => {
123
+ const layout =
124
+ 'flex items-center justify-between gap-2 text-left transition-colors ' +
125
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 ' +
126
+ 'cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed '
127
+ if (props.triggerClass) return layout + props.triggerClass
112
128
  const padding = props.size === 'sm' ? 'px-3 py-2' : 'px-4 py-2.5'
113
129
  return (
114
- 'flex items-center justify-between gap-2 rounded-lg border text-left text-sm transition-colors ' +
130
+ layout +
131
+ 'rounded-lg border text-sm ' +
115
132
  'border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-100 ' +
116
- 'hover:bg-slate-50 dark:hover:bg-slate-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 ' +
117
- 'cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed ' +
133
+ 'hover:bg-slate-50 dark:hover:bg-slate-800 ' +
118
134
  padding
119
135
  )
120
136
  })
@@ -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,56 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ const replace = vi.fn()
4
+ let query: Record<string, string> = {}
5
+
6
+ vi.mock('vue-router', () => ({
7
+ useRoute: () => ({ query }),
8
+ useRouter: () => ({ replace }),
9
+ }))
10
+
11
+ import { useQueryParamSync } from './useQueryParamSync'
12
+
13
+ beforeEach(() => {
14
+ replace.mockReset()
15
+ query = {}
16
+ })
17
+
18
+ describe('qparam / qenum', () => {
19
+ it('reads string params and returns "" for absent ones', () => {
20
+ query = { q: 'AMD' }
21
+ const { qparam } = useQueryParamSync()
22
+ expect(qparam('q')).toBe('AMD')
23
+ expect(qparam('missing')).toBe('')
24
+ })
25
+
26
+ it('qenum only accepts allowed values, else the fallback', () => {
27
+ query = { range: '7D', bogus: 'nope' }
28
+ const { qenum } = useQueryParamSync()
29
+ expect(qenum('range', ['1D', '7D', '30D'], '1D')).toBe('7D')
30
+ expect(qenum('bogus', ['a', 'b'], 'a')).toBe('a')
31
+ expect(qenum('missing', ['a', 'b'], 'b')).toBe('b')
32
+ })
33
+ })
34
+
35
+ describe('replaceQuery', () => {
36
+ it('replaces when the query differs', () => {
37
+ query = { q: 'old' }
38
+ const { replaceQuery } = useQueryParamSync()
39
+ replaceQuery({ q: 'new' })
40
+ expect(replace).toHaveBeenCalledWith({ query: { q: 'new' } })
41
+ })
42
+
43
+ it('no-ops when the query already matches (same keys and values)', () => {
44
+ query = { q: 'AMD', range: '7D' }
45
+ const { replaceQuery } = useQueryParamSync()
46
+ replaceQuery({ q: 'AMD', range: '7D' })
47
+ expect(replace).not.toHaveBeenCalled()
48
+ })
49
+
50
+ it('replaces when a key is removed (defaults omitted)', () => {
51
+ query = { q: 'AMD', range: '7D' }
52
+ const { replaceQuery } = useQueryParamSync()
53
+ replaceQuery({ q: 'AMD' })
54
+ expect(replace).toHaveBeenCalledWith({ query: { q: 'AMD' } })
55
+ })
56
+ })
@@ -0,0 +1,42 @@
1
+ import { useRoute, useRouter } from 'vue-router'
2
+
3
+ /**
4
+ * URL-query mirroring helpers for filterable views: read a filter's initial
5
+ * state from the query string once on setup, then write changes back with
6
+ * `replaceQuery` so a filtered view can be copied, shared and bookmarked
7
+ * without filter tweaks piling up in the browser history.
8
+ */
9
+ export function useQueryParamSync() {
10
+ const route = useRoute()
11
+ const router = useRouter()
12
+
13
+ /** String value of a query param ('' when absent or repeated). */
14
+ function qparam(name: string): string {
15
+ const v = route.query[name]
16
+ return typeof v === 'string' ? v : ''
17
+ }
18
+
19
+ /**
20
+ * Enum-ish param: only values from `allowed` pass; anything else in a
21
+ * hand-edited URL falls back to the default.
22
+ */
23
+ function qenum<T extends string>(name: string, allowed: readonly T[], fallback: T): T {
24
+ const v = qparam(name)
25
+ return (allowed as readonly string[]).includes(v) ? (v as T) : fallback
26
+ }
27
+
28
+ /**
29
+ * Mirror the given params into the URL with `router.replace` (not `push`),
30
+ * no-oping when nothing changed so timers/auto-refresh don't issue redundant
31
+ * navigations. Callers omit default values so the address stays clean.
32
+ */
33
+ function replaceQuery(next: Record<string, string>): void {
34
+ const current = route.query
35
+ const same =
36
+ Object.keys(next).length === Object.keys(current).length &&
37
+ Object.entries(next).every(([k, v]) => current[k] === v)
38
+ if (!same) void router.replace({ query: next })
39
+ }
40
+
41
+ return { qparam, qenum, replaceQuery }
42
+ }