Paragraph-level links, preview tooltips
This commit is contained in:
64
frontend/__mocks__/zustand.ts
Normal file
64
frontend/__mocks__/zustand.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
61
frontend/package-lock.json
generated
61
frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.27.8",
|
||||
"@tanstack/react-query": "^5.74.4",
|
||||
"@tanstack/react-query-devtools": "^5.74.6",
|
||||
"axios": "^1.9.0",
|
||||
@@ -18,6 +19,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.22.0",
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.73.3",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
@@ -1259,6 +1261,59 @@
|
||||
"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": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@@ -8129,6 +8184,12 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.27.8",
|
||||
"@tanstack/react-query": "^5.74.4",
|
||||
"@tanstack/react-query-devtools": "^5.74.6",
|
||||
"axios": "^1.9.0",
|
||||
@@ -21,6 +22,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.22.0",
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.73.3",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
|
||||
@@ -11,7 +11,8 @@ import CelexSelector from "./components/CelexSelector/CelexSelector";
|
||||
import { useTOC } from "./hooks/toc";
|
||||
|
||||
function App() {
|
||||
const { numPanels, addPanel } = useUIStore();
|
||||
const numPanels = useUIStore((state) => state.numPanels);
|
||||
const addPanel = useUIStore((state) => state.addPanel);
|
||||
const { data: toc, isPending, error } = useTOC();
|
||||
|
||||
if (isPending) {
|
||||
|
||||
@@ -32,10 +32,6 @@ describe("ArticleSelector", () => {
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders a top-level division as an optgroup", () => {
|
||||
const { getByRole } = render(<ArticleSelector toc={[mockDivision]} />);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Fragment, JSX } from "react";
|
||||
import type { Division } from "../../lib/types";
|
||||
import useNavState from "../../store/navStore";
|
||||
import useNavStore from "../../store/navStore";
|
||||
import styles from "./ArticleSelector.module.css";
|
||||
|
||||
type ArticleSelectorProps = {
|
||||
@@ -41,7 +41,8 @@ function renderDivision(div: Division): JSX.Element {
|
||||
}
|
||||
|
||||
function ArticleSelector({ toc }: ArticleSelectorProps) {
|
||||
const { articleId, setArticleId } = useNavState();
|
||||
const articleId = useNavStore.use.articleId();
|
||||
const setArticleId = useNavStore.use.setArticleId();
|
||||
|
||||
return (
|
||||
<select
|
||||
|
||||
@@ -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 useNavState from "../../store/navStore";
|
||||
import useNavStore from "../../store/navStore";
|
||||
import CelexSelector from "./CelexSelector";
|
||||
|
||||
jest.mock("../../store/navStore");
|
||||
|
||||
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", () => {
|
||||
render(<CelexSelector />);
|
||||
const { getByLabelText, getAllByRole, getByRole } = render(
|
||||
<CelexSelector />
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Select example:")).toBeInTheDocument();
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
expect(getByLabelText("Select example:")).toBeInTheDocument();
|
||||
expect(getByRole("combobox")).toBeInTheDocument();
|
||||
|
||||
const options = screen.getAllByRole("option");
|
||||
const options = getAllByRole("option");
|
||||
expect(options).toHaveLength(examples.length);
|
||||
for (const i in examples) {
|
||||
expect(options[i]).toHaveValue(examples[i].id);
|
||||
@@ -32,28 +20,15 @@ describe("CelexSelector", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("calls setCelexId and setArticleId on selection change", () => {
|
||||
const celexId = "32016R0679";
|
||||
render(<CelexSelector />);
|
||||
it("calls setCelexId and setArticleId on selection change", async () => {
|
||||
const celexId = examples[2].id;
|
||||
const { getByRole } = render(<CelexSelector />);
|
||||
|
||||
fireEvent.change(screen.getByRole("combobox"), {
|
||||
fireEvent.change(getByRole("combobox"), {
|
||||
target: { value: celexId },
|
||||
});
|
||||
|
||||
expect(mockSetCelexId).toHaveBeenCalledWith(celexId);
|
||||
expect(mockSetArticleId).toHaveBeenCalledWith(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);
|
||||
expect(useNavStore.getState().celexId).toEqual(celexId);
|
||||
expect(useNavStore.getState().articleId).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { examples } from "../../lib/examples";
|
||||
import useNavState from "../../store/navStore";
|
||||
import useNavStore from "../../store/navStore";
|
||||
|
||||
function CelexSelector() {
|
||||
const { celexId, setCelexId, setArticleId } = useNavState();
|
||||
const celexId = useNavStore.use.celexId();
|
||||
const setCelexId = useNavStore.use.setCelexId();
|
||||
const setArticleId = useNavStore.use.setArticleId();
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { fireEvent, render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { getArticle } from "../../lib/api";
|
||||
import { Language } from "../../lib/types";
|
||||
import useNavState from "../../store/navStore";
|
||||
import useNavStore from "../../store/navStore";
|
||||
import useUIStore from "../../store/uiStore";
|
||||
import Panel from "./Panel";
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("Panel Component", () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(useNavState).mockReturnValue(mockNavState);
|
||||
jest.mocked(useNavStore).mockReturnValue(mockNavState);
|
||||
jest.mocked(useUIStore).mockReturnValue(mockUseUIStore);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@ import useUIStore from "../../store/uiStore";
|
||||
import LanguageSwitcher from "../LanguageSwitcher/LanguageSwitcher";
|
||||
|
||||
import { useArticle } from "../../hooks/useArticle";
|
||||
import useNavState from "../../store/navStore";
|
||||
import useNavStore from "../../store/navStore";
|
||||
import "../../styles/PanelContent.css";
|
||||
import ParagraphPreview from "../ParagraphPreview/ParagraphPreview";
|
||||
import { Tooltip, TooltipContent } from "../Tooltip/Tooltip";
|
||||
import styles from "./Panel.module.css";
|
||||
|
||||
type PanelProps = {
|
||||
@@ -18,9 +20,13 @@ function Panel({ language }: PanelProps) {
|
||||
|
||||
const [lang, setLang] = useState(language || Language.ENG);
|
||||
const articleRef = useRef<HTMLDivElement>(null);
|
||||
const { articleId, celexId, setArticleId } = useNavState();
|
||||
const { articleId, celexId, setArticleId } = useNavStore();
|
||||
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(() => {
|
||||
const articleElement = articleRef.current;
|
||||
if (!articleElement) return;
|
||||
@@ -32,6 +38,7 @@ function Panel({ language }: PanelProps) {
|
||||
crossRefs.forEach((link) => {
|
||||
const target = link.getAttribute("data-target");
|
||||
const targetId = link.getAttribute("data-id");
|
||||
const paragraphId = link.getAttribute("data-paragraph-id");
|
||||
|
||||
if (target && targetId) {
|
||||
if (target === "article") {
|
||||
@@ -41,6 +48,17 @@ function Panel({ language }: PanelProps) {
|
||||
setArticleId(parseInt(targetId));
|
||||
return false;
|
||||
};
|
||||
if (paragraphId) {
|
||||
link.onmouseover = () => {
|
||||
setHoverArticleId(parseInt(targetId));
|
||||
setHoverParagraphId(parseInt(paragraphId));
|
||||
setIsTooltipOpen(true);
|
||||
};
|
||||
|
||||
link.onmouseout = () => {
|
||||
setIsTooltipOpen(false);
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn("No target or ID found for link:", link);
|
||||
@@ -83,18 +101,15 @@ function Panel({ language }: PanelProps) {
|
||||
// Cleanup event listeners
|
||||
return () => {
|
||||
console.log("Cleaning up event listeners");
|
||||
// crossRefs.forEach((link) => {
|
||||
// link.onmouseover = null;
|
||||
// link.onmouseout = null;
|
||||
// });
|
||||
paragraphs.forEach((element) => {
|
||||
element.removeEventListener("click", handleClick(element));
|
||||
});
|
||||
};
|
||||
}, [
|
||||
articleRef,
|
||||
data,
|
||||
celexId,
|
||||
selectedParagraphId,
|
||||
setSelectedParagraphId,
|
||||
setArticleId,
|
||||
]);
|
||||
});
|
||||
|
||||
if (isPending) return "Loading...";
|
||||
if (error) return "An error has occurred: " + error.message;
|
||||
@@ -105,6 +120,20 @@ function Panel({ language }: PanelProps) {
|
||||
defaultLang={lang}
|
||||
onChange={setLang}
|
||||
></LanguageSwitcher>
|
||||
<Tooltip
|
||||
open={isTooltipOpen}
|
||||
placement="right-start"
|
||||
onOpenChange={setIsTooltipOpen}
|
||||
>
|
||||
<TooltipContent>
|
||||
<ParagraphPreview
|
||||
celexId={celexId!}
|
||||
articleId={hoverArticleId!}
|
||||
paragraphId={hoverParagraphId!}
|
||||
lang={lang}
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div
|
||||
ref={articleRef}
|
||||
lang={lang.substring(0, 2)}
|
||||
|
||||
@@ -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;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Division } from "../../lib/types";
|
||||
import useNavState from "../../store/navStore";
|
||||
import useNavStore from "../../store/navStore";
|
||||
import styles from "./TOC.module.css";
|
||||
|
||||
type TOCProps = {
|
||||
@@ -8,7 +8,7 @@ type TOCProps = {
|
||||
};
|
||||
|
||||
function TOC({ toc }: TOCProps) {
|
||||
const { articleId, setArticleId } = useNavState();
|
||||
const { articleId, setArticleId } = useNavStore();
|
||||
|
||||
function containsArticle(division: Division, articleId: number): boolean {
|
||||
return division.content.some((c) => {
|
||||
|
||||
10
frontend/src/components/Tooltip/Tooltip.module.css
Normal file
10
frontend/src/components/Tooltip/Tooltip.module.css
Normal 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;
|
||||
}
|
||||
163
frontend/src/components/Tooltip/Tooltip.tsx
Normal file
163
frontend/src/components/Tooltip/Tooltip.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getToc } from "../lib/api";
|
||||
import { Language } from "../lib/types";
|
||||
import useNavState from "../store/navStore";
|
||||
import useNavStore from "../store/navStore";
|
||||
|
||||
export const useTOC = () => {
|
||||
const celexId = useNavState((state) => state.celexId);
|
||||
const celexId = useNavStore((state) => state.celexId);
|
||||
const query = useQuery({
|
||||
queryKey: ["toc", celexId],
|
||||
queryFn: () => getToc(celexId!, Language.ENG),
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import useNavState from "../store/navStore";
|
||||
import useNavStore from "../store/navStore";
|
||||
|
||||
export const useUrlSync = () => {
|
||||
const navigate = useNavigate();
|
||||
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
|
||||
useEffect(() => {
|
||||
|
||||
16
frontend/src/hooks/useParagraph.ts
Normal file
16
frontend/src/hooks/useParagraph.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
@@ -24,6 +24,21 @@ async function getArticle(
|
||||
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[]> {
|
||||
console.debug(`Fetching article list for CELEX ID ${celexId}`);
|
||||
const response = await axios.get<number[]>(`${celexId}/articles`);
|
||||
@@ -39,4 +54,4 @@ async function getToc(
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export { getArticle, getArticleIds, getToc };
|
||||
export { getArticle, getArticleIds, getParagraph, getToc };
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import App from "../App";
|
||||
import { useUrlSync } from "../hooks/urlSync";
|
||||
import useNavState from "../store/navStore";
|
||||
import useNavStore from "../store/navStore";
|
||||
|
||||
function MainView() {
|
||||
useUrlSync();
|
||||
const celexId = useNavState((state) => state.celexId);
|
||||
const celexId = useNavStore((state) => state.celexId);
|
||||
|
||||
if (!celexId) {
|
||||
return <div>Error: No CELEX ID provided</div>;
|
||||
|
||||
@@ -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;
|
||||
articleId: number | null;
|
||||
setCelexId: (celexId: string) => void;
|
||||
setArticleId: (articleId: number | null) => void;
|
||||
}
|
||||
};
|
||||
|
||||
const useNavState = create<NavState>((set) => ({
|
||||
celexId: null,
|
||||
articleId: null,
|
||||
const navStoreCreator: StateCreator<NavStore> = (set) => ({
|
||||
celexId: "",
|
||||
articleId: 1,
|
||||
setCelexId: (celexId) => set({ celexId }),
|
||||
setArticleId: (articleId) => set({ articleId }),
|
||||
}));
|
||||
});
|
||||
|
||||
export default useNavState;
|
||||
const useNavStore = createSelectors(create<NavStore>()(navStoreCreator));
|
||||
export default useNavStore;
|
||||
|
||||
@@ -11,11 +11,11 @@ interface UIState {
|
||||
const useUIStore = create<UIState>((set) => ({
|
||||
numPanels: 1,
|
||||
selectedParagraphId: null,
|
||||
|
||||
addPanel: () => set((state) => ({ numPanels: state.numPanels + 1 })),
|
||||
removePanel: () =>
|
||||
set((state) => ({ numPanels: Math.max(state.numPanels - 1, 1) })),
|
||||
setSelectedParagraphId: (selectedParagraphId: string | null) =>
|
||||
set({ selectedParagraphId }),
|
||||
setSelectedParagraphId: (selectedParagraphId) => set({ selectedParagraphId }),
|
||||
}));
|
||||
|
||||
export default useUIStore;
|
||||
|
||||
18
frontend/src/util/zustand.ts
Normal file
18
frontend/src/util/zustand.ts
Normal 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;
|
||||
};
|
||||
@@ -24,5 +24,5 @@
|
||||
|
||||
"plugins": [{ "name": "typescript-plugin-css-modules" }]
|
||||
},
|
||||
"include": ["src", "./jest.setup.ts"]
|
||||
"include": ["src", "./jest.setup.ts", "__mocks__"]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import html
|
||||
import re
|
||||
import warnings
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Union
|
||||
from typing import Literal, Optional, Union
|
||||
|
||||
import lxml.etree
|
||||
from lxml import etree as ET
|
||||
@@ -29,9 +29,10 @@ def text_content(el: lxml.etree.Element) -> str:
|
||||
|
||||
@dataclass
|
||||
class CrossReference:
|
||||
id: str
|
||||
target: Literal["article", "annex"]
|
||||
text: str
|
||||
target: str
|
||||
id: str
|
||||
paragraph: int | None = None
|
||||
|
||||
|
||||
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
|
||||
parts = PATTERN_PARTS[language]
|
||||
patterns = {
|
||||
"article": rf"\b{parts["article"]}\s+(?P<art_num>\d+){parts["exclusion"]}\b",
|
||||
"annex": rf"\b{parts["annex"]}\s+(?P<annex_num>[DILMVX]+){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"]})",
|
||||
}
|
||||
for key, pattern in patterns.items():
|
||||
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 = (
|
||||
match.group("art_num") if key == "article" else match.group("annex_num")
|
||||
)
|
||||
parag_num = match.groupdict().get("parag_num")
|
||||
crossref_text = match.group(0)
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
"""Converts Formex XML <ARTICLE> elements to semantic HTML5."""
|
||||
|
||||
@@ -136,7 +178,7 @@ class FormexArticleConverter:
|
||||
# Replace the cross-reference text with a link
|
||||
text = text.replace(
|
||||
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
|
||||
|
||||
@@ -418,10 +460,13 @@ class FormexArticleConverter:
|
||||
article_subtitle = self._convert_btx(sti_art) if sti_art is not None else ""
|
||||
|
||||
# Build the header section
|
||||
header = f'<header><h3 class="article-title">{article_title}</h3>'
|
||||
if article_subtitle:
|
||||
header += f'<h4 class="article-subtitle">{article_subtitle}</h4>'
|
||||
header += "</header>"
|
||||
if article_title and article_subtitle:
|
||||
header = f'<header><h3 class="article-title">{article_title}</h3>'
|
||||
if article_subtitle:
|
||||
header += f'<h4 class="article-subtitle">{article_subtitle}</h4>'
|
||||
header += "</header>"
|
||||
else:
|
||||
header = ""
|
||||
|
||||
# Process the content based on what's present
|
||||
content = ""
|
||||
|
||||
@@ -2,7 +2,11 @@ import lxml.etree as ET
|
||||
from fastapi import APIRouter, FastAPI, Response
|
||||
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 (
|
||||
CellarClient,
|
||||
CellarIdentifier,
|
||||
@@ -121,21 +125,46 @@ def toc(celex_id: str, language: Language = Language.ENG):
|
||||
|
||||
|
||||
@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.
|
||||
"""
|
||||
xml = _get_fmx4_data(celex_id, language)
|
||||
article = extract_article(xml, article_id=article_id)
|
||||
|
||||
article_xpath = "//ARTICLE"
|
||||
articles = xml.xpath(article_xpath)
|
||||
for article in articles:
|
||||
num = article.get("IDENTIFIER").lstrip("0")
|
||||
if num == str(article_id):
|
||||
return Response(
|
||||
FormexArticleConverter(language=language).convert_article(article),
|
||||
media_type="text/html",
|
||||
)
|
||||
if article is None:
|
||||
return Response(
|
||||
"Article not found",
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
return Response(
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user