-
+
+
{previewStatsIsBarStats(statistic) &&
Object.keys(statistic.extras).length > 0 && (
-
-
- );
-}
diff --git a/frontend/src/js/preview/Pagination.tsx b/frontend/src/js/preview/Pagination.tsx
new file mode 100644
index 0000000000..78cda39818
--- /dev/null
+++ b/frontend/src/js/preview/Pagination.tsx
@@ -0,0 +1,54 @@
+import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
+import { useHotkeys } from "react-hotkeys-hook";
+import { useTranslation } from "react-i18next";
+import { tv } from "tailwind-variants";
+import { Button } from "../ui-components/Button";
+import { C2 } from "../ui-components/Typography";
+
+const root = tv({ base: ["flex items-center justify-end", "gap-2"] });
+
+export default function Pagination({
+ page,
+ pageCount,
+ setPage,
+}: {
+ page: number;
+ pageCount: number;
+ setPage: (page: number) => void;
+}) {
+ const { t } = useTranslation();
+
+ const updatePage = (change: number) => {
+ const newValue = page + change;
+ if (newValue >= 0 && newValue < pageCount) {
+ setPage(newValue);
+ }
+ };
+
+ useHotkeys("left", () => updatePage(-1), [page]);
+ useHotkeys("right", () => updatePage(1), [page]);
+
+ return (
+
+
+
+ {t("preview.page")} {page + 1}/{pageCount}
+
+
+
+ );
+}
diff --git a/frontend/src/js/preview/Preview.tsx b/frontend/src/js/preview/Preview.tsx
index 9e7c5bc67c..e39618ef60 100644
--- a/frontend/src/js/preview/Preview.tsx
+++ b/frontend/src/js/preview/Preview.tsx
@@ -1,65 +1,72 @@
-import { LoaderCircleIcon } from "lucide-react";
-import { useMemo, useState } from "react";
+import {
+ ChevronDownIcon,
+ ChevronLeftIcon,
+ LoaderCircleIcon,
+} from "lucide-react";
+import { type ReactNode, useMemo, useState } from "react";
+import { MenuTrigger } from "react-aria-components";
import { useHotkeys } from "react-hotkeys-hook";
import { useTranslation } from "react-i18next";
import { useDispatch, useSelector } from "react-redux";
import { tv } from "tailwind-variants";
import type { PreviewStatistics, SecondaryId } from "../api/types";
import type { StateT } from "../app/reducers";
+import MatchingStats from "../info-pane/MatchingStats";
import { Button } from "../ui-components/Button";
+import { Card } from "../ui-components/Card";
+import { Menu, MenuItem } from "../ui-components/Menu";
+import { C3, H3 } from "../ui-components/Typography";
import { closePreview } from "./actions";
-import Charts from "./Charts";
+import Charts, { DIAGRAMS_PER_PAGE } from "./Charts";
import DiagramModal from "./DiagramModal";
-import HeadlineStats from "./HeadlineStats";
+import Pagination from "./Pagination";
import type { PreviewStateT } from "./reducer";
import ScrollBox from "./ScrollBox";
-import SelectBox from "./SelectBox";
import Table from "./Table";
const fullScreen = tv({
- base: [
- "fixed top-0 left-0",
- "z-2",
- "flex flex-col",
- "gap-[15px]",
- "h-full w-full",
- "bg-bg-100",
- ],
+ base: ["fixed top-0 left-0", "z-2", "h-full w-full", "bg-bg-100"],
});
-const headline = tv({
- base: ["flex flex-row items-center", "gap-[30px]"],
+const content = tv({
+ base: ["flex flex-col", "gap-5", "px-5 pt-[55px] pb-5"],
});
-const scrollBox = tv({
- base: ["flex flex-col", "gap-5", "pt-[60px] px-5 pb-5"],
-});
+const topRow = tv({ base: ["flex items-center", "gap-[30px]"] });
-const charts = tv({
- base: [
- "w-full",
- "bg-white",
- "p-[10px]",
- "shadow-[0_0_5px_0_rgba(0,0,0,0.2)]",
- ],
-});
+const section = tv({ base: ["flex flex-col", "gap-[10px]"] });
-const chartLoadingBlocker = tv({
- base: [
- "flex items-center justify-center",
- "h-[65vh] w-full",
- "bg-white",
- "p-[10px]",
- "shadow-[0_0_5px_0_rgba(0,0,0,0.2)]",
- ],
+const sectionHeader = tv({
+ base: ["flex items-start justify-between", "gap-5"],
});
-const spinnerIcon = tv({ base: "size-[30px]" });
+// the stats stand beside the charts, like the info pane beside the panes
+const statistics = tv({
+ base: ["grid grid-cols-[auto_minmax(0,1fr)]", "gap-5", "items-start"],
+});
-const selectBox = tv({
- base: ["rounded", "bg-white", "shadow-[0_0_5px_0_rgba(0,0,0,0.2)]"],
+const loading = tv({
+ base: ["flex items-center justify-center", "h-[65vh]"],
});
+const SectionHeader = ({
+ title,
+ subtitle,
+ children,
+}: {
+ title: string;
+ subtitle: string;
+ children?: ReactNode;
+}) => (
+
+
+
{title}
+ {subtitle}
+
+ {children}
+
+);
+
export default function Preview() {
const preview = useSelector
((state) => state.preview);
const loadedSecondaryIds = useSelector(
@@ -67,11 +74,10 @@ export default function Preview() {
);
const dispatch = useDispatch();
const { t } = useTranslation();
- const [selectBoxOpen, setSelectBoxOpen] = useState(false);
const [page, setPage] = useState(0);
const [popOver, setPopOver] = useState(null);
const onClose = () => dispatch(closePreview());
- const statistics = preview.statisticsData;
+ const stats = preview.statisticsData;
const idLabel = useMemo(() => {
const primaryIdLabel = t("common.entitiesFound", { count: 2 });
if (preview.queryData?.secondaryId) {
@@ -85,58 +91,114 @@ export default function Preview() {
}, [preview.queryData, loadedSecondaryIds, t]);
useHotkeys("esc", () => {
- if (!selectBoxOpen && !popOver) onClose();
+ if (!popOver) onClose();
});
return (
-
-
-
- Ergebnisvorschau
- {
- const stat = statistics?.statistics.find(
- (stat) => stat.label === res.label,
- );
- setPopOver(stat ?? null);
- }}
- isOpen={selectBoxOpen}
- setIsOpen={setSelectBoxOpen}
- />
-
-
- {statistics ? (
- {
- setPopOver(statistic);
- }}
- page={page}
- setPage={setPage}
- />
- ) : (
-
-
+
+
+
+
+
+
{preview.queryData?.label}
+ {t("preview.headline")}
+
- )}
- {popOver && (
-
setPopOver(null)} />
- )}
- {preview.arrowReader &&
- preview.initialTableData &&
- preview.queryData && (
-
+
+ {stats && (
+
+
+
+
+ )}
+
+
+
+
+
+
+ {stats ? (
+ {
+ setPopOver(statistic);
+ }}
+ page={page}
+ />
+ ) : (
+
+
+
+ )}
+
+
+ {stats && (
+
+ )}
+
+ {popOver && (
+ setPopOver(null)}
/>
)}
+ {preview.arrowReader &&
+ preview.initialTableData?.value &&
+ preview.queryData && (
+
+ )}
+
);
diff --git a/frontend/src/js/preview/ScrollBox.tsx b/frontend/src/js/preview/ScrollBox.tsx
index 95419db05d..6f7560c2d2 100644
--- a/frontend/src/js/preview/ScrollBox.tsx
+++ b/frontend/src/js/preview/ScrollBox.tsx
@@ -6,40 +6,30 @@ import {
useRef,
useState,
} from "react";
-import { Button as RacButton } from "react-aria-components";
+import { useTranslation } from "react-i18next";
import { tv } from "tailwind-variants";
+import { Button } from "../ui-components/Button";
const root = tv({ base: "overflow-auto" });
+// a white backing, because the secondary button is transparent over the content
const scrollTopButton = tv({
- base: [
- "absolute right-[30px] bottom-[30px]",
- "z-3",
- "flex justify-center",
- "h-[50px] w-[50px]",
- "items-center",
- "rounded-full",
- "border border-gray-500",
- "bg-white hover:bg-gray-50",
- "text-gray-800",
- "cursor-pointer",
- "shadow-[0_0_5px_0_rgba(0,0,0,0.2)]",
- ],
+ base: ["absolute right-5 bottom-5", "z-3", "rounded", "bg-white"],
});
export default function ScrollBox({
- threshold = 0,
children,
className,
...props
-}: PropsWithChildren<{ threshold?: number }> & HTMLAttributes) {
+}: PropsWithChildren>) {
+ const { t } = useTranslation();
const scrollBoxRef = useRef(null);
const [showButton, setShowButton] = useState(false);
useEffect(() => {
const scrollHandler = (e: Event) => {
const target = e.target as HTMLDivElement;
- setShowButton(target.scrollTop > threshold);
+ setShowButton(target.scrollTop > 0);
};
const scrollBox = scrollBoxRef.current;
@@ -47,19 +37,22 @@ export default function ScrollBox({
passive: true,
});
return () => scrollBox?.removeEventListener("scroll", scrollHandler);
- }, [threshold]);
+ }, []);
return (
{showButton && (
-
- scrollBoxRef.current?.scrollTo({ top: 0, behavior: "smooth" })
- }
- >
-
-
+
+
+
)}
{children}
diff --git a/frontend/src/js/preview/SelectBox.tsx b/frontend/src/js/preview/SelectBox.tsx
deleted file mode 100644
index d9c1beb6c8..0000000000
--- a/frontend/src/js/preview/SelectBox.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
-import { type SetStateAction, useMemo, useRef, useState } from "react";
-import { tv } from "tailwind-variants";
-import { useClickOutside } from "../common/helpers/useClickOutside";
-import { textStyle } from "../ui-components/Typography";
-
-export interface SelectItem {
- label: string;
-}
-
-interface SelectBoxProps {
- items: T[];
- onChange: (item: T) => void;
- className?: string;
- isOpen: boolean;
- setIsOpen: (open: boolean) => void;
-}
-
-const input = tv({
- base: [
- "mt-[5px] w-[190px] h-5",
- "border-0 outline-none",
- textStyle({ size: 2 }),
- "disabled:cursor-not-allowed disabled:pointer-events-none",
- ],
-});
-
-const root = tv({
- base: ["flex flex-col", "min-h-[30px]", "w-[20vw]"],
-});
-
-const list = tv({
- base: [
- "absolute",
- "z-1",
- "mt-[35px]",
- "flex flex-col",
- "gap-[5px]",
- "max-h-[40vh] w-[20vw]",
- "overflow-y-auto",
- "rounded",
- "bg-white",
- "shadow-[0_0_5px_rgba(0,0,0,0.2)]",
- "[clip-path:inset(0px_-8px_-8px_-8px)]",
- ],
-});
-
-const listItem = tv({
- base: ["px-[5px]", "cursor-pointer", "hover:bg-gray-50"],
-});
-
-const arrow = tv({
- base: ["mt-[5px]", "size-[17px]", "text-gray-600", "cursor-pointer"],
-});
-
-export default function SelectBox({
- items,
- onChange,
- className,
- isOpen,
- setIsOpen,
-}: SelectBoxProps) {
- const [searchTerm, setSearchTerm] = useState("");
- const clickOutsideRef = useRef(null);
- useClickOutside(clickOutsideRef, () => setIsOpen(false));
-
- const displayedItems = useMemo(() => {
- return items.filter((item) => {
- if (searchTerm === "") {
- return true;
- }
- if (item.label === null) {
- return false;
- }
- return item.label.toLowerCase().includes(searchTerm.toLowerCase());
- });
- }, [items, searchTerm]);
-
- return (
- // biome-ignore lint/a11y/noStaticElementInteractions: TODO make this a real combobox
- // biome-ignore lint/a11y/useKeyWithClickEvents: see above
- setIsOpen(!isOpen)}>
-
-
} }) =>
- setSearchTerm(e.target.value)
- }
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- onChange(displayedItems[0]);
- }
- }}
- spellCheck={false}
- />
-
- {isOpen ? (
-
- ) : (
-
- )}
-
-
-
- {isOpen &&
- displayedItems.map((item) => {
- return (
- // biome-ignore lint/a11y/noStaticElementInteractions: TODO make this a real listbox option
- // biome-ignore lint/a11y/useKeyWithClickEvents: see above
-
onChange(item)}
- >
- {item.label}
-
- );
- })}
-
-
- );
-}
diff --git a/frontend/src/js/preview/Table.tsx b/frontend/src/js/preview/Table.tsx
index f3521fc82e..34fdcda39f 100644
--- a/frontend/src/js/preview/Table.tsx
+++ b/frontend/src/js/preview/Table.tsx
@@ -20,8 +20,6 @@ interface Props {
// flipped so the horizontal scrollbar sits on top; the inner table flips back
const root = tv({
base: [
- "grow",
- "shadow-[0_0_10px_0_rgba(0,0,0,0.2)]",
"[transform:rotateX(180deg)]",
"[&_table]:[transform:rotateX(-180deg)]",
],
@@ -31,12 +29,13 @@ const table = tv({
base: [
"w-full",
"border-spacing-0",
- "[&_th]:bg-gray-50 [&_th]:text-left [&_th]:font-normal",
+ "[&_th]:bg-bg-50 [&_th]:text-left",
"[&_td]:max-w-[25ch] [&_td]:overflow-hidden [&_td]:text-ellipsis [&_td]:whitespace-nowrap",
- "[&_th]:p-[10px] [&_td]:p-[10px]",
- "[&_th]:border-r [&_th]:border-b [&_th]:border-gray-400",
- "[&_td]:border-r [&_td]:border-b [&_td]:border-gray-400",
+ "[&_th]:px-2 [&_th]:py-[5px] [&_td]:px-2 [&_td]:py-[5px]",
+ "[&_th]:border-r [&_th]:border-b [&_th]:border-gray-100",
+ "[&_td]:border-r [&_td]:border-b [&_td]:border-gray-100",
"[&_th:last-of-type]:border-r-0 [&_td:last-of-type]:border-r-0",
+ "[&_tbody_tr:last-of-type_td]:border-b-0",
"[&_.rc-table-measure-cell]:py-0 [&_.rc-table-measure-cell]:border-y-0",
"[&_.rc-table-measure-cell-content]:invisible [&_.rc-table-measure-cell-content]:pointer-events-none",
"[&_.rc-table-measure-cell-content]:h-0 [&_.rc-table-measure-cell-content]:overflow-hidden",
@@ -65,8 +64,14 @@ export default memo(function Table({
arrowReader.schema?.fields.map((field) => {
const renderer = getRenderFunctionByFieldName(field.name);
+ const title = field.name.charAt(0).toUpperCase() + field.name.slice(1);
+
return {
- title: field.name.charAt(0).toUpperCase() + field.name.slice(1),
+ title: (
+
+ {title}
+
+ ),
dataIndex: field.name,
key: field.name,
render: (value: string | Vector) => {
diff --git a/frontend/src/js/ui-components/Card.stories.tsx b/frontend/src/js/ui-components/Card.stories.tsx
new file mode 100644
index 0000000000..a8512e30b0
--- /dev/null
+++ b/frontend/src/js/ui-components/Card.stories.tsx
@@ -0,0 +1,35 @@
+import type { Meta, StoryObj } from "@storybook/react";
+
+import { Card } from "./Card";
+import { C2, C3, H3 } from "./Typography";
+
+export default {
+ title: "UiComponents/Card",
+ component: Card,
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+} as Meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => (
+
+
+ Regions
+ 12 entries
+
+ North, South, East and West, with their population per year.
+
+
+
+ Products
+
+
+ ),
+};
diff --git a/frontend/src/js/ui-components/Card.tsx b/frontend/src/js/ui-components/Card.tsx
new file mode 100644
index 0000000000..ba3bf3f654
--- /dev/null
+++ b/frontend/src/js/ui-components/Card.tsx
@@ -0,0 +1,14 @@
+import type { ReactNode } from "react";
+import { tv } from "tailwind-variants";
+
+const card = tv({
+ base: ["rounded", "border border-gray-100", "bg-white", "p-3"],
+});
+
+/**
+ * A white surface that groups content on a page background. Layout inside
+ * and around it belongs to the content and the parent.
+ */
+export const Card = ({ children }: { children: ReactNode }) => (
+ {children}
+);
diff --git a/frontend/src/js/ui-components/Menu.tsx b/frontend/src/js/ui-components/Menu.tsx
index 838b7b0e88..8ef295dd78 100644
--- a/frontend/src/js/ui-components/Menu.tsx
+++ b/frontend/src/js/ui-components/Menu.tsx
@@ -27,7 +27,8 @@ const menuItem = tv({
"flex items-center",
"gap-[10px]",
"rounded",
- "h-[30px] px-3",
+ // the menu scrolls at its max height instead of squeezing the items
+ "h-[30px] shrink-0 px-3",
textStyle({ size: 2, tone: "default" }),
"whitespace-nowrap",
"cursor-pointer",
diff --git a/frontend/src/localization/de.json b/frontend/src/localization/de.json
index 403532d67d..f9b884f041 100644
--- a/frontend/src/localization/de.json
+++ b/frontend/src/localization/de.json
@@ -409,21 +409,16 @@
"preview": "Vorschau",
"previousPage": "Vorherige Seite",
"nextPage": "Nächste Seite",
+ "scrollToTop": "Nach oben",
"page": "Seite",
"headline": "Ergebnisvorschau",
"previewHeadline": "Ergebnis-CSV",
"previewSubline": "Auszug der ersten {{count}} Zeilen",
"statisticsHeadline": "Spaltenstatistik",
"statisticsSubline": "Basierend auf den vollständigen Ergebnissen. Fehlende Zahlenwerte werden als 0 interpretiert.",
- "total": "Zeilen",
- "min": "Min Datum",
- "max": "Max Datum",
- "span": "Datumsbereich",
"loadingError": "Da ist etwas schiefgelaufen, bitte versuche es nochmal.",
- "dateError": "Datum unbekannt",
"name": "Name",
"value": "Wert",
- "densityPlot": "Dichtediagramm",
"chartYLabel": "Anzahl",
"idLabel": "{{primaryIdLabel}} und {{secondaryIdLabel}}"
},
diff --git a/frontend/src/localization/en.json b/frontend/src/localization/en.json
index c679b6449a..9e81d2d737 100644
--- a/frontend/src/localization/en.json
+++ b/frontend/src/localization/en.json
@@ -409,21 +409,16 @@
"preview": "Preview",
"previousPage": "Previous page",
"nextPage": "Next page",
+ "scrollToTop": "Scroll to top",
"page": "Page",
"headline": "Results preview",
"previewHeadline": "Result CSV",
"previewSubline": "Preview of the first {{count}} lines",
"statisticsHeadline": "Statistics per column",
"statisticsSubline": "Based on the full results. Missing number values interpreted as 0.",
- "total": "Total rows",
- "min": "Min date",
- "max": "Max date",
- "span": "Date span",
"loadingError": "An error occurred while loading the preview.",
- "dateError": "Date unknown",
"name": "Name",
"value": "Value",
- "densityPlot": "Density Plot",
"chartYLabel": "Count",
"idLabel": "{{primaryIdLabel}} and {{secondaryIdLabel}}"
},