mgv-backoffice 1.35.0 → 1.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mgv-backoffice",
3
- "version": "1.35.0",
3
+ "version": "1.36.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,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
+ })
@@ -0,0 +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>
@@ -6,7 +6,11 @@
6
6
  Slots:
7
7
  • icon — the page-specific Heroicon. Receives an `iconClass`
8
8
  slot prop carrying the theme-aware text colour, so callers can
9
- bind `:class="iconClass"` on their icon component.
9
+ bind `:class="iconClass"` on their icon component. The badge
10
+ square only renders when this slot is filled, so icon-less apps
11
+ get a plain title/subtitle header.
12
+ • subtitle — rich subtitle content (links, emphasis); overrides
13
+ the `subtitle` prop.
10
14
  • actions — refresh / destructive buttons rendered on the right.
11
15
 
12
16
  Props:
@@ -16,18 +20,27 @@
16
20
  ('emerald' | 'sky' | 'red' | 'amber'). Defaults to 'emerald'.
17
21
  • maxWidthClass — Tailwind max-w utility constraining the header
18
22
  width so sibling views can share a footprint. Defaults to
19
- 'max-w-4xl'.
23
+ 'max-w-4xl'; pass '' to skip the width wrapper entirely (the
24
+ header then spans whatever container it sits in).
25
+ • align — vertical alignment of the title block vs the actions:
26
+ 'center' (default) or 'end' (actions sit on the title baseline).
27
+ • marginClass — space under the header. Defaults to 'mb-8'; pass
28
+ '' when the parent already manages vertical rhythm (space-y-*).
20
29
  -->
21
- <div :class="['mx-auto px-4 sm:px-6', maxWidthClass]">
30
+ <header :class="maxWidthClass ? ['mx-auto px-4 sm:px-6', maxWidthClass] : undefined">
22
31
  <!--
23
32
  flex-wrap + gap so a long title and the action buttons reflow onto
24
33
  separate rows on narrow (mobile) viewports instead of the actions
25
34
  overflowing off the right edge of the screen. min-w-0 on the title
26
35
  group lets a long title truncate rather than shoving the actions out.
27
36
  -->
28
- <div class="flex flex-wrap items-center justify-between gap-3 mb-8">
37
+ <div
38
+ class="flex flex-wrap justify-between gap-3"
39
+ :class="[align === 'end' ? 'items-end' : 'items-center', marginClass]"
40
+ >
29
41
  <div class="flex items-center gap-3 min-w-0">
30
42
  <div
43
+ v-if="$slots.icon"
31
44
  class="w-10 h-10 rounded-lg flex items-center justify-center"
32
45
  :class="badgeBgClass"
33
46
  >
@@ -37,8 +50,8 @@
37
50
  <h1 class="text-2xl font-bold" :class="t.primaryTextSoft">
38
51
  {{ title }}
39
52
  </h1>
40
- <p v-if="subtitle" class="text-sm" :class="t.dimTextAlt">
41
- {{ subtitle }}
53
+ <p v-if="subtitle || $slots.subtitle" class="text-sm" :class="t.dimTextAlt">
54
+ <slot name="subtitle">{{ subtitle }}</slot>
42
55
  </p>
43
56
  </div>
44
57
  </div>
@@ -46,7 +59,7 @@
46
59
  <slot name="actions" />
47
60
  </div>
48
61
  </div>
49
- </div>
62
+ </header>
50
63
  </template>
51
64
 
52
65
  <script setup lang="ts">
@@ -62,11 +75,15 @@ const props = withDefaults(
62
75
  subtitle?: string
63
76
  iconColor?: IconColor
64
77
  maxWidthClass?: string
78
+ align?: 'center' | 'end'
79
+ marginClass?: string
65
80
  }>(),
66
81
  {
67
82
  subtitle: '',
68
83
  iconColor: 'emerald',
69
84
  maxWidthClass: 'max-w-4xl',
85
+ align: 'center',
86
+ marginClass: 'mb-8',
70
87
  },
71
88
  )
72
89
 
@@ -35,6 +35,10 @@ export function usePolling(
35
35
 
36
36
  let timer: ReturnType<typeof setInterval> | null = null
37
37
  const active = ref(false)
38
+ // True only when the visibility handler paused an ACTIVE loop. A loop the
39
+ // caller stopped explicitly (stop(), e.g. an auto-refresh toggle) must stay
40
+ // stopped across hide/show cycles instead of being silently restarted.
41
+ let suspendedByVisibility = false
38
42
 
39
43
  function startTimer() {
40
44
  if (timer !== null || intervalMs === null) return
@@ -49,6 +53,7 @@ export function usePolling(
49
53
  }
50
54
 
51
55
  function start() {
56
+ suspendedByVisibility = false
52
57
  if (active.value) return
53
58
  active.value = true
54
59
  if (immediate) void fn()
@@ -56,19 +61,26 @@ export function usePolling(
56
61
  }
57
62
 
58
63
  function stop() {
64
+ suspendedByVisibility = false
59
65
  active.value = false
60
66
  stopTimer()
61
67
  }
62
68
 
63
69
  function onVisibilityChange() {
64
70
  if (document.visibilityState === 'visible') {
65
- // Refresh right away so stale data doesn't linger for a full
66
- // interval after the user returns, then re-arm the timer.
71
+ // Only resume a loop THIS handler paused — a caller-stopped loop
72
+ // stays stopped. Refresh right away so stale data doesn't linger
73
+ // for a full interval after the user returns, then re-arm the timer.
74
+ if (!suspendedByVisibility) return
75
+ suspendedByVisibility = false
67
76
  active.value = true
68
77
  void fn()
69
78
  startTimer()
70
79
  } else {
71
- stop()
80
+ if (!active.value) return
81
+ active.value = false
82
+ stopTimer()
83
+ suspendedByVisibility = true
72
84
  }
73
85
  }
74
86
 
package/src/index.ts CHANGED
@@ -50,6 +50,7 @@ export { default as BaseStatBreakdown } from './components/BaseStatBreakdown.vue
50
50
  export { default as BaseFilterChip } from './components/BaseFilterChip.vue'
51
51
  export { default as BaseCredentialsForm } from './components/BaseCredentialsForm.vue'
52
52
  export { default as BasePillPickerModal } from './components/BasePillPickerModal.vue'
53
+ export { default as BaseBarDistribution } from './components/BaseBarDistribution.vue'
53
54
  export type { CredentialsView, CredentialsUpdate } from './components/BaseCredentialsForm.vue'
54
55
 
55
56
  // Composables
@@ -94,6 +95,7 @@ export type { TableColumn } from './types/table'
94
95
  export type { SpecField, SpecFieldType, SpecFieldValue } from './types/specField'
95
96
  export type { StatBreakdownItem } from './types/statBreakdown'
96
97
  export type { PillPickerItem } from './types/pillPicker'
98
+ export type { DistributionBar } from './types/distribution'
97
99
 
98
100
  // Utils
99
101
  export { getBaseColor, getBaseColorOf } from './utils/util'
@@ -0,0 +1,7 @@
1
+ /** One bar of a BaseBarDistribution chart. */
2
+ export interface DistributionBar {
3
+ /** Value rendered under the bar (already formatted for display). */
4
+ label: string
5
+ /** Occurrences of this value — drives the bar height. */
6
+ count: number
7
+ }