mgv-backoffice 1.36.0 → 1.36.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1794 -1719
- package/dist/components/BaseBadge.vue.d.ts +1 -11
- package/dist/components/BaseDropdown.vue.d.ts +1 -1
- package/dist/ui-lib.js +942 -925
- package/dist/ui-lib.umd.cjs +1 -1
- package/dist/utils/specForm.d.ts +1 -1
- package/package.json +54 -54
- package/src/components/BaseAlert.vue +104 -104
- package/src/components/BaseBadge.vue +12 -2
- package/src/components/BaseBarDistribution.test.ts +51 -51
- package/src/components/BaseBarDistribution.vue +73 -73
- package/src/components/BaseChipButton.vue +42 -42
- package/src/components/BaseCodeBlock.test.ts +70 -70
- package/src/components/BaseCodeBlock.vue +74 -74
- package/src/components/BaseCollapsibleSection.vue +113 -113
- package/src/components/BaseConfirmModal.vue +114 -114
- package/src/components/BaseCredentialsForm.test.ts +108 -108
- package/src/components/BaseCredentialsForm.vue +241 -241
- package/src/components/BaseDropdown.test.ts +87 -87
- package/src/components/BaseDropdown.vue +172 -172
- package/src/components/BaseEntityPickerModal.vue +330 -330
- package/src/components/BaseFileDropzone.vue +94 -94
- package/src/components/BaseFilterChip.test.ts +44 -44
- package/src/components/BaseFilterChip.vue +67 -67
- package/src/components/BaseModalShell.vue +209 -209
- package/src/components/BasePageHeader.vue +120 -120
- package/src/components/BasePillPickerModal.test.ts +95 -95
- package/src/components/BaseRemoveButton.vue +22 -22
- package/src/components/BaseSpecFields.test.ts +39 -0
- package/src/components/BaseSpecFields.vue +27 -3
- package/src/components/BaseStatBreakdown.test.ts +56 -56
- package/src/components/BaseStatBreakdown.vue +49 -49
- package/src/components/BaseStatusPill.vue +54 -54
- package/src/components/BaseTextInputModal.vue +140 -140
- package/src/components/BaseToolbarButton.test.ts +48 -48
- package/src/components/BaseToolbarButton.vue +94 -94
- package/src/composables/useFieldClasses.test.ts +55 -55
- package/src/composables/useFieldClasses.ts +56 -56
- package/src/composables/usePolling.test.ts +112 -112
- package/src/composables/usePolling.ts +102 -102
- package/src/composables/useQueryParamSync.test.ts +56 -56
- package/src/composables/useQueryParamSync.ts +42 -42
- package/src/composables/useThemeClasses.test.ts +34 -34
- package/src/composables/useThemeClasses.ts +137 -137
- package/src/index.ts +138 -138
- package/src/style.css +32 -32
- package/src/types/distribution.ts +7 -7
- package/src/types/pillPicker.ts +14 -14
- package/src/types/specField.ts +30 -30
- package/src/types/statBreakdown.ts +6 -6
- package/src/utils/format.test.ts +264 -264
- package/src/utils/format.ts +216 -216
- package/src/utils/httpColors.test.ts +100 -100
- package/src/utils/httpColors.ts +99 -99
- package/src/utils/kvRows.test.ts +47 -47
- package/src/utils/kvRows.ts +34 -34
- package/src/utils/specForm.test.ts +64 -53
- package/src/utils/specForm.ts +49 -48
- package/src/utils/validate.test.ts +82 -82
- package/src/utils/validate.ts +76 -76
package/src/utils/httpColors.ts
CHANGED
|
@@ -1,99 +1,99 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HTTP method / status code → Tailwind class helpers.
|
|
3
|
-
*
|
|
4
|
-
* Two visual families:
|
|
5
|
-
*
|
|
6
|
-
* Solid family (600 weight, white text): bold saturated badges for
|
|
7
|
-
* inline rows (e.g. request logs / detail modals).
|
|
8
|
-
*
|
|
9
|
-
* Bright/tinted family (500 weight + tinted backgrounds): softer
|
|
10
|
-
* badges for use on card surfaces, adapts to dark/light themes.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
type MethodColorMap = Readonly<Record<string, string>>
|
|
14
|
-
|
|
15
|
-
const SOLID_METHOD_COLORS: MethodColorMap = {
|
|
16
|
-
GET: 'bg-blue-600',
|
|
17
|
-
POST: 'bg-emerald-600',
|
|
18
|
-
PUT: 'bg-amber-600',
|
|
19
|
-
DELETE: 'bg-red-600',
|
|
20
|
-
PATCH: 'bg-purple-600',
|
|
21
|
-
HEAD: 'bg-gray-600',
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const BRIGHT_METHOD_COLORS: MethodColorMap = {
|
|
25
|
-
GET: 'bg-blue-500',
|
|
26
|
-
POST: 'bg-green-500',
|
|
27
|
-
PUT: 'bg-yellow-500',
|
|
28
|
-
DELETE: 'bg-red-500',
|
|
29
|
-
PATCH: 'bg-purple-500',
|
|
30
|
-
HEAD: 'bg-gray-500',
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Solid 600-weight method badge. */
|
|
34
|
-
export function methodBadgeSolid(method?: string): string {
|
|
35
|
-
if (!method) return 'bg-gray-600'
|
|
36
|
-
return SOLID_METHOD_COLORS[method.toUpperCase()] ?? 'bg-gray-600'
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Bright 500-weight method badge. */
|
|
40
|
-
export function methodBadgeBright(method?: string): string {
|
|
41
|
-
if (!method) return 'bg-gray-500'
|
|
42
|
-
return BRIGHT_METHOD_COLORS[method.toUpperCase()] ?? 'bg-gray-500'
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Solid 600-weight status badge with white text. */
|
|
46
|
-
export function statusBadgeSolid(status?: number): string {
|
|
47
|
-
const code = status ?? 0
|
|
48
|
-
if (code >= 200 && code < 300) return 'bg-emerald-600'
|
|
49
|
-
if (code >= 300 && code < 400) return 'bg-blue-600'
|
|
50
|
-
if (code >= 400 && code < 500) return 'bg-amber-600'
|
|
51
|
-
if (code >= 500) return 'bg-red-600'
|
|
52
|
-
return 'bg-gray-600'
|
|
53
|
-
}
|
|
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
|
-
|
|
86
|
-
/** Dark/light-aware tinted status badge. */
|
|
87
|
-
export function statusBadgeTinted(status: number | undefined, isDark: boolean): string {
|
|
88
|
-
const code = status ?? 200
|
|
89
|
-
if (isDark) {
|
|
90
|
-
if (code >= 200 && code < 300) return 'bg-green-500/15 text-green-400'
|
|
91
|
-
if (code >= 300 && code < 400) return 'bg-blue-500/15 text-blue-400'
|
|
92
|
-
if (code >= 400 && code < 500) return 'bg-yellow-500/15 text-yellow-400'
|
|
93
|
-
return 'bg-red-500/15 text-red-400'
|
|
94
|
-
}
|
|
95
|
-
if (code >= 200 && code < 300) return 'bg-green-100 text-green-800'
|
|
96
|
-
if (code >= 300 && code < 400) return 'bg-blue-100 text-blue-800'
|
|
97
|
-
if (code >= 400 && code < 500) return 'bg-yellow-100 text-yellow-800'
|
|
98
|
-
return 'bg-red-100 text-red-800'
|
|
99
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* HTTP method / status code → Tailwind class helpers.
|
|
3
|
+
*
|
|
4
|
+
* Two visual families:
|
|
5
|
+
*
|
|
6
|
+
* Solid family (600 weight, white text): bold saturated badges for
|
|
7
|
+
* inline rows (e.g. request logs / detail modals).
|
|
8
|
+
*
|
|
9
|
+
* Bright/tinted family (500 weight + tinted backgrounds): softer
|
|
10
|
+
* badges for use on card surfaces, adapts to dark/light themes.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
type MethodColorMap = Readonly<Record<string, string>>
|
|
14
|
+
|
|
15
|
+
const SOLID_METHOD_COLORS: MethodColorMap = {
|
|
16
|
+
GET: 'bg-blue-600',
|
|
17
|
+
POST: 'bg-emerald-600',
|
|
18
|
+
PUT: 'bg-amber-600',
|
|
19
|
+
DELETE: 'bg-red-600',
|
|
20
|
+
PATCH: 'bg-purple-600',
|
|
21
|
+
HEAD: 'bg-gray-600',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const BRIGHT_METHOD_COLORS: MethodColorMap = {
|
|
25
|
+
GET: 'bg-blue-500',
|
|
26
|
+
POST: 'bg-green-500',
|
|
27
|
+
PUT: 'bg-yellow-500',
|
|
28
|
+
DELETE: 'bg-red-500',
|
|
29
|
+
PATCH: 'bg-purple-500',
|
|
30
|
+
HEAD: 'bg-gray-500',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Solid 600-weight method badge. */
|
|
34
|
+
export function methodBadgeSolid(method?: string): string {
|
|
35
|
+
if (!method) return 'bg-gray-600'
|
|
36
|
+
return SOLID_METHOD_COLORS[method.toUpperCase()] ?? 'bg-gray-600'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Bright 500-weight method badge. */
|
|
40
|
+
export function methodBadgeBright(method?: string): string {
|
|
41
|
+
if (!method) return 'bg-gray-500'
|
|
42
|
+
return BRIGHT_METHOD_COLORS[method.toUpperCase()] ?? 'bg-gray-500'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Solid 600-weight status badge with white text. */
|
|
46
|
+
export function statusBadgeSolid(status?: number): string {
|
|
47
|
+
const code = status ?? 0
|
|
48
|
+
if (code >= 200 && code < 300) return 'bg-emerald-600'
|
|
49
|
+
if (code >= 300 && code < 400) return 'bg-blue-600'
|
|
50
|
+
if (code >= 400 && code < 500) return 'bg-amber-600'
|
|
51
|
+
if (code >= 500) return 'bg-red-600'
|
|
52
|
+
return 'bg-gray-600'
|
|
53
|
+
}
|
|
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
|
+
|
|
86
|
+
/** Dark/light-aware tinted status badge. */
|
|
87
|
+
export function statusBadgeTinted(status: number | undefined, isDark: boolean): string {
|
|
88
|
+
const code = status ?? 200
|
|
89
|
+
if (isDark) {
|
|
90
|
+
if (code >= 200 && code < 300) return 'bg-green-500/15 text-green-400'
|
|
91
|
+
if (code >= 300 && code < 400) return 'bg-blue-500/15 text-blue-400'
|
|
92
|
+
if (code >= 400 && code < 500) return 'bg-yellow-500/15 text-yellow-400'
|
|
93
|
+
return 'bg-red-500/15 text-red-400'
|
|
94
|
+
}
|
|
95
|
+
if (code >= 200 && code < 300) return 'bg-green-100 text-green-800'
|
|
96
|
+
if (code >= 300 && code < 400) return 'bg-blue-100 text-blue-800'
|
|
97
|
+
if (code >= 400 && code < 500) return 'bg-yellow-100 text-yellow-800'
|
|
98
|
+
return 'bg-red-100 text-red-800'
|
|
99
|
+
}
|
package/src/utils/kvRows.test.ts
CHANGED
|
@@ -1,47 +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
|
-
})
|
|
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
|
+
})
|
package/src/utils/kvRows.ts
CHANGED
|
@@ -1,34 +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
|
-
}
|
|
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
|
+
}
|
|
@@ -1,53 +1,64 @@
|
|
|
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('
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
expect(
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
expect(
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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('flags a fractional value on an integer field', () => {
|
|
35
|
+
const withInt: SpecField[] = [
|
|
36
|
+
...specs,
|
|
37
|
+
{ key: 'quantityPerSymbol', label: 'Quantity per symbol (shares)', type: 'integer', default: 0, min: 0, step: 1, options: null, help: '' },
|
|
38
|
+
]
|
|
39
|
+
const base = { amountPerSymbol: 10, stopLossPercent: 1 }
|
|
40
|
+
expect(firstInvalidNumericSpec(withInt, { ...base, quantityPerSymbol: 0.1 }))
|
|
41
|
+
.toBe('Quantity per symbol (shares)')
|
|
42
|
+
expect(firstInvalidNumericSpec(withInt, { ...base, quantityPerSymbol: 2 })).toBeNull()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('accepts a fully valid value set and tolerates missing specs', () => {
|
|
46
|
+
expect(firstInvalidNumericSpec(specs, { amountPerSymbol: 10, stopLossPercent: 1 })).toBeNull()
|
|
47
|
+
expect(firstInvalidNumericSpec(undefined, {})).toBeNull()
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('buildSpecParams', () => {
|
|
52
|
+
it('seeds from defaults, keeping caller-supplied overlaps', () => {
|
|
53
|
+
expect(buildSpecParams(specs, { amountPerSymbol: 10 }).amountPerSymbol).toBe(10)
|
|
54
|
+
expect(buildSpecParams(specs, {}).amountPerSymbol).toBe(4)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('seeds an optional field with null (renders as an empty input)', () => {
|
|
58
|
+
expect(buildSpecParams(specs, {}).stopLossPercent).toBeNull()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('returns an empty map when there are no specs', () => {
|
|
62
|
+
expect(buildSpecParams(undefined, { anything: 1 })).toEqual({})
|
|
63
|
+
})
|
|
64
|
+
})
|
package/src/utils/specForm.ts
CHANGED
|
@@ -1,48 +1,49 @@
|
|
|
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
|
-
|
|
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. Integer specs additionally reject fractional values.
|
|
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
|
+
if (spec.type === 'integer' && !Number.isInteger(v)) return spec.label
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null
|
|
49
|
+
}
|