Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 84 additions & 5 deletions src/components/ui/Card.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,88 @@
import classNames from "classnames";
import { ReactElement, ReactNode } from "react";
import React, { ReactElement, ReactNode } from "react";
import { useAnchoredElement } from "../../hooks/useAnchoredElement";
import { CardActionsMenu, CardAction } from "./CardActionsMenu";
import { CardAnchorLink } from "./CardAnchorLink";

export type { CardAction };

function groupFieldsByColumn(
fields: ReactNode[],
columnCount: number,
): ReactNode[][] {
const columns: ReactNode[][] = Array.from({ length: columnCount }, () => []);
const fieldsPerColumn = Math.ceil(fields.length / columnCount);
fields.forEach((field, index) => {
columns[Math.floor(index / fieldsPerColumn)].push(field);
});
return columns;
}

function FieldsColumnGroup({
fields,
columnCount,
className,
}: {
fields: ReactNode[];
columnCount: number;
className?: string;
}): ReactElement {
const columns = groupFieldsByColumn(fields, columnCount);

return (
<div
className={classNames(
"grid w-full items-start divide-x divide-border",
columnCount === 2 && "grid-cols-2",
columnCount === 3 && "grid-cols-3",
className,
)}
>
{columns.map((columnFields, index) => (
<dl
key={index}
className={classNames(
"grid min-w-0 grid-cols-[auto_1fr] content-start gap-x-3 gap-y-0.5 self-start",
index === 0
? "pr-4"
: index === columns.length - 1
? "pl-4"
: "px-4",
)}
>
{columnFields}
</dl>
))}
</div>
);
}

function ResponsiveFieldsBody({
children,
}: {
children: ReactNode;
}): ReactElement {
const fields = React.Children.toArray(children);

return (
<>
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-base md:hidden">
{fields}
</dl>
<FieldsColumnGroup
fields={fields}
columnCount={2}
className="hidden text-base md:grid xl:hidden"
/>
<FieldsColumnGroup
fields={fields}
columnCount={3}
className="hidden text-base xl:grid"
/>
</>
);
}

export function Card({
title,
children,
Expand All @@ -16,14 +93,14 @@ export function Card({
className,
variant = "fields",
}: {
title: string;
title: ReactNode;
children: ReactNode;
actions?: CardAction[];
headerControls?: ReactNode;
afterChildren?: ReactNode;
anchorId?: string;
className?: string;
variant?: "fields" | "block";
variant?: "fields" | "responsive-fields" | "block";
}): ReactElement {
const { ref, highlighted } = useAnchoredElement(anchorId ?? "");
const cardActions = actions ?? [];
Expand Down Expand Up @@ -63,6 +140,8 @@ export function Card({
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-base">
{children}
</dl>
) : variant === "responsive-fields" ? (
<ResponsiveFieldsBody>{children}</ResponsiveFieldsBody>
) : (
children
)}
Expand All @@ -80,8 +159,8 @@ export function Field({
}): ReactElement {
return (
<>
<dt className="text-muted">{label}</dt>
<dd>{children}</dd>
<dt className="text-muted shrink-0">{label}</dt>
<dd className="min-w-0">{children}</dd>
</>
);
}
144 changes: 113 additions & 31 deletions src/pages/Tables.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
import { ReactElement, useEffect, useState, useRef } from "react";
import { useSearchParams } from "react-router-dom";
import {
CommonTable,
Column,
CellPrimitive,
} from "../components/ui/CommonTable";
import { useNavigate, useSearchParams } from "react-router-dom";
import classNames from "classnames";
import { DropdownFilter } from "../components/core/DropdownFilter";
import { TextFilter } from "../components/core/TextFilter";
import { getTableList } from "../clients/admin/sdk.gen";
import type {
GetTableListResponse,
TableListItem,
TableProgress,
ValidationError,
} from "../clients/admin/types.gen";
import { Loading } from "../components/core/Loading";
Expand All @@ -19,6 +16,8 @@ import { useDataFetching } from "../hooks/useDataFetching";
import { Pagination } from "../components/ui/Pagination";
import { adminClient } from "../clients/config";
import { Link } from "../components/core/Link";
import { getSourceLink } from "../components/catalogs/CatalogCard";
import { Card, CardAction, Field } from "../components/ui/Card";

const SEARCH_DEBOUNCE_MS = 300;

Expand Down Expand Up @@ -97,35 +96,118 @@ function formatModificationDate(isoString: string): string {
.replace(",", "");
}

function TablesResults({ data, loading }: TablesResultsProps): ReactElement {
const columns: Column[] = [
function formatProgressPercent(count: number, total: number): string {
if (total <= 0) {
return "—";
}
return `${Math.floor((count / total) * 100)}%`;
}

function formatCatalogsSummary(
catalogs: TableProgress["catalogs"],
total: number,
): string {
if (total <= 0) {
return "—";
}

const parts = Object.entries(catalogs)
.map(([name, { structured }]) => ({
name,
percent: Math.floor((structured / total) * 100),
}))
.filter(({ percent }) => percent > 0)
.map(({ name, percent }) => `${name} (${percent}%)`);

return parts.length > 0 ? parts.join(", ") : "—";
}

function crossmatchListHref(tableName: string): string {
return `/crossmatch?table_name=${encodeURIComponent(tableName)}&triage_status=pending`;
}

function TableListCard({ table }: { table: TableListItem }): ReactElement {
const navigate = useNavigate();
const { progress } = table;
const total = progress.total_records;
const actions: CardAction[] = [
{
name: "Name",
renderCell: (value: CellPrimitive) => {
if (typeof value === "string") {
return <Link href={`/table/${value}`}>{value}</Link>;
}
return <span>—</span>;
},
title: "View crossmatch results",
onClick: () => navigate(crossmatchListHref(table.name)),
},
{ name: "Description" },
{ name: "Number of records" },
{ name: "Number of columns" },
{ name: "Modification date" },
];

const tableData: Record<string, CellPrimitive>[] =
data?.tables.map((table: TableListItem) => ({
Name: table.name,
Description: table.description,
"Number of records": table.num_entries,
"Number of columns": table.num_fields,
"Modification date": table.modification_dt
? formatModificationDate(table.modification_dt)
: "—",
})) ?? [];

return <CommonTable columns={columns} data={tableData} loading={loading} />;
return (
<Card
title={
<Link href={`/table/${table.name}`} className="hover:opacity-80">
{table.description || "—"}
</Link>
}
className="w-full"
variant="responsive-fields"
actions={actions}
>
<Field label="Slug">
<span className="font-mono break-all">{table.name}</span>
</Field>
<Field label="Source paper">
{table.bibcode ? (
<Link href={getSourceLink(table.bibcode)} external>
{table.bibcode}
</Link>
) : (
"—"
)}
</Field>
<Field label="Number of records">{table.num_entries}</Field>
<Field label="Number of columns">{table.num_fields}</Field>
<Field label="Modification date">
{table.modification_dt
? formatModificationDate(table.modification_dt)
: "—"}
</Field>
<Field label="Waiting for cross-identification">
{formatProgressPercent(progress.unprocessed, total)}
</Field>
<Field label="Waiting for manual check">
{formatProgressPercent(progress.pending_triage, total)}
</Field>
<Field label="Waiting for submission">
{formatProgressPercent(progress.resolved_unsubmitted, total)}
</Field>
<Field label="Submitted">
{formatProgressPercent(progress.submitted, total)}
</Field>
<Field label="Catalogs">
{formatCatalogsSummary(progress.catalogs, total)}
</Field>
</Card>
);
}

function TablesResults({ data, loading }: TablesResultsProps): ReactElement {
const tables = data?.tables ?? [];

return (
<div className="relative">
<div
className={classNames(
"flex w-full flex-col gap-4",
loading && "opacity-50 pointer-events-none",
)}
>
{tables.map((table) => (
<TableListCard key={table.name} table={table} />
))}
</div>
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-app/60">
<Loading />
</div>
)}
</div>
);
}

async function fetcher(
Expand Down
Loading