Merge branch 'feat/single-list-shortcut' into dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
ef16778ef5
3 files changed
+232
-21
No files matched your search
@@ -38,6 +38,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
hideTargetPicker = false,
|
hideTargetPicker = false,
|
||||||
fixedTarget,
|
fixedTarget,
|
||||||
}) => {
|
}) => {
|
||||||
|
const writableLists = lists.filter((l) => l.isOwner || l.role !== 'viewer');
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [priority, setPriority] = useState<PriorityLevel>('normal');
|
const [priority, setPriority] = useState<PriorityLevel>('normal');
|
||||||
const [dueDate, setDueDate] = useState('');
|
const [dueDate, setDueDate] = useState('');
|
||||||
@@ -77,10 +78,10 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
}, [autoFocusTrigger]);
|
}, [autoFocusTrigger]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!targetListId && lists.length > 0) {
|
if (!targetListId && writableLists.length > 0) {
|
||||||
setTargetListId(lists[0].id);
|
setTargetListId(writableLists[0].id);
|
||||||
}
|
}
|
||||||
}, [lists, targetListId]);
|
}, [writableLists, targetListId]);
|
||||||
|
|
||||||
// Close dropdown on outside click
|
// Close dropdown on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -180,7 +181,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
|
|
||||||
const effectiveTarget = fixedTarget || overrideTarget || target;
|
const effectiveTarget = fixedTarget || overrideTarget || target;
|
||||||
const effectiveListId =
|
const effectiveListId =
|
||||||
overrideListId || targetListId || (lists.length > 0 ? lists[0].id : '');
|
overrideListId || targetListId || (writableLists.length > 0 ? writableLists[0].id : '');
|
||||||
const capturedPriority = priority;
|
const capturedPriority = priority;
|
||||||
const capturedDueDate = dueDate;
|
const capturedDueDate = dueDate;
|
||||||
const capturedSource = hasVoiceInputRef.current || isListening ? 'voice' : 'manual';
|
const capturedSource = hasVoiceInputRef.current || isListening ? 'voice' : 'manual';
|
||||||
@@ -236,7 +237,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
await executeCapture();
|
await executeCapture();
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedList = lists.find((l) => l.id === targetListId) || lists[0];
|
const selectedList = writableLists.find((l) => l.id === targetListId) || writableLists[0];
|
||||||
|
|
||||||
const getTargetLabel = () => {
|
const getTargetLabel = () => {
|
||||||
if (target === 'task') return 'Direkt-Aufgabe';
|
if (target === 'task') return 'Direkt-Aufgabe';
|
||||||
@@ -449,8 +450,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTarget('list_item');
|
setTarget('list_item');
|
||||||
if (!targetListId && lists.length > 0) {
|
if (!targetListId && writableLists.length > 0) {
|
||||||
setTargetListId(lists[0].id);
|
setTargetListId(writableLists[0].id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
||||||
@@ -485,13 +486,13 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Optional Sub-Row: Shared List Picker */}
|
{/* Optional Sub-Row: Shared List Picker */}
|
||||||
{lists.length > 0 && (
|
{writableLists.length > 0 && (
|
||||||
<div className="pt-2 border-t border-border flex flex-wrap items-center gap-1.5">
|
<div className="pt-2 border-t border-border flex flex-wrap items-center gap-1.5">
|
||||||
<span className="text-muted mr-1 flex items-center gap-1">
|
<span className="text-muted mr-1 flex items-center gap-1">
|
||||||
<ShoppingCart className="w-3 h-3 text-accent" />
|
<ShoppingCart className="w-3 h-3 text-accent" />
|
||||||
Liste:
|
Liste:
|
||||||
</span>
|
</span>
|
||||||
{lists.map((lst) => {
|
{writableLists.map((lst) => {
|
||||||
const isSelected = target === 'list_item' && targetListId === lst.id;
|
const isSelected = target === 'list_item' && targetListId === lst.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -221,6 +221,9 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
onDeckChange,
|
onDeckChange,
|
||||||
onFocusCapture,
|
onFocusCapture,
|
||||||
}) => {
|
}) => {
|
||||||
|
const writableLists = lists.filter((l) => l.isOwner || l.role !== 'viewer');
|
||||||
|
const singleWritableList = writableLists.length === 1 ? writableLists[0] : null;
|
||||||
|
|
||||||
const [cards, setCards] = useState<Thought[]>(deck);
|
const [cards, setCards] = useState<Thought[]>(deck);
|
||||||
const [lastTriaged, setLastTriaged] = useState<Thought | null>(null);
|
const [lastTriaged, setLastTriaged] = useState<Thought | null>(null);
|
||||||
const [showDateModal, setShowDateModal] = useState(false);
|
const [showDateModal, setShowDateModal] = useState(false);
|
||||||
@@ -702,8 +705,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
Quick Dump direkt einer Liste zuordnen:
|
Quick Dump direkt einer Liste zuordnen:
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{lists.length > 0 ? (
|
{writableLists.length > 0 ? (
|
||||||
lists.map((lst) => (
|
writableLists.map((lst) => (
|
||||||
<button
|
<button
|
||||||
key={lst.id}
|
key={lst.id}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -746,17 +749,35 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
disabled={writableLists.length === 0}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (singleWritableList) {
|
||||||
|
handleAction('keep', {
|
||||||
|
convertTo: 'list_item',
|
||||||
|
targetListId: singleWritableList.id,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
setShowDateModal(false);
|
setShowDateModal(false);
|
||||||
setShowListSelector((prev) => !prev);
|
setShowListSelector((prev) => !prev);
|
||||||
}}
|
}}
|
||||||
title="Auf eine geteilte Liste setzen"
|
title={
|
||||||
|
writableLists.length === 0
|
||||||
|
? 'Keine Liste vorhanden'
|
||||||
|
: singleWritableList
|
||||||
|
? `Auf ${singleWritableList.title}`
|
||||||
|
: 'Auf eine geteilte Liste setzen'
|
||||||
|
}
|
||||||
className={`btn-action flex-1 h-9 px-3 justify-center whitespace-nowrap ${
|
className={`btn-action flex-1 h-9 px-3 justify-center whitespace-nowrap ${
|
||||||
showListSelector ? 'border-accent text-accent bg-accent-dim' : ''
|
writableLists.length === 0
|
||||||
|
? 'opacity-40 cursor-not-allowed'
|
||||||
|
: showListSelector
|
||||||
|
? 'border-accent text-accent bg-accent-dim'
|
||||||
|
: ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ShoppingCart className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
<ShoppingCart className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||||
<span>Auf Liste</span>
|
<span>{singleWritableList ? `Auf ${singleWritableList.title}` : 'Auf Liste'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -810,7 +831,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
<SwipeableQueueItem
|
<SwipeableQueueItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
lists={lists}
|
lists={writableLists}
|
||||||
onBringToFront={handleBringToFront}
|
onBringToFront={handleBringToFront}
|
||||||
onOpenActionSheet={openActionSheetForCard}
|
onOpenActionSheet={openActionSheetForCard}
|
||||||
onAction={handleAction}
|
onAction={handleAction}
|
||||||
@@ -854,13 +875,39 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSheetMode('list')}
|
disabled={writableLists.length === 0}
|
||||||
className="min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98"
|
onClick={() => {
|
||||||
|
if (singleWritableList) {
|
||||||
|
handleAction('keep', {
|
||||||
|
convertTo: 'list_item',
|
||||||
|
targetListId: singleWritableList.id,
|
||||||
|
thoughtOverride: actionSheetCard,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSheetMode('list');
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
writableLists.length === 0
|
||||||
|
? 'Keine Liste vorhanden'
|
||||||
|
: singleWritableList
|
||||||
|
? `Auf ${singleWritableList.title}`
|
||||||
|
: 'Auf Liste setzen'
|
||||||
|
}
|
||||||
|
className={`min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98 ${
|
||||||
|
writableLists.length === 0 ? 'opacity-40 cursor-not-allowed' : ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<ShoppingCart className="w-5 h-5 text-accent flex-shrink-0" />
|
<ShoppingCart className="w-5 h-5 text-accent flex-shrink-0" />
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-semibold text-main">Auf Liste</div>
|
<div className="text-sm font-semibold text-main">Auf Liste</div>
|
||||||
<div className="text-[11px] text-muted">Einkauf / Geteilt</div>
|
<div className="text-[11px] text-muted truncate">
|
||||||
|
{writableLists.length === 0
|
||||||
|
? 'Keine Liste vorhanden'
|
||||||
|
: singleWritableList
|
||||||
|
? singleWritableList.title
|
||||||
|
: 'Einkauf / Geteilt'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -999,8 +1046,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
Auf welche Liste soll dieser Punkt gesetzt werden?
|
Auf welche Liste soll dieser Punkt gesetzt werden?
|
||||||
</p>
|
</p>
|
||||||
<div className="max-h-60 overflow-y-auto space-y-1.5">
|
<div className="max-h-60 overflow-y-auto space-y-1.5">
|
||||||
{lists.length > 0 ? (
|
{writableLists.length > 0 ? (
|
||||||
lists.map((lst) => (
|
writableLists.map((lst) => (
|
||||||
<button
|
<button
|
||||||
key={lst.id}
|
key={lst.id}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { TriageDeck } from '../TriageDeck';
|
||||||
|
import { api } from '../../../lib/api';
|
||||||
|
import { SharedList, Thought } from '../../../lib/types';
|
||||||
|
|
||||||
|
const sampleDeck: Thought[] = [
|
||||||
|
{
|
||||||
|
id: 'th_1',
|
||||||
|
userId: 'u_1',
|
||||||
|
content: 'Hafermilch und Kaffeebohnen',
|
||||||
|
status: 'inbox',
|
||||||
|
priority: 'normal',
|
||||||
|
source: 'manual',
|
||||||
|
isCompleted: false,
|
||||||
|
tags: [],
|
||||||
|
createdAt: '2026-10-29T10:00:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ownList: SharedList = {
|
||||||
|
id: 'lst_own',
|
||||||
|
title: 'Wocheneinkauf',
|
||||||
|
icon: '🛒',
|
||||||
|
listType: 'shopping',
|
||||||
|
isOwner: true,
|
||||||
|
role: 'owner',
|
||||||
|
items: [],
|
||||||
|
createdAt: '2026-10-29T10:00:00Z',
|
||||||
|
updatedAt: '2026-10-29T10:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondEditableList: SharedList = {
|
||||||
|
id: 'lst_edit',
|
||||||
|
title: 'Baumarkt',
|
||||||
|
icon: '🔨',
|
||||||
|
listType: 'checklist',
|
||||||
|
isOwner: false,
|
||||||
|
role: 'editor',
|
||||||
|
items: [],
|
||||||
|
createdAt: '2026-10-29T10:00:00Z',
|
||||||
|
updatedAt: '2026-10-29T10:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewerList: SharedList = {
|
||||||
|
id: 'lst_view',
|
||||||
|
title: 'WG-Archiv',
|
||||||
|
icon: '📋',
|
||||||
|
listType: 'checklist',
|
||||||
|
isOwner: false,
|
||||||
|
role: 'viewer',
|
||||||
|
items: [],
|
||||||
|
createdAt: '2026-10-29T10:00:00Z',
|
||||||
|
updatedAt: '2026-10-29T10:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('TriageDeck single-list shortcut', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.spyOn(api, 'triageAction').mockResolvedValue(sampleDeck[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('directly assigns to the single own list without opening the selector', async () => {
|
||||||
|
const onDeckChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TriageDeck
|
||||||
|
deck={sampleDeck}
|
||||||
|
lists={[ownList]}
|
||||||
|
onDeckChange={onDeckChange}
|
||||||
|
onOpenDocument={vi.fn()}
|
||||||
|
onFocusCapture={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const btn = screen.getByRole('button', { name: /Auf Wocheneinkauf/i });
|
||||||
|
expect((btn as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
|
||||||
|
fireEvent.click(btn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(api.triageAction).toHaveBeenCalledWith('th_1', 'keep', {
|
||||||
|
convertTo: 'list_item',
|
||||||
|
targetListId: 'lst_own',
|
||||||
|
dueDate: undefined,
|
||||||
|
});
|
||||||
|
expect(onDeckChange).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
expect(screen.queryByText(/Quick Dump direkt einer Liste zuordnen/i)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the list selector when two writable lists exist instead of assigning directly', () => {
|
||||||
|
const onDeckChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TriageDeck
|
||||||
|
deck={sampleDeck}
|
||||||
|
lists={[ownList, secondEditableList]}
|
||||||
|
onDeckChange={onDeckChange}
|
||||||
|
onOpenDocument={vi.fn()}
|
||||||
|
onFocusCapture={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const btn = screen.getByRole('button', { name: /^Auf Liste$/i });
|
||||||
|
fireEvent.click(btn);
|
||||||
|
|
||||||
|
expect(api.triageAction).not.toHaveBeenCalled();
|
||||||
|
expect(onDeckChange).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText(/Quick Dump direkt einer Liste zuordnen/i)).toBeTruthy();
|
||||||
|
expect(screen.getByRole('button', { name: /Wocheneinkauf/i })).toBeTruthy();
|
||||||
|
expect(screen.getByRole('button', { name: /Baumarkt/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats one own list and one viewer-only shared list as a single writable list', async () => {
|
||||||
|
const onDeckChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TriageDeck
|
||||||
|
deck={sampleDeck}
|
||||||
|
lists={[viewerList, ownList]}
|
||||||
|
onDeckChange={onDeckChange}
|
||||||
|
onOpenDocument={vi.fn()}
|
||||||
|
onFocusCapture={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const btn = screen.getByRole('button', { name: /Auf Wocheneinkauf/i });
|
||||||
|
fireEvent.click(btn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(api.triageAction).toHaveBeenCalledWith('th_1', 'keep', {
|
||||||
|
convertTo: 'list_item',
|
||||||
|
targetListId: 'lst_own',
|
||||||
|
dueDate: undefined,
|
||||||
|
});
|
||||||
|
expect(onDeckChange).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the list button when no writable list exists', () => {
|
||||||
|
const onDeckChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TriageDeck
|
||||||
|
deck={sampleDeck}
|
||||||
|
lists={[viewerList]}
|
||||||
|
onDeckChange={onDeckChange}
|
||||||
|
onOpenDocument={vi.fn()}
|
||||||
|
onFocusCapture={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const btn = screen.getByRole('button', { name: /^Auf Liste$/i }) as HTMLButtonElement;
|
||||||
|
expect(btn.disabled).toBe(true);
|
||||||
|
expect(btn.title).toBe('Keine Liste vorhanden');
|
||||||
|
|
||||||
|
fireEvent.click(btn);
|
||||||
|
expect(api.triageAction).not.toHaveBeenCalled();
|
||||||
|
expect(onDeckChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user