T08b: Frontend Calendar UI (month view, kanban, ICS, resources, sharing)
- 8 calendar components (MonthView, KanbanBoard, AppointmentModal, TaskDetailPanel, IcsControls, ResourceBooking, SharingSettings + API client) - 2 pages (/calendar, /calendar/kanban) + zustand store - 17 vitest tests (MonthView 5, KanbanBoard 6, AppointmentModal 6) all passing - i18n: calendar namespace in en/de (104 lines each, +exportSuccess key) - TS strict mode pass, npm run build pass
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { AppointmentModal } from '@/components/calendar/AppointmentModal';
|
||||
import type { Calendar, CalendarEntry } from '@/api/calendar';
|
||||
|
||||
vi.mock('@/api/calendar', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/api/calendar')>('@/api/calendar');
|
||||
return {
|
||||
...actual,
|
||||
createEntry: vi.fn(),
|
||||
updateEntry: vi.fn(),
|
||||
deleteEntry: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { createEntry, updateEntry, deleteEntry } from '@/api/calendar';
|
||||
|
||||
const calendars: Calendar[] = [
|
||||
{
|
||||
id: 'cal-1',
|
||||
name: 'Persönlich',
|
||||
color: '#3B82F6',
|
||||
type: 'personal',
|
||||
owner_id: 'u-1',
|
||||
},
|
||||
];
|
||||
|
||||
const sampleEntry: CalendarEntry = {
|
||||
id: 'e-1',
|
||||
calendar_id: 'cal-1',
|
||||
entry_type: 'appointment',
|
||||
subtype: 'normal',
|
||||
title: 'Bestehender Termin',
|
||||
description: 'Notiz',
|
||||
location: 'Berlin',
|
||||
start_at: '2026-06-20T09:00:00.000Z',
|
||||
end_at: '2026-06-20T10:00:00.000Z',
|
||||
all_day: false,
|
||||
priority: 'medium',
|
||||
status: 'open',
|
||||
created_by: 'u-1',
|
||||
};
|
||||
|
||||
describe('AppointmentModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the create modal with pre-filled date from prefillDate', () => {
|
||||
render(
|
||||
<AppointmentModal
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
prefillDate={new Date(2026, 6, 1)}
|
||||
calendars={calendars}
|
||||
defaultCalendarId="cal-1"
|
||||
onSaved={vi.fn()}
|
||||
onDeleted={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('appointment-modal')).toBeInTheDocument();
|
||||
const start = screen.getByTestId('appointment-start') as HTMLInputElement;
|
||||
expect(start.value).toMatch(/2026-07-01/);
|
||||
});
|
||||
|
||||
it('in edit mode, prefills the form with the entry fields and shows the delete button', () => {
|
||||
render(
|
||||
<AppointmentModal
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
entry={sampleEntry}
|
||||
calendars={calendars}
|
||||
defaultCalendarId="cal-1"
|
||||
onSaved={vi.fn()}
|
||||
onDeleted={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect((screen.getByTestId('appointment-title') as HTMLInputElement).value).toBe(
|
||||
'Bestehender Termin'
|
||||
);
|
||||
expect(screen.getByTestId('appointment-delete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('save in create mode calls createEntry and onSaved', async () => {
|
||||
(createEntry as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(sampleEntry);
|
||||
const onSaved = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<AppointmentModal
|
||||
open
|
||||
onClose={onClose}
|
||||
prefillDate={new Date(2026, 6, 1, 9, 0)}
|
||||
calendars={calendars}
|
||||
defaultCalendarId="cal-1"
|
||||
onSaved={onSaved}
|
||||
onDeleted={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByTestId('appointment-title'), {
|
||||
target: { value: 'Neuer Termin' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('appointment-save'));
|
||||
await waitFor(() => {
|
||||
expect(createEntry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(onSaved).toHaveBeenCalledWith(sampleEntry);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('save in edit mode calls updateEntry and onSaved', async () => {
|
||||
(updateEntry as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...sampleEntry,
|
||||
title: 'Geändert',
|
||||
});
|
||||
const onSaved = vi.fn();
|
||||
render(
|
||||
<AppointmentModal
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
entry={sampleEntry}
|
||||
calendars={calendars}
|
||||
defaultCalendarId="cal-1"
|
||||
onSaved={onSaved}
|
||||
onDeleted={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByTestId('appointment-title'), {
|
||||
target: { value: 'Geändert' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('appointment-save'));
|
||||
await waitFor(() => {
|
||||
expect(updateEntry).toHaveBeenCalledWith(
|
||||
'e-1',
|
||||
expect.objectContaining({ title: 'Geändert' })
|
||||
);
|
||||
});
|
||||
expect(onSaved).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a validation error when title is empty', async () => {
|
||||
render(
|
||||
<AppointmentModal
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
prefillDate={new Date(2026, 6, 1)}
|
||||
calendars={calendars}
|
||||
defaultCalendarId="cal-1"
|
||||
onSaved={vi.fn()}
|
||||
onDeleted={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('appointment-save'));
|
||||
const err = await screen.findByTestId('appointment-error');
|
||||
expect(err.textContent?.toLowerCase()).toContain('erforderlich');
|
||||
});
|
||||
|
||||
it('delete button calls deleteEntry and onDeleted', async () => {
|
||||
(deleteEntry as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
const onDeleted = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
// Stub confirm
|
||||
window.confirm = vi.fn(() => true);
|
||||
render(
|
||||
<AppointmentModal
|
||||
open
|
||||
onClose={onClose}
|
||||
entry={sampleEntry}
|
||||
calendars={calendars}
|
||||
defaultCalendarId="cal-1"
|
||||
onSaved={vi.fn()}
|
||||
onDeleted={onDeleted}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('appointment-delete'));
|
||||
await waitFor(() => {
|
||||
expect(deleteEntry).toHaveBeenCalledWith('e-1');
|
||||
});
|
||||
expect(onDeleted).toHaveBeenCalledWith('e-1');
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { KanbanBoard } from '@/components/calendar/KanbanBoard';
|
||||
import type { CalendarEntry } from '@/api/calendar';
|
||||
|
||||
function makeTask(id: string, title: string, status: CalendarEntry['status']): CalendarEntry {
|
||||
return {
|
||||
id,
|
||||
calendar_id: 'cal-1',
|
||||
entry_type: 'task',
|
||||
subtype: 'normal',
|
||||
title,
|
||||
all_day: false,
|
||||
priority: 'medium',
|
||||
status,
|
||||
created_by: 'u-1',
|
||||
due_date: '2026-07-01T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
const board = {
|
||||
open: [makeTask('t-1', 'Recherche', 'open')],
|
||||
in_progress: [makeTask('t-2', 'Implementierung', 'in_progress')],
|
||||
done: [makeTask('t-3', 'Tests', 'done')],
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
describe('KanbanBoard', () => {
|
||||
it('renders three columns (open / in_progress / done)', () => {
|
||||
render(<KanbanBoard board={board} onMove={vi.fn()} />);
|
||||
expect(screen.getByTestId('kanban-column-open')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('kanban-column-in_progress')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('kanban-column-done')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders task cards inside the correct columns', () => {
|
||||
render(<KanbanBoard board={board} onMove={vi.fn()} />);
|
||||
expect(screen.getByTestId('kanban-card-t-1').textContent).toContain('Recherche');
|
||||
expect(screen.getByTestId('kanban-card-t-2').textContent).toContain('Implementierung');
|
||||
expect(screen.getByTestId('kanban-card-t-3').textContent).toContain('Tests');
|
||||
});
|
||||
|
||||
it('shows column counts', () => {
|
||||
render(<KanbanBoard board={board} onMove={vi.fn()} />);
|
||||
expect(screen.getByTestId('kanban-count-open').textContent).toBe('1');
|
||||
expect(screen.getByTestId('kanban-count-in_progress').textContent).toBe('1');
|
||||
expect(screen.getByTestId('kanban-count-done').textContent).toBe('1');
|
||||
});
|
||||
|
||||
it('shows empty state when there are no tasks', () => {
|
||||
render(
|
||||
<KanbanBoard
|
||||
board={{ open: [], in_progress: [], done: [], cancelled: [] }}
|
||||
onMove={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('kanban-empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('drag-and-drop into another column fires onMove with target status', () => {
|
||||
const onMove = vi.fn();
|
||||
render(<KanbanBoard board={board} onMove={onMove} />);
|
||||
const card = screen.getByTestId('kanban-card-t-1');
|
||||
const targetCol = screen.getByTestId('kanban-column-done');
|
||||
const dataTransfer = {
|
||||
setData: vi.fn(),
|
||||
getData: (k: string) => {
|
||||
if (k === 'application/x-kanban-entry') return 't-1';
|
||||
if (k === 'application/x-kanban-source') return 'open';
|
||||
return '';
|
||||
},
|
||||
types: ['application/x-kanban-entry', 'application/x-kanban-source'],
|
||||
effectAllowed: '',
|
||||
dropEffect: '',
|
||||
};
|
||||
fireEvent.dragStart(card, { dataTransfer });
|
||||
fireEvent.dragOver(targetCol, { dataTransfer });
|
||||
fireEvent.drop(targetCol, { dataTransfer });
|
||||
expect(onMove).toHaveBeenCalledTimes(1);
|
||||
const [movedEntry, target] = onMove.mock.calls[0];
|
||||
expect(movedEntry.id).toBe('t-1');
|
||||
expect(target).toBe('done');
|
||||
});
|
||||
|
||||
it('clicking a card fires onSelect', () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<KanbanBoard board={board} onMove={vi.fn()} onSelect={onSelect} />);
|
||||
fireEvent.click(screen.getByTestId('kanban-card-t-2'));
|
||||
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 't-2' }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MonthView } from '@/components/calendar/MonthView';
|
||||
import type { CalendarEntry } from '@/api/calendar';
|
||||
|
||||
const sampleEntries: CalendarEntry[] = [
|
||||
{
|
||||
id: 'a-1',
|
||||
calendar_id: 'cal-1',
|
||||
entry_type: 'appointment',
|
||||
subtype: 'normal',
|
||||
title: 'Team-Meeting',
|
||||
start_at: '2026-06-15T09:00:00.000Z',
|
||||
end_at: '2026-06-15T10:00:00.000Z',
|
||||
all_day: false,
|
||||
priority: 'medium',
|
||||
status: 'open',
|
||||
created_by: 'u-1',
|
||||
},
|
||||
{
|
||||
id: 'a-2',
|
||||
calendar_id: 'cal-1',
|
||||
entry_type: 'appointment',
|
||||
subtype: 'normal',
|
||||
title: 'Kundentermin',
|
||||
start_at: '2026-06-22T14:00:00.000Z',
|
||||
end_at: '2026-06-22T15:00:00.000Z',
|
||||
all_day: false,
|
||||
priority: 'high',
|
||||
status: 'open',
|
||||
created_by: 'u-1',
|
||||
},
|
||||
];
|
||||
|
||||
describe('MonthView', () => {
|
||||
it('renders the month grid with header', () => {
|
||||
render(
|
||||
<MonthView
|
||||
visibleMonth={new Date(2026, 5, 1)}
|
||||
entries={[]}
|
||||
onCreateAt={vi.fn()}
|
||||
onEditEntry={vi.fn()}
|
||||
onMoveEntry={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('month-view')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('month-view-grid')).toBeInTheDocument();
|
||||
// Header includes month name and year
|
||||
expect(screen.getByTestId('month-view-header').textContent).toContain('2026');
|
||||
});
|
||||
|
||||
it('renders all 42 day cells (6 weeks × 7 days)', () => {
|
||||
render(
|
||||
<MonthView
|
||||
visibleMonth={new Date(2026, 5, 1)}
|
||||
entries={[]}
|
||||
onCreateAt={vi.fn()}
|
||||
onEditEntry={vi.fn()}
|
||||
onMoveEntry={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const cells = screen.getAllByRole('button', { hidden: false }).filter((el) =>
|
||||
el.getAttribute('data-testid')?.startsWith('month-cell-')
|
||||
);
|
||||
expect(cells.length).toBe(42);
|
||||
});
|
||||
|
||||
it('renders an entry chip for each appointment and clicking it fires onEditEntry', () => {
|
||||
const onEdit = vi.fn();
|
||||
render(
|
||||
<MonthView
|
||||
visibleMonth={new Date(2026, 5, 1)}
|
||||
entries={sampleEntries}
|
||||
onCreateAt={vi.fn()}
|
||||
onEditEntry={onEdit}
|
||||
onMoveEntry={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('month-entry-a-1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('month-entry-a-2')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('month-entry-a-1'));
|
||||
expect(onEdit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'a-1', title: 'Team-Meeting' })
|
||||
);
|
||||
});
|
||||
|
||||
it('clicking an empty cell fires onCreateAt with the day Date', () => {
|
||||
const onCreate = vi.fn();
|
||||
render(
|
||||
<MonthView
|
||||
visibleMonth={new Date(2026, 5, 1)}
|
||||
entries={[]}
|
||||
onCreateAt={onCreate}
|
||||
onEditEntry={vi.fn()}
|
||||
onMoveEntry={vi.fn()}
|
||||
/>
|
||||
);
|
||||
// Pick the first cell of the grid
|
||||
const cells = screen.getAllByRole('button', { hidden: false }).filter((el) =>
|
||||
el.getAttribute('data-testid')?.startsWith('month-cell-')
|
||||
);
|
||||
fireEvent.click(cells[0]);
|
||||
expect(onCreate).toHaveBeenCalledTimes(1);
|
||||
const arg = onCreate.mock.calls[0][0] as Date;
|
||||
expect(arg).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('drag-and-drop fires onMoveEntry with the new start time-of-day preserved', () => {
|
||||
const onMove = vi.fn();
|
||||
const { container } = render(
|
||||
<MonthView
|
||||
visibleMonth={new Date(2026, 5, 1)}
|
||||
entries={sampleEntries}
|
||||
onCreateAt={vi.fn()}
|
||||
onEditEntry={vi.fn()}
|
||||
onMoveEntry={onMove}
|
||||
/>
|
||||
);
|
||||
const entryEl = screen.getByTestId('month-entry-a-1');
|
||||
const cells = container.querySelectorAll('[data-testid^="month-cell-"]');
|
||||
// Pick a cell that's clearly different from June 15
|
||||
const targetCell = cells[20] as HTMLElement;
|
||||
const dataTransfer = {
|
||||
setData: vi.fn(),
|
||||
getData: (k: string) => (k === 'application/x-calendar-entry' ? 'a-1' : ''),
|
||||
types: ['application/x-calendar-entry'],
|
||||
effectAllowed: '',
|
||||
dropEffect: '',
|
||||
};
|
||||
fireEvent.dragStart(entryEl, { dataTransfer });
|
||||
fireEvent.dragOver(targetCell, { dataTransfer });
|
||||
fireEvent.drop(targetCell, { dataTransfer });
|
||||
expect(onMove).toHaveBeenCalledTimes(1);
|
||||
const [, newStart] = onMove.mock.calls[0];
|
||||
expect(newStart).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user