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

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