From 8e5a667d18d9172ab0931506b51e92d1d87154f6 Mon Sep 17 00:00:00 2001 From: Erik Michelson Date: Thu, 22 Apr 2021 22:46:24 +0200 Subject: [PATCH] The History PR: I - Move to redux (#1156) --- cypress/integration/history.spec.ts | 12 +- public/api/private/history | 12 +- src/api/history/dto-methods.ts | 33 +++ src/api/history/index.ts | 47 ++-- src/api/history/types.d.ts | 23 ++ .../application-loader/initializers/index.ts | 4 + .../history-page/entry-menu/entry-menu.tsx | 21 +- .../history-card/history-card-list.tsx | 6 +- .../history-card/history-card.tsx | 28 ++- .../history-content/history-content.tsx | 55 ++-- src/components/history-page/history-page.tsx | 238 +++--------------- .../history-table/history-table-row.tsx | 16 +- .../history-table/history-table.tsx | 6 +- .../history-toolbar/clear-history-button.tsx | 25 +- .../history-toolbar/export-history-button.tsx | 9 +- .../history-toolbar/history-toolbar.tsx | 66 +++-- .../history-toolbar/import-history-button.tsx | 42 +++- src/components/history-page/utils.ts | 91 ++----- src/redux/history/methods.ts | 197 +++++++++++++++ src/redux/history/reducers.ts | 35 +++ src/redux/history/types.ts | 66 +++++ src/redux/index.ts | 4 + src/redux/ui-notifications/methods.ts | 8 + tsconfig.json | 2 +- 24 files changed, 629 insertions(+), 417 deletions(-) create mode 100644 src/api/history/dto-methods.ts create mode 100644 src/api/history/types.d.ts create mode 100644 src/redux/history/methods.ts create mode 100644 src/redux/history/reducers.ts create mode 100644 src/redux/history/types.ts diff --git a/cypress/integration/history.spec.ts b/cypress/integration/history.spec.ts index fcb883454..7274c1010 100644 --- a/cypress/integration/history.spec.ts +++ b/cypress/integration/history.spec.ts @@ -38,10 +38,10 @@ describe('History', () => { .first() .as('pin-button') cy.get('@pin-button') - .should('not.have.class', 'pinned') + .should('have.class', 'pinned') .click() cy.get('@pin-button') - .should('have.class', 'pinned') + .should('not.have.class', 'pinned') }) it('Table', () => { @@ -51,10 +51,10 @@ describe('History', () => { .first() .as('pin-button') cy.get('@pin-button') - .should('not.have.class', 'pinned') + .should('have.class', 'pinned') .click() cy.get('@pin-button') - .should('have.class', 'pinned') + .should('not.have.class', 'pinned') }) }) @@ -71,7 +71,7 @@ describe('History', () => { cy.get('.fa-thumb-tack') .first() .click() - cy.get('.modal-dialog') + cy.get('.notifications-area .toast') .should('be.visible') }) @@ -81,7 +81,7 @@ describe('History', () => { cy.get('.fa-thumb-tack') .first() .click() - cy.get('.modal-dialog') + cy.get('.notifications-area .toast') .should('be.visible') }) }) diff --git a/public/api/private/history b/public/api/private/history index 38e501a6e..93f9acf82 100644 --- a/public/api/private/history +++ b/public/api/private/history @@ -1,17 +1,19 @@ [ { - "id": "29QLD0AmT-adevdOPECtqg", + "identifier": "29QLD0AmT-adevdOPECtqg", "title": "HedgeDoc community call 2020-04-26", "lastVisited": "2020-05-16T22:26:56.547Z", + "pinStatus": false, "tags": [ "HedgeDoc", "Community Call" ] }, { - "id": "features", + "identifier": "features", "title": "Features", "lastVisited": "2020-05-31T15:20:36.088Z", + "pinStatus": true, "tags": [ "features", "cool", @@ -19,15 +21,17 @@ ] }, { - "id": "ODakLc2MQkyyFc_Xmb53sg", + "identifier": "ODakLc2MQkyyFc_Xmb53sg", "title": "HedgeDoc V2 API", "lastVisited": "2020-05-25T19:48:14.025Z", + "pinStatus": false, "tags": [] }, { - "id": "l8JuWxApTR6Fqa0LCrpnLg", + "identifier": "l8JuWxApTR6Fqa0LCrpnLg", "title": "Community call - Let’s meet! (2020-06-06 18:00 UTC / 20:00 CEST)", "lastVisited": "2020-05-24T16:04:36.433Z", + "pinStatus": false, "tags": [ "agenda", "HedgeDoc community", diff --git a/src/api/history/dto-methods.ts b/src/api/history/dto-methods.ts new file mode 100644 index 000000000..ccdcce30e --- /dev/null +++ b/src/api/history/dto-methods.ts @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { HistoryEntry, HistoryEntryOrigin } from '../../redux/history/types' +import { HistoryEntryDto, HistoryEntryPutDto, HistoryEntryUpdateDto } from './types' + +export const historyEntryDtoToHistoryEntry = (entryDto: HistoryEntryDto): HistoryEntry => { + return { + origin: HistoryEntryOrigin.REMOTE, + title: entryDto.title, + pinStatus: entryDto.pinStatus, + identifier: entryDto.identifier, + tags: entryDto.tags, + lastVisited: entryDto.lastVisited + } +} + +export const historyEntryToHistoryEntryPutDto = (entry: HistoryEntry): HistoryEntryPutDto => { + return { + pinStatus: entry.pinStatus, + lastVisited: entry.lastVisited, + note: entry.identifier + } +} + +export const historyEntryToHistoryEntryUpdateDto = (entry: HistoryEntry): HistoryEntryUpdateDto => { + return { + pinStatus: entry.pinStatus + } +} diff --git a/src/api/history/index.ts b/src/api/history/index.ts index abb76de9a..2786ec14a 100644 --- a/src/api/history/index.ts +++ b/src/api/history/index.ts @@ -4,22 +4,37 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { HistoryEntry } from '../../components/history-page/history-page' import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils' +import { HistoryEntryDto, HistoryEntryPutDto, HistoryEntryUpdateDto } from './types' -export const getHistory = async (): Promise => { +export const getHistory = async (): Promise => { const response = await fetch(getApiUrl() + '/history') expectResponseCode(response) - return await response.json() as Promise + return await response.json() as Promise } -export const setHistory = async (entries: HistoryEntry[]): Promise => { +export const postHistory = async (entries: HistoryEntryPutDto[]): Promise => { const response = await fetch(getApiUrl() + '/history', { ...defaultFetchConfig, method: 'POST', - body: JSON.stringify({ - history: entries - }) + body: JSON.stringify(entries) + }) + expectResponseCode(response) +} + +export const updateHistoryEntryPinStatus = async (noteId: string, entry: HistoryEntryUpdateDto): Promise => { + const response = await fetch(getApiUrl() + '/history/' + noteId, { + ...defaultFetchConfig, + method: 'PUT', + body: JSON.stringify(entry) + }) + expectResponseCode(response) +} + +export const deleteHistoryEntry = async (noteId: string): Promise => { + const response = await fetch(getApiUrl() + '/history/' + noteId, { + ...defaultFetchConfig, + method: 'DELETE' }) expectResponseCode(response) } @@ -31,21 +46,3 @@ export const deleteHistory = async (): Promise => { }) expectResponseCode(response) } - -export const updateHistoryEntry = async (noteId: string, entry: HistoryEntry): Promise => { - const response = await fetch(getApiUrl() + '/history/' + noteId, { - ...defaultFetchConfig, - method: 'PUT', - body: JSON.stringify(entry) - }) - expectResponseCode(response) - return await response.json() as Promise -} - -export const deleteHistoryEntry = async (noteId: string): Promise => { - const response = await fetch(getApiUrl() + '/history/' + noteId, { - ...defaultFetchConfig, - method: 'DELETE' - }) - expectResponseCode(response) -} diff --git a/src/api/history/types.d.ts b/src/api/history/types.d.ts new file mode 100644 index 000000000..5e42df888 --- /dev/null +++ b/src/api/history/types.d.ts @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export interface HistoryEntryPutDto { + note: string + pinStatus: boolean + lastVisited: string +} + +export interface HistoryEntryUpdateDto { + pinStatus: boolean +} + +export interface HistoryEntryDto { + identifier: string + title: string + lastVisited: string + tags: string[] + pinStatus: boolean +} diff --git a/src/components/application-loader/initializers/index.ts b/src/components/application-loader/initializers/index.ts index e1ed6b6b3..0c014aa46 100644 --- a/src/components/application-loader/initializers/index.ts +++ b/src/components/application-loader/initializers/index.ts @@ -6,6 +6,7 @@ import { loadAllConfig } from './configLoader' import { setUpI18n } from './i18n' +import { refreshHistoryState } from '../../../redux/history/methods' const customDelay: () => Promise = async () => { if (window.localStorage.getItem('customDelay')) { @@ -27,6 +28,9 @@ export const createSetUpTaskList = (baseUrl: string): InitTask[] => { }, { name: 'Load config', task: loadAllConfig(baseUrl) + }, { + name: 'Load history state', + task: refreshHistoryState() }, { name: 'Add Delay', task: customDelay() diff --git a/src/components/history-page/entry-menu/entry-menu.tsx b/src/components/history-page/entry-menu/entry-menu.tsx index fb671a48e..a72ef4e74 100644 --- a/src/components/history-page/entry-menu/entry-menu.tsx +++ b/src/components/history-page/entry-menu/entry-menu.tsx @@ -9,24 +9,28 @@ import { Dropdown } from 'react-bootstrap' import { Trans, useTranslation } from 'react-i18next' import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon' import { ShowIf } from '../../common/show-if/show-if' -import { HistoryEntryOrigin } from '../history-page' import { DeleteNoteItem } from './delete-note-item' import './entry-menu.scss' import { RemoveNoteEntryItem } from './remove-note-entry-item' +import { HistoryEntryOrigin } from '../../../redux/history/types' +import { useSelector } from 'react-redux' +import { ApplicationState } from '../../../redux' export interface EntryMenuProps { id: string; title: string - location: HistoryEntryOrigin + origin: HistoryEntryOrigin isDark: boolean; onRemove: () => void onDelete: () => void className?: string } -export const EntryMenu: React.FC = ({ id, title, location, isDark, onRemove, onDelete, className }) => { +export const EntryMenu: React.FC = ({ id, title, origin, isDark, onRemove, onDelete, className }) => { useTranslation() + const userExists = useSelector((state: ApplicationState) => !!state.user) + return ( = ({ id, title, location, isDar - + - + @@ -54,9 +58,10 @@ export const EntryMenu: React.FC = ({ id, title, location, isDar - - - + + + + ) diff --git a/src/components/history-page/history-card/history-card-list.tsx b/src/components/history-page/history-card/history-card-list.tsx index 7273c49b9..5e4a013b2 100644 --- a/src/components/history-page/history-card/history-card-list.tsx +++ b/src/components/history-page/history-card/history-card-list.tsx @@ -7,17 +7,17 @@ import React from 'react' import { Row } from 'react-bootstrap' import { Pager } from '../../common/pagination/pager' -import { HistoryEntriesProps } from '../history-content/history-content' +import { HistoryEntriesProps, HistoryEventHandlers } from '../history-content/history-content' import { HistoryCard } from './history-card' -export const HistoryCardList: React.FC = ({ entries, onPinClick, onRemoveClick, onDeleteClick, pageIndex, onLastPageIndexChange }) => { +export const HistoryCardList: React.FC = ({ entries, onPinClick, onRemoveClick, onDeleteClick, pageIndex, onLastPageIndexChange }) => { return ( { entries.map((entry) => ( = ({ entry, onPinClick, onRemoveClick, onDeleteClick }) => { +export const HistoryCard: React.FC = ({ entry, onPinClick, onRemoveClick, onDeleteClick }) => { + const onRemove = useCallback(() => { + onRemoveClick(entry.identifier) + }, [onRemoveClick, entry.identifier]) + + const onDelete = useCallback(() => { + onDeleteClick(entry.identifier) + }, [onDeleteClick, entry.identifier]) + return (
- onPinClick(entry.id, entry.location) }/> + onPinClick(entry.identifier) }/>
- +
{ entry.title }
@@ -44,12 +52,12 @@ export const HistoryCard: React.FC = ({ entry, onPinClick, on
onRemoveClick(entry.id, entry.location) } - onDelete={ () => onDeleteClick(entry.id, entry.location) } + onRemove={ onRemove } + onDelete={ onDelete } />
diff --git a/src/components/history-page/history-content/history-content.tsx b/src/components/history-page/history-content/history-content.tsx index d5623bc95..845a79aa3 100644 --- a/src/components/history-page/history-content/history-content.tsx +++ b/src/components/history-page/history-content/history-content.tsx @@ -4,46 +4,67 @@ SPDX-License-Identifier: AGPL-3.0-only */ -import React, { Fragment, useState } from 'react' +import React, { Fragment, useCallback, useState } from 'react' import { Alert, Row } from 'react-bootstrap' import { Trans, useTranslation } from 'react-i18next' import { PagerPagination } from '../../common/pagination/pager-pagination' import { HistoryCardList } from '../history-card/history-card-list' -import { HistoryEntryOrigin, LocatedHistoryEntry } from '../history-page' import { HistoryTable } from '../history-table/history-table' import { ViewStateEnum } from '../history-toolbar/history-toolbar' +import { HistoryEntry } from '../../../redux/history/types' +import { removeHistoryEntry, toggleHistoryEntryPinning } from '../../../redux/history/methods' +import { deleteNote } from '../../../api/notes' +import { showErrorNotification } from '../../../redux/ui-notifications/methods' -type OnEntryClick = (entryId: string, location: HistoryEntryOrigin) => void +type OnEntryClick = (entryId: string) => void + +export interface HistoryEventHandlers { + onPinClick: OnEntryClick + onRemoveClick: OnEntryClick + onDeleteClick: OnEntryClick +} export interface HistoryContentProps { viewState: ViewStateEnum - entries: LocatedHistoryEntry[] - onPinClick: OnEntryClick - onRemoveClick: OnEntryClick - onDeleteClick: OnEntryClick + entries: HistoryEntry[] } export interface HistoryEntryProps { - entry: LocatedHistoryEntry, - onPinClick: OnEntryClick - onRemoveClick: OnEntryClick - onDeleteClick: OnEntryClick + entry: HistoryEntry, } export interface HistoryEntriesProps { - entries: LocatedHistoryEntry[] - onPinClick: OnEntryClick - onRemoveClick: OnEntryClick - onDeleteClick: OnEntryClick + entries: HistoryEntry[] pageIndex: number onLastPageIndexChange: (lastPageIndex: number) => void } -export const HistoryContent: React.FC = ({ viewState, entries, onPinClick, onRemoveClick, onDeleteClick }) => { - useTranslation() +export const HistoryContent: React.FC = ({ viewState, entries }) => { + const { t } = useTranslation() + const [pageIndex, setPageIndex] = useState(0) const [lastPageIndex, setLastPageIndex] = useState(0) + const onPinClick = useCallback((noteId: string) => { + toggleHistoryEntryPinning(noteId).catch( + showErrorNotification(t('landing.history.error.updateEntry.text')) + ) + }, [t]) + + const onDeleteClick = useCallback((noteId: string) => { + deleteNote(noteId).then(() => { + return removeHistoryEntry(noteId) + }).catch( + showErrorNotification(t('landing.history.error.deleteNote.text')) + ) + }, [t]) + + const onRemoveClick = useCallback((noteId: string) => { + removeHistoryEntry(noteId).catch( + showErrorNotification(t('landing.history.error.deleteEntry.text')) + ) + }, [t]) + if (entries.length === 0) { return ( diff --git a/src/components/history-page/history-page.tsx b/src/components/history-page/history-page.tsx index abdb7d680..1583ae609 100644 --- a/src/components/history-page/history-page.tsx +++ b/src/components/history-page/history-page.tsx @@ -4,226 +4,48 @@ SPDX-License-Identifier: AGPL-3.0-only */ -import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react' +import React, { Fragment, useEffect, useMemo, useState } from 'react' import { Row } from 'react-bootstrap' import { Trans, useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' -import { deleteHistory, deleteHistoryEntry, getHistory, setHistory, updateHistoryEntry } from '../../api/history' -import { deleteNote } from '../../api/notes' import { ApplicationState } from '../../redux' -import { download } from '../common/download/download' -import { ErrorModal } from '../common/modals/error-modal' import { HistoryContent } from './history-content/history-content' import { HistoryToolbar, HistoryToolbarState, initState as toolbarInitState } from './history-toolbar/history-toolbar' - -import { - collectEntries, - loadHistoryFromLocalStore, - mergeEntryArrays, - setHistoryToLocalStore, - sortAndFilterEntries -} from './utils' - -export interface HistoryEntry { - id: string, - title: string, - lastVisited: string, - tags: string[], - pinned: boolean -} - -export interface HistoryJson { - version: number, - entries: HistoryEntry[] -} - -export type LocatedHistoryEntry = HistoryEntry & HistoryEntryLocation - -export interface HistoryEntryLocation { - location: HistoryEntryOrigin -} - -export enum HistoryEntryOrigin { - LOCAL = 'local', - REMOTE = 'remote' -} +import { sortAndFilterEntries } from './utils' +import { refreshHistoryState } from '../../redux/history/methods' +import { HistoryEntry } from '../../redux/history/types' +import { showErrorNotification } from '../../redux/ui-notifications/methods' export const HistoryPage: React.FC = () => { - useTranslation() - const [localHistoryEntries, setLocalHistoryEntries] = useState(loadHistoryFromLocalStore) - const [remoteHistoryEntries, setRemoteHistoryEntries] = useState([]) + const { t } = useTranslation() + + const allEntries = useSelector((state: ApplicationState) => state.history) const [toolbarState, setToolbarState] = useState(toolbarInitState) - const userExists = useSelector((state: ApplicationState) => !!state.user) - const [error, setError] = useState('') - const historyWrite = useCallback((entries: HistoryEntry[]) => { - if (!entries) { - return - } - setHistoryToLocalStore(entries) - }, []) - - useEffect(() => { - historyWrite(localHistoryEntries) - }, [historyWrite, localHistoryEntries]) - - const importHistory = useCallback((entries: HistoryEntry[]): void => { - if (userExists) { - setHistory(entries) - .then(() => setRemoteHistoryEntries(entries)) - .catch(() => setError('setHistory')) - } else { - setLocalHistoryEntries(entries) - } - }, [userExists]) - - const refreshHistory = useCallback(() => { - const localHistory = loadHistoryFromLocalStore() - setLocalHistoryEntries(localHistory) - if (userExists) { - getHistory() - .then((remoteHistory) => setRemoteHistoryEntries(remoteHistory)) - .catch(() => setError('getHistory')) - } - }, [userExists]) - - useEffect(() => { - refreshHistory() - }, [refreshHistory]) - - const exportHistory = useCallback(() => { - const dataObject: HistoryJson = { - version: 2, - entries: mergeEntryArrays(localHistoryEntries, remoteHistoryEntries) - } - download(JSON.stringify(dataObject), `history_${ (new Date()).getTime() }.json`, 'application/json') - }, [localHistoryEntries, remoteHistoryEntries]) - - const clearHistory = useCallback(() => { - setLocalHistoryEntries([]) - if (userExists) { - deleteHistory() - .then(() => setRemoteHistoryEntries([])) - .catch(() => setError('deleteHistory')) - } - historyWrite([]) - }, [historyWrite, userExists]) - - const uploadAll = useCallback((): void => { - const newHistory = mergeEntryArrays(localHistoryEntries, remoteHistoryEntries) - if (userExists) { - setHistory(newHistory) - .then(() => { - setRemoteHistoryEntries(newHistory) - setLocalHistoryEntries([]) - historyWrite([]) - }) - .catch(() => setError('setHistory')) - } - }, [historyWrite, localHistoryEntries, remoteHistoryEntries, userExists]) - - const removeFromHistoryClick = useCallback((entryId: string, location: HistoryEntryOrigin): void => { - if (location === HistoryEntryOrigin.LOCAL) { - setLocalHistoryEntries((entries) => entries.filter(entry => entry.id !== entryId)) - } else if (location === HistoryEntryOrigin.REMOTE) { - deleteHistoryEntry(entryId) - .then(() => setRemoteHistoryEntries((entries) => entries.filter(entry => entry.id !== entryId))) - .catch(() => setError('deleteEntry')) - } - }, []) - - const deleteNoteClick = useCallback((entryId: string, location: HistoryEntryOrigin): void => { - if (userExists) { - deleteNote(entryId) - .then(() => { - removeFromHistoryClick(entryId, location) - }) - .catch(() => setError('deleteNote')) - } - }, [userExists, removeFromHistoryClick]) - - const pinClick = useCallback((entryId: string, location: HistoryEntryOrigin): void => { - if (location === HistoryEntryOrigin.LOCAL) { - setLocalHistoryEntries((entries) => { - return entries.map((entry) => { - if (entry.id === entryId) { - entry.pinned = !entry.pinned - } - return entry - }) - }) - } else if (location === HistoryEntryOrigin.REMOTE) { - const foundEntry = remoteHistoryEntries.find(entry => entry.id === entryId) - if (!foundEntry) { - setError('notFoundEntry') - return - } - const changedEntry = { - ...foundEntry, - pinned: !foundEntry.pinned - } - updateHistoryEntry(entryId, changedEntry) - .then(() => setRemoteHistoryEntries((entries) => ( - entries.map((entry) => { - if (entry.id === entryId) { - entry.pinned = !entry.pinned - } - return entry - }) - ) - )) - .catch(() => setError('updateEntry')) - } - }, [remoteHistoryEntries]) - - const resetError = () => { - setError('') - } - - const allEntries = useMemo(() => { - return collectEntries(localHistoryEntries, remoteHistoryEntries) - }, [localHistoryEntries, remoteHistoryEntries]) - - const tags = useMemo(() => { - return allEntries.map(entry => entry.tags) - .reduce((a, b) => ([...a, ...b]), []) - .filter((value, index, array) => { - if (index === 0) { - return true - } - return (value !== array[index - 1]) - }) - }, [allEntries]) - - const entriesToShow = useMemo(() => + const entriesToShow = useMemo(() => sortAndFilterEntries(allEntries, toolbarState), [allEntries, toolbarState]) - return - -
- -
-
-

- - { + refreshHistoryState().catch( + showErrorNotification(t('landing.history.error.getHistory.text')) + ) + }, [t]) + + return ( + +

+ +

+ + + + -
- -
+ + ) } diff --git a/src/components/history-page/history-table/history-table-row.tsx b/src/components/history-page/history-table/history-table-row.tsx index 511a927d5..e43a5ed97 100644 --- a/src/components/history-page/history-table/history-table-row.tsx +++ b/src/components/history-page/history-table/history-table-row.tsx @@ -8,15 +8,15 @@ import React from 'react' import { Badge } from 'react-bootstrap' import { Link } from 'react-router-dom' import { EntryMenu } from '../entry-menu/entry-menu' -import { HistoryEntryProps } from '../history-content/history-content' +import { HistoryEntryProps, HistoryEventHandlers } from '../history-content/history-content' import { PinButton } from '../pin-button/pin-button' import { formatHistoryDate } from '../utils' -export const HistoryTableRow: React.FC = ({ entry, onPinClick, onRemoveClick, onDeleteClick }) => { +export const HistoryTableRow: React.FC = ({ entry, onPinClick, onRemoveClick, onDeleteClick }) => { return ( - + { entry.title } @@ -28,15 +28,15 @@ export const HistoryTableRow: React.FC = ({ entry, onPinClick } - onPinClick(entry.id, entry.location) } + onPinClick(entry.identifier) } className={ 'mb-1 mr-1' }/> onRemoveClick(entry.id, entry.location) } - onDelete={ () => onDeleteClick(entry.id, entry.location) } + onRemove={ () => onRemoveClick(entry.identifier) } + onDelete={ () => onDeleteClick(entry.identifier) } /> diff --git a/src/components/history-page/history-table/history-table.tsx b/src/components/history-page/history-table/history-table.tsx index d654cbe6f..6da7a8ec6 100644 --- a/src/components/history-page/history-table/history-table.tsx +++ b/src/components/history-page/history-table/history-table.tsx @@ -8,11 +8,11 @@ import React from 'react' import { Table } from 'react-bootstrap' import { Trans, useTranslation } from 'react-i18next' import { Pager } from '../../common/pagination/pager' -import { HistoryEntriesProps } from '../history-content/history-content' +import { HistoryEntriesProps, HistoryEventHandlers } from '../history-content/history-content' import { HistoryTableRow } from './history-table-row' import './history-table.scss' -export const HistoryTable: React.FC = ({ entries, onPinClick, onRemoveClick, onDeleteClick, pageIndex, onLastPageIndexChange }) => { +export const HistoryTable: React.FC = ({ entries, onPinClick, onRemoveClick, onDeleteClick, pageIndex, onLastPageIndexChange }) => { useTranslation() return ( @@ -29,7 +29,7 @@ export const HistoryTable: React.FC = ({ entries, onPinClic { entries.map((entry) => void -} - -export const ClearHistoryButton: React.FC = ({ onClearHistory }) => { +export const ClearHistoryButton: React.FC = () => { const { t } = useTranslation() const [show, setShow] = useState(false) const handleShow = () => setShow(true) const handleClose = () => setShow(false) + const onConfirm = useCallback(() => { + deleteAllHistoryEntries().catch(error => { + showErrorNotification(t('landing.history.error.deleteEntry.text'))(error) + refreshHistoryState().catch( + showErrorNotification(t('landing.history.error.getHistory.text')) + ) + }) + handleClose() + }, [t]) + return ( { - onClearHistory() - handleClose() - } } + onConfirm={ onConfirm } deletionButtonI18nKey={ 'landing.history.toolbar.clear' } show={ show } onHide={ handleClose } diff --git a/src/components/history-page/history-toolbar/export-history-button.tsx b/src/components/history-page/history-toolbar/export-history-button.tsx index 2f492351c..659c4763c 100644 --- a/src/components/history-page/history-toolbar/export-history-button.tsx +++ b/src/components/history-page/history-toolbar/export-history-button.tsx @@ -8,16 +8,13 @@ import React from 'react' import { Button } from 'react-bootstrap' import { useTranslation } from 'react-i18next' import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon' +import { downloadHistory } from '../../../redux/history/methods' -export interface ExportHistoryButtonProps { - onExportHistory: () => void -} - -export const ExportHistoryButton: React.FC = ({ onExportHistory }) => { +export const ExportHistoryButton: React.FC = () => { const { t } = useTranslation() return ( - ) diff --git a/src/components/history-page/history-toolbar/history-toolbar.tsx b/src/components/history-page/history-toolbar/history-toolbar.tsx index 7b5139b82..9625e9d4f 100644 --- a/src/components/history-page/history-toolbar/history-toolbar.tsx +++ b/src/components/history-page/history-toolbar/history-toolbar.tsx @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only */ -import React, { ChangeEvent, useEffect, useState } from 'react' +import React, { ChangeEvent, useCallback, useEffect, useMemo, 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' @@ -12,12 +12,14 @@ import { useSelector } from 'react-redux' import { ApplicationState } from '../../../redux' import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon' import { ShowIf } from '../../common/show-if/show-if' -import { HistoryEntry } from '../history-page' import { SortButton, SortModeEnum } from '../sort-button/sort-button' import { ClearHistoryButton } from './clear-history-button' import { ExportHistoryButton } from './export-history-button' import { ImportHistoryButton } from './import-history-button' import './typeahead-hacks.scss' +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; @@ -36,12 +38,6 @@ export enum ViewStateEnum { export interface HistoryToolbarProps { onSettingsChange: HistoryToolbarChange - tags: string[] - onClearHistory: () => void - onRefreshHistory: () => void - onExportHistory: () => void - onImportHistory: (entries: HistoryEntry[]) => void - onUploadAll: () => void } export const initState: HistoryToolbarState = { @@ -52,11 +48,18 @@ export const initState: HistoryToolbarState = { selectedTags: [] } -export const HistoryToolbar: React.FC = ({ onSettingsChange, tags, onClearHistory, onRefreshHistory, onExportHistory, onImportHistory, onUploadAll }) => { - const [t] = useTranslation() +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) + const tags = useMemo(() => { + const allTags = historyEntries.map(entry => entry.tags) + .flat() + return [...new Set(allTags)] + }, [historyEntries]) + const titleSortChanged = (direction: SortModeEnum) => { setState(prevState => ({ ...prevState, @@ -85,6 +88,33 @@ export const HistoryToolbar: React.FC = ({ onSettingsChange setState(prevState => ({ ...prevState, selectedTags: selected })) } + const refreshHistory = useCallback(() => { + refreshHistoryState() + .catch( + showErrorNotification(t('landing.history.error.getHistory.text')) + ) + }, [t]) + + const onUploadAllToRemote = useCallback(() => { + if (!userExists) { + return + } + const localEntries = historyEntries.filter(entry => entry.origin === HistoryEntryOrigin.LOCAL) + .map(entry => entry.identifier) + historyEntries.forEach(entry => entry.origin = HistoryEntryOrigin.REMOTE) + importHistoryEntries(historyEntries) + .catch(error => { + showErrorNotification(t('landing.history.error.setHistory.text'))(error) + historyEntries.forEach(entry => { + if (localEntries.includes(entry.identifier)) { + entry.origin = HistoryEntryOrigin.LOCAL + } + }) + setHistoryEntries(historyEntries) + refreshHistory() + }) + }, [userExists, historyEntries, t, refreshHistory]) + useEffect(() => { onSettingsChange(state) }, [onSettingsChange, state]) @@ -113,28 +143,28 @@ export const HistoryToolbar: React.FC = ({ onSettingsChange variant={ 'light' }> - + - + - + - - - { toggleViewChanged(newViewState) } }> diff --git a/src/components/history-page/history-toolbar/import-history-button.tsx b/src/components/history-page/history-toolbar/import-history-button.tsx index e59d353aa..14fa09d9b 100644 --- a/src/components/history-page/history-toolbar/import-history-button.tsx +++ b/src/components/history-page/history-toolbar/import-history-button.tsx @@ -4,34 +4,50 @@ SPDX-License-Identifier: AGPL-3.0-only */ -import React, { useRef, useState } from 'react' +import React, { useCallback, useRef, useState } from 'react' import { Button } from 'react-bootstrap' import { Trans, useTranslation } from 'react-i18next' import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon' import { ErrorModal } from '../../common/modals/error-modal' -import { HistoryEntry, HistoryJson } from '../history-page' -import { convertV1History, V1HistoryEntry } from '../utils' +import { HistoryEntry, HistoryEntryOrigin, HistoryExportJson, V1HistoryEntry } from '../../../redux/history/types' +import { + convertV1History, + importHistoryEntries, + mergeHistoryEntries, + refreshHistoryState +} from '../../../redux/history/methods' +import { ApplicationState } from '../../../redux' +import { useSelector } from 'react-redux' +import { showErrorNotification } from '../../../redux/ui-notifications/methods' -export interface ImportHistoryButtonProps { - onImportHistory: (entries: HistoryEntry[]) => void -} - -export const ImportHistoryButton: React.FC = ({ onImportHistory }) => { +export const ImportHistoryButton: React.FC = () => { const { t } = useTranslation() + const userExists = useSelector((state: ApplicationState) => !!state.user) + const historyState = useSelector((state: ApplicationState) => state.history) const uploadInput = useRef(null) const [show, setShow] = useState(false) const [fileName, setFilename] = useState('') const [i18nKey, setI18nKey] = useState('') - const handleShow = (key: string) => { + const handleShow = useCallback((key: string) => { setI18nKey(key) setShow(true) - } + }, []) - const handleClose = () => { + const handleClose = useCallback(() => { setI18nKey('') setShow(false) - } + }, []) + + const onImportHistory = useCallback((entries: HistoryEntry[]): void => { + entries.forEach(entry => entry.origin = userExists ? HistoryEntryOrigin.REMOTE : HistoryEntryOrigin.LOCAL) + importHistoryEntries(mergeHistoryEntries(historyState, entries)).catch(error => { + showErrorNotification(t('landing.history.error.setHistory.text'))(error) + refreshHistoryState().catch( + showErrorNotification(t('landing.history.error.getHistory.text')) + ) + }) + }, [historyState, userExists, t]) const handleUpload = (event: React.ChangeEvent) => { const { validity, files } = event.target @@ -47,7 +63,7 @@ export const ImportHistoryButton: React.FC = ({ onImpo if (event.target && event.target.result) { try { const result = event.target.result as string - const data = JSON.parse(result) as HistoryJson + const data = JSON.parse(result) as HistoryExportJson if (data) { if (data.version) { if (data.version === 2) { diff --git a/src/components/history-page/utils.ts b/src/components/history-page/utils.ts index 740271cf4..33cc69e00 100644 --- a/src/components/history-page/utils.ts +++ b/src/components/history-page/utils.ts @@ -6,47 +6,25 @@ import { DateTime } from 'luxon' import { SortModeEnum } from './sort-button/sort-button' -import { HistoryEntry, HistoryEntryOrigin, LocatedHistoryEntry } from './history-page' import { HistoryToolbarState } from './history-toolbar/history-toolbar' +import { HistoryEntry } from '../../redux/history/types' -export function collectEntries(localEntries: HistoryEntry[], remoteEntries: HistoryEntry[]): LocatedHistoryEntry[] { - const locatedLocalEntries = locateEntries(localEntries, HistoryEntryOrigin.LOCAL) - const locatedRemoteEntries = locateEntries(remoteEntries, HistoryEntryOrigin.REMOTE) - return mergeEntryArrays(locatedLocalEntries, locatedRemoteEntries) -} +export const formatHistoryDate = (date: string): string => DateTime.fromISO(date).toFormat('DDDD T') -export function sortAndFilterEntries(entries: LocatedHistoryEntry[], toolbarState: HistoryToolbarState): LocatedHistoryEntry[] { +export const sortAndFilterEntries = (entries: HistoryEntry[], toolbarState: HistoryToolbarState): HistoryEntry[] => { const filteredBySelectedTagsEntries = filterBySelectedTags(entries, toolbarState.selectedTags) const filteredByKeywordSearchEntries = filterByKeywordSearch(filteredBySelectedTagsEntries, toolbarState.keywordSearch) return sortEntries(filteredByKeywordSearchEntries, toolbarState) } -function locateEntries(entries: HistoryEntry[], location: HistoryEntryOrigin): LocatedHistoryEntry[] { - return entries.map(entry => { - return { - ...entry, - location: location - } - }) -} - -export function mergeEntryArrays(localEntries: T[], remoteEntries: T[]): T[] { - const filteredLocalEntries = localEntries.filter(localEntry => { - const entry = remoteEntries.find(remoteEntry => remoteEntry.id === localEntry.id) - return !entry - }) - - return filteredLocalEntries.concat(remoteEntries) -} - -function filterBySelectedTags(entries: LocatedHistoryEntry[], selectedTags: string[]): LocatedHistoryEntry[] { +const filterBySelectedTags = (entries: HistoryEntry[], selectedTags: string[]): HistoryEntry[] => { return entries.filter(entry => { return (selectedTags.length === 0 || arrayCommonCheck(entry.tags, selectedTags)) } ) } -function arrayCommonCheck(array1: T[], array2: T[]): boolean { +const arrayCommonCheck = (array1: T[], array2: T[]): boolean => { const foundElement = array1.find((element1) => array2.find((element2) => element2 === element1 @@ -55,18 +33,21 @@ function arrayCommonCheck(array1: T[], array2: T[]): boolean { return !!foundElement } -function filterByKeywordSearch(entries: LocatedHistoryEntry[], keywords: string): LocatedHistoryEntry[] { +const filterByKeywordSearch = (entries: HistoryEntry[], keywords: string): HistoryEntry[] => { const searchTerm = keywords.toLowerCase() - return entries.filter(entry => entry.title.toLowerCase() - .includes(searchTerm)) + return entries.filter( + entry => entry.title + .toLowerCase() + .includes(searchTerm) + ) } -function sortEntries(entries: LocatedHistoryEntry[], viewState: HistoryToolbarState): LocatedHistoryEntry[] { +const sortEntries = (entries: HistoryEntry[], viewState: HistoryToolbarState): HistoryEntry[] => { return entries.sort((firstEntry, secondEntry) => { - if (firstEntry.pinned && !secondEntry.pinned) { + if (firstEntry.pinStatus && !secondEntry.pinStatus) { return -1 } - if (!firstEntry.pinned && secondEntry.pinned) { + if (!firstEntry.pinStatus && secondEntry.pinStatus) { return 1 } @@ -86,47 +67,3 @@ function sortEntries(entries: LocatedHistoryEntry[], viewState: HistoryToolbarSt return 0 }) } - -export function formatHistoryDate(date: string): string { - return DateTime.fromISO(date) - .toFormat('DDDD T') -} - -export interface V1HistoryEntry { - id: string; - text: string; - time: number; - tags: string[]; - pinned: boolean; -} - -export function convertV1History(oldHistory: V1HistoryEntry[]): HistoryEntry[] { - return oldHistory.map((entry: V1HistoryEntry) => { - return { - id: entry.id, - title: entry.text, - lastVisited: DateTime.fromMillis(entry.time) - .toISO(), - tags: entry.tags, - pinned: entry.pinned - } - }) -} - -export function loadHistoryFromLocalStore(): HistoryEntry[] { - const historyJsonString = window.localStorage.getItem('history') - - if (!historyJsonString) { - // if localStorage["history"] is empty we check the old localStorage["notehistory"] - // and convert it to the new format - const oldHistoryJsonString = window.localStorage.getItem('notehistory') - const oldHistory = oldHistoryJsonString ? JSON.parse(JSON.parse(oldHistoryJsonString)) as V1HistoryEntry[] : [] - return convertV1History(oldHistory) - } else { - return JSON.parse(historyJsonString) as HistoryEntry[] - } -} - -export function setHistoryToLocalStore(entries: HistoryEntry[]): void { - window.localStorage.setItem('history', JSON.stringify(entries)) -} diff --git a/src/redux/history/methods.ts b/src/redux/history/methods.ts new file mode 100644 index 000000000..4d51f2160 --- /dev/null +++ b/src/redux/history/methods.ts @@ -0,0 +1,197 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { store } from '../index' +import { + HistoryActionType, + HistoryEntry, + HistoryEntryOrigin, + HistoryExportJson, + RemoveEntryAction, + SetEntriesAction, + UpdateEntryAction, + V1HistoryEntry +} from './types' +import { download } from '../../components/common/download/download' +import { DateTime } from 'luxon' +import { + deleteHistory, + deleteHistoryEntry, + getHistory, + postHistory, + updateHistoryEntryPinStatus +} from '../../api/history' +import { + historyEntryDtoToHistoryEntry, + historyEntryToHistoryEntryPutDto, + historyEntryToHistoryEntryUpdateDto +} from '../../api/history/dto-methods' + +export const setHistoryEntries = (entries: HistoryEntry[]): void => { + store.dispatch({ + type: HistoryActionType.SET_ENTRIES, + entries + } as SetEntriesAction) + storeLocalHistory() +} + +export const importHistoryEntries = (entries: HistoryEntry[]): Promise => { + setHistoryEntries(entries) + return storeRemoteHistory() +} + +export const deleteAllHistoryEntries = (): Promise => { + store.dispatch({ + type: HistoryActionType.SET_ENTRIES, + entries: [] + }) + storeLocalHistory() + return deleteHistory() +} + +export const updateHistoryEntryRedux = (noteId: string, newEntry: HistoryEntry): void => { + store.dispatch({ + type: HistoryActionType.UPDATE_ENTRY, + noteId, + newEntry + } as UpdateEntryAction) +} + +export const updateLocalHistoryEntry = (noteId: string, newEntry: HistoryEntry): void => { + updateHistoryEntryRedux(noteId, newEntry) + storeLocalHistory() +} + +export const removeHistoryEntry = async (noteId: string): Promise => { + const entryToDelete = store.getState().history.find(entry => entry.identifier === noteId) + if (entryToDelete && entryToDelete.origin === HistoryEntryOrigin.REMOTE) { + await deleteHistoryEntry(noteId) + } + store.dispatch({ + type: HistoryActionType.REMOVE_ENTRY, + noteId + } as RemoveEntryAction) + storeLocalHistory() +} + +export const toggleHistoryEntryPinning = async (noteId: string): Promise => { + const state = store.getState().history + const entryToUpdate = state.find(entry => entry.identifier === noteId) + if (!entryToUpdate) { + return Promise.reject(`History entry for note '${ noteId }' not found`) + } + if (entryToUpdate.pinStatus === undefined) { + entryToUpdate.pinStatus = false + } + entryToUpdate.pinStatus = !entryToUpdate.pinStatus + if (entryToUpdate.origin === HistoryEntryOrigin.LOCAL) { + updateLocalHistoryEntry(noteId, entryToUpdate) + } else { + const historyUpdateDto = historyEntryToHistoryEntryUpdateDto(entryToUpdate) + updateHistoryEntryRedux(noteId, entryToUpdate) + await updateHistoryEntryPinStatus(noteId, historyUpdateDto) + } +} + +export const downloadHistory = (): void => { + const history = store.getState().history + history.forEach((entry: Partial) => { + delete entry.origin + }) + const json = JSON.stringify({ + version: 2, + entries: history + } as HistoryExportJson) + download(json, `history_${ Date.now() }.json`, 'application/json') +} + +export const mergeHistoryEntries = (a: HistoryEntry[], b: HistoryEntry[]): HistoryEntry[] => { + const noDuplicates = a.filter(entryA => !b.some(entryB => entryA.identifier === entryB.identifier)) + return noDuplicates.concat(b) +} + +export const convertV1History = (oldHistory: V1HistoryEntry[]): HistoryEntry[] => { + return oldHistory.map(entry => ({ + identifier: entry.id, + title: entry.text, + tags: entry.tags, + lastVisited: DateTime.fromMillis(entry.time) + .toISO(), + pinStatus: entry.pinned, + origin: HistoryEntryOrigin.LOCAL + })) +} + +export const refreshHistoryState = async (): Promise => { + const localEntries = loadLocalHistory() + if (!store.getState().user) { + setHistoryEntries(localEntries) + return + } + const remoteEntries = await loadRemoteHistory() + const allEntries = mergeHistoryEntries(localEntries, remoteEntries) + setHistoryEntries(allEntries) +} + +export const storeLocalHistory = (): void => { + const history = store.getState().history + const localEntries = history.filter(entry => entry.origin === HistoryEntryOrigin.LOCAL) + const entriesWithoutOrigin = localEntries.map(entry => ({ + ...entry, + origin: undefined + })) + window.localStorage.setItem('history', JSON.stringify(entriesWithoutOrigin)) +} + +export const storeRemoteHistory = (): Promise => { + if (!store.getState().user) { + return Promise.resolve() + } + const history = store.getState().history + const remoteEntries = history.filter(entry => entry.origin === HistoryEntryOrigin.REMOTE) + const remoteEntryDtos = remoteEntries.map(historyEntryToHistoryEntryPutDto) + return postHistory(remoteEntryDtos) +} + +const loadLocalHistory = (): HistoryEntry[] => { + const localV1Json = window.localStorage.getItem('notehistory') + if (localV1Json) { + try { + const localV1History = JSON.parse(JSON.parse(localV1Json)) as V1HistoryEntry[] + window.localStorage.removeItem('notehistory') + return convertV1History(localV1History) + } catch (error) { + console.error(`Error converting old history entries: ${ String(error) }`) + return [] + } + } + + const localJson = window.localStorage.getItem('history') + if (!localJson) { + return [] + } + + try { + const localHistory = JSON.parse(localJson) as HistoryEntry[] + localHistory.forEach(entry => { + entry.origin = HistoryEntryOrigin.LOCAL + }) + return localHistory + } catch (error) { + console.error(`Error parsing local stored history entries: ${ String(error) }`) + return [] + } +} + +const loadRemoteHistory = async (): Promise => { + try { + const remoteHistory = await getHistory() + return remoteHistory.map(historyEntryDtoToHistoryEntry) + } catch (error) { + console.error(`Error fetching history entries from server: ${ String(error) }`) + return [] + } +} diff --git a/src/redux/history/reducers.ts b/src/redux/history/reducers.ts new file mode 100644 index 000000000..1786a01f8 --- /dev/null +++ b/src/redux/history/reducers.ts @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Reducer } from 'redux' +import { + HistoryAction, + HistoryActionType, + HistoryEntry, + RemoveEntryAction, + SetEntriesAction, + UpdateEntryAction +} from './types' + +// Q: Why is the reducer initialized with an empty array instead of the actual history entries like in the config reducer? +// A: The history reducer will be created without entries because of async entry retrieval. +// Entries will be added after reducer initialization. + +export const HistoryReducer: Reducer = (state: HistoryEntry[] = [], action: HistoryAction) => { + switch (action.type) { + case HistoryActionType.SET_ENTRIES: + return (action as SetEntriesAction).entries + case HistoryActionType.UPDATE_ENTRY: + return [ + ...state.filter(entry => entry.identifier !== (action as UpdateEntryAction).noteId), + (action as UpdateEntryAction).newEntry + ] + case HistoryActionType.REMOVE_ENTRY: + return state.filter(entry => entry.identifier !== (action as RemoveEntryAction).noteId) + default: + return state + } +} diff --git a/src/redux/history/types.ts b/src/redux/history/types.ts new file mode 100644 index 000000000..3dc04d809 --- /dev/null +++ b/src/redux/history/types.ts @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Action } from 'redux' + +export enum HistoryEntryOrigin { + LOCAL, + REMOTE +} + +export interface HistoryEntry { + identifier: string + title: string + lastVisited: string + tags: string[] + pinStatus: boolean + origin: HistoryEntryOrigin +} + +export interface V1HistoryEntry { + id: string + text: string + time: number + tags: string[] + pinned: boolean +} + +export interface HistoryExportJson { + version: number, + entries: HistoryEntry[] +} + +export enum HistoryActionType { + SET_ENTRIES = 'SET_ENTRIES', + ADD_ENTRY = 'ADD_ENTRY', + UPDATE_ENTRY = 'UPDATE_ENTRY', + REMOVE_ENTRY = 'REMOVE_ENTRY' +} + +export interface HistoryAction extends Action { + type: HistoryActionType +} + +export interface SetEntriesAction extends HistoryAction { + type: HistoryActionType.SET_ENTRIES + entries: HistoryEntry[] +} + +export interface AddEntryAction extends HistoryAction { + type: HistoryActionType.ADD_ENTRY + newEntry: HistoryEntry +} + +export interface UpdateEntryAction extends HistoryAction { + type: HistoryActionType.UPDATE_ENTRY + noteId: string + newEntry: HistoryEntry +} + +export interface RemoveEntryAction extends HistoryEntry { + type: HistoryActionType.REMOVE_ENTRY + noteId: string +} diff --git a/src/redux/index.ts b/src/redux/index.ts index 3bcd38eed..823e4caf2 100644 --- a/src/redux/index.ts +++ b/src/redux/index.ts @@ -21,11 +21,14 @@ import { UserReducer } from './user/reducers' import { MaybeUserState } from './user/types' import { UiNotificationState } from './ui-notifications/types' import { UiNotificationReducer } from './ui-notifications/reducers' +import { HistoryEntry } from './history/types' +import { HistoryReducer } from './history/reducers' export interface ApplicationState { user: MaybeUserState; config: Config; banner: BannerState; + history: HistoryEntry[]; apiUrl: ApiUrlObject; editorConfig: EditorConfig; darkMode: DarkModeConfig; @@ -38,6 +41,7 @@ export const allReducers: Reducer = combineReducers { notificationId } as DismissUiNotificationAction) } + +// Promises catch errors as any. +// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/explicit-module-boundary-types +export const showErrorNotification = (message: string) => (error: any): void => { + console.error(message, error) + dispatchUiNotification(i18n.t('common.errorOccurred'), message, DEFAULT_DURATION_IN_SECONDS, 'exclamation-triangle') +} diff --git a/tsconfig.json b/tsconfig.json index 27186b9da..5027a9a9c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "es6", "lib": [ "dom", "dom.iterable",