diff --git a/src/components/catalogs/CatalogCard.tsx b/src/components/catalogs/CatalogCard.tsx index a5ba02c..f7d3bb9 100644 --- a/src/components/catalogs/CatalogCard.tsx +++ b/src/components/catalogs/CatalogCard.tsx @@ -2,15 +2,14 @@ import classNames from "classnames"; import { Children, ReactElement, ReactNode, useState } from "react"; import { MdCode, MdKeyboardArrowDown } from "react-icons/md"; import { useNavigate } from "react-router-dom"; -import { useAnchoredElement } from "../../hooks/useAnchoredElement"; import { SqlQueryEmbed } from "../catalog/SqlQueryEmbed"; import { Button } from "../core/Button"; -import { CardActionsMenu, CatalogCardAction } from "../ui/CardActionsMenu"; -import { CardAnchorLink } from "../ui/CardAnchorLink"; import { AppTooltip } from "../ui/AppTooltip"; +import { Card, CardAction, Field } from "../ui/Card"; import { originalDataCatalogLink } from "./catalogActions"; -export type { CatalogCardAction }; +export type { CardAction as CatalogCardAction }; +export { Field }; export function getSourceLink(bibcode: string): string { return `https://ui.adsabs.harvard.edu/abs/${bibcode}/abstract`; @@ -31,14 +30,13 @@ export function CatalogCard({ }: { title: string; children: ReactNode; - actions?: CatalogCardAction[]; + actions?: CardAction[]; originalDataSql?: string; anchorId?: string; className?: string; variant?: "fields" | "block"; }): ReactElement { const navigate = useNavigate(); - const { ref, highlighted } = useAnchoredElement(anchorId ?? ""); const [originalDataOpen, setOriginalDataOpen] = useState(false); const [originalDataMounted, setOriginalDataMounted] = useState(false); @@ -55,99 +53,75 @@ export function CatalogCard({ setOriginalDataOpen(true); } - const cardActions = actions ?? []; - const hasActions = cardActions.length > 0; - const hasHeaderControls = hasActions || Boolean(originalDataSql); - - return ( -
+ + + ) : null; + + const afterChildren = + originalDataSql && originalDataMounted ? (
-

- {title} - {anchorId && } -

- {hasHeaderControls ? ( -
- {originalDataSql ? ( - - ) : null} - {hasActions ? : null} -
- ) : null} -
- {variant === "fields" ? ( -
- {children} -
- ) : ( - children - )} - {originalDataSql && originalDataMounted ? ( -
-
-
-
- -
- - - -
+
+
+
+ +
+ + +
- ) : null} -
+
+ ) : null; + + return ( + + {children} + ); } @@ -155,21 +129,6 @@ export function CatalogNoData(): ReactElement { return

No data available.

; } -export function Field({ - label, - children, -}: { - label: ReactNode; - children: ReactNode; -}): ReactElement { - return ( - <> -
{label}
-
{children}
- - ); -} - export function CatalogDetailSection({ title, children, diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx new file mode 100644 index 0000000..b6b2fce --- /dev/null +++ b/src/components/ui/Card.tsx @@ -0,0 +1,87 @@ +import classNames from "classnames"; +import { ReactElement, ReactNode } from "react"; +import { useAnchoredElement } from "../../hooks/useAnchoredElement"; +import { CardActionsMenu, CardAction } from "./CardActionsMenu"; +import { CardAnchorLink } from "./CardAnchorLink"; + +export type { CardAction }; + +export function Card({ + title, + children, + actions, + headerControls, + afterChildren, + anchorId, + className, + variant = "fields", +}: { + title: string; + children: ReactNode; + actions?: CardAction[]; + headerControls?: ReactNode; + afterChildren?: ReactNode; + anchorId?: string; + className?: string; + variant?: "fields" | "block"; +}): ReactElement { + const { ref, highlighted } = useAnchoredElement(anchorId ?? ""); + const cardActions = actions ?? []; + const hasActions = cardActions.length > 0; + const hasHeaderControls = + hasActions || Boolean(headerControls) || Boolean(anchorId); + + return ( +
+
+

+ {title} + {anchorId && } +

+ {hasHeaderControls ? ( +
+ {headerControls} + {hasActions ? : null} +
+ ) : null} +
+ {variant === "fields" ? ( +
+ {children} +
+ ) : ( + children + )} + {afterChildren} +
+ ); +} + +export function Field({ + label, + children, +}: { + label: ReactNode; + children: ReactNode; +}): ReactElement { + return ( + <> +
{label}
+
{children}
+ + ); +} diff --git a/src/components/ui/CardActionsMenu.tsx b/src/components/ui/CardActionsMenu.tsx index 97a56e3..4e41f64 100644 --- a/src/components/ui/CardActionsMenu.tsx +++ b/src/components/ui/CardActionsMenu.tsx @@ -4,18 +4,18 @@ import { MdMoreVert } from "react-icons/md"; import classNames from "classnames"; import { Button } from "../core/Button"; -type CatalogCardActionCommon = { +type CardActionCommon = { title: string; description?: string; icon?: IconType; }; -export type CatalogCardAction = - | (CatalogCardActionCommon & { href: string; onClick?: never }) - | (CatalogCardActionCommon & { onClick: () => void; href?: never }); +export type CardAction = + | (CardActionCommon & { href: string; onClick?: never }) + | (CardActionCommon & { onClick: () => void; href?: never }); interface CardActionsMenuProps { - actions: CatalogCardAction[]; + actions: CardAction[]; } const menuItemClassName = @@ -24,7 +24,7 @@ const menuItemClassName = function ActionMenuItemContent({ action, }: { - action: CatalogCardAction; + action: CardAction; }): ReactElement { const Icon = action.icon; @@ -77,7 +77,7 @@ export function CardActionsMenu({ return () => document.removeEventListener("mousedown", handlePointerDown); }, [open]); - function runAction(action: CatalogCardAction): void { + function runAction(action: CardAction): void { if ("onClick" in action && action.onClick) { action.onClick(); } diff --git a/src/pages/TableDetails.tsx b/src/pages/TableDetails.tsx index 7701c81..8c9f8fa 100644 --- a/src/pages/TableDetails.tsx +++ b/src/pages/TableDetails.tsx @@ -1,10 +1,10 @@ +import classNames from "classnames"; import { KeyboardEvent, ReactElement, useEffect, useState } from "react"; import { Bibliography, - CrossmatchTriageStatus, DataType, GetTableResponse, - TableCrossmatchResultStatus, + TableProgress, } from "../clients/admin/types.gen"; import { getTable, patchTable } from "../clients/admin/sdk.gen"; import { useNavigate, useParams } from "react-router-dom"; @@ -14,12 +14,13 @@ import { Column, CommonTable, } from "../components/ui/CommonTable"; -import { Button } from "../components/core/Button"; import { CopyButton } from "../components/ui/CopyButton"; -import { Badge, BadgeType } from "../components/ui/Badge"; +import { Badge } from "../components/ui/Badge"; import { Link } from "../components/core/Link"; import { Loading } from "../components/core/Loading"; +import { Card, CardAction, Field } from "../components/ui/Card"; import { ErrorPage } from "../components/ui/ErrorPage"; +import { Hint } from "../components/ui/Hint"; import { useDataFetching } from "../hooks/useDataFetching"; import { adminClient } from "../clients/config"; import { isLoggedIn } from "../auth/token"; @@ -44,36 +45,36 @@ function asDataType(value: unknown): DataType { } function renderBibliography(bib: Bibliography): ReactElement { - let authors = ""; - - if (bib.authors.length >= 1) { - authors += bib.authors[0]; - } - if (bib.authors.length >= 2) { - authors += " et al."; - } - - authors += ` ${bib.year}`; - - const targetLink = - "https://ui.adsabs.harvard.edu/abs/" + bib.bibcode + "/abstract"; + const targetLink = `https://ui.adsabs.harvard.edu/abs/${bib.bibcode}/abstract`; return ( - -
- - {bib.bibcode} - {" "} - | {authors}: "{bib.title}" -
-
+
+ + {bib.bibcode} + + + "{bib.title}". {bib.authors.join(", ")}. {bib.year} + + } + className="gap-1" + /> +
); } function renderTime(time: string): string { - const dt = new Date(time as string); - - return dt.toString(); + const dt = new Date(time); + + return dt.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + timeZoneName: "short", + }); } function renderUCD(ucd: CellPrimitive): ReactElement { @@ -106,6 +107,7 @@ interface TableMetaProps { tableName: string; table: GetTableResponse; onAfterPatch: () => void; + className?: string; } function TableMeta(props: TableMetaProps): ReactElement { @@ -113,7 +115,8 @@ function TableMeta(props: TableMetaProps): ReactElement { const canEdit = isLoggedIn(); const [editingName, setEditingName] = useState(false); const [editingDescription, setEditingDescription] = useState(false); - const showEditPencils = canEdit && !editingName && !editingDescription; + const showNameEdit = canEdit && !editingDescription; + const showSlugEdit = canEdit && !editingName; const [draftName, setDraftName] = useState(props.tableName); const [draftDescription, setDraftDescription] = useState( props.table.description, @@ -252,9 +255,7 @@ function TableMeta(props: TableMetaProps): ReactElement { ); } - const columns = [{ name: "Parameter" }, { name: "Value" }]; - - const datatypeValue: CellPrimitive = canEdit ? ( + const datatypeControl = canEdit ? ( setDraftDescription(event.target.value)} - onKeyDown={handleDescriptionKeyDown} - disabled={savingField === "description"} - className="text-2xl font-bold bg-transparent border border-border rounded px-2 py-0.5 flex-1 min-w-0 text-primary" - autoFocus - /> - ) : ( -

- {props.table.description} -

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

- {props.tableName} -

- )} - {showEditPencils && ( - - )} -
+ + +
+ {editingDescription ? ( + setDraftDescription(event.target.value)} + onKeyDown={handleDescriptionKeyDown} + disabled={savingField === "description"} + className="bg-transparent border border-border rounded px-2 py-0.5 flex-1 min-w-0 text-primary" + autoFocus + /> + ) : ( + {props.table.description} + )} + {showNameEdit && ( + + )} +
+
+ +
+ {editingName ? ( + setDraftName(event.target.value)} + onKeyDown={handleNameKeyDown} + disabled={savingField === "name"} + className="font-mono bg-transparent border border-border rounded px-2 py-0.5 flex-1 min-w-0" + autoFocus + /> + ) : ( + + {props.tableName} + + )} + {showSlugEdit && ( + + )} +
+
+ {props.table.id} + + {renderBibliography(props.table.bibliography)} + + {datatypeControl} + + {renderTime(props.table.meta.modification_dt as string)} + {patchError ? ( -

{patchError}

+ <> +
Error
+
{patchError}
+ ) : null} - +
); } -interface CrossmatchStatsProps { - table: GetTableResponse; - tableName: string; - navigate: (path: string) => void; +function formatPercent(marked: number, total: number): string { + if (total <= 0) { + return ""; + } + return `${Math.floor((marked / total) * 100)}%`; } -function CrossmatchStats(props: CrossmatchStatsProps): ReactElement { - const columns: Column[] = [{ name: "Status" }, { name: "Count" }]; +function formatProgressValue(count: number, total: number): ReactElement { + const percent = formatPercent(count, total); + if (!percent) { + return <>{count}; + } - const values: Record[] = []; + return ( + <> + {count} ({percent}) + + ); +} - if (!props.table.crossmatch) { - return
; - } +function catalogProgressTabClassName(isActive: boolean): string { + return classNames( + "w-full text-left px-2 py-1 text-xs font-medium border-l-2 transition-colors truncate", + isActive + ? "border-accent text-primary" + : "border-transparent text-muted hover:text-primary hover:border-border", + ); +} - 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", - ]; +function TableProgressSummaryCard({ + progress, + tableName, + hasCrossmatch, + navigate, + className, +}: { + progress: TableProgress; + tableName: string; + hasCrossmatch: boolean; + navigate: (path: string) => void; + className?: string; +}): ReactElement { + const actions: CardAction[] = hasCrossmatch + ? [ + { + title: "View crossmatch results", + onClick: () => { + navigate( + `/crossmatch?table_name=${encodeURIComponent(tableName)}&triage_status=pending`, + ); + }, + }, + ] + : []; - triageOrder.forEach((status) => { - const count = props.table.crossmatch.statuses[status] ?? 0; - if (count <= 0) { - return; - } - values.push({ - Status: triageLabels[status], - Count: count, - }); - }); + return ( + + {progress.total_records} + + {formatProgressValue(progress.unprocessed, progress.total_records)} + + + {formatProgressValue(progress.pending_triage, progress.total_records)} + + + {formatProgressValue( + progress.resolved_unsubmitted, + progress.total_records, + )} + + + {formatProgressValue(progress.submitted, progress.total_records)} + + + ); +} - function handleViewCrossmatchResults(event: React.MouseEvent): void { - const url = `/crossmatch?table_name=${encodeURIComponent(props.tableName)}&triage_status=pending`; +function CatalogProgressCard({ + catalogs, + totalRecords, + className, +}: { + catalogs: TableProgress["catalogs"]; + totalRecords: number; + className?: string; +}): ReactElement { + const catalogEntries = Object.entries(catalogs); + const [selectedCatalog, setSelectedCatalog] = useState( + catalogEntries[0]?.[0] ?? "", + ); - if (event.ctrlKey || event.metaKey) { - window.open(url, "_blank"); - } else { - props.navigate(url); + useEffect(() => { + if ( + catalogEntries.length > 0 && + !catalogEntries.some(([name]) => name === selectedCatalog) + ) { + setSelectedCatalog(catalogEntries[0][0]); } - } + }, [catalogEntries, selectedCatalog]); + + const selectedProgress = catalogs[selectedCatalog]; return ( - -
-
-

Crossmatch

- - {resultLabels[props.table.crossmatch.result]} - -
- + +
+ + {selectedProgress ? ( +
+ + {formatProgressValue(selectedProgress.structured, totalRecords)} + + + {formatProgressValue(selectedProgress.in_layer2, totalRecords)} + + + {formatProgressValue( + selectedProgress.layer2_pending, + totalRecords, + )} + +
+ ) : null}
- +
); } @@ -483,9 +560,9 @@ function ColumnInfo(props: ColumnInfoProps): ReactElement { }); return ( - -

Column information

-
+ + + ); } @@ -523,19 +600,35 @@ export function TableDetailsPage(): ReactElement { if (error) return ; if (payload) { return ( - <> - setRefreshKey((key) => key + 1)} - /> - +
+
+ setRefreshKey((key) => key + 1)} + className="lg:col-span-6" + /> + 0 + ? "lg:col-span-3" + : "lg:col-span-6" + } + /> + {Object.keys(payload.progress.catalogs).length > 0 ? ( + + ) : null} +
- +
); }