mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-18 09:04:44 -04:00
The History PR: I - Move to redux (#1156)
This commit is contained in:
parent
bba2b207c4
commit
8e5a667d18
24 changed files with 629 additions and 417 deletions
197
src/redux/history/methods.ts
Normal file
197
src/redux/history/methods.ts
Normal 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 []
|
||||
}
|
||||
}
|
35
src/redux/history/reducers.ts
Normal file
35
src/redux/history/reducers.ts
Normal 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
|
||||
}
|
||||
}
|
66
src/redux/history/types.ts
Normal file
66
src/redux/history/types.ts
Normal 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
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue