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<HistoryEntry[]> => { +export const getHistory = async (): Promise<HistoryEntryDto[]> => { const response = await fetch(getApiUrl() + '/history') expectResponseCode(response) - return await response.json() as Promise<HistoryEntry[]> + return await response.json() as Promise<HistoryEntryDto[]> } -export const setHistory = async (entries: HistoryEntry[]): Promise<void> => { +export const postHistory = async (entries: HistoryEntryPutDto[]): Promise<void> => { 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<void> => { + const response = await fetch(getApiUrl() + '/history/' + noteId, { + ...defaultFetchConfig, + method: 'PUT', + body: JSON.stringify(entry) + }) + expectResponseCode(response) +} + +export const deleteHistoryEntry = async (noteId: string): Promise<void> => { + const response = await fetch(getApiUrl() + '/history/' + noteId, { + ...defaultFetchConfig, + method: 'DELETE' }) expectResponseCode(response) } @@ -31,21 +46,3 @@ export const deleteHistory = async (): Promise<void> => { }) expectResponseCode(response) } - -export const updateHistoryEntry = async (noteId: string, entry: HistoryEntry): Promise<HistoryEntry> => { - const response = await fetch(getApiUrl() + '/history/' + noteId, { - ...defaultFetchConfig, - method: 'PUT', - body: JSON.stringify(entry) - }) - expectResponseCode(response) - return await response.json() as Promise<HistoryEntry> -} - -export const deleteHistoryEntry = async (noteId: string): Promise<void> => { - 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<void> = 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<EntryMenuProps> = ({ id, title, location, isDark, onRemove, onDelete, className }) => { +export const EntryMenu: React.FC<EntryMenuProps> = ({ id, title, origin, isDark, onRemove, onDelete, className }) => { useTranslation() + const userExists = useSelector((state: ApplicationState) => !!state.user) + return ( <Dropdown className={ `d-inline-flex ${ className || '' }` }> <Dropdown.Toggle variant={ isDark ? 'secondary' : 'light' } id={ `dropdown-card-${ id }` } @@ -40,13 +44,13 @@ export const EntryMenu: React.FC<EntryMenuProps> = ({ id, title, location, isDar <Trans i18nKey="landing.history.menu.recentNotes"/> </Dropdown.Header> - <ShowIf condition={ location === HistoryEntryOrigin.LOCAL }> + <ShowIf condition={ origin === HistoryEntryOrigin.LOCAL }> <Dropdown.Item disabled> <ForkAwesomeIcon icon="laptop" fixedWidth={ true } className="mx-2"/> <Trans i18nKey="landing.history.menu.entryLocal"/> </Dropdown.Item> </ShowIf> - <ShowIf condition={ location === HistoryEntryOrigin.REMOTE }> + <ShowIf condition={ origin === HistoryEntryOrigin.REMOTE }> <Dropdown.Item disabled> <ForkAwesomeIcon icon="cloud" fixedWidth={ true } className="mx-2"/> <Trans i18nKey="landing.history.menu.entryRemote"/> @@ -54,9 +58,10 @@ export const EntryMenu: React.FC<EntryMenuProps> = ({ id, title, location, isDar </ShowIf> <RemoveNoteEntryItem onConfirm={ onRemove } noteTitle={ title }/> - <Dropdown.Divider/> - - <DeleteNoteItem onConfirm={ onDelete } noteTitle={ title }/> + <ShowIf condition={ userExists }> + <Dropdown.Divider/> + <DeleteNoteItem onConfirm={ onDelete } noteTitle={ title }/> + </ShowIf> </Dropdown.Menu> </Dropdown> ) 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<HistoryEntriesProps> = ({ entries, onPinClick, onRemoveClick, onDeleteClick, pageIndex, onLastPageIndexChange }) => { +export const HistoryCardList: React.FC<HistoryEntriesProps & HistoryEventHandlers> = ({ entries, onPinClick, onRemoveClick, onDeleteClick, pageIndex, onLastPageIndexChange }) => { return ( <Row className="justify-content-start"> <Pager numberOfElementsPerPage={ 9 } pageIndex={ pageIndex } onLastPageIndexChange={ onLastPageIndexChange }> { entries.map((entry) => ( <HistoryCard - key={ entry.id } + key={ entry.identifier } entry={ entry } onPinClick={ onPinClick } onRemoveClick={ onRemoveClick } diff --git a/src/components/history-page/history-card/history-card.tsx b/src/components/history-page/history-card/history-card.tsx index 307185732..badb9ce67 100644 --- a/src/components/history-page/history-card/history-card.tsx +++ b/src/components/history-page/history-card/history-card.tsx @@ -5,26 +5,34 @@ */ import { DateTime } from 'luxon' -import React from 'react' +import React, { useCallback } from 'react' import { Badge, Card } from 'react-bootstrap' import { Link } from 'react-router-dom' import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon' 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' import './history-card.scss' -export const HistoryCard: React.FC<HistoryEntryProps> = ({ entry, onPinClick, onRemoveClick, onDeleteClick }) => { +export const HistoryCard: React.FC<HistoryEntryProps & HistoryEventHandlers> = ({ entry, onPinClick, onRemoveClick, onDeleteClick }) => { + const onRemove = useCallback(() => { + onRemoveClick(entry.identifier) + }, [onRemoveClick, entry.identifier]) + + const onDelete = useCallback(() => { + onDeleteClick(entry.identifier) + }, [onDeleteClick, entry.identifier]) + return ( <div className="p-2 col-xs-12 col-sm-6 col-md-6 col-lg-4"> <Card className="card-min-height" text={ 'dark' } bg={ 'light' }> <Card.Body className="p-2 d-flex flex-row justify-content-between"> <div className={ 'd-flex flex-column' }> - <PinButton isDark={ false } isPinned={ entry.pinned } - onPinClick={ () => onPinClick(entry.id, entry.location) }/> + <PinButton isDark={ false } isPinned={ entry.pinStatus } + onPinClick={ () => onPinClick(entry.identifier) }/> </div> - <Link to={ `/n/${ entry.id }` } className="text-decoration-none flex-fill text-dark"> + <Link to={ `/n/${ entry.identifier }` } className="text-decoration-none flex-fill text-dark"> <div className={ 'd-flex flex-column justify-content-between' }> <Card.Title className="m-0 mt-1dot5">{ entry.title }</Card.Title> <div> @@ -44,12 +52,12 @@ export const HistoryCard: React.FC<HistoryEntryProps> = ({ entry, onPinClick, on </Link> <div className={ 'd-flex flex-column' }> <EntryMenu - id={ entry.id } + id={ entry.identifier } title={ entry.title } - location={ entry.location } + origin={ entry.origin } isDark={ false } - onRemove={ () => onRemoveClick(entry.id, entry.location) } - onDelete={ () => onDeleteClick(entry.id, entry.location) } + onRemove={ onRemove } + onDelete={ onDelete } /> </div> </Card.Body> 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<HistoryContentProps> = ({ viewState, entries, onPinClick, onRemoveClick, onDeleteClick }) => { - useTranslation() +export const HistoryContent: React.FC<HistoryContentProps> = ({ 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 ( <Row className={ 'justify-content-center' }> 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<HistoryEntry[]>(loadHistoryFromLocalStore) - const [remoteHistoryEntries, setRemoteHistoryEntries] = useState<HistoryEntry[]>([]) + const { t } = useTranslation() + + const allEntries = useSelector((state: ApplicationState) => state.history) const [toolbarState, setToolbarState] = useState<HistoryToolbarState>(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<string[]>(() => { - 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<LocatedHistoryEntry[]>(() => + const entriesToShow = useMemo<HistoryEntry[]>(() => sortAndFilterEntries(allEntries, toolbarState), [allEntries, toolbarState]) - return <Fragment> - <ErrorModal show={ error !== '' } onHide={ resetError } - titleI18nKey={ error !== '' ? `landing.history.error.${ error }.title` : '' }> - <h5> - <Trans i18nKey={ error !== '' ? `landing.history.error.${ error }.text` : '' }/> - </h5> - </ErrorModal> - <h1 className="mb-4"><Trans i18nKey="landing.navigation.history"/></h1> - <Row className={ 'justify-content-center mt-5 mb-3' }> - <HistoryToolbar - onSettingsChange={ setToolbarState } - tags={ tags } - onClearHistory={ clearHistory } - onRefreshHistory={ refreshHistory } - onExportHistory={ exportHistory } - onImportHistory={ importHistory } - onUploadAll={ uploadAll } + useEffect(() => { + refreshHistoryState().catch( + showErrorNotification(t('landing.history.error.getHistory.text')) + ) + }, [t]) + + return ( + <Fragment> + <h1 className="mb-4"> + <Trans i18nKey="landing.navigation.history"/> + </h1> + <Row className={ 'justify-content-center mt-5 mb-3' }> + <HistoryToolbar + onSettingsChange={ setToolbarState } + /> + </Row> + <HistoryContent + viewState={ toolbarState.viewState } + entries={ entriesToShow } /> - </Row> - <HistoryContent - viewState={ toolbarState.viewState } - entries={ entriesToShow } - onPinClick={ pinClick } - onRemoveClick={ removeFromHistoryClick } - onDeleteClick={ deleteNoteClick } - /> - </Fragment> + </Fragment> + ) } 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<HistoryEntryProps> = ({ entry, onPinClick, onRemoveClick, onDeleteClick }) => { +export const HistoryTableRow: React.FC<HistoryEntryProps & HistoryEventHandlers> = ({ entry, onPinClick, onRemoveClick, onDeleteClick }) => { return ( <tr> <td> - <Link to={ `/n/${ entry.id }` } className="text-light"> + <Link to={ `/n/${ entry.identifier }` } className="text-light"> { entry.title } </Link> </td> @@ -28,15 +28,15 @@ export const HistoryTableRow: React.FC<HistoryEntryProps> = ({ entry, onPinClick } </td> <td> - <PinButton isDark={ true } isPinned={ entry.pinned } onPinClick={ () => onPinClick(entry.id, entry.location) } + <PinButton isDark={ true } isPinned={ entry.pinStatus } onPinClick={ () => onPinClick(entry.identifier) } className={ 'mb-1 mr-1' }/> <EntryMenu - id={ entry.id } + id={ entry.identifier } title={ entry.title } - location={ entry.location } + origin={ entry.origin } isDark={ true } - onRemove={ () => onRemoveClick(entry.id, entry.location) } - onDelete={ () => onDeleteClick(entry.id, entry.location) } + onRemove={ () => onRemoveClick(entry.identifier) } + onDelete={ () => onDeleteClick(entry.identifier) } /> </td> </tr> 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<HistoryEntriesProps> = ({ entries, onPinClick, onRemoveClick, onDeleteClick, pageIndex, onLastPageIndexChange }) => { +export const HistoryTable: React.FC<HistoryEntriesProps & HistoryEventHandlers> = ({ entries, onPinClick, onRemoveClick, onDeleteClick, pageIndex, onLastPageIndexChange }) => { useTranslation() return ( <Table striped bordered hover size="sm" variant="dark" className={ 'history-table' }> @@ -29,7 +29,7 @@ export const HistoryTable: React.FC<HistoryEntriesProps> = ({ entries, onPinClic { entries.map((entry) => <HistoryTableRow - key={ entry.id } + key={ entry.identifier } entry={ entry } onPinClick={ onPinClick } onRemoveClick={ onRemoveClick } diff --git a/src/components/history-page/history-toolbar/clear-history-button.tsx b/src/components/history-page/history-toolbar/clear-history-button.tsx index 250067e0f..35bf9bd71 100644 --- a/src/components/history-page/history-toolbar/clear-history-button.tsx +++ b/src/components/history-page/history-toolbar/clear-history-button.tsx @@ -4,33 +4,38 @@ SPDX-License-Identifier: AGPL-3.0-only */ -import React, { Fragment, useState } from 'react' +import React, { Fragment, useCallback, useState } from 'react' import { Button } from 'react-bootstrap' import { Trans, useTranslation } from 'react-i18next' import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon' import { DeletionModal } from '../../common/modals/deletion-modal' +import { deleteAllHistoryEntries, refreshHistoryState } from '../../../redux/history/methods' +import { showErrorNotification } from '../../../redux/ui-notifications/methods' -export interface ClearHistoryButtonProps { - onClearHistory: () => void -} - -export const ClearHistoryButton: React.FC<ClearHistoryButtonProps> = ({ 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 ( <Fragment> <Button variant={ 'light' } title={ t('landing.history.toolbar.clear') } onClick={ handleShow }> <ForkAwesomeIcon icon={ 'trash' }/> </Button> <DeletionModal - onConfirm={ () => { - 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<ExportHistoryButtonProps> = ({ onExportHistory }) => { +export const ExportHistoryButton: React.FC = () => { const { t } = useTranslation() return ( - <Button variant={ 'light' } title={ t('landing.history.toolbar.export') } onClick={ onExportHistory }> + <Button variant={ 'light' } title={ t('landing.history.toolbar.export') } onClick={ downloadHistory }> <ForkAwesomeIcon icon='download'/> </Button> ) 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<HistoryToolbarProps> = ({ onSettingsChange, tags, onClearHistory, onRefreshHistory, onExportHistory, onImportHistory, onUploadAll }) => { - const [t] = useTranslation() +export const HistoryToolbar: React.FC<HistoryToolbarProps> = ({ onSettingsChange }) => { + const { t } = useTranslation() const [state, setState] = useState<HistoryToolbarState>(initState) + const historyEntries = useSelector((state: ApplicationState) => state.history) const userExists = useSelector((state: ApplicationState) => !!state.user) + const tags = useMemo<string[]>(() => { + 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<HistoryToolbarProps> = ({ 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<HistoryToolbarProps> = ({ onSettingsChange variant={ 'light' }><Trans i18nKey={ 'landing.history.toolbar.sortByLastVisited' }/></SortButton> </InputGroup> <InputGroup className={ 'mr-1 mb-1' }> - <ExportHistoryButton onExportHistory={ onExportHistory }/> + <ExportHistoryButton/> </InputGroup> <InputGroup className={ 'mr-1 mb-1' }> - <ImportHistoryButton onImportHistory={ onImportHistory }/> + <ImportHistoryButton/> </InputGroup> <InputGroup className={ 'mr-1 mb-1' }> - <ClearHistoryButton onClearHistory={ onClearHistory }/> + <ClearHistoryButton/> </InputGroup> <InputGroup className={ 'mr-1 mb-1' }> - <Button variant={ 'light' } title={ t('landing.history.toolbar.refresh') } onClick={ onRefreshHistory }> - <ForkAwesomeIcon icon='refresh'/> + <Button variant={ 'light' } title={ t('landing.history.toolbar.refresh') } onClick={ refreshHistory }> + <ForkAwesomeIcon icon="refresh"/> </Button> </InputGroup> <ShowIf condition={ userExists }> <InputGroup className={ 'mr-1 mb-1' }> - <Button variant={ 'light' } title={ t('landing.history.toolbar.uploadAll') } onClick={ onUploadAll }> - <ForkAwesomeIcon icon='cloud-upload'/> + <Button variant={ 'light' } title={ t('landing.history.toolbar.uploadAll') } onClick={ onUploadAllToRemote }> + <ForkAwesomeIcon icon="cloud-upload"/> </Button> </InputGroup> </ShowIf> <InputGroup className={ 'mr-1 mb-1' }> - <ToggleButtonGroup type="radio" name="options" dir='ltr' value={ state.viewState } className={ 'button-height' } + <ToggleButtonGroup type="radio" name="options" dir="ltr" value={ state.viewState } className={ 'button-height' } onChange={ (newViewState: ViewStateEnum) => { 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<ImportHistoryButtonProps> = ({ 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<HTMLInputElement>(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<HTMLInputElement>) => { const { validity, files } = event.target @@ -47,7 +63,7 @@ export const ImportHistoryButton: React.FC<ImportHistoryButtonProps> = ({ 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<T extends HistoryEntry>(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<T>(array1: T[], array2: T[]): boolean { +const arrayCommonCheck = <T> (array1: T[], array2: T[]): boolean => { const foundElement = array1.find((element1) => array2.find((element2) => element2 === element1 @@ -55,18 +33,21 @@ function arrayCommonCheck<T>(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<void> => { + setHistoryEntries(entries) + return storeRemoteHistory() +} + +export const deleteAllHistoryEntries = (): Promise<void> => { + 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<void> => { + 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<void> => { + 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<HistoryEntry>) => { + 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<void> => { + 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<void> => { + 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<HistoryEntry[]> => { + 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<HistoryEntry[], HistoryAction> = (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<HistoryActionType> { + 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<ApplicationState> = combineReducers<Applicatio config: ConfigReducer, banner: BannerReducer, apiUrl: ApiUrlReducer, + history: HistoryReducer, editorConfig: EditorConfigReducer, darkMode: DarkModeConfigReducer, noteDetails: NoteDetailsReducer, diff --git a/src/redux/ui-notifications/methods.ts b/src/redux/ui-notifications/methods.ts index 6e2cbf2d7..8979df41b 100644 --- a/src/redux/ui-notifications/methods.ts +++ b/src/redux/ui-notifications/methods.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import i18n from 'i18next' import { store } from '../index' import { DismissUiNotificationAction, @@ -37,3 +38,10 @@ export const dismissUiNotification = (notificationId: number): void => { 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",