mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
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:
parent
0ca6b5921f
commit
ba4300d9e1
69 changed files with 3221 additions and 180 deletions
|
@ -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$/,
|
||||
|
|
|
@ -83,4 +83,8 @@ const withTheme = (Story, context) => {
|
|||
|
||||
export const decorators = [withTheme]
|
||||
|
||||
window.ExposedSettings = {}
|
||||
window.ExposedSettings = {
|
||||
appName: 'Overleaf',
|
||||
maxEntitiesPerProject: 10,
|
||||
maxUploadSize: 5 * 1024 * 1024
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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').
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -683,5 +683,6 @@ module.exports = settings =
|
|||
|
||||
overleafModuleImports: {
|
||||
# modules to import (an empty array for each set of modules)
|
||||
createFileModes: []
|
||||
}
|
||||
|
||||
|
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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([
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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} />
|
||||
|
||||
{label}
|
||||
</Button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
FileTreeModalCreateFileMode.propTypes = {
|
||||
mode: PropTypes.string.isRequired,
|
||||
icon: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
|
||||
<Button bsStyle="primary" onClick={cancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</p>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
UploadConflicts.propTypes = {
|
||||
cancel: PropTypes.func.isRequired,
|
||||
conflicts: PropTypes.array.isRequired,
|
||||
handleOverwrite: PropTypes.func.isRequired
|
||||
}
|
|
@ -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 }}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
|
|
|
@ -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}`,
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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 }
|
||||
}
|
|
@ -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 }
|
||||
}
|
|
@ -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 }
|
||||
}
|
4
services/web/frontend/js/features/file-tree/util/api.js
Normal file
4
services/web/frontend/js/features/file-tree/util/api.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { postJSON } from '../../../infrastructure/fetch-json'
|
||||
|
||||
export const refreshProjectMetadata = (projectId, entityId) =>
|
||||
postJSON(`/project/${projectId}/doc/${entityId}/metadata`)
|
|
@ -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'
|
||||
})
|
|
@ -5,7 +5,7 @@ export function useRefWithAutoFocus() {
|
|||
|
||||
useEffect(() => {
|
||||
if (autoFocusedRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
if (autoFocusedRef.current) autoFocusedRef.current.focus()
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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]
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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]
|
||||
}
|
|
@ -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
|
||||
}
|
31
services/web/frontend/stories/modals/modal-decorators.js
Normal file
31
services/web/frontend/stories/modals/modal-decorators.js
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
270
services/web/package-lock.json
generated
270
services/web/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
19
services/web/test/frontend/bootstrap.js
vendored
19
services/web/test/frontend/bootstrap.js
vendored
|
@ -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 })
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -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
|
||||
}
|
|
@ -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')
|
||||
|
||||
|
|
|
@ -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' })
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)$/,
|
||||
|
|
Loading…
Reference in a new issue