Merge pull request #12549 from overleaf/ii-history-react-list-of-all-versions

[web] All versions of history entries

GitOrigin-RevId: 7365ac4913c115b3b2872a3713d893463719c15e
This commit is contained in:
ilkin-overleaf 2023-04-11 11:35:33 +03:00 committed by Copybot
parent fb1f61434a
commit 8e0aa685ce
24 changed files with 735 additions and 57 deletions

View file

@ -38,6 +38,7 @@
"all_projects": "",
"also": "",
"an_error_occurred_when_verifying_the_coupon_code": "",
"anonymous": "",
"anyone_with_link_can_edit": "",
"anyone_with_link_can_view": "",
"approaching_compile_timeout_limit_upgrade_for_more_compile_time": "",
@ -246,6 +247,10 @@
"faster_compiles_feedback_seems_same": "",
"faster_compiles_feedback_seems_slower": "",
"faster_compiles_feedback_thanks": "",
"file_action_created": "",
"file_action_deleted": "",
"file_action_edited": "",
"file_action_renamed": "",
"file_already_exists": "",
"file_already_exists_in_this_location": "",
"file_name": "",
@ -347,6 +352,12 @@
"hide_document_preamble": "",
"hide_outline": "",
"history": "",
"history_entry_origin_dropbox": "",
"history_entry_origin_git": "",
"history_entry_origin_github": "",
"history_entry_origin_upload": "",
"history_label_created_by": "",
"history_label_project_current_state": "",
"history_view_a11y_description": "",
"history_view_all": "",
"history_view_labels": "",
@ -551,6 +562,7 @@
"other_output_files": "",
"overall_theme": "",
"overleaf": "",
"overleaf_history_system": "",
"overleaf_labs": "",
"overwrite": "",
"owned_by_x": "",
@ -947,6 +959,7 @@
"x_price_per_year": "",
"year": "",
"yes_move_me_to_personal_plan": "",
"you": "",
"you_already_have_a_subscription": "",
"you_are_a_manager_and_member_of_x_plan_as_member_of_group_subscription_y_administered_by_z": "",
"you_are_a_manager_of_commons_at_institution_x": "",

View file

@ -0,0 +1,22 @@
import { Fragment } from 'react'
import { useHistoryContext } from '../../context/history-context'
import HistoryVersion from './history-version'
function AllHistoryList() {
const { updates } = useHistoryContext()
return (
<>
{updates.map((update, index) => (
<Fragment key={`${update.fromV}_${update.toV}`}>
{update.meta.first_in_day && index > 0 && (
<hr className="history-version-divider" />
)}
<HistoryVersion update={update} />
</Fragment>
))}
</>
)
}
export default AllHistoryList

View file

@ -1,10 +1,11 @@
import usePersistedState from '../../../../shared/hooks/use-persisted-state'
import ToggleSwitch from './toggle-switch'
import Main from './main'
import AllHistoryList from './all-history-list'
import LabelsList from './labels-list'
import { useHistoryContext } from '../../context/history-context'
function ChangeList() {
const { projectId, isError } = useHistoryContext()
const { projectId, error } = useHistoryContext()
const [labelsOnly, setLabelsOnly] = usePersistedState(
`history.userPrefs.showOnlyLabels.${projectId}`,
false
@ -12,14 +13,16 @@ function ChangeList() {
return (
<aside className="change-list">
<div className="history-header toggle-switch-container">
{!isError && (
<div className="history-header history-toggle-switch-container">
{!error && (
<ToggleSwitch labelsOnly={labelsOnly} setLabelsOnly={setLabelsOnly} />
)}
</div>
{!error && (
<div className="version-list-container">
<Main />
{labelsOnly ? <LabelsList /> : <AllHistoryList />}
</div>
)}
</aside>
)
}

View file

@ -0,0 +1,39 @@
import { useTranslation } from 'react-i18next'
import { getProjectOpDoc } from '../../utils/history-details'
import { LoadedUpdate } from '../../services/types/update'
type ChangesProps = {
pathNames: LoadedUpdate['pathnames']
projectOps: LoadedUpdate['project_ops']
}
function Changes({ pathNames, projectOps }: ChangesProps) {
const { t } = useTranslation()
return (
<ol className="history-version-changes">
{pathNames.map(pathName => (
<li key={pathName}>
<div className="history-version-change-action">
{t('file_action_edited')}
</div>
<div className="history-version-change-doc">{pathName}</div>
</li>
))}
{projectOps.map((op, index) => (
<li key={index}>
<div className="history-version-change-action">
{op.rename && t('file_action_renamed')}
{op.add && t('file_action_created')}
{op.remove && t('file_action_deleted')}
</div>
<div className="history-version-change-doc">
{getProjectOpDoc(op)}
</div>
</li>
))}
</ol>
)
}
export default Changes

View file

@ -0,0 +1,49 @@
import LabelBadges from './label-badges'
import Changes from './changes'
import MetadataUsersList from './metadata-users-list'
import Origin from './origin'
import { useUserContext } from '../../../../shared/context/user-context'
import { relativeDate, formatTime } from '../../../utils/format-date'
import { LoadedUpdate } from '../../services/types/update'
type HistoryEntryProps = {
update: LoadedUpdate
}
function HistoryVersion({ update }: HistoryEntryProps) {
const { id: currentUserId } = useUserContext()
return (
<div>
{update.meta.first_in_day && (
<time className="history-version-day">
{relativeDate(update.meta.end_ts)}
</time>
)}
<div className="history-version-details">
<div>
<time className="history-version-metadata-time">
{formatTime(update.meta.end_ts, 'Do MMMM, h:mm a')}
</time>
<LabelBadges
labels={update.labels}
showTooltip
currentUserId={currentUserId}
/>
<Changes
pathNames={update.pathnames}
projectOps={update.project_ops}
/>
<MetadataUsersList
users={update.meta.users}
origin={update.meta.origin}
currentUserId={currentUserId}
/>
<Origin origin={update.meta.origin} />
</div>
</div>
</div>
)
}
export default HistoryVersion

View file

@ -0,0 +1,96 @@
import { Fragment } from 'react'
import { useTranslation } from 'react-i18next'
import Icon from '../../../../shared/components/icon'
import Tooltip from '../../../../shared/components/tooltip'
import { useHistoryContext } from '../../context/history-context'
import { isPseudoLabel } from '../../utils/label'
import { formatDate } from '../../../../utils/dates'
import { orderBy } from 'lodash'
import { Label, PseudoCurrentStateLabel } from '../../services/types/update'
type LabelBadgesProps = {
showTooltip: boolean
currentUserId: string
labels: Array<Label | PseudoCurrentStateLabel>
}
function LabelBadges({ labels, currentUserId, showTooltip }: LabelBadgesProps) {
const { t } = useTranslation()
const { labels: allLabels } = useHistoryContext()
const orderedLabels = orderBy(labels, ['created_at'], ['desc'])
const handleDelete = (e: React.MouseEvent, label: Label) => {
e.stopPropagation()
}
return (
<>
{orderedLabels.map(label => {
const isPseudoCurrentStateLabel = isPseudoLabel(label)
const isOwnedByCurrentUser = !isPseudoCurrentStateLabel
? label.user_id === currentUserId
: null
const currentLabelData = allLabels?.find(({ id }) => id === label.id)
const labelOwnerName =
currentLabelData && !isPseudoLabel(currentLabelData)
? currentLabelData.user_display_name
: t('anonymous')
const badgeContent = (
<span className="history-version-badge">
<Icon type="tag" fw />
<span className="history-version-badge-comment">
{isPseudoCurrentStateLabel
? t('history_label_project_current_state')
: label.comment}
</span>
{isOwnedByCurrentUser && !isPseudoCurrentStateLabel && (
<button
type="button"
className="history-version-label-delete-btn"
onClick={e => handleDelete(e, label)}
aria-label={t('delete')}
>
<span aria-hidden="true">&times;</span>
</button>
)}
</span>
)
return (
<Fragment key={label.id}>
{showTooltip && !isPseudoCurrentStateLabel ? (
<Tooltip
key={label.id}
description={
<div className="history-version-label-tooltip">
<div className="history-version-label-tooltip-row">
<b>
<Icon type="tag" fw />
{label.comment}
</b>
</div>
<div className="history-version-label-tooltip-row">
{t('history_label_created_by')} {labelOwnerName}
</div>
<div className="history-version-label-tooltip-row">
<time>{formatDate(label.created_at)}</time>
</div>
</div>
}
id={label.id}
overlayProps={{ placement: 'left' }}
>
{badgeContent}
</Tooltip>
) : (
badgeContent
)}
</Fragment>
)
})}
</>
)
}
export default LabelBadges

View file

@ -0,0 +1,5 @@
function LabelsList() {
return <div>Labels only</div>
}
export default LabelsList

View file

@ -1,5 +0,0 @@
function Main() {
return <div>Change List</div>
}
export default Main

View file

@ -0,0 +1,54 @@
import { useTranslation } from 'react-i18next'
import { getUserColor, formatUserName } from '../../utils/history-details'
import { LoadedUpdate } from '../../services/types/update'
type MetadataUsersListProps = {
currentUserId: string
} & Pick<LoadedUpdate['meta'], 'users' | 'origin'>
function MetadataUsersList({
users,
origin,
currentUserId,
}: MetadataUsersListProps) {
const { t } = useTranslation()
return (
<ol className="history-version-metadata-users">
{users.map((user, index) => {
let userName: string
if (!user) {
userName = t('anonymous')
} else if (user?.id === currentUserId) {
userName = t('you')
} else {
userName = formatUserName(user)
}
return (
<li key={index}>
<span
className="history-version-user-badge-color"
style={{ backgroundColor: getUserColor(user) }}
/>
{userName}
</li>
)
})}
{!users.length && (
<li>
<span
className="history-version-user-badge-color"
style={{ backgroundColor: getUserColor() }}
/>
{origin?.kind === 'history-resync' ||
origin?.kind === 'history-migration'
? t('overleaf_history_system')
: t('anonymous')}
</li>
)}
</ol>
)
}
export default MetadataUsersList

View file

@ -0,0 +1,19 @@
import { useTranslation } from 'react-i18next'
import { LoadedUpdate } from '../../services/types/update'
function Origin({ origin }: Pick<LoadedUpdate['meta'], 'origin'>) {
const { t } = useTranslation()
let result: string | null = null
if (origin?.kind === 'dropbox') result = t('history_entry_origin_dropbox')
if (origin?.kind === 'upload') result = t('history_entry_origin_upload')
if (origin?.kind === 'git-bridge') result = t('history_entry_origin_git')
if (origin?.kind === 'github') result = t('history_entry_origin_github')
if (result) {
return <span className="history-version-origin">({result})</span>
}
return null
}
export default Origin

View file

@ -1,30 +1,52 @@
import { createContext, useContext, useEffect, useState, useMemo } from 'react'
import { useIdeContext } from '../../../shared/context/ide-context'
import {
createContext,
useContext,
useCallback,
useEffect,
useState,
useMemo,
useRef,
} from 'react'
import { useLayoutContext } from '../../../shared/context/layout-context'
import useAsync from '../../../shared/hooks/use-async'
import { useUserContext } from '../../../shared/context/user-context'
import { useProjectContext } from '../../../shared/context/project-context'
import { HistoryContextValue } from './types/history-context-value'
import { Update, UpdateSelection } from '../services/types/update'
import { FileSelection } from '../services/types/file'
import { diffFiles, fetchUpdates } from '../services/api'
import { diffFiles, fetchLabels, fetchUpdates } from '../services/api'
import { renamePathnameKey, isFileRenamed } from '../utils/file-tree'
import { loadLabels } from '../utils/label'
import ColorManager from '../../../ide/colors/ColorManager'
import moment from 'moment'
import * as eventTracking from '../../../infrastructure/event-tracking'
import { cloneDeep } from 'lodash'
import { LoadedUpdate, Update, UpdateSelection } from '../services/types/update'
import { FileSelection } from '../services/types/file'
import { Nullable } from '../../../../../types/utils'
function useHistory() {
const { view } = useLayoutContext()
const ide = useIdeContext()
const projectId = ide.project_id
const user = useUserContext()
const project = useProjectContext()
const userId = user.id
const projectId = project._id
const projectOwnerId = project.owner?._id
const [updateSelection, setUpdateSelection] =
useState<UpdateSelection | null>(null)
const [fileSelection, setFileSelection] = useState<FileSelection | null>(null)
/* eslint-disable no-unused-vars */
const [viewMode, setViewMode] = useState<HistoryContextValue['viewMode']>('')
const [updates, setUpdates] = useState<LoadedUpdate[]>([])
const [loadingFileTree, setLoadingFileTree] =
useState<HistoryContextValue['loadingFileTree']>(true)
const [nextBeforeTimestamp, setNextBeforeTimestamp] =
useState<HistoryContextValue['nextBeforeTimestamp']>(null)
useState<HistoryContextValue['nextBeforeTimestamp']>()
const [atEnd, setAtEnd] = useState<HistoryContextValue['atEnd']>(false)
const [userHasFullFeature, setUserHasFullFeature] =
useState<HistoryContextValue['userHasFullFeature']>(undefined)
const [freeHistoryLimitHit, setFreeHistoryLimitHit] =
useState<HistoryContextValue['freeHistoryLimitHit']>(false)
const [labels, setLabels] = useState<HistoryContextValue['labels']>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
/* eslint-disable no-unused-vars */
const [viewMode, setViewMode] = useState<HistoryContextValue['viewMode']>('')
const [userHasFullFeature, setUserHasFullFeature] =
useState<HistoryContextValue['userHasFullFeature']>(undefined)
const [selection, setSelection] = useState<HistoryContextValue['selection']>({
docs: {},
pathname: null,
@ -42,18 +64,110 @@ function useHistory() {
})
/* eslint-enable no-unused-vars */
const { isLoading, isError, error, data, runAsync } = useAsync<{
updates: Update[]
}>()
const updates = useMemo(() => data?.updates ?? [], [data?.updates])
const loadingFileTree = true
const fetchNextBatchOfUpdates = useCallback(() => {
const loadUpdates = (updatesData: Update[]) => {
const dateTimeNow = new Date()
const timestamp24hoursAgo = dateTimeNow.setDate(dateTimeNow.getDate() - 1)
let previousUpdate = updates[updates.length - 1]
let cutOffIndex: Nullable<number> = null
let loadedUpdates: LoadedUpdate[] = cloneDeep(updatesData)
for (const [index, update] of loadedUpdates.entries()) {
for (const user of update.meta.users) {
if (user) {
user.hue = ColorManager.getHueForUserId(user.id)
}
}
if (
!previousUpdate ||
!moment(previousUpdate.meta.end_ts).isSame(update.meta.end_ts, 'day')
) {
update.meta.first_in_day = true
}
previousUpdate = update
if (userHasFullFeature && update.meta.end_ts < timestamp24hoursAgo) {
cutOffIndex = index || 1 // Make sure that we show at least one entry (to allow labelling).
setFreeHistoryLimitHit(true)
if (projectOwnerId === userId) {
eventTracking.send(
'subscription-funnel',
'editor-click-feature',
'history'
)
eventTracking.sendMB('paywall-prompt', {
'paywall-type': 'history',
})
}
break
}
}
if (!userHasFullFeature && cutOffIndex != null) {
loadedUpdates = loadedUpdates.slice(0, cutOffIndex)
}
setUpdates(updates.concat(loadedUpdates))
// TODO first load
}
if (atEnd) return
const updatesPromise = fetchUpdates(projectId, nextBeforeTimestamp)
const labelsPromise = labels == null ? fetchLabels(projectId) : null
setIsLoading(true)
Promise.all([updatesPromise, labelsPromise])
.then(([{ updates: updatesData, nextBeforeTimestamp }, labels]) => {
const lastUpdateToV = updatesData.length ? updatesData[0].toV : null
if (labels) {
setLabels(loadLabels(labels, lastUpdateToV))
}
loadUpdates(updatesData)
setNextBeforeTimestamp(nextBeforeTimestamp)
if (
nextBeforeTimestamp == null ||
freeHistoryLimitHit ||
!updates.length
) {
setAtEnd(true)
}
if (!updates.length) {
setLoadingFileTree(false)
}
})
.catch(error => {
setError(error)
setAtEnd(true)
setLoadingFileTree(false)
})
.finally(() => {
setIsLoading(false)
})
}, [
atEnd,
freeHistoryLimitHit,
labels,
nextBeforeTimestamp,
projectId,
projectOwnerId,
userId,
userHasFullFeature,
updates,
])
// Initial load when the History tab is active
const initialFetch = useRef(false)
useEffect(() => {
if (view === 'history') {
runAsync(fetchUpdates(projectId)).catch(console.error)
if (view === 'history' && !initialFetch.current) {
fetchNextBatchOfUpdates()
initialFetch.current = true
}
}, [view, projectId, runAsync])
}, [view, fetchNextBatchOfUpdates])
// Load files when the update selection changes
useEffect(() => {
@ -87,7 +201,6 @@ function useHistory() {
() => ({
atEnd,
error,
isError,
isLoading,
freeHistoryLimitHit,
labels,
@ -106,7 +219,6 @@ function useHistory() {
[
atEnd,
error,
isError,
isLoading,
freeHistoryLimitHit,
labels,

View file

@ -1,20 +1,24 @@
import { Nullable } from '../../../../../../types/utils'
import { Update, UpdateSelection } from '../../services/types/update'
import {
Label,
LoadedUpdate,
PseudoCurrentStateLabel,
UpdateSelection,
} from '../../services/types/update'
import { Selection } from '../../../../../../types/history/selection'
import { FileSelection } from '../../services/types/file'
export type HistoryContextValue = {
updates: Update[]
updates: LoadedUpdate[]
viewMode: string
nextBeforeTimestamp: Nullable<number>
nextBeforeTimestamp: number | undefined
atEnd: boolean
userHasFullFeature: boolean | undefined
freeHistoryLimitHit: boolean
selection: Selection
isError: boolean
isLoading: boolean
error: Nullable<unknown>
labels: Nullable<unknown>
labels: Nullable<Array<Label | PseudoCurrentStateLabel>>
loadingFileTree: boolean
projectId: string
fileSelection: FileSelection | null

View file

@ -1,6 +1,6 @@
import { getJSON } from '../../../infrastructure/fetch-json'
import { FileDiff } from './types/file'
import { Update } from './types/update'
import { Label, Update } from './types/update'
import { DocDiffResponse } from './types/doc'
const BATCH_SIZE = 10
@ -16,7 +16,14 @@ export function fetchUpdates(projectId: string, before?: number) {
const queryParamsSerialized = new URLSearchParams(queryParams).toString()
const updatesURL = `/project/${projectId}/updates?${queryParamsSerialized}`
return getJSON<{ updates: Update[] }>(updatesURL)
return getJSON<{ updates: Update[]; nextBeforeTimestamp?: number }>(
updatesURL
)
}
export function fetchLabels(projectId: string) {
const labelsURL = `/project/${projectId}/labels`
return getJSON<Label[]>(labelsURL)
}
export function diffFiles(projectId: string, fromV: number, toV: number) {

View file

@ -1,3 +1,5 @@
import { Nullable } from '../../../../../../types/utils'
export interface User {
first_name: string
last_name: string
@ -6,7 +8,16 @@ export interface User {
}
export interface Meta {
users: User[]
users: Nullable<User>[]
start_ts: number
end_ts: number
origin?: {
kind:
| 'dropbox'
| 'upload'
| 'git-bridge'
| 'github'
| 'history-resync'
| 'history-migration'
}
}

View file

@ -1,4 +1,5 @@
import { Meta } from './shared'
import { Meta, User } from './shared'
import { Nullable } from '../../../../../../types/utils'
interface UpdateLabel {
id: string
@ -8,11 +9,18 @@ interface UpdateLabel {
created_at: string
}
interface Label extends UpdateLabel {
export interface Label extends UpdateLabel {
user_display_name: string
}
interface ProjectOp {
export interface PseudoCurrentStateLabel {
id: '1'
isPseudoCurrentStateLabel: true
version: Nullable<number>
created_at: string
}
export interface ProjectOp {
add?: { pathname: string }
rename?: { pathname: string; newPathname: string }
remove?: { pathname: string }
@ -23,11 +31,26 @@ export interface Update {
fromV: number
toV: number
meta: Meta
labels?: Label[]
labels: Label[]
pathnames: string[]
project_ops: ProjectOp[]
}
interface LoadedUpdateMetaUser extends User {
hue?: number
}
export type LoadedUpdateMetaUsers = Nullable<LoadedUpdateMetaUser>[]
interface LoadedUpdateMeta extends Meta {
first_in_day?: true
users: LoadedUpdateMetaUsers
}
export interface LoadedUpdate extends Update {
meta: LoadedUpdateMeta
}
export interface UpdateSelection {
update: Update
comparing: boolean

View file

@ -33,7 +33,7 @@ export function highlightsFromDiffResponse(chunks: DocDiffChunk[]) {
// There doesn't seem to be a convenient way to make this translatable
label: `Added by ${name} on ${date}`,
range,
hue: ColorManager.getHueForUserId(user.id),
hue: ColorManager.getHueForUserId(user?.id),
})
} else if (isDeletion) {
highlights.push({
@ -41,7 +41,7 @@ export function highlightsFromDiffResponse(chunks: DocDiffChunk[]) {
// There doesn't seem to be a convenient way to make this translatable
label: `Deleted by ${name} on ${date}`,
range,
hue: ColorManager.getHueForUserId(user.id),
hue: ColorManager.getHueForUserId(user?.id),
})
}
}

View file

@ -0,0 +1,40 @@
import ColorManager from '../../../ide/colors/ColorManager'
import { Nullable } from '../../../../../types/utils'
import { User } from '../services/types/shared'
import { ProjectOp } from '../services/types/update'
export const getUserColor = (
user?: Nullable<{ id: string; _id?: never } | { id?: never; _id: string }>
) => {
const curUserId = user?.id || user?._id
const hue = ColorManager.getHueForUserId(curUserId) || 100
return `hsl(${hue}, 70%, 50%)`
}
export const formatUserName = (user: User) => {
let name = [user.first_name, user.last_name]
.filter(n => n != null)
.join(' ')
.trim()
if (name === '') {
name = user.email.split('@')[0]
}
if (name == null || name === '') {
return '?'
}
return name
}
export const getProjectOpDoc = (projectOp: ProjectOp) => {
if (projectOp.rename) {
return `${projectOp.rename.pathname}${projectOp.rename.newPathname}`
}
if (projectOp.add) {
return `${projectOp.add.pathname}`
}
if (projectOp.remove) {
return `${projectOp.remove.pathname}`
}
return ''
}

View file

@ -0,0 +1,60 @@
import { orderBy } from 'lodash'
import { Label, PseudoCurrentStateLabel } from '../services/types/update'
import { Nullable } from '../../../../../types/utils'
export const isPseudoLabel = (
label: Label | PseudoCurrentStateLabel
): label is PseudoCurrentStateLabel => {
return (label as PseudoCurrentStateLabel).isPseudoCurrentStateLabel === true
}
const sortLabelsByVersionAndDate = (
labels: Array<Label | PseudoCurrentStateLabel>
) => {
return orderBy(
labels,
['isPseudoCurrentStateLabel', 'version', 'created_at'],
['asc', 'desc', 'desc']
)
}
const deletePseudoCurrentStateLabelIfExistent = (
labels: Array<Label | PseudoCurrentStateLabel>
) => {
if (labels.length && isPseudoLabel(labels[0])) {
const [, ...rest] = labels
return rest
}
return labels
}
const addPseudoCurrentStateLabelIfNeeded = (
labels: Array<Label | PseudoCurrentStateLabel>,
mostRecentVersion: Nullable<number>
) => {
if (!labels.length || labels[0].version !== mostRecentVersion) {
const pseudoCurrentStateLabel: PseudoCurrentStateLabel = {
id: '1',
isPseudoCurrentStateLabel: true,
version: mostRecentVersion,
created_at: new Date().toISOString(),
}
return [pseudoCurrentStateLabel, ...labels]
}
return labels
}
export const loadLabels = (
labels: Label[],
lastUpdateToV: Nullable<number>
) => {
const sortedLabels = sortLabelsByVersionAndDate(labels)
const labelsWithoutPseudoLabel =
deletePseudoCurrentStateLabelIfExistent(sortedLabels)
const labelsWithPseudoLabelIfNeeded = addPseudoCurrentStateLabelIfNeeded(
labelsWithoutPseudoLabel,
lastUpdateToV
)
return labelsWithPseudoLabelIfNeeded
}

View file

@ -11,8 +11,8 @@ moment.updateLocale('en', {
},
})
export function formatTime(date) {
return moment(date).format('h:mm a')
export function formatTime(date, format = 'h:mm a') {
return moment(date).format(format)
}
export function relativeDate(date) {

View file

@ -31,7 +31,7 @@ export default {
(Story: React.ComponentType) => (
<div className="history-react">
<div className="change-list">
<div className="history-header toggle-switch-container">
<div className="history-header history-toggle-switch-container">
<Story />
</div>
</div>

View file

@ -15,7 +15,7 @@ history-root {
height: @history-toolbar-height;
background-color: @history-react-header-bg;
color: @history-react-header-color;
font-size: 14px;
font-size: @font-size-small;
display: flex;
flex-direction: column;
justify-content: center;
@ -50,12 +50,9 @@ history-root {
.change-list {
width: @versions-list-width;
font-size: @font-size-small;
border-left: 1px solid @history-react-separator-color;
box-sizing: content-box;
.toggle-switch-container {
padding: 0 16px;
}
}
.toggle-switch-label {
@ -66,6 +63,112 @@ history-root {
}
}
.history-toggle-switch-container,
.history-version-day,
.history-version-details {
padding: 0 16px;
}
.history-version-day {
position: sticky;
top: 0;
display: block;
padding-top: 12px;
padding-bottom: 4px;
line-height: 20px;
background-color: @white;
}
.history-version-details {
padding-top: 6px;
padding-bottom: 6px;
&:hover {
cursor: pointer;
background-color: @neutral-10;
}
}
.history-version-metadata-time {
display: block;
margin-bottom: 4px;
font-weight: bold;
color: @neutral-90;
}
.history-version-metadata-users,
.history-version-changes {
margin: 0;
padding: 0;
list-style: none;
}
.history-version-metadata-users {
display: inline;
> li {
display: inline-flex;
align-items: center;
margin-right: 8px;
}
}
.history-version-changes {
> li {
margin-bottom: 4px;
}
}
.history-version-user-badge-color {
@size: 8px;
display: inline-block;
width: @size;
height: @size;
margin-right: 4px;
border-radius: 2px;
}
.history-version-day,
.history-version-change-action,
.history-version-metadata-users,
.history-version-origin {
color: @neutral-70;
}
.history-version-change-doc {
color: @neutral-90;
}
.history-version-divider {
margin: 6px 8px;
border-color: @neutral-20;
}
.history-version-badge {
display: inline-flex;
align-items: center;
overflow: hidden;
height: 24px;
margin-bottom: 3px;
margin-right: 10px;
padding: 4px;
white-space: nowrap;
color: @ol-blue-gray-6;
background-color: @neutral-20;
border-radius: 4px;
&-comment {
margin-left: 2px;
}
}
.history-version-label-delete-btn {
.reset-button;
marigin-left: 8px;
padding: 4px;
font-size: 24px;
}
.history-loading-panel {
padding-top: 10rem;
font-family: @font-family-serif;
@ -85,3 +188,16 @@ history-root {
margin-top: 2px;
}
}
.history-version-label-tooltip {
padding: 6px;
text-align: initial;
&-row {
margin-bottom: 6.25px;
&:last-child {
margin-bottom: initial;
}
}
}

View file

@ -1317,3 +1317,11 @@
outline: 0;
}
}
.reset-button {
padding: 0;
cursor: pointer;
background: transparent;
border: 0;
-webkit-appearance: none;
}

View file

@ -13,6 +13,7 @@
@neutral-90: #1b222c;
@neutral-40: #afb5c0;
@neutral-10: #f4f5f6;
@neutral-70: #495365;
@neutral-80: #2f3a4c;
// Styleguide colors

View file

@ -1681,6 +1681,7 @@
"year": "year",
"yes_move_me_to_personal_plan": "Yes, move me to the Personal plan",
"yes_that_is_correct": "Yes, thats correct",
"you": "You",
"you_already_have_a_subscription": "You already have a subscription",
"you_and_collaborators_get_access_to": "You and your project collaborators get access to",
"you_and_collaborators_get_access_to_info": "These features are available to you and your collaborators (other Overleaf users that you invite to your projects).",