Der Field-Baustein verdrahtet Hinweis/Fehler jetzt per aria-describedby und aria-invalid mit dem Eingabefeld (cloneElement auf das einzelne Kind; Fragmente bleiben unangetastet) — Screenreader nennen den Fehler damit auch beim Feld-Fokus. Quota-Typ-Select mit Namen; die leeren Aktions-/Erledigt-Spaltenköpfe in API-Tokens, Feed-Tokens, Sitzungen und der Aufgabenübersicht (NodeView UND Server-Renderpfad) tragen visually-hidden-Beschriftungen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
158 lines
5.8 KiB
TypeScript
158 lines
5.8 KiB
TypeScript
import type { TaskOverviewPage } from '@dorfteich/shared';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { Node } from '@tiptap/core';
|
|
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
|
|
import type { NodeViewProps } from '@tiptap/react';
|
|
import { useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Link, useParams } from 'react-router-dom';
|
|
|
|
import { apiGet, apiPost } from '../../lib/api';
|
|
import { attributesFromSpec, nodeSpec } from '../spec-utils';
|
|
import { useWikilinks } from '../wikilink-context';
|
|
|
|
/**
|
|
* The task overview block (issue #154). Edit mode shows a placeholder card;
|
|
* read mode fetches the permission-filtered collection of the current page +
|
|
* subtree (`/read/:pond/:slug/tasks`) and renders the table. Checking a box
|
|
* posts the toggle (#153) — applied asynchronously through the collab
|
|
* server — so the UI flips optimistically and refetches shortly after.
|
|
*/
|
|
function TaskOverviewView({ editor }: NodeViewProps): React.JSX.Element {
|
|
const { t, i18n } = useTranslation('tasks');
|
|
const { pondSlug } = useWikilinks();
|
|
const { pageSlug = '' } = useParams<{ pageSlug: string }>();
|
|
const queryClient = useQueryClient();
|
|
const [optimistic, setOptimistic] = useState<Record<string, boolean>>({});
|
|
const editable = editor.isEditable;
|
|
|
|
const overview = useQuery({
|
|
queryKey: ['page-tasks', pondSlug, pageSlug],
|
|
queryFn: () => apiGet<TaskOverviewPage[]>(`/read/${pondSlug}/${pageSlug}/tasks`),
|
|
enabled: !editable && Boolean(pageSlug),
|
|
});
|
|
|
|
if (editable) {
|
|
return (
|
|
<NodeViewWrapper className="dt-transclusion-card" contentEditable={false}>
|
|
<span className="dt-transclusion-card__icon" aria-hidden>
|
|
☑
|
|
</span>
|
|
<span className="dt-transclusion-card__label">{t('editorCard')}</span>
|
|
</NodeViewWrapper>
|
|
);
|
|
}
|
|
|
|
const formatDate = (iso: string | null): string =>
|
|
iso
|
|
? new Intl.DateTimeFormat(i18n.language, { dateStyle: 'medium' }).format(
|
|
new Date(`${iso}T00:00:00`),
|
|
)
|
|
: '';
|
|
|
|
const toggle = async (pageId: string, taskId: string, checked: boolean): Promise<void> => {
|
|
setOptimistic((prev) => ({ ...prev, [taskId]: checked }));
|
|
await apiPost(`/pages/${pageId}/tasks/${taskId}`, { checked });
|
|
// The collab server applies the edit and persists it debounced (~2 s) —
|
|
// re-read a couple of times; the optimistic override stays until the
|
|
// server agrees (cleared below when the data catches up).
|
|
for (const delay of [2500, 6000]) {
|
|
setTimeout(() => {
|
|
void queryClient.invalidateQueries({ queryKey: ['page-tasks', pondSlug, pageSlug] });
|
|
}, delay);
|
|
}
|
|
};
|
|
|
|
const pages = overview.data ?? [];
|
|
// Drop optimistic overrides the server data now agrees with.
|
|
const agreed = pages
|
|
.flatMap((page) => page.tasks)
|
|
.filter((task) => task.id && task.id in optimistic && optimistic[task.id] === task.checked)
|
|
.map((task) => task.id as string);
|
|
if (agreed.length > 0) {
|
|
setOptimistic((prev) => {
|
|
const next = { ...prev };
|
|
for (const id of agreed) delete next[id];
|
|
return next;
|
|
});
|
|
}
|
|
const total = pages.reduce((sum, page) => sum + page.tasks.length, 0);
|
|
|
|
return (
|
|
<NodeViewWrapper className="dt-task-overview-view" contentEditable={false}>
|
|
{total === 0 ? (
|
|
<div className="dt-task-overview-empty">{t('empty')}</div>
|
|
) : (
|
|
<table className="dt-task-overview-table">
|
|
<thead>
|
|
<tr>
|
|
<th>
|
|
<span className="visually-hidden">{t('colDone')}</span>
|
|
</th>
|
|
<th>{t('colTask')}</th>
|
|
<th>{t('colMentions')}</th>
|
|
<th>{t('colStart')}</th>
|
|
<th>{t('colDue')}</th>
|
|
<th>{t('colPage')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{pages.flatMap((page) =>
|
|
page.tasks.map((task, index) => {
|
|
const key = task.id ?? `${page.pageId}:${index}`;
|
|
const checked =
|
|
task.id && task.id in optimistic ? optimistic[task.id]! : task.checked;
|
|
return (
|
|
<tr key={key}>
|
|
<td>
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
disabled={!task.id}
|
|
title={task.id ? undefined : t('unsavedHint')}
|
|
onChange={(event) =>
|
|
task.id && void toggle(page.pageId, task.id, event.target.checked)
|
|
}
|
|
/>
|
|
</td>
|
|
<td>{task.text}</td>
|
|
<td>
|
|
{task.mentions.map((mention) => (
|
|
<span key={mention.id || mention.username} className="dt-mention">
|
|
@{mention.displayName}
|
|
</span>
|
|
))}
|
|
</td>
|
|
<td>{formatDate(task.startDate)}</td>
|
|
<td>{formatDate(task.dueDate)}</td>
|
|
<td>
|
|
<Link className="wikilink" to={`/p/${pondSlug}/${page.slug}`}>
|
|
{page.title}
|
|
</Link>
|
|
</td>
|
|
</tr>
|
|
);
|
|
}),
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</NodeViewWrapper>
|
|
);
|
|
}
|
|
|
|
const taskOverviewSpec = nodeSpec('task_overview');
|
|
export const TaskOverview = Node.create({
|
|
name: 'task_overview',
|
|
group: taskOverviewSpec.group,
|
|
atom: taskOverviewSpec.atom,
|
|
addAttributes() {
|
|
return attributesFromSpec(taskOverviewSpec);
|
|
},
|
|
parseHTML: () => taskOverviewSpec.parseDOM,
|
|
renderHTML: ({ node }) => taskOverviewSpec.toDOM!(node),
|
|
addNodeView() {
|
|
return ReactNodeViewRenderer(TaskOverviewView);
|
|
},
|
|
});
|