diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a153ba01..d8159ecbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,7 @@ SPDX-License-Identifier: CC-BY-SA-4.0 - Improved security by wrapping the markdown rendering into an iframe. - The intro page content can be changed by editing `public/intro.md`. - When pasting tables (e.g. from LibreOffice Calc or MS Excel) they get reformatted to markdown tables. +- The history page supports URL parameters that allow bookmarking of a specific search of tags filter. ### Changed diff --git a/package.json b/package.json index 11701cdf2..2ef7b1fbd 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "react-router": "5.2.0", "react-router-bootstrap": "0.25.0", "react-router-dom": "5.2.0", + "react-router-use-location-state": "2.5.0", "react-scripts": "4.0.3", "react-use": "17.2.4", "redux": "4.0.5", diff --git a/src/components/history-page/history-page.tsx b/src/components/history-page/history-page.tsx index 1583ae609..94dd935a3 100644 --- a/src/components/history-page/history-page.tsx +++ b/src/components/history-page/history-page.tsx @@ -10,7 +10,7 @@ import { Trans, useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' import { ApplicationState } from '../../redux' import { HistoryContent } from './history-content/history-content' -import { HistoryToolbar, HistoryToolbarState, initState as toolbarInitState } from './history-toolbar/history-toolbar' +import { HistoryToolbar, HistoryToolbarState, initToolbarState } from './history-toolbar/history-toolbar' import { sortAndFilterEntries } from './utils' import { refreshHistoryState } from '../../redux/history/methods' import { HistoryEntry } from '../../redux/history/types' @@ -20,7 +20,7 @@ export const HistoryPage: React.FC = () => { const { t } = useTranslation() const allEntries = useSelector((state: ApplicationState) => state.history) - const [toolbarState, setToolbarState] = useState(toolbarInitState) + const [toolbarState, setToolbarState] = useState(initToolbarState) const entriesToShow = useMemo(() => sortAndFilterEntries(allEntries, toolbarState), diff --git a/src/components/history-page/history-toolbar/history-toolbar.tsx b/src/components/history-page/history-toolbar/history-toolbar.tsx index 9625e9d4f..a896ffcdd 100644 --- a/src/components/history-page/history-toolbar/history-toolbar.tsx +++ b/src/components/history-page/history-toolbar/history-toolbar.tsx @@ -4,11 +4,13 @@ SPDX-License-Identifier: AGPL-3.0-only */ -import React, { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react' +import equal from 'fast-deep-equal' +import React, { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button, Form, FormControl, InputGroup, ToggleButton, ToggleButtonGroup } from 'react-bootstrap' import { Typeahead } from 'react-bootstrap-typeahead' import { Trans, useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' +import { useQueryState } from 'react-router-use-location-state' import { ApplicationState } from '../../../redux' import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon' import { ShowIf } from '../../common/show-if/show-if' @@ -21,16 +23,21 @@ import { HistoryEntryOrigin } from '../../../redux/history/types' import { importHistoryEntries, refreshHistoryState, setHistoryEntries } from '../../../redux/history/methods' import { showErrorNotification } from '../../../redux/ui-notifications/methods' -export type HistoryToolbarChange = (settings: HistoryToolbarState) => void; +export type HistoryToolbarChange = (newState: HistoryToolbarState) => void; -export interface HistoryToolbarState { - viewState: ViewStateEnum +interface ToolbarSortState { titleSortDirection: SortModeEnum lastVisitedSortDirection: SortModeEnum +} + +interface ToolbarFilterState { + viewState: ViewStateEnum keywordSearch: string selectedTags: string[] } +export type HistoryToolbarState = ToolbarSortState & ToolbarFilterState + export enum ViewStateEnum { CARD, TABLE @@ -40,17 +47,20 @@ export interface HistoryToolbarProps { onSettingsChange: HistoryToolbarChange } -export const initState: HistoryToolbarState = { - viewState: ViewStateEnum.CARD, +const initSortState: ToolbarSortState = { titleSortDirection: SortModeEnum.no, - lastVisitedSortDirection: SortModeEnum.down, + lastVisitedSortDirection: SortModeEnum.down +} + +export const initToolbarState: HistoryToolbarState = { + ...initSortState, + viewState: ViewStateEnum.CARD, keywordSearch: '', selectedTags: [] } export const HistoryToolbar: React.FC = ({ onSettingsChange }) => { const { t } = useTranslation() - const [state, setState] = useState(initState) const historyEntries = useSelector((state: ApplicationState) => state.history) const userExists = useSelector((state: ApplicationState) => !!state.user) @@ -60,33 +70,37 @@ export const HistoryToolbar: React.FC = ({ onSettingsChange return [...new Set(allTags)] }, [historyEntries]) - const titleSortChanged = (direction: SortModeEnum) => { - setState(prevState => ({ - ...prevState, - titleSortDirection: direction, - lastVisitedSortDirection: SortModeEnum.no - })) - } + const previousState = useRef(initToolbarState) + const [searchState, setSearchState] = useQueryState('search', initToolbarState.keywordSearch) + const [tagsState, setTagsState] = useQueryState('tags', initToolbarState.selectedTags) + const [viewState, setViewState] = useState(initToolbarState.viewState) + const [sortState, setSortState] = useState(initSortState) - const lastVisitedSortChanged = (direction: SortModeEnum) => { - setState(prevState => ({ - ...prevState, + const titleSortChanged = useCallback((direction: SortModeEnum) => { + setSortState({ + lastVisitedSortDirection: SortModeEnum.no, + titleSortDirection: direction + }) + }, [setSortState]) + + const lastVisitedSortChanged = useCallback((direction: SortModeEnum) => { + setSortState({ lastVisitedSortDirection: direction, titleSortDirection: SortModeEnum.no - })) - } + }) + }, [setSortState]) - const keywordSearchChanged = (event: ChangeEvent) => { - setState(prevState => ({ ...prevState, keywordSearch: event.currentTarget.value })) - } + const keywordSearchChanged = useCallback((event: ChangeEvent) => { + setSearchState(event.currentTarget.value ?? '') + }, [setSearchState]) - const toggleViewChanged = (newViewState: ViewStateEnum) => { - setState((prevState) => ({ ...prevState, viewState: newViewState })) - } + const toggleViewChanged = useCallback((newViewState: ViewStateEnum) => { + setViewState(newViewState) + }, [setViewState]) - const selectedTagsChanged = (selected: string[]) => { - setState(prevState => ({ ...prevState, selectedTags: selected })) - } + const selectedTagsChanged = useCallback((selected: string[]) => { + setTagsState(selected) + }, [setTagsState]) const refreshHistory = useCallback(() => { refreshHistoryState() @@ -116,30 +130,45 @@ export const HistoryToolbar: React.FC = ({ onSettingsChange }, [userExists, historyEntries, t, refreshHistory]) useEffect(() => { - onSettingsChange(state) - }, [onSettingsChange, state]) + const newState: HistoryToolbarState = { + selectedTags: tagsState, + keywordSearch: searchState, + viewState: viewState, + ...sortState + } + // This is needed because the onSettingsChange triggers a state update in history-page which re-renders the toolbar. + // The re-rendering causes this effect to run again resulting in an infinite state update loop. + if (equal(previousState.current, newState)) { + return + } + onSettingsChange(newState) + previousState.current = newState + }, [onSettingsChange, tagsState, searchState, viewState, sortState]) return (
+ onChange={ selectedTagsChanged } + selected={ tagsState } + /> - - @@ -164,7 +193,7 @@ export const HistoryToolbar: React.FC = ({ onSettingsChange - { toggleViewChanged(newViewState) } }> diff --git a/yarn.lock b/yarn.lock index d6a18ff68..b39da79cd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11661,6 +11661,11 @@ qs@~6.5.2: resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== +query-state-core@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/query-state-core/-/query-state-core-2.5.0.tgz#7cac3fdc1f79c58c22f35efe8a5f5880f55728d3" + integrity sha512-XVo7I/K+gKXqu+HlxtGXfjUtQ+LPjs5bTHB4RC4vDs6yCYLmchc4IxcZWt5EdZZLqIg/CuY+PUxN141t3J17fQ== + query-string@^4.1.0: version "4.3.4" resolved "https://registry.yarnpkg.com/query-string/-/query-string-4.3.4.tgz#bbb693b9ca915c232515b228b1a02b609043dbeb" @@ -11965,6 +11970,13 @@ react-router-dom@5.2.0: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" +react-router-use-location-state@2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/react-router-use-location-state/-/react-router-use-location-state-2.5.0.tgz#4fe1cb6aa3cd5f8f997cfb77e7a6d5d8a55ea00f" + integrity sha512-p0duQtatgL8SZzIITI3He2MP/4d9x9GQHJs93spPYAckVrvCRLAlQxS7k04RHYZb0e4yxwW76Rp/PBvezyez6g== + dependencies: + use-location-state "^2.5.0" + react-router@5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.2.0.tgz#424e75641ca8747fbf76e5ecca69781aa37ea293" @@ -14312,6 +14324,13 @@ url@^0.11.0: punycode "1.3.2" querystring "0.2.0" +use-location-state@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/use-location-state/-/use-location-state-2.5.0.tgz#c71e6b5391898fa23ee1a6242d193924db6767c0" + integrity sha512-Gsn37xXWTVa4gGZA8WobtmC7ixm46TkQUyr9MApLhh9YIDcxOKuLCH/0wuKY7YcrCsb5t/S0b77qP50/mbvibQ== + dependencies: + query-state-core "^2.5.0" + use-resize-observer@7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/use-resize-observer/-/use-resize-observer-7.0.0.tgz#15f0efbd5a4e08a8cc51901f21a89ba836f2116e"