feat: add task multi-select on long-press
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-19 18:49:40 +01:00
parent 6f6d8901ec
commit fbd48afbd6
4 changed files with 312 additions and 40 deletions

View File

@@ -585,6 +585,12 @@
"showCollections": "Alle Pakete anzeigen",
"collectionsEmpty": "Keine Pakete vorhanden.",
"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",
"manageEmotionsHint": "Filtere und halte deine Taxonomie gepflegt.",
"saveEmotion": "Emotion speichern",

View File

@@ -581,6 +581,12 @@
"showCollections": "Show all",
"collectionsEmpty": "No collections available.",
"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",
"manageEmotionsHint": "Filter and keep your taxonomy tidy.",
"saveEmotion": "Save emotion",

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
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 { YStack, XStack } from '@tamagui/stacks';
import { YGroup } from '@tamagui/group';
@@ -13,6 +13,7 @@ import { AlertDialog } from '@tamagui/alert-dialog';
import { ScrollView } from '@tamagui/scroll-view';
import { ToggleGroup } from '@tamagui/toggle-group';
import { Switch } from '@tamagui/switch';
import { Checkbox } from '@tamagui/checkbox';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, SkeletonCard, FloatingActionButton, PillBadge } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
@@ -279,6 +280,12 @@ export default function MobileEventTasksPage() {
const [emotionFilter, setEmotionFilter] = React.useState<string>('');
const [expandedLibrary, setExpandedLibrary] = 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 [showBulkSheet, setShowBulkSheet] = React.useState(false);
const [bulkLines, setBulkLines] = React.useState('');
@@ -366,11 +373,15 @@ export default function MobileEventTasksPage() {
const event = await getEvent(slug);
setEventId(event.id);
setEventRecord(event);
const eventTypeSlug = event.event_type?.slug ?? null;
const [result, libraryTasks] = await Promise.all([
getEventTasks(event.id, 1),
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 assignedIds = new Set(result.data.map((t) => t.id));
const eventTypeId = event.event_type_id ?? event.event_type?.id ?? null;
@@ -383,6 +394,8 @@ export default function MobileEventTasksPage() {
setLibrary(filteredLibrary);
setCollections(collectionList.data ?? []);
setEmotions(emotionList ?? []);
setSelectionMode(false);
setSelectedTaskIds(new Set());
} catch (err) {
if (!isAuthError(err)) {
const message = getApiErrorMessage(err, t('events.errors.loadFailed', 'Tasks konnten nicht geladen werden.'));
@@ -503,6 +516,15 @@ export default function MobileEventTasksPage() {
try {
await detachTasksFromEvent(eventId, [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'));
} catch (err) {
if (!isAuthError(err)) {
@@ -521,6 +543,29 @@ export default function MobileEventTasksPage() {
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) => {
setNewTask({
id: task.id,
@@ -541,6 +586,68 @@ export default function MobileEventTasksPage() {
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() {
if (!eventId || !bulkLines.trim()) return;
const lines = bulkLines
@@ -898,17 +1005,58 @@ export default function MobileEventTasksPage() {
<Text fontSize="$sm" color={muted}>
{t('events.tasks.count', '{{count}} Tasks', { count: filteredTasks.length })}
</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)}>
{filteredTasks.map((task, idx) => (
<YGroup.Item key={task.id}>
<ListItem
hoverTheme
pressTheme
onPress={() => startEdit(task)}
onPress={() => handleTaskPress(task)}
onPointerDown={() => startLongPress(task.id)}
onPointerUp={cancelLongPress}
onPointerLeave={cancelLongPress}
onPointerCancel={cancelLongPress}
title={
<Text fontSize={12.5} fontWeight="600" color={text}>
{task.title}
</Text>
<XStack alignItems="center" space="$2">
{selectionMode ? (
<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={
task.description ? (
@@ -918,26 +1066,28 @@ export default function MobileEventTasksPage() {
) : null
}
iconAfter={
<XStack space="$2" alignItems="center">
{task.emotion ? (
<Tag label={task.emotion.name ?? ''} color={task.emotion.color ?? text} />
) : null}
<Button
size="$2"
circular
backgroundColor={dangerBg}
borderWidth={1}
borderColor={`${danger}33`}
icon={<Trash2 size={14} color={dangerText} />}
aria-label={t('events.tasks.remove', 'Remove task')}
disabled={busyId === task.id}
onPress={(event: any) => {
event?.stopPropagation?.();
setDeleteCandidate(task);
}}
/>
<ChevronRight size={14} color={subtle} />
</XStack>
selectionMode ? null : (
<XStack space="$2" alignItems="center">
{task.emotion ? (
<Tag label={task.emotion.name ?? ''} color={task.emotion.color ?? text} />
) : null}
<Button
size="$2"
circular
backgroundColor={dangerBg}
borderWidth={1}
borderColor={`${danger}33`}
icon={<Trash2 size={14} color={dangerText} />}
aria-label={t('events.tasks.remove', 'Remove task')}
disabled={busyId === task.id}
onPress={(event: any) => {
event?.stopPropagation?.();
setDeleteCandidate(task);
}}
/>
<ChevronRight size={14} color={subtle} />
</XStack>
)
}
paddingVertical="$2"
paddingHorizontal="$3"
@@ -1283,6 +1433,60 @@ export default function MobileEventTasksPage() {
</AlertDialog.Portal>
</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
onPress={() => setShowFabMenu(true)}
label={t('events.tasks.add', 'Add')}

View File

@@ -1,6 +1,7 @@
import React from 'react';
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(() => ({
event: {
@@ -9,7 +10,16 @@ const fixtures = vi.hoisted(() => ({
slug: 'demo-event',
event_date: '2026-02-19',
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',
settings: {},
},
@@ -32,17 +42,19 @@ vi.mock('react-router-dom', () => ({
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', () => ({
useTranslation: () => ({
t: (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;
},
t: tMock,
}),
}));
@@ -50,10 +62,12 @@ vi.mock('../hooks/useBackNavigation', () => ({
useBackNavigation: () => backMock,
}));
const selectEventMock = vi.fn();
vi.mock('../../context/EventContext', () => ({
useEventContext: () => ({
activeEvent: fixtures.event,
selectEvent: vi.fn(),
selectEvent: selectEventMock,
}),
}));
@@ -123,8 +137,17 @@ vi.mock('@tamagui/switch', () => ({
}));
vi.mock('@tamagui/list-item', () => ({
ListItem: ({ title, subTitle, iconAfter }: { title?: React.ReactNode; subTitle?: React.ReactNode; iconAfter?: React.ReactNode }) => (
<div>
ListItem: ({
title,
subTitle,
iconAfter,
...rest
}: {
title?: React.ReactNode;
subTitle?: React.ReactNode;
iconAfter?: React.ReactNode;
}) => (
<div {...rest}>
{title}
{subTitle}
{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', () => ({
RadioGroup: Object.assign(({ 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('Quick jump')).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);
});
});