mgv-backoffice 1.33.0 → 1.35.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.
@@ -1,4 +1,4 @@
1
- import { computed, type ComputedRef } from 'vue'
1
+ import { computed, reactive } from 'vue'
2
2
  import { useTheme } from './useTheme'
3
3
 
4
4
  /**
@@ -14,69 +14,73 @@ import { useTheme } from './useTheme'
14
14
  * <p :class="t.mutedText">...</p>
15
15
  * </div>
16
16
  *
17
- * Vue auto-unwraps the computed refs inside template `:class` bindings,
18
- * so callers do not write `.value`.
17
+ * The returned object is `reactive()`, so property access yields the
18
+ * plain class string while staying reactive to theme flips. (It used to
19
+ * be a plain object of ComputedRefs — Vue does NOT unwrap refs nested in
20
+ * plain objects inside template bindings, so `:class="t.card"` rendered
21
+ * the ref's own keys — `fn dep __v_isRef …` — instead of the classes.)
22
+ * Property access is string-valued now: do not append `.value`.
19
23
  */
20
24
  export interface ThemeClasses {
21
25
  // -- Surfaces --
22
26
  /** Card surface: bg-gray-800 / bg-white, with border. */
23
- card: ComputedRef<string>
27
+ card: string
24
28
  /** Slightly darker card surface: bg-gray-900 / bg-white, with border. */
25
- cardAlt: ComputedRef<string>
29
+ cardAlt: string
26
30
  /** Page background. */
27
- pageBg: ComputedRef<string>
31
+ pageBg: string
28
32
 
29
33
  // -- Borders --
30
34
  /** Standard border between cards/sections. */
31
- border: ComputedRef<string>
35
+ border: string
32
36
  /** Subtle divider (lighter than border). */
33
- divider: ComputedRef<string>
37
+ divider: string
34
38
 
35
39
  // -- Text hierarchy --
36
40
  /** Page headings: white / gray-900. */
37
- primaryText: ComputedRef<string>
41
+ primaryText: string
38
42
  /** Softer headings: gray-100 / gray-800. */
39
- primaryTextSoft: ComputedRef<string>
43
+ primaryTextSoft: string
40
44
  /** Body copy on cards: gray-200 / gray-700. */
41
- bodyText: ComputedRef<string>
45
+ bodyText: string
42
46
  /** Form labels: gray-300 / gray-700. */
43
- label: ComputedRef<string>
47
+ label: string
44
48
  /** Secondary text: gray-400 / gray-600. */
45
- mutedText: ComputedRef<string>
49
+ mutedText: string
46
50
  /** Tertiary text: gray-500 / gray-600. */
47
- subtleText: ComputedRef<string>
51
+ subtleText: string
48
52
  /** Dimmest text: gray-500 in dark / gray-400 in light. */
49
- dimText: ComputedRef<string>
53
+ dimText: string
50
54
  /** Slightly more contrast than dimText: gray-400 / gray-500. */
51
- dimTextAlt: ComputedRef<string>
55
+ dimTextAlt: string
52
56
  /** Big illustration / empty-state icon: gray-600 / gray-300. */
53
- illustration: ComputedRef<string>
57
+ illustration: string
54
58
 
55
59
  // -- Inputs --
56
60
  /** Standard text input. */
57
- input: ComputedRef<string>
61
+ input: string
58
62
  /** Input + placeholder colour. */
59
- inputWithPlaceholder: ComputedRef<string>
63
+ inputWithPlaceholder: string
60
64
 
61
65
  // -- Buttons --
62
66
  /** Ghost / Cancel button. */
63
- ghostButton: ComputedRef<string>
67
+ ghostButton: string
64
68
 
65
69
  // -- Accent text colours --
66
70
  /** Emerald accent text: emerald-400 / emerald-600. */
67
- emeraldText: ComputedRef<string>
71
+ emeraldText: string
68
72
  /** Amber accent text: amber-400 / amber-600. */
69
- amberText: ComputedRef<string>
73
+ amberText: string
70
74
  /** Amber heading-weight: amber-300 / amber-700. */
71
- amberTextStrong: ComputedRef<string>
75
+ amberTextStrong: string
72
76
  /** Red accent text: red-400 / red-500. */
73
- redText: ComputedRef<string>
77
+ redText: string
74
78
  }
75
79
 
76
80
  export function useThemeClasses(): ThemeClasses {
77
81
  const { isDark } = useTheme()
78
82
 
79
- return {
83
+ return reactive({
80
84
  card: computed(() =>
81
85
  isDark.value
82
86
  ? 'bg-gray-800 border-gray-700'
@@ -129,5 +133,5 @@ export function useThemeClasses(): ThemeClasses {
129
133
  isDark.value ? 'text-amber-300' : 'text-amber-700',
130
134
  ),
131
135
  redText: computed(() => (isDark.value ? 'text-red-400' : 'text-red-500')),
132
- }
136
+ })
133
137
  }
package/src/index.ts CHANGED
@@ -37,6 +37,7 @@ export { default as BaseChipButton } from './components/BaseChipButton.vue'
37
37
  export { default as BaseRemoveButton } from './components/BaseRemoveButton.vue'
38
38
  export { default as BaseStatusPill } from './components/BaseStatusPill.vue'
39
39
  export { default as BaseFileDropzone } from './components/BaseFileDropzone.vue'
40
+ export { default as BaseCodeBlock } from './components/BaseCodeBlock.vue'
40
41
 
41
42
  // Components — extracted from TradeAutomation
42
43
  export { default as BaseInput } from './components/BaseInput.vue'
@@ -48,6 +49,7 @@ export { default as BaseSpecFields } from './components/BaseSpecFields.vue'
48
49
  export { default as BaseStatBreakdown } from './components/BaseStatBreakdown.vue'
49
50
  export { default as BaseFilterChip } from './components/BaseFilterChip.vue'
50
51
  export { default as BaseCredentialsForm } from './components/BaseCredentialsForm.vue'
52
+ export { default as BasePillPickerModal } from './components/BasePillPickerModal.vue'
51
53
  export type { CredentialsView, CredentialsUpdate } from './components/BaseCredentialsForm.vue'
52
54
 
53
55
  // Composables
@@ -65,6 +67,8 @@ export { useNotifications } from './composables/useNotifications'
65
67
  export { useQueryParamSync } from './composables/useQueryParamSync'
66
68
  export { useFieldClasses } from './composables/useFieldClasses'
67
69
  export type { FieldClasses } from './composables/useFieldClasses'
70
+ export { usePolling } from './composables/usePolling'
71
+ export type { UsePollingOptions } from './composables/usePolling'
68
72
 
69
73
  // Enums
70
74
  export { AlertEnum } from './enums/AlertEnum'
@@ -89,6 +93,7 @@ export type { DropdownOption } from './types/dropdown'
89
93
  export type { TableColumn } from './types/table'
90
94
  export type { SpecField, SpecFieldType, SpecFieldValue } from './types/specField'
91
95
  export type { StatBreakdownItem } from './types/statBreakdown'
96
+ export type { PillPickerItem } from './types/pillPicker'
92
97
 
93
98
  // Utils
94
99
  export { getBaseColor, getBaseColorOf } from './utils/util'
@@ -117,7 +122,15 @@ export {
117
122
  fmtPct,
118
123
  fmtUsd,
119
124
  fmtDuration,
125
+ formatJson,
126
+ stringifyValue,
120
127
  } from './utils/format'
128
+ export {
129
+ isValidAbsoluteUrl,
130
+ isValidJson,
131
+ isValidXml,
132
+ isValidBase64,
133
+ } from './utils/validate'
121
134
  export { buildSpecParams, firstInvalidNumericSpec } from './utils/specForm'
122
135
  export { computePnL } from './utils/pnl'
123
136
  export type { PnL, PnLInputs } from './utils/pnl'
@@ -0,0 +1,14 @@
1
+ /**
2
+ * One clickable pill in a BasePillPickerModal.
3
+ *
4
+ * `id` must be unique (it keys the pill and identifies the pick),
5
+ * `label` is what the pill shows and what the free-text filter matches,
6
+ * `group` assigns the pill to one of the modal's segmented-filter groups,
7
+ * `title` becomes the pill's tooltip.
8
+ */
9
+ export interface PillPickerItem {
10
+ id: string
11
+ label: string
12
+ group?: string
13
+ title?: string
14
+ }
@@ -9,6 +9,8 @@ import {
9
9
  fmtPct,
10
10
  fmtUsd,
11
11
  fmtDuration,
12
+ formatJson,
13
+ stringifyValue,
12
14
  } from './format'
13
15
 
14
16
  describe('fmtDateTimeMs', () => {
@@ -233,3 +235,30 @@ describe('fmtBytes', () => {
233
235
  expect(fmtBytes(null)).toBe('')
234
236
  })
235
237
  })
238
+
239
+ describe('formatJson', () => {
240
+ it('pretty-prints parseable JSON with 2-space indentation', () => {
241
+ expect(formatJson('{"a":1,"b":[2,3]}')).toBe('{\n "a": 1,\n "b": [\n 2,\n 3\n ]\n}')
242
+ })
243
+ it('returns non-JSON input verbatim', () => {
244
+ expect(formatJson('plain text')).toBe('plain text')
245
+ expect(formatJson('<xml/>')).toBe('<xml/>')
246
+ expect(formatJson('')).toBe('')
247
+ })
248
+ })
249
+
250
+ describe('stringifyValue', () => {
251
+ it('passes strings through and blanks null/undefined', () => {
252
+ expect(stringifyValue('abc')).toBe('abc')
253
+ expect(stringifyValue(null)).toBe('')
254
+ expect(stringifyValue(undefined)).toBe('')
255
+ })
256
+ it('JSON-serializes objects, arrays and numbers', () => {
257
+ expect(stringifyValue({ a: 1 })).toBe('{"a":1}')
258
+ expect(stringifyValue([1, 2])).toBe('[1,2]')
259
+ expect(stringifyValue(42)).toBe('42')
260
+ })
261
+ it('falls back to String() when JSON.stringify throws', () => {
262
+ expect(stringifyValue(1n)).toBe('1')
263
+ })
264
+ })
@@ -168,6 +168,34 @@ export function fmtBytes(bytes: number | null | undefined): string {
168
168
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
169
169
  }
170
170
 
171
+ /**
172
+ * Pretty-print a string that may contain JSON: parseable input is
173
+ * re-serialized with 2-space indentation, anything else is returned
174
+ * verbatim. Handy for request/response bodies of unknown content type.
175
+ */
176
+ export function formatJson(content: string): string {
177
+ try {
178
+ return JSON.stringify(JSON.parse(content), null, 2)
179
+ } catch {
180
+ return content
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Render an unknown value as a display string: strings pass through,
186
+ * null/undefined become '', everything else is JSON-serialized (falling
187
+ * back to String() for values JSON can't handle, e.g. cycles or BigInt).
188
+ */
189
+ export function stringifyValue(value: unknown): string {
190
+ if (value === null || value === undefined) return ''
191
+ if (typeof value === 'string') return value
192
+ try {
193
+ return JSON.stringify(value)
194
+ } catch {
195
+ return String(value)
196
+ }
197
+ }
198
+
171
199
  /**
172
200
  * Format a millisecond duration as compact day/hour/minute units, e.g.
173
201
  * "3d 5h", "5h 12m" or "12m". Zero-value leading units are dropped;
@@ -0,0 +1,82 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ isValidAbsoluteUrl,
4
+ isValidJson,
5
+ isValidXml,
6
+ isValidBase64,
7
+ } from './validate'
8
+
9
+ describe('isValidAbsoluteUrl', () => {
10
+ it('accepts absolute http/https URLs', () => {
11
+ expect(isValidAbsoluteUrl('http://example.com')).toBe(true)
12
+ expect(isValidAbsoluteUrl('https://example.com/path?q=1')).toBe(true)
13
+ expect(isValidAbsoluteUrl(' https://example.com ')).toBe(true)
14
+ })
15
+
16
+ it('rejects empty, relative and non-http schemes', () => {
17
+ expect(isValidAbsoluteUrl('')).toBe(false)
18
+ expect(isValidAbsoluteUrl(' ')).toBe(false)
19
+ expect(isValidAbsoluteUrl('/relative/path')).toBe(false)
20
+ expect(isValidAbsoluteUrl('example.com')).toBe(false)
21
+ expect(isValidAbsoluteUrl('ftp://example.com')).toBe(false)
22
+ expect(isValidAbsoluteUrl('mailto:a@b.c')).toBe(false)
23
+ expect(isValidAbsoluteUrl('not a url')).toBe(false)
24
+ })
25
+ })
26
+
27
+ describe('isValidJson', () => {
28
+ it('treats empty input as valid', () => {
29
+ expect(isValidJson('')).toBe(true)
30
+ expect(isValidJson(' \n ')).toBe(true)
31
+ })
32
+
33
+ it('accepts well-formed JSON of any root type', () => {
34
+ expect(isValidJson('{"a":1}')).toBe(true)
35
+ expect(isValidJson('[1,2]')).toBe(true)
36
+ expect(isValidJson('"str"')).toBe(true)
37
+ expect(isValidJson('42')).toBe(true)
38
+ })
39
+
40
+ it('rejects malformed JSON', () => {
41
+ expect(isValidJson('{a:1}')).toBe(false)
42
+ expect(isValidJson('{"a":1,}')).toBe(false)
43
+ expect(isValidJson('<xml/>')).toBe(false)
44
+ })
45
+ })
46
+
47
+ describe('isValidXml', () => {
48
+ it('treats empty input as valid', () => {
49
+ expect(isValidXml('')).toBe(true)
50
+ expect(isValidXml(' ')).toBe(true)
51
+ })
52
+
53
+ it('accepts well-formed XML', () => {
54
+ expect(isValidXml('<root><child a="1"/></root>')).toBe(true)
55
+ expect(isValidXml('<?xml version="1.0"?><r/>')).toBe(true)
56
+ })
57
+
58
+ it('rejects malformed XML', () => {
59
+ expect(isValidXml('<root><unclosed></root>')).toBe(false)
60
+ expect(isValidXml('just text')).toBe(false)
61
+ expect(isValidXml('<a><b></a></b>')).toBe(false)
62
+ })
63
+ })
64
+
65
+ describe('isValidBase64', () => {
66
+ it('treats empty input as valid', () => {
67
+ expect(isValidBase64('')).toBe(true)
68
+ expect(isValidBase64(' \n ')).toBe(true)
69
+ })
70
+
71
+ it('accepts well-formed base64, ignoring embedded whitespace', () => {
72
+ expect(isValidBase64('aGVsbG8=')).toBe(true)
73
+ expect(isValidBase64('aGVs\nbG8=')).toBe(true)
74
+ expect(isValidBase64('YQ==')).toBe(true)
75
+ })
76
+
77
+ it('rejects bad lengths, bad alphabets and undecodable input', () => {
78
+ expect(isValidBase64('aGVsbG8')).toBe(false) // length % 4 !== 0
79
+ expect(isValidBase64('aGV$bG8=')).toBe(false) // bad character
80
+ expect(isValidBase64('====')).toBe(false) // padding only
81
+ })
82
+ })
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Pure, dependency-free input validators shared across back-office forms —
3
+ * extracted from WireMate's mock form.
4
+ *
5
+ * Semantics note: the payload validators (`isValidJson`, `isValidXml`,
6
+ * `isValidBase64`) treat empty/whitespace-only input as VALID — callers
7
+ * gate on "is there something to check" separately (required-ness is a
8
+ * different rule from well-formedness). `isValidAbsoluteUrl` is the
9
+ * opposite: it validates a value that must exist, so empty is invalid.
10
+ */
11
+
12
+ /**
13
+ * `true` when `value` parses as an absolute http:// or https:// URL.
14
+ * Deliberately tightens the scheme list — backends' HTTP clients won't
15
+ * follow ftp:// or mailto: anyway, so a stray scheme is a typo.
16
+ */
17
+ export function isValidAbsoluteUrl(value: string): boolean {
18
+ const trimmed = (value ?? '').trim()
19
+ if (!trimmed) return false
20
+ try {
21
+ const url = new URL(trimmed)
22
+ return url.protocol === 'http:' || url.protocol === 'https:'
23
+ } catch {
24
+ return false
25
+ }
26
+ }
27
+
28
+ /**
29
+ * `true` when `str` is empty or parses as JSON.
30
+ */
31
+ export function isValidJson(str: string): boolean {
32
+ if (!str.trim()) return true
33
+ try {
34
+ JSON.parse(str)
35
+ return true
36
+ } catch {
37
+ return false
38
+ }
39
+ }
40
+
41
+ /**
42
+ * `true` when `str` is empty or is well-formed XML. Runs the browser's
43
+ * DOMParser and checks for a `<parsererror>` node — the only reliable
44
+ * signal DOMParser provides, since it never throws on bad input.
45
+ */
46
+ export function isValidXml(str: string): boolean {
47
+ if (!str.trim()) return true
48
+ try {
49
+ const doc = new DOMParser().parseFromString(str, 'application/xml')
50
+ return !doc.getElementsByTagName('parsererror').length
51
+ } catch {
52
+ return false
53
+ }
54
+ }
55
+
56
+ /**
57
+ * `true` when `str` is empty or is well-formed base64 (after stripping
58
+ * whitespace/newlines, which are harmless in both RFC 4648 and most
59
+ * real-world payloads). Rejects lengths that can't possibly decode
60
+ * (`length % 4 !== 0` after padding) — the common "off by a character"
61
+ * copy/paste issue — then defers to the native decoder as the final
62
+ * authority.
63
+ */
64
+ export function isValidBase64(str: string): boolean {
65
+ if (!str.trim()) return true
66
+ const compact = str.replace(/\s+/g, '')
67
+ if (!compact) return true
68
+ if (compact.length % 4 !== 0) return false
69
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(compact)) return false
70
+ try {
71
+ atob(compact)
72
+ return true
73
+ } catch {
74
+ return false
75
+ }
76
+ }