feat: add task multi-select on long-press
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user