Skip to content

Commit

Permalink
Feature/f-216: Info pages docs (#177)
Browse files Browse the repository at this point in the history
* docs: remove "How to add new page" (added to docs)

* docs: info pages

* fix: remove unused file ExternalLink.tsx
  • Loading branch information
jschoedl authored Jan 14, 2025
1 parent 9826430 commit 6cccae0
Show file tree
Hide file tree
Showing 11 changed files with 109 additions and 89 deletions.
3 changes: 2 additions & 1 deletion src/app/about/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ function Page() {
];

const [searchWords, setSearchWords] = useState<string[]>([]);
const [visibleAccordions, setVisibleAccordions] = useState<Set<string>>(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<Set<string>>(new Set());
const handleVisibilityChange = useCallback(
(key: string, isVisible: boolean) =>
setVisibleAccordions((prevState) => {
Expand Down
19 changes: 0 additions & 19 deletions src/components/About/ExternalLink.tsx

This file was deleted.

3 changes: 3 additions & 0 deletions src/components/About/LiveSuperscript.tsx
Original file line number Diff line number Diff line change
@@ -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 <span className="align-super text-[0.6em] px-[0.2em]">LIVE</span>;
}
Expand Down
10 changes: 10 additions & 0 deletions src/components/About/StyledLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 1 addition & 2 deletions src/components/Search/DocsSearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions src/components/Search/RecursiveHighlighter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
12 changes: 5 additions & 7 deletions src/components/Search/SearchableSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
62 changes: 50 additions & 12 deletions src/components/Table/CustomTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
* ];
* <CustomTable columns={columns} data={data} ariaLabel="User Data" />;
*/
function CustomTable<D>({
columns,
data,
Expand All @@ -21,23 +52,30 @@ function CustomTable<D>({
showBorders = true,
zebraRows = true,
}: CustomTableProps<D>) {
// 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)));
Expand All @@ -56,15 +94,15 @@ function CustomTable<D>({
})}
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
}),
}}
>
Expand All @@ -73,8 +111,8 @@ function CustomTable<D>({
<TableColumn
key={column.columnId}
className={clsx('text-wrap', {
'text-center': !leftAlignedColumns.has(column.columnId),
'border-b-2 dark:border-default-200': showBorders,
'text-center': !leftAlignedColumns.has(column.columnId), // Center align if not left-aligned
'border-b-2 dark:border-default-200': showBorders, // Add border if enabled
})}
>
{column.label}
Expand Down
7 changes: 7 additions & 0 deletions src/components/Tooltip/Abbreviation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
9 changes: 9 additions & 0 deletions src/domain/hooks/queryParamsHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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';
Expand Down
68 changes: 20 additions & 48 deletions src/operations/Search/SearchOperations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,57 +6,12 @@ import { AccordionItemProps, SearchableAccordionItemProps } from '@/domain/entit
import DataSourceDescription, { DataSourceDescriptionItems } from '@/domain/entities/dataSources/DataSourceDescription';
import { SearchableElement } from '@/domain/props/SearchableSectionProps';

/**
* <h1>Adding the Search Feature to a New Page</h1>
* <h2>Demo Code</h2>
* ```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<string[]>([]);
* const [sectionIsVisible, setSectionIsVisible] = useState(true);
* return (
* <>
* <Suspense fallback={<DocsSearchBarSkeleton />}>
* <DocsSearchBar setSearchWords={setSearchWords} />
* </Suspense>
* {!searchWords.length && <h1 className="!mb-0">Demo Page</h1>}
* <SearchableSection
* searchWords={searchWords}
* accordionItems={demoItems}
* onVisibilityChange={setSectionIsVisible}
* />
* {!sectionIsVisible && !!searchWords.length && <p className="text-center">No results</p>}
* </>
* );
* }
* ```
* <h2>Recommended Approach</h2>
* * 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
*
* <h2>Extended / Customized Approach </h2>
* * 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) => {
Expand All @@ -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) => {
Expand All @@ -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) => {
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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 '';
Expand All @@ -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(
Expand Down

0 comments on commit 6cccae0

Please sign in to comment.