selected states stop changing font weight, buttons gain a pressed scale and scoped 120ms transitions with a reduced-motion override, every loading and empty state reserves its height, charts measure before first paint, hit targets rise to the 44px enhanced target where layout permits, long domains clamp to two lines on an unpadded inner span, chips and name cells truncate, tabular figures on counts and time columns, a z-index layer scale replaces magic numbers and fixes the dialog-over-confirm tie, history's empty state gains a clear-filters action, page headings balance, font smoothing and color-scheme land on the html reset.
213 lines
6.7 KiB
TypeScript
213 lines
6.7 KiB
TypeScript
/**
|
|
* Activity in history mode: the persisted queries the URL's filters select,
|
|
* paged by keyset cursor.
|
|
*
|
|
* The filters arrive already applied — the URL is the applied state — so this
|
|
* only reads them. Everything about how the reader got here lives one level up.
|
|
*/
|
|
|
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
import { Link } from "@tanstack/react-router";
|
|
import * as stylex from "@stylexjs/stylex";
|
|
import * as api from "@/lib/api";
|
|
import CoverageNotice from "@/lib/CoverageNotice";
|
|
import InlineError from "@/lib/InlineError";
|
|
import { queriesInfiniteQuery } from "@/lib/queries";
|
|
import type { QueryRow } from "@/lib/types";
|
|
import { useClientNames } from "@/features/clients/clientNames";
|
|
import { summarizeRow } from "@/features/provenance/querySummary";
|
|
import { styles as shared } from "@/ui/styles";
|
|
import { colors } from "@/ui/tokens.stylex";
|
|
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
|
|
import { queriesFilterOf, type ActivitySearch } from "./search";
|
|
|
|
const styles = stylex.create({
|
|
empty: {
|
|
marginTop: "1.5rem",
|
|
minHeight: "6rem",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "flex-start",
|
|
gap: "0.75rem",
|
|
color: colors.textMuted,
|
|
},
|
|
/** About a default page of rows, so the table does not jump in under the reader. */
|
|
loading: {
|
|
minHeight: "24rem",
|
|
},
|
|
tableWrap: {
|
|
marginTop: "1rem",
|
|
overflowX: "auto",
|
|
borderRadius: "0.25rem",
|
|
borderWidth: 1,
|
|
borderStyle: "solid",
|
|
borderColor: colors.border,
|
|
},
|
|
table: {
|
|
width: "100%",
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
},
|
|
/** `divide-y`: a hairline between rows, so the first row carries none. */
|
|
row: {
|
|
borderTopWidth: { default: 1, ":first-child": 0 },
|
|
borderTopStyle: "solid",
|
|
borderTopColor: colors.border,
|
|
},
|
|
footer: {
|
|
marginTop: "0.75rem",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.75rem",
|
|
},
|
|
note: {
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
color: colors.textMuted,
|
|
},
|
|
/** The gap a line standing on its own needs from the block above it. */
|
|
spacedTop: {
|
|
marginTop: "0.75rem",
|
|
},
|
|
moreButton: {
|
|
fontWeight: 500,
|
|
},
|
|
moreError: {
|
|
marginTop: "0.5rem",
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
color: colors.dangerText,
|
|
},
|
|
});
|
|
|
|
function errorMessage(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
interface Props {
|
|
search: ActivitySearch;
|
|
/** The toolbar's own Clear, so the empty state offers the way out it names. */
|
|
onClear: () => void;
|
|
}
|
|
|
|
export default function HistoryActivity({ search, onClear }: Props) {
|
|
const filter = queriesFilterOf(search);
|
|
const base = useInfiniteQuery(queriesInfiniteQuery(filter));
|
|
const clientNames = useClientNames();
|
|
|
|
const pages = base.data?.pages ?? [];
|
|
const rows: QueryRow[] = pages.flatMap((page) => page.queries);
|
|
// Only from the settled response. Placeholder pages belong to the previous
|
|
// filter, and a watermark is a claim about the window being displayed.
|
|
const coverage = base.isPlaceholderData ? undefined : pages[0]?.coverage;
|
|
const filterActive = Object.keys(filter).length > 0;
|
|
// `base.hasNextPage` reads the query state, which is empty while placeholder
|
|
// data stands in for a filter change; derive the cursor from what is on
|
|
// screen so the button keeps its place instead of flashing "end of log".
|
|
const lastPage = pages[pages.length - 1];
|
|
const hasMore = lastPage !== undefined && lastPage.next_before !== null;
|
|
// A 401 is already redirecting via the cache-level handleUnauthorized.
|
|
const isUnauthorized = base.error instanceof api.ApiError && base.error.status === 401;
|
|
const moreError = base.isFetchNextPageError && !isUnauthorized ? errorMessage(base.error) : null;
|
|
|
|
function loadMore() {
|
|
if (!hasMore || base.isFetchingNextPage || base.isPlaceholderData) return;
|
|
void base.fetchNextPage();
|
|
}
|
|
|
|
// The loader starts this fetch but does not wait for it, so both the first
|
|
// paint and a failed first page are this component's to render.
|
|
if (base.status === "error" && base.data === undefined) {
|
|
return <InlineError error={base.error} onRetry={() => void base.refetch()} />;
|
|
}
|
|
if (base.data === undefined) {
|
|
return (
|
|
<p {...stylex.props(styles.empty, styles.loading, shared.pulse)} role="status">
|
|
Loading activity…
|
|
</p>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{/*
|
|
* A quiet line, never a skeleton: the rows on screen stay put while a
|
|
* filter change is in flight, so a keystroke must not blank the table
|
|
* it is narrowing.
|
|
*/}
|
|
{base.isFetching && (
|
|
<p {...stylex.props(styles.note, styles.spacedTop)} role="status">
|
|
Updating…
|
|
</p>
|
|
)}
|
|
{rows.length === 0 ? (
|
|
<div {...stylex.props(styles.empty)}>
|
|
<p>{filterActive ? "No queries match the current filters." : "No queries logged yet."}</p>
|
|
{filterActive && (
|
|
<button type="button" onClick={onClear} {...stylex.props(shared.button, shared.focusRing)}>
|
|
Clear filters
|
|
</button>
|
|
)}
|
|
{coverage !== undefined && <CoverageNotice coverage={coverage} variant="note" />}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div {...stylex.props(styles.tableWrap)}>
|
|
<table {...stylex.props(styles.table)}>
|
|
<ActivityTableHead />
|
|
<tbody>
|
|
{rows.map((row) => (
|
|
<tr key={row.id} {...stylex.props(styles.row)}>
|
|
<ActivityCells
|
|
row={summarizeRow(row)}
|
|
clientNames={clientNames}
|
|
renderDomain={(id, children) =>
|
|
id === null ? (
|
|
children
|
|
) : (
|
|
<Link
|
|
to="/activity/queries/$id"
|
|
params={{ id: String(id) }}
|
|
search={search}
|
|
{...stylex.props(activityDomainLink, shared.focusRing)}
|
|
>
|
|
{children}
|
|
</Link>
|
|
)
|
|
}
|
|
/>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div {...stylex.props(styles.footer)}>
|
|
<p {...stylex.props(styles.note)}>
|
|
{/* The count is the one part of this line that moves as pages load. */}
|
|
Showing <span {...stylex.props(shared.tabularNums)}>{rows.length}</span>{" "}
|
|
{rows.length === 1 ? "query" : "queries"}
|
|
{hasMore ? "" : " — end of log"}
|
|
</p>
|
|
{coverage !== undefined && <CoverageNotice coverage={coverage} variant="note" />}
|
|
{hasMore && (
|
|
<button
|
|
type="button"
|
|
onClick={loadMore}
|
|
disabled={base.isFetchingNextPage || base.isPlaceholderData}
|
|
{...stylex.props(shared.button, styles.moreButton, shared.focusRing)}
|
|
>
|
|
{base.isFetchingNextPage ? "Loading…" : "Load more"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{moreError !== null && (
|
|
<p role="alert" {...stylex.props(styles.moreError)}>
|
|
Failed to load more: {moreError}
|
|
</p>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|