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,
|
||||
fixedTarget,
|
||||
}) => {
|
||||
const writableLists = lists.filter((l) => l.isOwner || l.role !== 'viewer');
|
||||
const [content, setContent] = useState('');
|
||||
const [priority, setPriority] = useState<PriorityLevel>('normal');
|
||||
const [dueDate, setDueDate] = useState('');
|
||||
@@ -77,10 +78,10 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
}, [autoFocusTrigger]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!targetListId && lists.length > 0) {
|
||||
setTargetListId(lists[0].id);
|
||||
if (!targetListId && writableLists.length > 0) {
|
||||
setTargetListId(writableLists[0].id);
|
||||
}
|
||||
}, [lists, targetListId]);
|
||||
}, [writableLists, targetListId]);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
@@ -180,7 +181,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
|
||||
const effectiveTarget = fixedTarget || overrideTarget || target;
|
||||
const effectiveListId =
|
||||
overrideListId || targetListId || (lists.length > 0 ? lists[0].id : '');
|
||||
overrideListId || targetListId || (writableLists.length > 0 ? writableLists[0].id : '');
|
||||
const capturedPriority = priority;
|
||||
const capturedDueDate = dueDate;
|
||||
const capturedSource = hasVoiceInputRef.current || isListening ? 'voice' : 'manual';
|
||||
@@ -236,7 +237,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
await executeCapture();
|
||||
};
|
||||
|
||||
const selectedList = lists.find((l) => l.id === targetListId) || lists[0];
|
||||
const selectedList = writableLists.find((l) => l.id === targetListId) || writableLists[0];
|
||||
|
||||
const getTargetLabel = () => {
|
||||
if (target === 'task') return 'Direkt-Aufgabe';
|
||||
@@ -449,8 +450,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTarget('list_item');
|
||||
if (!targetListId && lists.length > 0) {
|
||||
setTargetListId(lists[0].id);
|
||||
if (!targetListId && writableLists.length > 0) {
|
||||
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 ${
|
||||
@@ -485,13 +486,13 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<span className="text-muted mr-1 flex items-center gap-1">
|
||||
<ShoppingCart className="w-3 h-3 text-accent" />
|
||||
Liste:
|
||||
</span>
|
||||
{lists.map((lst) => {
|
||||
{writableLists.map((lst) => {
|
||||
const isSelected = target === 'list_item' && targetListId === lst.id;
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -221,6 +221,9 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
onDeckChange,
|
||||
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 [lastTriaged, setLastTriaged] = useState<Thought | null>(null);
|
||||
const [showDateModal, setShowDateModal] = useState(false);
|
||||
@@ -702,8 +705,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
Quick Dump direkt einer Liste zuordnen:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{lists.length > 0 ? (
|
||||
lists.map((lst) => (
|
||||
{writableLists.length > 0 ? (
|
||||
writableLists.map((lst) => (
|
||||
<button
|
||||
key={lst.id}
|
||||
onClick={() =>
|
||||
@@ -746,17 +749,35 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled={writableLists.length === 0}
|
||||
onClick={() => {
|
||||
if (singleWritableList) {
|
||||
handleAction('keep', {
|
||||
convertTo: 'list_item',
|
||||
targetListId: singleWritableList.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setShowDateModal(false);
|
||||
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 ${
|
||||
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" />
|
||||
<span>Auf Liste</span>
|
||||
<span>{singleWritableList ? `Auf ${singleWritableList.title}` : 'Auf Liste'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -810,7 +831,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
<SwipeableQueueItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
lists={lists}
|
||||
lists={writableLists}
|
||||
onBringToFront={handleBringToFront}
|
||||
onOpenActionSheet={openActionSheetForCard}
|
||||
onAction={handleAction}
|
||||
@@ -854,13 +875,39 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSheetMode('list')}
|
||||
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"
|
||||
disabled={writableLists.length === 0}
|
||||
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" />
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
</button>
|
||||
|
||||
@@ -999,8 +1046,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
Auf welche Liste soll dieser Punkt gesetzt werden?
|
||||
</p>
|
||||
<div className="max-h-60 overflow-y-auto space-y-1.5">
|
||||
{lists.length > 0 ? (
|
||||
lists.map((lst) => (
|
||||
{writableLists.length > 0 ? (
|
||||
writableLists.map((lst) => (
|
||||
<button
|
||||
key={lst.id}
|
||||
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