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
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { computed, type ComputedRef } from 'vue'
|
|
2
|
+
import { useTheme } from './useTheme'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared form-field class strings for the gray/emerald form skin
|
|
6
|
+
* (the WireMate mock-form family). Extracted because the exact same
|
|
7
|
+
* `labelClasses` / `inputClasses` / `requiredInputClasses` trio was
|
|
8
|
+
* copy-pasted across every form section component.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
*
|
|
12
|
+
* const f = useFieldClasses()
|
|
13
|
+
* <label :class="f.label">Name</label>
|
|
14
|
+
* <input :class="f.input" />
|
|
15
|
+
* <input :class="f.requiredInput(value)" /> // red skin while empty
|
|
16
|
+
*
|
|
17
|
+
* Vue auto-unwraps the computed refs in template bindings; `requiredInput`
|
|
18
|
+
* is a plain function and is called with the current field value.
|
|
19
|
+
*/
|
|
20
|
+
export interface FieldClasses {
|
|
21
|
+
/** Form label: gray-300 / gray-600. */
|
|
22
|
+
label: ComputedRef<string>
|
|
23
|
+
/** Standard input skin: gray-800 dark surface / white light surface, emerald focus. */
|
|
24
|
+
input: ComputedRef<string>
|
|
25
|
+
/**
|
|
26
|
+
* Input skin for required fields: red border + ring while the value is
|
|
27
|
+
* empty (undefined / null / blank after trim), the standard skin otherwise.
|
|
28
|
+
*/
|
|
29
|
+
requiredInput: (value: string | number | undefined | null) => string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function useFieldClasses(): FieldClasses {
|
|
33
|
+
const { isDark } = useTheme()
|
|
34
|
+
|
|
35
|
+
const label = computed(() =>
|
|
36
|
+
isDark.value ? 'text-gray-300' : 'text-gray-600',
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const input = computed(() =>
|
|
40
|
+
isDark.value
|
|
41
|
+
? 'bg-gray-800 border-gray-600 text-gray-100 placeholder-gray-500 focus:ring-emerald-500 focus:border-emerald-500'
|
|
42
|
+
: 'bg-white border-gray-300 text-gray-800 focus:ring-emerald-500 focus:border-emerald-500',
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
function requiredInput(value: string | number | undefined | null): string {
|
|
46
|
+
const isEmpty = value === undefined || value === null || String(value).trim() === ''
|
|
47
|
+
if (isEmpty) {
|
|
48
|
+
return isDark.value
|
|
49
|
+
? 'bg-gray-800 border-red-500 text-gray-100 placeholder-gray-500 focus:ring-red-500 focus:border-red-500 ring-1 ring-red-500/30'
|
|
50
|
+
: 'bg-white border-red-400 text-gray-800 focus:ring-red-500 focus:border-red-500 ring-1 ring-red-400/30'
|
|
51
|
+
}
|
|
52
|
+
return input.value
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { label, input, requiredInput }
|
|
56
|
+
}
|
|
@@ -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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -45,6 +45,9 @@ export { default as BaseSegmentedControl } from './components/BaseSegmentedContr
|
|
|
45
45
|
export { default as BaseTable } from './components/BaseTable.vue'
|
|
46
46
|
export { default as BaseSpecFields } from './components/BaseSpecFields.vue'
|
|
47
47
|
export { default as BaseStatBreakdown } from './components/BaseStatBreakdown.vue'
|
|
48
|
+
export { default as BaseFilterChip } from './components/BaseFilterChip.vue'
|
|
49
|
+
export { default as BaseCredentialsForm } from './components/BaseCredentialsForm.vue'
|
|
50
|
+
export type { CredentialsView, CredentialsUpdate } from './components/BaseCredentialsForm.vue'
|
|
48
51
|
|
|
49
52
|
// Composables
|
|
50
53
|
export { useTheme, initTheme } from './composables/useTheme'
|
|
@@ -58,6 +61,9 @@ export { useMobileSidebar } from './composables/useMobileSidebar'
|
|
|
58
61
|
export { useSidebarCollapse } from './composables/useSidebarCollapse'
|
|
59
62
|
export type { UseSidebarCollapseOptions } from './composables/useSidebarCollapse'
|
|
60
63
|
export { useNotifications } from './composables/useNotifications'
|
|
64
|
+
export { useQueryParamSync } from './composables/useQueryParamSync'
|
|
65
|
+
export { useFieldClasses } from './composables/useFieldClasses'
|
|
66
|
+
export type { FieldClasses } from './composables/useFieldClasses'
|
|
61
67
|
|
|
62
68
|
// Enums
|
|
63
69
|
export { AlertEnum } from './enums/AlertEnum'
|
|
@@ -88,19 +94,29 @@ export { getBaseColor, getBaseColorOf } from './utils/util'
|
|
|
88
94
|
export {
|
|
89
95
|
methodBadgeSolid,
|
|
90
96
|
methodBadgeBright,
|
|
97
|
+
methodBadgeTinted,
|
|
91
98
|
statusBadgeSolid,
|
|
92
99
|
statusBadgeTinted,
|
|
100
|
+
statusBadgeSoft,
|
|
93
101
|
} from './utils/httpColors'
|
|
102
|
+
export { rowKeyMissing, rowValueMissing } from './utils/kvRows'
|
|
103
|
+
export type { KeyValueRowLike } from './utils/kvRows'
|
|
94
104
|
export { sanitizeHtml, isSafeHref } from './utils/sanitizeHtml'
|
|
95
105
|
export {
|
|
96
106
|
fmtNumber,
|
|
97
107
|
fmtDate,
|
|
98
108
|
fmtDateTime,
|
|
109
|
+
fmtDateTimeMs,
|
|
99
110
|
fmtDateShort,
|
|
111
|
+
fmtCalendarDate,
|
|
112
|
+
fmtCalendarDateTime,
|
|
113
|
+
fmtMsAsSeconds,
|
|
114
|
+
fmtBytes,
|
|
100
115
|
fmtPrice,
|
|
101
116
|
fmtPct,
|
|
102
117
|
fmtUsd,
|
|
103
118
|
fmtDuration,
|
|
104
119
|
} from './utils/format'
|
|
120
|
+
export { buildSpecParams, firstInvalidNumericSpec } from './utils/specForm'
|
|
105
121
|
export { computePnL } from './utils/pnl'
|
|
106
122
|
export type { PnL, PnLInputs } from './utils/pnl'
|
package/src/types/specField.ts
CHANGED
|
@@ -11,6 +11,11 @@ export interface SpecField {
|
|
|
11
11
|
key: string
|
|
12
12
|
label: string
|
|
13
13
|
type: SpecFieldType
|
|
14
|
+
/**
|
|
15
|
+
* Default value the form seeds the field with. `null` marks the field as
|
|
16
|
+
* OPTIONAL for `firstInvalidNumericSpec` (blank = "knob disabled").
|
|
17
|
+
*/
|
|
18
|
+
default?: SpecFieldValue | null
|
|
14
19
|
/** Choices for `type: 'select'`. */
|
|
15
20
|
options?: string[] | null
|
|
16
21
|
/** Step for numeric inputs; defaults to 1 (integer) / 0.01 (decimal). */
|
package/src/utils/format.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
fmtNumber,
|
|
4
4
|
fmtDate,
|
|
5
5
|
fmtDateTime,
|
|
6
|
+
fmtDateTimeMs,
|
|
6
7
|
fmtDateShort,
|
|
7
8
|
fmtPrice,
|
|
8
9
|
fmtPct,
|
|
@@ -10,6 +11,21 @@ import {
|
|
|
10
11
|
fmtDuration,
|
|
11
12
|
} from './format'
|
|
12
13
|
|
|
14
|
+
describe('fmtDateTimeMs', () => {
|
|
15
|
+
it('keeps the millisecond fraction so same-second rows stay distinguishable', () => {
|
|
16
|
+
const out = fmtDateTimeMs('2026-06-08T14:30:51.123Z')
|
|
17
|
+
expect(out).toMatch(/51[.,:]?\d*123|123/) // ms fraction present, locale separators vary
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('accepts epoch millis', () => {
|
|
21
|
+
expect(fmtDateTimeMs(0)).toMatch(/19?70/)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('falls back to the raw value for unparseable input', () => {
|
|
25
|
+
expect(fmtDateTimeMs('not-a-date')).toBe('Invalid Date')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
13
29
|
const EM_DASH = '—'
|
|
14
30
|
|
|
15
31
|
// Fraction digits are locale-stable even when the grouping/decimal
|
|
@@ -162,3 +178,58 @@ describe('fmtDuration', () => {
|
|
|
162
178
|
expect(fmtDuration(59_000)).toBe('0m')
|
|
163
179
|
})
|
|
164
180
|
})
|
|
181
|
+
|
|
182
|
+
import { fmtCalendarDate, fmtCalendarDateTime, fmtMsAsSeconds, fmtBytes } from './format'
|
|
183
|
+
|
|
184
|
+
describe('fmtCalendarDate', () => {
|
|
185
|
+
it('renders a "Mon D, YYYY" en-US label', () => {
|
|
186
|
+
expect(fmtCalendarDate('2026-01-05T10:30:00Z')).toMatch(/^Jan \d{1,2}, 2026$/)
|
|
187
|
+
})
|
|
188
|
+
it('renders an em-dash for empty input', () => {
|
|
189
|
+
expect(fmtCalendarDate(null)).toBe(EM_DASH)
|
|
190
|
+
expect(fmtCalendarDate('')).toBe(EM_DASH)
|
|
191
|
+
})
|
|
192
|
+
it('falls back to the raw value for unparseable input', () => {
|
|
193
|
+
expect(fmtCalendarDate('not-a-date')).toBe('not-a-date')
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
describe('fmtCalendarDateTime', () => {
|
|
198
|
+
it('includes the time of day', () => {
|
|
199
|
+
expect(fmtCalendarDateTime('2026-01-05T10:30:00Z')).toMatch(/2026.*\d{1,2}:\d{2}/)
|
|
200
|
+
})
|
|
201
|
+
it('renders an em-dash for empty input', () => {
|
|
202
|
+
expect(fmtCalendarDateTime(undefined)).toBe(EM_DASH)
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
describe('fmtMsAsSeconds', () => {
|
|
207
|
+
it('renders 2 decimals at >= 1s', () => {
|
|
208
|
+
expect(fmtMsAsSeconds(1500)).toBe('= 1.50 s')
|
|
209
|
+
})
|
|
210
|
+
it('renders 3 decimals below 1s', () => {
|
|
211
|
+
expect(fmtMsAsSeconds(250)).toBe('= 0.250 s')
|
|
212
|
+
})
|
|
213
|
+
it('returns empty for zero / negative / non-finite input', () => {
|
|
214
|
+
expect(fmtMsAsSeconds(0)).toBe('')
|
|
215
|
+
expect(fmtMsAsSeconds(-5)).toBe('')
|
|
216
|
+
expect(fmtMsAsSeconds(NaN)).toBe('')
|
|
217
|
+
expect(fmtMsAsSeconds(null)).toBe('')
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
describe('fmtBytes', () => {
|
|
222
|
+
it('renders bytes below 1 KB', () => {
|
|
223
|
+
expect(fmtBytes(512)).toBe('512 B')
|
|
224
|
+
})
|
|
225
|
+
it('renders KB with one decimal', () => {
|
|
226
|
+
expect(fmtBytes(1536)).toBe('1.5 KB')
|
|
227
|
+
})
|
|
228
|
+
it('renders MB with one decimal', () => {
|
|
229
|
+
expect(fmtBytes(2 * 1024 * 1024)).toBe('2.0 MB')
|
|
230
|
+
})
|
|
231
|
+
it('returns empty for zero / falsy input', () => {
|
|
232
|
+
expect(fmtBytes(0)).toBe('')
|
|
233
|
+
expect(fmtBytes(null)).toBe('')
|
|
234
|
+
})
|
|
235
|
+
})
|
package/src/utils/format.ts
CHANGED
|
@@ -55,6 +55,29 @@ export function fmtDateShort(ms: number): string {
|
|
|
55
55
|
return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Format an ISO string / epoch-millis as a full 24-hour locale date-time WITH
|
|
60
|
+
* the millisecond fraction — for dense feeds where several rows can land in
|
|
61
|
+
* the same second and must stay distinguishable. Falls back to the raw value
|
|
62
|
+
* when the input can't be parsed.
|
|
63
|
+
*/
|
|
64
|
+
export function fmtDateTimeMs(s: string | number): string {
|
|
65
|
+
try {
|
|
66
|
+
return new Date(s).toLocaleString(undefined, {
|
|
67
|
+
year: 'numeric',
|
|
68
|
+
month: '2-digit',
|
|
69
|
+
day: '2-digit',
|
|
70
|
+
hour: '2-digit',
|
|
71
|
+
minute: '2-digit',
|
|
72
|
+
second: '2-digit',
|
|
73
|
+
fractionalSecondDigits: 3,
|
|
74
|
+
hour12: false,
|
|
75
|
+
})
|
|
76
|
+
} catch {
|
|
77
|
+
return String(s)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
58
81
|
/**
|
|
59
82
|
* Format a price with precision that scales to magnitude: penny stocks and
|
|
60
83
|
* sub-cent crypto get more decimals so the value never collapses to "0.00".
|
|
@@ -88,6 +111,63 @@ export function fmtUsd(v: number): string {
|
|
|
88
111
|
return `${sign}$${Math.abs(v).toFixed(2)}`
|
|
89
112
|
}
|
|
90
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Format an ISO date string / epoch as a "Mon D, YYYY" calendar label
|
|
116
|
+
* (en-US, e.g. "Jan 5, 2026"). Falls back to the raw value on parse
|
|
117
|
+
* failure and an em-dash on empty input.
|
|
118
|
+
*/
|
|
119
|
+
export function fmtCalendarDate(s: string | number | null | undefined): string {
|
|
120
|
+
if (s === null || s === undefined || s === '') return EM_DASH
|
|
121
|
+
const d = new Date(s)
|
|
122
|
+
if (isNaN(d.getTime())) return String(s)
|
|
123
|
+
return d.toLocaleDateString('en-US', {
|
|
124
|
+
year: 'numeric',
|
|
125
|
+
month: 'short',
|
|
126
|
+
day: 'numeric',
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Format an ISO date string / epoch as a "Mon D, YYYY, HH:MM" calendar
|
|
132
|
+
* label with the time of day (en-US). Same fallbacks as fmtCalendarDate.
|
|
133
|
+
*/
|
|
134
|
+
export function fmtCalendarDateTime(s: string | number | null | undefined): string {
|
|
135
|
+
if (s === null || s === undefined || s === '') return EM_DASH
|
|
136
|
+
const d = new Date(s)
|
|
137
|
+
if (isNaN(d.getTime())) return String(s)
|
|
138
|
+
return d.toLocaleDateString('en-US', {
|
|
139
|
+
year: 'numeric',
|
|
140
|
+
month: 'short',
|
|
141
|
+
day: 'numeric',
|
|
142
|
+
hour: '2-digit',
|
|
143
|
+
minute: '2-digit',
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Render a millisecond count as a "= 1.50 s" hint so users can
|
|
149
|
+
* sanity-check magnitudes without mental math. Sub-second values get 3
|
|
150
|
+
* decimals ("= 0.250 s"), everything else 2. Returns '' for
|
|
151
|
+
* non-positive / non-finite input so callers can `v-if` it away.
|
|
152
|
+
*/
|
|
153
|
+
export function fmtMsAsSeconds(ms: number | null | undefined): string {
|
|
154
|
+
if (typeof ms !== 'number' || !isFinite(ms) || ms <= 0) return ''
|
|
155
|
+
const seconds = ms / 1000
|
|
156
|
+
const decimals = seconds < 1 ? 3 : 2
|
|
157
|
+
return `= ${seconds.toFixed(decimals)} s`
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Pretty-print a byte count with KB/MB granularity ("512 B", "1.5 KB",
|
|
162
|
+
* "2.0 MB"). Returns '' for zero/falsy input.
|
|
163
|
+
*/
|
|
164
|
+
export function fmtBytes(bytes: number | null | undefined): string {
|
|
165
|
+
if (!bytes) return ''
|
|
166
|
+
if (bytes < 1024) return `${bytes} B`
|
|
167
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
168
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
169
|
+
}
|
|
170
|
+
|
|
91
171
|
/**
|
|
92
172
|
* Format a millisecond duration as compact day/hour/minute units, e.g.
|
|
93
173
|
* "3d 5h", "5h 12m" or "12m". Zero-value leading units are dropped;
|
|
@@ -60,3 +60,41 @@ describe('statusBadgeTinted', () => {
|
|
|
60
60
|
expect(statusBadgeTinted(undefined, false)).toBe('bg-green-100 text-green-800')
|
|
61
61
|
})
|
|
62
62
|
})
|
|
63
|
+
|
|
64
|
+
import { methodBadgeTinted, statusBadgeSoft } from './httpColors'
|
|
65
|
+
|
|
66
|
+
describe('methodBadgeTinted', () => {
|
|
67
|
+
it('gives each method its own hue (dark)', () => {
|
|
68
|
+
expect(methodBadgeTinted('GET', true)).toBe('bg-blue-500/15 text-blue-400')
|
|
69
|
+
expect(methodBadgeTinted('POST', true)).toBe('bg-emerald-500/15 text-emerald-400')
|
|
70
|
+
expect(methodBadgeTinted('PUT', true)).toBe('bg-amber-500/15 text-amber-400')
|
|
71
|
+
expect(methodBadgeTinted('DELETE', true)).toBe('bg-red-500/15 text-red-400')
|
|
72
|
+
expect(methodBadgeTinted('PATCH', true)).toBe('bg-purple-500/15 text-purple-400')
|
|
73
|
+
expect(methodBadgeTinted('HEAD', true)).toBe('bg-sky-500/15 text-sky-400')
|
|
74
|
+
})
|
|
75
|
+
it('uses the light palette when isDark is false', () => {
|
|
76
|
+
expect(methodBadgeTinted('GET', false)).toBe('bg-blue-100 text-blue-700')
|
|
77
|
+
})
|
|
78
|
+
it('is case-insensitive and falls back to gray for unknown/ANY/missing', () => {
|
|
79
|
+
expect(methodBadgeTinted('get', true)).toBe('bg-blue-500/15 text-blue-400')
|
|
80
|
+
expect(methodBadgeTinted(undefined, false)).toBe('bg-gray-100 text-gray-600')
|
|
81
|
+
expect(methodBadgeTinted('OPTIONS', false)).toBe('bg-gray-100 text-gray-600')
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('statusBadgeSoft', () => {
|
|
86
|
+
it('buckets by status class (light)', () => {
|
|
87
|
+
expect(statusBadgeSoft(201, false)).toBe('bg-emerald-100 text-emerald-700')
|
|
88
|
+
expect(statusBadgeSoft(302, false)).toBe('bg-sky-100 text-sky-700')
|
|
89
|
+
expect(statusBadgeSoft(404, false)).toBe('bg-amber-100 text-amber-700')
|
|
90
|
+
expect(statusBadgeSoft(503, false)).toBe('bg-red-100 text-red-700')
|
|
91
|
+
})
|
|
92
|
+
it('uses tinted surfaces in dark mode', () => {
|
|
93
|
+
expect(statusBadgeSoft(200, true)).toBe('bg-emerald-500/15 text-emerald-300')
|
|
94
|
+
})
|
|
95
|
+
it('defaults undefined to 200 and gray for out-of-range codes', () => {
|
|
96
|
+
expect(statusBadgeSoft(undefined, false)).toBe('bg-emerald-100 text-emerald-700')
|
|
97
|
+
expect(statusBadgeSoft(999, false)).toBe('bg-gray-100 text-gray-600')
|
|
98
|
+
expect(statusBadgeSoft(100, true)).toBe('bg-gray-800 text-gray-400')
|
|
99
|
+
})
|
|
100
|
+
})
|
package/src/utils/httpColors.ts
CHANGED
|
@@ -52,6 +52,37 @@ export function statusBadgeSolid(status?: number): string {
|
|
|
52
52
|
return 'bg-gray-600'
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Dark/light-aware tinted method badge — per-method hue on a soft
|
|
57
|
+
* `*-500/15` (dark) / `*-100` (light) surface, for card-surface chips.
|
|
58
|
+
* (Extracted from WireMate's mock/stub cards; GET is blue here, unlike
|
|
59
|
+
* the green-leaning bright family above.)
|
|
60
|
+
*/
|
|
61
|
+
export function methodBadgeTinted(method: string | undefined, isDark: boolean): string {
|
|
62
|
+
const m = (method || 'ANY').toUpperCase()
|
|
63
|
+
if (m === 'GET') return isDark ? 'bg-blue-500/15 text-blue-400' : 'bg-blue-100 text-blue-700'
|
|
64
|
+
if (m === 'POST') return isDark ? 'bg-emerald-500/15 text-emerald-400' : 'bg-emerald-100 text-emerald-700'
|
|
65
|
+
if (m === 'PUT') return isDark ? 'bg-amber-500/15 text-amber-400' : 'bg-amber-100 text-amber-700'
|
|
66
|
+
if (m === 'DELETE') return isDark ? 'bg-red-500/15 text-red-400' : 'bg-red-100 text-red-700'
|
|
67
|
+
if (m === 'PATCH') return isDark ? 'bg-purple-500/15 text-purple-400' : 'bg-purple-100 text-purple-700'
|
|
68
|
+
if (m === 'HEAD') return isDark ? 'bg-sky-500/15 text-sky-400' : 'bg-sky-100 text-sky-700'
|
|
69
|
+
return isDark ? 'bg-gray-500/15 text-gray-400' : 'bg-gray-100 text-gray-600'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Dark/light-aware soft status badge keyed by status class — emerald 2xx,
|
|
74
|
+
* sky 3xx, amber 4xx, red 5xx, gray otherwise. Softer companion to
|
|
75
|
+
* `statusBadgeTinted` (which uses the green/blue/yellow palette).
|
|
76
|
+
*/
|
|
77
|
+
export function statusBadgeSoft(status: number | undefined, isDark: boolean): string {
|
|
78
|
+
const code = status ?? 200
|
|
79
|
+
if (code >= 200 && code < 300) return isDark ? 'bg-emerald-500/15 text-emerald-300' : 'bg-emerald-100 text-emerald-700'
|
|
80
|
+
if (code >= 300 && code < 400) return isDark ? 'bg-sky-500/15 text-sky-300' : 'bg-sky-100 text-sky-700'
|
|
81
|
+
if (code >= 400 && code < 500) return isDark ? 'bg-amber-500/15 text-amber-300' : 'bg-amber-100 text-amber-700'
|
|
82
|
+
if (code >= 500 && code < 600) return isDark ? 'bg-red-500/15 text-red-300' : 'bg-red-100 text-red-700'
|
|
83
|
+
return isDark ? 'bg-gray-800 text-gray-400' : 'bg-gray-100 text-gray-600'
|
|
84
|
+
}
|
|
85
|
+
|
|
55
86
|
/** Dark/light-aware tinted status badge. */
|
|
56
87
|
export function statusBadgeTinted(status: number | undefined, isDark: boolean): string {
|
|
57
88
|
const code = status ?? 200
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { rowKeyMissing, rowValueMissing } from './kvRows'
|
|
3
|
+
|
|
4
|
+
describe('rowValueMissing', () => {
|
|
5
|
+
it('is false for a fully blank row', () => {
|
|
6
|
+
expect(rowValueMissing({ key: '', value: '' })).toBe(false)
|
|
7
|
+
expect(rowValueMissing({})).toBe(false)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('is true when the key is filled but the value is blank', () => {
|
|
11
|
+
expect(rowValueMissing({ key: 'X-Trace', value: '' })).toBe(true)
|
|
12
|
+
expect(rowValueMissing({ key: 'X-Trace', value: ' ' })).toBe(true)
|
|
13
|
+
expect(rowValueMissing({ key: 'X-Trace' })).toBe(true)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('is false when both sides are filled', () => {
|
|
17
|
+
expect(rowValueMissing({ key: 'X-Trace', value: 'abc' })).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('exempts absent matchers — they carry no value by design', () => {
|
|
21
|
+
expect(rowValueMissing({ key: 'X-Trace', value: '', matcherType: 'absent' })).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('treats whitespace-only keys as blank', () => {
|
|
25
|
+
expect(rowValueMissing({ key: ' ', value: '' })).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('rowKeyMissing', () => {
|
|
30
|
+
it('is false for a fully blank row', () => {
|
|
31
|
+
expect(rowKeyMissing({ key: '', value: '' })).toBe(false)
|
|
32
|
+
expect(rowKeyMissing({})).toBe(false)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('is true when the value is filled but the key is blank', () => {
|
|
36
|
+
expect(rowKeyMissing({ key: '', value: 'abc' })).toBe(true)
|
|
37
|
+
expect(rowKeyMissing({ key: ' ', value: 'abc' })).toBe(true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('is false when both sides are filled', () => {
|
|
41
|
+
expect(rowKeyMissing({ key: 'X-Trace', value: 'abc' })).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('exempts absent matchers', () => {
|
|
45
|
+
expect(rowKeyMissing({ key: '', value: 'abc', matcherType: 'absent' })).toBe(false)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
|
|
14
|
+
export interface KeyValueRowLike {
|
|
15
|
+
key?: string
|
|
16
|
+
value?: string
|
|
17
|
+
matcherType?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** True when the row has a (non-absent) key but no value. */
|
|
21
|
+
export function rowValueMissing(row: KeyValueRowLike): boolean {
|
|
22
|
+
const key = (row.key ?? '').trim()
|
|
23
|
+
if (!key) return false
|
|
24
|
+
if (row.matcherType === 'absent') return false
|
|
25
|
+
return !(row.value ?? '').trim()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** True when the row has a value but no key to bind it to. */
|
|
29
|
+
export function rowKeyMissing(row: KeyValueRowLike): boolean {
|
|
30
|
+
if (row.matcherType === 'absent') return false
|
|
31
|
+
const hasValue = !!(row.value ?? '').trim()
|
|
32
|
+
if (!hasValue) return false
|
|
33
|
+
return !(row.key ?? '').trim()
|
|
34
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type { SpecField } from '../types/specField'
|
|
3
|
+
import { buildSpecParams, firstInvalidNumericSpec } from './specForm'
|
|
4
|
+
|
|
5
|
+
// Mirrors a typical backend catalogue shape: required numerics carry a
|
|
6
|
+
// default, the optional field carries `default: null` (the optionality
|
|
7
|
+
// convention — blank means "knob disabled").
|
|
8
|
+
const specs: SpecField[] = [
|
|
9
|
+
{ key: 'amountPerSymbol', label: 'Amount per symbol ($)', type: 'decimal', default: 4, min: 1, step: 0.01, options: null, help: '' },
|
|
10
|
+
{ key: 'stopLossPercent', label: 'Stop loss (%)', type: 'decimal', default: null, min: 0.1, step: 0.1, options: null, help: '' },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
describe('firstInvalidNumericSpec', () => {
|
|
14
|
+
it('flags a blank required numeric field', () => {
|
|
15
|
+
expect(firstInvalidNumericSpec(specs, { amountPerSymbol: '', stopLossPercent: 1 }))
|
|
16
|
+
.toBe('Amount per symbol ($)')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it.each([['' as const], [null as never], [undefined as never]])(
|
|
20
|
+
'accepts a blank optional field (cleared → %j)',
|
|
21
|
+
(blank) => {
|
|
22
|
+
expect(firstInvalidNumericSpec(specs, { amountPerSymbol: 10, stopLossPercent: blank }))
|
|
23
|
+
.toBeNull()
|
|
24
|
+
},
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
it('still validates a non-blank value on an optional field', () => {
|
|
28
|
+
expect(firstInvalidNumericSpec(specs, { amountPerSymbol: 10, stopLossPercent: NaN }))
|
|
29
|
+
.toBe('Stop loss (%)')
|
|
30
|
+
expect(firstInvalidNumericSpec(specs, { amountPerSymbol: 10, stopLossPercent: 2.5 }))
|
|
31
|
+
.toBeNull()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('accepts a fully valid value set and tolerates missing specs', () => {
|
|
35
|
+
expect(firstInvalidNumericSpec(specs, { amountPerSymbol: 10, stopLossPercent: 1 })).toBeNull()
|
|
36
|
+
expect(firstInvalidNumericSpec(undefined, {})).toBeNull()
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('buildSpecParams', () => {
|
|
41
|
+
it('seeds from defaults, keeping caller-supplied overlaps', () => {
|
|
42
|
+
expect(buildSpecParams(specs, { amountPerSymbol: 10 }).amountPerSymbol).toBe(10)
|
|
43
|
+
expect(buildSpecParams(specs, {}).amountPerSymbol).toBe(4)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('seeds an optional field with null (renders as an empty input)', () => {
|
|
47
|
+
expect(buildSpecParams(specs, {}).stopLossPercent).toBeNull()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('returns an empty map when there are no specs', () => {
|
|
51
|
+
expect(buildSpecParams(undefined, { anything: 1 })).toEqual({})
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { SpecField, SpecFieldValue } from '../types/specField'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Helpers for spec-driven forms (the value maps behind BaseSpecFields), so
|
|
5
|
+
* param seeding and numeric validation stay single-sourced across consumers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Build the value map for a spec set: keep any overlapping values the caller
|
|
10
|
+
* already has (e.g. a shared amount field carries across spec sets), otherwise
|
|
11
|
+
* fall back to the spec's default. No specs → empty map. A `default: null`
|
|
12
|
+
* seeds the field with null so it renders as an empty (optional) input.
|
|
13
|
+
*/
|
|
14
|
+
export function buildSpecParams(
|
|
15
|
+
specs: readonly SpecField[] | undefined,
|
|
16
|
+
existing: Record<string, SpecFieldValue>,
|
|
17
|
+
): Record<string, SpecFieldValue> {
|
|
18
|
+
const out: Record<string, SpecFieldValue> = {}
|
|
19
|
+
if (!specs) return out
|
|
20
|
+
for (const spec of specs) {
|
|
21
|
+
const prev = existing[spec.key]
|
|
22
|
+
out[spec.key] = prev !== undefined && prev !== null ? prev : (spec.default as SpecFieldValue)
|
|
23
|
+
}
|
|
24
|
+
return out
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Label of the first blank / NaN numeric field (a cleared number input becomes
|
|
29
|
+
* '' under number coercion), or null when every numeric field holds a number.
|
|
30
|
+
* A spec whose `default` is null is OPTIONAL: blank means "knob disabled" and
|
|
31
|
+
* must pass; a non-blank value on an optional field is still validated as a
|
|
32
|
+
* number.
|
|
33
|
+
*/
|
|
34
|
+
export function firstInvalidNumericSpec(
|
|
35
|
+
specs: readonly SpecField[] | undefined,
|
|
36
|
+
params: Record<string, SpecFieldValue>,
|
|
37
|
+
): string | null {
|
|
38
|
+
if (!specs) return null
|
|
39
|
+
for (const spec of specs) {
|
|
40
|
+
if (spec.type === 'decimal' || spec.type === 'integer') {
|
|
41
|
+
const v = params[spec.key]
|
|
42
|
+
const blank = v === '' || v === null || v === undefined
|
|
43
|
+
if (spec.default === null && blank) continue
|
|
44
|
+
if (typeof v !== 'number' || Number.isNaN(v)) return spec.label
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null
|
|
48
|
+
}
|