feat(avocet): EmailCard component — subject, from/date, body preview, expand/collapse
This commit is contained in:
parent
e823c84196
commit
e05ac885d7
2 changed files with 152 additions and 0 deletions
39
web/src/components/EmailCard.test.ts
Normal file
39
web/src/components/EmailCard.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import EmailCard from './EmailCard.vue'
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
id: 'abc', subject: 'Interview Invitation',
|
||||||
|
body: 'Hi there, we would like to schedule a phone screen with you. This will be a 30-minute call.',
|
||||||
|
from: 'recruiter@acme.com', date: '2026-03-01', source: 'imap:test',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EmailCard', () => {
|
||||||
|
it('renders subject', () => {
|
||||||
|
const w = mount(EmailCard, { props: { item } })
|
||||||
|
expect(w.text()).toContain('Interview Invitation')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders from and date', () => {
|
||||||
|
const w = mount(EmailCard, { props: { item } })
|
||||||
|
expect(w.text()).toContain('recruiter@acme.com')
|
||||||
|
expect(w.text()).toContain('2026-03-01')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders truncated body by default', () => {
|
||||||
|
const w = mount(EmailCard, { props: { item } })
|
||||||
|
expect(w.text()).toContain('Hi there')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits expand on button click', async () => {
|
||||||
|
const w = mount(EmailCard, { props: { item } })
|
||||||
|
await w.find('[data-testid="expand-btn"]').trigger('click')
|
||||||
|
expect(w.emitted('expand')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows collapse button when expanded', () => {
|
||||||
|
const w = mount(EmailCard, { props: { item, expanded: true } })
|
||||||
|
expect(w.find('[data-testid="collapse-btn"]').exists()).toBe(true)
|
||||||
|
expect(w.find('[data-testid="expand-btn"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
113
web/src/components/EmailCard.vue
Normal file
113
web/src/components/EmailCard.vue
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
<template>
|
||||||
|
<article class="email-card" :class="{ expanded }">
|
||||||
|
<header class="card-header">
|
||||||
|
<h2 class="subject">{{ item.subject }}</h2>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="from" :title="item.from">{{ item.from }}</span>
|
||||||
|
<time class="date" :datetime="item.date">{{ item.date }}</time>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="body-text">{{ displayBody }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="card-footer">
|
||||||
|
<button
|
||||||
|
v-if="!expanded"
|
||||||
|
data-testid="expand-btn"
|
||||||
|
class="expand-btn"
|
||||||
|
aria-label="Expand full email"
|
||||||
|
@click="$emit('expand')"
|
||||||
|
>
|
||||||
|
Show more ↓
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
data-testid="collapse-btn"
|
||||||
|
class="expand-btn"
|
||||||
|
aria-label="Collapse email"
|
||||||
|
@click="$emit('collapse')"
|
||||||
|
>
|
||||||
|
Show less ↑
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { QueueItem } from '../stores/label'
|
||||||
|
|
||||||
|
const props = defineProps<{ item: QueueItem; expanded?: boolean }>()
|
||||||
|
defineEmits<{ expand: []; collapse: [] }>()
|
||||||
|
|
||||||
|
const PREVIEW_LINES = 6
|
||||||
|
|
||||||
|
const displayBody = computed(() => {
|
||||||
|
if (props.expanded) return props.item.body
|
||||||
|
const lines = props.item.body.split('\n').slice(0, PREVIEW_LINES).join('\n')
|
||||||
|
return lines.length < props.item.body.length ? lines + '…' : lines
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.email-card {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-6);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-4);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-text {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--app-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: opacity var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-btn:hover { opacity: 0.75; }
|
||||||
|
.expand-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--app-primary);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in a new issue