mgv-backoffice 1.40.0 → 1.41.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.40.0",
3
+ "version": "1.41.0",
4
4
  "description": "Shared Vue 3 UI component library",
5
5
  "type": "module",
6
6
  "main": "./dist/ui-lib.umd.cjs",
@@ -1,48 +1,48 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { mount, RouterLinkStub } from '@vue/test-utils'
3
- import BaseAuthLayout from './BaseAuthLayout.vue'
4
- import BaseBooleanBadge from './BaseBooleanBadge.vue'
5
-
6
- const global = { stubs: { RouterLink: RouterLinkStub } }
7
-
8
- describe('BaseAuthLayout', () => {
9
- it('renders the default logo, title, content and footer', () => {
10
- const wrapper = mount(BaseAuthLayout, {
11
- props: { title: 'Sign in' },
12
- slots: { default: '<form>form</form>', footer: 'No account? <a>Sign up</a>' },
13
- global,
14
- })
15
- expect(wrapper.find('svg').exists()).toBe(true)
16
- expect(wrapper.find('h1').text()).toBe('Sign in')
17
- expect(wrapper.find('form').text()).toBe('form')
18
- expect(wrapper.text()).toContain('No account? Sign up')
19
- })
20
-
21
- it('lets the logo slot replace the default logo', () => {
22
- const wrapper = mount(BaseAuthLayout, { slots: { logo: '<img alt="brand" />' }, global })
23
- expect(wrapper.find('img').exists()).toBe(true)
24
- expect(wrapper.find('svg').exists()).toBe(false)
25
- })
26
-
27
- it('widens the card with `wide`', () => {
28
- const card = (w: ReturnType<typeof mount>) => w.find('.rounded-xl')
29
- expect(card(mount(BaseAuthLayout, { global })).classes()).toContain('max-w-md')
30
- expect(card(mount(BaseAuthLayout, { props: { wide: true }, global })).classes()).toContain('max-w-3xl')
31
- })
32
- })
33
-
34
- describe('BaseBooleanBadge', () => {
35
- it('renders a green "Yes" pill for true and a neutral "No" pill for false', () => {
36
- const yes = mount(BaseBooleanBadge, { props: { value: true } })
37
- expect(yes.text()).toBe('Yes')
38
- expect(yes.classes()).toContain('bg-emerald-50')
39
- const no = mount(BaseBooleanBadge, { props: { value: false } })
40
- expect(no.text()).toBe('No')
41
- expect(no.classes()).toContain('bg-slate-100')
42
- })
43
-
44
- it('uses custom labels and treats null as false', () => {
45
- const w = mount(BaseBooleanBadge, { props: { value: null, trueLabel: 'Verified', falseLabel: 'Not verified' } })
46
- expect(w.text()).toBe('Not verified')
47
- })
48
- })
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount, RouterLinkStub } from '@vue/test-utils'
3
+ import BaseAuthLayout from './BaseAuthLayout.vue'
4
+ import BaseBooleanBadge from './BaseBooleanBadge.vue'
5
+
6
+ const global = { stubs: { RouterLink: RouterLinkStub } }
7
+
8
+ describe('BaseAuthLayout', () => {
9
+ it('renders the default logo, title, content and footer', () => {
10
+ const wrapper = mount(BaseAuthLayout, {
11
+ props: { title: 'Sign in' },
12
+ slots: { default: '<form>form</form>', footer: 'No account? <a>Sign up</a>' },
13
+ global,
14
+ })
15
+ expect(wrapper.find('svg').exists()).toBe(true)
16
+ expect(wrapper.find('h1').text()).toBe('Sign in')
17
+ expect(wrapper.find('form').text()).toBe('form')
18
+ expect(wrapper.text()).toContain('No account? Sign up')
19
+ })
20
+
21
+ it('lets the logo slot replace the default logo', () => {
22
+ const wrapper = mount(BaseAuthLayout, { slots: { logo: '<img alt="brand" />' }, global })
23
+ expect(wrapper.find('img').exists()).toBe(true)
24
+ expect(wrapper.find('svg').exists()).toBe(false)
25
+ })
26
+
27
+ it('widens the card with `wide`', () => {
28
+ const card = (w: ReturnType<typeof mount>) => w.find('.rounded-xl')
29
+ expect(card(mount(BaseAuthLayout, { global })).classes()).toContain('max-w-md')
30
+ expect(card(mount(BaseAuthLayout, { props: { wide: true }, global })).classes()).toContain('max-w-3xl')
31
+ })
32
+ })
33
+
34
+ describe('BaseBooleanBadge', () => {
35
+ it('renders a green "Yes" pill for true and a red "No" pill for false', () => {
36
+ const yes = mount(BaseBooleanBadge, { props: { value: true } })
37
+ expect(yes.text()).toBe('Yes')
38
+ expect(yes.classes()).toContain('bg-emerald-50')
39
+ const no = mount(BaseBooleanBadge, { props: { value: false } })
40
+ expect(no.text()).toBe('No')
41
+ expect(no.classes()).toContain('bg-red-50')
42
+ })
43
+
44
+ it('uses custom labels and treats null as false', () => {
45
+ const w = mount(BaseBooleanBadge, { props: { value: null, trueLabel: 'Verified', falseLabel: 'Not verified' } })
46
+ expect(w.text()).toBe('Not verified')
47
+ })
48
+ })
@@ -1,33 +1,32 @@
1
- <template>
2
- <span
3
- class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset"
4
- :class="value
5
- ? 'bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/30'
6
- : 'bg-slate-100 text-slate-600 ring-slate-500/20 dark:bg-slate-700/50 dark:text-slate-300 dark:ring-slate-400/20'"
7
- >
8
- <CheckCircleIcon v-if="value" class="h-3.5 w-3.5" aria-hidden="true" />
9
- <XCircleIcon v-else class="h-3.5 w-3.5" aria-hidden="true" />
10
- {{ value ? trueLabel : falseLabel }}
11
- </span>
12
- </template>
13
-
14
- <script setup lang="ts">
15
- /**
16
- * Yes/no status pill for boolean columns ("Verified", "Has API", …):
17
- * a green check pill when true, a neutral cross pill when false — so a
18
- * `false` reads as a state, not as an error.
19
- */
20
- import { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/20/solid'
21
-
22
- interface Props {
23
- value?: boolean | null
24
- trueLabel?: string
25
- falseLabel?: string
26
- }
27
-
28
- withDefaults(defineProps<Props>(), {
29
- value: false,
30
- trueLabel: 'Yes',
31
- falseLabel: 'No',
32
- })
33
- </script>
1
+ <template>
2
+ <span
3
+ class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset"
4
+ :class="value
5
+ ? 'bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/30'
6
+ : 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-500/10 dark:text-red-300 dark:ring-red-400/30'"
7
+ >
8
+ <CheckCircleIcon v-if="value" class="h-3.5 w-3.5" aria-hidden="true" />
9
+ <XCircleIcon v-else class="h-3.5 w-3.5" aria-hidden="true" />
10
+ {{ value ? trueLabel : falseLabel }}
11
+ </span>
12
+ </template>
13
+
14
+ <script setup lang="ts">
15
+ /**
16
+ * Yes/no status pill for boolean columns ("Verified", "Has API", …):
17
+ * a green check pill when true, a red cross pill when false.
18
+ */
19
+ import { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/20/solid'
20
+
21
+ interface Props {
22
+ value?: boolean | null
23
+ trueLabel?: string
24
+ falseLabel?: string
25
+ }
26
+
27
+ withDefaults(defineProps<Props>(), {
28
+ value: false,
29
+ trueLabel: 'Yes',
30
+ falseLabel: 'No',
31
+ })
32
+ </script>
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import BaseCheckbox from './BaseCheckbox.vue'
4
+
5
+ describe('BaseCheckbox', () => {
6
+ it('is rounded and emerald when checked', () => {
7
+ const input = mount(BaseCheckbox).find('input')
8
+ expect(input.classes()).toEqual(expect.arrayContaining(['rounded-md', 'text-emerald-600', 'accent-emerald-600']))
9
+ })
10
+
11
+ it('reflects modelValue and emits the new value on change', async () => {
12
+ const wrapper = mount(BaseCheckbox, { props: { modelValue: false } })
13
+ const input = wrapper.find('input')
14
+ expect((input.element as HTMLInputElement).checked).toBe(false)
15
+ await input.setValue(true)
16
+ expect(wrapper.emitted('update:modelValue')).toEqual([[true]])
17
+ expect(wrapper.emitted('change')).toEqual([[true]])
18
+ })
19
+
20
+ it('renders the label and forwards attrs to the input', () => {
21
+ const wrapper = mount(BaseCheckbox, { props: { label: 'Remember me' }, attrs: { id: 'remember' } })
22
+ expect(wrapper.text()).toBe('Remember me')
23
+ expect(wrapper.find('input').attributes('id')).toBe('remember')
24
+ })
25
+
26
+ it('can be disabled', () => {
27
+ const wrapper = mount(BaseCheckbox, { props: { disabled: true } })
28
+ expect(wrapper.find('input').attributes('disabled')).toBeDefined()
29
+ })
30
+ })
@@ -0,0 +1,51 @@
1
+ <template>
2
+ <label
3
+ class="inline-flex items-center gap-2 select-none"
4
+ :class="disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'"
5
+ >
6
+ <input
7
+ v-bind="$attrs"
8
+ type="checkbox"
9
+ :checked="modelValue"
10
+ :disabled="disabled"
11
+ class="h-5 w-5 shrink-0 cursor-[inherit] rounded-md border-slate-300 bg-white text-emerald-600 accent-emerald-600 transition-colors focus:ring-2 focus:ring-emerald-500/40 focus:ring-offset-0 dark:border-slate-600 dark:bg-slate-900 dark:checked:bg-emerald-600"
12
+ @change="onChange"
13
+ />
14
+ <span v-if="label || $slots.default" class="text-sm text-slate-700 dark:text-slate-200">
15
+ <slot>{{ label }}</slot>
16
+ </span>
17
+ </label>
18
+ </template>
19
+
20
+ <script setup lang="ts">
21
+ /**
22
+ * Rounded checkbox, emerald when checked (both a forms-plugin fill via
23
+ * `text-emerald-600` and native `accent-color`, so it looks the same with or
24
+ * without @tailwindcss/forms / Flowbite). `v-model` is the boolean; `change`
25
+ * fires with the new value. Attrs (id, name, …) go to the <input>.
26
+ */
27
+ defineOptions({ inheritAttrs: false })
28
+
29
+ interface Props {
30
+ modelValue?: boolean
31
+ label?: string
32
+ disabled?: boolean
33
+ }
34
+
35
+ withDefaults(defineProps<Props>(), {
36
+ modelValue: false,
37
+ label: '',
38
+ disabled: false,
39
+ })
40
+
41
+ const emit = defineEmits<{
42
+ (e: 'update:modelValue', value: boolean): void
43
+ (e: 'change', value: boolean): void
44
+ }>()
45
+
46
+ function onChange(e: Event) {
47
+ const checked = (e.target as HTMLInputElement).checked
48
+ emit('update:modelValue', checked)
49
+ emit('change', checked)
50
+ }
51
+ </script>
@@ -0,0 +1,26 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount, RouterLinkStub } from '@vue/test-utils'
3
+ import BaseSidebarUser from './BaseSidebarUser.vue'
4
+
5
+ const global = { stubs: { RouterLink: RouterLinkStub } }
6
+
7
+ describe('BaseSidebarUser', () => {
8
+ it('shows initials, name and email', () => {
9
+ const wrapper = mount(BaseSidebarUser, { props: { name: 'john.doe', email: 'j@d.test' }, global })
10
+ expect(wrapper.text()).toContain('JD')
11
+ expect(wrapper.text()).toContain('john.doe')
12
+ expect(wrapper.text()).toContain('j@d.test')
13
+ })
14
+
15
+ it('links to the profile when `to` is set', () => {
16
+ const wrapper = mount(BaseSidebarUser, { props: { name: 'test', to: { name: 'Profile' } }, global })
17
+ expect(wrapper.findComponent(RouterLinkStub).props('to')).toEqual({ name: 'Profile' })
18
+ })
19
+
20
+ it('emits logout from the button, and can hide it', async () => {
21
+ const wrapper = mount(BaseSidebarUser, { props: { name: 'test' }, global })
22
+ await wrapper.find('button').trigger('click')
23
+ expect(wrapper.emitted('logout')).toHaveLength(1)
24
+ expect(mount(BaseSidebarUser, { props: { name: 'test', showLogout: false }, global }).find('button').exists()).toBe(false)
25
+ })
26
+ })
@@ -0,0 +1,75 @@
1
+ <template>
2
+ <div class="flex flex-col gap-2 text-left">
3
+ <component
4
+ :is="to ? RouterLink : 'div'"
5
+ v-bind="to ? { to } : {}"
6
+ class="flex items-center gap-3 rounded-lg p-2 transition-colors"
7
+ :class="to ? 'hover:bg-slate-100 dark:hover:bg-slate-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500' : ''"
8
+ :title="to ? profileLabel : undefined"
9
+ >
10
+ <span
11
+ class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-sm font-semibold text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300"
12
+ aria-hidden="true"
13
+ >{{ initials }}</span>
14
+ <span class="min-w-0">
15
+ <span class="block truncate text-sm font-medium text-slate-900 dark:text-slate-100">{{ name }}</span>
16
+ <span v-if="email" class="block truncate text-xs text-slate-500 dark:text-slate-400">{{ email }}</span>
17
+ </span>
18
+ </component>
19
+ <BaseButton
20
+ v-if="showLogout"
21
+ class="w-full justify-center"
22
+ :description="logoutLabel"
23
+ :color="BaseButtonEnum.RED"
24
+ :size="BaseButtonSizeEnum.SMALL"
25
+ outline
26
+ icon-left
27
+ @click="emit('logout')"
28
+ >
29
+ <ArrowRightStartOnRectangleIcon class="mr-1.5 h-4 w-4" aria-hidden="true" />
30
+ </BaseButton>
31
+ </div>
32
+ </template>
33
+
34
+ <script setup lang="ts">
35
+ /**
36
+ * Signed-in user block for the sidebar footer (BaseSidebar `footer` slot):
37
+ * initials avatar + name + email, linking to the profile page when `to` is
38
+ * set, and a Logout button that emits `logout` (the app clears its session).
39
+ */
40
+ import { computed } from 'vue'
41
+ import { RouterLink, type RouteLocationRaw } from 'vue-router'
42
+ import { ArrowRightStartOnRectangleIcon } from '@heroicons/vue/24/outline'
43
+ import BaseButton from './BaseButton.vue'
44
+ import { BaseButtonEnum } from '../enums/BaseButtonEnum'
45
+ import { BaseButtonSizeEnum } from '../enums/BaseButtonSizeEnum'
46
+
47
+ interface Props {
48
+ name: string
49
+ email?: string
50
+ /** Profile route; makes the name/email block a link. */
51
+ to?: RouteLocationRaw
52
+ showLogout?: boolean
53
+ logoutLabel?: string
54
+ profileLabel?: string
55
+ }
56
+
57
+ const props = withDefaults(defineProps<Props>(), {
58
+ email: '',
59
+ to: undefined,
60
+ showLogout: true,
61
+ logoutLabel: 'Logout',
62
+ profileLabel: 'View profile',
63
+ })
64
+
65
+ const emit = defineEmits<{ (e: 'logout'): void }>()
66
+
67
+ const initials = computed(() =>
68
+ (props.name || '?')
69
+ .split(/[\s._-]+/)
70
+ .filter(Boolean)
71
+ .slice(0, 2)
72
+ .map((p) => p[0]!.toUpperCase())
73
+ .join(''),
74
+ )
75
+ </script>
@@ -1,76 +1,76 @@
1
- <template>
2
- <!--
3
- Animated loading spinner — a spinning ring with a neutral track and a
4
- coloured leading arc.
5
-
6
- Props:
7
- • size — diameter + ring thickness. 'sm' (16px) is the default and
8
- reproduces the original spinner; 'md' (24px), 'lg' (32px) and
9
- 'xl' (48px) step up for full-page / empty-state loaders.
10
- • color — the colour of the spinning arc (`border-t-*`). The track
11
- stays neutral gray. Defaults to 'blue' to match the original.
12
-
13
- With no props it renders exactly the legacy spinner, so existing
14
- `<BaseSpinner />` call sites are unaffected.
15
- -->
16
- <div
17
- class="border-gray-200 rounded-full animate-spin"
18
- :class="[sizeClass, colorClass]"
19
- role="status"
20
- aria-label="Loading"
21
- ></div>
22
- </template>
23
-
24
- <script setup lang="ts">
25
- import { computed } from 'vue'
26
-
27
- type SpinnerSize = 'sm' | 'md' | 'lg' | 'xl'
28
- type SpinnerColor =
29
- | 'blue'
30
- | 'emerald'
31
- | 'sky'
32
- | 'indigo'
33
- | 'teal'
34
- | 'purple'
35
- | 'red'
36
- | 'amber'
37
-
38
- const props = withDefaults(
39
- defineProps<{
40
- size?: SpinnerSize
41
- color?: SpinnerColor
42
- }>(),
43
- {
44
- size: 'sm',
45
- color: 'blue',
46
- },
47
- )
48
-
49
- // Diameter pairs with a proportional ring thickness. Full literal strings
50
- // so Tailwind detects them at build time.
51
- const sizeClass = computed(() => {
52
- const sizes: Record<SpinnerSize, string> = {
53
- sm: 'w-4 h-4 border-2',
54
- md: 'w-6 h-6 border-2',
55
- lg: 'w-8 h-8 border-4',
56
- xl: 'w-12 h-12 border-4',
57
- }
58
- return sizes[props.size]
59
- })
60
-
61
- // Only the leading arc (`border-t-*`) is tinted; the rest of the ring is
62
- // the neutral gray track from the template.
63
- const colorClass = computed(() => {
64
- const colors: Record<SpinnerColor, string> = {
65
- blue: 'border-t-blue-500',
66
- emerald: 'border-t-emerald-500',
67
- sky: 'border-t-sky-500',
68
- indigo: 'border-t-indigo-500',
69
- teal: 'border-t-teal-500',
70
- purple: 'border-t-purple-500',
71
- red: 'border-t-red-500',
72
- amber: 'border-t-amber-500',
73
- }
74
- return colors[props.color]
75
- })
76
- </script>
1
+ <template>
2
+ <div
3
+ class="border-gray-200 dark:border-slate-700 rounded-full animate-spin"
4
+ :class="[sizeClass, colorClass]"
5
+ role="status"
6
+ aria-label="Loading"
7
+ ></div>
8
+ </template>
9
+
10
+ <script setup lang="ts">
11
+ /**
12
+ * Animated loading spinner — a spinning ring with a neutral track and a
13
+ * coloured leading arc.
14
+ *
15
+ * Props:
16
+ * • size — diameter + ring thickness. 'sm' (16px) is the default and
17
+ * reproduces the original spinner; 'md' (24px), 'lg' (32px) and
18
+ * 'xl' (48px) step up for full-page / empty-state loaders.
19
+ * • color — the colour of the spinning arc (`border-t-*`). The track
20
+ * stays neutral gray. Defaults to 'emerald' (the brand green).
21
+ *
22
+ * With no props it renders the small (16px) emerald spinner.
23
+ *
24
+ */
25
+ import { computed } from 'vue'
26
+
27
+ type SpinnerSize = 'sm' | 'md' | 'lg' | 'xl'
28
+ type SpinnerColor =
29
+ | 'blue'
30
+ | 'emerald'
31
+ | 'sky'
32
+ | 'indigo'
33
+ | 'teal'
34
+ | 'purple'
35
+ | 'red'
36
+ | 'amber'
37
+
38
+ const props = withDefaults(
39
+ defineProps<{
40
+ size?: SpinnerSize
41
+ color?: SpinnerColor
42
+ }>(),
43
+ {
44
+ size: 'sm',
45
+ color: 'emerald',
46
+ },
47
+ )
48
+
49
+ // Diameter pairs with a proportional ring thickness. Full literal strings
50
+ // so Tailwind detects them at build time.
51
+ const sizeClass = computed(() => {
52
+ const sizes: Record<SpinnerSize, string> = {
53
+ sm: 'w-4 h-4 border-2',
54
+ md: 'w-6 h-6 border-2',
55
+ lg: 'w-8 h-8 border-4',
56
+ xl: 'w-12 h-12 border-4',
57
+ }
58
+ return sizes[props.size]
59
+ })
60
+
61
+ // Only the leading arc (`border-t-*`) is tinted; the rest of the ring is
62
+ // the neutral gray track from the template.
63
+ const colorClass = computed(() => {
64
+ const colors: Record<SpinnerColor, string> = {
65
+ blue: 'border-t-blue-500',
66
+ emerald: 'border-t-emerald-500',
67
+ sky: 'border-t-sky-500',
68
+ indigo: 'border-t-indigo-500',
69
+ teal: 'border-t-teal-500',
70
+ purple: 'border-t-purple-500',
71
+ red: 'border-t-red-500',
72
+ amber: 'border-t-amber-500',
73
+ }
74
+ return colors[props.color]
75
+ })
76
+ </script>
package/src/index.ts CHANGED
@@ -61,6 +61,8 @@ export { default as BaseCard } from './components/BaseCard.vue'
61
61
  export { default as BaseField } from './components/BaseField.vue'
62
62
  export { default as BaseStatCard } from './components/BaseStatCard.vue'
63
63
  export { default as BaseToggle } from './components/BaseToggle.vue'
64
+ export { default as BaseCheckbox } from './components/BaseCheckbox.vue'
65
+ export { default as BaseSidebarUser } from './components/BaseSidebarUser.vue'
64
66
  export { default as BaseAuthLayout } from './components/BaseAuthLayout.vue'
65
67
  export { default as BaseBooleanBadge } from './components/BaseBooleanBadge.vue'
66
68
  export { default as BaseDetailList } from './components/BaseDetailList.vue'