first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,73 @@
import React, { useEffect, useRef } from 'react'
import ReactDOM from 'react-dom'
import {
Dropdown,
DropdownMenu,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { useFileTreeMainContext } from '../contexts/file-tree-main'
import FileTreeItemMenuItems from './file-tree-item/file-tree-item-menu-items'
function FileTreeContextMenu() {
const { fileTreeReadOnly } = useFileTreeData()
const { contextMenuCoords, setContextMenuCoords } = useFileTreeMainContext()
const toggleButtonRef = useRef<HTMLButtonElement | null>(null)
useEffect(() => {
if (contextMenuCoords) {
toggleButtonRef.current = document.querySelector(
'.entity-menu-toggle'
) as HTMLButtonElement | null
}
}, [contextMenuCoords])
if (!contextMenuCoords || fileTreeReadOnly) return null
function close() {
setContextMenuCoords(null)
if (toggleButtonRef.current) {
// A11y - Move the focus back to the toggle button when the context menu closes by pressing the Esc key
toggleButtonRef.current.focus()
}
}
function handleToggle(wantOpen: boolean) {
if (!wantOpen) close()
}
// A11y - Close the context menu when the user presses the Tab key
// Focus should move to the next element in the filetree
function handleKeyDown(event: React.KeyboardEvent<Element>) {
if (event.key === 'Tab') {
close()
}
}
return ReactDOM.createPortal(
<div style={contextMenuCoords} className="context-menu">
<Dropdown
show
drop={
document.body.offsetHeight / contextMenuCoords.top < 2 &&
document.body.offsetHeight - contextMenuCoords.top < 250
? 'up'
: 'down'
}
focusFirstItemOnShow // A11y - Focus the first item in the context menu when it opens since the menu is rendered at the root level
onKeyDown={handleKeyDown}
onToggle={handleToggle}
>
<DropdownMenu
className="dropdown-menu-sm-width"
id="dropdown-file-tree-context-menu"
>
<FileTreeItemMenuItems />
</DropdownMenu>
</Dropdown>
</div>,
document.body
)
}
export default FileTreeContextMenu

View File

@@ -0,0 +1,43 @@
import { FileTreeMainProvider } from '../contexts/file-tree-main'
import { FileTreeActionableProvider } from '../contexts/file-tree-actionable'
import { FileTreeSelectableProvider } from '../contexts/file-tree-selectable'
import { FileTreeDraggableProvider } from '../contexts/file-tree-draggable'
import { FC } from 'react'
// renders all the contexts needed for the file tree:
// FileTreeMain: generic store
// FileTreeActionable: global UI state for actions (rename, delete, etc.)
// FileTreeMutable: provides entities mutation operations
// FileTreeSelectable: handles selection and multi-selection
const FileTreeContext: FC<{
refProviders: Record<string, boolean>
setRefProviderEnabled: (provider: string, value: boolean) => void
setStartedFreeTrial: (value: boolean) => void
onSelect: () => void
fileTreeContainer?: HTMLDivElement
}> = ({
refProviders,
setRefProviderEnabled,
setStartedFreeTrial,
onSelect,
fileTreeContainer,
children,
}) => {
return (
<FileTreeMainProvider
refProviders={refProviders}
setRefProviderEnabled={setRefProviderEnabled}
setStartedFreeTrial={setStartedFreeTrial}
>
<FileTreeSelectableProvider onSelect={onSelect}>
<FileTreeActionableProvider>
<FileTreeDraggableProvider fileTreeContainer={fileTreeContainer}>
{children}
</FileTreeDraggableProvider>
</FileTreeActionableProvider>
</FileTreeSelectableProvider>
</FileTreeMainProvider>
)
}
export default FileTreeContext

View File

@@ -0,0 +1,9 @@
import OLNotification from '@/features/ui/components/ol/ol-notification'
import PropTypes from 'prop-types'
export default function DangerMessage({ children }) {
return <OLNotification type="error" content={children} />
}
DangerMessage.propTypes = {
children: PropTypes.any.isRequired,
}

View File

@@ -0,0 +1,124 @@
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import { FetchError } from '../../../../infrastructure/fetch-json'
import RedirectToLogin from './redirect-to-login'
import {
BlockedFilenameError,
DuplicateFilenameError,
InvalidFilenameError,
} from '../../errors'
import DangerMessage from './danger-message'
export default function ErrorMessage({ error }) {
const { t } = useTranslation()
const fileNameLimit = 150
// the error is a string
// TODO: translate? always? is this a key or a message?
if (typeof error === 'string') {
switch (error) {
case 'name-exists':
return <DangerMessage>{t('file_already_exists')}</DangerMessage>
case 'too-many-files':
return <DangerMessage>{t('project_has_too_many_files')}</DangerMessage>
case 'remote-service-error':
return <DangerMessage>{t('remote_service_error')}</DangerMessage>
case 'folder_not_found':
return (
<DangerMessage>
{t('the_target_folder_could_not_be_found')}
</DangerMessage>
)
case 'invalid_filename':
return (
<DangerMessage>
{t('invalid_filename', {
nameLimit: fileNameLimit,
})}
</DangerMessage>
)
case 'duplicate_file_name':
return (
<DangerMessage>
{t('file_or_folder_name_already_exists')}
</DangerMessage>
)
case 'rate-limit-hit':
return (
<DangerMessage>
{t('too_many_files_uploaded_throttled_short_period')}
</DangerMessage>
)
case 'not-logged-in':
return (
<DangerMessage>
<RedirectToLogin />
</DangerMessage>
)
case 'linked-project-compile-error':
return (
<DangerMessage>
{t('generic_linked_file_compile_error')}
</DangerMessage>
)
default:
// TODO: convert error.response.data to an error key and try again?
// return error
return (
<DangerMessage>{t('generic_something_went_wrong')}</DangerMessage>
)
}
}
// the error is an object
// TODO: error.name?
switch (error.constructor) {
case FetchError: {
const message = error.data?.message
if (message) {
return <DangerMessage>{message.text || message}</DangerMessage>
}
// TODO: translations
switch (error.response?.status) {
case 400:
return <DangerMessage>{t('invalid_request')}</DangerMessage>
case 403:
return <DangerMessage>{t('session_error')}</DangerMessage>
case 429:
return <DangerMessage>{t('too_many_attempts')}</DangerMessage>
default:
return (
<DangerMessage>{t('something_went_wrong_server')}</DangerMessage>
)
}
}
// these are handled by the filename input component
case DuplicateFilenameError:
case InvalidFilenameError:
case BlockedFilenameError:
return null
// a generic error message
default:
// return error.message
return <DangerMessage>{t('generic_something_went_wrong')}</DangerMessage>
}
}
ErrorMessage.propTypes = {
error: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired,
}

View File

@@ -0,0 +1,129 @@
import { useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useFileTreeCreateName } from '../../contexts/file-tree-create-name'
import PropTypes from 'prop-types'
import {
BlockedFilenameError,
DuplicateFilenameError,
InvalidFilenameError,
} from '../../errors'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
import OLNotification from '@/features/ui/components/ol/ol-notification'
/**
* A form component that renders a text input with label,
* plus a validation warning and/or an error message when needed
*/
export default function FileTreeCreateNameInput({
label,
focusName = false,
classes = {},
placeholder,
error,
inFlight,
}) {
const { t } = useTranslation()
// the value is stored in a context provider, so it's available elsewhere in the form
const { name, setName, touchedName, validName } = useFileTreeCreateName()
// focus the first part of the filename if needed
const inputRef = useRef(null)
useEffect(() => {
if (inputRef.current && focusName) {
window.requestAnimationFrame(() => {
if (inputRef.current) {
inputRef.current.focus()
inputRef.current.setSelectionRange(
0,
inputRef.current.value.lastIndexOf('.')
)
}
})
}
}, [focusName])
return (
<OLFormGroup controlId="new-doc-name" className={classes.formGroup}>
<OLFormLabel>{label || t('file_name')}</OLFormLabel>
<OLFormControl
type="text"
placeholder={placeholder || t('file_name')}
required
value={name}
onChange={event => setName(event.target.value)}
ref={inputRef}
disabled={inFlight}
/>
{touchedName && !validName && (
<OLNotification
type="error"
className="row-spaced-small"
content={t('files_cannot_include_invalid_characters')}
/>
)}
{error && <ErrorMessage error={error} />}
</OLFormGroup>
)
}
FileTreeCreateNameInput.propTypes = {
focusName: PropTypes.bool,
label: PropTypes.string,
classes: PropTypes.shape({
formGroup: PropTypes.string,
}),
placeholder: PropTypes.string,
error: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
inFlight: PropTypes.bool.isRequired,
}
function ErrorMessage({ error }) {
const { t } = useTranslation()
// if (typeof error === 'string') {
// return error
// }
switch (error.constructor) {
case DuplicateFilenameError:
return (
<OLNotification
type="error"
className="row-spaced-small"
content={t('file_already_exists')}
/>
)
case InvalidFilenameError:
return (
<OLNotification
type="error"
className="row-spaced-small"
content={t('files_cannot_include_invalid_characters')}
/>
)
case BlockedFilenameError:
return (
<OLNotification
type="error"
className="row-spaced-small"
content={t('blocked_filename')}
/>
)
default:
// return <Trans i18nKey="generic_something_went_wrong" />
return null // other errors are displayed elsewhere
}
}
ErrorMessage.propTypes = {
error: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired,
}

View File

@@ -0,0 +1,114 @@
import { useTranslation } from 'react-i18next'
import FileTreeCreateNewDoc from './modes/file-tree-create-new-doc'
import FileTreeImportFromUrl from './modes/file-tree-import-from-url'
import FileTreeImportFromProject from './modes/file-tree-import-from-project'
import FileTreeModalCreateFileMode from './file-tree-modal-create-file-mode'
import FileTreeCreateNameProvider from '../../contexts/file-tree-create-name'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
import { useFileTreeData } from '../../../../shared/context/file-tree-data-context'
import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
import { lazy, Suspense } from 'react'
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
import getMeta from '@/utils/meta'
const createFileModeModules = importOverleafModules('createFileModes')
const FileTreeUploadDoc = lazy(() => import('./modes/file-tree-upload-doc'))
export default function FileTreeModalCreateFileBody() {
const { t } = useTranslation()
const { newFileCreateMode } = useFileTreeActionable()
const { fileCount } = useFileTreeData()
const {
hasLinkedProjectFileFeature,
hasLinkedProjectOutputFileFeature,
hasLinkUrlFeature,
} = getMeta('ol-ExposedSettings')
if (!fileCount || fileCount.status === 'error') {
return null
}
return (
<table>
<tbody>
<tr>
<td className="modal-new-file-list">
<ul className="list-unstyled">
<FileTreeModalCreateFileMode
mode="doc"
icon="description"
label={t('new_file')}
/>
<FileTreeModalCreateFileMode
mode="upload"
icon="upload"
label={t('upload')}
/>
{(hasLinkedProjectFileFeature ||
hasLinkedProjectOutputFileFeature) && (
<FileTreeModalCreateFileMode
mode="project"
icon="folder_open"
label={t('from_another_project')}
/>
)}
{hasLinkUrlFeature && (
<FileTreeModalCreateFileMode
mode="url"
icon="globe"
label={t('from_external_url')}
/>
)}
{createFileModeModules.map(
({ import: { CreateFileMode }, path }) => (
<CreateFileMode key={path} />
)
)}
</ul>
</td>
<td
className={`modal-new-file-body modal-new-file-body-${newFileCreateMode}`}
>
{newFileCreateMode === 'doc' && (
<FileTreeCreateNameProvider initialName="name.tex">
<FileTreeCreateNewDoc />
</FileTreeCreateNameProvider>
)}
{newFileCreateMode === 'url' && (
<FileTreeCreateNameProvider>
<FileTreeImportFromUrl />
</FileTreeCreateNameProvider>
)}
{newFileCreateMode === 'project' && (
<FileTreeCreateNameProvider>
<FileTreeImportFromProject />
</FileTreeCreateNameProvider>
)}
{newFileCreateMode === 'upload' && (
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
<FileTreeUploadDoc />
</Suspense>
)}
{createFileModeModules.map(
({ import: { CreateFilePane }, path }) => (
<CreateFilePane key={path} />
)
)}
</td>
</tr>
</tbody>
</table>
)
}

View File

@@ -0,0 +1,86 @@
import { useTranslation } from 'react-i18next'
import { useFileTreeCreateForm } from '../../contexts/file-tree-create-form'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
import { useFileTreeData } from '../../../../shared/context/file-tree-data-context'
import PropTypes from 'prop-types'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLNotification from '@/features/ui/components/ol/ol-notification'
export default function FileTreeModalCreateFileFooter() {
const { valid } = useFileTreeCreateForm()
const { newFileCreateMode, inFlight, cancel } = useFileTreeActionable()
const { fileCount } = useFileTreeData()
return (
<FileTreeModalCreateFileFooterContent
valid={valid}
cancel={cancel}
newFileCreateMode={newFileCreateMode}
inFlight={inFlight}
fileCount={fileCount}
/>
)
}
export function FileTreeModalCreateFileFooterContent({
valid,
fileCount,
inFlight,
newFileCreateMode,
cancel,
}) {
const { t } = useTranslation()
return (
<>
{fileCount.status === 'warning' && (
<div className="modal-footer-left approaching-file-limit">
{t('project_approaching_file_limit')} ({fileCount.value}/
{fileCount.limit})
</div>
)}
{fileCount.status === 'error' && (
<OLNotification
type="error"
className="at-file-limit"
content={t('project_has_too_many_files')}
>
{/* TODO: add parameter for fileCount.limit */}
</OLNotification>
)}
<OLButton
variant="secondary"
type="button"
disabled={inFlight}
onClick={cancel}
>
{t('cancel')}
</OLButton>
{newFileCreateMode !== 'upload' && (
<OLButton
variant="primary"
type="submit"
form="create-file"
disabled={inFlight || !valid}
isLoading={inFlight}
>
{t('create')}
</OLButton>
)}
</>
)
}
FileTreeModalCreateFileFooterContent.propTypes = {
cancel: PropTypes.func.isRequired,
fileCount: PropTypes.shape({
limit: PropTypes.number.isRequired,
status: PropTypes.string.isRequired,
value: PropTypes.number.isRequired,
}).isRequired,
inFlight: PropTypes.bool.isRequired,
newFileCreateMode: PropTypes.string,
valid: PropTypes.bool.isRequired,
}

View File

@@ -0,0 +1,35 @@
import classnames from 'classnames'
import PropTypes from 'prop-types'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
import * as eventTracking from '../../../../infrastructure/event-tracking'
import OLButton from '@/features/ui/components/ol/ol-button'
import MaterialIcon from '@/shared/components/material-icon'
export default function FileTreeModalCreateFileMode({ mode, icon, label }) {
const { newFileCreateMode, startCreatingFile } = useFileTreeActionable()
const handleClick = () => {
startCreatingFile(mode)
eventTracking.sendMB('file-modal-click', { method: mode })
}
return (
<li className={classnames({ active: newFileCreateMode === mode })}>
<OLButton
variant="link"
onClick={handleClick}
className="modal-new-file-mode"
>
<MaterialIcon type={icon} />
&nbsp;
{label}
</OLButton>
</li>
)
}
FileTreeModalCreateFileMode.propTypes = {
mode: PropTypes.string.isRequired,
icon: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
}

View File

@@ -0,0 +1,155 @@
import { FileTreeEntity } from '../../../../../../types/file-tree-entity'
import { useTranslation } from 'react-i18next'
import { useProjectContext } from '@/shared/context/project-context'
import { useCallback } from 'react'
import { syncDelete } from '@/features/file-tree/util/sync-mutation'
import { TFunction } from 'i18next'
import OLButton from '@/features/ui/components/ol/ol-button'
export type Conflict = {
entity: FileTreeEntity
type: 'file' | 'folder'
}
const getConflictText = (conflicts: Conflict[], t: TFunction) => {
const hasFolderConflict = conflicts.some(
conflict => conflict.type === 'folder'
)
const hasFileConflict = conflicts.some(conflict => conflict.type === 'file')
if (hasFolderConflict && hasFileConflict) {
return t('the_following_files_and_folders_already_exist_in_this_project')
}
if (hasFolderConflict) {
return t('the_following_folder_already_exists_in_this_project', {
count: conflicts.length,
})
}
return t('the_following_files_already_exist_in_this_project')
}
export function FileUploadConflicts({
cancel,
conflicts,
handleOverwrite,
}: {
cancel: () => void
conflicts: Conflict[]
handleOverwrite: () => void
}) {
const { t } = useTranslation()
// Don't allow overwriting folders with files
const hasFolderConflict = conflicts.some(
conflict => conflict.type === 'folder'
)
return (
<div className="small modal-new-file-body-conflict">
{conflicts.length > 0 && (
<>
<p className="text-center mb-0">{getConflictText(conflicts, t)}</p>
<ul className="text-center list-unstyled row-spaced-small mt-1">
{conflicts.map((conflict, index) => (
<li key={index}>
<strong>{conflict.entity.name}</strong>
</li>
))}
</ul>
</>
)}
{!hasFolderConflict && (
<p className="text-center row-spaced-small">
{t('do_you_want_to_overwrite_them')}
</p>
)}
<p className="text-center">
<OLButton variant="secondary" onClick={cancel}>
{t('cancel')}
</OLButton>
&nbsp;
{!hasFolderConflict && (
<OLButton variant="danger" onClick={handleOverwrite}>
{t('overwrite')}
</OLButton>
)}
</p>
</div>
)
}
export function FolderUploadConflicts({
cancel,
handleOverwrite,
conflicts,
setError,
}: {
cancel: () => void
handleOverwrite: () => void
conflicts: Conflict[]
setError: (error: string) => void
}) {
const { t } = useTranslation()
const { _id: projectId } = useProjectContext()
// Don't allow overwriting files with a folder
const hasFileConflict = conflicts.some(conflict => conflict.type === 'file')
const deleteAndRetry = useCallback(async () => {
// TODO: confirm deletion?
try {
await Promise.all(
conflicts.map(conflict =>
syncDelete(projectId, 'folder', conflict.entity._id)
)
)
handleOverwrite()
} catch (error: any) {
setError(error.message)
}
}, [setError, conflicts, handleOverwrite, projectId])
return (
<div className="small modal-new-file-body-conflict">
<p className="text-center mb-0">{getConflictText(conflicts, t)}</p>
<ul className="text-center list-unstyled row-spaced-small mt-1">
{conflicts.map((conflict, index) => (
<li key={index}>
<strong>{conflict.entity.name}</strong>
</li>
))}
</ul>
{!hasFileConflict && (
<p className="text-center row-spaced-small">
{t('overwriting_the_original_folder')}
<br />
{t('do_you_want_to_overwrite_it', {
count: conflicts.length,
})}
</p>
)}
<p className="text-center">
<OLButton variant="secondary" onClick={cancel}>
{t('cancel')}
</OLButton>
&nbsp;
{!hasFileConflict && (
<OLButton variant="danger" onClick={deleteAndRetry}>
{t('overwrite')}
</OLButton>
)}
</p>
</div>
)
}

View File

@@ -0,0 +1,38 @@
import { useCallback, useEffect } from 'react'
import FileTreeCreateNameInput from '../file-tree-create-name-input'
import { useFileTreeActionable } from '../../../contexts/file-tree-actionable'
import { useFileTreeCreateName } from '../../../contexts/file-tree-create-name'
import { useFileTreeCreateForm } from '../../../contexts/file-tree-create-form'
import * as eventTracking from '../../../../../infrastructure/event-tracking'
import ErrorMessage from '../error-message'
export default function FileTreeCreateNewDoc() {
const { name, validName } = useFileTreeCreateName()
const { setValid } = useFileTreeCreateForm()
const { error, finishCreatingDoc, inFlight } = useFileTreeActionable()
// form validation: name is valid
useEffect(() => {
setValid(validName)
}, [setValid, validName])
// form submission: create an empty doc with this name
const handleSubmit = useCallback(
event => {
event.preventDefault()
finishCreatingDoc({ name })
eventTracking.sendMB('new-file-created', {
method: 'doc',
extension: name.split('.').length > 1 ? name.split('.').pop() : '',
})
},
[finishCreatingDoc, name]
)
return (
<form noValidate id="create-file" onSubmit={handleSubmit}>
<FileTreeCreateNameInput focusName error={error} inFlight={inFlight} />
{error && <ErrorMessage error={error} />}
</form>
)
}

View File

@@ -0,0 +1,373 @@
import {
useState,
useCallback,
useEffect,
useMemo,
FormEventHandler,
} from 'react'
import FileTreeCreateNameInput from '../file-tree-create-name-input'
import { useTranslation } from 'react-i18next'
import { useUserProjects } from '../../../hooks/use-user-projects'
import { Entity, useProjectEntities } from '../../../hooks/use-project-entities'
import { useProjectOutputFiles } from '../../../hooks/use-project-output-files'
import { useFileTreeActionable } from '../../../contexts/file-tree-actionable'
import { useFileTreeCreateName } from '../../../contexts/file-tree-create-name'
import { useFileTreeCreateForm } from '../../../contexts/file-tree-create-form'
import { useProjectContext } from '../../../../../shared/context/project-context'
import ErrorMessage from '../error-message'
import * as eventTracking from '../../../../../infrastructure/event-tracking'
import { File } from '@/features/source-editor/utils/file'
import { Project } from '../../../../../../../types/project'
import getMeta from '@/utils/meta'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLForm from '@/features/ui/components/ol/ol-form'
import OLFormSelect from '@/features/ui/components/ol/ol-form-select'
import { Spinner } from 'react-bootstrap-5'
export default function FileTreeImportFromProject() {
const { t } = useTranslation()
const { hasLinkedProjectFileFeature, hasLinkedProjectOutputFileFeature } =
getMeta('ol-ExposedSettings')
const canSwitchOutputFilesMode =
hasLinkedProjectFileFeature && hasLinkedProjectOutputFileFeature
const { name, setName, validName } = useFileTreeCreateName()
const { setValid } = useFileTreeCreateForm()
const { error, finishCreatingLinkedFile, inFlight } = useFileTreeActionable()
const [selectedProject, setSelectedProject] = useState<Project>()
const [selectedProjectEntity, setSelectedProjectEntity] = useState<Entity>()
const [selectedProjectOutputFile, setSelectedProjectOutputFile] = useState<
File & { build: string; clsiServerId: string }
>()
const [isOutputFilesMode, setOutputFilesMode] = useState(
// default to project file mode, unless the feature is not enabled
!hasLinkedProjectFileFeature
)
// use the basename of a path as the file name
const setNameFromPath = useCallback(
path => {
const filename = path.split('/').pop()
if (filename) {
setName(filename)
}
},
[setName]
)
// update the name when an output file is selected
useEffect(() => {
if (selectedProjectOutputFile) {
if (
selectedProjectOutputFile.path === 'output.pdf' &&
selectedProject!.name
) {
// if the output PDF is selected, use the project's name as the filename
setName(`${selectedProject!.name}.pdf`)
} else {
setNameFromPath(selectedProjectOutputFile.path)
}
}
}, [selectedProject, selectedProjectOutputFile, setName, setNameFromPath])
// update the name when an entity is selected
useEffect(() => {
if (selectedProjectEntity) {
setNameFromPath(selectedProjectEntity.path)
}
}, [selectedProjectEntity, setNameFromPath])
// form validation: name is valid and entity or output file is selected
useEffect(() => {
const hasSelectedEntity = Boolean(
isOutputFilesMode ? selectedProjectOutputFile : selectedProjectEntity
)
setValid(validName && hasSelectedEntity)
}, [
setValid,
validName,
isOutputFilesMode,
selectedProjectEntity,
selectedProjectOutputFile,
])
// form submission: create a linked file with this name, from this entity or output file
const handleSubmit: FormEventHandler = event => {
event.preventDefault()
eventTracking.sendMB('new-file-created', {
method: 'project',
extension: name.split('.').length > 1 ? name.split('.').pop() : '',
})
if (isOutputFilesMode) {
finishCreatingLinkedFile({
name,
provider: 'project_output_file',
data: {
source_project_id: selectedProject!._id,
source_output_file_path: selectedProjectOutputFile!.path,
build_id: selectedProjectOutputFile!.build,
clsiServerId: selectedProjectOutputFile!.clsiServerId,
},
})
} else {
finishCreatingLinkedFile({
name,
provider: 'project_file',
data: {
source_project_id: selectedProject!._id,
source_entity_path: selectedProjectEntity!.path,
},
})
}
}
return (
<OLForm id="create-file" onSubmit={handleSubmit}>
<SelectProject
selectedProject={selectedProject}
setSelectedProject={setSelectedProject}
/>
{isOutputFilesMode ? (
<SelectProjectOutputFile
selectedProjectId={selectedProject?._id}
selectedProjectOutputFile={selectedProjectOutputFile}
setSelectedProjectOutputFile={setSelectedProjectOutputFile}
/>
) : (
<SelectProjectEntity
selectedProjectId={selectedProject?._id}
selectedProjectEntity={selectedProjectEntity}
setSelectedProjectEntity={setSelectedProjectEntity}
/>
)}
{canSwitchOutputFilesMode && (
<div className="toggle-file-type-button">
or&nbsp;
<OLButton
variant="link"
type="button"
onClick={() => setOutputFilesMode(value => !value)}
>
<span>
{isOutputFilesMode
? t('select_from_source_files')
: t('select_from_output_files')}
</span>
</OLButton>
</div>
)}
<FileTreeCreateNameInput
label={t('file_name_in_this_project')}
classes={{
formGroup: 'form-controls row-spaced-small',
}}
placeholder="example.tex"
error={error}
inFlight={inFlight}
/>
{error && <ErrorMessage error={error} />}
</OLForm>
)
}
type SelectProjectProps = {
selectedProject?: any
setSelectedProject(project: any): void
}
function SelectProject({
selectedProject,
setSelectedProject,
}: SelectProjectProps) {
const { t } = useTranslation()
const { _id: projectId } = useProjectContext()
const { data, error, loading } = useUserProjects()
const filteredData = useMemo(() => {
if (!data) {
return null
}
return data.filter(item => item._id !== projectId)
}, [data, projectId])
if (error) {
return <ErrorMessage error={error} />
}
return (
<OLFormGroup controlId="project-select">
<OLFormLabel>{t('select_a_project')}</OLFormLabel>
{loading && (
<span>
&nbsp;
<Spinner
animation="border"
aria-hidden="true"
size="sm"
role="status"
/>
</span>
)}
<OLFormSelect
disabled={!data}
value={selectedProject ? selectedProject._id : ''}
onChange={event => {
const projectId = (event.target as HTMLSelectElement).value
const project = data!.find(item => item._id === projectId)
setSelectedProject(project)
}}
>
<option disabled value="">
- {t('please_select_a_project')}
</option>
{filteredData &&
filteredData.map(project => (
<option key={project._id} value={project._id}>
{project.name}
</option>
))}
</OLFormSelect>
{filteredData && !filteredData.length && (
<small>{t('no_other_projects_found')}</small>
)}
</OLFormGroup>
)
}
type SelectProjectOutputFileProps = {
selectedProjectId?: string
selectedProjectOutputFile?: any
setSelectedProjectOutputFile(file: any): void
}
function SelectProjectOutputFile({
selectedProjectId,
selectedProjectOutputFile,
setSelectedProjectOutputFile,
}: SelectProjectOutputFileProps) {
const { t } = useTranslation()
const { data, error, loading } = useProjectOutputFiles(selectedProjectId)
if (error) {
return <ErrorMessage error={error} />
}
return (
<OLFormGroup
className="row-spaced-small"
controlId="project-output-file-select"
>
<OLFormLabel>{t('select_an_output_file')}</OLFormLabel>
{loading && (
<span>
&nbsp;
<Spinner
animation="border"
aria-hidden="true"
size="sm"
role="status"
/>
</span>
)}
<OLFormSelect
disabled={!data}
value={selectedProjectOutputFile?.path || ''}
onChange={event => {
const path = (event.target as unknown as HTMLSelectElement).value
const file = data?.find(item => item.path === path)
setSelectedProjectOutputFile(file)
}}
>
<option disabled value="">
- {t('please_select_an_output_file')}
</option>
{data &&
data.map(file => (
<option key={file.path} value={file.path}>
{file.path}
</option>
))}
</OLFormSelect>
</OLFormGroup>
)
}
type SelectProjectEntityProps = {
selectedProjectId?: string
selectedProjectEntity?: any
setSelectedProjectEntity(entity: any): void
}
function SelectProjectEntity({
selectedProjectId,
selectedProjectEntity,
setSelectedProjectEntity,
}: SelectProjectEntityProps) {
const { t } = useTranslation()
const { data, error, loading } = useProjectEntities(selectedProjectId)
if (error) {
return <ErrorMessage error={error} />
}
return (
<OLFormGroup className="row-spaced-small" controlId="project-entity-select">
<OLFormLabel>{t('select_a_file')}</OLFormLabel>
{loading && (
<span>
&nbsp;
<Spinner
animation="border"
aria-hidden="true"
size="sm"
role="status"
/>
</span>
)}
<OLFormSelect
disabled={!data}
value={selectedProjectEntity?.path || ''}
onChange={event => {
const path = (event.target as HTMLSelectElement).value
const entity = data!.find(item => item.path === path)
setSelectedProjectEntity(entity)
}}
>
<option disabled value="">
- {t('please_select_a_file')}
</option>
{data &&
data.map(entity => (
<option key={entity.path} value={entity.path}>
{entity.path.slice(1)}
</option>
))}
</OLFormSelect>
</OLFormGroup>
)
}

View File

@@ -0,0 +1,80 @@
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import FileTreeCreateNameInput from '../file-tree-create-name-input'
import { useFileTreeActionable } from '../../../contexts/file-tree-actionable'
import { useFileTreeCreateName } from '../../../contexts/file-tree-create-name'
import { useFileTreeCreateForm } from '../../../contexts/file-tree-create-form'
import ErrorMessage from '../error-message'
import * as eventTracking from '../../../../../infrastructure/event-tracking'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
export default function FileTreeImportFromUrl() {
const { t } = useTranslation()
const { name, setName, validName } = useFileTreeCreateName()
const { setValid } = useFileTreeCreateForm()
const { finishCreatingLinkedFile, error, inFlight } = useFileTreeActionable()
const [url, setUrl] = useState('')
const handleChange = useCallback(event => {
setUrl(event.target.value)
}, [])
// set the name when the URL changes
useEffect(() => {
if (url) {
const matches = url.match(/^\s*https?:\/\/.+\/([^/]+\.(\w+))\s*$/)
setName(matches ? matches[1] : '')
}
}, [setName, url])
// form validation: URL is set and name is valid
useEffect(() => {
setValid(validName && !!url)
}, [setValid, validName, url])
// form submission: create a linked file with this name, from this URL
const handleSubmit = event => {
event.preventDefault()
eventTracking.sendMB('new-file-created', {
method: 'url',
extension: name.split('.').length > 1 ? name.split('.').pop() : '',
})
finishCreatingLinkedFile({
name,
provider: 'url',
data: { url: url.trim() },
})
}
return (
<form
className="form-controls"
id="create-file"
noValidate
onSubmit={handleSubmit}
>
<OLFormGroup controlId="import-from-url">
<OLFormLabel>{t('url_to_fetch_the_file_from')}</OLFormLabel>
<OLFormControl
type="url"
placeholder="https://example.com/my-file.png"
required
value={url}
onChange={handleChange}
/>
</OLFormGroup>
<FileTreeCreateNameInput
label={t('file_name_in_this_project')}
placeholder="my_file"
error={error}
inFlight={inFlight}
/>
{error && <ErrorMessage error={error} />}
</form>
)
}

View File

@@ -0,0 +1,339 @@
import { useTranslation } from 'react-i18next'
import { useCallback, useEffect, useState } from 'react'
import Uppy from '@uppy/core'
import XHRUpload from '@uppy/xhr-upload'
import { Dashboard } from '@uppy/react'
import { useFileTreeActionable } from '../../../contexts/file-tree-actionable'
import { useProjectContext } from '../../../../../shared/context/project-context'
import * as eventTracking from '../../../../../infrastructure/event-tracking'
import '@uppy/core/dist/style.css'
import '@uppy/dashboard/dist/style.css'
import { refreshProjectMetadata } from '../../../util/api'
import ErrorMessage from '../error-message'
import { debugConsole } from '@/utils/debugging'
import { isAcceptableFile } from '@/features/file-tree/util/is-acceptable-file'
import {
findFileByNameInFolder,
findFolderByNameInFolder,
} from '@/features/file-tree/util/is-name-unique-in-folder'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import {
Conflict,
FileUploadConflicts,
FolderUploadConflicts,
} from '@/features/file-tree/components/file-tree-create/file-tree-upload-conflicts'
import getMeta from '@/utils/meta'
export default function FileTreeUploadDoc() {
const { parentFolderId, cancel, droppedFiles, setDroppedFiles } =
useFileTreeActionable()
const { fileTreeData } = useFileTreeData()
const { _id: projectId } = useProjectContext()
const [error, setError] = useState<string>()
const [conflicts, setConflicts] = useState<Conflict[]>([])
const [folderConflicts, setFolderConflicts] = useState<Conflict[]>([])
const [overwrite, setOverwrite] = useState(false)
const maxNumberOfFiles = 180
const maxFileSize = getMeta('ol-ExposedSettings').maxUploadSize
// calculate conflicts
const buildConflicts = (files: Record<string, any>) => {
const conflicts: Conflict[] = []
for (const file of Object.values(files)) {
const { name, relativePath } = file.meta
if (!relativePath) {
const targetFolderId = file.meta.targetFolderId ?? parentFolderId
const duplicateFile = findFileByNameInFolder(
fileTreeData,
targetFolderId,
name
)
if (duplicateFile) {
conflicts.push({
entity: duplicateFile,
type: 'file',
})
}
const duplicateFolder = findFolderByNameInFolder(
fileTreeData,
targetFolderId,
name
)
if (duplicateFolder) {
conflicts.push({
entity: duplicateFolder,
type: 'folder',
})
}
}
}
return conflicts
}
const buildFolderConflicts = (files: Record<string, any>) => {
const conflicts: Conflict[] = []
for (const file of Object.values(files)) {
const { relativePath } = file.meta
if (relativePath) {
const [rootName] = relativePath.replace(/^\//, '').split('/')
const targetFolderId = file.meta.targetFolderId ?? parentFolderId
const duplicateFile = findFileByNameInFolder(
fileTreeData,
targetFolderId,
rootName
)
if (duplicateFile) {
conflicts.push({
entity: duplicateFile,
type: 'file',
})
}
const duplicateFolder = findFolderByNameInFolder(
fileTreeData,
targetFolderId,
rootName
)
if (duplicateFolder) {
conflicts.push({
entity: duplicateFolder,
type: 'folder',
})
}
}
}
return conflicts
}
const buildEndpoint = (projectId: string, targetFolderId: string) => {
let endpoint = `/project/${projectId}/upload`
if (targetFolderId) {
endpoint += `?folder_id=${targetFolderId}`
}
return endpoint
}
// initialise the Uppy object
const [uppy] = useState(() => {
const endpoint = buildEndpoint(projectId, parentFolderId)
return (
new Uppy<{ relativePath?: string; targetFolderId: string }>({
// logger: Uppy.debugLogger,
allowMultipleUploadBatches: false,
restrictions: {
maxNumberOfFiles,
maxFileSize: maxFileSize || null,
},
onBeforeFileAdded(file) {
if (
!isAcceptableFile(
file.name,
file.meta.relativePath as string | undefined
)
) {
return false
}
},
onBeforeUpload(files) {
const conflicts = buildConflicts(files)
const folderConflicts = buildFolderConflicts(files)
setConflicts(conflicts)
setFolderConflicts(folderConflicts)
return conflicts.length === 0 && folderConflicts.length === 0
},
autoProceed: true,
locale: {
strings: {
youCanOnlyUploadX:
'You can only upload %{smart_count} files at a time',
},
},
})
// use the basic XHR uploader
.use(XHRUpload, {
endpoint,
headers: {
'X-CSRF-TOKEN': getMeta('ol-csrfToken'),
},
// limit: maxConnections || 1,
limit: 1,
fieldName: 'qqfile', // "qqfile" field inherited from FineUploader
})
// close the modal when all the uploads completed successfully
.on('complete', result => {
if (!result.failed.length) {
// $scope.$emit('done', { name: name })
cancel()
}
})
// broadcast doc metadata after each successful upload
.on('upload-success', (file, response) => {
eventTracking.sendMB('new-file-created', {
method: 'upload',
extension:
file?.name && file?.name.split('.').length > 1
? file?.name.split('.').pop()
: '',
})
if (response.body.entity_type === 'doc') {
window.setTimeout(() => {
refreshProjectMetadata(projectId, response.body.entity_id)
}, 250)
}
})
// handle upload errors
.on('upload-error', (file, error, response) => {
switch (response?.status) {
case 429:
setError('rate-limit-hit')
break
case 403:
setError('not-logged-in')
break
default:
debugConsole.error(error)
setError(response?.body?.error || 'generic_something_went_wrong')
break
}
})
)
})
useEffect(() => {
if (uppy && droppedFiles) {
uppy.setOptions({
autoProceed: false,
})
for (const file of droppedFiles.files) {
const fileId = uppy.addFile({
name: file.name,
type: file.type,
data: file,
source: 'Local',
isRemote: false,
meta: {
relativePath: (file as any).relativePath,
targetFolderId: droppedFiles.targetFolderId,
},
})
const uppyFile = uppy.getFile(fileId)
uppy.setFileState(fileId, {
xhrUpload: {
...(uppyFile as any).xhrUpload,
endpoint: buildEndpoint(projectId, droppedFiles.targetFolderId),
},
})
}
}
return () => {
setDroppedFiles(null)
}
}, [uppy, droppedFiles, setDroppedFiles, projectId])
// handle forced overwriting of conflicting files
const handleOverwrite = useCallback(() => {
setOverwrite(true)
uppy.setOptions({
onBeforeUpload() {
// don't check for file conflicts
return true
},
})
uppy.upload()
}, [uppy])
const showFolderUploadConflicts = !overwrite && folderConflicts.length > 0
const showFileUploadConfilcts =
!overwrite && !showFolderUploadConflicts && conflicts.length > 0
const showDashboard = !showFileUploadConfilcts && !showFolderUploadConflicts
return (
<>
{error && (
<UploadErrorMessage error={error} maxNumberOfFiles={maxNumberOfFiles} />
)}
{showFolderUploadConflicts && (
<FolderUploadConflicts
cancel={cancel}
conflicts={folderConflicts}
handleOverwrite={handleOverwrite}
setError={setError}
/>
)}
{showFileUploadConfilcts && (
<FileUploadConflicts
cancel={cancel}
conflicts={conflicts}
handleOverwrite={handleOverwrite}
/>
)}
{showDashboard && (
<Dashboard
uppy={uppy}
showProgressDetails
// note={`Up to ${maxNumberOfFiles} files, up to ${maxFileSize / (1024 * 1024)}MB`}
height={400}
width="100%"
showLinkToFileUploadResult={false}
proudlyDisplayPoweredByUppy={false}
// allow files or folders to be selected
fileManagerSelectionType="both"
locale={{
strings: {
// Text to show on the droppable area.
// `%{browse}` is replaced with a link that opens the system file selection dialog.
// TODO: 'drag_here' or 'drop_files_here_to_upload'?
// dropHereOr: `${t('drag_here')} ${t('or')} %{browse}`,
dropPasteBoth: `Drop or paste your files, folder, or images here. %{browseFiles} or %{browseFolders} from your computer.`,
// Used as the label for the link that opens the system file selection dialog.
// browseFiles: t('select_from_your_computer')
browseFiles: 'Select files',
browseFolders: 'select a folder',
},
}}
/>
)}
</>
)
}
function UploadErrorMessage({
error,
maxNumberOfFiles,
}: {
error: string
maxNumberOfFiles: number
}) {
const { t } = useTranslation()
switch (error) {
case 'too-many-files':
return (
<>
{t('maximum_files_uploaded_together', {
max: maxNumberOfFiles,
})}
</>
)
default:
return <ErrorMessage error={error} />
}
}

View File

@@ -0,0 +1,36 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useProjectContext } from '../../../../shared/context/project-context'
import { useLocation } from '../../../../shared/hooks/use-location'
// handle "not-logged-in" errors by redirecting to the login page
export default function RedirectToLogin() {
const { t } = useTranslation()
const { _id: projectId } = useProjectContext()
const [secondsToRedirect, setSecondsToRedirect] = useState(10)
const location = useLocation()
useEffect(() => {
setSecondsToRedirect(10)
const timer = window.setInterval(() => {
setSecondsToRedirect(value => {
if (value === 0) {
window.clearInterval(timer)
location.assign(`/login?redir=/project/${projectId}`)
return 0
}
return value - 1
})
}, 1000)
return () => {
window.clearInterval(timer)
}
}, [projectId, location])
return t('session_expired_redirecting_to_login', {
seconds: secondsToRedirect,
})
}

View File

@@ -0,0 +1,43 @@
import { useSelectableEntity } from '../contexts/file-tree-selectable'
import FileTreeIcon from './file-tree-icon'
import FileTreeItemInner from './file-tree-item/file-tree-item-inner'
function FileTreeDoc({
name,
id,
isFile,
isLinkedFile,
}: {
name: string
id: string
isFile?: boolean
isLinkedFile?: boolean
}) {
const type = isFile ? 'file' : 'doc'
const { isSelected, props: selectableEntityProps } = useSelectableEntity(
id,
type
)
return (
<li
// eslint-disable-next-line jsx-a11y/role-has-required-aria-props
role="treeitem"
// aria-selected is provided in selectableEntityProps
{...selectableEntityProps}
aria-label={name}
tabIndex={0}
>
<FileTreeItemInner
id={id}
name={name}
type={type}
isSelected={isSelected}
icons={<FileTreeIcon isLinkedFile={isLinkedFile} name={name} />}
/>
</li>
)
}
export default FileTreeDoc

View File

@@ -0,0 +1,78 @@
import { useRef } from 'react'
import PropTypes from 'prop-types'
import classNames from 'classnames'
// a custom component rendered on top of a draggable area that renders the
// dragged item. See
// https://react-dnd.github.io/react-dnd/examples/drag-around/custom-drag-layer
// for more details.
// Also used to display a container border when hovered.
function FileTreeDraggablePreviewLayer({
isOver,
isDragging,
item,
clientOffset,
}) {
const ref = useRef()
return (
<div
ref={ref}
className={classNames('dnd-draggable-preview-layer', {
'dnd-droppable-hover': isOver,
})}
>
{isDragging && item?.title && (
<div
style={getItemStyle(
clientOffset,
ref.current?.getBoundingClientRect()
)}
>
<DraggablePreviewItem title={item.title} />
</div>
)}
</div>
)
}
FileTreeDraggablePreviewLayer.propTypes = {
isOver: PropTypes.bool.isRequired,
isDragging: PropTypes.bool.isRequired,
item: PropTypes.shape({
title: PropTypes.string,
}),
clientOffset: PropTypes.shape({
x: PropTypes.number,
y: PropTypes.number,
}),
}
function DraggablePreviewItem({ title }) {
return <div className="dnd-draggable-preview-item">{title}</div>
}
DraggablePreviewItem.propTypes = {
title: PropTypes.string.isRequired,
}
// makes the preview item follow the cursor.
// See https://react-dnd.github.io/react-dnd/docs/api/drag-layer-monitor
function getItemStyle(clientOffset, containerOffset) {
if (!containerOffset || !clientOffset) {
return {
display: 'none',
}
}
const { x: containerX, y: containerY } = containerOffset
const { x: clientX, y: clientY } = clientOffset
const posX = clientX - containerX - 15
const posY = clientY - containerY - 15
const transform = `translate(${posX}px, ${posY}px)`
return {
transform,
WebkitTransform: transform,
}
}
export default FileTreeDraggablePreviewLayer

View File

@@ -0,0 +1,20 @@
import { useTranslation } from 'react-i18next'
import { useLocation } from '../../../shared/hooks/use-location'
import OLButton from '@/features/ui/components/ol/ol-button'
function FileTreeError() {
const { t } = useTranslation()
const { reload: handleClick } = useLocation()
return (
<div className="file-tree-error">
<p>{t('generic_something_went_wrong')}</p>
<p>{t('please_refresh')}</p>
<OLButton variant="primary" onClick={handleClick}>
{t('refresh')}
</OLButton>
</div>
)
}
export default FileTreeError

View File

@@ -0,0 +1,51 @@
import { useTranslation } from 'react-i18next'
import MaterialIcon from '@/shared/components/material-icon'
import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
function FileTreeFolderIcons({
expanded,
onExpandCollapseClick,
}: {
expanded: boolean
onExpandCollapseClick: () => void
}) {
const { t } = useTranslation()
const newEditor = useIsNewEditorEnabled()
if (newEditor) {
return (
<>
<button
className="folder-expand-collapse-button"
onClick={onExpandCollapseClick}
aria-label={expanded ? t('collapse') : t('expand')}
>
<MaterialIcon
type={expanded ? 'expand_more' : 'chevron_right'}
className="file-tree-expand-icon"
/>
</button>
</>
)
}
return (
<>
<button
onClick={onExpandCollapseClick}
aria-label={expanded ? t('collapse') : t('expand')}
>
<MaterialIcon
type={expanded ? 'expand_more' : 'chevron_right'}
className="file-tree-expand-icon"
/>
</button>
<MaterialIcon
type={expanded ? 'folder_open' : 'folder'}
className="file-tree-folder-icon"
/>
</>
)
}
export default FileTreeFolderIcons

View File

@@ -0,0 +1,84 @@
import classNames from 'classnames'
import FileTreeDoc from './file-tree-doc'
import FileTreeFolder from './file-tree-folder'
import { fileCollator } from '../util/file-collator'
import { Folder } from '../../../../../types/folder'
import { Doc } from '../../../../../types/doc'
import { FileRef } from '../../../../../types/file-ref'
import { ConnectDropTarget } from 'react-dnd'
type ExtendedFileRef = FileRef & { isFile: true }
function FileTreeFolderList({
folders,
docs,
files,
classes = {},
dropRef = null,
children,
dataTestId,
}: {
folders: Folder[]
docs: Doc[]
files: FileRef[]
classes?: { root?: string }
dropRef?: ConnectDropTarget | null
children?: React.ReactNode
dataTestId?: string
}) {
files = files.map(file => ({ ...file, isFile: true }))
const docsAndFiles: (Doc | ExtendedFileRef)[] = [...docs, ...files]
return (
<ul
className={classNames(
'list-unstyled',
'file-tree-folder-list',
classes.root
)}
role="tree"
ref={dropRef}
data-testid={dataTestId}
>
<div className="file-tree-folder-list-inner">
{folders.sort(compareFunction).map(folder => {
return (
<FileTreeFolder
key={folder._id}
name={folder.name}
id={folder._id}
folders={folder.folders}
docs={folder.docs}
files={folder.fileRefs}
/>
)
})}
{docsAndFiles.sort(compareFunction).map(doc => {
if ('isFile' in doc) {
return (
<FileTreeDoc
key={doc._id}
name={doc.name}
id={doc._id}
isFile={doc.isFile}
isLinkedFile={
doc.linkedFileData && !!doc.linkedFileData.provider
}
/>
)
}
return <FileTreeDoc key={doc._id} name={doc.name} id={doc._id} />
})}
{children}
</div>
</ul>
)
}
function compareFunction(one: { name: string }, two: { name: string }) {
return fileCollator.compare(one.name, two.name)
}
export default FileTreeFolderList

View File

@@ -0,0 +1,96 @@
import { useEffect } from 'react'
import classNames from 'classnames'
import {
useFileTreeSelectable,
useSelectableEntity,
} from '../contexts/file-tree-selectable'
import { useDroppable } from '../contexts/file-tree-draggable'
import FileTreeItemInner from './file-tree-item/file-tree-item-inner'
import FileTreeFolderList from './file-tree-folder-list'
import usePersistedState from '../../../shared/hooks/use-persisted-state'
import { Folder } from '../../../../../types/folder'
import { Doc } from '../../../../../types/doc'
import { FileRef } from '../../../../../types/file-ref'
import FileTreeFolderIcons from './file-tree-folder-icons'
function FileTreeFolder({
name,
id,
folders,
docs,
files,
}: {
name: string
id: string
folders: Folder[]
docs: Doc[]
files: FileRef[]
}) {
const { isSelected, props: selectableEntityProps } = useSelectableEntity(
id,
'folder'
)
const { selectedEntityParentIds } = useFileTreeSelectable()
const [expanded, setExpanded] = usePersistedState(
`folder.${id}.expanded`,
false
)
useEffect(() => {
if (selectedEntityParentIds.has(id)) {
setExpanded(true)
}
}, [id, selectedEntityParentIds, setExpanded])
function handleExpandCollapseClick() {
setExpanded(!expanded)
}
const { isOver: isOverRoot, dropRef: dropRefRoot } = useDroppable(id)
const { isOver: isOverList, dropRef: dropRefList } = useDroppable(id)
return (
<>
<li
// eslint-disable-next-line jsx-a11y/role-has-required-aria-props
role="treeitem"
// aria-selected is provided in selectableEntityProps
{...selectableEntityProps}
aria-expanded={expanded}
aria-label={name}
tabIndex={0}
ref={dropRefRoot}
className={classNames(selectableEntityProps.className, {
'dnd-droppable-hover': isOverRoot || isOverList,
})}
>
<FileTreeItemInner
id={id}
name={name}
type="folder"
isSelected={isSelected}
icons={
<FileTreeFolderIcons
expanded={expanded}
onExpandCollapseClick={handleExpandCollapseClick}
/>
}
/>
</li>
{expanded ? (
<FileTreeFolderList
folders={folders}
docs={docs}
files={files}
dropRef={dropRefList}
/>
) : null}
</>
)
}
export default FileTreeFolder

View File

@@ -0,0 +1,60 @@
import { useTranslation } from 'react-i18next'
import iconTypeFromName, {
newEditorIconTypeFromName,
} from '../util/icon-type-from-name'
import classnames from 'classnames'
import MaterialIcon from '@/shared/components/material-icon'
import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
function FileTreeIcon({
isLinkedFile,
name,
}: {
name: string
isLinkedFile?: boolean
}) {
const { t } = useTranslation()
const className = classnames('file-tree-icon', {
'linked-file-icon': isLinkedFile,
})
const newEditor = useIsNewEditorEnabled()
if (newEditor) {
return (
<>
<MaterialIcon
unfilled
type={newEditorIconTypeFromName(name)}
className={className}
/>
{isLinkedFile && (
<MaterialIcon
type="open_in_new"
modifier="rotate-180"
className="linked-file-highlight"
accessibilityLabel={t('linked_file')}
/>
)}
</>
)
}
return (
<>
&nbsp;
<MaterialIcon type={iconTypeFromName(name)} className={className} />
{isLinkedFile && (
<MaterialIcon
type="open_in_new"
modifier="rotate-180"
className="linked-file-highlight"
accessibilityLabel={t('linked_file')}
/>
)}
</>
)
}
export default FileTreeIcon

View File

@@ -0,0 +1,27 @@
import { useFileTreeSelectable } from '../contexts/file-tree-selectable'
import { FC, useCallback } from 'react'
const FileTreeInner: FC = ({ children }) => {
const { setIsRootFolderSelected, selectedEntityIds, select } =
useFileTreeSelectable()
const handleFileTreeClick = useCallback(() => {
setIsRootFolderSelected(true)
if (selectedEntityIds.size > 1) {
select([])
}
}, [select, selectedEntityIds.size, setIsRootFolderSelected])
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
className="file-tree-inner"
onClick={handleFileTreeClick}
data-testid="file-tree-inner"
>
{children}
</div>
)
}
export default FileTreeInner

View File

@@ -0,0 +1,99 @@
import { ReactNode, useEffect, useRef } from 'react'
import classNames from 'classnames'
import scrollIntoViewIfNeeded from 'scroll-into-view-if-needed'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { useFileTreeMainContext } from '../../contexts/file-tree-main'
import { useDraggable } from '../../contexts/file-tree-draggable'
import FileTreeItemName from './file-tree-item-name'
import FileTreeItemMenu from './file-tree-item-menu'
import { useFileTreeSelectable } from '../../contexts/file-tree-selectable'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
import { useDragDropManager } from 'react-dnd'
function FileTreeItemInner({
id,
name,
type,
isSelected,
icons,
}: {
id: string
name: string
type: string
isSelected: boolean
icons?: ReactNode
}) {
const { fileTreeReadOnly } = useFileTreeData()
const { setContextMenuCoords } = useFileTreeMainContext()
const { isRenaming } = useFileTreeActionable()
const { selectedEntityIds } = useFileTreeSelectable()
const hasMenu =
!fileTreeReadOnly && isSelected && selectedEntityIds.size === 1
const { dragRef, setIsDraggable } = useDraggable(id)
const dragDropItem = useDragDropManager().getMonitor().getItem()
const itemRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const item = itemRef.current
if (isSelected && item) {
// we're delaying scrolling due to a race condition with other elements,
// mainly the Outline, being resized inside the same panel, causing the
// FileTree to have its viewport shrinked after the selected item is
// scrolled into the view, hiding it again.
// See `left-pane-resize-all` in `file-tree-controller` for more information.
setTimeout(() => {
if (item) {
scrollIntoViewIfNeeded(item, {
scrollMode: 'if-needed',
})
}
}, 100)
}
}, [isSelected, itemRef])
function handleContextMenu(ev: React.MouseEvent<HTMLDivElement>) {
ev.preventDefault()
setContextMenuCoords({
top: ev.pageY,
left: ev.pageX,
})
}
return (
<div
className={classNames('entity', {
'file-tree-entity-dragging': dragDropItem?.draggedEntityIds?.has(id),
})}
role="presentation"
ref={dragRef}
draggable={!isRenaming}
onContextMenu={handleContextMenu}
data-file-id={id}
data-file-type={type}
>
<div
className="entity-name entity-name-react"
role="presentation"
ref={itemRef}
>
{icons}
<FileTreeItemName
name={name}
isSelected={isSelected}
setIsDraggable={setIsDraggable}
/>
{hasMenu ? <FileTreeItemMenu id={id} name={name} /> : null}
</div>
</div>
)
}
export default FileTreeItemInner

View File

@@ -0,0 +1,94 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import * as eventTracking from '../../../../infrastructure/event-tracking'
import { useProjectContext } from '@/shared/context/project-context'
import {
DropdownDivider,
DropdownItem,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
function FileTreeItemMenuItems() {
const { t } = useTranslation()
const {
canRename,
canDelete,
canCreate,
startRenaming,
startDeleting,
startCreatingFolder,
startCreatingDocOrFile,
startUploadingDocOrFile,
downloadPath,
selectedFileName,
} = useFileTreeActionable()
const { owner } = useProjectContext()
const downloadWithAnalytics = useCallback(() => {
// we are only interested in downloads of bib files WRT analytics, for the purposes of promoting the tpr integrations
if (selectedFileName?.endsWith('.bib')) {
eventTracking.sendMB('download-bib-file', { projectOwner: owner._id })
}
}, [selectedFileName, owner])
const createWithAnalytics = useCallback(() => {
eventTracking.sendMB('new-file-click', { location: 'file-menu' })
startCreatingDocOrFile()
}, [startCreatingDocOrFile])
const uploadWithAnalytics = useCallback(() => {
eventTracking.sendMB('upload-click', { location: 'file-menu' })
startUploadingDocOrFile()
}, [startUploadingDocOrFile])
return (
<>
{canRename ? (
<li role="none">
<DropdownItem onClick={startRenaming}>{t('rename')}</DropdownItem>
</li>
) : null}
{downloadPath ? (
<li role="none">
<DropdownItem
href={downloadPath}
onClick={downloadWithAnalytics}
download={selectedFileName ?? undefined}
>
{t('download')}
</DropdownItem>
</li>
) : null}
{canDelete ? (
<li role="none">
<DropdownItem onClick={startDeleting}>{t('delete')}</DropdownItem>
</li>
) : null}
{canCreate ? (
<>
<DropdownDivider />
<li role="none">
<DropdownItem onClick={createWithAnalytics}>
{t('new_file')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem onClick={startCreatingFolder}>
{t('new_folder')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem onClick={uploadWithAnalytics}>
{t('upload')}
</DropdownItem>
</li>
</>
) : null}
</>
)
}
export default FileTreeItemMenuItems

View File

@@ -0,0 +1,43 @@
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useFileTreeMainContext } from '../../contexts/file-tree-main'
import MaterialIcon from '@/shared/components/material-icon'
function FileTreeItemMenu({ id, name }: { id: string; name: string }) {
const { t } = useTranslation()
const { contextMenuCoords, setContextMenuCoords } = useFileTreeMainContext()
const menuButtonRef = useRef<HTMLButtonElement>(null)
const isMenuOpen = Boolean(contextMenuCoords)
function handleClick(event: React.MouseEvent) {
event.stopPropagation()
if (!contextMenuCoords && menuButtonRef.current) {
const target = menuButtonRef.current.getBoundingClientRect()
setContextMenuCoords({
top: target.top + target.height / 2,
left: target.right,
})
} else {
setContextMenuCoords(null)
}
}
return (
<div className="menu-button btn-group">
<button
className="entity-menu-toggle btn btn-sm"
id={`menu-button-${id}`}
onClick={handleClick}
ref={menuButtonRef}
aria-haspopup="true"
aria-expanded={isMenuOpen}
aria-label={t('open_action_menu', { name })}
>
<MaterialIcon type="more_vert" accessibilityLabel={t('menu')} />
</button>
</div>
)
}
export default FileTreeItemMenu

View File

@@ -0,0 +1,133 @@
import { useState, useEffect, RefObject } from 'react'
import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
function FileTreeItemName({
name,
isSelected,
setIsDraggable,
}: {
name: string
isSelected: boolean
setIsDraggable: (isDraggable: boolean) => void
}) {
const { isRenaming, startRenaming, finishRenaming, error, cancel } =
useFileTreeActionable()
const isRenamingEntity = isRenaming && isSelected && !error
useEffect(() => {
setIsDraggable(!isRenamingEntity)
}, [setIsDraggable, isRenamingEntity])
if (isRenamingEntity) {
return (
<InputName
initialValue={name}
finishRenaming={finishRenaming}
cancel={cancel}
/>
)
}
return (
<DisplayName
name={name}
isSelected={isSelected}
startRenaming={startRenaming}
/>
)
}
function DisplayName({
name,
isSelected,
startRenaming,
}: {
name: string
isSelected: boolean
startRenaming: () => void
}) {
const [clicksInSelectedCount, setClicksInSelectedCount] = useState(0)
function onClick() {
setClicksInSelectedCount(clicksInSelectedCount + 1)
if (!isSelected) setClicksInSelectedCount(0)
}
function onDoubleClick() {
// only start renaming if the button got two or more clicks while the item
// was selected. This is to prevent starting a rename on an unselected item.
// When the item is being selected it can trigger a loss of focus which
// causes UI problems.
if (clicksInSelectedCount < 2) return
startRenaming()
}
return (
<button
className="item-name-button"
onClick={onClick}
onDoubleClick={onDoubleClick}
>
<span>{name}</span>
</button>
)
}
function InputName({
initialValue,
finishRenaming,
cancel,
}: {
initialValue: string
finishRenaming: (value: string) => void
cancel: () => void
}) {
const [value, setValue] = useState(initialValue)
// The react-bootstrap Dropdown re-focuses on the Dropdown.Toggle
// after a menu item is clicked, following the ARIA authoring practices:
// https://www.w3.org/TR/wai-aria-practices/examples/menu-button/menu-button-links.html
// To improve UX, we want to auto-focus to the input when renaming. We use
// requestAnimationFrame to immediately move focus to the input after it is
// shown
const { autoFocusedRef } = useRefWithAutoFocus()
function handleFocus(ev: React.FocusEvent<HTMLInputElement>) {
const lastDotIndex = ev.target.value.lastIndexOf('.')
ev.target.setSelectionRange(0, lastDotIndex)
}
function handleChange(ev: React.ChangeEvent<HTMLInputElement>) {
setValue(ev.target.value)
}
function handleKeyDown(ev: React.KeyboardEvent<HTMLInputElement>) {
if (ev.key === 'Enter') {
finishRenaming(value)
}
if (ev.key === 'Escape') {
cancel()
}
}
function handleBlur() {
finishRenaming(value)
}
return (
<span className="rename-input">
<input
type="text"
value={value}
onKeyDown={handleKeyDown}
onChange={handleChange}
onBlur={handleBlur}
onFocus={handleFocus}
ref={autoFocusedRef as RefObject<HTMLInputElement>}
/>
</span>
)
}
export default FileTreeItemName

View File

@@ -0,0 +1,149 @@
import React, { useEffect, useState } from 'react'
import withErrorBoundary from '../../../infrastructure/error-boundary'
import { useProjectContext } from '../../../shared/context/project-context'
import { useFileTreeData } from '../../../shared/context/file-tree-data-context'
import FileTreeContext from './file-tree-context'
import FileTreeDraggablePreviewLayer from './file-tree-draggable-preview-layer'
import FileTreeFolderList from './file-tree-folder-list'
import FileTreeToolbar from './file-tree-toolbar'
import FileTreeToolbarNew from '@/features/ide-redesign/components/file-tree-toolbar'
import FileTreeModalDelete from './modals/file-tree-modal-delete'
import FileTreeModalCreateFolder from './modals/file-tree-modal-create-folder'
import FileTreeModalError from './modals/file-tree-modal-error'
import FileTreeContextMenu from './file-tree-context-menu'
import FileTreeError from './file-tree-error'
import { useDroppable } from '../contexts/file-tree-draggable'
import { useFileTreeSocketListener } from '../hooks/file-tree-socket-listener'
import FileTreeModalCreateFile from './modals/file-tree-modal-create-file'
import FileTreeInner from './file-tree-inner'
import { useDragLayer } from 'react-dnd'
import classnames from 'classnames'
import { pathInFolder } from '@/features/file-tree/util/path'
import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
const FileTreeRoot = React.memo<{
onSelect: () => void
onDelete: () => void
onInit: () => void
isConnected: boolean
setRefProviderEnabled: () => void
setStartedFreeTrial: () => void
refProviders: Record<string, boolean>
}>(function FileTreeRoot({
refProviders,
setRefProviderEnabled,
setStartedFreeTrial,
onSelect,
onInit,
onDelete,
isConnected,
}) {
const [fileTreeContainer, setFileTreeContainer] =
useState<HTMLDivElement | null>(null)
const { _id: projectId } = useProjectContext()
const { fileTreeData } = useFileTreeData()
const isReady = Boolean(projectId && fileTreeData)
const newEditor = useIsNewEditorEnabled()
useEffect(() => {
if (fileTreeContainer) {
const listener = (event: DragEvent) => {
if (event.dataTransfer) {
// store the dragged entity in dataTransfer
const { dataset } = event.target as HTMLDivElement
if (
dataset.fileId &&
dataset.fileType &&
dataset.fileType !== 'folder'
) {
event.dataTransfer.setData(
'application/x-overleaf-file-id',
dataset.fileId
)
const filePath = pathInFolder(fileTreeData, dataset.fileId)
if (filePath) {
event.dataTransfer.setData(
'application/x-overleaf-file-path',
filePath
)
}
}
}
}
fileTreeContainer.addEventListener('dragstart', listener)
return () => {
fileTreeContainer.removeEventListener('dragstart', listener)
}
}
}, [fileTreeContainer, fileTreeData])
useEffect(() => {
if (isReady) onInit()
}, [isReady, onInit])
if (!isReady) return null
return (
<div
className="file-tree"
data-testid="file-tree"
ref={setFileTreeContainer}
>
{fileTreeContainer && (
<FileTreeContext
refProviders={refProviders}
setRefProviderEnabled={setRefProviderEnabled}
setStartedFreeTrial={setStartedFreeTrial}
onSelect={onSelect}
fileTreeContainer={fileTreeContainer}
>
{isConnected ? null : <div className="disconnected-overlay" />}
{newEditor ? <FileTreeToolbarNew /> : <FileTreeToolbar />}
<FileTreeContextMenu />
<FileTreeInner>
<FileTreeRootFolder onDelete={onDelete} />
</FileTreeInner>
<FileTreeModalDelete />
<FileTreeModalCreateFile />
<FileTreeModalCreateFolder />
<FileTreeModalError />
</FileTreeContext>
)}
</div>
)
})
function FileTreeRootFolder({ onDelete }: { onDelete: () => void }) {
useFileTreeSocketListener(onDelete)
const { fileTreeData } = useFileTreeData()
const { isOver, dropRef } = useDroppable(fileTreeData._id)
const dragLayer = useDragLayer(monitor => ({
isDragging: monitor.isDragging(),
item: monitor.getItem(),
clientOffset: monitor.getClientOffset(),
}))
return (
<>
<FileTreeDraggablePreviewLayer isOver={isOver} {...dragLayer} />
<FileTreeFolderList
folders={fileTreeData.folders}
docs={fileTreeData.docs}
files={fileTreeData.fileRefs}
classes={{
root: classnames('file-tree-list', {
'file-tree-dragging': dragLayer.isDragging,
}),
}}
dropRef={dropRef}
dataTestId="file-tree-list-root"
/>
</>
)
}
export default withErrorBoundary(FileTreeRoot, FileTreeError)

View File

@@ -0,0 +1,126 @@
import { useTranslation } from 'react-i18next'
import * as eventTracking from '../../../infrastructure/event-tracking'
import { useFileTreeActionable } from '../contexts/file-tree-actionable'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import MaterialIcon from '@/shared/components/material-icon'
import OLButtonToolbar from '@/features/ui/components/ol/ol-button-toolbar'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import React, { ElementType } from 'react'
const fileTreeToolbarComponents = importOverleafModules(
'fileTreeToolbarComponents'
) as { import: { default: ElementType }; path: string }[]
function FileTreeToolbar() {
const { fileTreeReadOnly } = useFileTreeData()
const { t } = useTranslation()
if (fileTreeReadOnly) return null
return (
<OLButtonToolbar
className="toolbar toolbar-filetree"
aria-label={t('project_files')}
>
<FileTreeToolbarLeft />
<FileTreeToolbarRight />
</OLButtonToolbar>
)
}
function FileTreeToolbarLeft() {
const { t } = useTranslation()
const {
canCreate,
startCreatingFolder,
startCreatingDocOrFile,
startUploadingDocOrFile,
} = useFileTreeActionable()
const createWithAnalytics = () => {
eventTracking.sendMB('new-file-click', { location: 'toolbar' })
startCreatingDocOrFile()
}
const uploadWithAnalytics = () => {
eventTracking.sendMB('upload-click', { location: 'toolbar' })
startUploadingDocOrFile()
}
if (!canCreate) return null
return (
<div className="toolbar-left">
<OLTooltip
id="new-file"
description={t('new_file')}
overlayProps={{ placement: 'bottom' }}
>
<button className="btn" onClick={createWithAnalytics}>
<MaterialIcon type="description" accessibilityLabel={t('new_file')} />
</button>
</OLTooltip>
<OLTooltip
id="new-folder"
description={t('new_folder')}
overlayProps={{ placement: 'bottom' }}
>
<button className="btn" onClick={startCreatingFolder} tabIndex={-1}>
<MaterialIcon type="folder" accessibilityLabel={t('new_folder')} />
</button>
</OLTooltip>
<OLTooltip
id="upload"
description={t('upload')}
overlayProps={{ placement: 'bottom' }}
>
<button className="btn" onClick={uploadWithAnalytics} tabIndex={-1}>
<MaterialIcon type="upload" accessibilityLabel={t('upload')} />
</button>
</OLTooltip>
</div>
)
}
function FileTreeToolbarRight() {
const { t } = useTranslation()
const { canRename, canDelete, startRenaming, startDeleting } =
useFileTreeActionable()
return (
<div className="toolbar-right">
{fileTreeToolbarComponents.map(
({ import: { default: Component }, path }) => (
<Component key={path} />
)
)}
{canRename ? (
<OLTooltip
id="rename"
description={t('rename')}
overlayProps={{ placement: 'bottom' }}
>
<button className="btn" onClick={startRenaming} tabIndex={-1}>
<MaterialIcon type="edit" accessibilityLabel={t('rename')} />
</button>
</OLTooltip>
) : null}
{canDelete ? (
<OLTooltip
id="delete"
description={t('delete')}
overlayProps={{ placement: 'bottom' }}
>
<button className="btn" onClick={startDeleting} tabIndex={-1}>
<MaterialIcon type="delete" accessibilityLabel={t('delete')} />
</button>
</OLTooltip>
) : null}
</div>
)
}
export default FileTreeToolbar

View File

@@ -0,0 +1,39 @@
import { useTranslation } from 'react-i18next'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
import FileTreeCreateFormProvider from '../../contexts/file-tree-create-form'
import FileTreeModalCreateFileBody from '../file-tree-create/file-tree-modal-create-file-body'
import FileTreeModalCreateFileFooter from '../file-tree-create/file-tree-modal-create-file-footer'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
export default function FileTreeModalCreateFile() {
const { t } = useTranslation()
const { isCreatingFile, cancel } = useFileTreeActionable()
if (!isCreatingFile) {
return null
}
return (
<FileTreeCreateFormProvider>
<OLModal size="lg" onHide={cancel} show>
<OLModalHeader closeButton>
<OLModalTitle>{t('add_files')}</OLModalTitle>
</OLModalHeader>
<OLModalBody className="modal-new-file">
<FileTreeModalCreateFileBody />
</OLModalBody>
<OLModalFooter>
<FileTreeModalCreateFileFooter />
</OLModalFooter>
</OLModal>
</FileTreeCreateFormProvider>
)
}

View File

@@ -0,0 +1,151 @@
import { useEffect, useState } from 'react'
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
import { DuplicateFilenameError } from '../../errors'
import { isCleanFilename } from '../../util/safe-path'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
function FileTreeModalCreateFolder() {
const { t } = useTranslation()
const [name, setName] = useState('')
const [validName, setValidName] = useState(true)
const { isCreatingFolder, inFlight, finishCreatingFolder, cancel, error } =
useFileTreeActionable()
useEffect(() => {
if (!isCreatingFolder) {
// clear the input when the modal is closed
setName('')
}
}, [isCreatingFolder])
if (!isCreatingFolder) return null // the modal will not be rendered; return early
function handleHide() {
cancel()
}
function handleCreateFolder() {
finishCreatingFolder(name)
}
function errorMessage() {
switch (error.constructor) {
case DuplicateFilenameError:
return t('file_already_exists')
default:
return t('generic_something_went_wrong')
}
}
return (
<OLModal show onHide={handleHide}>
<OLModalHeader>
<OLModalTitle>{t('new_folder')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<InputName
name={name}
setName={setName}
validName={validName}
setValidName={setValidName}
handleCreateFolder={handleCreateFolder}
/>
{!validName ? (
<div
role="alert"
aria-label={t('files_cannot_include_invalid_characters')}
className="alert alert-danger file-tree-modal-alert"
>
{t('files_cannot_include_invalid_characters')}
</div>
) : null}
{error ? (
<div
role="alert"
aria-label={errorMessage()}
className="alert alert-danger file-tree-modal-alert"
>
{errorMessage()}
</div>
) : null}
</OLModalBody>
<OLModalFooter>
{inFlight ? (
<OLButton variant="primary" disabled isLoading={inFlight} />
) : (
<>
<OLButton variant="secondary" onClick={handleHide}>
{t('cancel')}
</OLButton>
<OLButton
variant="primary"
onClick={handleCreateFolder}
disabled={!validName}
>
{t('create')}
</OLButton>
</>
)}
</OLModalFooter>
</OLModal>
)
}
function InputName({
name,
setName,
validName,
setValidName,
handleCreateFolder,
}) {
const { autoFocusedRef } = useRefWithAutoFocus()
function handleFocus(ev) {
ev.target.setSelectionRange(0, -1)
}
function handleChange(ev) {
setValidName(isCleanFilename(ev.target.value.trim()))
setName(ev.target.value)
}
function handleKeyDown(ev) {
if (ev.key === 'Enter' && validName) {
handleCreateFolder()
}
}
return (
<input
ref={autoFocusedRef}
className="form-control"
type="text"
value={name}
onKeyDown={handleKeyDown}
onChange={handleChange}
onFocus={handleFocus}
/>
)
}
InputName.propTypes = {
name: PropTypes.string.isRequired,
setName: PropTypes.func.isRequired,
validName: PropTypes.bool.isRequired,
setValidName: PropTypes.func.isRequired,
handleCreateFolder: PropTypes.func.isRequired,
}
export default FileTreeModalCreateFolder

View File

@@ -0,0 +1,74 @@
import { useTranslation } from 'react-i18next'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLNotification from '@/features/ui/components/ol/ol-notification'
function FileTreeModalDelete() {
const { t } = useTranslation()
const {
isDeleting,
inFlight,
finishDeleting,
actionedEntities,
cancel,
error,
} = useFileTreeActionable()
if (!isDeleting) return null // the modal will not be rendered; return early
function handleHide() {
cancel()
}
function handleDelete() {
finishDeleting()
}
return (
<OLModal show onHide={handleHide}>
<OLModalHeader>
<OLModalTitle>{t('delete')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<p>{t('sure_you_want_to_delete')}</p>
<ul>
{actionedEntities.map(entity => (
<li key={entity._id}>{entity.name}</li>
))}
</ul>
{error && (
<OLNotification
type="error"
content={t('generic_something_went_wrong')}
/>
)}
</OLModalBody>
<OLModalFooter>
{inFlight ? (
<OLButton variant="danger" disabled isLoading />
) : (
<>
<OLButton variant="secondary" onClick={handleHide}>
{t('cancel')}
</OLButton>
<OLButton variant="danger" onClick={handleDelete}>
{t('delete')}
</OLButton>
</>
)}
</OLModalFooter>
</OLModal>
)
}
export default FileTreeModalDelete

View File

@@ -0,0 +1,89 @@
import { Trans, useTranslation } from 'react-i18next'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
import {
InvalidFilenameError,
BlockedFilenameError,
DuplicateFilenameError,
DuplicateFilenameMoveError,
} from '../../errors'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
function FileTreeModalError() {
const { t } = useTranslation()
const { isRenaming, isMoving, cancel, error } = useFileTreeActionable()
// the modal will not be rendered; return early
if (!error) return null
if (!isRenaming && !isMoving) return null
function handleHide() {
cancel()
}
function errorTitle() {
switch (error.constructor) {
case DuplicateFilenameError:
case DuplicateFilenameMoveError:
return t('duplicate_file')
case InvalidFilenameError:
case BlockedFilenameError:
return t('invalid_file_name')
default:
return t('error')
}
}
function errorMessage() {
switch (error.constructor) {
case DuplicateFilenameError:
return t('file_already_exists')
case DuplicateFilenameMoveError:
return (
<Trans
i18nKey="file_already_exists_in_this_location"
components={[<strong />]} // eslint-disable-line react/jsx-key
values={{ fileName: error.entityName }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
)
case InvalidFilenameError:
return t('files_cannot_include_invalid_characters')
case BlockedFilenameError:
return t('blocked_filename')
default:
return t('generic_something_went_wrong')
}
}
return (
<OLModal show onHide={handleHide}>
<OLModalHeader>
<OLModalTitle>{errorTitle()}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<div role="alert" aria-label={errorMessage()}>
{errorMessage()}
</div>
</OLModalBody>
<OLModalFooter>
<OLButton onClick={handleHide} variant="primary">
{t('ok')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}
export default FileTreeModalError