mgv-backoffice 1.30.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.
@@ -45,6 +45,29 @@ export declare function fmtPct(n: number, digits?: number): string;
45
45
  * Format a signed USD amount with an explicit leading sign (e.g. "+$5.00").
46
46
  */
47
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;
48
71
  /**
49
72
  * Format a millisecond duration as compact day/hour/minute units, e.g.
50
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mgv-backoffice",
3
- "version": "1.30.0",
3
+ "version": "1.31.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,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
+ })
@@ -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
+ }
package/src/index.ts CHANGED
@@ -62,6 +62,8 @@ export { useSidebarCollapse } from './composables/useSidebarCollapse'
62
62
  export type { UseSidebarCollapseOptions } from './composables/useSidebarCollapse'
63
63
  export { useNotifications } from './composables/useNotifications'
64
64
  export { useQueryParamSync } from './composables/useQueryParamSync'
65
+ export { useFieldClasses } from './composables/useFieldClasses'
66
+ export type { FieldClasses } from './composables/useFieldClasses'
65
67
 
66
68
  // Enums
67
69
  export { AlertEnum } from './enums/AlertEnum'
@@ -92,9 +94,13 @@ export { getBaseColor, getBaseColorOf } from './utils/util'
92
94
  export {
93
95
  methodBadgeSolid,
94
96
  methodBadgeBright,
97
+ methodBadgeTinted,
95
98
  statusBadgeSolid,
96
99
  statusBadgeTinted,
100
+ statusBadgeSoft,
97
101
  } from './utils/httpColors'
102
+ export { rowKeyMissing, rowValueMissing } from './utils/kvRows'
103
+ export type { KeyValueRowLike } from './utils/kvRows'
98
104
  export { sanitizeHtml, isSafeHref } from './utils/sanitizeHtml'
99
105
  export {
100
106
  fmtNumber,
@@ -102,6 +108,10 @@ export {
102
108
  fmtDateTime,
103
109
  fmtDateTimeMs,
104
110
  fmtDateShort,
111
+ fmtCalendarDate,
112
+ fmtCalendarDateTime,
113
+ fmtMsAsSeconds,
114
+ fmtBytes,
105
115
  fmtPrice,
106
116
  fmtPct,
107
117
  fmtUsd,
@@ -178,3 +178,58 @@ describe('fmtDuration', () => {
178
178
  expect(fmtDuration(59_000)).toBe('0m')
179
179
  })
180
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
+ })
@@ -111,6 +111,63 @@ export function fmtUsd(v: number): string {
111
111
  return `${sign}$${Math.abs(v).toFixed(2)}`
112
112
  }
113
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
+
114
171
  /**
115
172
  * Format a millisecond duration as compact day/hour/minute units, e.g.
116
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
+ })
@@ -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
+ }