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