feat(gui-v2): StateBlock 3-state wrapper (exhaustive Vitest, no @visual per constraint #5)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
25
apps/app/src/components-v2/shared/StateBlock.stories.ts
Normal file
25
apps/app/src/components-v2/shared/StateBlock.stories.ts
Normal file
@@ -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<typeof StateBlock> = {
|
||||||
|
title: 'Shared/StateBlock',
|
||||||
|
component: StateBlock,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
argTypes: { state: { control: 'inline-radio', options: ['loading', 'error', 'empty', 'success'] } },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof StateBlock>
|
||||||
|
|
||||||
|
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: '<StateBlock v-bind="args"><p>Echte inhoud.</p></StateBlock>' }),
|
||||||
|
}
|
||||||
77
apps/app/src/components-v2/shared/StateBlock.vue
Normal file
77
apps/app/src/components-v2/shared/StateBlock.vue
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* StateBlock — the CLAUDE.md mandatory three-state wrapper (loading /
|
||||||
|
* error / empty) + success passthrough. Built fresh (no crewli-starter
|
||||||
|
* source). Per Plan 3 constraint #5 this component intentionally has
|
||||||
|
* NO @visual baseline yet (self-baseline is tautological); correctness
|
||||||
|
* is locked by the exhaustive Vitest spec. Visual baseline is a
|
||||||
|
* Plans 4–5 follow-up after first real page usage.
|
||||||
|
*/
|
||||||
|
import Skeleton from 'primevue/skeleton'
|
||||||
|
import Message from 'primevue/message'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import Card from 'primevue/card'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
state: 'loading' | 'error' | 'empty' | 'success'
|
||||||
|
errorMessage?: string
|
||||||
|
emptyMessage?: string
|
||||||
|
actionLabel?: string
|
||||||
|
retryLabel?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ retry: []; action: [] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="state === 'loading'"
|
||||||
|
class="flex flex-col gap-3"
|
||||||
|
data-state="loading"
|
||||||
|
>
|
||||||
|
<Skeleton height="2rem" />
|
||||||
|
<Skeleton
|
||||||
|
height="2rem"
|
||||||
|
width="80%"
|
||||||
|
/>
|
||||||
|
<Skeleton
|
||||||
|
height="2rem"
|
||||||
|
width="60%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Message
|
||||||
|
v-else-if="state === 'error'"
|
||||||
|
severity="error"
|
||||||
|
:closable="false"
|
||||||
|
data-state="error"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-4 w-full">
|
||||||
|
<span>{{ errorMessage ?? 'Er ging iets mis.' }}</span>
|
||||||
|
<Button
|
||||||
|
:label="retryLabel ?? 'Opnieuw proberen'"
|
||||||
|
size="small"
|
||||||
|
@click="emit('retry')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Message>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
v-else-if="state === 'empty'"
|
||||||
|
data-state="empty"
|
||||||
|
:pt="{ root: 'border border-dashed border-[var(--p-content-border-color)] shadow-none', content: 'flex flex-col items-center gap-3 py-10 text-center' }"
|
||||||
|
>
|
||||||
|
<template #content>
|
||||||
|
<p class="m-0 text-[var(--p-text-muted-color)]">
|
||||||
|
{{ emptyMessage ?? 'Nog niets om te tonen.' }}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
v-if="actionLabel"
|
||||||
|
:label="actionLabel"
|
||||||
|
@click="emit('action')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<slot v-else />
|
||||||
|
</template>
|
||||||
@@ -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: '<div class="skeleton-stub" />' }),
|
||||||
|
Message: defineComponent({ name: 'Message', props: ['severity'], template: '<div class="message-stub"><slot /></div>' }),
|
||||||
|
Button: defineComponent({ name: 'Button', props: ['label'], emits: ['click'], template: '<button class="btn-stub" @click="$emit(\'click\')">{{ label }}</button>' }),
|
||||||
|
Card: defineComponent({ name: 'Card', template: '<div class="card-stub"><slot name="content" /></div>' }),
|
||||||
|
}
|
||||||
|
|
||||||
|
const mountSB = (props: { state: 'loading' | 'error' | 'empty' | 'success'; errorMessage?: string; emptyMessage?: string; actionLabel?: string; retryLabel?: string }, slots: Record<string, string> = {}) =>
|
||||||
|
mount(StateBlock, { props, slots, global: { stubs } })
|
||||||
|
|
||||||
|
describe('StateBlock', () => {
|
||||||
|
it('loading: renders Skeleton, nothing else', () => {
|
||||||
|
const w = mountSB({ state: 'loading' }, { default: '<p>data</p>' })
|
||||||
|
|
||||||
|
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: '<p>real content</p>' })
|
||||||
|
|
||||||
|
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: '<p>loaded</p>' })
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user