Paragraph-level links, preview tooltips

This commit is contained in:
Adrian Rumpold
2025-04-30 12:04:38 +02:00
parent ea7885eeee
commit 7dd913df7b
25 changed files with 569 additions and 102 deletions

View File

@@ -0,0 +1,64 @@
import { act } from "@testing-library/react";
import type * as ZustandExportedTypes from "zustand";
export * from "zustand";
const { create: actualCreate, createStore: actualCreateStore } =
jest.requireActual<typeof ZustandExportedTypes>("zustand");
// a variable to hold reset functions for all stores declared in the app
export const storeResetFns = new Set<() => void>();
const createUncurried = <T>(
stateCreator: ZustandExportedTypes.StateCreator<T>
) => {
const store = actualCreate(stateCreator);
const initialState = store.getInitialState();
storeResetFns.add(() => {
store.setState(initialState, true);
});
return store;
};
// when creating a store, we get its initial state, create a reset function and add it in the set
export const create = (<T>(
stateCreator: ZustandExportedTypes.StateCreator<T>
) => {
console.log("zustand create mock");
// to support curried version of create
return typeof stateCreator === "function"
? createUncurried(stateCreator)
: createUncurried;
}) as typeof ZustandExportedTypes.create;
const createStoreUncurried = <T>(
stateCreator: ZustandExportedTypes.StateCreator<T>
) => {
const store = actualCreateStore(stateCreator);
const initialState = store.getInitialState();
storeResetFns.add(() => {
store.setState(initialState, true);
});
return store;
};
// when creating a store, we get its initial state, create a reset function and add it in the set
export const createStore = (<T>(
stateCreator: ZustandExportedTypes.StateCreator<T>
) => {
console.log("zustand createStore mock");
// to support curried version of createStore
return typeof stateCreator === "function"
? createStoreUncurried(stateCreator)
: createStoreUncurried;
}) as typeof ZustandExportedTypes.createStore;
// reset all stores after each test run
afterEach(() => {
act(() => {
storeResetFns.forEach((resetFn) => {
resetFn();
});
});
});

View File

@@ -8,6 +8,7 @@
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@floating-ui/react": "^0.27.8",
"@tanstack/react-query": "^5.74.4", "@tanstack/react-query": "^5.74.4",
"@tanstack/react-query-devtools": "^5.74.6", "@tanstack/react-query-devtools": "^5.74.6",
"axios": "^1.9.0", "axios": "^1.9.0",
@@ -18,6 +19,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.22.0", "@eslint/js": "^9.22.0",
"@jest/globals": "^29.7.0",
"@tanstack/eslint-plugin-query": "^5.73.3", "@tanstack/eslint-plugin-query": "^5.73.3",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
@@ -1259,6 +1261,59 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz",
"integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.6.13",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz",
"integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/react": {
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.8.tgz",
"integrity": "sha512-EQJ4Th328y2wyHR3KzOUOoTW2UKjFk53fmyahfwExnFQ8vnsMYqKc+fFPOkeYtj5tcp1DUMiNJ7BFhed7e9ONw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.2",
"@floating-ui/utils": "^0.2.9",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz",
"integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -8129,6 +8184,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/tabbable": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
"license": "MIT"
},
"node_modules/test-exclude": { "node_modules/test-exclude": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",

View File

@@ -11,6 +11,7 @@
"test": "jest" "test": "jest"
}, },
"dependencies": { "dependencies": {
"@floating-ui/react": "^0.27.8",
"@tanstack/react-query": "^5.74.4", "@tanstack/react-query": "^5.74.4",
"@tanstack/react-query-devtools": "^5.74.6", "@tanstack/react-query-devtools": "^5.74.6",
"axios": "^1.9.0", "axios": "^1.9.0",
@@ -21,6 +22,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.22.0", "@eslint/js": "^9.22.0",
"@jest/globals": "^29.7.0",
"@tanstack/eslint-plugin-query": "^5.73.3", "@tanstack/eslint-plugin-query": "^5.73.3",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",

View File

@@ -11,7 +11,8 @@ import CelexSelector from "./components/CelexSelector/CelexSelector";
import { useTOC } from "./hooks/toc"; import { useTOC } from "./hooks/toc";
function App() { function App() {
const { numPanels, addPanel } = useUIStore(); const numPanels = useUIStore((state) => state.numPanels);
const addPanel = useUIStore((state) => state.addPanel);
const { data: toc, isPending, error } = useTOC(); const { data: toc, isPending, error } = useTOC();
if (isPending) { if (isPending) {

View File

@@ -32,10 +32,6 @@ describe("ArticleSelector", () => {
], ],
}; };
beforeEach(() => {
jest.clearAllMocks();
});
test("renders a top-level division as an optgroup", () => { test("renders a top-level division as an optgroup", () => {
const { getByRole } = render(<ArticleSelector toc={[mockDivision]} />); const { getByRole } = render(<ArticleSelector toc={[mockDivision]} />);

View File

@@ -1,6 +1,6 @@
import { Fragment, JSX } from "react"; import { Fragment, JSX } from "react";
import type { Division } from "../../lib/types"; import type { Division } from "../../lib/types";
import useNavState from "../../store/navStore"; import useNavStore from "../../store/navStore";
import styles from "./ArticleSelector.module.css"; import styles from "./ArticleSelector.module.css";
type ArticleSelectorProps = { type ArticleSelectorProps = {
@@ -41,7 +41,8 @@ function renderDivision(div: Division): JSX.Element {
} }
function ArticleSelector({ toc }: ArticleSelectorProps) { function ArticleSelector({ toc }: ArticleSelectorProps) {
const { articleId, setArticleId } = useNavState(); const articleId = useNavStore.use.articleId();
const setArticleId = useNavStore.use.setArticleId();
return ( return (
<select <select

View File

@@ -1,30 +1,18 @@
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render } from "@testing-library/react";
import { examples } from "../../lib/examples"; import { examples } from "../../lib/examples";
import useNavState from "../../store/navStore"; import useNavStore from "../../store/navStore";
import CelexSelector from "./CelexSelector"; import CelexSelector from "./CelexSelector";
jest.mock("../../store/navStore");
describe("CelexSelector", () => { describe("CelexSelector", () => {
const mockSetCelexId = jest.fn();
const mockSetArticleId = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(useNavState).mockReturnValue({
celexId: "",
setCelexId: mockSetCelexId,
setArticleId: mockSetArticleId,
});
});
it("renders the dropdown with options", () => { it("renders the dropdown with options", () => {
render(<CelexSelector />); const { getByLabelText, getAllByRole, getByRole } = render(
<CelexSelector />
);
expect(screen.getByLabelText("Select example:")).toBeInTheDocument(); expect(getByLabelText("Select example:")).toBeInTheDocument();
expect(screen.getByRole("combobox")).toBeInTheDocument(); expect(getByRole("combobox")).toBeInTheDocument();
const options = screen.getAllByRole("option"); const options = getAllByRole("option");
expect(options).toHaveLength(examples.length); expect(options).toHaveLength(examples.length);
for (const i in examples) { for (const i in examples) {
expect(options[i]).toHaveValue(examples[i].id); expect(options[i]).toHaveValue(examples[i].id);
@@ -32,28 +20,15 @@ describe("CelexSelector", () => {
} }
}); });
it("calls setCelexId and setArticleId on selection change", () => { it("calls setCelexId and setArticleId on selection change", async () => {
const celexId = "32016R0679"; const celexId = examples[2].id;
render(<CelexSelector />); const { getByRole } = render(<CelexSelector />);
fireEvent.change(screen.getByRole("combobox"), { fireEvent.change(getByRole("combobox"), {
target: { value: celexId }, target: { value: celexId },
}); });
expect(mockSetCelexId).toHaveBeenCalledWith(celexId); expect(useNavStore.getState().celexId).toEqual(celexId);
expect(mockSetArticleId).toHaveBeenCalledWith(1); expect(useNavStore.getState().articleId).toEqual(1);
});
it("sets the correct value in the dropdown based on celexId", () => {
const mockCelexId = "32024R1689";
jest.mocked(useNavState).mockReturnValue({
celexId: mockCelexId,
setCelexId: mockSetCelexId,
setArticleId: mockSetArticleId,
});
render(<CelexSelector />);
expect(screen.getByRole("combobox")).toHaveValue(mockCelexId);
}); });
}); });

View File

@@ -1,8 +1,10 @@
import { examples } from "../../lib/examples"; import { examples } from "../../lib/examples";
import useNavState from "../../store/navStore"; import useNavStore from "../../store/navStore";
function CelexSelector() { function CelexSelector() {
const { celexId, setCelexId, setArticleId } = useNavState(); const celexId = useNavStore.use.celexId();
const setCelexId = useNavStore.use.setCelexId();
const setArticleId = useNavStore.use.setArticleId();
return ( return (
<div> <div>

View File

@@ -3,7 +3,7 @@ import { fireEvent, render } from "@testing-library/react";
import React from "react"; import React from "react";
import { getArticle } from "../../lib/api"; import { getArticle } from "../../lib/api";
import { Language } from "../../lib/types"; import { Language } from "../../lib/types";
import useNavState from "../../store/navStore"; import useNavStore from "../../store/navStore";
import useUIStore from "../../store/uiStore"; import useUIStore from "../../store/uiStore";
import Panel from "./Panel"; import Panel from "./Panel";
@@ -39,7 +39,7 @@ describe("Panel Component", () => {
}; };
beforeEach(() => { beforeEach(() => {
jest.mocked(useNavState).mockReturnValue(mockNavState); jest.mocked(useNavStore).mockReturnValue(mockNavState);
jest.mocked(useUIStore).mockReturnValue(mockUseUIStore); jest.mocked(useUIStore).mockReturnValue(mockUseUIStore);
}); });

View File

@@ -5,8 +5,10 @@ import useUIStore from "../../store/uiStore";
import LanguageSwitcher from "../LanguageSwitcher/LanguageSwitcher"; import LanguageSwitcher from "../LanguageSwitcher/LanguageSwitcher";
import { useArticle } from "../../hooks/useArticle"; import { useArticle } from "../../hooks/useArticle";
import useNavState from "../../store/navStore"; import useNavStore from "../../store/navStore";
import "../../styles/PanelContent.css"; import "../../styles/PanelContent.css";
import ParagraphPreview from "../ParagraphPreview/ParagraphPreview";
import { Tooltip, TooltipContent } from "../Tooltip/Tooltip";
import styles from "./Panel.module.css"; import styles from "./Panel.module.css";
type PanelProps = { type PanelProps = {
@@ -18,9 +20,13 @@ function Panel({ language }: PanelProps) {
const [lang, setLang] = useState(language || Language.ENG); const [lang, setLang] = useState(language || Language.ENG);
const articleRef = useRef<HTMLDivElement>(null); const articleRef = useRef<HTMLDivElement>(null);
const { articleId, celexId, setArticleId } = useNavState(); const { articleId, celexId, setArticleId } = useNavStore();
const { data, isPending, error } = useArticle(celexId, articleId, lang); const { data, isPending, error } = useArticle(celexId, articleId, lang);
const [hoverArticleId, setHoverArticleId] = useState<number | null>(null);
const [hoverParagraphId, setHoverParagraphId] = useState<number | null>(null);
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
useEffect(() => { useEffect(() => {
const articleElement = articleRef.current; const articleElement = articleRef.current;
if (!articleElement) return; if (!articleElement) return;
@@ -32,6 +38,7 @@ function Panel({ language }: PanelProps) {
crossRefs.forEach((link) => { crossRefs.forEach((link) => {
const target = link.getAttribute("data-target"); const target = link.getAttribute("data-target");
const targetId = link.getAttribute("data-id"); const targetId = link.getAttribute("data-id");
const paragraphId = link.getAttribute("data-paragraph-id");
if (target && targetId) { if (target && targetId) {
if (target === "article") { if (target === "article") {
@@ -41,6 +48,17 @@ function Panel({ language }: PanelProps) {
setArticleId(parseInt(targetId)); setArticleId(parseInt(targetId));
return false; return false;
}; };
if (paragraphId) {
link.onmouseover = () => {
setHoverArticleId(parseInt(targetId));
setHoverParagraphId(parseInt(paragraphId));
setIsTooltipOpen(true);
};
link.onmouseout = () => {
setIsTooltipOpen(false);
};
}
} }
} else { } else {
console.warn("No target or ID found for link:", link); console.warn("No target or ID found for link:", link);
@@ -83,18 +101,15 @@ function Panel({ language }: PanelProps) {
// Cleanup event listeners // Cleanup event listeners
return () => { return () => {
console.log("Cleaning up event listeners"); console.log("Cleaning up event listeners");
// crossRefs.forEach((link) => {
// link.onmouseover = null;
// link.onmouseout = null;
// });
paragraphs.forEach((element) => { paragraphs.forEach((element) => {
element.removeEventListener("click", handleClick(element)); element.removeEventListener("click", handleClick(element));
}); });
}; };
}, [ });
articleRef,
data,
celexId,
selectedParagraphId,
setSelectedParagraphId,
setArticleId,
]);
if (isPending) return "Loading..."; if (isPending) return "Loading...";
if (error) return "An error has occurred: " + error.message; if (error) return "An error has occurred: " + error.message;
@@ -105,6 +120,20 @@ function Panel({ language }: PanelProps) {
defaultLang={lang} defaultLang={lang}
onChange={setLang} onChange={setLang}
></LanguageSwitcher> ></LanguageSwitcher>
<Tooltip
open={isTooltipOpen}
placement="right-start"
onOpenChange={setIsTooltipOpen}
>
<TooltipContent>
<ParagraphPreview
celexId={celexId!}
articleId={hoverArticleId!}
paragraphId={hoverParagraphId!}
lang={lang}
/>
</TooltipContent>
</Tooltip>
<div <div
ref={articleRef} ref={articleRef}
lang={lang.substring(0, 2)} lang={lang.substring(0, 2)}

View File

@@ -0,0 +1,34 @@
import { useParagraph } from "../../hooks/useParagraph";
import { Language } from "../../lib/types";
type ParagraphPreviewProps = {
celexId: string;
articleId: number;
paragraphId: number;
lang: Language;
};
function ParagraphPreview({
celexId,
articleId,
paragraphId,
lang,
}: ParagraphPreviewProps) {
const { data, isPending, error } = useParagraph(
celexId,
articleId,
paragraphId,
lang
);
if (isPending) {
return <p>Loading...</p>;
}
if (error) {
return <p>Error loading paragraph: {error.message}</p>;
}
return <div dangerouslySetInnerHTML={{ __html: data }} />;
}
export default ParagraphPreview;

View File

@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { Division } from "../../lib/types"; import { Division } from "../../lib/types";
import useNavState from "../../store/navStore"; import useNavStore from "../../store/navStore";
import styles from "./TOC.module.css"; import styles from "./TOC.module.css";
type TOCProps = { type TOCProps = {
@@ -8,7 +8,7 @@ type TOCProps = {
}; };
function TOC({ toc }: TOCProps) { function TOC({ toc }: TOCProps) {
const { articleId, setArticleId } = useNavState(); const { articleId, setArticleId } = useNavStore();
function containsArticle(division: Division, articleId: number): boolean { function containsArticle(division: Division, articleId: number): boolean {
return division.content.some((c) => { return division.content.some((c) => {

View File

@@ -0,0 +1,10 @@
.Tooltip {
background-color: #444;
color: white;
font-size: 90%;
padding: 4px 8px;
border-radius: 4px;
box-sizing: border-box;
width: max-content;
max-width: 60ch;
}

View File

@@ -0,0 +1,163 @@
import type { Placement } from "@floating-ui/react";
import {
autoUpdate,
flip,
FloatingPortal,
offset,
shift,
useDismiss,
useFloating,
useFocus,
useHover,
useInteractions,
useMergeRefs,
useRole,
} from "@floating-ui/react";
import * as React from "react";
import styles from "./Tooltip.module.css";
interface TooltipOptions {
initialOpen?: boolean;
placement?: Placement;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function useTooltip({
initialOpen = false,
placement = "top",
open: controlledOpen,
onOpenChange: setControlledOpen,
}: TooltipOptions = {}) {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(initialOpen);
const open = controlledOpen ?? uncontrolledOpen;
const setOpen = setControlledOpen ?? setUncontrolledOpen;
const data = useFloating({
placement,
open,
onOpenChange: setOpen,
whileElementsMounted: autoUpdate,
middleware: [
offset(5),
flip({
crossAxis: placement.includes("-"),
fallbackAxisSideDirection: "start",
padding: 5,
}),
shift({ padding: 5 }),
],
});
const context = data.context;
const hover = useHover(context, {
move: false,
enabled: controlledOpen == null,
});
const focus = useFocus(context, {
enabled: controlledOpen == null,
});
const dismiss = useDismiss(context);
const role = useRole(context, { role: "tooltip" });
const interactions = useInteractions([hover, focus, dismiss, role]);
return React.useMemo(
() => ({
open,
setOpen,
...interactions,
...data,
}),
[open, setOpen, interactions, data]
);
}
type ContextType = ReturnType<typeof useTooltip> | null;
const TooltipContext = React.createContext<ContextType>(null);
export const useTooltipContext = () => {
const context = React.useContext(TooltipContext);
if (context == null) {
throw new Error("Tooltip components must be wrapped in <Tooltip />");
}
return context;
};
export function Tooltip({
children,
...options
}: { children: React.ReactNode } & TooltipOptions) {
// This can accept any props as options, e.g. `placement`,
// or other positioning options.
const tooltip = useTooltip(options);
return (
<TooltipContext.Provider value={tooltip}>
{children}
</TooltipContext.Provider>
);
}
export const TooltipTrigger = React.forwardRef<
HTMLElement,
React.HTMLProps<HTMLElement> & { asChild?: boolean }
>(function TooltipTrigger({ children, asChild = false, ...props }, propRef) {
const context = useTooltipContext();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const childrenRef = (children as any).ref;
const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef]);
// `asChild` allows the user to pass any element as the anchor
if (asChild && React.isValidElement(children)) {
return React.cloneElement(
children,
context.getReferenceProps({
ref,
...props,
...children.props,
"data-state": context.open ? "open" : "closed",
})
);
}
return (
<button
ref={ref}
// The user can style the trigger based on the state
data-state={context.open ? "open" : "closed"}
{...context.getReferenceProps(props)}
>
{children}
</button>
);
});
export const TooltipContent = React.forwardRef<
HTMLDivElement,
React.HTMLProps<HTMLDivElement>
>(function TooltipContent({ style, ...props }, propRef) {
const context = useTooltipContext();
const ref = useMergeRefs([context.refs.setFloating, propRef]);
if (!context.open) return null;
return (
<FloatingPortal>
<div
ref={ref}
className={styles.Tooltip}
style={{
...context.floatingStyles,
...style,
}}
{...context.getFloatingProps(props)}
/>
</FloatingPortal>
);
});

View File

@@ -1,10 +1,10 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getToc } from "../lib/api"; import { getToc } from "../lib/api";
import { Language } from "../lib/types"; import { Language } from "../lib/types";
import useNavState from "../store/navStore"; import useNavStore from "../store/navStore";
export const useTOC = () => { export const useTOC = () => {
const celexId = useNavState((state) => state.celexId); const celexId = useNavStore((state) => state.celexId);
const query = useQuery({ const query = useQuery({
queryKey: ["toc", celexId], queryKey: ["toc", celexId],
queryFn: () => getToc(celexId!, Language.ENG), queryFn: () => getToc(celexId!, Language.ENG),

View File

@@ -1,11 +1,15 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import useNavState from "../store/navStore"; import useNavStore from "../store/navStore";
export const useUrlSync = () => { export const useUrlSync = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { celexId: paramCelexId, articleId: paramArticleId } = useParams(); const { celexId: paramCelexId, articleId: paramArticleId } = useParams();
const { articleId, celexId, setArticleId, setCelexId } = useNavState();
const celexId = useNavStore.use.celexId();
const setCelexId = useNavStore.use.setCelexId();
const articleId = useNavStore.use.articleId();
const setArticleId = useNavStore.use.setArticleId();
// Effect to read from URL when URL changes // Effect to read from URL when URL changes
useEffect(() => { useEffect(() => {

View File

@@ -0,0 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import { getParagraph } from "../lib/api";
import { Language } from "../lib/types";
export const useParagraph = (
celexId: string | null,
articleId: number | null,
paragraphId: number | null,
lang: Language
) => {
return useQuery({
queryKey: ["paragraph", celexId, articleId, paragraphId, lang],
queryFn: () => getParagraph(celexId!, articleId!, paragraphId!, lang),
enabled: !!celexId && !!articleId && !!paragraphId,
});
};

View File

@@ -24,6 +24,21 @@ async function getArticle(
return response.data; return response.data;
} }
async function getParagraph(
celexId: string,
article: number,
paragraph: number,
language: string
): Promise<string> {
console.debug(
`Fetching article ${article} paragraph ${paragraph} for CELEX ID ${celexId} in ${language} language`
);
const response = await axios.get<string>(
`${celexId}/articles/${article}/${paragraph}/${language}`
);
return response.data;
}
async function getArticleIds(celexId: string): Promise<number[]> { async function getArticleIds(celexId: string): Promise<number[]> {
console.debug(`Fetching article list for CELEX ID ${celexId}`); console.debug(`Fetching article list for CELEX ID ${celexId}`);
const response = await axios.get<number[]>(`${celexId}/articles`); const response = await axios.get<number[]>(`${celexId}/articles`);
@@ -39,4 +54,4 @@ async function getToc(
return response.data; return response.data;
} }
export { getArticle, getArticleIds, getToc }; export { getArticle, getArticleIds, getParagraph, getToc };

View File

@@ -1,10 +1,10 @@
import App from "../App"; import App from "../App";
import { useUrlSync } from "../hooks/urlSync"; import { useUrlSync } from "../hooks/urlSync";
import useNavState from "../store/navStore"; import useNavStore from "../store/navStore";
function MainView() { function MainView() {
useUrlSync(); useUrlSync();
const celexId = useNavState((state) => state.celexId); const celexId = useNavStore((state) => state.celexId);
if (!celexId) { if (!celexId) {
return <div>Error: No CELEX ID provided</div>; return <div>Error: No CELEX ID provided</div>;

View File

@@ -1,17 +1,19 @@
import { create } from "zustand"; import { create, StateCreator } from "zustand";
import { createSelectors } from "../util/zustand";
interface NavState { export type NavStore = {
celexId: string | null; celexId: string | null;
articleId: number | null; articleId: number | null;
setCelexId: (celexId: string) => void; setCelexId: (celexId: string) => void;
setArticleId: (articleId: number | null) => void; setArticleId: (articleId: number | null) => void;
} };
const useNavState = create<NavState>((set) => ({ const navStoreCreator: StateCreator<NavStore> = (set) => ({
celexId: null, celexId: "",
articleId: null, articleId: 1,
setCelexId: (celexId) => set({ celexId }), setCelexId: (celexId) => set({ celexId }),
setArticleId: (articleId) => set({ articleId }), setArticleId: (articleId) => set({ articleId }),
})); });
export default useNavState; const useNavStore = createSelectors(create<NavStore>()(navStoreCreator));
export default useNavStore;

View File

@@ -11,11 +11,11 @@ interface UIState {
const useUIStore = create<UIState>((set) => ({ const useUIStore = create<UIState>((set) => ({
numPanels: 1, numPanels: 1,
selectedParagraphId: null, selectedParagraphId: null,
addPanel: () => set((state) => ({ numPanels: state.numPanels + 1 })), addPanel: () => set((state) => ({ numPanels: state.numPanels + 1 })),
removePanel: () => removePanel: () =>
set((state) => ({ numPanels: Math.max(state.numPanels - 1, 1) })), set((state) => ({ numPanels: Math.max(state.numPanels - 1, 1) })),
setSelectedParagraphId: (selectedParagraphId: string | null) => setSelectedParagraphId: (selectedParagraphId) => set({ selectedParagraphId }),
set({ selectedParagraphId }),
})); }));
export default useUIStore; export default useUIStore;

View File

@@ -0,0 +1,18 @@
import { StoreApi, UseBoundStore } from "zustand";
type WithSelectors<S> = S extends { getState: () => infer T }
? S & { use: { [K in keyof T]: () => T[K] } }
: never;
export const createSelectors = <S extends UseBoundStore<StoreApi<object>>>(
_store: S
) => {
const store = _store as WithSelectors<typeof _store>;
store.use = {};
for (const k of Object.keys(store.getState())) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(store.use as any)[k] = () => store((s) => s[k as keyof typeof s]);
}
return store;
};

View File

@@ -24,5 +24,5 @@
"plugins": [{ "name": "typescript-plugin-css-modules" }] "plugins": [{ "name": "typescript-plugin-css-modules" }]
}, },
"include": ["src", "./jest.setup.ts"] "include": ["src", "./jest.setup.ts", "__mocks__"]
} }

View File

@@ -2,7 +2,7 @@ import html
import re import re
import warnings import warnings
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional, Union from typing import Literal, Optional, Union
import lxml.etree import lxml.etree
from lxml import etree as ET from lxml import etree as ET
@@ -29,9 +29,10 @@ def text_content(el: lxml.etree.Element) -> str:
@dataclass @dataclass
class CrossReference: class CrossReference:
id: str target: Literal["article", "annex"]
text: str text: str
target: str id: str
paragraph: int | None = None
def extract_xrefs(el: lxml.etree.Element, language: Language) -> list[CrossReference]: def extract_xrefs(el: lxml.etree.Element, language: Language) -> list[CrossReference]:
@@ -69,8 +70,8 @@ def extract_xrefs(el: lxml.etree.Element, language: Language) -> list[CrossRefer
# Also, match only at word boundaries to prevent partial matches # Also, match only at word boundaries to prevent partial matches
parts = PATTERN_PARTS[language] parts = PATTERN_PARTS[language]
patterns = { patterns = {
"article": rf"\b{parts["article"]}\s+(?P<art_num>\d+){parts["exclusion"]}\b", "article": rf"\b{parts["article"]}\s+(?P<art_num>\d+)(?:[(](?P<parag_num>\d+)[)])?(?:{parts["exclusion"]})",
"annex": rf"\b{parts["annex"]}\s+(?P<annex_num>[DILMVX]+){parts["exclusion"]}\b", "annex": rf"\b{parts["annex"]}\s+(?P<annex_num>[DILMVX]+)(?:{parts["exclusion"]})",
} }
for key, pattern in patterns.items(): for key, pattern in patterns.items():
matches = re.finditer(pattern, text, flags=re.IGNORECASE) matches = re.finditer(pattern, text, flags=re.IGNORECASE)
@@ -78,13 +79,54 @@ def extract_xrefs(el: lxml.etree.Element, language: Language) -> list[CrossRefer
crossref_id = ( crossref_id = (
match.group("art_num") if key == "article" else match.group("annex_num") match.group("art_num") if key == "article" else match.group("annex_num")
) )
parag_num = match.groupdict().get("parag_num")
crossref_text = match.group(0) crossref_text = match.group(0)
crossrefs.append( crossrefs.append(
CrossReference(id=crossref_id, text=crossref_text, target=key) CrossReference(
target=key,
id=crossref_id,
paragraph=parag_num,
text=crossref_text,
)
) )
return crossrefs return crossrefs
def extract_article(doc: ET.ElementBase, article_id: int) -> ET.ElementBase | None:
"""Extract a specific article from a Formex document.
Args:
doc: The XML document to extract from.
article_id: The article number.
Returns:
The extracted article element.
"""
# Use XPath to find the specific article
xpath = f".//ARTICLE[@IDENTIFIER='{article_id:03d}']"
return doc.xpath(xpath)[0] if doc.xpath(xpath) else None
def extract_paragraph(
doc: ET.ElementBase, article_id: int, paragraph_id: int
) -> ET.ElementBase | None:
"""Extract a specific paragraph from an article in a Formex document.
Args:
doc: The XML document to extract from.
article_id: The article number.
paragraph_id: The paragraph number.
Returns:
The extracted paragraph element.
"""
# Use XPath to find the specific paragraph
xpath = f".//PARAG[@IDENTIFIER='{article_id:03d}.{paragraph_id:03d}']"
return doc.xpath(xpath)[0] if doc.xpath(xpath) else None
class FormexArticleConverter: class FormexArticleConverter:
"""Converts Formex XML <ARTICLE> elements to semantic HTML5.""" """Converts Formex XML <ARTICLE> elements to semantic HTML5."""
@@ -136,7 +178,7 @@ class FormexArticleConverter:
# Replace the cross-reference text with a link # Replace the cross-reference text with a link
text = text.replace( text = text.replace(
xref.text, xref.text,
f'<a href="" data-target="{xref.target}" data-id="{xref.id}" class="cross-ref">{xref.text}</a>', f'<a href="" data-target="{xref.target}" data-id="{xref.id}" data-paragraph-id="{xref.paragraph or ''}" class="cross-ref">{xref.text}</a>',
) )
return text return text
@@ -418,10 +460,13 @@ class FormexArticleConverter:
article_subtitle = self._convert_btx(sti_art) if sti_art is not None else "" article_subtitle = self._convert_btx(sti_art) if sti_art is not None else ""
# Build the header section # Build the header section
header = f'<header><h3 class="article-title">{article_title}</h3>' if article_title and article_subtitle:
if article_subtitle: header = f'<header><h3 class="article-title">{article_title}</h3>'
header += f'<h4 class="article-subtitle">{article_subtitle}</h4>' if article_subtitle:
header += "</header>" header += f'<h4 class="article-subtitle">{article_subtitle}</h4>'
header += "</header>"
else:
header = ""
# Process the content based on what's present # Process the content based on what's present
content = "" content = ""

View File

@@ -2,7 +2,11 @@ import lxml.etree as ET
from fastapi import APIRouter, FastAPI, Response from fastapi import APIRouter, FastAPI, Response
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from formex_viewer.formex4 import FormexArticleConverter from formex_viewer.formex4 import (
FormexArticleConverter,
extract_article,
extract_paragraph,
)
from formex_viewer.main import ( from formex_viewer.main import (
CellarClient, CellarClient,
CellarIdentifier, CellarIdentifier,
@@ -121,21 +125,46 @@ def toc(celex_id: str, language: Language = Language.ENG):
@api_router.get("/{celex_id}/articles/{article_id}/{language}") @api_router.get("/{celex_id}/articles/{article_id}/{language}")
def article(celex_id: str, article_id: int, language: Language = Language.ENG): def article(
celex_id: str,
article_id: int,
language: Language = Language.ENG,
):
""" """
Fetch an article from the server. Fetch an article from the server.
""" """
xml = _get_fmx4_data(celex_id, language) xml = _get_fmx4_data(celex_id, language)
article = extract_article(xml, article_id=article_id)
article_xpath = "//ARTICLE" if article is None:
articles = xml.xpath(article_xpath) return Response(
for article in articles: "Article not found",
num = article.get("IDENTIFIER").lstrip("0") status_code=404,
if num == str(article_id): )
return Response(
FormexArticleConverter(language=language).convert_article(article), return Response(
media_type="text/html", FormexArticleConverter(language=language).convert_article(article),
) media_type="text/html",
)
@api_router.get("/{celex_id}/articles/{article_id}/{parag_id}/{language}")
def paragraph(
celex_id: str,
article_id: int,
parag_id: int,
language: Language = Language.ENG,
):
"""
Fetch a paragraph within an article from the server.
"""
xml = _get_fmx4_data(celex_id, language)
parag = extract_paragraph(xml, article_id=article_id, paragraph_id=parag_id)
return Response(
FormexArticleConverter(language=language).convert_article(parag),
media_type="text/html",
)
app.include_router(api_router, prefix="/api") app.include_router(api_router, prefix="/api")