Merge pull request #3518 from overleaf/ae-react-create-file-modal

Migrate "Add Files" modal to React

GitOrigin-RevId: fc5235108ee65294e3176da9c327791c34aa5b3c
This commit is contained in:
Alf Eaton 2021-03-18 09:52:36 +00:00 committed by Copybot
parent 0ca6b5921f
commit ba4300d9e1
69 changed files with 3221 additions and 180 deletions

View file

@ -23,8 +23,11 @@ module.exports = {
rule => !rule.test.toString().includes('woff')
),
// Replace the less rule, adding to-string-loader
// Filter out the MiniCSS extraction, which conflicts with the built-in CSS loader
...customConfig.module.rules.filter(
rule => !rule.test.toString().includes('less')
rule =>
!rule.test.toString().includes('less') &&
!rule.test.toString().includes('css')
),
{
test: /\.less$/,

View file

@ -83,4 +83,8 @@ const withTheme = (Story, context) => {
export const decorators = [withTheme]
window.ExposedSettings = {}
window.ExposedSettings = {
appName: 'Overleaf',
maxEntitiesPerProject: 10,
maxUploadSize: 5 * 1024 * 1024
}

View file

@ -746,6 +746,8 @@ const ProjectController = {
req.query && req.query.new_file_tree_ui === 'false'
const wantsNewShareModalUI =
req.query && req.query.new_share_modal_ui === 'true'
const wantsNewAddFilesModalUI =
req.query && req.query.new_add_files_modal_ui === 'true'
AuthorizationManager.getPrivilegeLevelForProject(
userId,
@ -861,7 +863,8 @@ const ProjectController = {
showNewNavigationUI:
req.query && req.query.new_navigation_ui === 'true',
showReactFileTree: !wantsOldFileTreeUI,
showReactShareModal: wantsNewShareModalUI
showReactShareModal: wantsNewShareModalUI,
showReactAddFilesModal: wantsNewAddFilesModalUI
})
timer.done()
}

View file

@ -154,9 +154,7 @@ module.exports = function(webRouter, privateApiRouter, publicApiRouter) {
}
}
res.locals.buildCssPath = function(themeModifier = '') {
const cssFileName = `${themeModifier}style.css`
res.locals.buildStylesheetPath = function(cssFileName) {
let path
if (IS_DEV_ENV) {
// In dev: resolve path within CSS asset directory
@ -173,6 +171,10 @@ module.exports = function(webRouter, privateApiRouter, publicApiRouter) {
return Url.resolve(staticFilesBase, path)
}
res.locals.buildCssPath = function(themeModifier = '') {
return res.locals.buildStylesheetPath(`${themeModifier}style.css`)
}
res.locals.buildImgPath = function(imgFile) {
const path = Path.join('/img/', imgFile)
return Url.resolve(staticFilesBase, path)
@ -349,8 +351,11 @@ module.exports = function(webRouter, privateApiRouter, publicApiRouter) {
hasSamlBeta: req.session.samlBeta,
hasSamlFeature: Features.hasFeature('saml'),
samlInitPath: _.get(Settings, ['saml', 'ukamf', 'initPath']),
hasLinkUrlFeature: Features.hasFeature('link-url'),
siteUrl: Settings.siteUrl,
emailConfirmationDisabled: Settings.emailConfirmationDisabled,
maxEntitiesPerProject: Settings.maxEntitiesPerProject,
maxUploadSize: Settings.maxUploadSize,
recaptchaSiteKeyV3:
Settings.recaptcha != null ? Settings.recaptcha.siteKeyV3 : undefined,
recaptchaDisabled:

View file

@ -19,6 +19,7 @@ html(
//- Stylesheet
link(rel='stylesheet', href=buildCssPath(getCssThemeModifier(userSettings, brandVariation)), id="main-stylesheet")
link(rel='stylesheet', href=buildStylesheetPath("ide.css"))
block _headLinks

View file

@ -189,6 +189,7 @@ block content
window.useShareJsHash = true
window.wsRetryHandshake = #{settings.wsRetryHandshake}
window.showReactFileTree = "!{showReactFileTree}" === 'true'
window.showReactAddFilesModal = "!{showReactAddFilesModal}" === 'true'
- if (settings.overleaf != null)
script(type='text/javascript').

View file

@ -19,6 +19,11 @@ aside.editor-sidebar.full-size(
on-select="onSelect"
on-init="onInit"
is-connected="isConnected"
has-feature="hasFeature"
ref-providers="refProviders"
reindex-references="reindexReferences"
set-ref-provider-enabled="setRefProviderEnabled"
set-started-free-trial="setStartedFreeTrial"
)
div(ng-controller="FileTreeController")

View file

@ -683,5 +683,6 @@ module.exports = settings =
overleafModuleImports: {
# modules to import (an empty array for each set of modules)
createFileModes: []
}

View file

@ -1,4 +1,5 @@
{
"account_settings": "",
"anyone_with_link_can_edit": "",
"anyone_with_link_can_view": "",
"ask_proj_owner_to_upgrade_for_longer_compiles": "",
@ -46,6 +47,7 @@
"dismiss_error_popup": "",
"done": "",
"download_pdf": "",
"drag_here": "",
"duplicate_file": "",
"editing": "",
"error": "",
@ -53,6 +55,7 @@
"fast": "",
"file_already_exists": "",
"file_already_exists_in_this_location": "",
"file_name_in_this_project": "",
"file_outline": "",
"files_cannot_include_invalid_characters": "",
"find_out_more_about_the_file_outline": "",
@ -76,6 +79,8 @@
"learn_more_about_link_sharing": "",
"link_sharing_is_off": "",
"link_sharing_is_on": "",
"link_to_mendeley": "",
"link_to_zotero": "",
"linked_file": "",
"loading": "",
"log_entry_description": "",
@ -86,6 +91,12 @@
"make_private": "",
"math_display": "",
"math_inline": "",
"maximum_files_uploaded_together": "",
"mendeley_is_premium": "",
"mendeley_reference_loading_error": "",
"mendeley_reference_loading_error_expired": "",
"mendeley_reference_loading_error_forbidden": "",
"mendeley_sync_description": "",
"menu": "",
"n_errors": "",
"n_errors_plural": "",
@ -104,6 +115,7 @@
"off": "",
"ok": "",
"on": "",
"or": "",
"other_logs_and_files": "",
"other_output_files": "",
"owner": "",
@ -116,7 +128,9 @@
"please_set_main_file": "",
"plus_upgraded_accounts_receive": "",
"proj_timed_out_reason": "",
"project_approaching_file_limit": "",
"project_flagged_too_many_compiles": "",
"project_has_too_many_files": "",
"project_ownership_transfer_confirmation_1": "",
"project_ownership_transfer_confirmation_2": "",
"project_too_large": "",
@ -126,16 +140,20 @@
"read_only": "",
"recompile": "",
"recompile_from_scratch": "",
"reference_error_relink_hint": "",
"refresh": "",
"refresh_page_after_starting_free_trial": "",
"remote_service_error": "",
"remove_collaborator": "",
"rename": "",
"resend": "",
"review": "",
"revoke_invite": "",
"run_syntax_check_now": "",
"select_from_your_computer": "",
"send_first_message": "",
"server_error": "",
"session_expired_redirecting_to_login": "",
"share": "",
"share_project": "",
"share_with_your_collabs": "",
@ -157,6 +175,7 @@
"to_change_access_permissions": "",
"toggle_compile_options_menu": "",
"toggle_output_files_list": "",
"too_many_files_uploaded_throttled_short_period": "",
"too_many_requests": "",
"too_recently_compiled": "",
"total_words": "",
@ -174,5 +193,10 @@
"we_cant_find_any_sections_or_subsections_in_this_file": "",
"word_count": "",
"your_message": "",
"your_project_has_errors": ""
"your_project_has_errors": "",
"zotero_is_premium": "",
"zotero_reference_loading_error": "",
"zotero_reference_loading_error_expired": "",
"zotero_reference_loading_error_forbidden": "",
"zotero_sync_description": ""
}

View file

@ -23,8 +23,10 @@ function CloneProjectModal({
// reset error when the modal is opened
useEffect(() => {
setError(undefined)
}, [])
if (show) {
setError(undefined)
}
}, [show])
// close the modal if not in flight
const cancel = useCallback(() => {
@ -54,7 +56,7 @@ function CloneProjectModal({
openProject(data.project_id)
})
.catch(({ response, data }) => {
if (response.status === 400) {
if (response?.status === 400) {
setError(data.message)
} else {
setError(true)

View file

@ -1,8 +1,8 @@
import React, { useContext } from 'react'
import React from 'react'
import ReactDOM from 'react-dom'
import { Dropdown } from 'react-bootstrap'
import { FileTreeMainContext } from '../contexts/file-tree-main'
import { useFileTreeMainContext } from '../contexts/file-tree-main'
import FileTreeItemMenuItems from './file-tree-item/file-tree-item-menu-items'
@ -11,7 +11,7 @@ function FileTreeContextMenu() {
hasWritePermissions,
contextMenuCoords,
setContextMenuCoords
} = useContext(FileTreeMainContext)
} = useFileTreeMainContext()
if (!hasWritePermissions || !contextMenuCoords) return null

View file

@ -17,6 +17,11 @@ function FileTreeContext({
rootFolder,
hasWritePermissions,
rootDocId,
hasFeature,
refProviders,
reindexReferences,
setRefProviderEnabled,
setStartedFreeTrial,
onSelect,
children
}) {
@ -24,6 +29,11 @@ function FileTreeContext({
<FileTreeMainProvider
projectId={projectId}
hasWritePermissions={hasWritePermissions}
hasFeature={hasFeature}
refProviders={refProviders}
setRefProviderEnabled={setRefProviderEnabled}
setStartedFreeTrial={setStartedFreeTrial}
reindexReferences={reindexReferences}
>
<FileTreeMutableProvider rootFolder={rootFolder}>
<FileTreeSelectableProvider
@ -44,6 +54,11 @@ FileTreeContext.propTypes = {
projectId: PropTypes.string.isRequired,
rootFolder: PropTypes.array.isRequired,
hasWritePermissions: PropTypes.bool.isRequired,
hasFeature: PropTypes.func.isRequired,
reindexReferences: PropTypes.func.isRequired,
refProviders: PropTypes.object.isRequired,
setRefProviderEnabled: PropTypes.func.isRequired,
setStartedFreeTrial: PropTypes.func.isRequired,
rootDocId: PropTypes.string,
onSelect: PropTypes.func.isRequired,
children: PropTypes.oneOfType([

View file

@ -0,0 +1,10 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Alert } from 'react-bootstrap'
export default function DangerMessage({ children }) {
return <Alert bsStyle="danger">{children}</Alert>
}
DangerMessage.propTypes = {
children: PropTypes.string.isRequired
}

View file

@ -0,0 +1,109 @@
import React from 'react'
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()
// 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 'rate-limit-hit':
return (
<DangerMessage>
{t('too_many_files_uploaded_throttled_short_period')}
</DangerMessage>
)
case 'not-logged-in':
return (
<DangerMessage>
<RedirectToLogin />
</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>
Invalid Request. Please correct the data and try again.
</DangerMessage>
)
case 403:
return (
<DangerMessage>
Session error. Please check you have cookies enabled. If the
problem persists, try clearing your cache and cookies.
</DangerMessage>
)
case 429:
return (
<DangerMessage>
Too many attempts. Please wait for a while and try again.
</DangerMessage>
)
default:
return (
<DangerMessage>
Something went wrong talking to the server :(. Please try again.
</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,111 @@
import ControlLabel from 'react-bootstrap/lib/ControlLabel'
import { Alert, FormControl } from 'react-bootstrap'
import FormGroup from 'react-bootstrap/lib/FormGroup'
import React, { useCallback } from 'react'
import { Trans } from 'react-i18next'
import { useFileTreeCreateName } from '../../contexts/file-tree-create-name'
import PropTypes from 'prop-types'
import {
BlockedFilenameError,
DuplicateFilenameError,
InvalidFilenameError
} from '../../errors'
/**
* 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 = 'File Name',
focusName = false,
classes = {},
placeholder = 'File Name',
error
}) {
// 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 = useCallback(
element => {
if (element && focusName) {
window.requestAnimationFrame(() => {
element.focus()
element.setSelectionRange(0, element.value.lastIndexOf('.'))
})
}
},
[focusName]
)
return (
<FormGroup controlId="new-doc-name" className={classes.formGroup}>
<ControlLabel>{label}</ControlLabel>
<FormControl
type="text"
placeholder={placeholder}
required
value={name}
onChange={event => setName(event.target.value)}
inputRef={inputRef}
/>
<FormControl.Feedback />
{touchedName && !validName && (
<Alert bsStyle="danger" className="row-spaced-small">
<Trans i18nKey="files_cannot_include_invalid_characters" />
</Alert>
)}
{error && <ErrorMessage error={error} />}
</FormGroup>
)
}
FileTreeCreateNameInput.propTypes = {
focusName: PropTypes.bool,
label: PropTypes.string,
classes: PropTypes.shape({
formGroup: PropTypes.string
}),
placeholder: PropTypes.string,
error: PropTypes.oneOfType([PropTypes.object, PropTypes.string])
}
function ErrorMessage({ error }) {
// if (typeof error === 'string') {
// return error
// }
switch (error.constructor) {
case DuplicateFilenameError:
return (
<Alert bsStyle="danger" className="row-spaced-small">
<Trans i18nKey="file_already_exists" />
</Alert>
)
case InvalidFilenameError:
return (
<Alert bsStyle="danger" className="row-spaced-small">
<Trans i18nKey="files_cannot_include_invalid_characters" />
</Alert>
)
case BlockedFilenameError:
return (
<Alert bsStyle="danger" className="row-spaced-small">
<Trans i18nKey="blocked_filename" />
</Alert>
)
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,96 @@
import React from 'react'
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 FileTreeUploadDoc from './modes/file-tree-upload-doc'
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 { useFileTreeMutable } from '../../contexts/file-tree-mutable'
import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
const createFileModeModules = importOverleafModules('createFileModes')
export default function FileTreeModalCreateFileBody() {
const { newFileCreateMode } = useFileTreeActionable()
const { fileCount } = useFileTreeMutable()
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="file"
label="New File"
/>
<FileTreeModalCreateFileMode
mode="upload"
icon="upload"
label="Upload"
/>
<FileTreeModalCreateFileMode
mode="project"
icon="folder-open"
label="From Another Project"
/>
{window.ExposedSettings.hasLinkUrlFeature && (
<FileTreeModalCreateFileMode
mode="url"
icon="globe"
label="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' && <FileTreeUploadDoc />}
{createFileModeModules.map(
({ import: { CreateFilePane }, path }) => (
<CreateFilePane key={path} />
)
)}
</td>
</tr>
</tbody>
</table>
)
}

View file

@ -0,0 +1,82 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Alert, Button } from 'react-bootstrap'
import { useFileTreeCreateForm } from '../../contexts/file-tree-create-form'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
import { useFileTreeMutable } from '../../contexts/file-tree-mutable'
import PropTypes from 'prop-types'
export default function FileTreeModalCreateFileFooter() {
const { valid } = useFileTreeCreateForm()
const { newFileCreateMode, inFlight, cancel } = useFileTreeActionable()
const { fileCount } = useFileTreeMutable()
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' && (
<Alert bsStyle="warning" className="at-file-limit">
{/* TODO: add parameter for fileCount.limit */}
{t('project_has_too_many_files')}
</Alert>
)}
<Button
bsStyle="default"
type="button"
disabled={inFlight}
onClick={cancel}
>
{t('cancel')}
</Button>
{newFileCreateMode !== 'upload' && (
<Button
bsStyle="primary"
type="submit"
form="create-file"
disabled={inFlight || !valid}
>
<span>{inFlight ? `${t('creating')}` : t('create')}</span>
</Button>
)}
</>
)
}
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 React from 'react'
import classnames from 'classnames'
import { Button } from 'react-bootstrap'
import PropTypes from 'prop-types'
import Icon from '../../../../shared/components/icon'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
export default function FileTreeModalCreateFileMode({ mode, icon, label }) {
const { newFileCreateMode, startCreatingFile } = useFileTreeActionable()
const handleClick = () => {
startCreatingFile(mode)
}
return (
<li className={classnames({ active: newFileCreateMode === mode })}>
<Button
bsStyle="link"
block
onClick={handleClick}
className="modal-new-file-mode"
>
<Icon modifier="fw" type={icon} />
&nbsp;
{label}
</Button>
</li>
)
}
FileTreeModalCreateFileMode.propTypes = {
mode: PropTypes.string.isRequired,
icon: PropTypes.string.isRequired,
label: PropTypes.string.isRequired
}

View file

@ -0,0 +1,35 @@
import React, { 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 ErrorMessage from '../error-message'
export default function FileTreeCreateNewDoc() {
const { name, validName } = useFileTreeCreateName()
const { setValid } = useFileTreeCreateForm()
const { error, finishCreatingDoc } = 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 })
},
[finishCreatingDoc, name]
)
return (
<form noValidate id="create-file" onSubmit={handleSubmit}>
<FileTreeCreateNameInput focusName error={error} />
{error && <ErrorMessage error={error} />}
</form>
)
}

View file

@ -0,0 +1,316 @@
import React, { useState, useCallback, useEffect, useMemo } from 'react'
import { Button, ControlLabel, FormControl, FormGroup } from 'react-bootstrap'
import Icon from '../../../../../shared/components/icon'
import FileTreeCreateNameInput from '../file-tree-create-name-input'
import { useTranslation } from 'react-i18next'
import PropTypes from 'prop-types'
import { useUserProjects } from '../../../hooks/use-user-projects'
import { 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 { useFileTreeMainContext } from '../../../contexts/file-tree-main'
import ErrorMessage from '../error-message'
export default function FileTreeImportFromProject() {
const { t } = useTranslation()
const { name, setName, validName } = useFileTreeCreateName()
const { setValid } = useFileTreeCreateForm()
const { projectId } = useFileTreeMainContext()
const { error, finishCreatingLinkedFile } = useFileTreeActionable()
const [selectedProject, setSelectedProject] = useState()
const [selectedProjectEntity, setSelectedProjectEntity] = useState()
const [selectedProjectOutputFile, setSelectedProjectOutputFile] = useState()
const [isOutputFilesMode, setOutputFilesMode] = useState(false)
// 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 = event => {
event.preventDefault()
if (isOutputFilesMode) {
finishCreatingLinkedFile({
name,
provider: 'project_output_file',
data: {
source_project_id: selectedProject._id,
source_output_file_path: selectedProjectOutputFile.path,
build_id: selectedProjectOutputFile.build
}
})
} else {
finishCreatingLinkedFile({
name,
provider: 'project_file',
data: {
source_project_id: selectedProject._id,
source_entity_path: selectedProjectEntity.path
}
})
}
}
return (
<form className="form-controls" id="create-file" onSubmit={handleSubmit}>
<SelectProject
projectId={projectId}
selectedProject={selectedProject}
setSelectedProject={setSelectedProject}
/>
{isOutputFilesMode ? (
<SelectProjectOutputFile
selectedProjectId={selectedProject?._id}
selectedProjectOutputFile={selectedProjectOutputFile}
setSelectedProjectOutputFile={setSelectedProjectOutputFile}
/>
) : (
<SelectProjectEntity
selectedProjectId={selectedProject?._id}
selectedProjectEntity={selectedProjectEntity}
setSelectedProjectEntity={setSelectedProjectEntity}
/>
)}
<div className="toggle-file-type-button">
or&nbsp;
<Button
bsStyle="link"
type="button"
onClick={() => setOutputFilesMode(value => !value)}
>
<span>
{isOutputFilesMode
? 'select from source files'
: 'select from output files'}
</span>
</Button>
</div>
<FileTreeCreateNameInput
label={t('file_name_in_this_project')}
classes={{
formGroup: 'form-controls row-spaced-small'
}}
placeholder="example.tex"
error={error}
/>
{error && <ErrorMessage error={error} />}
</form>
)
}
function SelectProject({ projectId, selectedProject, setSelectedProject }) {
// NOTE: unhandled error
const { data, loading } = useUserProjects()
const filteredData = useMemo(() => {
if (!data) {
return null
}
return data.filter(item => item._id !== projectId)
}, [data, projectId])
return (
<FormGroup className="form-controls" controlId="project-select">
<ControlLabel>Select a Project</ControlLabel>
{loading && (
<span>
&nbsp;
<Icon type="spinner" spin />
</span>
)}
<FormControl
componentClass="select"
disabled={!data}
value={selectedProject ? selectedProject._id : ''}
onChange={event => {
const projectId = event.target.value
const project = data.find(item => item._id === projectId)
setSelectedProject(project)
}}
>
<option disabled value="">
- Please Select a Project
</option>
{filteredData &&
filteredData.map(project => (
<option key={project._id} value={project._id}>
{project.name}
</option>
))}
</FormControl>
{filteredData && !filteredData.length && (
<small>
No other projects found, please create another project first
</small>
)}
</FormGroup>
)
}
SelectProject.propTypes = {
projectId: PropTypes.string.isRequired,
selectedProject: PropTypes.object,
setSelectedProject: PropTypes.func.isRequired
}
function SelectProjectOutputFile({
selectedProjectId,
selectedProjectOutputFile,
setSelectedProjectOutputFile
}) {
// NOTE: unhandled error
const { data, loading } = useProjectOutputFiles(selectedProjectId)
return (
<FormGroup
className="form-controls row-spaced-small"
controlId="project-output-file-select"
>
<ControlLabel>Select an Output File</ControlLabel>
{loading && (
<span>
&nbsp;
<Icon type="spinner" spin />
</span>
)}
<FormControl
componentClass="select"
disabled={!data}
value={selectedProjectOutputFile?.path || ''}
onChange={event => {
const path = event.target.value
const file = data.find(item => item.path === path)
setSelectedProjectOutputFile(file)
}}
>
<option disabled value="">
- Please Select an Output File
</option>
{data &&
data.map(file => (
<option key={file.path} value={file.path}>
{file.path}
</option>
))}
</FormControl>
</FormGroup>
)
}
SelectProjectOutputFile.propTypes = {
selectedProjectId: PropTypes.string,
selectedProjectOutputFile: PropTypes.object,
setSelectedProjectOutputFile: PropTypes.func.isRequired
}
function SelectProjectEntity({
selectedProjectId,
selectedProjectEntity,
setSelectedProjectEntity
}) {
// NOTE: unhandled error
const { data, loading } = useProjectEntities(selectedProjectId)
return (
<FormGroup
className="form-controls row-spaced-small"
controlId="project-entity-select"
>
<ControlLabel>Select a File</ControlLabel>
{loading && (
<span>
&nbsp;
<Icon type="spinner" spin />
</span>
)}
<FormControl
componentClass="select"
disabled={!data}
value={selectedProjectEntity?.path || ''}
onChange={event => {
const path = event.target.value
const entity = data.find(item => item.path === path)
setSelectedProjectEntity(entity)
}}
>
<option disabled value="">
- Please Select a File
</option>
{data &&
data.map(entity => (
<option key={entity.path} value={entity.path}>
{entity.path.slice(1)}
</option>
))}
</FormControl>
</FormGroup>
)
}
SelectProjectEntity.propTypes = {
selectedProjectId: PropTypes.string,
selectedProjectEntity: PropTypes.object,
setSelectedProjectEntity: PropTypes.func.isRequired
}

View file

@ -0,0 +1,74 @@
import { ControlLabel, FormControl, FormGroup } from 'react-bootstrap'
import React, { 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'
export default function FileTreeImportFromUrl() {
const { t } = useTranslation()
const { name, setName, validName } = useFileTreeCreateName()
const { setValid } = useFileTreeCreateForm()
const { finishCreatingLinkedFile, error } = 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(/^https?:\/\/.+\/([^/]+\.(\w+))$/)
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()
finishCreatingLinkedFile({
name,
provider: 'url',
data: { url }
})
}
return (
<form
className="form-controls"
id="create-file"
noValidate
onSubmit={handleSubmit}
>
<FormGroup controlId="import-from-url">
<ControlLabel>URL to fetch the file from</ControlLabel>
<FormControl
type="url"
placeholder="https://example.com/my-file.png"
required
value={url}
onChange={handleChange}
/>
</FormGroup>
<FileTreeCreateNameInput
label={t('file_name_in_this_project')}
placeholder="my_file"
error={error}
/>
{error && <ErrorMessage error={error} />}
</form>
)
}

View file

@ -0,0 +1,219 @@
import { Trans } from 'react-i18next'
import { Alert, Button } from 'react-bootstrap'
import React, { useCallback, useState } from 'react'
import PropTypes from 'prop-types'
import Uppy from '@uppy/core'
import XHRUpload from '@uppy/xhr-upload'
import { Dashboard, useUppy } from '@uppy/react'
import { useFileTreeActionable } from '../../../contexts/file-tree-actionable'
import { useFileTreeMainContext } from '../../../contexts/file-tree-main'
import '@uppy/core/dist/style.css'
import '@uppy/dashboard/dist/style.css'
import { refreshProjectMetadata } from '../../../util/api'
import ErrorMessage from '../error-message'
export default function FileTreeUploadDoc() {
const { parentFolderId, cancel, isDuplicate } = useFileTreeActionable()
const { projectId } = useFileTreeMainContext()
const [error, setError] = useState()
const [conflicts, setConflicts] = useState([])
const [overwrite, setOverwrite] = useState(false)
const maxNumberOfFiles = 40
const maxFileSize = window.ExposedSettings.maxUploadSize
// calculate conflicts
const buildConflicts = files =>
Object.values(files).filter(file =>
isDuplicate(parentFolderId, file.meta.name)
)
// initialise the Uppy object
const uppy = useUppy(() => {
let endpoint = `/project/${projectId}/upload`
if (parentFolderId) {
endpoint += `?folder_id=${parentFolderId}`
}
return (
new Uppy({
// logger: Uppy.debugLogger,
allowMultipleUploads: false,
restrictions: {
maxNumberOfFiles,
maxFileSize: maxFileSize || null
},
onBeforeUpload: files => {
let result = true
setOverwrite(overwrite => {
if (!overwrite) {
setConflicts(() => {
const conflicts = buildConflicts(files)
result = conflicts.length === 0
return conflicts
})
}
return overwrite
})
return result
},
autoProceed: true
})
// use the basic XHR uploader
.use(XHRUpload, {
endpoint,
headers: {
'X-CSRF-TOKEN': window.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) => {
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:
// TODO
break
}
})
)
})
// handle forced overwriting of conflicting files
const handleOverwrite = useCallback(() => {
setOverwrite(true)
window.setTimeout(() => {
uppy.upload()
}, 10)
}, [uppy])
// whether to show a message about conflicting files
const showConflicts = !overwrite && conflicts.length > 0
return (
<>
{error && (
<UploadErrorMessage error={error} maxNumberOfFiles={maxNumberOfFiles} />
)}
{showConflicts ? (
<UploadConflicts
cancel={cancel}
conflicts={conflicts}
handleOverwrite={handleOverwrite}
/>
) : (
<Dashboard
uppy={uppy}
showProgressDetails
// note={`Up to ${maxNumberOfFiles} files, up to ${maxFileSize / (1024 * 1024)}MB`}
height={400}
width="100%"
showLinkToFileUploadResult={false}
proudlyDisplayPoweredByUppy={false}
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}`,
dropPasteFiles: `Drag here or %{browseFiles}`,
// Used as the label for the link that opens the system file selection dialog.
// browseFiles: t('select_from_your_computer')
browseFiles: 'select from your computer'
}
}}
/>
)}
</>
)
}
function UploadErrorMessage({ error, maxNumberOfFiles }) {
switch (error) {
case 'too-many-files':
return (
<Trans
i18nKey="maximum_files_uploaded_together"
values={{ max: maxNumberOfFiles }}
/>
)
default:
return <ErrorMessage error={error} />
}
}
UploadErrorMessage.propTypes = {
error: PropTypes.string.isRequired,
maxNumberOfFiles: PropTypes.number.isRequired
}
function UploadConflicts({ cancel, conflicts, handleOverwrite }) {
return (
<Alert bsStyle="warning" className="small">
<p className="text-center">
The following files already exist in this project:
</p>
<ul className="text-center list-unstyled row-spaced-small">
{conflicts.map((conflict, index) => (
<li key={index}>
<strong>{conflict.meta.name}</strong>
</li>
))}
</ul>
<p className="text-center row-spaced-small">
Do you want to overwrite them?
</p>
<p className="text-center">
<Button bsStyle="primary" onClick={handleOverwrite}>
Overwrite
</Button>
&nbsp;
<Button bsStyle="primary" onClick={cancel}>
Cancel
</Button>
</p>
</Alert>
)
}
UploadConflicts.propTypes = {
cancel: PropTypes.func.isRequired,
conflicts: PropTypes.array.isRequired,
handleOverwrite: PropTypes.func.isRequired
}

View file

@ -0,0 +1,37 @@
import React, { useState, useEffect } from 'react'
import { Trans } from 'react-i18next'
import { useFileTreeMainContext } from '../../contexts/file-tree-main'
// handle "not-logged-in" errors by redirecting to the login page
export default function RedirectToLogin() {
const { projectId } = useFileTreeMainContext()
const [secondsToRedirect, setSecondsToRedirect] = useState(10)
useEffect(() => {
setSecondsToRedirect(10)
const timer = window.setInterval(() => {
setSecondsToRedirect(value => {
if (value === 0) {
window.clearInterval(timer)
window.location.assign(`/login?redir=/project/${projectId}`)
return 0
}
return value - 1
})
}, 1000)
return () => {
window.clearInterval(timer)
}
}, [projectId])
return (
<Trans
i18nKey="session_expired_redirecting_to_login"
values={{ seconds: secondsToRedirect }}
/>
)
}

View file

@ -4,6 +4,7 @@ import classNames from 'classnames'
import FileTreeDoc from './file-tree-doc'
import FileTreeFolder from './file-tree-folder'
import { fileCollator } from '../util/file-collator'
function FileTreeFolderList({
folders,
@ -60,19 +61,8 @@ FileTreeFolderList.propTypes = {
children: PropTypes.node
}
// the collator used to sort files docs and folders in the tree. Use english as
// base language for consistency. Options used:
// numeric: true so 10 comes after 2
// sensitivity: 'variant' so case and accent are not equal
// caseFirst: 'upper' so upper-case letters come first
const collator = new Intl.Collator('en', {
numeric: true,
sensitivity: 'variant',
caseFirst: 'upper'
})
function compareFunction(one, two) {
return collator.compare(one.name, two.name)
return fileCollator.compare(one.name, two.name)
}
export default FileTreeFolderList

View file

@ -1,18 +1,16 @@
import React, { useContext, useEffect, createRef } from 'react'
import React, { useEffect, createRef } from 'react'
import PropTypes from 'prop-types'
import classNames from 'classnames'
import scrollIntoViewIfNeeded from 'scroll-into-view-if-needed'
import { FileTreeMainContext } from '../../contexts/file-tree-main'
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'
function FileTreeItemInner({ id, name, isSelected, icons }) {
const { hasWritePermissions, setContextMenuCoords } = useContext(
FileTreeMainContext
)
const { hasWritePermissions, setContextMenuCoords } = useFileTreeMainContext()
const hasMenu = hasWritePermissions && isSelected

View file

@ -17,12 +17,18 @@ import { useDroppable } from '../contexts/file-tree-draggable'
import { useFileTreeAngularListener } from '../hooks/file-tree-angular-listener'
import { useFileTreeSocketListener } from '../hooks/file-tree-socket-listener'
import FileTreeModalCreateFile from './modals/file-tree-modal-create-file'
function FileTreeRoot({
projectId,
rootFolder,
rootDocId,
hasWritePermissions,
hasFeature,
refProviders,
reindexReferences,
setRefProviderEnabled,
setStartedFreeTrial,
onSelect,
onInit,
isConnected
@ -38,6 +44,11 @@ function FileTreeRoot({
<FileTreeContext
projectId={projectId}
hasWritePermissions={hasWritePermissions}
hasFeature={hasFeature}
refProviders={refProviders}
setRefProviderEnabled={setRefProviderEnabled}
setStartedFreeTrial={setStartedFreeTrial}
reindexReferences={reindexReferences}
rootFolder={rootFolder}
rootDocId={rootDocId}
onSelect={onSelect}
@ -49,6 +60,7 @@ function FileTreeRoot({
<FileTreeRootFolder />
</div>
<FileTreeModalDelete />
{window.showReactAddFilesModal && <FileTreeModalCreateFile />}
<FileTreeModalCreateFolder />
<FileTreeModalError />
</FileTreeContext>
@ -86,7 +98,12 @@ FileTreeRoot.propTypes = {
hasWritePermissions: PropTypes.bool.isRequired,
onSelect: PropTypes.func.isRequired,
onInit: PropTypes.func.isRequired,
isConnected: PropTypes.bool.isRequired
isConnected: PropTypes.bool.isRequired,
setRefProviderEnabled: PropTypes.func.isRequired,
hasFeature: PropTypes.func.isRequired,
setStartedFreeTrial: PropTypes.func.isRequired,
reindexReferences: PropTypes.func.isRequired,
refProviders: PropTypes.object.isRequired
}
export default withErrorBoundary(FileTreeRoot, FileTreeError)

View file

@ -1,14 +1,14 @@
import React, { useContext } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import Icon from '../../../shared/components/icon'
import TooltipButton from '../../../shared/components/tooltip-button'
import { FileTreeMainContext } from '../contexts/file-tree-main'
import { useFileTreeMainContext } from '../contexts/file-tree-main'
import { useFileTreeActionable } from '../contexts/file-tree-actionable'
function FileTreeToolbar() {
const { hasWritePermissions } = useContext(FileTreeMainContext)
const { hasWritePermissions } = useFileTreeMainContext()
if (!hasWritePermissions) return null
@ -32,7 +32,7 @@ function FileTreeToolbarLeft() {
if (!canCreate) return null
return (
<>
<div className="toolbar-left">
<TooltipButton
id="new_file"
description={t('new_file')}
@ -58,7 +58,7 @@ function FileTreeToolbarLeft() {
>
<Icon type="upload" modifier="fw" accessibilityLabel={t('upload')} />
</TooltipButton>
</>
</div>
)
}

View file

@ -0,0 +1,33 @@
import React from 'react'
import { Modal } from 'react-bootstrap'
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 AccessibleModal from '../../../../shared/components/accessible-modal'
export default function FileTreeModalCreateFile() {
const { isCreatingFile, cancel } = useFileTreeActionable()
if (!isCreatingFile) {
return null
}
return (
<FileTreeCreateFormProvider>
<AccessibleModal bsSize="large" onHide={cancel} show>
<Modal.Header closeButton>
<Modal.Title>Add Files</Modal.Title>
</Modal.Header>
<Modal.Body className="modal-new-file">
<FileTreeModalCreateFileBody />
</Modal.Body>
<Modal.Footer>
<FileTreeModalCreateFileFooter />
</Modal.Footer>
</AccessibleModal>
</FileTreeCreateFormProvider>
)
}

View file

@ -46,7 +46,7 @@ function FileTreeModalCreateFolder() {
}
return (
<AccessibleModal show={isCreatingFolder} onHide={handleHide}>
<AccessibleModal show onHide={handleHide}>
<Modal.Header>
<Modal.Title>{t('new_folder')}</Modal.Title>
</Modal.Header>

View file

@ -29,7 +29,7 @@ function FileTreeModalDelete() {
}
return (
<AccessibleModal show={isDeleting} onHide={handleHide}>
<AccessibleModal show onHide={handleHide}>
<Modal.Header>
<Modal.Title>{t('delete')}</Modal.Title>
</Modal.Header>

View file

@ -1,6 +1,7 @@
import React, {
createContext,
useCallback,
useMemo,
useReducer,
useContext
} from 'react'
@ -18,7 +19,7 @@ import { findInTreeOrThrow } from '../util/find-in-tree'
import { isNameUniqueInFolder } from '../util/is-name-unique-in-folder'
import { isBlockedFilename, isCleanFilename } from '../util/safe-path'
import { FileTreeMainContext } from './file-tree-main'
import { useFileTreeMainContext } from './file-tree-main'
import { useFileTreeMutable } from './file-tree-mutable'
import { useFileTreeSelectable } from './file-tree-selectable'
@ -35,7 +36,9 @@ const ACTION_TYPES = {
START_RENAME: 'START_RENAME',
START_DELETE: 'START_DELETE',
DELETING: 'DELETING',
START_CREATE_FILE: 'START_CREATE_FILE',
START_CREATE_FOLDER: 'START_CREATE_FOLDER',
CREATING_FILE: 'CREATING_FILE',
CREATING_FOLDER: 'CREATING_FOLDER',
MOVING: 'MOVING',
CANCEL: 'CANCEL',
@ -46,10 +49,12 @@ const ACTION_TYPES = {
const defaultState = {
isDeleting: false,
isRenaming: false,
isCreatingFile: false,
isCreatingFolder: false,
isMoving: false,
inFlight: false,
actionedEntities: null,
newFileCreateMode: null,
error: null
}
@ -67,8 +72,21 @@ function fileTreeActionableReducer(state, action) {
isDeleting: true,
actionedEntities: action.actionedEntities
}
case ACTION_TYPES.START_CREATE_FILE:
return {
...defaultState,
isCreatingFile: true,
newFileCreateMode: action.newFileCreateMode
}
case ACTION_TYPES.START_CREATE_FOLDER:
return { ...defaultState, isCreatingFolder: true }
case ACTION_TYPES.CREATING_FILE:
return {
...defaultState,
isCreatingFile: true,
newFileCreateMode: state.newFileCreateMode,
inFlight: true
}
case ACTION_TYPES.CREATING_FOLDER:
return { ...defaultState, isCreatingFolder: true, inFlight: true }
case ACTION_TYPES.DELETING:
@ -126,13 +144,15 @@ export function useFileTreeActionable() {
isDeleting,
isRenaming,
isMoving,
isCreatingFile,
isCreatingFolder,
inFlight,
error,
actionedEntities,
newFileCreateMode,
dispatch
} = useContext(FileTreeActionableContext)
const { projectId } = useContext(FileTreeMainContext)
const { projectId } = useFileTreeMainContext()
const { fileTreeData, dispatchRename, dispatchMove } = useFileTreeMutable()
const { selectedEntityIds } = useFileTreeSelectable()
@ -168,6 +188,13 @@ export function useFileTreeActionable() {
[dispatch, dispatchRename, fileTreeData, projectId, selectedEntityIds]
)
const isDuplicate = useCallback(
(parentFolderId, name) => {
return !isNameUniqueInFolder(fileTreeData, parentFolderId, name)
},
[fileTreeData]
)
// init deletion flow (this will open the delete modal).
// A copy of the selected entities is set as `actionedEntities` so it is kept
// unchanged as the entities are deleted and the selection is updated
@ -253,12 +280,12 @@ export function useFileTreeActionable() {
selectedEntityIds
)
// check for duplicates and throw
if (isNameUniqueInFolder(fileTreeData, parentFolderId, entity.name)) {
return syncCreateEntity(projectId, parentFolderId, entity)
} else {
return Promise.reject(new DuplicateFilenameError())
const error = validateCreate(fileTreeData, parentFolderId, entity)
if (error) {
return Promise.reject(error)
}
return syncCreateEntity(projectId, parentFolderId, entity)
},
[fileTreeData, projectId, selectedEntityIds]
)
@ -277,65 +304,90 @@ export function useFileTreeActionable() {
[dispatch, finishCreatingEntity]
)
// bypass React file tree entirely; requesting the Angular new doc or file
// modal instead
const startCreatingFile = useCallback(
newFileCreateMode => {
dispatch({ type: ACTION_TYPES.START_CREATE_FILE, newFileCreateMode })
},
[dispatch]
)
const startCreatingDocOrFile = useCallback(() => {
const parentFolderId = getSelectedParentFolderId(
fileTreeData,
selectedEntityIds
)
window.dispatchEvent(
new CustomEvent('FileTreeReactBridge.openNewDocModal', {
detail: {
mode: 'doc',
parentFolderId
}
})
)
}, [fileTreeData, selectedEntityIds])
if (window.showReactAddFilesModal) {
startCreatingFile('doc')
} else {
const parentFolderId = getSelectedParentFolderId(
fileTreeData,
selectedEntityIds
)
window.dispatchEvent(
new CustomEvent('FileTreeReactBridge.openNewDocModal', {
detail: {
mode: 'doc',
parentFolderId
}
})
)
}
}, [fileTreeData, selectedEntityIds, startCreatingFile])
const startUploadingDocOrFile = useCallback(() => {
const parentFolderId = getSelectedParentFolderId(
fileTreeData,
selectedEntityIds
)
if (window.showReactAddFilesModal) {
startCreatingFile('upload')
} else {
const parentFolderId = getSelectedParentFolderId(
fileTreeData,
selectedEntityIds
)
window.dispatchEvent(
new CustomEvent('FileTreeReactBridge.openNewDocModal', {
detail: {
mode: 'upload',
parentFolderId
}
})
)
}, [fileTreeData, selectedEntityIds])
window.dispatchEvent(
new CustomEvent('FileTreeReactBridge.openNewDocModal', {
detail: {
mode: 'upload',
parentFolderId
}
})
)
}
}, [fileTreeData, selectedEntityIds, startCreatingFile])
const finishCreatingDocOrFile = useCallback(
entity =>
finishCreatingEntity(entity)
entity => {
dispatch({ type: ACTION_TYPES.CREATING_FILE })
return finishCreatingEntity(entity)
.then(() => {
// dispatch FileTreeReactBridge event to update the Angular modal
window.dispatchEvent(
new CustomEvent('FileTreeReactBridge.openNewFileModal', {
detail: {
done: true
}
})
)
if (window.showReactAddFilesModal) {
dispatch({ type: ACTION_TYPES.CLEAR })
} else {
// dispatch FileTreeReactBridge event to update the Angular modal
window.dispatchEvent(
new CustomEvent('FileTreeReactBridge.openNewFileModal', {
detail: {
done: true
}
})
)
}
})
.catch(error => {
// dispatch FileTreeReactBridge event to update the Angular modal with
// an error
window.dispatchEvent(
new CustomEvent('FileTreeReactBridge.openNewFileModal', {
detail: {
error: true,
data: error.message
}
})
)
}),
[finishCreatingEntity]
if (window.showReactAddFilesModal) {
dispatch({ type: ACTION_TYPES.ERROR, error })
} else {
// dispatch FileTreeReactBridge event to update the Angular modal with
// an error
window.dispatchEvent(
new CustomEvent('FileTreeReactBridge.openNewFileModal', {
detail: {
error: true,
data: error.message
}
})
)
}
})
},
[dispatch, finishCreatingEntity]
)
const finishCreatingDoc = useCallback(
@ -358,6 +410,11 @@ export function useFileTreeActionable() {
dispatch({ type: ACTION_TYPES.CANCEL })
}, [dispatch])
const parentFolderId = useMemo(
() => getSelectedParentFolderId(fileTreeData, selectedEntityIds),
[fileTreeData, selectedEntityIds]
)
return {
canDelete: selectedEntityIds.size > 0,
canRename: selectedEntityIds.size === 1,
@ -365,15 +422,20 @@ export function useFileTreeActionable() {
isDeleting,
isMoving,
isRenaming,
isCreatingFile,
isCreatingFolder,
inFlight,
actionedEntities,
error,
parentFolderId,
isDuplicate,
newFileCreateMode,
startRenaming,
finishRenaming,
startDeleting,
finishDeleting,
finishMoving,
startCreatingFile,
startCreatingFolder,
finishCreatingFolder,
startCreatingDocOrFile,
@ -396,6 +458,23 @@ function getSelectedParentFolderId(fileTreeData, selectedEntityIds) {
return found.type === 'folder' ? found.entity._id : found.parentFolderId
}
function validateCreate(fileTreeData, parentFolderId, entity) {
if (!isCleanFilename(entity.name)) {
return new InvalidFilenameError()
}
if (!isNameUniqueInFolder(fileTreeData, parentFolderId, entity.name)) {
return new DuplicateFilenameError()
}
// check that the name of a file is allowed, if creating in the root folder
const isMoveToRoot = parentFolderId === fileTreeData._id
const isFolder = entity.endpoint === 'folder'
if (isMoveToRoot && !isFolder && isBlockedFilename(entity.name)) {
return new BlockedFilenameError()
}
}
function validateRename(fileTreeData, found, newName) {
if (!isCleanFilename(newName)) {
return new InvalidFilenameError()

View file

@ -0,0 +1,34 @@
import React, { createContext, useContext, useState } from 'react'
import PropTypes from 'prop-types'
const FileTreeCreateFormContext = createContext()
export const useFileTreeCreateForm = () => {
const context = useContext(FileTreeCreateFormContext)
if (!context) {
throw new Error(
'useFileTreeCreateForm is only available inside FileTreeCreateFormProvider'
)
}
return context
}
export default function FileTreeCreateFormProvider({ children }) {
// is the form valid
const [valid, setValid] = useState(false)
return (
<FileTreeCreateFormContext.Provider value={{ valid, setValid }}>
{children}
</FileTreeCreateFormContext.Provider>
)
}
FileTreeCreateFormProvider.propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node
]).isRequired
}

View file

@ -0,0 +1,52 @@
import React, { createContext, useContext, useMemo, useReducer } from 'react'
import { isCleanFilename } from '../util/safe-path'
import PropTypes from 'prop-types'
const FileTreeCreateNameContext = createContext()
export const useFileTreeCreateName = () => {
const context = useContext(FileTreeCreateNameContext)
if (!context) {
throw new Error(
'useFileTreeCreateName is only available inside FileTreeCreateNameProvider'
)
}
return context
}
export default function FileTreeCreateNameProvider({
children,
initialName = ''
}) {
const [state, setName] = useReducer(
(state, name) => ({
name, // the file name
touchedName: true // whether the name has been edited
}),
{
name: initialName,
touchedName: false
}
)
// validate the file name
const validName = useMemo(() => isCleanFilename(state.name.trim()), [state])
return (
<FileTreeCreateNameContext.Provider
value={{ ...state, setName, validName }}
>
{children}
</FileTreeCreateNameContext.Provider>
)
}
FileTreeCreateNameProvider.propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node
]).isRequired,
initialName: PropTypes.string
}

View file

@ -1,11 +1,28 @@
import React, { createContext, useState } from 'react'
import React, { createContext, useContext, useState } from 'react'
import PropTypes from 'prop-types'
export const FileTreeMainContext = createContext({})
const FileTreeMainContext = createContext()
export function useFileTreeMainContext() {
const context = useContext(FileTreeMainContext)
if (!context) {
throw new Error(
'useFileTreeMainContext is only available inside FileTreeMainProvider'
)
}
return context
}
export const FileTreeMainProvider = function({
projectId,
hasWritePermissions,
hasFeature,
refProviders,
reindexReferences,
setRefProviderEnabled,
setStartedFreeTrial,
children
}) {
const [contextMenuCoords, setContextMenuCoords] = useState()
@ -15,6 +32,11 @@ export const FileTreeMainProvider = function({
value={{
projectId,
hasWritePermissions,
hasFeature,
refProviders,
reindexReferences,
setRefProviderEnabled,
setStartedFreeTrial,
contextMenuCoords,
setContextMenuCoords
}}
@ -27,6 +49,11 @@ export const FileTreeMainProvider = function({
FileTreeMainProvider.propTypes = {
projectId: PropTypes.string.isRequired,
hasWritePermissions: PropTypes.bool.isRequired,
hasFeature: PropTypes.func.isRequired,
reindexReferences: PropTypes.func.isRequired,
refProviders: PropTypes.object.isRequired,
setRefProviderEnabled: PropTypes.func.isRequired,
setStartedFreeTrial: PropTypes.func.isRequired,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node

View file

@ -24,44 +24,71 @@ const ACTION_TYPES = {
function fileTreeMutableReducer({ fileTreeData }, action) {
switch (action.type) {
case ACTION_TYPES.RENAME:
case ACTION_TYPES.RENAME: {
const newFileTreeData = renameInTree(fileTreeData, action.id, {
newName: action.newName
})
return {
fileTreeData: renameInTree(fileTreeData, action.id, {
newName: action.newName
})
fileTreeData: newFileTreeData,
fileCount: countFiles(newFileTreeData)
}
case ACTION_TYPES.DELETE:
}
case ACTION_TYPES.DELETE: {
const newFileTreeData = deleteInTree(fileTreeData, action.id)
return {
fileTreeData: deleteInTree(fileTreeData, action.id)
fileTreeData: newFileTreeData,
fileCount: countFiles(newFileTreeData)
}
case ACTION_TYPES.MOVE:
}
case ACTION_TYPES.MOVE: {
const newFileTreeData = moveInTree(
fileTreeData,
action.entityId,
action.toFolderId
)
return {
fileTreeData: moveInTree(
fileTreeData,
action.entityId,
action.toFolderId
)
fileTreeData: newFileTreeData,
fileCount: countFiles(newFileTreeData)
}
case ACTION_TYPES.CREATE_ENTITY:
}
case ACTION_TYPES.CREATE_ENTITY: {
const newFileTreeData = createEntityInTree(
fileTreeData,
action.parentFolderId,
action.entity
)
return {
fileTreeData: createEntityInTree(
fileTreeData,
action.parentFolderId,
action.entity
)
fileTreeData: newFileTreeData,
fileCount: countFiles(newFileTreeData)
}
default:
}
default: {
throw new Error(`Unknown mutable file tree action type: ${action.type}`)
}
}
}
export const FileTreeMutableProvider = function({ rootFolder, children }) {
const [{ fileTreeData }, dispatch] = useReducer(fileTreeMutableReducer, {
fileTreeData: rootFolder[0]
})
const [{ fileTreeData, fileCount }, dispatch] = useReducer(
fileTreeMutableReducer,
{
fileTreeData: rootFolder[0],
fileCount: countFiles(rootFolder[0])
}
)
return (
<FileTreeMutableContext.Provider value={{ fileTreeData, dispatch }}>
<FileTreeMutableContext.Provider
value={{ fileTreeData, fileCount, dispatch }}
>
{children}
</FileTreeMutableContext.Provider>
)
@ -76,7 +103,9 @@ FileTreeMutableProvider.propTypes = {
}
export function useFileTreeMutable() {
const { fileTreeData, dispatch } = useContext(FileTreeMutableContext)
const { fileTreeData, fileCount, dispatch } = useContext(
FileTreeMutableContext
)
const dispatchCreateFolder = useCallback(
(parentFolderId, entity) => {
@ -141,6 +170,7 @@ export function useFileTreeMutable() {
return {
fileTreeData,
fileCount,
dispatchRename,
dispatchDelete,
dispatchMove,
@ -149,3 +179,37 @@ export function useFileTreeMutable() {
dispatchCreateFile
}
}
function filesInFolder({ docs, folders, fileRefs }) {
const files = [...docs, ...fileRefs]
for (const folder of folders) {
files.push(...filesInFolder(folder))
}
return files
}
function countFiles(fileTreeData) {
const files = filesInFolder(fileTreeData)
// count all the non-deleted entities
const value = files.filter(item => !item.deleted).length
const limit = window.ExposedSettings.maxEntitiesPerProject
const status = fileCountStatus(value, limit, Math.ceil(limit / 20))
return { value, status, limit }
}
function fileCountStatus(value, limit, range) {
if (value >= limit) {
return 'error'
}
if (value >= limit - range) {
return 'warning'
}
return 'success'
}

View file

@ -12,7 +12,7 @@ import classNames from 'classnames'
import { findInTree } from '../util/find-in-tree'
import { useFileTreeMutable } from './file-tree-mutable'
import { FileTreeMainContext } from './file-tree-main'
import { useFileTreeMainContext } from './file-tree-main'
import usePersistedState from '../../../infrastructure/persisted-state-hook'
const FileTreeSelectableContext = createContext()
@ -77,7 +77,7 @@ export function FileTreeSelectableProvider({
onSelect,
children
}) {
const { projectId } = useContext(FileTreeMainContext)
const { projectId } = useFileTreeMainContext()
const [initialSelectedEntityId] = usePersistedState(
`doc.open_id.${projectId}`,

View file

@ -1,13 +1,14 @@
import App from '../../../base'
import { react2angular } from 'react2angular'
import { cloneDeep } from 'lodash'
import FileTreeRoot from '../components/file-tree-root'
App.controller('ReactFileTreeController', function(
$scope,
$timeout,
ide,
eventTracking
ide
// eventTracking
) {
$scope.projectId = ide.project_id
$scope.rootFolder = null
@ -80,6 +81,39 @@ App.controller('ReactFileTreeController', function(
$scope.$emit('entity:no-selection')
}
}
$scope.hasFeature = feature =>
ide.$scope.project.features[feature] || ide.$scope.user.features[feature]
$scope.$watch('permissions.write', hasWritePermissions => {
$scope.hasWritePermissions = hasWritePermissions
})
$scope.refProviders = ide.$scope.user.refProviders || {}
ide.$scope.$watch(
'user.refProviders',
refProviders => {
$scope.refProviders = cloneDeep(refProviders)
},
true
)
$scope.setRefProviderEnabled = provider => {
ide.$scope.$applyAsync(() => {
ide.$scope.user.refProviders[provider] = true
})
}
$scope.setStartedFreeTrial = started => {
$scope.$applyAsync(() => {
$scope.startedFreeTrial = started
})
}
$scope.reindexReferences = () => {
ide.$scope.$emit('references:should-reindex', {})
}
})
App.component('fileTreeRoot', react2angular(FileTreeRoot))

View file

@ -0,0 +1,28 @@
import { useEffect, useState } from 'react'
import { getJSON } from '../../../infrastructure/fetch-json'
import { fileCollator } from '../util/file-collator'
const alphabetical = (a, b) => fileCollator.compare(a.path, b.path)
export function useProjectEntities(projectId) {
const [loading, setLoading] = useState(false)
const [data, setData] = useState(null)
const [error, setError] = useState(false)
useEffect(() => {
if (projectId) {
setLoading(true)
setError(false)
setData(null)
getJSON(`/project/${projectId}/entities`)
.then(data => {
setData(data.entities.sort(alphabetical))
})
.catch(error => setError(error))
.finally(() => setLoading(false))
}
}, [projectId])
return { loading, data, error }
}

View file

@ -0,0 +1,42 @@
import { useEffect, useState } from 'react'
import { postJSON } from '../../../infrastructure/fetch-json'
import { fileCollator } from '../util/file-collator'
const alphabetical = (a, b) => fileCollator.compare(a.path, b.path)
export function useProjectOutputFiles(projectId) {
const [loading, setLoading] = useState(false)
const [data, setData] = useState(null)
const [error, setError] = useState(false)
useEffect(() => {
if (projectId) {
setLoading(true)
setError(false)
setData(null)
postJSON(`/project/${projectId}/compile`, {
body: {
check: 'silent',
draft: false,
incrementalCompilesEnabled: false
}
})
.then(data => {
if (data.status === 'success') {
const filteredFiles = data.outputFiles.filter(file =>
file.path.match(/.*\.(pdf|png|jpeg|jpg|gif)/)
)
setData(filteredFiles.sort(alphabetical))
} else {
setError(true)
}
})
.catch(error => setError(error))
.finally(() => setLoading(false))
}
}, [projectId])
return { loading, data, error }
}

View file

@ -0,0 +1,22 @@
import { useEffect, useState } from 'react'
import { getJSON } from '../../../infrastructure/fetch-json'
import { fileCollator } from '../util/file-collator'
const alphabetical = (a, b) => fileCollator.compare(a.name, b.name)
export function useUserProjects() {
const [loading, setLoading] = useState(true)
const [data, setData] = useState(null)
const [error, setError] = useState(false)
useEffect(() => {
getJSON('/user/projects')
.then(data => {
setData(data.projects.sort(alphabetical))
})
.catch(error => setError(error))
.finally(() => setLoading(false))
}, [])
return { loading, data, error }
}

View file

@ -0,0 +1,4 @@
import { postJSON } from '../../../infrastructure/fetch-json'
export const refreshProjectMetadata = (projectId, entityId) =>
postJSON(`/project/${projectId}/doc/${entityId}/metadata`)

View file

@ -0,0 +1,11 @@
// The collator used to sort files docs and folders in the tree.
// Uses English as base language for consistency.
// Options used:
// numeric: true so 10 comes after 2
// sensitivity: 'variant' so case and accent are not equal
// caseFirst: 'upper' so upper-case letters come first
export const fileCollator = new Intl.Collator('en', {
numeric: true,
sensitivity: 'variant',
caseFirst: 'upper'
})

View file

@ -5,7 +5,7 @@ export function useRefWithAutoFocus() {
useEffect(() => {
if (autoFocusedRef.current) {
requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
if (autoFocusedRef.current) autoFocusedRef.current.focus()
})
}

View file

@ -1,36 +0,0 @@
import React from 'react'
import importOverleafModules from '../macros/import-overleaf-module.macro'
const imports = importOverleafModules('storybook')
function ImportOverleafModulesMacroDemo() {
if (!imports.length) {
return (
<div style={{ backgroundColor: 'white' }}>
<p>
You do not have any module imports configured. Add the following to
your settings:
</p>
<code>
{`moduleImports: { storybook: [PATH_TO_MODULE_THAT_EXPORTS_COMPONENT] }`}
</code>
<p>Then restart Storybook.</p>
</div>
)
}
return (
<div style={{ backgroundColor: 'white' }}>
{imports.map(({ import: { default: Component }, path }) => {
return <Component key={path} />
})}
</div>
)
}
export const Demo = args => <ImportOverleafModulesMacroDemo {...args} />
export default {
title: 'importOverleafModule Macro'
}

View file

@ -0,0 +1,135 @@
import React, { useEffect } from 'react'
import fetchMock from 'fetch-mock'
import FileTreeContext from '../../../js/features/file-tree/components/file-tree-context'
import FileTreeCreateNameProvider from '../../../js/features/file-tree/contexts/file-tree-create-name'
import FileTreeCreateFormProvider from '../../../js/features/file-tree/contexts/file-tree-create-form'
import { useFileTreeActionable } from '../../../js/features/file-tree/contexts/file-tree-actionable'
import PropTypes from 'prop-types'
const defaultContextProps = {
projectId: 'project-1',
hasWritePermissions: true,
hasFeature: () => true,
refProviders: {},
reindexReferences: () => {
console.log('reindex references')
},
setRefProviderEnabled: provider => {
console.log(`ref provider ${provider} enabled`)
},
setStartedFreeTrial: () => {
console.log('started free trial')
},
rootFolder: [
{
docs: [
{
_id: 'entity-1'
}
],
fileRefs: [],
folders: []
}
],
initialSelectedEntityId: 'entity-1',
onSelect: () => {
console.log('selected')
}
}
export const createFileModalDecorator = (
contextProps = {},
createMode = 'doc'
) => Story => {
fetchMock
.restore()
.get('path:/user/projects', {
projects: [
{
_id: 'project-1',
name: 'Project One'
},
{
_id: 'project-2',
name: 'Project Two'
}
]
})
.get('path:/mendeley/groups', {
groups: [
{
id: 'group-1',
name: 'Group One'
},
{
id: 'group-2',
name: 'Group Two'
}
]
})
.get('express:/project/:projectId/entities', {
entities: [
{
path: '/foo.tex'
},
{
path: '/bar.tex'
}
]
})
.post('express:/project/:projectId/compile', {
status: 'success',
outputFiles: [
{
build: 'foo',
path: 'baz.jpg'
},
{
build: 'foo',
path: 'ball.jpg'
}
]
})
.post('express:/project/:projectId/doc', (path, req) => {
console.log({ path, req })
return 204
})
.post('express:/project/:projectId/upload', (path, req) => {
console.log({ path, req })
return 204
})
.post('express:/project/:projectId/linked_file', (path, req) => {
console.log({ path, req })
return 204
})
return (
<FileTreeContext {...defaultContextProps} {...contextProps}>
<FileTreeCreateNameProvider>
<FileTreeCreateFormProvider>
<OpenCreateFileModal createMode={createMode}>
<Story />
</OpenCreateFileModal>
</FileTreeCreateFormProvider>
</FileTreeCreateNameProvider>
</FileTreeContext>
)
}
function OpenCreateFileModal({ children, createMode }) {
const { startCreatingFile } = useFileTreeActionable()
useEffect(() => {
startCreatingFile(createMode)
}, [createMode]) // eslint-disable-line react-hooks/exhaustive-deps
return <>{children}</>
}
OpenCreateFileModal.propTypes = {
createMode: PropTypes.string,
finishCreating: PropTypes.bool,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node
]).isRequired
}

View file

@ -0,0 +1,63 @@
import React from 'react'
import {
ModalFooterDecorator,
ModalContentDecorator
} from '../modal-decorators'
import { FileTreeModalCreateFileFooterContent } from '../../../js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-footer'
export const Valid = args => <FileTreeModalCreateFileFooterContent {...args} />
export const Invalid = args => (
<FileTreeModalCreateFileFooterContent {...args} />
)
Invalid.args = {
valid: false
}
export const Inflight = args => (
<FileTreeModalCreateFileFooterContent {...args} />
)
Inflight.args = {
inFlight: true
}
export const FileLimitWarning = args => (
<FileTreeModalCreateFileFooterContent {...args} />
)
FileLimitWarning.args = {
fileCount: {
status: 'warning',
value: 1990,
limit: 2000
}
}
export const FileLimitError = args => (
<FileTreeModalCreateFileFooterContent {...args} />
)
FileLimitError.args = {
fileCount: {
status: 'error',
value: 2000,
limit: 2000
}
}
export default {
title: 'Modals / Create File / Footer',
component: FileTreeModalCreateFileFooterContent,
args: {
fileCount: {
status: 'success',
limit: 10,
value: 1
},
valid: true,
inFlight: false,
newFileCreateMode: 'doc'
},
argTypes: {
cancel: { action: 'cancel' }
},
decorators: [ModalFooterDecorator, ModalContentDecorator]
}

View file

@ -0,0 +1,33 @@
import React from 'react'
import { createFileModalDecorator } from './create-file-modal-decorator'
import FileTreeModalCreateFile from '../../../js/features/file-tree/components/modals/file-tree-modal-create-file'
export const MinimalFeatures = args => <FileTreeModalCreateFile {...args} />
MinimalFeatures.decorators = [
createFileModalDecorator({
hasFeature: () => false
})
]
export const WithExtraFeatures = args => <FileTreeModalCreateFile {...args} />
WithExtraFeatures.decorators = [createFileModalDecorator()]
export const FileLimitReached = args => <FileTreeModalCreateFile {...args} />
FileLimitReached.decorators = [
createFileModalDecorator({
rootFolder: [
{
docs: Array.from({ length: 10 }, (_, index) => ({
_id: `entity-${index}`
})),
fileRefs: [],
folders: []
}
]
})
]
export default {
title: 'Modals / Create File',
component: FileTreeModalCreateFile
}

View file

@ -0,0 +1,65 @@
import React from 'react'
import FileTreeCreateNameInput from '../../../js/features/file-tree/components/file-tree-create/file-tree-create-name-input'
import FileTreeCreateNameProvider from '../../../js/features/file-tree/contexts/file-tree-create-name'
import {
BlockedFilenameError,
DuplicateFilenameError
} from '../../../js/features/file-tree/errors'
import { ModalBodyDecorator, ModalContentDecorator } from '../modal-decorators'
export const DefaultLabel = args => (
<FileTreeCreateNameProvider initialName="example.tex">
<FileTreeCreateNameInput {...args} />
</FileTreeCreateNameProvider>
)
export const CustomLabel = args => (
<FileTreeCreateNameProvider initialName="example.tex">
<FileTreeCreateNameInput {...args} />
</FileTreeCreateNameProvider>
)
CustomLabel.args = {
label: 'File Name in this Project'
}
export const FocusName = args => (
<FileTreeCreateNameProvider initialName="example.tex">
<FileTreeCreateNameInput {...args} />
</FileTreeCreateNameProvider>
)
FocusName.args = {
focusName: true
}
export const CustomPlaceholder = args => (
<FileTreeCreateNameProvider>
<FileTreeCreateNameInput {...args} />
</FileTreeCreateNameProvider>
)
CustomPlaceholder.args = {
placeholder: 'Enter a file name…'
}
export const DuplicateError = args => (
<FileTreeCreateNameProvider initialName="main.tex">
<FileTreeCreateNameInput {...args} />
</FileTreeCreateNameProvider>
)
DuplicateError.args = {
error: new DuplicateFilenameError()
}
export const BlockedError = args => (
<FileTreeCreateNameProvider initialName="main.tex">
<FileTreeCreateNameInput {...args} />
</FileTreeCreateNameProvider>
)
BlockedError.args = {
error: new BlockedFilenameError()
}
export default {
title: 'Modals / Create File / File Name Input',
component: FileTreeCreateNameInput,
decorators: [ModalBodyDecorator, ModalContentDecorator]
}

View file

@ -0,0 +1,109 @@
import React from 'react'
import ErrorMessage from '../../../js/features/file-tree/components/file-tree-create/error-message'
import { createFileModalDecorator } from './create-file-modal-decorator'
import { FetchError } from '../../../js/infrastructure/fetch-json'
import {
BlockedFilenameError,
DuplicateFilenameError,
InvalidFilenameError
} from '../../../js/features/file-tree/errors'
export const KeyedErrors = () => {
return (
<>
<ErrorMessage error="name-exists" />
<ErrorMessage error="too-many-files" />
<ErrorMessage error="remote-service-error" />
<ErrorMessage error="rate-limit-hit" />
{/* <ErrorMessage error="not-logged-in" /> */}
<ErrorMessage error="something-else" />
</>
)
}
KeyedErrors.decorators = [createFileModalDecorator()]
export const FetchStatusErrors = () => {
return (
<>
<ErrorMessage
error={
new FetchError(
'There was an error',
'/story',
{},
new Response(null, { status: 400 })
)
}
/>
<ErrorMessage
error={
new FetchError(
'There was an error',
'/story',
{},
new Response(null, { status: 403 })
)
}
/>
<ErrorMessage
error={
new FetchError(
'There was an error',
'/story',
{},
new Response(null, { status: 429 })
)
}
/>
<ErrorMessage
error={
new FetchError(
'There was an error',
'/story',
{},
new Response(null, { status: 500 })
)
}
/>
</>
)
}
export const FetchDataErrors = () => {
return (
<>
<ErrorMessage
error={
new FetchError('Error', '/story', {}, new Response(), {
message: 'There was an error!'
})
}
/>
<ErrorMessage
error={
new FetchError('Error', '/story', {}, new Response(), {
message: {
text: 'There was an error with some text!'
}
})
}
/>
</>
)
}
export const SpecificClassErrors = () => {
return (
<>
<ErrorMessage error={new DuplicateFilenameError()} />
<ErrorMessage error={new InvalidFilenameError()} />
<ErrorMessage error={new BlockedFilenameError()} />
<ErrorMessage error={new Error()} />
</>
)
}
export default {
title: 'Modals / Create File / Error Message',
component: ErrorMessage
}

View file

@ -0,0 +1,31 @@
import React from 'react'
/**
* Wrap modal content in modal classes, without modal behaviours
*/
export function ModalContentDecorator(Story) {
return (
<div className="modal-dialog">
<div className="modal-content">
<Story />
</div>
</div>
)
}
export function ModalBodyDecorator(Story) {
return (
<div className="modal-body">
<Story />
</div>
)
}
export function ModalFooterDecorator(Story) {
return (
<div className="modal-footer">
<Story />
</div>
)
}

View file

@ -369,27 +369,63 @@
vertical-align: top;
}
}
/* old modal */
.toggle-output-files-button {
font-size: 80%;
}
/* new modal */
.toggle-file-type-button {
font-size: 80%;
margin-top: -12px;
.btn {
display: inline-block;
padding: 0;
vertical-align: baseline;
font-size: inherit;
}
.btn:focus-within {
outline: none;
text-decoration: none;
}
}
}
.modal-new-file--list {
background-color: @modal-footer-background-color;
width: 220px;
ul {
li {
/* old modal (a) */
a {
color: @text-color;
padding: (@line-height-computed / 4);
display: block;
text-decoration: none;
}
/* new modal (button) */
.btn {
color: @text-color;
padding: (@line-height-computed / 4);
}
.btn:hover {
text-decoration: none;
}
.btn:focus {
outline: none;
text-decoration: none;
background-color: white;
}
}
li.active {
background-color: white;
/* old modal (a) */
a {
color: @link-color;
}
/* new modal (button) */
.btn {
color: @link-color;
text-decoration: none;
}
}
li:hover {
background-color: white;
@ -408,6 +444,10 @@
margin-bottom: 0px;
}
.btn.modal-new-file-mode {
text-align: left;
}
.modal-new-file--body {
padding: 20px;
padding-top: (@line-height-computed / 4);
@ -426,3 +466,13 @@
text-align: left;
}
}
.modal-new-file--body-upload .uppy-Root {
font-family: inherit;
}
.modal-new-file--body-upload .uppy-size--md {
.uppy-Dashboard-AddFiles-title {
font-size: inherit;
}
}

View file

@ -1,5 +1,5 @@
#!/bin/bash
npm run webpack:production & WEBPACK=$!
SHARELATEX_CONFIG=/app/config/settings.webpack.coffee npm run webpack:production & WEBPACK=$!
wait $WEBPACK && echo "Webpack complete" || exit 1

View file

@ -60,6 +60,7 @@
"requires": {
"anymatch": "~3.1.1",
"braces": "~3.0.2",
"fsevents": "~2.3.1",
"glob-parent": "~5.1.0",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
@ -5370,6 +5371,11 @@
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
"integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw=="
},
"@transloadit/prettier-bytes": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.0.7.tgz",
"integrity": "sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA=="
},
"@types/angular": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@types/angular/-/angular-1.7.2.tgz",
@ -5785,6 +5791,158 @@
"integrity": "sha512-FA/BWv8t8ZWJ+gEOnLLd8ygxH/2UFbAvgEonyfN6yWGLKc7zVjbpl2Y4CTjid9h2RfgPP6SEt6uHwEOply00yw==",
"dev": true
},
"@uppy/companion-client": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/@uppy/companion-client/-/companion-client-1.8.2.tgz",
"integrity": "sha512-FBjAJU3xaWRqYXDBlrMDQKlRBqi4Ng54Dmnoe3hGSggfgBQAl9RzRAg0WvrGjaK3QjCTZPhzSdTpBrqDP22Rng==",
"requires": {
"@uppy/utils": "^3.4.1",
"namespace-emitter": "^2.0.1",
"qs-stringify": "^1.1.0"
}
},
"@uppy/core": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/@uppy/core/-/core-1.16.1.tgz",
"integrity": "sha512-GI8baCa1S21j7KoCq0LK4MLD6sII8OvWVgp+auXNcZIbKa182dOMQRtk7bXVY0OzS1TxkIq0DAzDEUnP7MbnDg==",
"requires": {
"@transloadit/prettier-bytes": "0.0.7",
"@uppy/store-default": "^1.2.5",
"@uppy/utils": "^3.4.1",
"cuid": "^2.1.1",
"lodash.throttle": "^4.1.1",
"mime-match": "^1.0.2",
"namespace-emitter": "^2.0.1",
"preact": "8.2.9"
}
},
"@uppy/dashboard": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/@uppy/dashboard/-/dashboard-1.17.0.tgz",
"integrity": "sha512-Xvz6bPPmubexIrPX+j14hOT3ndGeed4VABle/Myjtyb+qTiWzdtp5j4XpPByB4QWl1/nVv9+wtrpnSoR9I9RrQ==",
"requires": {
"@transloadit/prettier-bytes": "0.0.7",
"@uppy/informer": "^1.6.1",
"@uppy/provider-views": "^1.11.1",
"@uppy/status-bar": "^1.9.1",
"@uppy/thumbnail-generator": "^1.7.6",
"@uppy/utils": "^3.4.1",
"classnames": "^2.2.6",
"cuid": "^2.1.1",
"is-shallow-equal": "^1.0.1",
"lodash.debounce": "^4.0.8",
"lodash.throttle": "^4.1.1",
"memoize-one": "^5.0.4",
"preact": "8.2.9",
"resize-observer-polyfill": "^1.5.0"
}
},
"@uppy/drag-drop": {
"version": "1.4.25",
"resolved": "https://registry.npmjs.org/@uppy/drag-drop/-/drag-drop-1.4.25.tgz",
"integrity": "sha512-tQKt5KbYLcWVdv4QhWQYLFfipG7ddNKu6qWAEbFqv6cQSyoaJZY0GumiPcxLJlPu/8lDNo803bd0lrK1q9uk4g==",
"requires": {
"@uppy/utils": "^3.4.1",
"preact": "8.2.9"
}
},
"@uppy/file-input": {
"version": "1.4.23",
"resolved": "https://registry.npmjs.org/@uppy/file-input/-/file-input-1.4.23.tgz",
"integrity": "sha512-YZyLdkuQWvnYe3BRfB1ZEONMN+7dFJCHBvVz1UHoXunLkWEGegdoSXOjzDKcVxfrzDTnk5QYopjcOLOzuktPRQ==",
"requires": {
"@uppy/utils": "^3.4.1",
"preact": "8.2.9"
}
},
"@uppy/informer": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@uppy/informer/-/informer-1.6.1.tgz",
"integrity": "sha512-Tl9r9jyYphexiY8KHSxKI8KReS0XPRXeuuzHIriAO7QTmRuOJhq4r8WZ6ZMy1xNA5q4oJrDp2wKmHGBCjBnxpg==",
"requires": {
"@uppy/utils": "^3.4.1",
"preact": "8.2.9"
}
},
"@uppy/progress-bar": {
"version": "1.3.25",
"resolved": "https://registry.npmjs.org/@uppy/progress-bar/-/progress-bar-1.3.25.tgz",
"integrity": "sha512-oKIwtrYP0b5qfCqfXNXc4guYRV2YFhjQQthsitgTHWB2ybItc3YCjVWc5NZejcUEK2gkG9xWL7Jb9rDNN4Ho5Q==",
"requires": {
"@uppy/utils": "^3.4.1",
"preact": "8.2.9"
}
},
"@uppy/provider-views": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@uppy/provider-views/-/provider-views-1.11.1.tgz",
"integrity": "sha512-HPRHkyQfMS1Ny05zU2eaCCTGcT/RwJZ+36Q0GFHbDyVACyaf4joJnkGen9woYqqLFP46RGjfvPfpMMZVDgHEJw==",
"requires": {
"@uppy/utils": "^3.4.1",
"classnames": "^2.2.6",
"preact": "8.2.9"
}
},
"@uppy/react": {
"version": "1.11.4",
"resolved": "https://registry.npmjs.org/@uppy/react/-/react-1.11.4.tgz",
"integrity": "sha512-0COBrFqjJDQIhNZ45moLPc8HO/WEGAT8/X3NVSEDSP6rfmc2rxB9tYdDOUtW/6zSIbSRLBqz+oHT0p1uD0rgtw==",
"requires": {
"@uppy/dashboard": "^1.17.0",
"@uppy/drag-drop": "^1.4.25",
"@uppy/file-input": "^1.4.23",
"@uppy/progress-bar": "^1.3.25",
"@uppy/status-bar": "^1.9.1",
"@uppy/utils": "^3.4.1",
"prop-types": "^15.6.1"
}
},
"@uppy/status-bar": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@uppy/status-bar/-/status-bar-1.9.1.tgz",
"integrity": "sha512-BBcX7+foAobFGhSYTNK8vb3B4iiQrGlSnpI3sWCj8PawTzo1uXD79m9GXyCtclKg/dcyW8TOWE+03wwrZX9J+A==",
"requires": {
"@transloadit/prettier-bytes": "0.0.7",
"@uppy/utils": "^3.4.1",
"classnames": "^2.2.6",
"lodash.throttle": "^4.1.1",
"preact": "8.2.9"
}
},
"@uppy/store-default": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@uppy/store-default/-/store-default-1.2.5.tgz",
"integrity": "sha512-jnf0U8cfb8Bhgt6yh86YRJO9EEnCyG9BgXZ8dPWWLybgC9Expw3Ah/s3T21tcdChgv4zzdhSACd0JKxCQowyYg=="
},
"@uppy/thumbnail-generator": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@uppy/thumbnail-generator/-/thumbnail-generator-1.7.6.tgz",
"integrity": "sha512-l0f95/1ZF7EbMBbD71yKKa6oVHt3vZVCcqHpuT16pZF5dSupPkcbwSbGwNUF0em9p3eR1Na7fxUGM1EJtHCcDQ==",
"requires": {
"@uppy/utils": "^3.4.1",
"exifr": "^6.0.0",
"math-log2": "^1.0.1"
}
},
"@uppy/utils": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/@uppy/utils/-/utils-3.4.1.tgz",
"integrity": "sha512-9YVKjVRK/AmX3yV0GJsD5p/eHUzqn4+eXzsjv3wHahipKCo0o7Jwpw/7kl+bv12EHOOy5jPsATWkjn40aSWW8w==",
"requires": {
"abortcontroller-polyfill": "^1.4.0",
"lodash.throttle": "^4.1.1"
}
},
"@uppy/xhr-upload": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@uppy/xhr-upload/-/xhr-upload-1.7.0.tgz",
"integrity": "sha512-3A4k1WxbszdErmEwSIFrmPHEU895YHs9Jpk9ZAmzaNc3DWVvqlXsjI/Tffs4QH8RgrRQ7hBWSiRtKp8HcGqP2w==",
"requires": {
"@uppy/companion-client": "^1.8.2",
"@uppy/utils": "^3.4.1",
"cuid": "^2.1.1"
}
},
"@webassemblyjs/ast": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
@ -5991,6 +6149,11 @@
"event-target-shim": "^5.0.0"
}
},
"abortcontroller-polyfill": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.1.tgz",
"integrity": "sha512-yml9NiDEH4M4p0G4AcPkg8AAa4mF3nfYF28VQxaokpO67j9H7gWgmsVWJ/f1Rn+PzsnDYvzJzWIQzCqDKRvWlA=="
},
"accepts": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz",
@ -9789,6 +9952,7 @@
"anymatch": "^2.0.0",
"async-each": "^1.0.1",
"braces": "^2.3.2",
"fsevents": "^1.2.7",
"glob-parent": "^3.1.0",
"inherits": "^2.0.3",
"is-binary-path": "^1.0.0",
@ -9799,6 +9963,17 @@
"upath": "^1.1.1"
},
"dependencies": {
"fsevents": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
"dev": true,
"optional": true,
"requires": {
"bindings": "^1.5.0",
"nan": "^2.12.1"
}
},
"normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@ -11460,6 +11635,11 @@
}
}
},
"cuid": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/cuid/-/cuid-2.1.8.tgz",
"integrity": "sha512-xiEMER6E7TlTPnDxrM4eRiC6TRgjNX9xzEZ5U/Se2YJKr7Mq4pJn/2XEHjl3STcSh96GmkHPcBXLES8M29wyyg=="
},
"custom-event": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz",
@ -13927,6 +14107,11 @@
"strip-eof": "^1.0.0"
}
},
"exifr": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/exifr/-/exifr-6.0.0.tgz",
"integrity": "sha512-a8n3SVIyuI5NP5VJCb/rJHsqXnofgYL1ZXcJdKBXOmCNIrj+pSExaBFHcbdEF5xp5GQrK4kpOabLJ+wBfUGYuA=="
},
"expand-brackets": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz",
@ -15101,6 +15286,12 @@
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="
},
"fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"optional": true
},
"function-bind": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
@ -17859,6 +18050,11 @@
"integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==",
"dev": true
},
"is-shallow-equal": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-shallow-equal/-/is-shallow-equal-1.0.1.tgz",
"integrity": "sha512-lq5RvK+85Hs5J3p4oA4256M1FEffzmI533ikeDHvJd42nouRRx5wBzt36JuviiGe5dIPyHON/d0/Up+PBo6XkQ=="
},
"is-stream": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
@ -18130,6 +18326,7 @@
"@types/node": "*",
"anymatch": "^3.0.3",
"fb-watchman": "^2.0.0",
"fsevents": "^2.1.2",
"graceful-fs": "^4.2.4",
"jest-regex-util": "^26.0.0",
"jest-serializer": "^26.6.2",
@ -18783,6 +18980,7 @@
"requires": {
"anymatch": "~3.1.1",
"braces": "~3.0.2",
"fsevents": "~2.1.2",
"glob-parent": "~5.1.0",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
@ -18839,6 +19037,13 @@
"path-exists": "^4.0.0"
}
},
"fsevents": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.3.tgz",
"integrity": "sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==",
"dev": true,
"optional": true
},
"glob-parent": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz",
@ -19555,6 +19760,11 @@
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="
},
"lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168="
},
"lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@ -19643,6 +19853,11 @@
"integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==",
"dev": true
},
"lodash.throttle": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
"integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ="
},
"lodash.union": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz",
@ -19947,6 +20162,11 @@
"integrity": "sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==",
"dev": true
},
"math-log2": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/math-log2/-/math-log2-1.0.1.tgz",
"integrity": "sha1-+4lBvl9evol55xjmJzsXjlhpRWU="
},
"mathjax": {
"version": "2.7.9",
"resolved": "https://registry.npmjs.org/mathjax/-/mathjax-2.7.9.tgz",
@ -20050,6 +20270,11 @@
"p-is-promise": "^2.0.0"
}
},
"memoize-one": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.1.1.tgz",
"integrity": "sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA=="
},
"memoizerific": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/memoizerific/-/memoizerific-1.11.3.tgz",
@ -20216,6 +20441,14 @@
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.30.0.tgz",
"integrity": "sha512-SUaL89ROHF5P6cwrhLxE1Xmk60cFcctcJl3zwMeQWcoQzt0Al/X8qxUz2gi19NECqYspzbYpAJryIRnLcjp20g=="
},
"mime-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/mime-match/-/mime-match-1.0.2.tgz",
"integrity": "sha1-P4fDHprxpf1IX7nbE0Qosju7e6g=",
"requires": {
"wildcard": "^1.1.0"
}
},
"mime-types": {
"version": "2.1.17",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.17.tgz",
@ -20455,7 +20688,7 @@
},
"mkdirp": {
"version": "0.5.1",
"resolved": "",
"resolved": false,
"integrity": "sha512-SknJC52obPfGQPnjIkXbmA6+5H15E+fR+E4iR2oQ3zzCLbd7/ONua69R/Gw7AgkTLsRG+r5fzksYwWe1AgTyWA==",
"requires": {
"minimist": "0.0.8"
@ -20896,6 +21129,11 @@
}
}
},
"namespace-emitter": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/namespace-emitter/-/namespace-emitter-2.0.1.tgz",
"integrity": "sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g=="
},
"nan": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz",
@ -21421,6 +21659,7 @@
"requires": {
"anymatch": "~3.1.1",
"braces": "~3.0.2",
"fsevents": "~2.1.2",
"glob-parent": "~5.1.0",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
@ -21446,6 +21685,13 @@
"to-regex-range": "^5.0.1"
}
},
"fsevents": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.3.tgz",
"integrity": "sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==",
"dev": true,
"optional": true
},
"glob-parent": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz",
@ -23898,6 +24144,11 @@
}
}
},
"preact": {
"version": "8.2.9",
"resolved": "https://registry.npmjs.org/preact/-/preact-8.2.9.tgz",
"integrity": "sha512-ThuGXBmJS3VsT+jIP+eQufD3L8pRw/PY3FoCys6O9Pu6aF12Pn9zAJDX99TfwRAFOCEKm/P0lwiPTbqKMJp0fA=="
},
"prelude-ls": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
@ -24603,6 +24854,11 @@
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.1.tgz",
"integrity": "sha512-eRzhrN1WSINYCDCbrz796z37LOe3m5tmW7RQf6oBntukAG1nmovJvhnwHHRMAfeoItc1m2Hk02WER2aQ/iqs+A=="
},
"qs-stringify": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/qs-stringify/-/qs-stringify-1.2.1.tgz",
"integrity": "sha512-2N5xGLGZUxpgAYq1fD1LmBSCbxQVsXYt5JU0nU3FuPWO8PlCnKNFQwXkZgyB6mrTdg7IbexX4wxIR403dJw9pw=="
},
"query-string": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-4.3.4.tgz",
@ -24958,6 +25214,7 @@
"requires": {
"anymatch": "~3.1.1",
"braces": "~3.0.2",
"fsevents": "~2.3.1",
"glob-parent": "~5.1.0",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
@ -26739,6 +26996,11 @@
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
},
"resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
},
"resolve": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.5.0.tgz",
@ -31532,6 +31794,7 @@
"requires": {
"anymatch": "~3.1.1",
"braces": "~3.0.2",
"fsevents": "~2.3.1",
"glob-parent": "~5.1.0",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
@ -32571,6 +32834,11 @@
}
}
},
"wildcard": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-1.1.2.tgz",
"integrity": "sha1-pwIEUwhNjNLv5wup02liY94XEKU="
},
"window-size": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz",

View file

@ -55,6 +55,9 @@
"@pollyjs/core": "^4.2.1",
"@pollyjs/persister-fs": "^4.2.1",
"@sentry/browser": "^5.27.6",
"@uppy/core": "^1.15.0",
"@uppy/react": "^1.11.0",
"@uppy/xhr-upload": "^1.6.8",
"ace-builds": "https://github.com/overleaf/ace-builds/archive/v1.4.12-69aace50e6796d42116f8f96e19d2468d8a88af9.tar.gz",
"algoliasearch": "^3.35.1",
"angular": "~1.8.0",
@ -225,6 +228,7 @@
"node-fetch": "^2.6.1",
"nodemon": "^2.0.6",
"optimize-css-assets-webpack-plugin": "^5.0.3",
"pirates": "^4.0.1",
"postcss-loader": "^3.0.0",
"prettier": "^1.19.1",
"requirejs": "^2.3.6",

View file

@ -2,7 +2,14 @@
require('@babel/register')
// Load JSDOM to mock the DOM in Node
require('jsdom-global/register')
// Set pretendToBeVisual to enable requestAnimationFrame
require('jsdom-global')(undefined, { pretendToBeVisual: true })
const path = require('path')
process.env.SHARELATEX_CONFIG = path.resolve(
__dirname,
'../../config/settings.webpack.coffee'
)
// Load sinon-chai assertions so expect(stubFn).to.have.been.calledWith('abc')
// has a nicer failure messages
@ -45,4 +52,12 @@ const fetch = require('node-fetch')
global.fetch = (url, ...options) => fetch('http://localhost' + url, ...options)
// Mock global settings
window.ExposedSettings = {}
window.ExposedSettings = {
appName: 'Overleaf',
maxEntitiesPerProject: 10,
maxUploadSize: 5 * 1024 * 1024
}
// ignore CSS files
const { addHook } = require('pirates')
addHook(() => '', { exts: ['.css'], ignoreNodeModules: false })

View file

@ -0,0 +1,26 @@
import sinon from 'sinon'
export const contextProps = {
projectId: 'test-project',
hasWritePermissions: true,
hasFeature: () => true,
refProviders: {},
reindexReferences: () => {
console.log('reindex references')
},
setRefProviderEnabled: provider => {
console.log(`ref provider ${provider} enabled`)
},
setStartedFreeTrial: () => {
console.log('started free trial')
},
rootFolder: [
{
docs: [{ _id: 'entity-1' }],
fileRefs: [],
folders: []
}
],
initialSelectedEntityId: 'entity-1',
onSelect: sinon.stub()
}

View file

@ -0,0 +1,93 @@
import { expect } from 'chai'
import React from 'react'
import { screen, render, waitFor, cleanup } from '@testing-library/react'
import sinon from 'sinon'
import { contextProps } from './context-props'
import FileTreeCreateNameInput from '../../../../../../frontend/js/features/file-tree/components/file-tree-create/file-tree-create-name-input'
import FileTreeContext from '../../../../../../frontend/js/features/file-tree/components/file-tree-context'
import FileTreeCreateNameProvider from '../../../../../../frontend/js/features/file-tree/contexts/file-tree-create-name'
describe('<FileTreeCreateNameInput/>', function() {
const sandbox = sinon.createSandbox()
beforeEach(function() {
sandbox.spy(window, 'requestAnimationFrame')
})
afterEach(function() {
sandbox.restore()
cleanup()
})
it('renders an empty input', async function() {
render(
<FileTreeContext {...contextProps}>
<FileTreeCreateNameProvider>
<FileTreeCreateNameInput />
</FileTreeCreateNameProvider>
</FileTreeContext>
)
await screen.getByLabelText('File Name')
await screen.getByPlaceholderText('File Name')
})
it('renders a custom label and placeholder', async function() {
render(
<FileTreeContext {...contextProps}>
<FileTreeCreateNameProvider>
<FileTreeCreateNameInput
label="File name in this project"
placeholder="Enter a file name…"
/>
</FileTreeCreateNameProvider>
</FileTreeContext>
)
await screen.getByLabelText('File name in this project')
await screen.getByPlaceholderText('Enter a file name…')
})
it('uses an initial name', async function() {
render(
<FileTreeContext {...contextProps}>
<FileTreeCreateNameProvider initialName="test.tex">
<FileTreeCreateNameInput />
</FileTreeCreateNameProvider>
</FileTreeContext>
)
const input = await screen.getByLabelText('File Name')
expect(input.value).to.equal('test.tex')
})
it('focuses the name', async function() {
render(
<FileTreeContext {...contextProps}>
<FileTreeCreateNameProvider initialName="test.tex">
<FileTreeCreateNameInput focusName />
</FileTreeCreateNameProvider>
</FileTreeContext>
)
const input = await screen.getByLabelText('File Name')
expect(input.value).to.equal('test.tex')
await waitFor(
() => expect(window.requestAnimationFrame).to.have.been.calledOnce
)
// https://github.com/jsdom/jsdom/issues/2995
// "window.getSelection doesn't work with selection of <input> element"
// const selection = window.getSelection().toString()
// expect(selection).to.equal('test')
// wait for the selection to update
await new Promise(resolve => window.setTimeout(resolve, 100))
expect(input.selectionStart).to.equal(0)
expect(input.selectionEnd).to.equal(4)
})
})

View file

@ -0,0 +1,377 @@
import { expect } from 'chai'
import React, { useEffect } from 'react'
import {
screen,
render,
fireEvent,
cleanup,
waitFor
} from '@testing-library/react'
import fetchMock from 'fetch-mock'
import PropTypes from 'prop-types'
import { contextProps } from './context-props'
import FileTreeModalCreateFile from '../../../../../../frontend/js/features/file-tree/components/modals/file-tree-modal-create-file'
import FileTreeContext from '../../../../../../frontend/js/features/file-tree/components/file-tree-context'
import { useFileTreeActionable } from '../../../../../../frontend/js/features/file-tree/contexts/file-tree-actionable'
import { useFileTreeMutable } from '../../../../../../frontend/js/features/file-tree/contexts/file-tree-mutable'
describe('<FileTreeModalCreateFile/>', function() {
afterEach(function() {
fetchMock.restore()
cleanup()
})
it('handles invalid file names', async function() {
render(
<FileTreeContext {...contextProps}>
<OpenWithMode mode="doc" />
</FileTreeContext>
)
const submitButton = screen.getByRole('button', { name: 'Create' })
const input = screen.getByLabelText('File Name')
expect(input.value).to.equal('name.tex')
expect(submitButton.disabled).to.be.false
expect(screen.queryAllByRole('alert')).to.be.empty
fireEvent.change(input, { target: { value: '' } })
expect(submitButton.disabled).to.be.true
screen.getByRole(
(role, element) =>
role === 'alert' && element.textContent.match(/File name is empty/)
)
await fireEvent.change(input, { target: { value: 'test.tex' } })
expect(submitButton.disabled).to.be.false
expect(screen.queryAllByRole('alert')).to.be.empty
await fireEvent.change(input, { target: { value: 'oops/i/did/it/again' } })
expect(submitButton.disabled).to.be.true
screen.getByRole(
(role, element) =>
role === 'alert' &&
element.textContent.match(/contains invalid characters/)
)
})
it('displays an error when the file limit is reached', async function() {
const rootFolder = [
{
docs: Array.from({ length: 10 }, (_, index) => ({
_id: `entity-${index}`
})),
fileRefs: [],
folders: []
}
]
render(
<FileTreeContext
{...contextProps}
rootFolder={rootFolder}
initialSelectedEntityId="entity-1"
>
<OpenWithMode mode="doc" />
</FileTreeContext>
)
screen.getByRole(
(role, element) =>
role === 'alert' &&
element.textContent.match(/This project has reached the \d+ file limit/)
)
})
it('displays a warning when the file limit is nearly reached', async function() {
const rootFolder = [
{
docs: Array.from({ length: 9 }, (_, index) => ({
_id: `entity-${index}`
})),
fileRefs: [],
folders: []
}
]
render(
<FileTreeContext
{...contextProps}
rootFolder={rootFolder}
initialSelectedEntityId="entity-1"
>
<OpenWithMode mode="doc" />
</FileTreeContext>
)
screen.getByText(/This project is approaching the file limit \(\d+\/\d+\)/)
})
it('counts files in nested folders', async function() {
const rootFolder = [
{
docs: [{ _id: 'entity-1' }],
fileRefs: [],
folders: [
{
docs: [{ _id: 'entity-1-2' }],
fileRefs: [],
folders: [
{
docs: [
{ _id: 'entity-3' },
{ _id: 'entity-4' },
{ _id: 'entity-5' },
{ _id: 'entity-6' },
{ _id: 'entity-7' },
{ _id: 'entity-8' },
{ _id: 'entity-9' }
],
fileRefs: [],
folders: []
}
]
}
]
}
]
render(
<FileTreeContext
{...contextProps}
rootFolder={rootFolder}
initialSelectedEntityId="entity-1"
>
<OpenWithMode mode="doc" />
</FileTreeContext>
)
screen.getByText(/This project is approaching the file limit \(\d+\/\d+\)/)
})
it('creates a new file when the form is submitted', async function() {
fetchMock.post('express:/project/:projectId/doc', () => 204)
render(
<FileTreeContext {...contextProps}>
<OpenWithMode mode="doc" />
</FileTreeContext>
)
const input = screen.getByLabelText('File Name')
await fireEvent.change(input, { target: { value: 'test.tex' } })
const submitButton = screen.getByRole('button', { name: 'Create' })
await fireEvent.click(submitButton)
expect(
fetchMock.called('express:/project/:projectId/doc', {
body: { name: 'test.tex' }
})
).to.be.true
})
it('imports a new file from a project', async function() {
fetchMock
.get('path:/user/projects', {
projects: [
{
_id: 'test-project',
name: 'This Project'
},
{
_id: 'project-1',
name: 'Project One'
},
{
_id: 'project-2',
name: 'Project Two'
}
]
})
.get('express:/project/:projectId/entities', {
entities: [
{
path: '/foo.tex'
},
{
path: '/bar.tex'
}
]
})
.post('express:/project/:projectId/compile', {
status: 'success',
outputFiles: [
{
build: 'test',
path: 'baz.jpg'
},
{
build: 'test',
path: 'ball.jpg'
}
]
})
.post('express:/project/:projectId/linked_file', () => 204)
render(
<FileTreeContext {...contextProps}>
<OpenWithMode mode="project" />
</FileTreeContext>
)
// initial state, no project selected
const projectInput = screen.getByLabelText('Select a Project')
expect(projectInput.disabled).to.be.true
await waitFor(() => {
expect(projectInput.disabled).to.be.false
})
// the submit button should be disabled
const submitButton = screen.getByRole('button', { name: 'Create' })
expect(submitButton.disabled).to.be.true
// the source file selector should be disabled
const fileInput = screen.getByLabelText('Select a File')
expect(fileInput.disabled).to.be.true
// TODO: check for options length, excluding current project
// select a project
await fireEvent.change(projectInput, { target: { value: 'project-2' } }) // TODO: getByRole('option')?
// wait for the source file selector to be enabled
await waitFor(() => {
expect(fileInput.disabled).to.be.false
})
expect(screen.queryByLabelText('Select a File')).not.to.be.null
expect(screen.queryByLabelText('Select an Output File')).to.be.null
expect(submitButton.disabled).to.be.true
// TODO: check for fileInput options length, excluding current project
// click on the button to toggle between source and output files
const sourceTypeButton = screen.getByRole('button', {
name: 'select from output files'
})
await fireEvent.click(sourceTypeButton)
// wait for the output file selector to be enabled
const entityInput = screen.getByLabelText('Select an Output File')
await waitFor(() => {
expect(entityInput.disabled).to.be.false
})
expect(screen.queryByLabelText('Select a File')).to.be.null
expect(screen.queryByLabelText('Select an Output File')).not.to.be.null
expect(submitButton.disabled).to.be.true
// TODO: check for entityInput options length, excluding current project
await fireEvent.change(entityInput, { target: { value: 'ball.jpg' } }) // TODO: getByRole('option')?
await waitFor(() => {
expect(submitButton.disabled).to.be.false
})
await fireEvent.click(submitButton)
expect(
fetchMock.called('express:/project/:projectId/linked_file', {
body: {
name: 'ball.jpg',
provider: 'project_output_file',
data: {
source_project_id: 'project-2',
source_output_file_path: 'ball.jpg',
build_id: 'test'
}
}
})
).to.be.true
})
it('import from a URL when the form is submitted', async function() {
fetchMock.post('express:/project/:projectId/linked_file', () => 204)
render(
<FileTreeContext {...contextProps}>
<OpenWithMode mode="url" />
</FileTreeContext>
)
const urlInput = screen.getByLabelText('URL to fetch the file from')
const nameInput = screen.getByLabelText('File Name In This Project')
await fireEvent.change(urlInput, {
target: { value: 'https://example.com/example.tex' }
})
// check that the name has updated automatically
expect(nameInput.value).to.equal('example.tex')
await fireEvent.change(nameInput, {
target: { value: 'test.tex' }
})
// check that the name can still be edited manually
expect(nameInput.value).to.equal('test.tex')
const submitButton = screen.getByRole('button', { name: 'Create' })
await fireEvent.click(submitButton)
expect(
fetchMock.called('express:/project/:projectId/linked_file', {
body: {
name: 'test.tex',
provider: 'url',
data: { url: 'https://example.com/example.tex' }
}
})
).to.be.true
})
// eslint-disable-next-line mocha/no-skipped-tests
it.skip('uploads a new file', async function() {
fetchMock.post('express:/project/:projectId/linked_file', () => 204)
render(
<FileTreeContext {...contextProps}>
<OpenWithMode mode="upload" />
</FileTreeContext>
)
// the submit button should not be present
expect(screen.queryByRole('button', { name: 'Create' })).to.be.null
const dropzone = screen.getByLabelText('File Uploader')
expect(dropzone).not.to.be.null
// https://github.com/jsdom/jsdom/issues/1568 - no paste
fireEvent.drop(dropzone, {
dataTransfer: {
files: [new File(['test'], 'test.tex', { type: 'text/plain' })]
}
})
expect(fetchMock.called('express:/project/:projectId/upload')).to.be.true
})
})
function OpenWithMode({ mode }) {
const { newFileCreateMode, startCreatingFile } = useFileTreeActionable()
const { fileCount } = useFileTreeMutable()
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => startCreatingFile(mode), [])
if (!fileCount || !newFileCreateMode) {
return null
}
return <FileTreeModalCreateFile />
}
OpenWithMode.propTypes = {
mode: PropTypes.string.isRequired
}

View file

@ -17,7 +17,18 @@ describe('<FileTreeDoc/>', function() {
it('renders selected', function() {
renderWithContext(
<FileTreeDoc name="foo.tex" id="123abc" isLinkedFile={false} />
<FileTreeDoc name="foo.tex" id="123abc" isLinkedFile={false} />,
{
contextProps: {
rootFolder: [
{
docs: [{ _id: '123abc' }],
fileRefs: [],
folders: []
}
]
}
}
)
const treeitem = screen.getByRole('treeitem', { selected: false })
@ -41,7 +52,17 @@ describe('<FileTreeDoc/>', function() {
})
it('selects', function() {
renderWithContext(<FileTreeDoc name="foo.tex" id="123abc" expanded />)
renderWithContext(<FileTreeDoc name="foo.tex" id="123abc" expanded />, {
contextProps: {
rootFolder: [
{
docs: [{ _id: '123abc' }],
fileRefs: [],
folders: []
}
]
}
})
const treeitem = screen.getByRole('treeitem', { selected: false })
fireEvent.click(treeitem)
@ -50,7 +71,17 @@ describe('<FileTreeDoc/>', function() {
})
it('multi-selects', function() {
renderWithContext(<FileTreeDoc name="foo.tex" id="123abc" expanded />)
renderWithContext(<FileTreeDoc name="foo.tex" id="123abc" expanded />, {
contextProps: {
rootFolder: [
{
docs: [{ _id: '123abc' }],
fileRefs: [],
folders: []
}
]
}
})
const treeitem = screen.getByRole('treeitem')

View file

@ -41,7 +41,18 @@ describe('<FileTreeFolderList/>', function() {
]
renderWithContext(
<FileTreeFolderList folders={[]} docs={docs} files={[]} />,
{ contextProps: { hasWritePermissions: false } }
{
contextProps: {
hasWritePermissions: false,
rootFolder: [
{
docs: [{ _id: '1' }, { _id: '2' }],
fileRefs: [],
folders: []
}
]
}
}
)
const treeitem1 = screen.getByRole('treeitem', { name: '1.tex' })
@ -65,7 +76,18 @@ describe('<FileTreeFolderList/>', function() {
{ _id: '3', name: '3.tex' }
]
renderWithContext(
<FileTreeFolderList folders={[]} docs={docs} files={[]} />
<FileTreeFolderList folders={[]} docs={docs} files={[]} />,
{
contextProps: {
rootFolder: [
{
docs: [{ _id: '1' }, { _id: '2' }, { _id: '3' }],
fileRefs: [],
folders: []
}
]
}
}
)
const treeitem1 = screen.getByRole('treeitem', { name: '1.tex' })

View file

@ -33,7 +33,18 @@ describe('<FileTreeFolder/>', function() {
folders={[]}
docs={[]}
files={[]}
/>
/>,
{
contextProps: {
rootFolder: [
{
docs: [{ _id: '123abc' }],
fileRefs: [],
folders: []
}
]
}
}
)
const treeitem = screen.getByRole('treeitem', { selected: false })
@ -58,7 +69,18 @@ describe('<FileTreeFolder/>', function() {
folders={[]}
docs={[]}
files={[]}
/>
/>,
{
contextProps: {
rootFolder: [
{
docs: [{ _id: '123abc' }],
fileRefs: [],
folders: []
}
]
}
}
)
screen.getByRole('treeitem')
@ -76,7 +98,18 @@ describe('<FileTreeFolder/>', function() {
folders={[]}
docs={[]}
files={[]}
/>
/>,
{
contextProps: {
rootFolder: [
{
docs: [{ _id: '123abc' }],
fileRefs: [],
folders: []
}
]
}
}
)
expect(screen.queryByRole('tree')).to.not.exist

View file

@ -1,21 +1,23 @@
import { expect } from 'chai'
import React from 'react'
import sinon from 'sinon'
import { screen, fireEvent } from '@testing-library/react'
import { screen, fireEvent, cleanup } from '@testing-library/react'
import renderWithContext from '../../helpers/render-with-context'
import FileTreeItemName from '../../../../../../frontend/js/features/file-tree/components/file-tree-item/file-tree-item-name'
describe('<FileTreeItemName />', function() {
const sandbox = sinon.createSandbox()
const setIsDraggable = sinon.stub()
beforeEach(function() {
global.requestAnimationFrame = sinon.stub()
sandbox.spy(window, 'requestAnimationFrame')
})
afterEach(function() {
delete global.requestAnimationFrame
sandbox.restore()
setIsDraggable.reset()
cleanup()
})
it('renders name as button', function() {
@ -62,7 +64,7 @@ describe('<FileTreeItemName />', function() {
fireEvent.doubleClick(button)
screen.getByRole('textbox')
expect(screen.queryByRole('button')).to.not.exist
expect(global.requestAnimationFrame).to.be.calledOnce
expect(window.requestAnimationFrame).to.be.calledOnce
expect(setIsDraggable).to.be.calledWith(false)
})

View file

@ -36,6 +36,11 @@ describe('<FileTreeRoot/>', function() {
rootFolder={rootFolder}
projectId="123abc"
hasWritePermissions={false}
hasFeature={() => true}
refProviders={{}}
reindexReferences={() => null}
setRefProviderEnabled={() => null}
setStartedFreeTrial={() => null}
rootDocId="456def"
onSelect={onSelect}
onInit={onInit}
@ -67,6 +72,11 @@ describe('<FileTreeRoot/>', function() {
rootFolder={rootFolder}
projectId="123abc"
hasWritePermissions
hasFeature={() => true}
refProviders={{}}
reindexReferences={() => null}
setRefProviderEnabled={() => null}
setStartedFreeTrial={() => null}
rootDocId="456def"
onSelect={onSelect}
onInit={onInit}
@ -103,6 +113,11 @@ describe('<FileTreeRoot/>', function() {
onSelect={onSelect}
onInit={onInit}
isConnected={false}
hasFeature={() => true}
refProviders={{}}
reindexReferences={() => null}
setRefProviderEnabled={() => null}
setStartedFreeTrial={() => null}
/>
)
@ -127,6 +142,11 @@ describe('<FileTreeRoot/>', function() {
projectId="123abc"
rootDocId="456def"
hasWritePermissions={false}
hasFeature={() => true}
refProviders={{}}
reindexReferences={() => null}
setRefProviderEnabled={() => null}
setStartedFreeTrial={() => null}
onSelect={onSelect}
onInit={onInit}
isConnected
@ -175,6 +195,11 @@ describe('<FileTreeRoot/>', function() {
projectId="123abc"
rootDocId="456def"
hasWritePermissions={false}
hasFeature={() => true}
refProviders={{}}
reindexReferences={() => null}
setRefProviderEnabled={() => null}
setStartedFreeTrial={() => null}
onSelect={onSelect}
onInit={onInit}
isConnected

View file

@ -23,6 +23,11 @@ describe('FileTree Context Menu Flow', function() {
rootFolder={rootFolder}
projectId="123abc"
hasWritePermissions
hasFeature={() => true}
refProviders={{}}
reindexReferences={() => null}
setRefProviderEnabled={() => null}
setStartedFreeTrial={() => null}
rootDocId="456def"
onSelect={onSelect}
onInit={onInit}
@ -52,6 +57,11 @@ describe('FileTree Context Menu Flow', function() {
rootFolder={rootFolder}
projectId="123abc"
hasWritePermissions={false}
hasFeature={() => true}
refProviders={{}}
reindexReferences={() => null}
setRefProviderEnabled={() => null}
setStartedFreeTrial={() => null}
rootDocId="456def"
onSelect={onSelect}
onInit={onInit}

View file

@ -40,6 +40,11 @@ describe('FileTree Create Folder Flow', function() {
rootFolder={rootFolder}
projectId="123abc"
hasWritePermissions
hasFeature={() => true}
refProviders={{}}
reindexReferences={() => null}
setRefProviderEnabled={() => null}
setStartedFreeTrial={() => null}
onSelect={onSelect}
onInit={onInit}
isConnected
@ -95,6 +100,11 @@ describe('FileTree Create Folder Flow', function() {
rootFolder={rootFolder}
projectId="123abc"
hasWritePermissions
hasFeature={() => true}
refProviders={{}}
reindexReferences={() => null}
setRefProviderEnabled={() => null}
setStartedFreeTrial={() => null}
rootDocId="789ghi"
onSelect={onSelect}
onInit={onInit}
@ -160,6 +170,11 @@ describe('FileTree Create Folder Flow', function() {
rootFolder={rootFolder}
projectId="123abc"
hasWritePermissions
hasFeature={() => true}
refProviders={{}}
reindexReferences={() => null}
setRefProviderEnabled={() => null}
setStartedFreeTrial={() => null}
rootDocId="456def"
onSelect={onSelect}
onInit={onInit}
@ -214,6 +229,11 @@ describe('FileTree Create Folder Flow', function() {
rootFolder={rootFolder}
projectId="123abc"
hasWritePermissions
hasFeature={() => true}
refProviders={{}}
reindexReferences={() => null}
setRefProviderEnabled={() => null}
setStartedFreeTrial={() => null}
rootDocId="456def"
onSelect={onSelect}
onInit={onInit}
@ -223,7 +243,7 @@ describe('FileTree Create Folder Flow', function() {
var newFolderName = 'existingFile'
fireCreateFolder(newFolderName)
await fireCreateFolder(newFolderName)
expect(fetchMock.called()).to.be.false

View file

@ -39,6 +39,11 @@ describe('FileTree Delete Entity Flow', function() {
rootFolder={rootFolder}
projectId="123abc"
hasWritePermissions
hasFeature={() => true}
refProviders={{}}
reindexReferences={() => null}
setRefProviderEnabled={() => null}
setStartedFreeTrial={() => null}
onSelect={onSelect}
onInit={onInit}
isConnected
@ -148,6 +153,11 @@ describe('FileTree Delete Entity Flow', function() {
rootFolder={rootFolder}
projectId="123abc"
hasWritePermissions
hasFeature={() => true}
refProviders={{}}
reindexReferences={() => null}
setRefProviderEnabled={() => null}
setStartedFreeTrial={() => null}
onSelect={onSelect}
onInit={onInit}
isConnected
@ -197,6 +207,11 @@ describe('FileTree Delete Entity Flow', function() {
rootFolder={rootFolder}
projectId="123abc"
hasWritePermissions
hasFeature={() => true}
refProviders={{}}
reindexReferences={() => null}
setRefProviderEnabled={() => null}
setStartedFreeTrial={() => null}
onSelect={onSelect}
onInit={onInit}
isConnected

View file

@ -51,6 +51,11 @@ describe('FileTree Rename Entity Flow', function() {
rootFolder={rootFolder}
projectId="123abc"
hasWritePermissions
hasFeature={() => true}
refProviders={{}}
reindexReferences={() => null}
setRefProviderEnabled={() => null}
setStartedFreeTrial={() => null}
onSelect={onSelect}
onInit={onInit}
isConnected

View file

@ -6,8 +6,25 @@ export default (children, options = {}) => {
let { contextProps = {}, ...renderOptions } = options
contextProps = {
projectId: '123abc',
rootFolder: [{}],
rootFolder: [
{
docs: [],
fileRefs: [],
folders: []
}
],
hasWritePermissions: true,
hasFeature: () => true,
refProviders: {},
reindexReferences: () => {
console.log('reindex references')
},
setRefProviderEnabled: provider => {
console.log(`ref provider ${provider} enabled`)
},
setStartedFreeTrial: () => {
console.log('started free trial')
},
onSelect: () => {},
...contextProps
}

View file

@ -114,6 +114,11 @@ module.exports = {
{ loader: 'less-loader' }
]
},
{
// Pass CSS files through css-loader & mini-css-extract-plugin (note: run in reverse order)
test: /\.css$/i,
use: [MiniCssExtractPlugin.loader, 'css-loader']
},
{
// Load fonts
test: /\.(woff|woff2)$/,