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/dist/utils/specForm.d.ts
CHANGED
|
@@ -15,6 +15,6 @@ export declare function buildSpecParams(specs: readonly SpecField[] | undefined,
|
|
|
15
15
|
* '' under number coercion), or null when every numeric field holds a number.
|
|
16
16
|
* A spec whose `default` is null is OPTIONAL: blank means "knob disabled" and
|
|
17
17
|
* must pass; a non-blank value on an optional field is still validated as a
|
|
18
|
-
* number.
|
|
18
|
+
* number. Integer specs additionally reject fractional values.
|
|
19
19
|
*/
|
|
20
20
|
export declare function firstInvalidNumericSpec(specs: readonly SpecField[] | undefined, params: Record<string, SpecFieldValue>): string | null;
|
package/package.json
CHANGED
|
@@ -1,54 +1,54 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "mgv-backoffice",
|
|
3
|
-
"version": "1.36.
|
|
4
|
-
"description": "Shared Vue 3 UI component library",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "./dist/ui-lib.umd.cjs",
|
|
7
|
-
"module": "./dist/ui-lib.js",
|
|
8
|
-
"types": "./dist/index.d.ts",
|
|
9
|
-
"exports": {
|
|
10
|
-
".": {
|
|
11
|
-
"types": "./dist/index.d.ts",
|
|
12
|
-
"import": "./dist/ui-lib.js",
|
|
13
|
-
"require": "./dist/ui-lib.umd.cjs"
|
|
14
|
-
},
|
|
15
|
-
"./dist/style.css": "./dist/ui-lib.css",
|
|
16
|
-
"./tailwind.safelist": "./tailwind.safelist.js",
|
|
17
|
-
"./tailwind.safelist.css": "./tailwind.safelist.css"
|
|
18
|
-
},
|
|
19
|
-
"files": [
|
|
20
|
-
"dist",
|
|
21
|
-
"src",
|
|
22
|
-
"tailwind.safelist.js",
|
|
23
|
-
"tailwind.safelist.css"
|
|
24
|
-
],
|
|
25
|
-
"scripts": {
|
|
26
|
-
"safelist": "node scripts/generate-safelist.mjs",
|
|
27
|
-
"prebuild": "npm run safelist",
|
|
28
|
-
"build": "vite build",
|
|
29
|
-
"dev": "vite build --watch",
|
|
30
|
-
"test": "vitest run",
|
|
31
|
-
"test:watch": "vitest"
|
|
32
|
-
},
|
|
33
|
-
"peerDependencies": {
|
|
34
|
-
"@heroicons/vue": "^2.0.0",
|
|
35
|
-
"vue": "^3.5.0",
|
|
36
|
-
"vue-router": "^5.0.0"
|
|
37
|
-
},
|
|
38
|
-
"devDependencies": {
|
|
39
|
-
"@heroicons/vue": "^2.2.0",
|
|
40
|
-
"@tailwindcss/vite": "^4.3.1",
|
|
41
|
-
"@vitejs/plugin-vue": "^6.0.7",
|
|
42
|
-
"@vue/language-core": "^3.3.5",
|
|
43
|
-
"@vue/test-utils": "^2.4.11",
|
|
44
|
-
"jsdom": "^29.1.1",
|
|
45
|
-
"sass": "^1.101.0",
|
|
46
|
-
"tailwindcss": "^4.3.1",
|
|
47
|
-
"typescript": "^6.0.3",
|
|
48
|
-
"vite": "^8.1.0",
|
|
49
|
-
"vite-plugin-dts": "^5.0.3",
|
|
50
|
-
"vitest": "^4.1.9",
|
|
51
|
-
"vue": "^3.5.38",
|
|
52
|
-
"vue-router": "^5.1.0"
|
|
53
|
-
}
|
|
54
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "mgv-backoffice",
|
|
3
|
+
"version": "1.36.2",
|
|
4
|
+
"description": "Shared Vue 3 UI component library",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/ui-lib.umd.cjs",
|
|
7
|
+
"module": "./dist/ui-lib.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/ui-lib.js",
|
|
13
|
+
"require": "./dist/ui-lib.umd.cjs"
|
|
14
|
+
},
|
|
15
|
+
"./dist/style.css": "./dist/ui-lib.css",
|
|
16
|
+
"./tailwind.safelist": "./tailwind.safelist.js",
|
|
17
|
+
"./tailwind.safelist.css": "./tailwind.safelist.css"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"src",
|
|
22
|
+
"tailwind.safelist.js",
|
|
23
|
+
"tailwind.safelist.css"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"safelist": "node scripts/generate-safelist.mjs",
|
|
27
|
+
"prebuild": "npm run safelist",
|
|
28
|
+
"build": "vite build",
|
|
29
|
+
"dev": "vite build --watch",
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"test:watch": "vitest"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@heroicons/vue": "^2.0.0",
|
|
35
|
+
"vue": "^3.5.0",
|
|
36
|
+
"vue-router": "^5.0.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@heroicons/vue": "^2.2.0",
|
|
40
|
+
"@tailwindcss/vite": "^4.3.1",
|
|
41
|
+
"@vitejs/plugin-vue": "^6.0.7",
|
|
42
|
+
"@vue/language-core": "^3.3.5",
|
|
43
|
+
"@vue/test-utils": "^2.4.11",
|
|
44
|
+
"jsdom": "^29.1.1",
|
|
45
|
+
"sass": "^1.101.0",
|
|
46
|
+
"tailwindcss": "^4.3.1",
|
|
47
|
+
"typescript": "^6.0.3",
|
|
48
|
+
"vite": "^8.1.0",
|
|
49
|
+
"vite-plugin-dts": "^5.0.3",
|
|
50
|
+
"vitest": "^4.1.9",
|
|
51
|
+
"vue": "^3.5.38",
|
|
52
|
+
"vue-router": "^5.1.0"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -1,104 +1,104 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<!--
|
|
3
|
-
Inline notice panel (error / warning / success / info).
|
|
4
|
-
|
|
5
|
-
v2 (1.27.0): rewritten from the legacy dynamic-class version —
|
|
6
|
-
• theme-aware via the shared `isDark` ref (same pattern as the other
|
|
7
|
-
extracted components; no runtime-composed class names, so every
|
|
8
|
-
class survives Tailwind's static scan)
|
|
9
|
-
• optional default slot for body content below the title
|
|
10
|
-
• `compact` variant for slim in-form warnings (text-xs, small icon)
|
|
11
|
-
Backward compatible: `<BaseAlert title="..." :color="AlertEnum.X" />`
|
|
12
|
-
renders the same single-line alert as before, mb-4 included.
|
|
13
|
-
-->
|
|
14
|
-
<div
|
|
15
|
-
role="alert"
|
|
16
|
-
class="flex gap-3 mb-4 rounded-lg border text-sm transition-colors"
|
|
17
|
-
:class="[compact ? 'items-start px-3 py-2 text-xs' : 'items-start p-4', palette]"
|
|
18
|
-
>
|
|
19
|
-
<component
|
|
20
|
-
:is="iconComponent"
|
|
21
|
-
class="shrink-0"
|
|
22
|
-
:class="[compact ? 'w-4 h-4 mt-px' : 'w-5 h-5 mt-0.5', iconColorClass]"
|
|
23
|
-
aria-hidden="true"
|
|
24
|
-
/>
|
|
25
|
-
<div class="min-w-0 flex-1">
|
|
26
|
-
<p v-if="title" class="font-medium">{{ title }}</p>
|
|
27
|
-
<!-- Body — caller-styled free-form content under the title. -->
|
|
28
|
-
<slot />
|
|
29
|
-
</div>
|
|
30
|
-
</div>
|
|
31
|
-
</template>
|
|
32
|
-
|
|
33
|
-
<script lang="ts" setup>
|
|
34
|
-
import {
|
|
35
|
-
CheckCircleIcon,
|
|
36
|
-
ExclamationCircleIcon,
|
|
37
|
-
ExclamationTriangleIcon,
|
|
38
|
-
InformationCircleIcon,
|
|
39
|
-
} from '@heroicons/vue/24/outline'
|
|
40
|
-
import { AlertEnum } from '../enums/AlertEnum'
|
|
41
|
-
import { computed } from 'vue'
|
|
42
|
-
import { useTheme } from '../composables/useTheme'
|
|
43
|
-
|
|
44
|
-
interface Props {
|
|
45
|
-
/** Bold headline. Optional when the slot carries the whole message. */
|
|
46
|
-
title?: string
|
|
47
|
-
/** Alert mode — drives palette and icon. Defaults to ERROR. */
|
|
48
|
-
color?: AlertEnum
|
|
49
|
-
/** Slim variant for in-form warnings: text-xs, smaller icon/padding. */
|
|
50
|
-
compact?: boolean
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const props = withDefaults(defineProps<Props>(), {
|
|
54
|
-
title: '',
|
|
55
|
-
color: AlertEnum.ERROR,
|
|
56
|
-
compact: false,
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
const { isDark } = useTheme()
|
|
60
|
-
|
|
61
|
-
// Literal palette table so Tailwind's scanner sees every class.
|
|
62
|
-
const PALETTES: Record<AlertEnum, { light: string; dark: string }> = {
|
|
63
|
-
[AlertEnum.ERROR]: {
|
|
64
|
-
light: 'bg-red-50 border-red-200 text-red-700',
|
|
65
|
-
dark: 'bg-red-500/10 border-red-500/30 text-red-300',
|
|
66
|
-
},
|
|
67
|
-
[AlertEnum.WARNING]: {
|
|
68
|
-
light: 'bg-amber-50 border-amber-200 text-amber-700',
|
|
69
|
-
dark: 'bg-amber-500/10 border-amber-500/30 text-amber-300',
|
|
70
|
-
},
|
|
71
|
-
[AlertEnum.SUCCESS]: {
|
|
72
|
-
light: 'bg-emerald-50 border-emerald-200 text-emerald-700',
|
|
73
|
-
dark: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-300',
|
|
74
|
-
},
|
|
75
|
-
[AlertEnum.INFO]: {
|
|
76
|
-
light: 'bg-sky-50 border-sky-200 text-sky-700',
|
|
77
|
-
dark: 'bg-sky-500/10 border-sky-500/30 text-sky-300',
|
|
78
|
-
},
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const palette = computed(() =>
|
|
82
|
-
isDark.value ? PALETTES[props.color].dark : PALETTES[props.color].light,
|
|
83
|
-
)
|
|
84
|
-
|
|
85
|
-
// Icon + tint follow the alert mode so an error doesn't render with a
|
|
86
|
-
// neutral info glyph.
|
|
87
|
-
const iconComponent = computed(() => {
|
|
88
|
-
switch (props.color) {
|
|
89
|
-
case AlertEnum.SUCCESS: return CheckCircleIcon
|
|
90
|
-
case AlertEnum.WARNING: return ExclamationTriangleIcon
|
|
91
|
-
case AlertEnum.INFO: return InformationCircleIcon
|
|
92
|
-
default: return ExclamationCircleIcon
|
|
93
|
-
}
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
const iconColorClass = computed(() => {
|
|
97
|
-
switch (props.color) {
|
|
98
|
-
case AlertEnum.SUCCESS: return 'text-emerald-500'
|
|
99
|
-
case AlertEnum.WARNING: return 'text-amber-500'
|
|
100
|
-
case AlertEnum.INFO: return 'text-sky-500'
|
|
101
|
-
default: return 'text-red-500'
|
|
102
|
-
}
|
|
103
|
-
})
|
|
104
|
-
</script>
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Inline notice panel (error / warning / success / info).
|
|
4
|
+
|
|
5
|
+
v2 (1.27.0): rewritten from the legacy dynamic-class version —
|
|
6
|
+
• theme-aware via the shared `isDark` ref (same pattern as the other
|
|
7
|
+
extracted components; no runtime-composed class names, so every
|
|
8
|
+
class survives Tailwind's static scan)
|
|
9
|
+
• optional default slot for body content below the title
|
|
10
|
+
• `compact` variant for slim in-form warnings (text-xs, small icon)
|
|
11
|
+
Backward compatible: `<BaseAlert title="..." :color="AlertEnum.X" />`
|
|
12
|
+
renders the same single-line alert as before, mb-4 included.
|
|
13
|
+
-->
|
|
14
|
+
<div
|
|
15
|
+
role="alert"
|
|
16
|
+
class="flex gap-3 mb-4 rounded-lg border text-sm transition-colors"
|
|
17
|
+
:class="[compact ? 'items-start px-3 py-2 text-xs' : 'items-start p-4', palette]"
|
|
18
|
+
>
|
|
19
|
+
<component
|
|
20
|
+
:is="iconComponent"
|
|
21
|
+
class="shrink-0"
|
|
22
|
+
:class="[compact ? 'w-4 h-4 mt-px' : 'w-5 h-5 mt-0.5', iconColorClass]"
|
|
23
|
+
aria-hidden="true"
|
|
24
|
+
/>
|
|
25
|
+
<div class="min-w-0 flex-1">
|
|
26
|
+
<p v-if="title" class="font-medium">{{ title }}</p>
|
|
27
|
+
<!-- Body — caller-styled free-form content under the title. -->
|
|
28
|
+
<slot />
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</template>
|
|
32
|
+
|
|
33
|
+
<script lang="ts" setup>
|
|
34
|
+
import {
|
|
35
|
+
CheckCircleIcon,
|
|
36
|
+
ExclamationCircleIcon,
|
|
37
|
+
ExclamationTriangleIcon,
|
|
38
|
+
InformationCircleIcon,
|
|
39
|
+
} from '@heroicons/vue/24/outline'
|
|
40
|
+
import { AlertEnum } from '../enums/AlertEnum'
|
|
41
|
+
import { computed } from 'vue'
|
|
42
|
+
import { useTheme } from '../composables/useTheme'
|
|
43
|
+
|
|
44
|
+
interface Props {
|
|
45
|
+
/** Bold headline. Optional when the slot carries the whole message. */
|
|
46
|
+
title?: string
|
|
47
|
+
/** Alert mode — drives palette and icon. Defaults to ERROR. */
|
|
48
|
+
color?: AlertEnum
|
|
49
|
+
/** Slim variant for in-form warnings: text-xs, smaller icon/padding. */
|
|
50
|
+
compact?: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
54
|
+
title: '',
|
|
55
|
+
color: AlertEnum.ERROR,
|
|
56
|
+
compact: false,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const { isDark } = useTheme()
|
|
60
|
+
|
|
61
|
+
// Literal palette table so Tailwind's scanner sees every class.
|
|
62
|
+
const PALETTES: Record<AlertEnum, { light: string; dark: string }> = {
|
|
63
|
+
[AlertEnum.ERROR]: {
|
|
64
|
+
light: 'bg-red-50 border-red-200 text-red-700',
|
|
65
|
+
dark: 'bg-red-500/10 border-red-500/30 text-red-300',
|
|
66
|
+
},
|
|
67
|
+
[AlertEnum.WARNING]: {
|
|
68
|
+
light: 'bg-amber-50 border-amber-200 text-amber-700',
|
|
69
|
+
dark: 'bg-amber-500/10 border-amber-500/30 text-amber-300',
|
|
70
|
+
},
|
|
71
|
+
[AlertEnum.SUCCESS]: {
|
|
72
|
+
light: 'bg-emerald-50 border-emerald-200 text-emerald-700',
|
|
73
|
+
dark: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-300',
|
|
74
|
+
},
|
|
75
|
+
[AlertEnum.INFO]: {
|
|
76
|
+
light: 'bg-sky-50 border-sky-200 text-sky-700',
|
|
77
|
+
dark: 'bg-sky-500/10 border-sky-500/30 text-sky-300',
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const palette = computed(() =>
|
|
82
|
+
isDark.value ? PALETTES[props.color].dark : PALETTES[props.color].light,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
// Icon + tint follow the alert mode so an error doesn't render with a
|
|
86
|
+
// neutral info glyph.
|
|
87
|
+
const iconComponent = computed(() => {
|
|
88
|
+
switch (props.color) {
|
|
89
|
+
case AlertEnum.SUCCESS: return CheckCircleIcon
|
|
90
|
+
case AlertEnum.WARNING: return ExclamationTriangleIcon
|
|
91
|
+
case AlertEnum.INFO: return InformationCircleIcon
|
|
92
|
+
default: return ExclamationCircleIcon
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const iconColorClass = computed(() => {
|
|
97
|
+
switch (props.color) {
|
|
98
|
+
case AlertEnum.SUCCESS: return 'text-emerald-500'
|
|
99
|
+
case AlertEnum.WARNING: return 'text-amber-500'
|
|
100
|
+
case AlertEnum.INFO: return 'text-sky-500'
|
|
101
|
+
default: return 'text-red-500'
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
</script>
|
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<span v-if="hasDefaultSlot" :class="badgeClasses">
|
|
3
|
-
<
|
|
3
|
+
<BadgeContent />
|
|
4
4
|
</span>
|
|
5
5
|
</template>
|
|
6
6
|
|
|
7
7
|
<script lang="ts" setup>
|
|
8
|
-
import { computed, useSlots } from 'vue'
|
|
8
|
+
import { computed, createTextVNode, Text, useSlots } from 'vue'
|
|
9
9
|
import { ColorsEnums } from "../enums/ColorsEnums";
|
|
10
10
|
|
|
11
11
|
const slots = useSlots()
|
|
12
12
|
const hasDefaultSlot = computed(() => !!slots.default)
|
|
13
13
|
|
|
14
|
+
// Badge labels are often raw enum names (PROFIT_TARGET, RSI_REVERT); render
|
|
15
|
+
// their underscores as spaces so callers don't have to humanize at every call
|
|
16
|
+
// site. Only plain text nodes are rewritten — element children pass through.
|
|
17
|
+
const BadgeContent = () =>
|
|
18
|
+
(slots.default?.() ?? []).map((node) =>
|
|
19
|
+
node.type === Text && typeof node.children === 'string'
|
|
20
|
+
? createTextVNode(node.children.replace(/_/g, ' '))
|
|
21
|
+
: node
|
|
22
|
+
)
|
|
23
|
+
|
|
14
24
|
const props = defineProps({
|
|
15
25
|
color: {
|
|
16
26
|
type: String,
|
|
@@ -1,51 +1,51 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { mount } from '@vue/test-utils'
|
|
3
|
-
import BaseBarDistribution from './BaseBarDistribution.vue'
|
|
4
|
-
import type { DistributionBar } from '../types/distribution'
|
|
5
|
-
|
|
6
|
-
const BARS: DistributionBar[] = [
|
|
7
|
-
{ label: '0.5', count: 1 },
|
|
8
|
-
{ label: '1', count: 4 },
|
|
9
|
-
{ label: '2', count: 2 },
|
|
10
|
-
]
|
|
11
|
-
|
|
12
|
-
describe('BaseBarDistribution', () => {
|
|
13
|
-
it('renders one bar per entry with its count and label', () => {
|
|
14
|
-
const wrap = mount(BaseBarDistribution, { props: { bars: BARS } })
|
|
15
|
-
const bars = wrap.findAll('div[title]')
|
|
16
|
-
expect(bars).toHaveLength(3)
|
|
17
|
-
expect(wrap.text()).toContain('4')
|
|
18
|
-
expect(wrap.text()).toContain('0.5')
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
it('scales bar heights against the tallest count, with a 3px floor', () => {
|
|
22
|
-
const wrap = mount(BaseBarDistribution, {
|
|
23
|
-
props: { bars: [...BARS, { label: '9', count: 0 }], maxBarHeight: 56 },
|
|
24
|
-
})
|
|
25
|
-
const heights = wrap
|
|
26
|
-
.findAll('.rounded-t')
|
|
27
|
-
.map((el) => el.attributes('style'))
|
|
28
|
-
// count 4 → full height; count 2 → half; count 0 → 3px floor.
|
|
29
|
-
expect(heights[1]).toContain('height: 56px')
|
|
30
|
-
expect(heights[2]).toContain('height: 28px')
|
|
31
|
-
expect(heights[3]).toContain('height: 3px')
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
it('builds singular/plural tooltips with the prefix and noun', () => {
|
|
35
|
-
const wrap = mount(BaseBarDistribution, {
|
|
36
|
-
props: { bars: BARS, titlePrefix: 'Take profit', countNoun: 'variant' },
|
|
37
|
-
})
|
|
38
|
-
const titles = wrap
|
|
39
|
-
.findAll('div[title]')
|
|
40
|
-
.map((el) => el.attributes('title'))
|
|
41
|
-
expect(titles[0]).toBe('Take profit 0.5: 1 variant')
|
|
42
|
-
expect(titles[1]).toBe('Take profit 1: 4 variants')
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
it('exposes the aria label on the chart container', () => {
|
|
46
|
-
const wrap = mount(BaseBarDistribution, {
|
|
47
|
-
props: { bars: BARS, ariaLabel: 'Distribution of X' },
|
|
48
|
-
})
|
|
49
|
-
expect(wrap.get('[role="img"]').attributes('aria-label')).toBe('Distribution of X')
|
|
50
|
-
})
|
|
51
|
-
})
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BaseBarDistribution from './BaseBarDistribution.vue'
|
|
4
|
+
import type { DistributionBar } from '../types/distribution'
|
|
5
|
+
|
|
6
|
+
const BARS: DistributionBar[] = [
|
|
7
|
+
{ label: '0.5', count: 1 },
|
|
8
|
+
{ label: '1', count: 4 },
|
|
9
|
+
{ label: '2', count: 2 },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
describe('BaseBarDistribution', () => {
|
|
13
|
+
it('renders one bar per entry with its count and label', () => {
|
|
14
|
+
const wrap = mount(BaseBarDistribution, { props: { bars: BARS } })
|
|
15
|
+
const bars = wrap.findAll('div[title]')
|
|
16
|
+
expect(bars).toHaveLength(3)
|
|
17
|
+
expect(wrap.text()).toContain('4')
|
|
18
|
+
expect(wrap.text()).toContain('0.5')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('scales bar heights against the tallest count, with a 3px floor', () => {
|
|
22
|
+
const wrap = mount(BaseBarDistribution, {
|
|
23
|
+
props: { bars: [...BARS, { label: '9', count: 0 }], maxBarHeight: 56 },
|
|
24
|
+
})
|
|
25
|
+
const heights = wrap
|
|
26
|
+
.findAll('.rounded-t')
|
|
27
|
+
.map((el) => el.attributes('style'))
|
|
28
|
+
// count 4 → full height; count 2 → half; count 0 → 3px floor.
|
|
29
|
+
expect(heights[1]).toContain('height: 56px')
|
|
30
|
+
expect(heights[2]).toContain('height: 28px')
|
|
31
|
+
expect(heights[3]).toContain('height: 3px')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('builds singular/plural tooltips with the prefix and noun', () => {
|
|
35
|
+
const wrap = mount(BaseBarDistribution, {
|
|
36
|
+
props: { bars: BARS, titlePrefix: 'Take profit', countNoun: 'variant' },
|
|
37
|
+
})
|
|
38
|
+
const titles = wrap
|
|
39
|
+
.findAll('div[title]')
|
|
40
|
+
.map((el) => el.attributes('title'))
|
|
41
|
+
expect(titles[0]).toBe('Take profit 0.5: 1 variant')
|
|
42
|
+
expect(titles[1]).toBe('Take profit 1: 4 variants')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('exposes the aria label on the chart container', () => {
|
|
46
|
+
const wrap = mount(BaseBarDistribution, {
|
|
47
|
+
props: { bars: BARS, ariaLabel: 'Distribution of X' },
|
|
48
|
+
})
|
|
49
|
+
expect(wrap.get('[role="img"]').attributes('aria-label')).toBe('Distribution of X')
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -1,73 +1,73 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
// Compact value-distribution chart: one thin rounded bar per distinct value,
|
|
3
|
-
// count labelled on top and the value underneath — scrolls sideways when
|
|
4
|
-
// there are many bars. Pure Tailwind, no chart library. Extracted from
|
|
5
|
-
// TradeAutomation's variant-stats modal, where it summarises how often each
|
|
6
|
-
// parameter value occurs across generated strategy variants.
|
|
7
|
-
import { computed } from 'vue'
|
|
8
|
-
import type { DistributionBar } from '../types/distribution'
|
|
9
|
-
|
|
10
|
-
const props = withDefaults(
|
|
11
|
-
defineProps<{
|
|
12
|
-
/** Bars in display order (callers sort ascending for numeric values). */
|
|
13
|
-
bars: DistributionBar[]
|
|
14
|
-
/** Accessible description of what the chart shows. */
|
|
15
|
-
ariaLabel?: string
|
|
16
|
-
/** Noun for each bar's tooltip count, e.g. "variant" → "3 variants". */
|
|
17
|
-
countNoun?: string
|
|
18
|
-
/** Tooltip prefix before the value, e.g. the field name. */
|
|
19
|
-
titlePrefix?: string
|
|
20
|
-
/** Height of the tallest bar, in px. */
|
|
21
|
-
maxBarHeight?: number
|
|
22
|
-
/** Tailwind classes for the bar fill. */
|
|
23
|
-
barClass?: string
|
|
24
|
-
}>(),
|
|
25
|
-
{
|
|
26
|
-
ariaLabel: 'Value distribution',
|
|
27
|
-
countNoun: 'item',
|
|
28
|
-
titlePrefix: '',
|
|
29
|
-
maxBarHeight: 56,
|
|
30
|
-
barClass: 'bg-emerald-500/80 dark:bg-emerald-400/80',
|
|
31
|
-
},
|
|
32
|
-
)
|
|
33
|
-
|
|
34
|
-
const maxCount = computed(() => Math.max(1, ...props.bars.map((b) => b.count)))
|
|
35
|
-
|
|
36
|
-
const barHeight = (b: DistributionBar): number =>
|
|
37
|
-
Math.max(3, Math.round((b.count / maxCount.value) * props.maxBarHeight))
|
|
38
|
-
|
|
39
|
-
const barTitle = (b: DistributionBar): string =>
|
|
40
|
-
`${props.titlePrefix ? `${props.titlePrefix} ` : ''}${b.label}: ${b.count} ${props.countNoun}${
|
|
41
|
-
b.count === 1 ? '' : 's'
|
|
42
|
-
}`
|
|
43
|
-
</script>
|
|
44
|
-
|
|
45
|
-
<template>
|
|
46
|
-
<div
|
|
47
|
-
class="flex items-end gap-1.5 overflow-x-auto pb-1"
|
|
48
|
-
role="img"
|
|
49
|
-
:aria-label="ariaLabel"
|
|
50
|
-
>
|
|
51
|
-
<div
|
|
52
|
-
v-for="b in bars"
|
|
53
|
-
:key="b.label"
|
|
54
|
-
class="flex min-w-7 flex-1 flex-col items-center gap-0.5"
|
|
55
|
-
:title="barTitle(b)"
|
|
56
|
-
>
|
|
57
|
-
<span class="text-[10px] tabular-nums text-slate-400 dark:text-slate-500">
|
|
58
|
-
{{ b.count }}
|
|
59
|
-
</span>
|
|
60
|
-
<div
|
|
61
|
-
class="w-full max-w-8 rounded-t"
|
|
62
|
-
:class="barClass"
|
|
63
|
-
:style="{ height: `${barHeight(b)}px` }"
|
|
64
|
-
/>
|
|
65
|
-
<span
|
|
66
|
-
class="max-w-full truncate font-mono text-[10px] text-slate-500 dark:text-slate-400"
|
|
67
|
-
:title="b.label"
|
|
68
|
-
>
|
|
69
|
-
{{ b.label }}
|
|
70
|
-
</span>
|
|
71
|
-
</div>
|
|
72
|
-
</div>
|
|
73
|
-
</template>
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Compact value-distribution chart: one thin rounded bar per distinct value,
|
|
3
|
+
// count labelled on top and the value underneath — scrolls sideways when
|
|
4
|
+
// there are many bars. Pure Tailwind, no chart library. Extracted from
|
|
5
|
+
// TradeAutomation's variant-stats modal, where it summarises how often each
|
|
6
|
+
// parameter value occurs across generated strategy variants.
|
|
7
|
+
import { computed } from 'vue'
|
|
8
|
+
import type { DistributionBar } from '../types/distribution'
|
|
9
|
+
|
|
10
|
+
const props = withDefaults(
|
|
11
|
+
defineProps<{
|
|
12
|
+
/** Bars in display order (callers sort ascending for numeric values). */
|
|
13
|
+
bars: DistributionBar[]
|
|
14
|
+
/** Accessible description of what the chart shows. */
|
|
15
|
+
ariaLabel?: string
|
|
16
|
+
/** Noun for each bar's tooltip count, e.g. "variant" → "3 variants". */
|
|
17
|
+
countNoun?: string
|
|
18
|
+
/** Tooltip prefix before the value, e.g. the field name. */
|
|
19
|
+
titlePrefix?: string
|
|
20
|
+
/** Height of the tallest bar, in px. */
|
|
21
|
+
maxBarHeight?: number
|
|
22
|
+
/** Tailwind classes for the bar fill. */
|
|
23
|
+
barClass?: string
|
|
24
|
+
}>(),
|
|
25
|
+
{
|
|
26
|
+
ariaLabel: 'Value distribution',
|
|
27
|
+
countNoun: 'item',
|
|
28
|
+
titlePrefix: '',
|
|
29
|
+
maxBarHeight: 56,
|
|
30
|
+
barClass: 'bg-emerald-500/80 dark:bg-emerald-400/80',
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const maxCount = computed(() => Math.max(1, ...props.bars.map((b) => b.count)))
|
|
35
|
+
|
|
36
|
+
const barHeight = (b: DistributionBar): number =>
|
|
37
|
+
Math.max(3, Math.round((b.count / maxCount.value) * props.maxBarHeight))
|
|
38
|
+
|
|
39
|
+
const barTitle = (b: DistributionBar): string =>
|
|
40
|
+
`${props.titlePrefix ? `${props.titlePrefix} ` : ''}${b.label}: ${b.count} ${props.countNoun}${
|
|
41
|
+
b.count === 1 ? '' : 's'
|
|
42
|
+
}`
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<template>
|
|
46
|
+
<div
|
|
47
|
+
class="flex items-end gap-1.5 overflow-x-auto pb-1"
|
|
48
|
+
role="img"
|
|
49
|
+
:aria-label="ariaLabel"
|
|
50
|
+
>
|
|
51
|
+
<div
|
|
52
|
+
v-for="b in bars"
|
|
53
|
+
:key="b.label"
|
|
54
|
+
class="flex min-w-7 flex-1 flex-col items-center gap-0.5"
|
|
55
|
+
:title="barTitle(b)"
|
|
56
|
+
>
|
|
57
|
+
<span class="text-[10px] tabular-nums text-slate-400 dark:text-slate-500">
|
|
58
|
+
{{ b.count }}
|
|
59
|
+
</span>
|
|
60
|
+
<div
|
|
61
|
+
class="w-full max-w-8 rounded-t"
|
|
62
|
+
:class="barClass"
|
|
63
|
+
:style="{ height: `${barHeight(b)}px` }"
|
|
64
|
+
/>
|
|
65
|
+
<span
|
|
66
|
+
class="max-w-full truncate font-mono text-[10px] text-slate-500 dark:text-slate-400"
|
|
67
|
+
:title="b.label"
|
|
68
|
+
>
|
|
69
|
+
{{ b.label }}
|
|
70
|
+
</span>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
</template>
|