feat: make magic todo status editable
This commit is contained in:
@@ -479,6 +479,9 @@ const api: DesktopApi = {
|
||||
throw new Error('not used')
|
||||
}),
|
||||
listTodos: vi.fn(async () => ({ todos: [] })),
|
||||
updateTodo: vi.fn(async () => {
|
||||
throw new Error('not used')
|
||||
}),
|
||||
analyzeTodo: vi.fn(async () => {
|
||||
throw new Error('not used')
|
||||
}),
|
||||
|
||||
@@ -152,6 +152,7 @@ const listTodos = vi.fn<() => Promise<MagicTodosSnapshot>>()
|
||||
const remove = vi.fn<DesktopApi['magicNotes']['remove']>()
|
||||
const createEntry = vi.fn<DesktopApi['magicNotes']['createEntry']>()
|
||||
const analyze = vi.fn<DesktopApi['magicNotes']['analyze']>()
|
||||
const updateTodo = vi.fn<DesktopApi['magicNotes']['updateTodo']>()
|
||||
const analyzeTodo = vi.fn<DesktopApi['magicNotes']['analyzeTodo']>()
|
||||
const analyzeDraft = vi.fn<DesktopApi['magicNotes']['analyzeDraft']>()
|
||||
let analysisEventListener:
|
||||
@@ -206,6 +207,11 @@ beforeEach(() => {
|
||||
]
|
||||
}
|
||||
createEntry.mockResolvedValue(createdDetail)
|
||||
updateTodo.mockImplementation(async (input) => ({
|
||||
...noteTodo,
|
||||
completed: input.completed,
|
||||
revision: noteTodo.revision + 1
|
||||
}))
|
||||
analyze.mockResolvedValue({
|
||||
...createdDetail,
|
||||
entries: createdDetail.entries.map((entry) =>
|
||||
@@ -258,6 +264,7 @@ beforeEach(() => {
|
||||
remove,
|
||||
createEntry,
|
||||
analyze,
|
||||
updateTodo,
|
||||
analyzeTodo,
|
||||
analyzeDraft,
|
||||
onAnalysisEvent
|
||||
@@ -319,8 +326,12 @@ describe('MagicNotesWorkspace', () => {
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(screen.getByRole('tab', { name: '待办' }))
|
||||
expect(screen.getByText('准备演示')).toBeInTheDocument()
|
||||
const selectedTodoButton = screen
|
||||
.getAllByText('核对发布材料')
|
||||
.find((element) => element.tagName === 'STRONG')
|
||||
?.closest('button')
|
||||
expect(
|
||||
screen.getByRole('button', { name: /核对发布材料/ })
|
||||
selectedTodoButton
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(onNotify).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -355,7 +366,11 @@ describe('MagicNotesWorkspace', () => {
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy()
|
||||
|
||||
expect(screen.getByLabelText('未完成')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByRole('button', {
|
||||
name: `标记为已完成:${noteTodo.title}`
|
||||
})
|
||||
).toHaveLength(2)
|
||||
expect(
|
||||
screen.getByRole('button', { name: '打开原笔记修改' })
|
||||
).toBeInTheDocument()
|
||||
@@ -571,7 +586,7 @@ describe('MagicNotesWorkspace', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('groups note-backed todos in a directory view', async () => {
|
||||
it('only shows note-backed todos in a directory view', async () => {
|
||||
render(
|
||||
<MagicNotesWorkspace onNotify={onNotify} />
|
||||
)
|
||||
@@ -581,12 +596,45 @@ describe('MagicNotesWorkspace', () => {
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '新建待办' })
|
||||
).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '目录视图' }))
|
||||
expect(
|
||||
screen.queryByRole('group', { name: '待办列表方式' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByText('发布笔记')).toBeInTheDocument()
|
||||
expect(screen.getByText('演示笔记')).toBeInTheDocument()
|
||||
expect(screen.getByText('准备演示')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks a todo completed from the standalone todo tab', async () => {
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
|
||||
await screen.findByText('记录正文')
|
||||
fireEvent.click(screen.getByRole('tab', { name: '待办' }))
|
||||
fireEvent.click(
|
||||
screen.getAllByRole('button', {
|
||||
name: `标记为已完成:${noteTodo.title}`
|
||||
})[0]!
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateTodo).toHaveBeenCalledWith({
|
||||
todoId: noteTodo.id,
|
||||
completed: true,
|
||||
expectedRevision: noteTodo.revision
|
||||
})
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: `标记为未完成:${noteTodo.title}`
|
||||
})
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(onNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tone: 'success',
|
||||
message: '待办已完成'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('reuses the AI comments pane for selected todos', async () => {
|
||||
getApplicationSettings.mockResolvedValue({
|
||||
checkUpdatesOnStartup: false,
|
||||
|
||||
@@ -54,7 +54,6 @@ export type MagicNotesWorkspaceProps = {
|
||||
|
||||
type LibraryView = 'notes' | 'todos'
|
||||
type TodoFilter = 'active' | 'completed' | 'all'
|
||||
type TodoListMode = 'list' | 'directory'
|
||||
type LoadStatus = 'loading' | 'ready' | 'error'
|
||||
type ValidationTarget =
|
||||
| 'create-note'
|
||||
@@ -73,11 +72,6 @@ const todoFilters = [
|
||||
{ value: 'all', label: '全部' }
|
||||
] as const
|
||||
|
||||
const todoListModes = [
|
||||
{ value: 'list', label: '待办视图' },
|
||||
{ value: 'directory', label: '目录视图' }
|
||||
] as const
|
||||
|
||||
const commentDirections: ReadonlyArray<{
|
||||
value: MagicNoteCommentDirection
|
||||
label: string
|
||||
@@ -211,35 +205,50 @@ function AiComment({
|
||||
}
|
||||
|
||||
function TodoListItem({
|
||||
disabled,
|
||||
onSelect,
|
||||
onToggle,
|
||||
selected,
|
||||
todo
|
||||
}: {
|
||||
disabled: boolean
|
||||
onSelect: () => void
|
||||
onToggle: () => void
|
||||
selected: boolean
|
||||
todo: MagicTodoItem
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<button
|
||||
aria-pressed={selected}
|
||||
<div
|
||||
className={`magic-todo-list-item ${
|
||||
selected ? 'magic-todo-list-item--active' : ''
|
||||
}`}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden="true" className="magic-todo-list-item__check">
|
||||
<button
|
||||
aria-label={`${
|
||||
todo.completed ? '标记为未完成' : '标记为已完成'
|
||||
}:${todo.title}`}
|
||||
aria-pressed={todo.completed}
|
||||
className="magic-todo-list-item__check"
|
||||
disabled={disabled}
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
{todo.completed ? (
|
||||
<CheckCircle2 size={16} />
|
||||
) : (
|
||||
<Circle size={16} />
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
</button>
|
||||
<button
|
||||
aria-pressed={selected}
|
||||
className="magic-todo-list-item__content"
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
<strong>{todo.title}</strong>
|
||||
<small>来自笔记:{todo.noteTitle}</small>
|
||||
</span>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -250,8 +259,6 @@ export function MagicNotesWorkspace({
|
||||
const [todos, setTodos] = useState<MagicTodoItem[]>([])
|
||||
const [libraryView, setLibraryView] = useState<LibraryView>('notes')
|
||||
const [todoFilter, setTodoFilter] = useState<TodoFilter>('active')
|
||||
const [todoListMode, setTodoListMode] =
|
||||
useState<TodoListMode>('list')
|
||||
const [commentMode, setCommentMode] =
|
||||
useState<MagicNoteCommentMode>('immediate')
|
||||
const [commentDirection, setCommentDirection] =
|
||||
@@ -873,6 +880,30 @@ export function MagicNotesWorkspace({
|
||||
}
|
||||
}
|
||||
|
||||
const updateTodoCompletion = async (
|
||||
todo: MagicTodoItem
|
||||
): Promise<void> => {
|
||||
const operation = `update-todo-${todo.id}`
|
||||
if (!beginBusy(operation)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const completed = !todo.completed
|
||||
applyTodo(
|
||||
await window.goodbuddy.magicNotes.updateTodo({
|
||||
todoId: todo.id,
|
||||
completed,
|
||||
expectedRevision: todo.revision
|
||||
})
|
||||
)
|
||||
notifySuccess(completed ? '待办已完成' : '待办已恢复为未完成')
|
||||
} catch (updateError) {
|
||||
notifyError(updateError)
|
||||
} finally {
|
||||
endBusy(operation)
|
||||
}
|
||||
}
|
||||
|
||||
const updateTitle = async (): Promise<void> => {
|
||||
if (!detail || titleDraft.trim() === detail.title) {
|
||||
return
|
||||
@@ -1308,12 +1339,6 @@ export function MagicNotesWorkspace({
|
||||
value={search}
|
||||
/>
|
||||
</label>
|
||||
<SegmentedControl
|
||||
ariaLabel="待办列表方式"
|
||||
onChange={setTodoListMode}
|
||||
options={todoListModes}
|
||||
value={todoListMode}
|
||||
/>
|
||||
<SegmentedControl
|
||||
ariaLabel="筛选待办"
|
||||
onChange={setTodoFilter}
|
||||
@@ -1347,18 +1372,6 @@ export function MagicNotesWorkspace({
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : todoListMode === 'list' ? (
|
||||
visibleTodos.map((todo) => (
|
||||
<TodoListItem
|
||||
key={todo.id}
|
||||
onSelect={() => {
|
||||
setValidation(undefined)
|
||||
setSelectedTodoId(todo.id)
|
||||
}}
|
||||
selected={selectedTodoId === todo.id}
|
||||
todo={todo}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
todoDirectories.map((directory) => (
|
||||
<section
|
||||
@@ -1373,11 +1386,15 @@ export function MagicNotesWorkspace({
|
||||
<div className="magic-todo-directory__items">
|
||||
{directory.todos.map((todo) => (
|
||||
<TodoListItem
|
||||
disabled={busy === `update-todo-${todo.id}`}
|
||||
key={todo.id}
|
||||
onSelect={() => {
|
||||
setValidation(undefined)
|
||||
setSelectedTodoId(todo.id)
|
||||
}}
|
||||
onToggle={() =>
|
||||
void updateTodoCompletion(todo)
|
||||
}
|
||||
selected={selectedTodoId === todo.id}
|
||||
todo={todo}
|
||||
/>
|
||||
@@ -1780,18 +1797,28 @@ export function MagicNotesWorkspace({
|
||||
) : (
|
||||
<section className="magic-todo-detail">
|
||||
<header>
|
||||
<span
|
||||
aria-label={
|
||||
selectedTodo.completed ? '已完成' : '未完成'
|
||||
}
|
||||
<button
|
||||
aria-label={`${
|
||||
selectedTodo.completed
|
||||
? '标记为未完成'
|
||||
: '标记为已完成'
|
||||
}:${selectedTodo.title}`}
|
||||
aria-pressed={selectedTodo.completed}
|
||||
className="magic-todo-detail__check"
|
||||
disabled={
|
||||
busy === `update-todo-${selectedTodo.id}`
|
||||
}
|
||||
onClick={() =>
|
||||
void updateTodoCompletion(selectedTodo)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{selectedTodo.completed ? (
|
||||
<CheckCircle2 size={24} />
|
||||
) : (
|
||||
<Circle size={24} />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<div>
|
||||
<h2>{selectedTodo.title}</h2>
|
||||
<span>来自笔记:{selectedTodo.noteTitle}</span>
|
||||
|
||||
@@ -316,7 +316,6 @@
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
gap: var(--space-2);
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
text-align: left;
|
||||
@@ -333,18 +332,34 @@
|
||||
|
||||
.magic-todo-list-item__check {
|
||||
display: grid;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding-top: 1px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.magic-todo-list-item > span:last-child {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-1);
|
||||
.magic-todo-list-item__check:hover {
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.magic-todo-list-item strong {
|
||||
.magic-todo-list-item__content {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
gap: var(--space-1);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.magic-todo-list-item__content strong {
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-body);
|
||||
@@ -352,7 +367,7 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.magic-todo-list-item small {
|
||||
.magic-todo-list-item__content small {
|
||||
overflow: hidden;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
@@ -476,12 +491,19 @@
|
||||
display: grid;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.magic-todo-detail__check:hover {
|
||||
background: var(--accent-selected);
|
||||
}
|
||||
|
||||
.magic-todo-detail > header > div:nth-child(2) {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
|
||||
Reference in New Issue
Block a user