feat(members): implement /members page — Kachel-Ansicht (E2, issue #48)
Backend: - Rename V006 migration to V026 (avoid conflict with existing V006) - Migration adds invalidated_at + partial unique index on household_invite Frontend: - Toast.svelte — new system component (message + dismiss) - SegmentedControl.svelte — new system component (options, value, onchange) - members/+page.server.ts — loads members + active invite - members/[userId]/+server.ts — DELETE/PATCH proxy - members/invites/+server.ts — POST (regenerate) proxy - MemberCard.svelte — tile with avatar, kebab, inline role edit - RemoveDialog.svelte — confirmation dialog (desktop modal + BottomSheet mobile) - InviteCard.svelte + InvitePanel.svelte — invite management UI - MemberGrid.svelte — responsive 4/2-col grid with sorted members - members/+page.svelte — page composing all components with optimistic updates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
50
frontend/src/lib/components/SegmentedControl.svelte
Normal file
50
frontend/src/lib/components/SegmentedControl.svelte
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onchange
|
||||||
|
}: {
|
||||||
|
options: { value: string; label: string }[];
|
||||||
|
value: string;
|
||||||
|
onchange: (value: string) => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div role="group" class="segmented-control">
|
||||||
|
{#each options as option (option.value)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={option.value === value ? 'true' : 'false'}
|
||||||
|
class="segment"
|
||||||
|
class:active={option.value === value}
|
||||||
|
onclick={() => onchange(option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.segmented-control {
|
||||||
|
display: flex;
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment {
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment.active {
|
||||||
|
background: var(--color-page);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
30
frontend/src/lib/components/SegmentedControl.test.ts
Normal file
30
frontend/src/lib/components/SegmentedControl.test.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import SegmentedControl from './SegmentedControl.svelte';
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ value: 'planner', label: 'Planer' },
|
||||||
|
{ value: 'member', label: 'Mitglied' }
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('SegmentedControl', () => {
|
||||||
|
it('renders all option labels', () => {
|
||||||
|
render(SegmentedControl, { props: { options, value: 'planner', onchange: vi.fn() } });
|
||||||
|
expect(screen.getByRole('button', { name: 'Planer' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Mitglied' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the active option with aria-pressed', () => {
|
||||||
|
render(SegmentedControl, { props: { options, value: 'planner', onchange: vi.fn() } });
|
||||||
|
expect(screen.getByRole('button', { name: 'Planer' })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
expect(screen.getByRole('button', { name: 'Mitglied' })).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onchange with the new value when an option is clicked', async () => {
|
||||||
|
const onchange = vi.fn();
|
||||||
|
render(SegmentedControl, { props: { options, value: 'planner', onchange } });
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Mitglied' }));
|
||||||
|
expect(onchange).toHaveBeenCalledWith('member');
|
||||||
|
});
|
||||||
|
});
|
||||||
31
frontend/src/lib/components/Toast.svelte
Normal file
31
frontend/src/lib/components/Toast.svelte
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
const { message, visible, ondismiss }: {
|
||||||
|
message: string;
|
||||||
|
visible: boolean;
|
||||||
|
ondismiss?: () => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if visible}
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
style="
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 200;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
box-shadow: var(--shadow-overlay);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span>{message}</span>
|
||||||
|
<button aria-label="Schließen" onclick={ondismiss}>✕</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
23
frontend/src/lib/components/Toast.test.ts
Normal file
23
frontend/src/lib/components/Toast.test.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import Toast from './Toast.svelte';
|
||||||
|
|
||||||
|
describe('Toast', () => {
|
||||||
|
it('is not mounted when visible is false', () => {
|
||||||
|
render(Toast, { props: { message: 'Hallo', visible: false } });
|
||||||
|
expect(screen.queryByRole('status')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the message when visible is true', () => {
|
||||||
|
render(Toast, { props: { message: 'Gespeichert', visible: true } });
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('Gespeichert');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls ondismiss when close button is clicked', async () => {
|
||||||
|
const ondismiss = vi.fn();
|
||||||
|
render(Toast, { props: { message: 'Fehler', visible: true, ondismiss } });
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /schließen/i }));
|
||||||
|
expect(ondismiss).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
17
frontend/src/routes/(app)/members/+page.server.ts
Normal file
17
frontend/src/routes/(app)/members/+page.server.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { apiClient } from '$lib/server/api';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
||||||
|
const api = apiClient(fetch);
|
||||||
|
|
||||||
|
const [membersRes, inviteRes] = await Promise.all([
|
||||||
|
api.GET('/v1/households/mine/members'),
|
||||||
|
api.GET('/v1/households/mine/invites')
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
members: membersRes.data ?? [],
|
||||||
|
currentUserId: locals.benutzer!.id,
|
||||||
|
activeInvite: inviteRes.data?.data ?? null
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1 +1,97 @@
|
|||||||
<h1 class="text-2xl font-medium p-6">Mitglieder</h1>
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import MemberGrid from './MemberGrid.svelte';
|
||||||
|
import InvitePanel from './InvitePanel.svelte';
|
||||||
|
import RemoveDialog from './RemoveDialog.svelte';
|
||||||
|
import Toast from '$lib/components/Toast.svelte';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
let members = $state(untrack(() => data.members as { userId: string; displayName: string; role: string; joinedAt: string }[]));
|
||||||
|
let activeInvite = $state(untrack(() => data.activeInvite as { inviteCode: string; shareUrl: string; expiresAt: string } | null));
|
||||||
|
let showInvitePanel = $state(false);
|
||||||
|
let removeTarget: { userId: string; displayName: string; role: string; joinedAt: string } | null = $state(null);
|
||||||
|
let toastMessage = $state('');
|
||||||
|
let toastVisible = $state(false);
|
||||||
|
|
||||||
|
const currentUserRole = $derived(members.find((m) => m.userId === data.currentUserId)?.role ?? 'member');
|
||||||
|
const isPlanner = $derived(currentUserRole === 'planner');
|
||||||
|
|
||||||
|
function showToast(message: string) {
|
||||||
|
toastMessage = message;
|
||||||
|
toastVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRoleChange(
|
||||||
|
member: { userId: string; displayName: string; role: string; joinedAt: string },
|
||||||
|
newRole: string
|
||||||
|
) {
|
||||||
|
const original = members.slice();
|
||||||
|
members = members.map((m) => (m.userId === member.userId ? { ...m, role: newRole } : m));
|
||||||
|
|
||||||
|
const res = await fetch('/members/' + member.userId, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ role: newRole })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
members = original;
|
||||||
|
showToast('Fehler beim Ändern der Rolle');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemove(member: { userId: string; displayName: string; role: string; joinedAt: string }) {
|
||||||
|
removeTarget = member;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirmRemove() {
|
||||||
|
if (!removeTarget) return;
|
||||||
|
const target = removeTarget;
|
||||||
|
const original = members.slice();
|
||||||
|
removeTarget = null;
|
||||||
|
members = members.filter((m) => m.userId !== target.userId);
|
||||||
|
|
||||||
|
const res = await fetch('/members/' + target.userId, { method: 'DELETE' });
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
members = original;
|
||||||
|
showToast('Fehler beim Entfernen des Mitglieds');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegenerate() {
|
||||||
|
const res = await fetch('/members/invites', { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
activeInvite = await res.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Mitglieder — Mealprep</title></svelte:head>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<h1>Mitglieder</h1>
|
||||||
|
|
||||||
|
<MemberGrid
|
||||||
|
{members}
|
||||||
|
currentUserId={data.currentUserId}
|
||||||
|
{isPlanner}
|
||||||
|
showInviteCard={isPlanner}
|
||||||
|
onremove={handleRemove}
|
||||||
|
onrolechange={handleRoleChange}
|
||||||
|
oninviteclick={() => (showInvitePanel = !showInvitePanel)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if showInvitePanel && isPlanner && activeInvite}
|
||||||
|
<InvitePanel invite={activeInvite} onregenerate={handleRegenerate} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<RemoveDialog
|
||||||
|
show={removeTarget !== null}
|
||||||
|
member={removeTarget ?? { userId: '', displayName: '', role: '', joinedAt: '' }}
|
||||||
|
onconfirm={handleConfirmRemove}
|
||||||
|
oncancel={() => (removeTarget = null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Toast message={toastMessage} visible={toastVisible} ondismiss={() => (toastVisible = false)} />
|
||||||
|
</main>
|
||||||
|
|||||||
17
frontend/src/routes/(app)/members/InviteCard.svelte
Normal file
17
frontend/src/routes/(app)/members/InviteCard.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
onclick
|
||||||
|
}: {
|
||||||
|
onclick: () => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="invite-card"
|
||||||
|
{onclick}
|
||||||
|
style="background: var(--color-surface); border-radius: var(--radius-xl); border: 2px dashed var(--color-border); padding: 20px; cursor: pointer; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; width: 100%;"
|
||||||
|
>
|
||||||
|
<span style="font-size: 2rem; line-height: 1;" aria-hidden="true">+</span>
|
||||||
|
<span>Einladen</span>
|
||||||
|
</button>
|
||||||
23
frontend/src/routes/(app)/members/InviteCard.test.ts
Normal file
23
frontend/src/routes/(app)/members/InviteCard.test.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import InviteCard from './InviteCard.svelte';
|
||||||
|
|
||||||
|
describe('InviteCard', () => {
|
||||||
|
it('renders the invite tile', () => {
|
||||||
|
render(InviteCard, { props: { onclick: vi.fn() } });
|
||||||
|
expect(screen.getByTestId('invite-card')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a descriptive label', () => {
|
||||||
|
render(InviteCard, { props: { onclick: vi.fn() } });
|
||||||
|
expect(screen.getByText(/einladen/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onclick when tile is clicked', async () => {
|
||||||
|
const onclick = vi.fn();
|
||||||
|
render(InviteCard, { props: { onclick } });
|
||||||
|
await userEvent.click(screen.getByTestId('invite-card'));
|
||||||
|
expect(onclick).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
50
frontend/src/routes/(app)/members/InvitePanel.svelte
Normal file
50
frontend/src/routes/(app)/members/InvitePanel.svelte
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
invite,
|
||||||
|
onregenerate
|
||||||
|
}: {
|
||||||
|
invite: { inviteCode: string; shareUrl: string; expiresAt: string };
|
||||||
|
onregenerate: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let copied = $state(false);
|
||||||
|
|
||||||
|
function copy() {
|
||||||
|
if (navigator.clipboard) {
|
||||||
|
navigator.clipboard.writeText(invite.shareUrl);
|
||||||
|
}
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
copied = false;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExpiry(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style="background: var(--color-surface); border-radius: var(--radius-xl); padding: 20px; margin-top: 16px; border: 1px solid var(--color-border);"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style="font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin: 0 0 12px;"
|
||||||
|
>
|
||||||
|
{invite.shareUrl || invite.inviteCode}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px;">
|
||||||
|
<button type="button" data-testid="copy-btn" onclick={copy}>
|
||||||
|
{copied ? 'Kopiert ✓' : 'Kopieren'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" data-testid="regenerate-btn" onclick={onregenerate}>
|
||||||
|
Neu generieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin: 0; font-size: 0.875rem;">
|
||||||
|
Läuft ab: {formatExpiry(invite.expiresAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
34
frontend/src/routes/(app)/members/InvitePanel.test.ts
Normal file
34
frontend/src/routes/(app)/members/InvitePanel.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import InvitePanel from './InvitePanel.svelte';
|
||||||
|
|
||||||
|
const invite = {
|
||||||
|
inviteCode: 'ABC123XY',
|
||||||
|
shareUrl: 'https://example.com/join/ABC123XY',
|
||||||
|
expiresAt: '2026-12-01T00:00:00Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('InvitePanel', () => {
|
||||||
|
it('shows the invite URL', () => {
|
||||||
|
render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
|
||||||
|
expect(screen.getByText(/ABC123XY/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a copy button', () => {
|
||||||
|
render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
|
||||||
|
expect(screen.getByTestId('copy-btn')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a regenerate button', () => {
|
||||||
|
render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
|
||||||
|
expect(screen.getByTestId('regenerate-btn')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onregenerate when regenerate button is clicked', async () => {
|
||||||
|
const onregenerate = vi.fn();
|
||||||
|
render(InvitePanel, { props: { invite, onregenerate } });
|
||||||
|
await userEvent.click(screen.getByTestId('regenerate-btn'));
|
||||||
|
expect(onregenerate).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
215
frontend/src/routes/(app)/members/MemberCard.svelte
Normal file
215
frontend/src/routes/(app)/members/MemberCard.svelte
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
||||||
|
|
||||||
|
type Member = {
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
role: string;
|
||||||
|
joinedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
member,
|
||||||
|
isCurrentUser,
|
||||||
|
isPlanner,
|
||||||
|
onremove,
|
||||||
|
onrolechange
|
||||||
|
}: {
|
||||||
|
member: Member;
|
||||||
|
isCurrentUser: boolean;
|
||||||
|
isPlanner: boolean;
|
||||||
|
onremove: (member: Member) => void;
|
||||||
|
onrolechange: (member: Member, newRole: string) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let menuOpen = $state(false);
|
||||||
|
let editingRole = $state(false);
|
||||||
|
|
||||||
|
const roleOptions = [
|
||||||
|
{ value: 'planner', label: 'Planer' },
|
||||||
|
{ value: 'member', label: 'Mitglied' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const initials = $derived(member.displayName.slice(0, 2).toUpperCase());
|
||||||
|
|
||||||
|
let cardEl: HTMLElement | undefined = $state(undefined);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser || !menuOpen) return;
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
menuOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', onKeydown);
|
||||||
|
return () => document.removeEventListener('keydown', onKeydown);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser || !menuOpen) return;
|
||||||
|
|
||||||
|
function onClickAway(e: MouseEvent) {
|
||||||
|
if (cardEl && !cardEl.contains(e.target as Node)) {
|
||||||
|
menuOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', onClickAway);
|
||||||
|
return () => document.removeEventListener('click', onClickAway);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<article
|
||||||
|
bind:this={cardEl}
|
||||||
|
data-testid="member-card"
|
||||||
|
style="
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: 20px;
|
||||||
|
position: relative;
|
||||||
|
{isCurrentUser ? 'border: 2px solid var(--color-success);' : ''}
|
||||||
|
"
|
||||||
|
class="member-card"
|
||||||
|
>
|
||||||
|
<!-- Avatar -->
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-success-dark);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Name row -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 6px;">
|
||||||
|
<span style="font-weight: 500;">{member.displayName}</span>
|
||||||
|
{#if isCurrentUser}
|
||||||
|
<span
|
||||||
|
style="
|
||||||
|
background: var(--color-success-tint);
|
||||||
|
color: var(--color-success-dark);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
"
|
||||||
|
>Du</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Role badge or SegmentedControl -->
|
||||||
|
{#if editingRole}
|
||||||
|
<SegmentedControl
|
||||||
|
options={roleOptions}
|
||||||
|
value={member.role}
|
||||||
|
onchange={(newValue) => {
|
||||||
|
onrolechange(member, newValue);
|
||||||
|
editingRole = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span style="font-size: 13px; color: var(--color-text-muted);">
|
||||||
|
{member.role === 'planner' ? 'Planer' : 'Mitglied'}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Kebab button -->
|
||||||
|
{#if isPlanner && !isCurrentUser}
|
||||||
|
<button
|
||||||
|
data-testid="kebab-btn"
|
||||||
|
type="button"
|
||||||
|
class="kebab-btn"
|
||||||
|
onclick={() => { menuOpen = true; }}
|
||||||
|
aria-label="Optionen"
|
||||||
|
style="
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
line-height: 1;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
"
|
||||||
|
>⋮</button>
|
||||||
|
|
||||||
|
<!-- Dropdown -->
|
||||||
|
{#if menuOpen}
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
position: absolute;
|
||||||
|
top: 40px;
|
||||||
|
right: 12px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-raised);
|
||||||
|
z-index: 10;
|
||||||
|
min-width: 140px;
|
||||||
|
overflow: hidden;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style="
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
text-align: left;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
"
|
||||||
|
onclick={() => { menuOpen = false; editingRole = true; }}
|
||||||
|
>Rolle ändern</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style="
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
text-align: left;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-danger, #e53e3e);
|
||||||
|
"
|
||||||
|
onclick={() => { menuOpen = false; onremove(member); }}
|
||||||
|
>Entfernen</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.kebab-btn {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.kebab-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-card:hover .kebab-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
147
frontend/src/routes/(app)/members/MemberCard.test.ts
Normal file
147
frontend/src/routes/(app)/members/MemberCard.test.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import MemberCard from './MemberCard.svelte';
|
||||||
|
|
||||||
|
const plannerMember = {
|
||||||
|
userId: 'u1',
|
||||||
|
displayName: 'Sarah',
|
||||||
|
role: 'planner',
|
||||||
|
joinedAt: '2024-01-01T00:00:00Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
const regularMember = {
|
||||||
|
userId: 'u2',
|
||||||
|
displayName: 'Tom',
|
||||||
|
role: 'member',
|
||||||
|
joinedAt: '2024-02-01T00:00:00Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('MemberCard', () => {
|
||||||
|
it('shows the member display name', () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: plannerMember,
|
||||||
|
isCurrentUser: false,
|
||||||
|
isPlanner: false,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Sarah')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Du"-badge when isCurrentUser is true', () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: plannerMember,
|
||||||
|
isCurrentUser: true,
|
||||||
|
isPlanner: false,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Du')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show kebab button when isCurrentUser is true', () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: plannerMember,
|
||||||
|
isCurrentUser: true,
|
||||||
|
isPlanner: true,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId('kebab-btn')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show kebab button when viewer is not a planner', () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: regularMember,
|
||||||
|
isCurrentUser: false,
|
||||||
|
isPlanner: false,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId('kebab-btn')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows kebab button for other members when viewer is planner', () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: regularMember,
|
||||||
|
isCurrentUser: false,
|
||||||
|
isPlanner: true,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId('kebab-btn')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens dropdown when kebab is clicked', async () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: regularMember,
|
||||||
|
isCurrentUser: false,
|
||||||
|
isPlanner: true,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await userEvent.click(screen.getByTestId('kebab-btn'));
|
||||||
|
expect(screen.getByText('Rolle ändern')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Entfernen')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onremove when "Entfernen" is clicked in dropdown', async () => {
|
||||||
|
const onremove = vi.fn();
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: regularMember,
|
||||||
|
isCurrentUser: false,
|
||||||
|
isPlanner: true,
|
||||||
|
onremove,
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await userEvent.click(screen.getByTestId('kebab-btn'));
|
||||||
|
await userEvent.click(screen.getByText('Entfernen'));
|
||||||
|
expect(onremove).toHaveBeenCalledWith(regularMember);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows SegmentedControl when "Rolle ändern" is clicked', async () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: regularMember,
|
||||||
|
isCurrentUser: false,
|
||||||
|
isPlanner: true,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await userEvent.click(screen.getByTestId('kebab-btn'));
|
||||||
|
await userEvent.click(screen.getByText('Rolle ändern'));
|
||||||
|
expect(screen.getByRole('group')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes dropdown on Escape key', async () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: regularMember,
|
||||||
|
isCurrentUser: false,
|
||||||
|
isPlanner: true,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await userEvent.click(screen.getByTestId('kebab-btn'));
|
||||||
|
expect(screen.getByText('Entfernen')).toBeInTheDocument();
|
||||||
|
await userEvent.keyboard('{Escape}');
|
||||||
|
expect(screen.queryByText('Entfernen')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
67
frontend/src/routes/(app)/members/MemberGrid.svelte
Normal file
67
frontend/src/routes/(app)/members/MemberGrid.svelte
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import MemberCard from './MemberCard.svelte';
|
||||||
|
import InviteCard from './InviteCard.svelte';
|
||||||
|
|
||||||
|
type Member = {
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
role: string;
|
||||||
|
joinedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
members,
|
||||||
|
currentUserId,
|
||||||
|
isPlanner,
|
||||||
|
showInviteCard,
|
||||||
|
onremove,
|
||||||
|
onrolechange,
|
||||||
|
oninviteclick
|
||||||
|
}: {
|
||||||
|
members: Member[];
|
||||||
|
currentUserId: string;
|
||||||
|
isPlanner: boolean;
|
||||||
|
showInviteCard: boolean;
|
||||||
|
onremove: (member: any) => void;
|
||||||
|
onrolechange: (member: any, newRole: string) => void;
|
||||||
|
oninviteclick: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const sortedMembers = $derived(
|
||||||
|
[...members].sort((a, b) => {
|
||||||
|
if (a.userId === currentUserId) return -1;
|
||||||
|
if (b.userId === currentUserId) return 1;
|
||||||
|
return new Date(a.joinedAt).getTime() - new Date(b.joinedAt).getTime();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="member-grid">
|
||||||
|
{#each sortedMembers as m (m.userId)}
|
||||||
|
<MemberCard
|
||||||
|
member={m}
|
||||||
|
isCurrentUser={m.userId === currentUserId}
|
||||||
|
{isPlanner}
|
||||||
|
{onremove}
|
||||||
|
onrolechange={(m, role) => onrolechange(m, role)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{#if isPlanner && showInviteCard}
|
||||||
|
<InviteCard onclick={oninviteclick} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.member-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.member-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
73
frontend/src/routes/(app)/members/MemberGrid.test.ts
Normal file
73
frontend/src/routes/(app)/members/MemberGrid.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import MemberGrid from './MemberGrid.svelte';
|
||||||
|
|
||||||
|
const members = [
|
||||||
|
{ userId: 'u1', displayName: 'Sarah', role: 'planner', joinedAt: '2024-01-01T00:00:00Z' },
|
||||||
|
{ userId: 'u2', displayName: 'Tom', role: 'member', joinedAt: '2024-02-01T00:00:00Z' },
|
||||||
|
{ userId: 'u3', displayName: 'Anna', role: 'member', joinedAt: '2024-03-01T00:00:00Z' }
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('MemberGrid', () => {
|
||||||
|
it('renders all member cards', () => {
|
||||||
|
render(MemberGrid, {
|
||||||
|
props: {
|
||||||
|
members,
|
||||||
|
currentUserId: 'u1',
|
||||||
|
isPlanner: true,
|
||||||
|
showInviteCard: true,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn(),
|
||||||
|
oninviteclick: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Sarah')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Tom')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Anna')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows invite card when showInviteCard is true and isPlanner is true', () => {
|
||||||
|
render(MemberGrid, {
|
||||||
|
props: {
|
||||||
|
members,
|
||||||
|
currentUserId: 'u1',
|
||||||
|
isPlanner: true,
|
||||||
|
showInviteCard: true,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn(),
|
||||||
|
oninviteclick: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId('invite-card')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides invite card when isPlanner is false', () => {
|
||||||
|
render(MemberGrid, {
|
||||||
|
props: {
|
||||||
|
members,
|
||||||
|
currentUserId: 'u2',
|
||||||
|
isPlanner: false,
|
||||||
|
showInviteCard: true,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn(),
|
||||||
|
oninviteclick: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId('invite-card')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Du"-badge on the current user card', () => {
|
||||||
|
render(MemberGrid, {
|
||||||
|
props: {
|
||||||
|
members,
|
||||||
|
currentUserId: 'u1',
|
||||||
|
isPlanner: true,
|
||||||
|
showInviteCard: false,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn(),
|
||||||
|
oninviteclick: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Du')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
86
frontend/src/routes/(app)/members/RemoveDialog.svelte
Normal file
86
frontend/src/routes/(app)/members/RemoveDialog.svelte
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BottomSheet from '$lib/components/BottomSheet.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
show,
|
||||||
|
member,
|
||||||
|
onconfirm,
|
||||||
|
oncancel
|
||||||
|
}: {
|
||||||
|
show: boolean;
|
||||||
|
member: { userId: string; displayName: string; role: string; joinedAt: string };
|
||||||
|
onconfirm: () => void;
|
||||||
|
oncancel: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const isMobile = () => typeof window !== 'undefined' && window.innerWidth < 768;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if show}
|
||||||
|
{#if isMobile()}
|
||||||
|
<BottomSheet open={show} onclose={oncancel}>
|
||||||
|
<div data-testid="remove-dialog" style="padding: 24px;">
|
||||||
|
<h2 style="margin: 0 0 12px;">Mitglied entfernen</h2>
|
||||||
|
<p>
|
||||||
|
Soll <strong>{member.displayName}</strong> wirklich entfernt werden?
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={oncancel}
|
||||||
|
style="background: transparent; border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 10px 20px; cursor: pointer;"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="confirm-remove-btn"
|
||||||
|
onclick={onconfirm}
|
||||||
|
style="background: var(--color-error); color: white; border: none; border-radius: var(--radius-md); padding: 10px 20px; cursor: pointer;"
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BottomSheet>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
data-testid="dialog-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
style="position: fixed; inset: 0; z-index: 100; background: rgba(28,28,24,0.4); display: flex; align-items: center; justify-content: center;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-testid="remove-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="remove-dialog-title"
|
||||||
|
tabindex="-1"
|
||||||
|
style="background: var(--color-surface); border-radius: var(--radius-xl); padding: 24px; max-width: 400px; width: 90%;"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 id="remove-dialog-title" style="margin: 0 0 12px;">Mitglied entfernen</h2>
|
||||||
|
<p>
|
||||||
|
Soll <strong>{member.displayName}</strong> wirklich entfernt werden?
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={oncancel}
|
||||||
|
style="background: transparent; border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 10px 20px; cursor: pointer;"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="confirm-remove-btn"
|
||||||
|
onclick={onconfirm}
|
||||||
|
style="background: var(--color-error); color: white; border: none; border-radius: var(--radius-md); padding: 10px 20px; cursor: pointer;"
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
56
frontend/src/routes/(app)/members/RemoveDialog.test.ts
Normal file
56
frontend/src/routes/(app)/members/RemoveDialog.test.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import RemoveDialog from './RemoveDialog.svelte';
|
||||||
|
|
||||||
|
const member = {
|
||||||
|
userId: 'u2',
|
||||||
|
displayName: 'Tom',
|
||||||
|
role: 'member',
|
||||||
|
joinedAt: '2024-02-01T00:00:00Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('RemoveDialog', () => {
|
||||||
|
it('is not rendered when show is false', () => {
|
||||||
|
render(RemoveDialog, {
|
||||||
|
props: { show: false, member, onconfirm: vi.fn(), oncancel: vi.fn() }
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId('remove-dialog')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the member displayName in dialog', () => {
|
||||||
|
render(RemoveDialog, {
|
||||||
|
props: { show: true, member, onconfirm: vi.fn(), oncancel: vi.fn() }
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId('remove-dialog')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Tom/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onconfirm when confirm button is clicked', async () => {
|
||||||
|
const onconfirm = vi.fn();
|
||||||
|
render(RemoveDialog, {
|
||||||
|
props: { show: true, member, onconfirm, oncancel: vi.fn() }
|
||||||
|
});
|
||||||
|
await userEvent.click(screen.getByTestId('confirm-remove-btn'));
|
||||||
|
expect(onconfirm).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls oncancel when cancel button is clicked', async () => {
|
||||||
|
const oncancel = vi.fn();
|
||||||
|
render(RemoveDialog, {
|
||||||
|
props: { show: true, member, onconfirm: vi.fn(), oncancel }
|
||||||
|
});
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /abbrechen/i }));
|
||||||
|
expect(oncancel).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT call oncancel when backdrop is clicked', async () => {
|
||||||
|
const oncancel = vi.fn();
|
||||||
|
render(RemoveDialog, {
|
||||||
|
props: { show: true, member, onconfirm: vi.fn(), oncancel }
|
||||||
|
});
|
||||||
|
const backdrop = screen.getByTestId('dialog-backdrop');
|
||||||
|
await userEvent.click(backdrop);
|
||||||
|
expect(oncancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
21
frontend/src/routes/(app)/members/[userId]/+server.ts
Normal file
21
frontend/src/routes/(app)/members/[userId]/+server.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { apiClient } from '$lib/server/api';
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = async ({ fetch, params }) => {
|
||||||
|
const api = apiClient(fetch);
|
||||||
|
const { response } = await api.DELETE('/v1/households/mine/members/{userId}', {
|
||||||
|
params: { path: { userId: params.userId } }
|
||||||
|
});
|
||||||
|
return new Response(null, { status: response?.status ?? 204 });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PATCH: RequestHandler = async ({ fetch, params, request }) => {
|
||||||
|
const body = await request.json();
|
||||||
|
const api = apiClient(fetch);
|
||||||
|
const { data, response } = await api.PATCH('/v1/households/mine/members/{userId}', {
|
||||||
|
params: { path: { userId: params.userId } },
|
||||||
|
body
|
||||||
|
});
|
||||||
|
return json(data, { status: response?.status ?? 200 });
|
||||||
|
};
|
||||||
50
frontend/src/routes/(app)/members/[userId]/server.test.ts
Normal file
50
frontend/src/routes/(app)/members/[userId]/server.test.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('$env/dynamic/private', () => ({ env: { BACKEND_URL: 'http://localhost:8080' } }));
|
||||||
|
|
||||||
|
const mockDelete = vi.fn();
|
||||||
|
const mockPatch = vi.fn();
|
||||||
|
vi.mock('$lib/server/api', () => ({
|
||||||
|
apiClient: () => ({ DELETE: mockDelete, PATCH: mockPatch })
|
||||||
|
}));
|
||||||
|
|
||||||
|
const USER_UUID = '22222222-2222-2222-2222-222222222222';
|
||||||
|
|
||||||
|
describe('members server routes', () => {
|
||||||
|
let DELETE: any;
|
||||||
|
let PATCH: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockDelete.mockReset();
|
||||||
|
mockPatch.mockReset();
|
||||||
|
vi.resetModules();
|
||||||
|
const mod = await import('./+server');
|
||||||
|
DELETE = mod.DELETE;
|
||||||
|
PATCH = mod.PATCH;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE proxies to backend and returns 204', async () => {
|
||||||
|
mockDelete.mockResolvedValue({ response: { status: 204 } });
|
||||||
|
const event = {
|
||||||
|
fetch: vi.fn(),
|
||||||
|
params: { userId: USER_UUID },
|
||||||
|
request: { json: vi.fn() }
|
||||||
|
} as any;
|
||||||
|
const res = await DELETE(event);
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH proxies to backend and returns member response', async () => {
|
||||||
|
mockPatch.mockResolvedValue({
|
||||||
|
data: { status: 'success', data: { userId: USER_UUID, displayName: 'Tom', role: 'planner', joinedAt: '' } },
|
||||||
|
response: { status: 200 }
|
||||||
|
});
|
||||||
|
const event = {
|
||||||
|
fetch: vi.fn(),
|
||||||
|
params: { userId: USER_UUID },
|
||||||
|
request: { json: async () => ({ role: 'planner' }) }
|
||||||
|
} as any;
|
||||||
|
const res = await PATCH(event);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
9
frontend/src/routes/(app)/members/invites/+server.ts
Normal file
9
frontend/src/routes/(app)/members/invites/+server.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { apiClient } from '$lib/server/api';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ fetch }) => {
|
||||||
|
const api = apiClient(fetch);
|
||||||
|
const { data, response } = await api.POST('/v1/households/mine/invites');
|
||||||
|
return json(data, { status: response?.status ?? 201 });
|
||||||
|
};
|
||||||
74
frontend/src/routes/(app)/members/page.server.test.ts
Normal file
74
frontend/src/routes/(app)/members/page.server.test.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('$lib/server/api', () => ({
|
||||||
|
apiClient: vi.fn(() => ({
|
||||||
|
GET: vi.fn()
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$env/dynamic/private', () => ({ env: { BACKEND_URL: 'http://localhost:8080' } }));
|
||||||
|
|
||||||
|
describe('members page.server load', () => {
|
||||||
|
let load: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
const mod = await import('./+page.server');
|
||||||
|
load = mod.load;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns members and currentUserId', async () => {
|
||||||
|
const mockGet = vi.fn().mockImplementation((path: string) => {
|
||||||
|
if (path === '/v1/households/mine/members') {
|
||||||
|
return {
|
||||||
|
data: [
|
||||||
|
{ userId: 'u1', displayName: 'Sarah', role: 'planner', joinedAt: '2024-01-01T00:00:00Z' },
|
||||||
|
{ userId: 'u2', displayName: 'Tom', role: 'member', joinedAt: '2024-02-01T00:00:00Z' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (path === '/v1/households/mine/invites') {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
data: {
|
||||||
|
inviteCode: 'ABC123',
|
||||||
|
shareUrl: 'https://x.com/join/ABC123',
|
||||||
|
expiresAt: '2024-12-01T00:00:00Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { data: null };
|
||||||
|
});
|
||||||
|
|
||||||
|
const { apiClient } = await import('$lib/server/api');
|
||||||
|
(apiClient as ReturnType<typeof vi.fn>).mockReturnValue({ GET: mockGet });
|
||||||
|
|
||||||
|
const result = await load({
|
||||||
|
fetch: vi.fn(),
|
||||||
|
locals: { benutzer: { id: 'u1', name: 'Sarah', email: 'sarah@example.com' }, haushalt: {} }
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(result.members).toHaveLength(2);
|
||||||
|
expect(result.currentUserId).toBe('u1');
|
||||||
|
expect(result.activeInvite).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null activeInvite when no active invite exists', async () => {
|
||||||
|
const mockGet = vi.fn().mockImplementation((path: string) => {
|
||||||
|
if (path === '/v1/households/mine/members') return { data: [] };
|
||||||
|
if (path === '/v1/households/mine/invites') return { data: null };
|
||||||
|
return { data: null };
|
||||||
|
});
|
||||||
|
|
||||||
|
const { apiClient } = await import('$lib/server/api');
|
||||||
|
(apiClient as ReturnType<typeof vi.fn>).mockReturnValue({ GET: mockGet });
|
||||||
|
|
||||||
|
const result = await load({
|
||||||
|
fetch: vi.fn(),
|
||||||
|
locals: { benutzer: { id: 'u1', name: 'Sarah', email: 'sarah@example.com' }, haushalt: {} }
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(result.activeInvite).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user