From 20ca2e46406aeed0a7ed2193d709f53b72eac232 Mon Sep 17 00:00:00 2001 From: Piv94165 <106757110+Piv94165@users.noreply.github.com> Date: Thu, 11 Apr 2024 12:19:34 +0200 Subject: [PATCH] feat(frontend): enable advanced research (#463) * working on component querying backend * feat(frontend): link advanced research component with backend * feat(frontend): add parent, child, ancestor, descendant filter * feat(frontend): disable modified filter with comment * feat(frontend): add search results with pagination * feat(frontend): search expression is refactored by backend * refactor(frontend): replace search page, avoid useeffect and many rerenders * refactor(frontend): a file for each main component * feat(frontend): is:external and is:not:external filter * feat(frontend): add external filter * refactor(frontend): clean code, add error message if needed * lint * refactor(frontend): change multiple select filter behaviour * refactor(frontend): remove useless states and props * feat(frontend): unify design with select and inputs components only * refactor(frontend): refactor singleSelectFilter * feat: improve search results display (#484) * feat(frontend): make language selection for translations more intuitive (#461) The PR modifies the translations section of the edit entry page: * Changes "All languages" terminology to "Fallback translations" (fixes #458) * Adds an info alert if "en" (English) or "xx" (Fallback translations) is not the main language for an entry (fixes #457) / Fixes the fact that the alert message about changing the display name of a language appears even if the language had no translations and we add the first one (fixes #459) * Adds a "Show all existing translations" checkbox to see all the languages that currently have translations, with their translations Adds a possibility to "pin" languages to select them (so they stay in local storage and appear at the top for each entry), and a possibility to hide (unselect) these languages easily (with an icon next to their title) * Modifies the selection of new languages: I removed the "number of languages shown" button that had to be clicked to add a language, and created a "Show another language" button at the bottom of the section. Also, the dialog is now an autocomplete instead of a select, and you just type the languages that you want to add and see languages that are not selected, instead of seeing all current languages and being able to remove them. The autocomplete with the options is also automatically focused when opening the dialog. * Adds vite-plugin-svgr to easily import svg files in React --------- Co-authored-by: Charles Perier * feat: add property filter to search API (#456) * feat: add property filter to search API * chore: generate SDK * chore: Add info banners on the frontend (#473) * docs: add info banners * refactor: delete unnecessary components * fix: add line break * improve search results, and few other improvements * show translations instead of translated languages --------- Co-authored-by: Charles Perier Co-authored-by: Eric Nguyen * refactor(frontend): remove useless lines, add css * rewording * test --------- Co-authored-by: alice.juan Co-authored-by: Charles Perier <82757576+perierc@users.noreply.github.com> Co-authored-by: Charles Perier Co-authored-by: Eric Nguyen --- backend/editor/models/node_models.py | 1 - taxonomy-editor-frontend/src/App.tsx | 7 +- .../src/backend-types/types.ts | 7 - .../src/components/EntryNodesTableBody.tsx | 122 +++++++++ .../src/components/NodesTableBody.tsx | 48 ---- .../src/components/ResponsiveAppBar.tsx | 3 +- .../project/editentry/ListTranslations.tsx | 2 +- .../src/pages/project/root-nodes/index.tsx | 251 ------------------ .../src/pages/project/search/FilterInput.tsx | 43 +++ .../src/pages/project/search/FiltersArea.tsx | 184 +++++++++++++ .../project/search/MultipleSelectFilter.tsx | 92 +++++++ .../project/search/SearchExpressionInput.tsx | 56 ++++ .../pages/project/search/SearchResults.tsx | 219 +++++++-------- .../project/search/SingleSelectFilter.tsx | 78 ++++++ .../src/pages/project/search/index.tsx | 166 ++++++------ 15 files changed, 768 insertions(+), 511 deletions(-) create mode 100644 taxonomy-editor-frontend/src/components/EntryNodesTableBody.tsx delete mode 100644 taxonomy-editor-frontend/src/components/NodesTableBody.tsx delete mode 100644 taxonomy-editor-frontend/src/pages/project/root-nodes/index.tsx create mode 100644 taxonomy-editor-frontend/src/pages/project/search/FilterInput.tsx create mode 100644 taxonomy-editor-frontend/src/pages/project/search/FiltersArea.tsx create mode 100644 taxonomy-editor-frontend/src/pages/project/search/MultipleSelectFilter.tsx create mode 100644 taxonomy-editor-frontend/src/pages/project/search/SearchExpressionInput.tsx create mode 100644 taxonomy-editor-frontend/src/pages/project/search/SingleSelectFilter.tsx diff --git a/backend/editor/models/node_models.py b/backend/editor/models/node_models.py index 28542888..1958010e 100644 --- a/backend/editor/models/node_models.py +++ b/backend/editor/models/node_models.py @@ -30,7 +30,6 @@ class EntryNodeCreate(BaseModel): class EntryNode(BaseModel): id: str preceding_lines: list[str] - src_position: int main_language: str tags: dict[str, list[str]] properties: dict[str, str] diff --git a/taxonomy-editor-frontend/src/App.tsx b/taxonomy-editor-frontend/src/App.tsx index 25edfb78..514fd4df 100644 --- a/taxonomy-editor-frontend/src/App.tsx +++ b/taxonomy-editor-frontend/src/App.tsx @@ -7,12 +7,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createTheme, CssBaseline, ThemeProvider } from "@mui/material"; -import { RootNodesWrapper } from "./pages/project/root-nodes"; import { EditEntryWrapper } from "./pages/project/editentry"; import { ExportTaxonomyWrapper } from "./pages/project/export"; import { GoToProject } from "./pages/go-to-project"; import { Home } from "./pages/home"; -import { SearchNodeWrapper } from "./pages/project/search"; +import { AdvancedSearchForm } from "./pages/project/search"; import { StartProject } from "./pages/startproject"; import { Errors } from "./pages/project/errors"; import { ProjectPage, projectLoader } from "./pages/project"; @@ -58,7 +57,7 @@ const router = createBrowserRouter([ }, { path: "entry", - element: , + element: , }, { path: "entry/:id", @@ -66,7 +65,7 @@ const router = createBrowserRouter([ }, { path: "search", - element: , + element: , }, { path: "errors", diff --git a/taxonomy-editor-frontend/src/backend-types/types.ts b/taxonomy-editor-frontend/src/backend-types/types.ts index 8822f20e..0749c0e6 100644 --- a/taxonomy-editor-frontend/src/backend-types/types.ts +++ b/taxonomy-editor-frontend/src/backend-types/types.ts @@ -1,8 +1 @@ -export type NodeInfo = { - id: string; - is_external: boolean; -}; - -export type RootEntriesAPIResponse = Array; - export type ParentsAPIResponse = string[]; diff --git a/taxonomy-editor-frontend/src/components/EntryNodesTableBody.tsx b/taxonomy-editor-frontend/src/components/EntryNodesTableBody.tsx new file mode 100644 index 00000000..8500c8ec --- /dev/null +++ b/taxonomy-editor-frontend/src/components/EntryNodesTableBody.tsx @@ -0,0 +1,122 @@ +import { EntryNode } from "@/client"; +import { + Chip, + Stack, + TableBody, + TableCell, + TableRow, + Tooltip, + Typography, +} from "@mui/material"; +import { Link } from "react-router-dom"; +import ISO6391 from "iso-639-1"; +import { useEffect, useState } from "react"; +import { SHOWN_LANGUAGES_KEY } from "@/pages/project/editentry/ListTranslations"; + +type Props = { + entryNodes: EntryNode[]; + taxonomyName: string; + branchName: string; +}; + +const EntryTitle = ({ id }: { id: string }) => { + const languageCode = id.split(":", 1)[0]; + const languageName = ISO6391.getName(languageCode); + return ( + + {id.slice(languageCode.length + 1)} + + + + + ); +}; + +const getTranslations = ( + tags: Record, + shownLanguageCodes: string[] +) => { + const result: string[] = []; + + shownLanguageCodes.forEach((languageCode) => { + const languageName = + languageCode === "xx" + ? "Fallback translations" + : ISO6391.getName(languageCode); + const translations = tags[`tags_${languageCode}`]; + if (translations) { + result.push(`${languageName}: ${translations.join(", ")}`); + } + }); + + return result; +}; + +export const EntryNodesTableBody = ({ + entryNodes, + taxonomyName, + branchName, +}: Props) => { + const [shownLanguageCodes, setShownLanguageCodes] = useState([]); + + useEffect(() => { + // get shown languages from local storage if it exists else use main language + try { + const rawLocalStorageShownLanguages = + localStorage.getItem(SHOWN_LANGUAGES_KEY); + let localStorageShownLanguages: string[] | null = + rawLocalStorageShownLanguages + ? JSON.parse(rawLocalStorageShownLanguages) + : null; + // validate that shown languages is an array of strings and filter all items that are valid language codes + if ( + Array.isArray(localStorageShownLanguages) && + localStorageShownLanguages.every((item) => typeof item === "string") + ) { + localStorageShownLanguages = localStorageShownLanguages.filter( + (item) => { + return item === "xx" || ISO6391.validate(item); + } + ); + } else { + localStorageShownLanguages = []; + } + setShownLanguageCodes(localStorageShownLanguages); + } catch (e) { + // shown languages is an empty list, when we can't parse the local storage + console.log(e); + } + }, []); + + return ( + <> + + {entryNodes.map(({ id, isExternal, tags }) => ( + + + + + {isExternal && ( + + External Node + + )} + {getTranslations(tags, shownLanguageCodes).map((line, i) => ( + + {line} + + ))} + + + + ))} + + + ); +}; diff --git a/taxonomy-editor-frontend/src/components/NodesTableBody.tsx b/taxonomy-editor-frontend/src/components/NodesTableBody.tsx deleted file mode 100644 index 3b94c731..00000000 --- a/taxonomy-editor-frontend/src/components/NodesTableBody.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { - IconButton, - TableBody, - TableCell, - TableRow, - Typography, -} from "@mui/material"; -import EditIcon from "@mui/icons-material/Edit"; -import { Link } from "react-router-dom"; -import { NodeInfo } from "@/backend-types/types"; - -type Props = { - nodeInfos: NodeInfo[]; - taxonomyName: string; - branchName: string; -}; - -const NodesTableBody = ({ nodeInfos, taxonomyName, branchName }: Props) => { - return ( - <> - - {nodeInfos.map(({ id, is_external: isExternal }) => ( - - - {id} - {isExternal && ( - - External Node - - )} - - - - - - - - ))} - - - ); -}; - -export default NodesTableBody; diff --git a/taxonomy-editor-frontend/src/components/ResponsiveAppBar.tsx b/taxonomy-editor-frontend/src/components/ResponsiveAppBar.tsx index 4c4ab158..3a6e8134 100644 --- a/taxonomy-editor-frontend/src/components/ResponsiveAppBar.tsx +++ b/taxonomy-editor-frontend/src/components/ResponsiveAppBar.tsx @@ -26,7 +26,6 @@ const getDisplayedPages = ( const navUrlPrefix = `${params.taxonomyName}/${params.branchName}/`; return [ - { url: navUrlPrefix + "entry", translationKey: "Nodes" }, { url: navUrlPrefix + "search", translationKey: "Search" }, { url: navUrlPrefix + "export", translationKey: "Export" }, { url: navUrlPrefix + "errors", translationKey: "Errors" }, @@ -46,7 +45,7 @@ export const ResponsiveAppBar = () => { }; return ( - + {/* Mobile content */} diff --git a/taxonomy-editor-frontend/src/pages/project/editentry/ListTranslations.tsx b/taxonomy-editor-frontend/src/pages/project/editentry/ListTranslations.tsx index d09a5fbb..8bf6ca67 100644 --- a/taxonomy-editor-frontend/src/pages/project/editentry/ListTranslations.tsx +++ b/taxonomy-editor-frontend/src/pages/project/editentry/ListTranslations.tsx @@ -16,7 +16,7 @@ import { useMemo, useEffect, useState } from "react"; import ISO6391 from "iso-639-1"; import { TranslationTags } from "./TranslationTags"; -const SHOWN_LANGUAGES_KEY = "shownLanguages"; +export const SHOWN_LANGUAGES_KEY = "shownLanguages"; const getLanguageName = (languageCode: string): string => { if (languageCode === "xx") { diff --git a/taxonomy-editor-frontend/src/pages/project/root-nodes/index.tsx b/taxonomy-editor-frontend/src/pages/project/root-nodes/index.tsx deleted file mode 100644 index c677ecd8..00000000 --- a/taxonomy-editor-frontend/src/pages/project/root-nodes/index.tsx +++ /dev/null @@ -1,251 +0,0 @@ -import { useState } from "react"; -import { useParams } from "react-router-dom"; - -import { - Typography, - Snackbar, - Alert, - Box, - Stack, - IconButton, -} from "@mui/material"; -import Table from "@mui/material/Table"; -import TableBody from "@mui/material/TableBody"; -import TableCell from "@mui/material/TableCell"; -import TableContainer from "@mui/material/TableContainer"; -import TableHead from "@mui/material/TableHead"; -import TableRow from "@mui/material/TableRow"; -import AddBoxIcon from "@mui/icons-material/AddBox"; -import Dialog from "@mui/material/Dialog"; -import CircularProgress from "@mui/material/CircularProgress"; - -import CreateNodeDialogContent from "@/components/CreateNodeDialogContent"; -import { toTitleCase, createBaseURL } from "@/utils"; -import { greyHexCode } from "@/constants"; -import { - type RootEntriesAPIResponse, - type NodeInfo, -} from "@/backend-types/types"; -import NodesTableBody from "@/components/NodesTableBody"; -import { useQuery } from "@tanstack/react-query"; -import { DefaultService, Project, ProjectStatus } from "@/client"; - -type RootNodesProps = { - taxonomyName: string; - branchName: string; -}; - -const RootNodes = ({ taxonomyName, branchName }: RootNodesProps) => { - const [openCreateNodeDialog, setOpenCreateNodeDialog] = useState(false); - const [openCreateNodeSuccessSnackbar, setCreateNodeOpenSuccessSnackbar] = - useState(false); - - const baseUrl = createBaseURL(taxonomyName, branchName); - const rootNodesUrl = `${baseUrl}rootentries`; - - const { - data: info, - isPending: infoPending, - isError: infoIsError, - error: infoError, - } = useQuery({ - queryKey: [ - "getProjectInfoTaxonomyNameBranchProjectGet", - branchName, - taxonomyName, - ], - queryFn: async () => { - return await DefaultService.getProjectInfoTaxonomyNameBranchProjectGet( - branchName, - taxonomyName - ); - }, - refetchInterval: (d) => { - return d.state.status === "success" && - d.state.data?.status === ProjectStatus.LOADING - ? 1000 - : false; - }, - }); - - const { - data: nodes, - isPending, - isError, - error, - } = useQuery({ - queryKey: [rootNodesUrl], - queryFn: async () => { - const response = await fetch(rootNodesUrl); - if (!response.ok) { - throw new Error("Failed to fetch root nodes"); - } - return response.json(); - }, - // fetch root nodes after receiving project status - enabled: - !!info && - [ProjectStatus.OPEN, ProjectStatus.EXPORTED].includes(info.status), - }); - - let nodeInfos: NodeInfo[] = []; - if (nodes && nodes.length > 0) { - nodeInfos = nodes.map((node) => ({ - id: node[0].id, - is_external: node[0].is_external, - })); - } - - const handleCloseAddDialog = () => { - setOpenCreateNodeDialog(false); - }; - - const handleCloseSuccessSnackbar = () => { - setCreateNodeOpenSuccessSnackbar(false); - }; - - if (isError || infoIsError || !branchName || !taxonomyName) { - return ( - - - {error?.message ?? infoError?.message} - - - ); - } - - if (info && info["status"] === ProjectStatus.FAILED) { - return ( - - Parsing of the project has failed, rendering it uneditable. - - ); - } - - if (isPending || infoPending || !nodes) { - return ( - - - {info && info["status"] === ProjectStatus.LOADING && ( - - Taxonomy parsing may take several minutes, depending on the - complexity of the taxonomy being imported. - - )} - - ); - } - - return ( - - - Root Nodes: - - - - - - - Taxonomy Name - - - Branch Name - - - - - - {toTitleCase(taxonomyName ?? "")} - - - - {branchName} - - -
-
- - - Number of root nodes in taxonomy: {nodes.length} - - - {/* Table for listing all nodes in taxonomy */} - - - - - - - Nodes - - { - setOpenCreateNodeDialog(true); - }} - > - - - - - Action - - - - -
-
- - {/* Dialog box for adding nodes */} - - { - setOpenCreateNodeDialog(false); - setCreateNodeOpenSuccessSnackbar(true); - }} - /> - - - {/* Snackbar for acknowledgment of addition of node */} - - - The node has been successfully added! - - -
- ); -}; - -export const RootNodesWrapper = () => { - const { taxonomyName, branchName } = useParams(); - if (!taxonomyName || !branchName) - return ( - - Oops, something went wrong! Please try again later. - - ); - - return ; -}; diff --git a/taxonomy-editor-frontend/src/pages/project/search/FilterInput.tsx b/taxonomy-editor-frontend/src/pages/project/search/FilterInput.tsx new file mode 100644 index 00000000..b0e7e594 --- /dev/null +++ b/taxonomy-editor-frontend/src/pages/project/search/FilterInput.tsx @@ -0,0 +1,43 @@ +import { FormControl, InputLabel, OutlinedInput } from "@mui/material"; +import { Dispatch, SetStateAction, useState } from "react"; + +type FilterInputProps = { + label: string; + setQ: Dispatch>; + keySearchTerm: string; + setCurrentPage: Dispatch>; +}; + +export const FilterInput = ({ + label, + setQ, + keySearchTerm, + setCurrentPage, +}: FilterInputProps) => { + const [filterValue, setFilterValue] = useState(""); + const addFilter = (event: React.KeyboardEvent) => { + if (event.key === "Enter" && filterValue !== "") { + // If the filterValue includes a space, wrap it in double quotes. Ex : “en: paprika extract”, en:e101ii + const value = filterValue.includes(" ") + ? `"${filterValue}"` + : filterValue; + setCurrentPage(1); + setQ((prevQ) => `${prevQ} ${keySearchTerm}:${value}`); + event.preventDefault(); + setFilterValue(""); + } + }; + + return ( + + {label} + setFilterValue(event.target.value)} + onKeyDown={addFilter} + /> + + ); +}; diff --git a/taxonomy-editor-frontend/src/pages/project/search/FiltersArea.tsx b/taxonomy-editor-frontend/src/pages/project/search/FiltersArea.tsx new file mode 100644 index 00000000..79e0dc94 --- /dev/null +++ b/taxonomy-editor-frontend/src/pages/project/search/FiltersArea.tsx @@ -0,0 +1,184 @@ +import { Box } from "@mui/material"; +import { FilterInput } from "./FilterInput"; +import { MultipleSelectFilter } from "./MultipleSelectFilter"; +import { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useState, +} from "react"; +import ISO6391 from "iso-639-1"; +import { EntryNodeSearchResult } from "@/client"; +import { SingleSelectFilter } from "./SingleSelectFilter"; + +type FiltersAreaProps = { + setCurrentPage: Dispatch>; + setQ: Dispatch>; + filters: EntryNodeSearchResult["filters"]; +}; + +export const FiltersArea = ({ + setCurrentPage, + setQ, + filters, +}: FiltersAreaProps) => { + const [nodesLevel, setNodesLevel] = useState("root"); + const [taxonomyScope, setTaxonomyScope] = useState("both"); + const [chosenLanguagesCodes, setChosenLanguagesCodes] = useState( + [] + ); + const [withoutChosenLanguagesCodes, setWithoutChosenLanguagesCodes] = + useState([]); + + const initializeFilters = (): { + nodesLevel: string; + taxonomyScopeMode: string; //"in" -> in taxonomy, "out" -> outside taxonomy, "" -> filter not selected + chosenLanguagesCodes: string[]; + withoutChosenLanguagesCodes: string[]; + } => { + return { + nodesLevel: "root", + taxonomyScopeMode: "", + chosenLanguagesCodes: [], + withoutChosenLanguagesCodes: [], + }; + }; + + const updateFiltersStates = useCallback((updatedFilters) => { + const filtersStates = initializeFilters(); + let hasScopeFilterInQ = false; + let hasLevelFilterInQ = false; + for (const filter of updatedFilters) { + switch (filter.filterType) { + case "is": + switch (filter.filterValue) { + case "root": + filtersStates.nodesLevel = "root"; + hasLevelFilterInQ = true; + break; + case "external": + filtersStates.taxonomyScopeMode = "external"; + hasScopeFilterInQ = true; + break; + case "not:external": + filtersStates.taxonomyScopeMode = "not:external"; + hasScopeFilterInQ = true; + break; + } + break; + case "language": + if (filter.negated) { + filtersStates.withoutChosenLanguagesCodes.push( + filter.filterValue.replace("not:", "") + ); + } else { + filtersStates.chosenLanguagesCodes.push(filter.filterValue); + } + break; + default: + break; + } + } + setNodesLevel(hasLevelFilterInQ ? filtersStates.nodesLevel : "both"); + setTaxonomyScope( + hasScopeFilterInQ ? filtersStates.taxonomyScopeMode : "both" + ); + setChosenLanguagesCodes(filtersStates.chosenLanguagesCodes); + setWithoutChosenLanguagesCodes(filtersStates.withoutChosenLanguagesCodes); + }, []); + + useEffect(() => { + updateFiltersStates(filters); + }, [filters, updateFiltersStates]); + + const scopeOptions = { + "Only Outside Current Taxonomy": "external", + "Only In Current Taxonomy": "not:external", + "Both In and Outside Current Taxonomy": "both", + }; + + const levelOptions = { + "Top level entries": "root", + "All levels": "both", + }; + + return ( + + levelOptions[value]} + setQ={setQ} + keySearchTerm="is" + setCurrentPage={setCurrentPage} + /> + scopeOptions[value]} + setQ={setQ} + keySearchTerm="is" + setCurrentPage={setCurrentPage} + /> + + + + + + + + ); +}; diff --git a/taxonomy-editor-frontend/src/pages/project/search/MultipleSelectFilter.tsx b/taxonomy-editor-frontend/src/pages/project/search/MultipleSelectFilter.tsx new file mode 100644 index 00000000..4bad544b --- /dev/null +++ b/taxonomy-editor-frontend/src/pages/project/search/MultipleSelectFilter.tsx @@ -0,0 +1,92 @@ +import { + FormControl, + OutlinedInput, + Checkbox, + Select, + MenuItem, + InputLabel, + ListItemText, +} from "@mui/material"; +import { ChangeEvent, Dispatch, SetStateAction, useState } from "react"; + +type MultipleSelectFilterProps = { + label: string; + filterValue: string[]; + listOfChoices: string[]; + mapCodeToValue: (code: string) => string; + mapValueToCode: (value: string) => string; + setQ: Dispatch>; + keySearchTerm: string; + setCurrentPage: Dispatch>; +}; + +export const MultipleSelectFilter = ({ + label, + filterValue, + listOfChoices, + mapCodeToValue = () => "", + mapValueToCode = () => "", + setQ, + keySearchTerm, + setCurrentPage, +}: MultipleSelectFilterProps) => { + const [menuOpen, setMenuOpen] = useState(false); + + const handleChange = ( + event: ChangeEvent, + languageCodeItem: string + ) => { + setCurrentPage(1); + if (!filterValue.includes(languageCodeItem)) { + setQ((prevQ) => prevQ + ` ${keySearchTerm}:${languageCodeItem}`); + } else { + setQ((prevQ) => + prevQ.replace(`${keySearchTerm}:${languageCodeItem}`, "") + ); + } + setMenuOpen((prevMenuOpen) => !prevMenuOpen); + }; + + const handleSelectOpen = () => { + setMenuOpen(true); + }; + + const handleSelectClose = () => { + setMenuOpen(false); + }; + + return ( + + {label} + + + ); +}; diff --git a/taxonomy-editor-frontend/src/pages/project/search/SearchExpressionInput.tsx b/taxonomy-editor-frontend/src/pages/project/search/SearchExpressionInput.tsx new file mode 100644 index 00000000..d8244177 --- /dev/null +++ b/taxonomy-editor-frontend/src/pages/project/search/SearchExpressionInput.tsx @@ -0,0 +1,56 @@ +import { FormControl, OutlinedInput, IconButton } from "@mui/material"; +import SearchIcon from "@mui/icons-material/Search"; +import { Dispatch, SetStateAction } from "react"; + +type SearchExpressionInputType = { + searchExpression: string; + setSearchExpression: Dispatch>; + setCurrentPage: Dispatch>; + setQ: Dispatch>; +}; + +export const SearchExpressionInput = ({ + searchExpression, + setSearchExpression, + setCurrentPage, + setQ, +}: SearchExpressionInputType) => { + const handleEnterKeyPress = ( + event: React.KeyboardEvent + ) => { + setCurrentPage(1); + if (event.key === "Enter") { + handleSearch(); + event.preventDefault(); + } + }; + + const handleSearch = () => { + setQ(searchExpression); + }; + + const handleSearchInputChange = ( + event: React.ChangeEvent + ) => { + setSearchExpression(event.target.value); + }; + + return ( + + + + + + + ); +}; diff --git a/taxonomy-editor-frontend/src/pages/project/search/SearchResults.tsx b/taxonomy-editor-frontend/src/pages/project/search/SearchResults.tsx index e5edaacc..1e9992f6 100644 --- a/taxonomy-editor-frontend/src/pages/project/search/SearchResults.tsx +++ b/taxonomy-editor-frontend/src/pages/project/search/SearchResults.tsx @@ -1,61 +1,57 @@ -import CircularProgress from "@mui/material/CircularProgress"; -import { useState } from "react"; - +import CreateNodeDialogContent from "@/components/CreateNodeDialogContent"; +import { EntryNodesTableBody } from "@/components/EntryNodesTableBody"; +import { greyHexCode } from "@/constants"; import { + TableContainer, + Paper, + Table, + TableHead, + TableRow, + Stack, + TableCell, Typography, - Snackbar, + IconButton, + Dialog, Alert, - Box, Grid, - Stack, - IconButton, - Paper, + Snackbar, + TablePagination, + Box, + CircularProgress, + Container, } from "@mui/material"; -import Container from "@mui/material/Container"; -import Table from "@mui/material/Table"; -import TableCell from "@mui/material/TableCell"; -import TableContainer from "@mui/material/TableContainer"; -import TableHead from "@mui/material/TableHead"; -import TableRow from "@mui/material/TableRow"; +import { Dispatch, SetStateAction, useState } from "react"; import AddBoxIcon from "@mui/icons-material/AddBox"; -import Dialog from "@mui/material/Dialog"; - -import useFetch from "@/components/useFetch"; -import { createBaseURL } from "@/utils"; -import { greyHexCode } from "@/constants"; +import { useParams } from "react-router-dom"; +import { EntryNode } from "@/client"; -import CreateNodeDialogContent from "@/components/CreateNodeDialogContent"; -import NodesTableBody from "@/components/NodesTableBody"; -import { EntryNodeSearchResult } from "@/client"; - -type Props = { - query: string; - taxonomyName: string; - branchName: string; +type SearchResultsType = { + entryNodes: EntryNode[]; + nodeCount: number | undefined; + currentPage: number; + setCurrentPage: Dispatch>; + isError: boolean; + errorMessage: string; + isPending: boolean; }; -const SearchResults = ({ query, taxonomyName, branchName }: Props) => { +export const SearchResults = ({ + entryNodes, + nodeCount = 0, + currentPage, + setCurrentPage, + isError, + errorMessage, + isPending, +}: SearchResultsType) => { + const { taxonomyName, branchName } = useParams() as unknown as { + taxonomyName: string; + branchName: string; + }; + const [openNewNodeDialog, setOpenNewNodeDialog] = useState(false); const [showNewNodeSuccess, setShowNewNodeSuccess] = useState(false); - const baseUrl = createBaseURL(taxonomyName, branchName); - const { - data: result, - isPending, - isError, - errorMessage, - } = useFetch( - `${baseUrl}nodes/entry?q=${encodeURI(query)}` - ); - - const nodes = result?.nodes; - const nodeInfos = nodes?.map((node) => { - return { - id: node.id, - is_external: node.isExternal, - }; - }); - const handleCloseAddDialog = () => { setOpenNewNodeDialog(false); }; @@ -64,6 +60,26 @@ const SearchResults = ({ query, taxonomyName, branchName }: Props) => { setShowNewNodeSuccess(false); }; + const handlePageChange = ( + _event: React.MouseEvent | null, + newPage: number + ) => { + setCurrentPage(newPage + 1); + }; + + const SearchTablePagination = () => ( + + ); + // Displaying errorMessages if any if (isError) { return ( @@ -108,29 +124,21 @@ const SearchResults = ({ query, taxonomyName, branchName }: Props) => { } return ( - - - - Search Results - - - Number of nodes found:{" "} - {`${result?.nodeCount} | pages: ${result?.pageCount}`} - - {/* Table for listing all nodes in taxonomy */} - - - - + + + +
+ + + - - Nodes - + Entries { @@ -140,51 +148,46 @@ const SearchResults = ({ query, taxonomyName, branchName }: Props) => { - - Action - - - - -
-
- - {/* Dialog box for adding nodes */} - - + + + { - setOpenNewNodeDialog(false); - setShowNewNodeSuccess(true); - }} /> - + + + - {/* Snackbar for acknowledgment of addition of node */} - + { + setOpenNewNodeDialog(false); + setShowNewNodeSuccess(true); + }} + /> + + {/* Snackbar for acknowledgment of addition of node */} + + - - The node has been successfully added! - - -
-
+ The node has been successfully added! + + + ); }; - -export default SearchResults; diff --git a/taxonomy-editor-frontend/src/pages/project/search/SingleSelectFilter.tsx b/taxonomy-editor-frontend/src/pages/project/search/SingleSelectFilter.tsx new file mode 100644 index 00000000..1ff1fa01 --- /dev/null +++ b/taxonomy-editor-frontend/src/pages/project/search/SingleSelectFilter.tsx @@ -0,0 +1,78 @@ +import { + FormControl, + Select, + MenuItem, + InputLabel, + ListItemText, +} from "@mui/material"; +import { ChangeEvent, Dispatch, SetStateAction, useState } from "react"; + +type SingleSelectFilterType = { + label: string; + filterValue: string; + listOfChoices: string[]; + mapValueToCode: (value: string) => string; + setQ: Dispatch>; + keySearchTerm: string; + setCurrentPage: Dispatch>; +}; + +export const SingleSelectFilter = ({ + label, + filterValue, + listOfChoices, + mapValueToCode = () => "", + setQ, + keySearchTerm, + setCurrentPage, +}: SingleSelectFilterType) => { + const [menuOpen, setMenuOpen] = useState(false); + + const handleChange = (event: ChangeEvent) => { + const codeItem = event.target.value; + setCurrentPage(1); + if (filterValue !== codeItem) { + setQ((prevQ) => { + let newQ = prevQ; + if (codeItem !== "both") { + newQ += ` ${keySearchTerm}:${codeItem}`; // add new filter value + } + newQ = newQ.replace(`${keySearchTerm}:${filterValue}`, ""); //remove potential previous filter value + return newQ; + }); + } + setMenuOpen((prevMenuOpen) => !prevMenuOpen); + }; + + const handleSelectOpen = () => { + setMenuOpen(true); + }; + + const handleSelectClose = () => { + setMenuOpen(false); + }; + + return ( + + {label} + + + ); +}; diff --git a/taxonomy-editor-frontend/src/pages/project/search/index.tsx b/taxonomy-editor-frontend/src/pages/project/search/index.tsx index 4c4af7e7..8cd2580a 100644 --- a/taxonomy-editor-frontend/src/pages/project/search/index.tsx +++ b/taxonomy-editor-frontend/src/pages/project/search/index.tsx @@ -1,99 +1,87 @@ -import { useState } from "react"; -import { useParams } from "react-router-dom"; +import { useParams, useSearchParams } from "react-router-dom"; +import { useEffect, useState } from "react"; +import { Box } from "@mui/material"; +import { DefaultService } from "@/client"; -import { - Typography, - Box, - TextField, - Grid, - IconButton, - InputAdornment, -} from "@mui/material"; -import SearchIcon from "@mui/icons-material/Search"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { SearchResults } from "./SearchResults"; +import { SearchExpressionInput } from "./SearchExpressionInput"; +import { FiltersArea } from "./FiltersArea"; -import SearchResults from "./SearchResults"; -import { ENTER_KEYCODE } from "@/constants"; -import classificationImgUrl from "@/assets/classification.png"; +export const AdvancedSearchForm = () => { + const { taxonomyName, branchName } = useParams() as unknown as { + taxonomyName: string; + branchName: string; + }; -type SearchNodeProps = { - taxonomyName: string; - branchName: string; -}; + const [searchParams, setSearchParams] = useSearchParams(); -const SearchNode = ({ taxonomyName, branchName }: SearchNodeProps) => { - const [searchInput, setSearchInput] = useState(""); - const [queryFetchString, setQueryFetchString] = useState(""); + const [q, setQ] = useState(searchParams.get("q") ?? ""); + const pageParam = searchParams.get("page"); - return ( - - - Search - -
{ - event.preventDefault(); - setQueryFetchString(searchInput.trim()); - }} - > - - - - - - ), - }} - onKeyDown={(e) => { - if (e.keyCode === ENTER_KEYCODE && searchInput.length !== 0) { - setQueryFetchString(searchInput.trim()); - } - }} - onChange={(event) => { - setSearchInput(event.target.value); - }} - value={searchInput} - /> - -
- {queryFetchString !== "" && ( - - )} -
+ const [searchExpression, setSearchExpression] = useState(q); + const [currentPage, setCurrentPage] = useState( + parseInt(pageParam ?? "1") ); -}; -export const SearchNodeWrapper = () => { - const { taxonomyName, branchName } = useParams(); + const { + data: entryNodeSearchResult, + isError, + isPending, + error, + } = useQuery({ + queryKey: [ + "searchEntryNodesTaxonomyNameBranchNodesEntryGet", + branchName, + taxonomyName, + q, + currentPage, + ], + queryFn: async () => { + const nodesResult = + await DefaultService.searchEntryNodesTaxonomyNameBranchNodesEntryGet( + branchName, + taxonomyName, + q, + currentPage + ); + return nodesResult; + }, + placeholderData: keepPreviousData, + }); - if (!taxonomyName || !branchName) - return ( - - Oops, something went wrong! Please try again later. - - ); + useEffect(() => { + if (entryNodeSearchResult?.q !== undefined) { + setSearchExpression(entryNodeSearchResult.q); + setSearchParams((prevSearchParams) => ({ + ...prevSearchParams, + q: entryNodeSearchResult.q, + })); + } + }, [entryNodeSearchResult?.q, setSearchParams]); - return ; + return ( + + + + + + ); };