Context menu to each history entry (#171)

- added entry-menu
- added subsection in entry-menu with the location of the history entry and the action to remove an entry from history
- added uploadAll functionality
- show uploadAll Button in history only if the user is logged in
- added deleteNote api call

Signed-off-by: Philip Molares <philip.molares@udo.edu>
This commit is contained in:
Philip Molares 2020-06-09 22:35:09 +02:00 committed by GitHub
parent 107a8eeaaf
commit 72a161ea16
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 238 additions and 110 deletions

View file

@ -26,6 +26,22 @@
"setHistory": {
"title": "Upload History Error",
"text": "While trying to upload the history to the server an error occurred"
},
"deleteNote": {
"title": "Delete Note Error",
"text": "While trying to delete a note on the server an error occurred"
},
"updateEntry": {
"title": "Update History Entry Error",
"text": "While trying to update a history entry on the server an error occurred"
},
"deleteEntry": {
"title": "Delete History Entry Error",
"text": "While trying to delete a history entry on the server an error occurred"
},
"notFoundEntry": {
"title": "History Entry not found",
"text": "We can't find the history entry you requested."
}
},
"noHistory": "No history",
@ -40,7 +56,8 @@
"export": "Export history",
"import": "Import history",
"clear": "Clear history",
"refresh": "Refresh history"
"refresh": "Refresh history",
"uploadAll": "Sync the complete history to the server"
},
"modal": {
"clearHistory": {
@ -60,6 +77,13 @@
"actions": "Actions",
"tags": "Tags",
"lastVisit": "Last Visit"
},
"menu": {
"recentNotes": "Recent notes",
"entryLocal": "Saved in your browser history",
"entryRemote": "Saved in your user history",
"removeEntry": "Remove from history",
"deleteNote": "Delete note"
}
},
"navigation": {

10
src/api/note.ts Normal file
View file

@ -0,0 +1,10 @@
import { expectResponseCode, getBackendUrl } from '../utils/apiUtils'
import { defaultFetchConfig } from './default'
export const deleteNote = async (noteId: string): Promise<void> => {
const response = await fetch(getBackendUrl() + `/notes/${noteId}`, {
...defaultFetchConfig,
method: 'DELETE'
})
expectResponseCode(response)
}

View file

@ -1,9 +0,0 @@
.history-close {
.fa {
opacity: 0.5;
}
&:hover .fa {
opacity: 1;
}
}

View file

@ -1,19 +0,0 @@
import React from 'react'
import { Button } from 'react-bootstrap'
import { ForkAwesomeIcon } from '../../../../common/fork-awesome/fork-awesome-icon'
import './close-button.scss'
export interface CloseButtonProps {
isDark: boolean;
className?: string
}
const CloseButton: React.FC<CloseButtonProps> = ({ isDark, className }) => {
return (
<Button variant={isDark ? 'secondary' : 'light'} className={`history-close ${className || ''}`}>
<ForkAwesomeIcon icon="times"/>
</Button>
)
}
export { CloseButton }

View file

@ -0,0 +1,10 @@
.history-menu {
.fa, &::after {
opacity: 0.5;
}
&:hover .fa, &:hover::after {
opacity: 1;
}
}

View file

@ -0,0 +1,59 @@
import React from 'react'
import { Dropdown } from 'react-bootstrap'
import { Trans } from 'react-i18next'
import { ForkAwesomeIcon } from '../../../../common/fork-awesome/fork-awesome-icon'
import { ShowIf } from '../../../../common/show-if/show-if'
import { HistoryEntryOrigin } from '../history'
import './entry-menu.scss'
export interface EntryMenuProps {
id: string;
location: HistoryEntryOrigin
isDark: boolean;
onRemove: () => void
onDelete: () => void
className?: string
}
const EntryMenu: React.FC<EntryMenuProps> = ({ id, location, isDark, onRemove, onDelete, className }) => {
return (
<Dropdown className={className || ''}>
<Dropdown.Toggle size="sm" variant={isDark ? 'secondary' : 'light'} id={`dropdown-card-${id}`} className='history-menu d-flex align-items-center'>
<ForkAwesomeIcon icon="ellipsis-h" className='history-menu'/>
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Header>
<Trans i18nKey="landing.history.menu.recentNotes"/>
</Dropdown.Header>
<ShowIf condition={location === 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}>
<Dropdown.Item disabled>
<ForkAwesomeIcon icon="cloud" fixedWidth={true} className="mx-2"/>
<Trans i18nKey="landing.history.menu.entryRemote"/>
</Dropdown.Item>
</ShowIf>
<Dropdown.Item onClick={onRemove}>
<ForkAwesomeIcon icon="archive" fixedWidth={true} className="mx-2"/>
<Trans i18nKey="landing.history.menu.removeEntry"/>
</Dropdown.Item>
<Dropdown.Divider/>
<Dropdown.Item onClick={onDelete}>
<ForkAwesomeIcon icon="trash" fixedWidth={true} className="mx-2"/>
<Trans i18nKey="landing.history.menu.deleteNote"/>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
)
}
export { EntryMenu }

View file

@ -1,10 +0,0 @@
.sync-icon {
.fa {
opacity: 0.2;
transition: opacity 0.2s ease-in-out, color 0.2s ease-in-out;
}
&:hover .fa {
opacity: 1;
}
}

View file

@ -1,21 +0,0 @@
import React from 'react'
import { Button } from 'react-bootstrap'
import { ForkAwesomeIcon } from '../../../../common/fork-awesome/fork-awesome-icon'
import { HistoryEntryOrigin } from '../history'
import './sync-status.scss'
export interface SyncStatusProps {
isDark: boolean
location: HistoryEntryOrigin
onSync: () => void
className?: string
}
export const SyncStatus: React.FC<SyncStatusProps> = ({ isDark, location, onSync, className }) => {
const icon = location === HistoryEntryOrigin.REMOTE ? 'cloud' : 'laptop'
return (
<Button variant={isDark ? 'secondary' : 'light'} onClick={onSync} className={`sync-icon ${className || ''}`}>
<ForkAwesomeIcon icon={icon}/>
</Button>
)
}

View file

@ -4,7 +4,7 @@ import { Pager } from '../../../../common/pagination/pager'
import { HistoryEntriesProps } from '../history-content/history-content'
import { HistoryCard } from './history-card'
export const HistoryCardList: React.FC<HistoryEntriesProps> = ({ entries, onPinClick, onSyncClick, pageIndex, onLastPageIndexChange }) => {
export const HistoryCardList: React.FC<HistoryEntriesProps> = ({ entries, onPinClick, onRemoveClick, onDeleteClick, pageIndex, onLastPageIndexChange }) => {
return (
<Row className="justify-content-start">
<Pager numberOfElementsPerPage={9} pageIndex={pageIndex} onLastPageIndexChange={onLastPageIndexChange}>
@ -14,7 +14,8 @@ export const HistoryCardList: React.FC<HistoryEntriesProps> = ({ entries, onPinC
key={entry.id}
entry={entry}
onPinClick={onPinClick}
onSyncClick={onSyncClick}
onRemoveClick={onRemoveClick}
onDeleteClick={onDeleteClick}
/>))
}
</Pager>

View file

@ -3,21 +3,18 @@ import React from 'react'
import { Badge, Card } from 'react-bootstrap'
import { formatHistoryDate } from '../../../../../utils/historyUtils'
import { ForkAwesomeIcon } from '../../../../common/fork-awesome/fork-awesome-icon'
import { CloseButton } from '../common/close-button'
import { EntryMenu } from '../common/entry-menu'
import { PinButton } from '../common/pin-button'
import { SyncStatus } from '../common/sync-status'
import { HistoryEntryProps } from '../history-content/history-content'
import './history-card.scss'
export const HistoryCard: React.FC<HistoryEntryProps> = ({ entry, onPinClick, onSyncClick }) => {
export const HistoryCard: React.FC<HistoryEntryProps> = ({ entry, onPinClick, onRemoveClick }) => {
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)}/>
<SyncStatus isDark={false} location={entry.location} onSync={() => onSyncClick(entry.id)}
className={'mt-1'}/>
<PinButton isDark={false} isPinned={entry.pinned} onPinClick={() => onPinClick(entry.id, entry.location)}/>
</div>
<div className={'d-flex flex-column justify-content-between'}>
<Card.Title className="m-0 mt-1dot5">{entry.title}</Card.Title>
@ -34,7 +31,13 @@ export const HistoryCard: React.FC<HistoryEntryProps> = ({ entry, onPinClick, on
</div>
</div>
<div className={'d-flex flex-column'}>
<CloseButton isDark={false}/>
<EntryMenu
id={entry.id}
location={entry.location}
isDark={false}
onRemove={() => onRemoveClick(entry.id, entry.location)}
onDelete={() => onRemoveClick(entry.id, entry.location)}
/>
</div>
</Card.Body>
</Card>

View file

@ -1,34 +1,39 @@
import React, { Fragment, useState } from 'react'
import { Alert, Row } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import { LocatedHistoryEntry } from '../history'
import { PagerPagination } from '../../../../common/pagination/pager-pagination'
import { HistoryEntryOrigin, LocatedHistoryEntry } from '../history'
import { HistoryCardList } from '../history-card/history-card-list'
import { HistoryTable } from '../history-table/history-table'
import { ViewStateEnum } from '../history-toolbar/history-toolbar'
type OnEntryClick = (entryId: string, location: HistoryEntryOrigin) => void
export interface HistoryContentProps {
viewState: ViewStateEnum
entries: LocatedHistoryEntry[]
onPinClick: (entryId: string) => void
onSyncClick: (entryId: string) => void
onPinClick: OnEntryClick
onRemoveClick: OnEntryClick
onDeleteClick: OnEntryClick
}
export interface HistoryEntryProps {
entry: LocatedHistoryEntry,
onPinClick: (entryId: string) => void
onSyncClick: (entryId: string) => void
onPinClick: OnEntryClick
onRemoveClick: OnEntryClick
onDeleteClick: OnEntryClick
}
export interface HistoryEntriesProps {
entries: LocatedHistoryEntry[]
onPinClick: (entryId: string) => void
onSyncClick: (entryId: string) => void
onPinClick: OnEntryClick
onRemoveClick: OnEntryClick
onDeleteClick: OnEntryClick
pageIndex: number
onLastPageIndexChange: (lastPageIndex: number) => void
}
export const HistoryContent: React.FC<HistoryContentProps> = ({ viewState, entries, onPinClick, onSyncClick }) => {
export const HistoryContent: React.FC<HistoryContentProps> = ({ viewState, entries, onPinClick, onRemoveClick, onDeleteClick }) => {
useTranslation()
const [pageIndex, setPageIndex] = useState(0)
const [lastPageIndex, setLastPageIndex] = useState(0)
@ -49,11 +54,16 @@ export const HistoryContent: React.FC<HistoryContentProps> = ({ viewState, entri
case ViewStateEnum.CARD:
return <HistoryCardList entries={entries}
onPinClick={onPinClick}
onSyncClick={onSyncClick}
onRemoveClick={onRemoveClick}
onDeleteClick={onDeleteClick}
pageIndex={pageIndex}
onLastPageIndexChange={setLastPageIndex}/>
case ViewStateEnum.TABLE:
return <HistoryTable entries={entries} onPinClick={onPinClick} onSyncClick={onSyncClick} pageIndex={pageIndex}
return <HistoryTable entries={entries}
onPinClick={onPinClick}
onRemoveClick={onRemoveClick}
onDeleteClick={onDeleteClick}
pageIndex={pageIndex}
onLastPageIndexChange={setLastPageIndex}/>
}
}

View file

@ -1,12 +1,11 @@
import React from 'react'
import { Badge } from 'react-bootstrap'
import { formatHistoryDate } from '../../../../../utils/historyUtils'
import { CloseButton } from '../common/close-button'
import { EntryMenu } from '../common/entry-menu'
import { PinButton } from '../common/pin-button'
import { SyncStatus } from '../common/sync-status'
import { HistoryEntryProps } from '../history-content/history-content'
export const HistoryTableRow: React.FC<HistoryEntryProps> = ({ entry, onPinClick, onSyncClick }) => {
export const HistoryTableRow: React.FC<HistoryEntryProps> = ({ entry, onPinClick, onRemoveClick }) => {
return (
<tr>
<td>{entry.title}</td>
@ -18,9 +17,14 @@ export const HistoryTableRow: React.FC<HistoryEntryProps> = ({ entry, onPinClick
}
</td>
<td>
<SyncStatus isDark={true} location={entry.location} onSync={() => onSyncClick(entry.id)} className={'mb-1 mr-1'}/>
<PinButton isDark={true} isPinned={entry.pinned} onPinClick={() => onPinClick(entry.id)} className={'mb-1 mr-1'}/>
<CloseButton isDark={true} className={'mb-1 mr-1'}/>
<PinButton isDark={true} isPinned={entry.pinned} onPinClick={() => onPinClick(entry.id, entry.location)} className={'mb-1 mr-1'}/>
<EntryMenu
id={entry.id}
location={entry.location}
isDark={true}
onRemove={() => onRemoveClick(entry.id, entry.location)}
onDelete={() => onRemoveClick(entry.id, entry.location)}
/>
</td>
</tr>
)

View file

@ -6,7 +6,7 @@ import { HistoryEntriesProps } from '../history-content/history-content'
import { HistoryTableRow } from './history-table-row'
import './history-table.scss'
export const HistoryTable: React.FC<HistoryEntriesProps> = ({ entries, onPinClick, onSyncClick, pageIndex, onLastPageIndexChange }) => {
export const HistoryTable: React.FC<HistoryEntriesProps> = ({ entries, onPinClick, onRemoveClick, onDeleteClick, pageIndex, onLastPageIndexChange }) => {
useTranslation()
return (
<Table striped bordered hover size="sm" variant="dark" className={'history-table'}>
@ -26,7 +26,8 @@ export const HistoryTable: React.FC<HistoryEntriesProps> = ({ entries, onPinClic
key={entry.id}
entry={entry}
onPinClick={onPinClick}
onSyncClick={onSyncClick}
onRemoveClick={onRemoveClick}
onDeleteClick={onDeleteClick}
/>)
}
</Pager>

View file

@ -2,7 +2,10 @@ import React, { ChangeEvent, useEffect, useState } from 'react'
import { Button, Form, FormControl, InputGroup, ToggleButton, ToggleButtonGroup } from 'react-bootstrap'
import { Typeahead } from 'react-bootstrap-typeahead'
import { Trans, useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import { ApplicationState } from '../../../../../redux'
import { ForkAwesomeIcon } from '../../../../common/fork-awesome/fork-awesome-icon'
import { ShowIf } from '../../../../common/show-if/show-if'
import { SortButton, SortModeEnum } from '../../../../common/sort-button/sort-button'
import { HistoryEntry } from '../history'
import { ClearHistoryButton } from './clear-history-button'
@ -32,6 +35,7 @@ export interface HistoryToolbarProps {
onRefreshHistory: () => void
onExportHistory: () => void
onImportHistory: (entries: HistoryEntry[]) => void
onUploadAll: () => void
}
export const initState: HistoryToolbarState = {
@ -42,9 +46,10 @@ export const initState: HistoryToolbarState = {
selectedTags: []
}
export const HistoryToolbar: React.FC<HistoryToolbarProps> = ({ onSettingsChange, tags, onClearHistory, onRefreshHistory, onExportHistory, onImportHistory }) => {
export const HistoryToolbar: React.FC<HistoryToolbarProps> = ({ onSettingsChange, tags, onClearHistory, onRefreshHistory, onExportHistory, onImportHistory, onUploadAll }) => {
const [t] = useTranslation()
const [state, setState] = useState<HistoryToolbarState>(initState)
const user = useSelector((state: ApplicationState) => state.user)
const titleSortChanged = (direction: SortModeEnum) => {
setState(prevState => ({
@ -113,6 +118,13 @@ export const HistoryToolbar: React.FC<HistoryToolbarProps> = ({ onSettingsChange
<ForkAwesomeIcon icon='refresh'/>
</Button>
</InputGroup>
<ShowIf condition={!!user}>
<InputGroup className={'mr-1 mb-1'}>
<Button variant={'light'} title={t('landing.history.toolbar.uploadAll')} onClick={onUploadAll}>
<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'}
onChange={(newViewState: ViewStateEnum) => {

View file

@ -2,7 +2,8 @@ import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'reac
import { Row } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import { deleteHistory, getHistory, setHistory } from '../../../../api/history'
import { deleteHistory, deleteHistoryEntry, getHistory, setHistory, updateHistoryEntry } from '../../../../api/history'
import { deleteNote } from '../../../../api/note'
import { ApplicationState } from '../../../../redux'
import {
collectEntries,
@ -65,10 +66,9 @@ export const History: React.FC = () => {
.then(() => setRemoteHistoryEntries(entries))
.catch(() => setError('setHistory'))
} else {
historyWrite(entries)
setLocalHistoryEntries(entries)
}
}, [historyWrite, user])
}, [user])
const refreshHistory = useCallback(() => {
const localHistory = loadHistoryFromLocalStore()
@ -102,13 +102,45 @@ export const History: React.FC = () => {
historyWrite([])
}, [historyWrite, user])
const syncClick = useCallback((entryId: string): void => {
console.log(entryId)
// ToDo: add syncClick
const uploadAll = useCallback((): void => {
const newHistory = mergeEntryArrays(localHistoryEntries, remoteHistoryEntries)
if (user) {
setHistory(newHistory)
.then(() => {
setRemoteHistoryEntries(newHistory)
setLocalHistoryEntries([])
historyWrite([])
})
.catch(() => setError('setHistory'))
}
}, [historyWrite, localHistoryEntries, remoteHistoryEntries, user])
const deleteClick = useCallback((entryId: string, location: HistoryEntryOrigin): void => {
if (user) {
deleteNote(entryId)
.then(() => {
if (location === HistoryEntryOrigin.LOCAL) {
setLocalHistoryEntries(entries => entries.filter(entry => entry.id !== entryId))
} else if (location === HistoryEntryOrigin.REMOTE) {
setRemoteHistoryEntries(entries => entries.filter(entry => entry.id !== entryId))
}
})
.catch(() => setError('deleteNote'))
}
}, [user])
const removeClick = 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 pinClick = useCallback((entryId: string): void => {
// ToDo: determine if entry is local or remote
const pinClick = useCallback((entryId: string, location: HistoryEntryOrigin): void => {
if (location === HistoryEntryOrigin.LOCAL) {
setLocalHistoryEntries((entries) => {
return entries.map((entry) => {
if (entry.id === entryId) {
@ -117,7 +149,25 @@ export const History: React.FC = () => {
return entry
})
})
}, [])
} else if (location === HistoryEntryOrigin.REMOTE) {
const entry = remoteHistoryEntries.find(entry => entry.id === entryId)
if (!entry) {
setError('notFoundEntry')
return
}
entry.pinned = !entry.pinned
updateHistoryEntry(entryId, entry)
.then(() => setRemoteHistoryEntries((entries) => {
return entries.map((entry) => {
if (entry.id === entryId) {
entry.pinned = !entry.pinned
}
return entry
})
}))
.catch(() => setError('updateEntry'))
}
}, [remoteHistoryEntries])
const resetError = () => {
setError('')
@ -159,12 +209,15 @@ export const History: React.FC = () => {
onRefreshHistory={refreshHistory}
onExportHistory={exportHistory}
onImportHistory={importHistory}
onUploadAll={uploadAll}
/>
</Row>
<HistoryContent viewState={toolbarState.viewState}
<HistoryContent
viewState={toolbarState.viewState}
entries={entriesToShow}
onPinClick={pinClick}
onSyncClick={syncClick}
onRemoveClick={removeClick}
onDeleteClick={deleteClick}
/>
</Fragment>
)

View file

@ -29,13 +29,13 @@ function locateEntries (entries: HistoryEntry[], location: HistoryEntryOrigin):
})
}
export function mergeEntryArrays<T extends HistoryEntry> (locatedLocalEntries: T[], locatedRemoteEntries: T[]): T[] {
const filteredLocalEntries = locatedLocalEntries.filter(localEntry => {
const entry = locatedRemoteEntries.find(remoteEntry => remoteEntry.id === localEntry.id)
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(locatedRemoteEntries)
return filteredLocalEntries.concat(remoteEntries)
}
function filterBySelectedTags (entries: LocatedHistoryEntry[], selectedTags: string[]): LocatedHistoryEntry[] {