feat: add task multi-select on long-press
This commit is contained in:
@@ -585,6 +585,12 @@
|
|||||||
"showCollections": "Alle Pakete anzeigen",
|
"showCollections": "Alle Pakete anzeigen",
|
||||||
"collectionsEmpty": "Keine Pakete vorhanden.",
|
"collectionsEmpty": "Keine Pakete vorhanden.",
|
||||||
"bulkAdd": "Bulk add",
|
"bulkAdd": "Bulk add",
|
||||||
|
"selectionCount": "{{count}} ausgewählt",
|
||||||
|
"bulkRemove": "Auswahl löschen",
|
||||||
|
"bulkCancel": "Auswahl beenden",
|
||||||
|
"bulkRemoveTitle": "Auswahl löschen",
|
||||||
|
"bulkRemoveBody": "Dies entfernt die ausgewählten Aufgaben aus dem Event.",
|
||||||
|
"select": "Aufgabe auswählen",
|
||||||
"manageEmotions": "Emotionen verwalten",
|
"manageEmotions": "Emotionen verwalten",
|
||||||
"manageEmotionsHint": "Filtere und halte deine Taxonomie gepflegt.",
|
"manageEmotionsHint": "Filtere und halte deine Taxonomie gepflegt.",
|
||||||
"saveEmotion": "Emotion speichern",
|
"saveEmotion": "Emotion speichern",
|
||||||
|
|||||||
@@ -581,6 +581,12 @@
|
|||||||
"showCollections": "Show all",
|
"showCollections": "Show all",
|
||||||
"collectionsEmpty": "No collections available.",
|
"collectionsEmpty": "No collections available.",
|
||||||
"bulkAdd": "Bulk add",
|
"bulkAdd": "Bulk add",
|
||||||
|
"selectionCount": "{{count}} selected",
|
||||||
|
"bulkRemove": "Delete selection",
|
||||||
|
"bulkCancel": "End selection",
|
||||||
|
"bulkRemoveTitle": "Delete selection",
|
||||||
|
"bulkRemoveBody": "This will remove the selected tasks from the event.",
|
||||||
|
"select": "Select task",
|
||||||
"manageEmotions": "Manage emotions",
|
"manageEmotions": "Manage emotions",
|
||||||
"manageEmotionsHint": "Filter and keep your taxonomy tidy.",
|
"manageEmotionsHint": "Filter and keep your taxonomy tidy.",
|
||||||
"saveEmotion": "Save emotion",
|
"saveEmotion": "Save emotion",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { RefreshCcw, Plus, Pencil, Trash2, ChevronDown, ChevronRight } from 'lucide-react';
|
import { RefreshCcw, Plus, Pencil, Trash2, ChevronDown, ChevronRight, Check } from 'lucide-react';
|
||||||
import { Card } from '@tamagui/card';
|
import { Card } from '@tamagui/card';
|
||||||
import { YStack, XStack } from '@tamagui/stacks';
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { YGroup } from '@tamagui/group';
|
import { YGroup } from '@tamagui/group';
|
||||||
@@ -13,6 +13,7 @@ import { AlertDialog } from '@tamagui/alert-dialog';
|
|||||||
import { ScrollView } from '@tamagui/scroll-view';
|
import { ScrollView } from '@tamagui/scroll-view';
|
||||||
import { ToggleGroup } from '@tamagui/toggle-group';
|
import { ToggleGroup } from '@tamagui/toggle-group';
|
||||||
import { Switch } from '@tamagui/switch';
|
import { Switch } from '@tamagui/switch';
|
||||||
|
import { Checkbox } from '@tamagui/checkbox';
|
||||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||||
import { MobileCard, CTAButton, SkeletonCard, FloatingActionButton, PillBadge } from './components/Primitives';
|
import { MobileCard, CTAButton, SkeletonCard, FloatingActionButton, PillBadge } from './components/Primitives';
|
||||||
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
|
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
|
||||||
@@ -279,6 +280,12 @@ export default function MobileEventTasksPage() {
|
|||||||
const [emotionFilter, setEmotionFilter] = React.useState<string>('');
|
const [emotionFilter, setEmotionFilter] = React.useState<string>('');
|
||||||
const [expandedLibrary, setExpandedLibrary] = React.useState(false);
|
const [expandedLibrary, setExpandedLibrary] = React.useState(false);
|
||||||
const [expandedCollections, setExpandedCollections] = React.useState(false);
|
const [expandedCollections, setExpandedCollections] = React.useState(false);
|
||||||
|
const [selectionMode, setSelectionMode] = React.useState(false);
|
||||||
|
const [selectedTaskIds, setSelectedTaskIds] = React.useState<Set<number>>(new Set());
|
||||||
|
const [bulkDeleteOpen, setBulkDeleteOpen] = React.useState(false);
|
||||||
|
const [bulkDeleteBusy, setBulkDeleteBusy] = React.useState(false);
|
||||||
|
const longPressTimer = React.useRef<number | null>(null);
|
||||||
|
const longPressTriggered = React.useRef(false);
|
||||||
const [showFabMenu, setShowFabMenu] = React.useState(false);
|
const [showFabMenu, setShowFabMenu] = React.useState(false);
|
||||||
const [showBulkSheet, setShowBulkSheet] = React.useState(false);
|
const [showBulkSheet, setShowBulkSheet] = React.useState(false);
|
||||||
const [bulkLines, setBulkLines] = React.useState('');
|
const [bulkLines, setBulkLines] = React.useState('');
|
||||||
@@ -366,11 +373,15 @@ export default function MobileEventTasksPage() {
|
|||||||
const event = await getEvent(slug);
|
const event = await getEvent(slug);
|
||||||
setEventId(event.id);
|
setEventId(event.id);
|
||||||
setEventRecord(event);
|
setEventRecord(event);
|
||||||
|
const eventTypeSlug = event.event_type?.slug ?? null;
|
||||||
const [result, libraryTasks] = await Promise.all([
|
const [result, libraryTasks] = await Promise.all([
|
||||||
getEventTasks(event.id, 1),
|
getEventTasks(event.id, 1),
|
||||||
getTasks({ per_page: 200 }),
|
getTasks({ per_page: 200 }),
|
||||||
]);
|
]);
|
||||||
const collectionList = await getTaskCollections({ per_page: 50 });
|
const collectionList = await getTaskCollections({
|
||||||
|
per_page: 50,
|
||||||
|
event_type: eventTypeSlug ?? undefined,
|
||||||
|
});
|
||||||
const emotionList = await getEmotions();
|
const emotionList = await getEmotions();
|
||||||
const assignedIds = new Set(result.data.map((t) => t.id));
|
const assignedIds = new Set(result.data.map((t) => t.id));
|
||||||
const eventTypeId = event.event_type_id ?? event.event_type?.id ?? null;
|
const eventTypeId = event.event_type_id ?? event.event_type?.id ?? null;
|
||||||
@@ -383,6 +394,8 @@ export default function MobileEventTasksPage() {
|
|||||||
setLibrary(filteredLibrary);
|
setLibrary(filteredLibrary);
|
||||||
setCollections(collectionList.data ?? []);
|
setCollections(collectionList.data ?? []);
|
||||||
setEmotions(emotionList ?? []);
|
setEmotions(emotionList ?? []);
|
||||||
|
setSelectionMode(false);
|
||||||
|
setSelectedTaskIds(new Set());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isAuthError(err)) {
|
if (!isAuthError(err)) {
|
||||||
const message = getApiErrorMessage(err, t('events.errors.loadFailed', 'Tasks konnten nicht geladen werden.'));
|
const message = getApiErrorMessage(err, t('events.errors.loadFailed', 'Tasks konnten nicht geladen werden.'));
|
||||||
@@ -503,6 +516,15 @@ export default function MobileEventTasksPage() {
|
|||||||
try {
|
try {
|
||||||
await detachTasksFromEvent(eventId, [taskId]);
|
await detachTasksFromEvent(eventId, [taskId]);
|
||||||
setAssignedTasks((prev) => prev.filter((task) => task.id !== taskId));
|
setAssignedTasks((prev) => prev.filter((task) => task.id !== taskId));
|
||||||
|
setSelectedTaskIds((prev) => {
|
||||||
|
if (!prev.has(taskId)) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(taskId);
|
||||||
|
if (next.size === 0) {
|
||||||
|
setSelectionMode(false);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
toast.success(t('events.tasks.removed', 'Aufgabe entfernt'));
|
toast.success(t('events.tasks.removed', 'Aufgabe entfernt'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isAuthError(err)) {
|
if (!isAuthError(err)) {
|
||||||
@@ -521,6 +543,29 @@ export default function MobileEventTasksPage() {
|
|||||||
await detachTask(taskId);
|
await detachTask(taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function confirmBulkDelete() {
|
||||||
|
if (!eventId || selectedTaskIds.size === 0) {
|
||||||
|
setBulkDeleteOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ids = Array.from(selectedTaskIds);
|
||||||
|
setBulkDeleteOpen(false);
|
||||||
|
setBulkDeleteBusy(true);
|
||||||
|
try {
|
||||||
|
await detachTasksFromEvent(eventId, ids);
|
||||||
|
setAssignedTasks((prev) => prev.filter((task) => !selectedTaskIds.has(task.id)));
|
||||||
|
setSelectedTaskIds(new Set());
|
||||||
|
setSelectionMode(false);
|
||||||
|
toast.success(t('events.tasks.removed', 'Aufgabe entfernt'));
|
||||||
|
} catch (err) {
|
||||||
|
if (!isAuthError(err)) {
|
||||||
|
toast.error(t('events.errors.saveFailed', 'Aufgabe konnte nicht entfernt werden.'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBulkDeleteBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const startEdit = (task: TenantTask) => {
|
const startEdit = (task: TenantTask) => {
|
||||||
setNewTask({
|
setNewTask({
|
||||||
id: task.id,
|
id: task.id,
|
||||||
@@ -541,6 +586,68 @@ export default function MobileEventTasksPage() {
|
|||||||
return matchText && matchEmotion;
|
return matchText && matchEmotion;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const toggleSelectedTask = React.useCallback((taskId: number) => {
|
||||||
|
setSelectedTaskIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(taskId)) {
|
||||||
|
next.delete(taskId);
|
||||||
|
} else {
|
||||||
|
next.add(taskId);
|
||||||
|
}
|
||||||
|
if (next.size === 0) {
|
||||||
|
setSelectionMode(false);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearSelection = React.useCallback(() => {
|
||||||
|
setSelectedTaskIds(new Set());
|
||||||
|
setSelectionMode(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startLongPress = React.useCallback(
|
||||||
|
(taskId: number) => {
|
||||||
|
if (selectionMode) return;
|
||||||
|
if (longPressTimer.current) {
|
||||||
|
window.clearTimeout(longPressTimer.current);
|
||||||
|
}
|
||||||
|
longPressTriggered.current = false;
|
||||||
|
longPressTimer.current = window.setTimeout(() => {
|
||||||
|
longPressTriggered.current = true;
|
||||||
|
setSelectionMode(true);
|
||||||
|
toggleSelectedTask(taskId);
|
||||||
|
}, 450);
|
||||||
|
},
|
||||||
|
[selectionMode, toggleSelectedTask]
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelLongPress = React.useCallback(() => {
|
||||||
|
if (longPressTimer.current) {
|
||||||
|
window.clearTimeout(longPressTimer.current);
|
||||||
|
longPressTimer.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTaskPress = React.useCallback(
|
||||||
|
(task: TenantTask) => {
|
||||||
|
if (longPressTriggered.current) {
|
||||||
|
longPressTriggered.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectionMode) {
|
||||||
|
toggleSelectedTask(task.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startEdit(task);
|
||||||
|
},
|
||||||
|
[selectionMode, startEdit, toggleSelectedTask]
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
return () => cancelLongPress();
|
||||||
|
}, [cancelLongPress]);
|
||||||
|
|
||||||
async function handleBulkAdd() {
|
async function handleBulkAdd() {
|
||||||
if (!eventId || !bulkLines.trim()) return;
|
if (!eventId || !bulkLines.trim()) return;
|
||||||
const lines = bulkLines
|
const lines = bulkLines
|
||||||
@@ -898,17 +1005,58 @@ export default function MobileEventTasksPage() {
|
|||||||
<Text fontSize="$sm" color={muted}>
|
<Text fontSize="$sm" color={muted}>
|
||||||
{t('events.tasks.count', '{{count}} Tasks', { count: filteredTasks.length })}
|
{t('events.tasks.count', '{{count}} Tasks', { count: filteredTasks.length })}
|
||||||
</Text>
|
</Text>
|
||||||
|
{selectionMode ? (
|
||||||
|
<MobileCard padding="$2.5" space="$2">
|
||||||
|
<Text fontSize={12} fontWeight="700" color={text}>
|
||||||
|
{t('events.tasks.selectionCount', '{{count}} ausgewählt', { count: selectedTaskIds.size })}
|
||||||
|
</Text>
|
||||||
|
<XStack space="$2">
|
||||||
|
<CTAButton
|
||||||
|
label={t('events.tasks.bulkRemove', 'Auswahl löschen')}
|
||||||
|
tone="danger"
|
||||||
|
fullWidth={false}
|
||||||
|
disabled={selectedTaskIds.size === 0 || bulkDeleteBusy}
|
||||||
|
onPress={() => setBulkDeleteOpen(true)}
|
||||||
|
/>
|
||||||
|
<CTAButton
|
||||||
|
label={t('events.tasks.bulkCancel', 'Auswahl beenden')}
|
||||||
|
tone="ghost"
|
||||||
|
fullWidth={false}
|
||||||
|
onPress={() => clearSelection()}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
|
</MobileCard>
|
||||||
|
) : null}
|
||||||
<YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
|
<YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
|
||||||
{filteredTasks.map((task, idx) => (
|
{filteredTasks.map((task, idx) => (
|
||||||
<YGroup.Item key={task.id}>
|
<YGroup.Item key={task.id}>
|
||||||
<ListItem
|
<ListItem
|
||||||
hoverTheme
|
hoverTheme
|
||||||
pressTheme
|
pressTheme
|
||||||
onPress={() => startEdit(task)}
|
onPress={() => handleTaskPress(task)}
|
||||||
|
onPointerDown={() => startLongPress(task.id)}
|
||||||
|
onPointerUp={cancelLongPress}
|
||||||
|
onPointerLeave={cancelLongPress}
|
||||||
|
onPointerCancel={cancelLongPress}
|
||||||
title={
|
title={
|
||||||
<Text fontSize={12.5} fontWeight="600" color={text}>
|
<XStack alignItems="center" space="$2">
|
||||||
{task.title}
|
{selectionMode ? (
|
||||||
</Text>
|
<Checkbox
|
||||||
|
size="$3"
|
||||||
|
checked={selectedTaskIds.has(task.id)}
|
||||||
|
onCheckedChange={() => toggleSelectedTask(task.id)}
|
||||||
|
onPress={(event: any) => event?.stopPropagation?.()}
|
||||||
|
aria-label={t('events.tasks.select', 'Select task')}
|
||||||
|
>
|
||||||
|
<Checkbox.Indicator>
|
||||||
|
<Check size={14} color={text} />
|
||||||
|
</Checkbox.Indicator>
|
||||||
|
</Checkbox>
|
||||||
|
) : null}
|
||||||
|
<Text fontSize={12.5} fontWeight="600" color={text}>
|
||||||
|
{task.title}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
}
|
}
|
||||||
subTitle={
|
subTitle={
|
||||||
task.description ? (
|
task.description ? (
|
||||||
@@ -918,26 +1066,28 @@ export default function MobileEventTasksPage() {
|
|||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
iconAfter={
|
iconAfter={
|
||||||
<XStack space="$2" alignItems="center">
|
selectionMode ? null : (
|
||||||
{task.emotion ? (
|
<XStack space="$2" alignItems="center">
|
||||||
<Tag label={task.emotion.name ?? ''} color={task.emotion.color ?? text} />
|
{task.emotion ? (
|
||||||
) : null}
|
<Tag label={task.emotion.name ?? ''} color={task.emotion.color ?? text} />
|
||||||
<Button
|
) : null}
|
||||||
size="$2"
|
<Button
|
||||||
circular
|
size="$2"
|
||||||
backgroundColor={dangerBg}
|
circular
|
||||||
borderWidth={1}
|
backgroundColor={dangerBg}
|
||||||
borderColor={`${danger}33`}
|
borderWidth={1}
|
||||||
icon={<Trash2 size={14} color={dangerText} />}
|
borderColor={`${danger}33`}
|
||||||
aria-label={t('events.tasks.remove', 'Remove task')}
|
icon={<Trash2 size={14} color={dangerText} />}
|
||||||
disabled={busyId === task.id}
|
aria-label={t('events.tasks.remove', 'Remove task')}
|
||||||
onPress={(event: any) => {
|
disabled={busyId === task.id}
|
||||||
event?.stopPropagation?.();
|
onPress={(event: any) => {
|
||||||
setDeleteCandidate(task);
|
event?.stopPropagation?.();
|
||||||
}}
|
setDeleteCandidate(task);
|
||||||
/>
|
}}
|
||||||
<ChevronRight size={14} color={subtle} />
|
/>
|
||||||
</XStack>
|
<ChevronRight size={14} color={subtle} />
|
||||||
|
</XStack>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
paddingVertical="$2"
|
paddingVertical="$2"
|
||||||
paddingHorizontal="$3"
|
paddingHorizontal="$3"
|
||||||
@@ -1283,6 +1433,60 @@ export default function MobileEventTasksPage() {
|
|||||||
</AlertDialog.Portal>
|
</AlertDialog.Portal>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={bulkDeleteOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setBulkDeleteOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialog.Portal>
|
||||||
|
<AlertDialog.Overlay backgroundColor={`${overlay}66` as any} />
|
||||||
|
<AlertDialog.Content
|
||||||
|
{...({ borderRadius: 20 } as any)}
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor={border}
|
||||||
|
backgroundColor={surface}
|
||||||
|
padding="$4"
|
||||||
|
maxWidth={420}
|
||||||
|
width="90%"
|
||||||
|
>
|
||||||
|
<YStack space="$3">
|
||||||
|
<AlertDialog.Title>
|
||||||
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
|
{t('events.tasks.bulkRemoveTitle', 'Auswahl löschen')}
|
||||||
|
</Text>
|
||||||
|
</AlertDialog.Title>
|
||||||
|
<AlertDialog.Description>
|
||||||
|
<Text fontSize="$sm" color={muted}>
|
||||||
|
{t('events.tasks.bulkRemoveBody', 'This will remove the selected tasks from the event.')}
|
||||||
|
</Text>
|
||||||
|
</AlertDialog.Description>
|
||||||
|
<XStack space="$2" justifyContent="flex-end">
|
||||||
|
<AlertDialog.Cancel asChild>
|
||||||
|
<CTAButton
|
||||||
|
label={t('common.cancel', 'Cancel')}
|
||||||
|
tone="ghost"
|
||||||
|
fullWidth={false}
|
||||||
|
onPress={() => setBulkDeleteOpen(false)}
|
||||||
|
/>
|
||||||
|
</AlertDialog.Cancel>
|
||||||
|
<AlertDialog.Action asChild>
|
||||||
|
<CTAButton
|
||||||
|
label={t('events.tasks.remove', 'Remove')}
|
||||||
|
tone="danger"
|
||||||
|
fullWidth={false}
|
||||||
|
onPress={() => confirmBulkDelete()}
|
||||||
|
disabled={bulkDeleteBusy}
|
||||||
|
/>
|
||||||
|
</AlertDialog.Action>
|
||||||
|
</XStack>
|
||||||
|
</YStack>
|
||||||
|
</AlertDialog.Content>
|
||||||
|
</AlertDialog.Portal>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
<FloatingActionButton
|
<FloatingActionButton
|
||||||
onPress={() => setShowFabMenu(true)}
|
onPress={() => setShowFabMenu(true)}
|
||||||
label={t('events.tasks.add', 'Add')}
|
label={t('events.tasks.add', 'Add')}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import * as api from '../../api';
|
||||||
|
|
||||||
const fixtures = vi.hoisted(() => ({
|
const fixtures = vi.hoisted(() => ({
|
||||||
event: {
|
event: {
|
||||||
@@ -9,7 +10,16 @@ const fixtures = vi.hoisted(() => ({
|
|||||||
slug: 'demo-event',
|
slug: 'demo-event',
|
||||||
event_date: '2026-02-19',
|
event_date: '2026-02-19',
|
||||||
event_type_id: null,
|
event_type_id: null,
|
||||||
event_type: null,
|
event_type: {
|
||||||
|
id: 1,
|
||||||
|
slug: 'wedding',
|
||||||
|
name: 'Wedding',
|
||||||
|
name_translations: { de: 'Hochzeit', en: 'Wedding' },
|
||||||
|
icon: null,
|
||||||
|
settings: {},
|
||||||
|
created_at: null,
|
||||||
|
updated_at: null,
|
||||||
|
},
|
||||||
status: 'published',
|
status: 'published',
|
||||||
settings: {},
|
settings: {},
|
||||||
},
|
},
|
||||||
@@ -32,17 +42,19 @@ vi.mock('react-router-dom', () => ({
|
|||||||
useParams: () => ({ slug: fixtures.event.slug }),
|
useParams: () => ({ slug: fixtures.event.slug }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const tMock = (key: string, fallback?: string | Record<string, unknown>) => {
|
||||||
|
if (typeof fallback === 'string') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
|
||||||
|
return fallback.defaultValue;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
};
|
||||||
|
|
||||||
vi.mock('react-i18next', () => ({
|
vi.mock('react-i18next', () => ({
|
||||||
useTranslation: () => ({
|
useTranslation: () => ({
|
||||||
t: (key: string, fallback?: string | Record<string, unknown>) => {
|
t: tMock,
|
||||||
if (typeof fallback === 'string') {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
|
|
||||||
return fallback.defaultValue;
|
|
||||||
}
|
|
||||||
return key;
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -50,10 +62,12 @@ vi.mock('../hooks/useBackNavigation', () => ({
|
|||||||
useBackNavigation: () => backMock,
|
useBackNavigation: () => backMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const selectEventMock = vi.fn();
|
||||||
|
|
||||||
vi.mock('../../context/EventContext', () => ({
|
vi.mock('../../context/EventContext', () => ({
|
||||||
useEventContext: () => ({
|
useEventContext: () => ({
|
||||||
activeEvent: fixtures.event,
|
activeEvent: fixtures.event,
|
||||||
selectEvent: vi.fn(),
|
selectEvent: selectEventMock,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -123,8 +137,17 @@ vi.mock('@tamagui/switch', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@tamagui/list-item', () => ({
|
vi.mock('@tamagui/list-item', () => ({
|
||||||
ListItem: ({ title, subTitle, iconAfter }: { title?: React.ReactNode; subTitle?: React.ReactNode; iconAfter?: React.ReactNode }) => (
|
ListItem: ({
|
||||||
<div>
|
title,
|
||||||
|
subTitle,
|
||||||
|
iconAfter,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
title?: React.ReactNode;
|
||||||
|
subTitle?: React.ReactNode;
|
||||||
|
iconAfter?: React.ReactNode;
|
||||||
|
}) => (
|
||||||
|
<div {...rest}>
|
||||||
{title}
|
{title}
|
||||||
{subTitle}
|
{subTitle}
|
||||||
{iconAfter}
|
{iconAfter}
|
||||||
@@ -148,6 +171,22 @@ vi.mock('@tamagui/button', () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@tamagui/checkbox', () => ({
|
||||||
|
Checkbox: Object.assign(
|
||||||
|
({ children, checked, onCheckedChange, 'aria-label': ariaLabel }: any) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-pressed={checked}
|
||||||
|
onClick={() => onCheckedChange?.(!checked)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
{ Indicator: ({ children }: { children: React.ReactNode }) => <span>{children}</span> },
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@tamagui/radio-group', () => ({
|
vi.mock('@tamagui/radio-group', () => ({
|
||||||
RadioGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
|
RadioGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
|
||||||
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
@@ -233,5 +272,22 @@ describe('MobileEventTasksPage', () => {
|
|||||||
expect(screen.getByText('Tasks total')).toBeInTheDocument();
|
expect(screen.getByText('Tasks total')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Quick jump')).toBeInTheDocument();
|
expect(screen.getByText('Quick jump')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Assigned')).toBeInTheDocument();
|
expect(screen.getByText('Assigned')).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(api.getTaskCollections).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ event_type: 'wedding' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enters selection mode on long press', async () => {
|
||||||
|
render(<MobileEventTasksPage />);
|
||||||
|
|
||||||
|
const task = await screen.findByText('Task A');
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.pointerDown(task);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
fireEvent.pointerUp(task);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await screen.findAllByText('Auswahl löschen')).length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user