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:
Erik Michelson 2020-09-02 22:57:44 +02:00 committed by GitHub
parent 0fecda027c
commit d597438c42
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 455 additions and 29 deletions

View file

@ -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> => {

View 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
View 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
View file

@ -0,0 +1,8 @@
import { LoginProvider } from '../../redux/user/types'
export interface UserResponse {
id: string
name: string
photo: string
provider: LoginProvider
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View 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
}

View file

@ -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={