From 284fdcc43790cefcae12b1d338916e2d6dd2ce5d Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Mon, 18 May 2026 11:00:20 +0200 Subject: [PATCH] feat(gui-v2): StateBlock 3-state wrapper (exhaustive Vitest, no @visual per constraint #5) Co-Authored-By: Claude Sonnet 4.6 --- .../shared/StateBlock.stories.ts | 25 ++++++ .../src/components-v2/shared/StateBlock.vue | 77 +++++++++++++++++++ .../shared/__tests__/StateBlock.spec.ts | 65 ++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 apps/app/src/components-v2/shared/StateBlock.stories.ts create mode 100644 apps/app/src/components-v2/shared/StateBlock.vue create mode 100644 apps/app/src/components-v2/shared/__tests__/StateBlock.spec.ts diff --git a/apps/app/src/components-v2/shared/StateBlock.stories.ts b/apps/app/src/components-v2/shared/StateBlock.stories.ts new file mode 100644 index 00000000..4d2b053e --- /dev/null +++ b/apps/app/src/components-v2/shared/StateBlock.stories.ts @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import StateBlock from '@/components-v2/shared/StateBlock.vue' + +/** + * StateBlock stories. NOTE: per Plan 3 constraint #5 this component has + * NO Tier-4 @visual baseline yet (self-baseline is tautological). These + * stories exist for autodocs + a11y + manual review only. + */ +const meta: Meta = { + title: 'Shared/StateBlock', + component: StateBlock, + tags: ['autodocs'], + argTypes: { state: { control: 'inline-radio', options: ['loading', 'error', 'empty', 'success'] } }, +} + +export default meta +type Story = StoryObj + +export const Loading: Story = { args: { state: 'loading' } } +export const Error: Story = { args: { state: 'error', errorMessage: 'Kon evenementen niet laden.' } } +export const Empty: Story = { args: { state: 'empty', emptyMessage: 'Nog geen evenementen.', actionLabel: 'Nieuw evenement' } } +export const Success: Story = { + args: { state: 'success' }, + render: args => ({ components: { StateBlock }, setup: () => ({ args }), template: '

Echte inhoud.

' }), +} diff --git a/apps/app/src/components-v2/shared/StateBlock.vue b/apps/app/src/components-v2/shared/StateBlock.vue new file mode 100644 index 00000000..356608fe --- /dev/null +++ b/apps/app/src/components-v2/shared/StateBlock.vue @@ -0,0 +1,77 @@ + + + diff --git a/apps/app/src/components-v2/shared/__tests__/StateBlock.spec.ts b/apps/app/src/components-v2/shared/__tests__/StateBlock.spec.ts new file mode 100644 index 00000000..3335262b --- /dev/null +++ b/apps/app/src/components-v2/shared/__tests__/StateBlock.spec.ts @@ -0,0 +1,65 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { defineComponent } from 'vue' +import StateBlock from '@/components-v2/shared/StateBlock.vue' + +const stubs = { + Skeleton: defineComponent({ name: 'Skeleton', template: '
' }), + Message: defineComponent({ name: 'Message', props: ['severity'], template: '
' }), + Button: defineComponent({ name: 'Button', props: ['label'], emits: ['click'], template: '' }), + Card: defineComponent({ name: 'Card', template: '
' }), +} + +const mountSB = (props: { state: 'loading' | 'error' | 'empty' | 'success'; errorMessage?: string; emptyMessage?: string; actionLabel?: string; retryLabel?: string }, slots: Record = {}) => + mount(StateBlock, { props, slots, global: { stubs } }) + +describe('StateBlock', () => { + it('loading: renders Skeleton, nothing else', () => { + const w = mountSB({ state: 'loading' }, { default: '

data

' }) + + expect(w.find('.skeleton-stub').exists()).toBe(true) + expect(w.text()).not.toContain('data') + }) + + it('error: renders Message + retry Button; emits retry on click', async () => { + const w = mountSB({ state: 'error', errorMessage: 'Mislukt' }) + + expect(w.get('.message-stub').text()).toContain('Mislukt') + await w.get('.btn-stub').trigger('click') + expect(w.emitted('retry')).toHaveLength(1) + }) + + it('empty: renders empty Card + action Button; emits action on click', async () => { + const w = mountSB({ state: 'empty', emptyMessage: 'Niets hier', actionLabel: 'Maak aan' }) + + expect(w.text()).toContain('Niets hier') + await w.get('.btn-stub').trigger('click') + expect(w.emitted('action')).toHaveLength(1) + }) + + it('success: renders the default slot, no state chrome', () => { + const w = mountSB({ state: 'success' }, { default: '

real content

' }) + + expect(w.text()).toContain('real content') + expect(w.find('.skeleton-stub').exists()).toBe(false) + expect(w.find('.message-stub').exists()).toBe(false) + }) + + it('transition loading→success swaps chrome for slot content', async () => { + const w = mountSB({ state: 'loading' }, { default: '

loaded

' }) + + expect(w.find('.skeleton-stub').exists()).toBe(true) + await w.setProps({ state: 'success' }) + expect(w.find('.skeleton-stub').exists()).toBe(false) + expect(w.text()).toContain('loaded') + }) + + it('transition error→loading clears the message', async () => { + const w = mountSB({ state: 'error', errorMessage: 'Mislukt' }) + + expect(w.text()).toContain('Mislukt') + await w.setProps({ state: 'loading' }) + expect(w.find('.message-stub').exists()).toBe(false) + expect(w.find('.skeleton-stub').exists()).toBe(true) + }) +})