79 lines
4.5 KiB
TypeScript
79 lines
4.5 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import { Link, useSearchParams } from "react-router";
|
||
import { ISSUE_FILTER_NAMES, useBoard, useIssues, useSettings, type IssueFilters } from "../api/queries.ts";
|
||
import { dueOf, epicNames, filterOptions } from "./options.ts";
|
||
import "./issues.css";
|
||
|
||
const SELECTS = [
|
||
["sprint", "Спринт"], ["assignee", "Исполнитель"], ["work_kind", "Вид работы"], ["fact_status", "Статус по факту"],
|
||
["status", "Статус Jira"], ["label", "Метка"], ["epic", "Эпик"],
|
||
] as const;
|
||
const Q_DELAY = 300; // поиск — после паузы ввода (R-7)
|
||
|
||
export function IssuesPage() {
|
||
const [params, setParams] = useSearchParams();
|
||
const filters = useMemo<IssueFilters>(() =>
|
||
Object.fromEntries(ISSUE_FILTER_NAMES.map((n) => [n, params.get(n) ?? ""]).filter(([, v]) => v)), [params]);
|
||
const set = (name: string, value: string) => setParams((p) => { const n = new URLSearchParams(p); if (value) n.set(name, value); else n.delete(name); return n; });
|
||
const [q, setQ] = useState(filters.q ?? "");
|
||
const written = useRef(filters.q ?? ""); // последнее значение q, согласованное с адресом (записанное нами или принятое из него)
|
||
useEffect(() => {
|
||
// адрес изменён извне (назад, щелчок по «Задачи»): поле поиска следует за ним
|
||
const fromUrl = filters.q ?? "";
|
||
if (fromUrl === written.current) return;
|
||
written.current = fromUrl;
|
||
setQ(fromUrl);
|
||
}, [filters.q]);
|
||
useEffect(() => {
|
||
if (q === (filters.q ?? "")) return;
|
||
const t = setTimeout(() => {
|
||
written.current = q;
|
||
setParams((p) => { const n = new URLSearchParams(p); if (q) n.set("q", q); else n.delete("q"); return n; }, { replace: true });
|
||
}, Q_DELAY);
|
||
return () => clearTimeout(t);
|
||
}, [q, filters.q, setParams]);
|
||
|
||
const board = useBoard(), settings = useSettings();
|
||
const opts = useMemo(() => filterOptions(board.data, settings.data), [board.data, settings.data]);
|
||
const names = useMemo(() => epicNames(board.data), [board.data]);
|
||
const list = useIssues(filters);
|
||
const rows = list.data?.pages.flat() ?? [];
|
||
|
||
return (
|
||
<div data-testid="page-issues" className="issues">
|
||
<h1>Задачи</h1>
|
||
<div className="filters">
|
||
{SELECTS.map(([name, label]) => (
|
||
<label key={name}>{label}
|
||
<select aria-label={label} value={filters[name] ?? ""} onChange={(e) => set(name, e.target.value)}>
|
||
<option value="">все</option>
|
||
{opts[name].map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||
</select>
|
||
</label>
|
||
))}
|
||
<label className="check"><input type="checkbox" checked={filters.neighbor === "1"} onChange={(e) => set("neighbor", e.target.checked ? "1" : "")} />только соседи</label>
|
||
<input type="search" aria-label="Поиск по ключу или теме" placeholder="ключ или тема" value={q} onChange={(e) => setQ(e.target.value)} />
|
||
</div>
|
||
{list.isError && (
|
||
<p className="warn" role="alert">{(list.error as Error).message}{" "}
|
||
<button className="btn sm" type="button" onClick={() => void list.refetch()}>Повторить</button></p>
|
||
)}
|
||
<table className="grid">
|
||
<thead><tr><th>Ключ</th><th>Тема</th><th>Тип</th><th>Статус Jira</th><th>Исполнитель</th><th>Спринт</th><th>Срок</th><th>Эпик</th><th>Вид работы</th><th>Статус по факту</th><th>Сосед</th></tr></thead>
|
||
<tbody>
|
||
{rows.map((i) => (
|
||
<tr key={i.key}>
|
||
<td><Link to={`/issues/${i.key}`}>{i.key}</Link></td><td>{i.summary}</td><td>{i.issue_type}</td><td>{i.status}</td>
|
||
<td>{i.assignee}</td><td>{i.sprint}</td><td>{dueOf(i)}</td><td>{i.epic_key ? names[i.epic_key] ?? i.epic_key : ""}</td>
|
||
<td>{i.work_kind}</td><td>{i.fact_status}</td><td>{i.neighbor ? "да" : ""}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
{list.isPending && <p className="hint">загрузка…</p>}
|
||
{!list.isPending && !rows.length && !list.isError && <p className="hint">По этим фильтрам задач нет.</p>}
|
||
{list.hasNextPage && <button className="btn" type="button" disabled={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>Показать ещё</button>}
|
||
</div>
|
||
);
|
||
}
|