From fbd48afbd6afb5b84015eceb16d2d771706c3349 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 19 Jan 2026 18:49:40 +0100 Subject: [PATCH] feat: add task multi-select on long-press --- .../js/admin/i18n/locales/de/management.json | 6 + .../js/admin/i18n/locales/en/management.json | 6 + resources/js/admin/mobile/EventTasksPage.tsx | 256 ++++++++++++++++-- .../mobile/__tests__/EventTasksPage.test.tsx | 84 +++++- 4 files changed, 312 insertions(+), 40 deletions(-) diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 58b6f8c..6174375 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -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", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 1284647..a417fc8 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -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", diff --git a/resources/js/admin/mobile/EventTasksPage.tsx b/resources/js/admin/mobile/EventTasksPage.tsx index 1c45b52..dfe799c 100644 --- a/resources/js/admin/mobile/EventTasksPage.tsx +++ b/resources/js/admin/mobile/EventTasksPage.tsx @@ -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(''); const [expandedLibrary, setExpandedLibrary] = React.useState(false); const [expandedCollections, setExpandedCollections] = React.useState(false); + const [selectionMode, setSelectionMode] = React.useState(false); + const [selectedTaskIds, setSelectedTaskIds] = React.useState>(new Set()); + const [bulkDeleteOpen, setBulkDeleteOpen] = React.useState(false); + const [bulkDeleteBusy, setBulkDeleteBusy] = React.useState(false); + const longPressTimer = React.useRef(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() { {t('events.tasks.count', '{{count}} Tasks', { count: filteredTasks.length })} + {selectionMode ? ( + + + {t('events.tasks.selectionCount', '{{count}} ausgewählt', { count: selectedTaskIds.size })} + + + setBulkDeleteOpen(true)} + /> + clearSelection()} + /> + + + ) : null} {filteredTasks.map((task, idx) => ( startEdit(task)} + onPress={() => handleTaskPress(task)} + onPointerDown={() => startLongPress(task.id)} + onPointerUp={cancelLongPress} + onPointerLeave={cancelLongPress} + onPointerCancel={cancelLongPress} title={ - - {task.title} - + + {selectionMode ? ( + toggleSelectedTask(task.id)} + onPress={(event: any) => event?.stopPropagation?.()} + aria-label={t('events.tasks.select', 'Select task')} + > + + + + + ) : null} + + {task.title} + + } subTitle={ task.description ? ( @@ -918,26 +1066,28 @@ export default function MobileEventTasksPage() { ) : null } iconAfter={ - - {task.emotion ? ( - - ) : null} - + ), + { Indicator: ({ children }: { children: React.ReactNode }) => {children} }, + ), +})); + vi.mock('@tamagui/radio-group', () => ({ RadioGroup: Object.assign(({ children }: { children: React.ReactNode }) =>
{children}
, { Item: ({ children }: { children: React.ReactNode }) =>
{children}
, @@ -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(); + + 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); }); });