diff --git a/src/pages/TableDetails.tsx b/src/pages/TableDetails.tsx index 28af69a..83f93f4 100644 --- a/src/pages/TableDetails.tsx +++ b/src/pages/TableDetails.tsx @@ -1,11 +1,14 @@ -import { ReactElement } from "react"; +import { KeyboardEvent, ReactElement, useEffect, useState } from "react"; import { Bibliography, + CrossmatchTriageStatus, + DataType, GetTableResponse, - RecordCrossmatchStatus, + TableCrossmatchResultStatus, } from "../clients/admin/types.gen"; -import { getTable } from "../clients/admin/sdk.gen"; +import { getTable, patchTable } from "../clients/admin/sdk.gen"; import { useNavigate, useParams } from "react-router-dom"; +import { MdEdit } from "react-icons/md"; import { CellPrimitive, Column, @@ -13,12 +16,32 @@ import { } from "../components/ui/CommonTable"; import { Button } from "../components/core/Button"; import { CopyButton } from "../components/ui/CopyButton"; -import { Badge } from "../components/ui/Badge"; +import { Badge, BadgeType } from "../components/ui/Badge"; import { Link } from "../components/core/Link"; import { Loading } from "../components/core/Loading"; import { ErrorPage } from "../components/ui/ErrorPage"; import { useDataFetching } from "../hooks/useDataFetching"; -import { backendClient } from "../clients/config"; +import { adminClient } from "../clients/config"; +import { isLoggedIn } from "../auth/token"; + +const DATA_TYPES: DataType[] = [ + "regular", + "reprocessing", + "preliminary", + "compilation", +]; + +function asDataType(value: unknown): DataType { + if ( + value === "regular" || + value === "reprocessing" || + value === "preliminary" || + value === "compilation" + ) { + return value; + } + return "regular"; +} function renderBibliography(bib: Bibliography): ReactElement { let authors = ""; @@ -82,11 +105,172 @@ function renderColumnName(name: CellPrimitive): ReactElement { interface TableMetaProps { tableName: string; table: GetTableResponse; + onAfterPatch: () => void; } function TableMeta(props: TableMetaProps): ReactElement { + const navigate = useNavigate(); + const canEdit = isLoggedIn(); + const [editingName, setEditingName] = useState(false); + const [editingDescription, setEditingDescription] = useState(false); + const showEditPencils = canEdit && !editingName && !editingDescription; + const [draftName, setDraftName] = useState(props.tableName); + const [draftDescription, setDraftDescription] = useState( + props.table.description, + ); + const [savingField, setSavingField] = useState< + "name" | "description" | "datatype" | null + >(null); + const [patchError, setPatchError] = useState(null); + + async function runTablePatch( + field: "name" | "description" | "datatype", + body: { + table_name: string; + new_table_name?: string; + description?: string; + datatype?: DataType; + }, + onSuccess: () => void, + ): Promise { + setPatchError(null); + setSavingField(field); + try { + const response = await patchTable({ + client: adminClient, + body, + }); + if (response.error) { + throw new Error(JSON.stringify(response.error)); + } + onSuccess(); + } catch (err) { + setPatchError(`${err}`); + } finally { + setSavingField(null); + } + } + + useEffect(() => { + if (!editingName) { + setDraftName(props.tableName); + } + }, [props.tableName, editingName]); + + useEffect(() => { + if (!editingDescription) { + setDraftDescription(props.table.description); + } + }, [props.table.description, editingDescription]); + + async function commitName(): Promise { + const trimmed = draftName.trim(); + if (!trimmed) { + setDraftName(props.tableName); + setEditingName(false); + setPatchError(null); + return; + } + if (trimmed === props.tableName) { + setEditingName(false); + setPatchError(null); + return; + } + await runTablePatch( + "name", + { + table_name: props.tableName, + new_table_name: trimmed, + }, + () => { + setEditingName(false); + navigate(`/table/${encodeURIComponent(trimmed)}`); + }, + ); + } + + async function commitDescription(): Promise { + const trimmed = draftDescription.trim(); + if (trimmed === props.table.description) { + setEditingDescription(false); + setPatchError(null); + return; + } + await runTablePatch( + "description", + { + table_name: props.tableName, + description: trimmed, + }, + () => { + setEditingDescription(false); + props.onAfterPatch(); + }, + ); + } + + function handleNameKeyDown(event: KeyboardEvent): void { + if (event.key === "Enter") { + event.preventDefault(); + void commitName(); + } + if (event.key === "Escape") { + event.preventDefault(); + setDraftName(props.tableName); + setEditingName(false); + setPatchError(null); + } + } + + function handleDescriptionKeyDown( + event: KeyboardEvent, + ): void { + if (event.key === "Enter") { + event.preventDefault(); + void commitDescription(); + } + if (event.key === "Escape") { + event.preventDefault(); + setDraftDescription(props.table.description); + setEditingDescription(false); + setPatchError(null); + } + } + + async function commitDatatype(next: DataType): Promise { + const current = asDataType(props.table.meta.datatype); + if (next === current) { + return; + } + await runTablePatch( + "datatype", + { + table_name: props.tableName, + datatype: next, + }, + () => props.onAfterPatch(), + ); + } + const columns = [{ name: "Parameter" }, { name: "Value" }]; + const datatypeValue: CellPrimitive = canEdit ? ( + + ) : ( + String(props.table.meta.datatype) + ); + const values: Record[] = [ { Parameter: "Table ID", @@ -102,7 +286,7 @@ function TableMeta(props: TableMetaProps): ReactElement { }, { Parameter: "Type of data", - Value: String(props.table.meta.datatype), + Value: datatypeValue, }, { Parameter: "Modification time", @@ -112,8 +296,69 @@ function TableMeta(props: TableMetaProps): ReactElement { return ( -

{props.table.description}

-

{props.tableName}

+
+ {editingDescription ? ( + setDraftDescription(event.target.value)} + onKeyDown={handleDescriptionKeyDown} + disabled={savingField === "description"} + className="text-2xl font-bold bg-transparent border border-gray-500 rounded px-2 py-0.5 flex-1 min-w-0 text-white" + autoFocus + /> + ) : ( +

+ {props.table.description} +

+ )} + {showEditPencils && ( + + )} +
+
+ {editingName ? ( + setDraftName(event.target.value)} + onKeyDown={handleNameKeyDown} + disabled={savingField === "name"} + className="text-gray-300 font-mono bg-transparent border border-gray-500 rounded px-2 py-0.5 flex-1 min-w-0" + autoFocus + /> + ) : ( +

+ {props.tableName} +

+ )} + {showEditPencils && ( + + )} +
+ {patchError ? ( +

{patchError}

+ ) : null}
); } @@ -129,28 +374,44 @@ function CrossmatchStats(props: CrossmatchStatsProps): ReactElement { const values: Record[] = []; - if (props.table.statistics) { - const statusLabels: Record = { - unprocessed: "Unprocessed", - new: "New", - collided: "Collided", - existing: "Existing", - }; - - Object.entries(props.table.statistics).forEach(([status, count]) => { - values.push({ - Status: statusLabels[status as RecordCrossmatchStatus] || status, - Count: count || 0, - }); - }); - } - - if (values.length === 0) { + if (!props.table.crossmatch) { return
; } + const triageLabels: Record = { + unprocessed: "Unprocessed", + pending: "Pending", + resolved: "Resolved", + }; + const resultLabels: Record = { + not_started: "Not started", + in_progress: "In progress", + done: "Done", + }; + const resultBadgeTypes: Record = { + not_started: "info", + in_progress: "warning", + done: "success", + }; + const triageOrder: CrossmatchTriageStatus[] = [ + "unprocessed", + "pending", + "resolved", + ]; + + triageOrder.forEach((status) => { + const count = props.table.crossmatch.statuses[status] ?? 0; + if (count <= 0) { + return; + } + values.push({ + Status: triageLabels[status], + Count: count, + }); + }); + function handleViewCrossmatchResults(event: React.MouseEvent): void { - const url = `/crossmatch?table_name=${encodeURIComponent(props.tableName)}&status=collided`; + const url = `/crossmatch?table_name=${encodeURIComponent(props.tableName)}&triage_status=pending`; if (event.ctrlKey || event.metaKey) { window.open(url, "_blank"); @@ -162,7 +423,12 @@ function CrossmatchStats(props: CrossmatchStatsProps): ReactElement { return (
-

Crossmatch Statistics

+
+

Crossmatch

+ + {resultLabels[props.table.crossmatch.result]} + +
@@ -231,7 +497,7 @@ async function fetcher( } const response = await getTable({ - client: backendClient, + client: adminClient, query: { table_name: tableName }, }); if (response.error) { @@ -244,12 +510,13 @@ async function fetcher( export function TableDetailsPage(): ReactElement { const { tableName } = useParams<{ tableName: string }>(); const navigate = useNavigate(); + const [refreshKey, setRefreshKey] = useState(0); const { data: payload, loading, error, - } = useDataFetching(() => fetcher(tableName), [tableName]); + } = useDataFetching(() => fetcher(tableName), [tableName, refreshKey]); function Content(): ReactElement { if (loading) return ; @@ -257,7 +524,11 @@ export function TableDetailsPage(): ReactElement { if (payload) { return ( <> - + setRefreshKey((key) => key + 1)} + />