The History PR: I - Move to redux (#1156)

This commit is contained in:
Erik Michelson 2021-04-22 22:46:24 +02:00 committed by GitHub
parent bba2b207c4
commit 8e5a667d18
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 629 additions and 417 deletions

View file

@ -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')
})
})

View file

@ -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 - Lets meet! (2020-06-06 18:00 UTC / 20:00 CEST)",
"lastVisited": "2020-05-24T16:04:36.433Z",
"pinStatus": false,
"tags": [
"agenda",
"HedgeDoc community",

View file

@ -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
}
}

View file

@ -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)
}

23
src/api/history/types.d.ts vendored Normal file
View file

@ -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
}

View file

@ -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()

View file

@ -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>
)

View file

@ -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 }

View file

@ -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>

View file

@ -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' }>

View file

@ -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>
)
}

View file

@ -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>

View file

@ -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 }

View file

@ -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 }

View file

@ -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>
)

View file

@ -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)
} }>

View file

@ -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) {

View file

@ -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))
}

View file

@ -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 []
}
}

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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,

View file

@ -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')
}

View file

@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es5",
"target": "es6",
"lib": [
"dom",
"dom.iterable",