mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-30 06:45:47 -04:00
Add revisions dialog (#485)
* Add mock files Note that revisions-list needs to be called revisions in the reality to be confirm with the API spec, but our mocking solution doesn't allow that... * Add revisions API calls * Fix line endings in mock files * Extend CommonModal to accept size and additionalClasses * Clarify variable name in API request * Add react-diff-viewer as dependency * Add revision chooser modal * Fix type of route params * Added and updated mock files * Added user-icon list per revision * Added translation to alt text of avatars * Updated mock file to remove inconsistencies * Add caching for revisions * Sort mock file revisions-list descending by timestamp * Pre-select first/newest revision on first modal open * Regenerated yarn.lock file from scratch * Applied requested changes in variable names and line lengths * User UserAvatar component instead of manually set image * Move revision-modal-list-entry to own component * Removed unnecessary return statements
This commit is contained in:
parent
0fecda027c
commit
d597438c42
23 changed files with 455 additions and 29 deletions
|
@ -1,19 +1,12 @@
|
|||
import { LoginProvider } from '../../redux/user/types'
|
||||
import { UserResponse } from '../users/types'
|
||||
import { expectResponseCode, getApiUrl, defaultFetchConfig } from '../utils'
|
||||
|
||||
export const getMe = async (): Promise<meResponse> => {
|
||||
export const getMe = async (): Promise<UserResponse> => {
|
||||
const response = await fetch(getApiUrl() + '/me', {
|
||||
...defaultFetchConfig
|
||||
})
|
||||
expectResponseCode(response)
|
||||
return (await response.json()) as meResponse
|
||||
}
|
||||
|
||||
export interface meResponse {
|
||||
id: string
|
||||
name: string
|
||||
photo: string
|
||||
provider: LoginProvider
|
||||
return (await response.json()) as UserResponse
|
||||
}
|
||||
|
||||
export const updateDisplayName = async (displayName: string): Promise<void> => {
|
||||
|
|
30
src/api/revisions/index.ts
Normal file
30
src/api/revisions/index.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
|
||||
|
||||
export interface Revision {
|
||||
content: string
|
||||
timestamp: number
|
||||
authors: string[]
|
||||
}
|
||||
|
||||
export interface RevisionListEntry {
|
||||
timestamp: number
|
||||
length: number
|
||||
authors: string[]
|
||||
}
|
||||
|
||||
export const getRevision = async (noteId: string, timestamp: number): Promise<Revision> => {
|
||||
const response = await fetch(getApiUrl() + `/notes/${noteId}/revisions/${timestamp}`, {
|
||||
...defaultFetchConfig
|
||||
})
|
||||
expectResponseCode(response)
|
||||
return await response.json() as Promise<Revision>
|
||||
}
|
||||
|
||||
export const getAllRevisions = async (noteId: string): Promise<RevisionListEntry[]> => {
|
||||
// TODO Change 'revisions-list' to 'revisions' as soon as the backend is ready to serve some data!
|
||||
const response = await fetch(getApiUrl() + `/notes/${noteId}/revisions-list`, {
|
||||
...defaultFetchConfig
|
||||
})
|
||||
expectResponseCode(response)
|
||||
return await response.json() as Promise<RevisionListEntry[]>
|
||||
}
|
10
src/api/users/index.ts
Normal file
10
src/api/users/index.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
|
||||
import { UserResponse } from './types'
|
||||
|
||||
export const getUserById = async (userid: string): Promise<UserResponse> => {
|
||||
const response = await fetch(`${getApiUrl()}/users/${userid}`, {
|
||||
...defaultFetchConfig
|
||||
})
|
||||
expectResponseCode(response)
|
||||
return (await response.json()) as UserResponse
|
||||
}
|
8
src/api/users/types.d.ts
vendored
Normal file
8
src/api/users/types.d.ts
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { LoginProvider } from '../../redux/user/types'
|
||||
|
||||
export interface UserResponse {
|
||||
id: string
|
||||
name: string
|
||||
photo: string
|
||||
provider: LoginProvider
|
||||
}
|
|
@ -11,13 +11,15 @@ export interface CommonModalProps {
|
|||
titleI18nKey: string
|
||||
closeButton?: boolean
|
||||
icon?: IconName
|
||||
size?: 'lg' | 'sm' | 'xl'
|
||||
additionalClasses?: string
|
||||
}
|
||||
|
||||
export const CommonModal: React.FC<CommonModalProps> = ({ show, onHide, titleI18nKey, closeButton, icon, children }) => {
|
||||
export const CommonModal: React.FC<CommonModalProps> = ({ show, onHide, titleI18nKey, closeButton, icon, additionalClasses, size, children }) => {
|
||||
useTranslation()
|
||||
|
||||
return (
|
||||
<Modal show={show} onHide={onHide} animation={true} className="text-dark">
|
||||
<Modal show={show} onHide={onHide} animation={true} dialogClassName={`text-dark ${additionalClasses ?? ''}`} size={size}>
|
||||
<Modal.Header closeButton={!!closeButton}>
|
||||
<Modal.Title>
|
||||
<ShowIf condition={!!icon}>
|
||||
|
|
|
@ -19,6 +19,7 @@ const UserAvatar: React.FC<UserAvatarProps> = ({ name, photo, additionalClasses
|
|||
src={photo}
|
||||
className="user-avatar rounded"
|
||||
alt={t('common.avatarOf', { name })}
|
||||
title={name}
|
||||
/>
|
||||
<ShowIf condition={showName}>
|
||||
<span className="mx-1 user-name">{name}</span>
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
import React from 'react'
|
||||
import { TranslatedIconButton } from '../../../common/icon-button/translated-icon-button'
|
||||
|
||||
export const RevisionButton: React.FC = () => {
|
||||
return <TranslatedIconButton size={'sm'} className={'mx-1'} icon={'history'} variant={'light'} i18nKey={'editor.documentBar.revision'}/>
|
||||
}
|
|
@ -8,13 +8,14 @@ import { ImportMenu } from './menus/import-menu'
|
|||
import { PermissionButton } from './buttons/permission-button'
|
||||
import { PinToHistoryButton } from './buttons/pin-to-history-button'
|
||||
import { ShareLinkButton } from './buttons/share-link-button'
|
||||
import { RevisionButton } from './buttons/revision-button'
|
||||
import { RevisionButton } from './revisions/revision-button'
|
||||
|
||||
export interface DocumentBarProps {
|
||||
title: string
|
||||
noteContent: string
|
||||
}
|
||||
|
||||
export const DocumentBar: React.FC<DocumentBarProps> = ({ title }) => {
|
||||
export const DocumentBar: React.FC<DocumentBarProps> = ({ title, noteContent }) => {
|
||||
useTranslation()
|
||||
|
||||
return (
|
||||
|
@ -22,7 +23,7 @@ export const DocumentBar: React.FC<DocumentBarProps> = ({ title }) => {
|
|||
<div className="navbar-nav">
|
||||
<ShareLinkButton/>
|
||||
<DocumentInfoButton/>
|
||||
<RevisionButton/>
|
||||
<RevisionButton noteContent={noteContent}/>
|
||||
<PinToHistoryButton/>
|
||||
<PermissionButton/>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
import React, { Fragment, useState } from 'react'
|
||||
import { TranslatedIconButton } from '../../../common/icon-button/translated-icon-button'
|
||||
import { RevisionModal } from './revision-modal'
|
||||
|
||||
export interface RevisionButtonProps {
|
||||
noteContent: string
|
||||
}
|
||||
|
||||
export const RevisionButton: React.FC<RevisionButtonProps> = ({ noteContent }) => {
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<TranslatedIconButton size={'sm'} className={'mx-1'} icon={'history'} variant={'light'} i18nKey={'editor.documentBar.revision'} onClick={() => setShow(true)}/>
|
||||
<RevisionModal show={show} onHide={() => setShow(false)} titleI18nKey={'editor.modal.revision.title'} icon={'history'} noteContent={noteContent}/>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import moment from 'moment'
|
||||
import React from 'react'
|
||||
import { ListGroup } from 'react-bootstrap'
|
||||
import { Trans } from 'react-i18next'
|
||||
import { RevisionListEntry } from '../../../../api/revisions'
|
||||
import { UserResponse } from '../../../../api/users/types'
|
||||
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
|
||||
import { UserAvatar } from '../../../common/user-avatar/user-avatar'
|
||||
|
||||
export interface RevisionModalListEntryProps {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
revision: RevisionListEntry
|
||||
revisionAuthorListMap: Map<number, UserResponse[]>
|
||||
}
|
||||
|
||||
export const RevisionModalListEntry: React.FC<RevisionModalListEntryProps> = ({ active, onClick, revision, revisionAuthorListMap }) => (
|
||||
<ListGroup.Item
|
||||
as='li'
|
||||
active={active}
|
||||
onClick={onClick}
|
||||
className='user-select-none revision-item d-flex flex-column'
|
||||
>
|
||||
<span>
|
||||
<ForkAwesomeIcon icon={'clock-o'} className='mx-2'/>
|
||||
{moment(revision.timestamp * 1000).format('LLLL')}
|
||||
</span>
|
||||
<span>
|
||||
<ForkAwesomeIcon icon={'file-text-o'} className='mx-2'/>
|
||||
<Trans i18nKey={'editor.modal.revision.length'}/>: {revision.length}
|
||||
</span>
|
||||
<span className={'d-flex flex-row my-1 align-items-center'}>
|
||||
<ForkAwesomeIcon icon={'user-o'} className={'mx-2'}/>
|
||||
{
|
||||
revisionAuthorListMap.get(revision.timestamp)?.map((user, index) => {
|
||||
return (
|
||||
<UserAvatar name={user.name} photo={user.photo} showName={false} additionalClasses={'mx-1'} key={index}/>
|
||||
)
|
||||
})
|
||||
}
|
||||
</span>
|
||||
</ListGroup.Item>
|
||||
)
|
|
@ -0,0 +1,12 @@
|
|||
.revision-modal .row .scroll-col {
|
||||
max-height: 75vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
li.revision-item {
|
||||
cursor: pointer;
|
||||
|
||||
span > img {
|
||||
height: 1.25rem;
|
||||
}
|
||||
}
|
111
src/components/editor/document-bar/revisions/revision-modal.tsx
Normal file
111
src/components/editor/document-bar/revisions/revision-modal.tsx
Normal file
|
@ -0,0 +1,111 @@
|
|||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Alert, Col, ListGroup, Modal, Row, Button } from 'react-bootstrap'
|
||||
import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { useParams } from 'react-router'
|
||||
import { getAllRevisions, getRevision, Revision, RevisionListEntry } from '../../../../api/revisions'
|
||||
import { UserResponse } from '../../../../api/users/types'
|
||||
import { CommonModal, CommonModalProps } from '../../../common/modals/common-modal'
|
||||
import { ShowIf } from '../../../common/show-if/show-if'
|
||||
import { RevisionButtonProps } from './revision-button'
|
||||
import { RevisionModalListEntry } from './revision-modal-list-entry'
|
||||
import './revision-modal.scss'
|
||||
import { downloadRevision, getUserDataForRevision } from './utils'
|
||||
|
||||
export const RevisionModal: React.FC<CommonModalProps & RevisionButtonProps> = ({ show, onHide, icon, titleI18nKey, noteContent }) => {
|
||||
useTranslation()
|
||||
const [revisions, setRevisions] = useState<RevisionListEntry[]>([])
|
||||
const [selectedRevisionTimestamp, setSelectedRevisionTimestamp] = useState<number | null>(null)
|
||||
const [selectedRevision, setSelectedRevision] = useState<Revision | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
const revisionAuthorListMap = useRef(new Map<number, UserResponse[]>())
|
||||
const revisionCacheMap = useRef(new Map<number, Revision>())
|
||||
const { id } = useParams<{ id: string }>()
|
||||
|
||||
useEffect(() => {
|
||||
getAllRevisions(id).then(fetchedRevisions => {
|
||||
fetchedRevisions.forEach(revision => {
|
||||
const authorData = getUserDataForRevision(revision.authors)
|
||||
revisionAuthorListMap.current.set(revision.timestamp, authorData)
|
||||
})
|
||||
setRevisions(fetchedRevisions)
|
||||
if (fetchedRevisions.length >= 1) {
|
||||
setSelectedRevisionTimestamp(fetchedRevisions[0].timestamp)
|
||||
}
|
||||
}).catch(() => setError(true))
|
||||
}, [setRevisions, setError, id])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRevisionTimestamp === null) {
|
||||
return
|
||||
}
|
||||
const cacheEntry = revisionCacheMap.current.get(selectedRevisionTimestamp)
|
||||
if (cacheEntry) {
|
||||
setSelectedRevision(cacheEntry)
|
||||
return
|
||||
}
|
||||
getRevision(id, selectedRevisionTimestamp).then(fetchedRevision => {
|
||||
setSelectedRevision(fetchedRevision)
|
||||
revisionCacheMap.current.set(selectedRevisionTimestamp, fetchedRevision)
|
||||
}).catch(() => setError(true))
|
||||
}, [selectedRevisionTimestamp, id])
|
||||
|
||||
return (
|
||||
<CommonModal show={show} onHide={onHide} titleI18nKey={titleI18nKey} icon={icon} closeButton={true} size={'xl'} additionalClasses='revision-modal'>
|
||||
<Modal.Body>
|
||||
<Row>
|
||||
<Col lg={4} className={'scroll-col'}>
|
||||
<ListGroup as='ul'>
|
||||
{
|
||||
revisions.map((revision, revisionIndex) => (
|
||||
<RevisionModalListEntry
|
||||
key={revisionIndex}
|
||||
active={selectedRevisionTimestamp === revision.timestamp}
|
||||
revision={revision}
|
||||
revisionAuthorListMap={revisionAuthorListMap.current}
|
||||
onClick={() => setSelectedRevisionTimestamp(revision.timestamp)}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</ListGroup>
|
||||
</Col>
|
||||
<Col lg={8} className={'scroll-col'}>
|
||||
<ShowIf condition={error}>
|
||||
<Alert variant='danger'>
|
||||
<Trans i18nKey='editor.modal.revision.error'/>
|
||||
</Alert>
|
||||
</ShowIf>
|
||||
<ShowIf condition={!error && !!selectedRevision}>
|
||||
<ReactDiffViewer
|
||||
oldValue={selectedRevision?.content}
|
||||
newValue={noteContent}
|
||||
splitView={false}
|
||||
compareMethod={DiffMethod.WORDS}
|
||||
useDarkTheme={false}
|
||||
/>
|
||||
</ShowIf>
|
||||
</Col>
|
||||
</Row>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
variant='secondary'
|
||||
onClick={onHide}>
|
||||
<Trans i18nKey={'common.close'}/>
|
||||
</Button>
|
||||
<Button
|
||||
variant='danger'
|
||||
disabled={!selectedRevisionTimestamp}
|
||||
onClick={() => window.alert('Not yet implemented. Requires websocket.')}>
|
||||
<Trans i18nKey={'editor.modal.revision.revertButton'}/>
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
disabled={!selectedRevisionTimestamp}
|
||||
onClick={() => downloadRevision(id, selectedRevision)}>
|
||||
<Trans i18nKey={'editor.modal.revision.download'}/>
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</CommonModal>
|
||||
)
|
||||
}
|
39
src/components/editor/document-bar/revisions/utils.ts
Normal file
39
src/components/editor/document-bar/revisions/utils.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { Revision } from '../../../../api/revisions'
|
||||
import { getUserById } from '../../../../api/users'
|
||||
import { UserResponse } from '../../../../api/users/types'
|
||||
|
||||
const userResponseCache = new Map<string, UserResponse>()
|
||||
|
||||
export const downloadRevision = (noteId: string, revision: Revision | null): void => {
|
||||
if (!revision) {
|
||||
return
|
||||
}
|
||||
const encoded = Buffer.from(revision.content).toString('base64')
|
||||
const wrapper = document.createElement('a')
|
||||
wrapper.download = `${noteId}-${revision.timestamp}.md`
|
||||
wrapper.href = `data:text/markdown;charset=utf-8;base64,${encoded}`
|
||||
document.body.appendChild(wrapper)
|
||||
wrapper.click()
|
||||
document.body.removeChild(wrapper)
|
||||
}
|
||||
|
||||
export const getUserDataForRevision = (authors: string[]): UserResponse[] => {
|
||||
const users: UserResponse[] = []
|
||||
authors.forEach((author, index) => {
|
||||
if (index > 9) {
|
||||
return
|
||||
}
|
||||
const cacheEntry = userResponseCache.get(author)
|
||||
if (cacheEntry) {
|
||||
users.push(cacheEntry)
|
||||
return
|
||||
}
|
||||
getUserById(author)
|
||||
.then(userData => {
|
||||
users.push(userData)
|
||||
userResponseCache.set(author, userData)
|
||||
})
|
||||
.catch((error) => console.error(error))
|
||||
})
|
||||
return users
|
||||
}
|
|
@ -113,7 +113,7 @@ export const Editor: React.FC = () => {
|
|||
<DocumentTitle title={documentTitle}/>
|
||||
<div className={'d-flex flex-column vh-100'}>
|
||||
<AppBar/>
|
||||
<DocumentBar title={documentTitle}/>
|
||||
<DocumentBar title={documentTitle} noteContent={markdownContent}/>
|
||||
<Splitter
|
||||
showLeft={editorMode === EditorMode.EDITOR || editorMode === EditorMode.BOTH}
|
||||
left={
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue