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

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