diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index a7b427ce..b3d8fe26 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -20,8 +20,9 @@ function Page() { ]; const [searchWords, setSearchWords] = useState([]); - const [visibleAccordions, setVisibleAccordions] = useState>(new Set()); + // maintain a set of accordions with search results to be able to show "No results" if there are none + const [visibleAccordions, setVisibleAccordions] = useState>(new Set()); const handleVisibilityChange = useCallback( (key: string, isVisible: boolean) => setVisibleAccordions((prevState) => { diff --git a/src/components/About/ExternalLink.tsx b/src/components/About/ExternalLink.tsx deleted file mode 100644 index c3a6435c..00000000 --- a/src/components/About/ExternalLink.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Link } from '@nextui-org/link'; -import clsx from 'clsx'; -import React, { ReactNode } from 'react'; - -function ExternalLink({ href, children, className }: { href: string; children: ReactNode; className?: string }) { - return ( - - {children} - - ); -} - -export default ExternalLink; diff --git a/src/components/About/LiveSuperscript.tsx b/src/components/About/LiveSuperscript.tsx index 4b6491eb..6f7435fa 100644 --- a/src/components/About/LiveSuperscript.tsx +++ b/src/components/About/LiveSuperscript.tsx @@ -1,5 +1,8 @@ import React from 'react'; +/** + * Returns the "LIVE" letters in superscript as used for "Hungermap LIVE". Do not insert a preceding space. + */ export function LiveSuperscript() { return LIVE; } diff --git a/src/components/About/StyledLink.tsx b/src/components/About/StyledLink.tsx index b6013354..594753a3 100644 --- a/src/components/About/StyledLink.tsx +++ b/src/components/About/StyledLink.tsx @@ -2,6 +2,16 @@ import { Link } from '@nextui-org/link'; import clsx from 'clsx'; import React, { ReactNode } from 'react'; +/** + * Display a custom styled NextUI link. + * + * External links are opened in a new tab and marked with `target="_blank"` and `rel="noopener noreferrer`. + * + * @param {string} href string to point to + * @param {ReactNode} children the clickable content (typically some text) + * @param {string} className custom class names + * @param {boolean} internal whether the link is internal to the HungerMap site + */ function StyledLink({ href, children, diff --git a/src/components/Search/DocsSearchBar.tsx b/src/components/Search/DocsSearchBar.tsx index c425b103..75c057b3 100644 --- a/src/components/Search/DocsSearchBar.tsx +++ b/src/components/Search/DocsSearchBar.tsx @@ -8,8 +8,7 @@ import { getSearchWords } from '@/utils/searchUtils'; /** * Show a search bar that synchronizes with the `?search=...` query param. * - * See `SearchOperations.tsx` for information on how to add the search feature to a new page. - * @param setSearchWords Function to call if the search input changes. + * @param {(searchWords: string[]) => void} setSearchWords Function to call if the search input changes. */ function DocsSearchBar({ setSearchWords }: DocsSearchBarProps) { const [search, setSearch] = useSearchQuery(); diff --git a/src/components/Search/RecursiveHighlighter.tsx b/src/components/Search/RecursiveHighlighter.tsx index 0cee95c0..2f06d762 100644 --- a/src/components/Search/RecursiveHighlighter.tsx +++ b/src/components/Search/RecursiveHighlighter.tsx @@ -11,6 +11,8 @@ import { getSearchWords } from '@/utils/searchUtils'; * * if `children` is a React Element (e.g. p or div): create the element and continue within its children (if exist) * * if `children` is an array (i.e. there are multiple children): deal with each item recursively * * if `children` is a string: wrap it with a `Highlighter` component + * + * @param {string | React.ReactElement | string[] | undefined} children content that should be made highlightable */ function RecursiveHighlighter({ children }: RecursiveHighlighterProps) { const searchParams = useSearchParams(); diff --git a/src/components/Search/SearchableSection.tsx b/src/components/Search/SearchableSection.tsx index cd9c8185..bdfa8166 100644 --- a/src/components/Search/SearchableSection.tsx +++ b/src/components/Search/SearchableSection.tsx @@ -9,13 +9,11 @@ import { filterSearchableItems } from '@/utils/searchUtils'; * Wrap the provided searchable elements into a section. * If there is an ongoing search (i.e. `searchWords.length > 0`) with no results in this section, hide it. * - * See `SearchOperations.tsx` for information on how to add the search feature to a new page. - * - * @param heading Heading of the section. Will be hidden if the other elements contain no results during search. - * @param textElements Array of text elements to display. Only elements with results will be shown during search. - * @param accordionItems Array of accordion items to display. Only items with results will be shown during search. - * @param searchWords - * @param onVisibilityChange is being called with the current visibility of the section (true = visible) + * @param {string | undefined} heading Heading of the section. Will be hidden if the other elements contain no results during search. + * @param {SearchableElement[] | undefined} textElements Array of text elements to display. Only elements with results will be shown during search. + * @param {SearchableAccordionItemProps[] | undefined} accordionItems Array of accordion items to display. Only items with results will be shown during search. + * @param {string[]} searchWords If not empty, filter the section content by occurrence of these words. + * @param {((isVisible: boolean) => void) | undefined} onVisibilityChange is being called with the current visibility of the section (true = visible) */ function SearchableSection({ heading, diff --git a/src/components/Table/CustomTable.tsx b/src/components/Table/CustomTable.tsx index 508a2176..dea4f576 100644 --- a/src/components/Table/CustomTable.tsx +++ b/src/components/Table/CustomTable.tsx @@ -12,6 +12,37 @@ import { getTableCell } from '@/operations/tables/groupedTableOperations'; import tableFormatters from '@/operations/tables/tableFormatters'; import { getSearchWords } from '@/utils/searchUtils'; +/** + * Renders a customizable table component with support for advanced features such as + * grouped data, zebra rows, borders, and search filtering. Utilizes the `@nextui-org/table` + * library for the base table structure. + * + * @template D - The type of the data used in the table rows. + * + * @param {Array<{ columnId: string, label: string, alignLeft?: boolean }>} props.columns The configuration for the table columns. Each column must have a unique `columnId` and a display `label`. Optionally, `alignLeft` can be set to true for left alignment. + * @param {D[]} props.data - The raw data used to generate the table rows. + * @param {string} props.ariaLabel - The accessibility label for the table. + * @param {string} [props.className] - An optional custom CSS class for the table container. + * @param {string} [props.format='simple'] - The formatting mode for the table. Should be a key of `tableFormatters`. Defaults to `simple`. + * @param {boolean} [props.showBorders=true] - A flag to enable or disable borders around the table. + * Defaults to `true`. + * @param {boolean} [props.zebraRows=true] - A flag to enable zebra striping for table rows. + * Defaults to `true`. + * + * @returns {JSX.Element} A fully styled and interactive table component. + * + * @example + * // Basic usage + * const columns = [ + * { columnId: 'name', label: 'Name' }, + * { columnId: 'age', label: 'Age', alignLeft: true }, + * ]; + * const data = [ + * { name: 'Alice', age: 25 }, + * { name: 'Bob', age: 30 }, + * ]; + * ; + */ function CustomTable({ columns, data, @@ -21,23 +52,30 @@ function CustomTable({ showBorders = true, zebraRows = true, }: CustomTableProps) { + // Extract search terms from the URL query parameters const searchWords = getSearchWords(useSearchParams().get('search') ?? ''); + + // Determine the formatting function based on the provided format const formattingFunction = tableFormatters[format] as (d: D) => CustomTableData; + // Process the data into rows, applying group and formatting logic let rows = formattingFunction(data).flatMap(({ groupKey, groupName, attributeRows, containedWords }) => attributeRows.map((row, index) => ({ - index, - groupKey, - groupLength: attributeRows.length, + index, // Row index within the group + groupKey, // Identifier for the group the row belongs to + groupLength: attributeRows.length, // Total rows in the group cellContents: { - keyColumn: groupName, - ...row, + keyColumn: groupName, // Group name for the key column + ...row, // Spread other row data }, - containedWords, + containedWords, // Words contained in the group })) ) as CustomTableRow[]; if (format === 'dataSources' && searchWords.length) { + // If this is a data sources table and a search is ongoing: + // a) Show the whole table if the aria label is matching with the search + // b) Filter to the rows with matches otherwise const noLabelMatch = searchWords.every((w) => !ariaLabel?.toLowerCase().includes(w)); if (noLabelMatch) { rows = rows.filter((row) => searchWords.some((w) => row.containedWords?.includes(w))); @@ -56,15 +94,15 @@ function CustomTable({ })} classNames={{ base: clsx({ - 'border-2 rounded-xl dark:border-default-200': showBorders, + 'border-2 rounded-xl dark:border-default-200': showBorders, // Add border styles if enabled 'min-w-[400px]': true, // Force horizontal scroll by setting a large min-width }), thead: clsx({ - '[&>tr:last-child]:hidden': true, - 'bg-background dark:bg-chatbotUserMsg': zebraRows, + '[&>tr:last-child]:hidden': true, // Hide the last child row in the header + 'bg-background dark:bg-chatbotUserMsg': zebraRows, // Add zebra styling for headers }), tr: clsx({ - 'even:bg-background dark:even:bg-chatbotUserMsg': zebraRows, + 'even:bg-background dark:even:bg-chatbotUserMsg': zebraRows, // Apply zebra striping to rows }), }} > @@ -73,8 +111,8 @@ function CustomTable({ {column.label} diff --git a/src/components/Tooltip/Abbreviation.tsx b/src/components/Tooltip/Abbreviation.tsx index c6fc9c81..06eddae9 100644 --- a/src/components/Tooltip/Abbreviation.tsx +++ b/src/components/Tooltip/Abbreviation.tsx @@ -8,6 +8,13 @@ import { getSearchWords } from '@/utils/searchUtils'; import CustomInfoCircle from '../CustomInfoCircle/CustomInfoCircle'; +/** + * Render an abbreviation underlined and with a small info icon. Show a tooltip with the long form on hover. + * + * @param {string} abbreviation a key from the object in `domain/constant/abbreviations` + * @param {boolean | undefined} searchable whether to highlight the abbreviation based on the `?search=...` query param + * @constructor + */ function Abbreviation({ abbreviation, searchable = true }: { abbreviation: string; searchable?: boolean }) { const searchParams = useSearchParams(); let searchWords: string[] = []; diff --git a/src/domain/hooks/queryParamsHooks.ts b/src/domain/hooks/queryParamsHooks.ts index 8e08be88..0ec2fbe5 100644 --- a/src/domain/hooks/queryParamsHooks.ts +++ b/src/domain/hooks/queryParamsHooks.ts @@ -36,6 +36,14 @@ export const useSelectedCountries = (countryMapData: CountryMapDataWrapper) => { return [selectedCountries, setSelectedCountriesFn] as const; }; +/** + * Return a delayed version of `input` that only changes after `input` has been constant for `msDelay` Milliseconds. + * This is useful for not triggering an event while a user is typing. + * + * @param {string} input The raw user input + * @param {number} msDelay Number of milliseconds without input changes that leads to a changed the output + * @return {string} debounced input + */ const useDebounce = (input: string, msDelay: number) => { const [output, setOutput] = useState(input); @@ -54,6 +62,7 @@ const useDebounce = (input: string, msDelay: number) => { * Updates to the query params happen in a debounced way to keep the browser history clean. * * Note: It is assumed that there is only one relevant query param, any others will be erased on change. + * @return {[string, (newValue: string) => void]} the current (non-debounced) query and a function to update the query */ export const useSearchQuery = () => { const PARAM_NAME = 'search'; diff --git a/src/operations/Search/SearchOperations.tsx b/src/operations/Search/SearchOperations.tsx index d99e116b..b3d07192 100644 --- a/src/operations/Search/SearchOperations.tsx +++ b/src/operations/Search/SearchOperations.tsx @@ -6,57 +6,12 @@ import { AccordionItemProps, SearchableAccordionItemProps } from '@/domain/entit import DataSourceDescription, { DataSourceDescriptionItems } from '@/domain/entities/dataSources/DataSourceDescription'; import { SearchableElement } from '@/domain/props/SearchableSectionProps'; -/** - *

Adding the Search Feature to a New Page

- *

Demo Code

- * ```tsx - * // demoItems.ts - * const demoItems = [ - * { title: 'Demo 1', content: 'a' }, - * { title: 'Demo 2', content: 'b' }, - * ]; - * export const SearchOperations.makeAccordionItemsSearchable(demoItems); - * ``` - * ```tsx - * // page.tsx - * function Page() { - * const [searchWords, setSearchWords] = useState([]); - * const [sectionIsVisible, setSectionIsVisible] = useState(true); - * return ( - * <> - * }> - * - * - * {!searchWords.length &&

Demo Page

} - * - * {!sectionIsVisible && !!searchWords.length &&

No results

} - * - * ); - * } - * ``` - *

Recommended Approach

- * * store all logical units of the content (e.g. accordion items, paragraphs, table lines) into an array - * * apply the fitting `makeXxxSearchable` function from this class - * * put the result into the `SearchableSection` component - * * add `DocsSearchBar` to the current page - * - *

Extended / Customized Approach

- * * store all logical units of the content (e.g. accordion items, paragraphs, table lines) into an array - * * convert them into objects with a `containedWords` field - * * `containedWords` should be a string of all contained lowercase words, ideally without unnecessary whitespace or repetitions - * * filter that array using `filterSearchableItems(...)` in `searchUtils.ts` - * * store the current search query in a query param `?search=...`, e.g. using the hook `useSearchQuery` - * * wrap components containing text content with a `RecursiveHighlighter` component - */ export class SearchOperations { /** * For each element, wrap the text contents of its components with a `Highlighter` component. In addition, store all contained words into `containedWords`. * - * @param textElements Elements to deal with. This value should never change since the array indices are used as keys. + * @param {ReactElement[]} textElements Elements to deal with. This value should never change since the array indices are used as keys. + * @return {SearchableElement[]} text elements with an additional `containedWords` prop */ static makeTextElementsSearchable(textElements: ReactElement[]): SearchableElement[] { return textElements.map((item, index) => { @@ -74,7 +29,9 @@ export class SearchOperations { /** * Find all contained words for an accordion of tables and store them into the respective `containedWords` fields. - * @param items Accordion items where every `content` field is assumed to contain a `CustomTable`. + * + * @param {AccordionItemProps[]} items Accordion items where every `content` field is assumed to contain a `CustomTable`. + * @return {SearchableAccordionItemProps[]} Accordion items with an additional `containedWords` field */ static makeDataSourceAccordionSearchable(items: AccordionItemProps[]): SearchableAccordionItemProps[] { return items.map((item) => { @@ -88,6 +45,9 @@ export class SearchOperations { /** * For each item, wrap the text contents of its components with a `Highlighter` component. In addition, store all contained words into `containedWords`. + * + * @param {AccordionItemProps[]} items Accordion items to deal with + * @return {SearchableAccordionItemProps[]} Accordion items with an additional `containedWords` prop */ static makeAccordionItemsSearchable(items: AccordionItemProps[]): SearchableAccordionItemProps[] { return items.map((item) => { @@ -105,6 +65,9 @@ export class SearchOperations { /** * Put all contained words from a table row into a lowercase string. + * + * @param {DataSourceDescription} item A single row from the data source table + * @return {string} All contained words prepared for search */ static sanitizeTableRow(item: DataSourceDescription): string { return SearchOperations.sanitizeText( @@ -119,6 +82,9 @@ export class SearchOperations { /** * Put all contained words from an accordion item into a lowercase string. + * + * @param {AccordionItemProps} item A single accordion item + * @return {string} All contained words from title and description prepared for search */ private static sanitizeAccordionItem(item: AccordionItemProps): string { return SearchOperations.sanitizeText( @@ -128,6 +94,9 @@ export class SearchOperations { /** * Convert a React Node into a string, omitting component names and props. + * + * @param {ReactNode} item The react node to deal with + * @return {string} A string of the rendered node without component names and props */ private static sanitizeReactNode(item: ReactNode): string { if (item === undefined) return ''; @@ -140,6 +109,9 @@ export class SearchOperations { /** * Turn an arbitrary text into a string of the contained words that is lowercase, without redundant whitespace and has no duplicate words. + * + * @param {string} text An arbitrary text + * @return {string} The text prepared for search as described */ private static sanitizeText(text: string) { return Array.from(