diff --git a/services/web/.storybook/main.js b/services/web/.storybook/main.js index 8b1994a907..fda07d3be1 100644 --- a/services/web/.storybook/main.js +++ b/services/web/.storybook/main.js @@ -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$/, diff --git a/services/web/.storybook/preview.js b/services/web/.storybook/preview.js index ffe06d9a91..de015ceb95 100644 --- a/services/web/.storybook/preview.js +++ b/services/web/.storybook/preview.js @@ -83,4 +83,8 @@ const withTheme = (Story, context) => { export const decorators = [withTheme] -window.ExposedSettings = {} +window.ExposedSettings = { + appName: 'Overleaf', + maxEntitiesPerProject: 10, + maxUploadSize: 5 * 1024 * 1024 +} diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index 38c15aafaf..fcbbdca59f 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -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() } diff --git a/services/web/app/src/infrastructure/ExpressLocals.js b/services/web/app/src/infrastructure/ExpressLocals.js index 19c7b434bd..d3b76fdc9f 100644 --- a/services/web/app/src/infrastructure/ExpressLocals.js +++ b/services/web/app/src/infrastructure/ExpressLocals.js @@ -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: diff --git a/services/web/app/views/layout.pug b/services/web/app/views/layout.pug index b0daa3846f..eb6836c11b 100644 --- a/services/web/app/views/layout.pug +++ b/services/web/app/views/layout.pug @@ -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 diff --git a/services/web/app/views/project/editor.pug b/services/web/app/views/project/editor.pug index 9ad1eef194..a3fb9d5738 100644 --- a/services/web/app/views/project/editor.pug +++ b/services/web/app/views/project/editor.pug @@ -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'). diff --git a/services/web/app/views/project/editor/file-tree-react.pug b/services/web/app/views/project/editor/file-tree-react.pug index 34ba1859dd..8ba1659383 100644 --- a/services/web/app/views/project/editor/file-tree-react.pug +++ b/services/web/app/views/project/editor/file-tree-react.pug @@ -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") diff --git a/services/web/config/settings.defaults.coffee b/services/web/config/settings.defaults.coffee index e56cecaaec..45614db807 100644 --- a/services/web/config/settings.defaults.coffee +++ b/services/web/config/settings.defaults.coffee @@ -683,5 +683,6 @@ module.exports = settings = overleafModuleImports: { # modules to import (an empty array for each set of modules) + createFileModes: [] } diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index efb799db81..2afba5ff53 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "" } diff --git a/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal.js b/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal.js index f1dbb81458..7dc2cef824 100644 --- a/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal.js +++ b/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal.js @@ -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) diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.js b/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.js index 50cb034d13..2687d6d6f8 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.js +++ b/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.js @@ -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 diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-context.js b/services/web/frontend/js/features/file-tree/components/file-tree-context.js index fcc2f06d41..b665f1adab 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-context.js +++ b/services/web/frontend/js/features/file-tree/components/file-tree-context.js @@ -17,6 +17,11 @@ function FileTreeContext({ rootFolder, hasWritePermissions, rootDocId, + hasFeature, + refProviders, + reindexReferences, + setRefProviderEnabled, + setStartedFreeTrial, onSelect, children }) { @@ -24,6 +29,11 @@ function FileTreeContext({ {children} +} +DangerMessage.propTypes = { + children: PropTypes.string.isRequired +} diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/error-message.js b/services/web/frontend/js/features/file-tree/components/file-tree-create/error-message.js new file mode 100644 index 0000000000..2864823420 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/components/file-tree-create/error-message.js @@ -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 {t('file_already_exists')} + + case 'too-many-files': + return {t('project_has_too_many_files')} + + case 'remote-service-error': + return {t('remote_service_error')} + + case 'rate-limit-hit': + return ( + + {t('too_many_files_uploaded_throttled_short_period')} + + ) + + case 'not-logged-in': + return ( + + + + ) + + default: + // TODO: convert error.response.data to an error key and try again? + // return error + return ( + {t('generic_something_went_wrong')} + ) + } + } + + // the error is an object + // TODO: error.name? + switch (error.constructor) { + case FetchError: { + const message = error.data?.message + + if (message) { + return {message.text || message} + } + + // TODO: translations + switch (error.response?.status) { + case 400: + return ( + + Invalid Request. Please correct the data and try again. + + ) + + case 403: + return ( + + Session error. Please check you have cookies enabled. If the + problem persists, try clearing your cache and cookies. + + ) + + case 429: + return ( + + Too many attempts. Please wait for a while and try again. + + ) + + default: + return ( + + Something went wrong talking to the server :(. Please try again. + + ) + } + } + + // 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 {t('generic_something_went_wrong')} + } +} +ErrorMessage.propTypes = { + error: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired +} diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-create-name-input.js b/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-create-name-input.js new file mode 100644 index 0000000000..dd31132d1a --- /dev/null +++ b/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-create-name-input.js @@ -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 ( + + {label} + + setName(event.target.value)} + inputRef={inputRef} + /> + + + + {touchedName && !validName && ( + + + + )} + + {error && } + + ) +} + +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 ( + + + + ) + + case InvalidFilenameError: + return ( + + + + ) + + case BlockedFilenameError: + return ( + + + + ) + + default: + // return + return null // other errors are displayed elsewhere + } +} +ErrorMessage.propTypes = { + error: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired +} diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-body.js b/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-body.js new file mode 100644 index 0000000000..5c155a87e3 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-body.js @@ -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 ( + + + + + + + + +
+
    + + + + + + + {window.ExposedSettings.hasLinkUrlFeature && ( + + )} + + {createFileModeModules.map( + ({ import: { CreateFileMode }, path }) => ( + + ) + )} +
+
+ {newFileCreateMode === 'doc' && ( + + + + )} + + {newFileCreateMode === 'url' && ( + + + + )} + + {newFileCreateMode === 'project' && ( + + + + )} + + {newFileCreateMode === 'upload' && } + + {createFileModeModules.map( + ({ import: { CreateFilePane }, path }) => ( + + ) + )} +
+ ) +} diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-footer.js b/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-footer.js new file mode 100644 index 0000000000..97d79babff --- /dev/null +++ b/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-footer.js @@ -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 ( + + ) +} + +export function FileTreeModalCreateFileFooterContent({ + valid, + fileCount, + inFlight, + newFileCreateMode, + cancel +}) { + const { t } = useTranslation() + + return ( + <> + {fileCount.status === 'warning' && ( +
+ {t('project_approaching_file_limit')} ({fileCount.value}/ + {fileCount.limit}) +
+ )} + + {fileCount.status === 'error' && ( + + {/* TODO: add parameter for fileCount.limit */} + {t('project_has_too_many_files')} + + )} + + + + {newFileCreateMode !== 'upload' && ( + + )} + + ) +} +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 +} diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-mode.js b/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-mode.js new file mode 100644 index 0000000000..45a9da601e --- /dev/null +++ b/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-mode.js @@ -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 ( +
  • + +
  • + ) +} + +FileTreeModalCreateFileMode.propTypes = { + mode: PropTypes.string.isRequired, + icon: PropTypes.string.isRequired, + label: PropTypes.string.isRequired +} diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-create-new-doc.js b/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-create-new-doc.js new file mode 100644 index 0000000000..39e9617152 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-create-new-doc.js @@ -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 ( +
    + + + {error && } + + ) +} diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.js b/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.js new file mode 100644 index 0000000000..393859dcd2 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.js @@ -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 ( +
    + + + {isOutputFilesMode ? ( + + ) : ( + + )} + +
    + or  + +
    + + + + {error && } + + ) +} + +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 ( + + Select a Project + + {loading && ( + +   + + + )} + + { + const projectId = event.target.value + const project = data.find(item => item._id === projectId) + setSelectedProject(project) + }} + > + + + {filteredData && + filteredData.map(project => ( + + ))} + + + {filteredData && !filteredData.length && ( + + No other projects found, please create another project first + + )} + + ) +} +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 ( + + Select an Output File + + {loading && ( + +   + + + )} + + { + const path = event.target.value + const file = data.find(item => item.path === path) + setSelectedProjectOutputFile(file) + }} + > + + + {data && + data.map(file => ( + + ))} + + + ) +} +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 ( + + Select a File + + {loading && ( + +   + + + )} + + { + const path = event.target.value + const entity = data.find(item => item.path === path) + setSelectedProjectEntity(entity) + }} + > + + + {data && + data.map(entity => ( + + ))} + + + ) +} +SelectProjectEntity.propTypes = { + selectedProjectId: PropTypes.string, + selectedProjectEntity: PropTypes.object, + setSelectedProjectEntity: PropTypes.func.isRequired +} diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-url.js b/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-url.js new file mode 100644 index 0000000000..b7e119301a --- /dev/null +++ b/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-url.js @@ -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 ( +
    + + URL to fetch the file from + + + + + + + {error && } + + ) +} diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.js b/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.js new file mode 100644 index 0000000000..35e7498177 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.js @@ -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 && ( + + )} + + {showConflicts ? ( + + ) : ( + + )} + + ) +} + +function UploadErrorMessage({ error, maxNumberOfFiles }) { + switch (error) { + case 'too-many-files': + return ( + + ) + + default: + return + } +} +UploadErrorMessage.propTypes = { + error: PropTypes.string.isRequired, + maxNumberOfFiles: PropTypes.number.isRequired +} + +function UploadConflicts({ cancel, conflicts, handleOverwrite }) { + return ( + +

    + The following files already exist in this project: +

    + +
      + {conflicts.map((conflict, index) => ( +
    • + {conflict.meta.name} +
    • + ))} +
    + +

    + Do you want to overwrite them? +

    + +

    + +   + +

    +
    + ) +} +UploadConflicts.propTypes = { + cancel: PropTypes.func.isRequired, + conflicts: PropTypes.array.isRequired, + handleOverwrite: PropTypes.func.isRequired +} diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/redirect-to-login.js b/services/web/frontend/js/features/file-tree/components/file-tree-create/redirect-to-login.js new file mode 100644 index 0000000000..5b44a6a818 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/components/file-tree-create/redirect-to-login.js @@ -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 ( + + ) +} diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-folder-list.js b/services/web/frontend/js/features/file-tree/components/file-tree-folder-list.js index e2d09098ed..9780a45444 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-folder-list.js +++ b/services/web/frontend/js/features/file-tree/components/file-tree-folder-list.js @@ -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 diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.js b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.js index c75d383d90..d1e6fb967d 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.js +++ b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.js @@ -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 diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-root.js b/services/web/frontend/js/features/file-tree/components/file-tree-root.js index ada036ed14..775a7cf618 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-root.js +++ b/services/web/frontend/js/features/file-tree/components/file-tree-root.js @@ -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({ + {window.showReactAddFilesModal && } @@ -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) diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-toolbar.js b/services/web/frontend/js/features/file-tree/components/file-tree-toolbar.js index 6f2266f1b6..532dce52c6 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-toolbar.js +++ b/services/web/frontend/js/features/file-tree/components/file-tree-toolbar.js @@ -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 ( - <> +
    - +
    ) } diff --git a/services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-create-file.js b/services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-create-file.js new file mode 100644 index 0000000000..6180de232d --- /dev/null +++ b/services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-create-file.js @@ -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 ( + + + + Add Files + + + + + + + + + + + + ) +} diff --git a/services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-create-folder.js b/services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-create-folder.js index 47eb776fa3..af71c3d356 100644 --- a/services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-create-folder.js +++ b/services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-create-folder.js @@ -46,7 +46,7 @@ function FileTreeModalCreateFolder() { } return ( - + {t('new_folder')} diff --git a/services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-delete.js b/services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-delete.js index 4b807de94a..cd9654d73f 100644 --- a/services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-delete.js +++ b/services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-delete.js @@ -29,7 +29,7 @@ function FileTreeModalDelete() { } return ( - + {t('delete')} diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.js b/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.js index 28d2eda6d7..1dba48cf33 100644 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.js +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.js @@ -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() diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-create-form.js b/services/web/frontend/js/features/file-tree/contexts/file-tree-create-form.js new file mode 100644 index 0000000000..91f1c59600 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-create-form.js @@ -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 ( + + {children} + + ) +} + +FileTreeCreateFormProvider.propTypes = { + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node + ]).isRequired +} diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-create-name.js b/services/web/frontend/js/features/file-tree/contexts/file-tree-create-name.js new file mode 100644 index 0000000000..658b42669a --- /dev/null +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-create-name.js @@ -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 ( + + {children} + + ) +} + +FileTreeCreateNameProvider.propTypes = { + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node + ]).isRequired, + initialName: PropTypes.string +} diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-main.js b/services/web/frontend/js/features/file-tree/contexts/file-tree-main.js index ce0a16315a..af74c058a3 100644 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-main.js +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-main.js @@ -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 diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-mutable.js b/services/web/frontend/js/features/file-tree/contexts/file-tree-mutable.js index adccceaa0a..dd653a981e 100644 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-mutable.js +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-mutable.js @@ -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 ( - + {children} ) @@ -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' +} diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.js b/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.js index 7965ddcf5e..4844c00098 100644 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.js +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.js @@ -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}`, diff --git a/services/web/frontend/js/features/file-tree/controllers/file-tree-controller.js b/services/web/frontend/js/features/file-tree/controllers/file-tree-controller.js index 5b19524ae7..9f79fa4fc7 100644 --- a/services/web/frontend/js/features/file-tree/controllers/file-tree-controller.js +++ b/services/web/frontend/js/features/file-tree/controllers/file-tree-controller.js @@ -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)) diff --git a/services/web/frontend/js/features/file-tree/hooks/use-project-entities.js b/services/web/frontend/js/features/file-tree/hooks/use-project-entities.js new file mode 100644 index 0000000000..d6697fcd6f --- /dev/null +++ b/services/web/frontend/js/features/file-tree/hooks/use-project-entities.js @@ -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 } +} diff --git a/services/web/frontend/js/features/file-tree/hooks/use-project-output-files.js b/services/web/frontend/js/features/file-tree/hooks/use-project-output-files.js new file mode 100644 index 0000000000..f47e4688da --- /dev/null +++ b/services/web/frontend/js/features/file-tree/hooks/use-project-output-files.js @@ -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 } +} diff --git a/services/web/frontend/js/features/file-tree/hooks/use-user-projects.js b/services/web/frontend/js/features/file-tree/hooks/use-user-projects.js new file mode 100644 index 0000000000..667319ca68 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/hooks/use-user-projects.js @@ -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 } +} diff --git a/services/web/frontend/js/features/file-tree/util/api.js b/services/web/frontend/js/features/file-tree/util/api.js new file mode 100644 index 0000000000..6bd6bd90b6 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/util/api.js @@ -0,0 +1,4 @@ +import { postJSON } from '../../../infrastructure/fetch-json' + +export const refreshProjectMetadata = (projectId, entityId) => + postJSON(`/project/${projectId}/doc/${entityId}/metadata`) diff --git a/services/web/frontend/js/features/file-tree/util/file-collator.js b/services/web/frontend/js/features/file-tree/util/file-collator.js new file mode 100644 index 0000000000..aa99a4a3a3 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/util/file-collator.js @@ -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' +}) diff --git a/services/web/frontend/js/infrastructure/auto-focus.js b/services/web/frontend/js/infrastructure/auto-focus.js index 27df0b0053..f4ad8624f7 100644 --- a/services/web/frontend/js/infrastructure/auto-focus.js +++ b/services/web/frontend/js/infrastructure/auto-focus.js @@ -5,7 +5,7 @@ export function useRefWithAutoFocus() { useEffect(() => { if (autoFocusedRef.current) { - requestAnimationFrame(() => { + window.requestAnimationFrame(() => { if (autoFocusedRef.current) autoFocusedRef.current.focus() }) } diff --git a/services/web/frontend/stories/import-overleaf-modules.stories.js b/services/web/frontend/stories/import-overleaf-modules.stories.js deleted file mode 100644 index e6f25a2f69..0000000000 --- a/services/web/frontend/stories/import-overleaf-modules.stories.js +++ /dev/null @@ -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 ( -
    -

    - You do not have any module imports configured. Add the following to - your settings: -

    - - {`moduleImports: { storybook: [PATH_TO_MODULE_THAT_EXPORTS_COMPONENT] }`} - -

    Then restart Storybook.

    -
    - ) - } - - return ( -
    - {imports.map(({ import: { default: Component }, path }) => { - return - })} -
    - ) -} - -export const Demo = args => - -export default { - title: 'importOverleafModule Macro' -} diff --git a/services/web/frontend/stories/modals/create-file/create-file-modal-decorator.js b/services/web/frontend/stories/modals/create-file/create-file-modal-decorator.js new file mode 100644 index 0000000000..c460689887 --- /dev/null +++ b/services/web/frontend/stories/modals/create-file/create-file-modal-decorator.js @@ -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 ( + + + + + + + + + + ) +} + +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 +} diff --git a/services/web/frontend/stories/modals/create-file/create-file-modal-footer.stories.js b/services/web/frontend/stories/modals/create-file/create-file-modal-footer.stories.js new file mode 100644 index 0000000000..50ce15cd24 --- /dev/null +++ b/services/web/frontend/stories/modals/create-file/create-file-modal-footer.stories.js @@ -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 => + +export const Invalid = args => ( + +) +Invalid.args = { + valid: false +} + +export const Inflight = args => ( + +) +Inflight.args = { + inFlight: true +} + +export const FileLimitWarning = args => ( + +) +FileLimitWarning.args = { + fileCount: { + status: 'warning', + value: 1990, + limit: 2000 + } +} + +export const FileLimitError = 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] +} diff --git a/services/web/frontend/stories/modals/create-file/create-file-modal.stories.js b/services/web/frontend/stories/modals/create-file/create-file-modal.stories.js new file mode 100644 index 0000000000..cc04cd03b4 --- /dev/null +++ b/services/web/frontend/stories/modals/create-file/create-file-modal.stories.js @@ -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 => +MinimalFeatures.decorators = [ + createFileModalDecorator({ + hasFeature: () => false + }) +] + +export const WithExtraFeatures = args => +WithExtraFeatures.decorators = [createFileModalDecorator()] + +export const FileLimitReached = args => +FileLimitReached.decorators = [ + createFileModalDecorator({ + rootFolder: [ + { + docs: Array.from({ length: 10 }, (_, index) => ({ + _id: `entity-${index}` + })), + fileRefs: [], + folders: [] + } + ] + }) +] + +export default { + title: 'Modals / Create File', + component: FileTreeModalCreateFile +} diff --git a/services/web/frontend/stories/modals/create-file/create-file-name-input.stories.js b/services/web/frontend/stories/modals/create-file/create-file-name-input.stories.js new file mode 100644 index 0000000000..20dee1666c --- /dev/null +++ b/services/web/frontend/stories/modals/create-file/create-file-name-input.stories.js @@ -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 => ( + + + +) + +export const CustomLabel = args => ( + + + +) +CustomLabel.args = { + label: 'File Name in this Project' +} + +export const FocusName = args => ( + + + +) +FocusName.args = { + focusName: true +} + +export const CustomPlaceholder = args => ( + + + +) +CustomPlaceholder.args = { + placeholder: 'Enter a file name…' +} + +export const DuplicateError = args => ( + + + +) +DuplicateError.args = { + error: new DuplicateFilenameError() +} + +export const BlockedError = args => ( + + + +) +BlockedError.args = { + error: new BlockedFilenameError() +} + +export default { + title: 'Modals / Create File / File Name Input', + component: FileTreeCreateNameInput, + decorators: [ModalBodyDecorator, ModalContentDecorator] +} diff --git a/services/web/frontend/stories/modals/create-file/error-message.stories.js b/services/web/frontend/stories/modals/create-file/error-message.stories.js new file mode 100644 index 0000000000..6673c74f70 --- /dev/null +++ b/services/web/frontend/stories/modals/create-file/error-message.stories.js @@ -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 ( + <> + + + + + {/* */} + + + ) +} +KeyedErrors.decorators = [createFileModalDecorator()] + +export const FetchStatusErrors = () => { + return ( + <> + + + + + + ) +} + +export const FetchDataErrors = () => { + return ( + <> + + + + ) +} + +export const SpecificClassErrors = () => { + return ( + <> + + + + + + ) +} + +export default { + title: 'Modals / Create File / Error Message', + component: ErrorMessage +} diff --git a/services/web/frontend/stories/modals/modal-decorators.js b/services/web/frontend/stories/modals/modal-decorators.js new file mode 100644 index 0000000000..76cfb0f13b --- /dev/null +++ b/services/web/frontend/stories/modals/modal-decorators.js @@ -0,0 +1,31 @@ +import React from 'react' + +/** + * Wrap modal content in modal classes, without modal behaviours + */ + +export function ModalContentDecorator(Story) { + return ( +
    +
    + +
    +
    + ) +} + +export function ModalBodyDecorator(Story) { + return ( +
    + +
    + ) +} + +export function ModalFooterDecorator(Story) { + return ( +
    + +
    + ) +} diff --git a/services/web/frontend/stylesheets/app/editor/file-tree.less b/services/web/frontend/stylesheets/app/editor/file-tree.less index 88d8a6342c..35cec90291 100644 --- a/services/web/frontend/stylesheets/app/editor/file-tree.less +++ b/services/web/frontend/stylesheets/app/editor/file-tree.less @@ -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; + } +} diff --git a/services/web/install_deps.sh b/services/web/install_deps.sh index b06a3ff4e1..f915e8e91e 100755 --- a/services/web/install_deps.sh +++ b/services/web/install_deps.sh @@ -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 diff --git a/services/web/package-lock.json b/services/web/package-lock.json index 01581328f9..eb9a673aa9 100644 --- a/services/web/package-lock.json +++ b/services/web/package-lock.json @@ -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", diff --git a/services/web/package.json b/services/web/package.json index ebf1d921e0..c45a1b6460 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -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", diff --git a/services/web/test/frontend/bootstrap.js b/services/web/test/frontend/bootstrap.js index b6b2306d90..6080ecdbcd 100644 --- a/services/web/test/frontend/bootstrap.js +++ b/services/web/test/frontend/bootstrap.js @@ -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 }) diff --git a/services/web/test/frontend/features/file-tree/components/file-tree-create/context-props.js b/services/web/test/frontend/features/file-tree/components/file-tree-create/context-props.js new file mode 100644 index 0000000000..f0aac3be25 --- /dev/null +++ b/services/web/test/frontend/features/file-tree/components/file-tree-create/context-props.js @@ -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() +} diff --git a/services/web/test/frontend/features/file-tree/components/file-tree-create/file-tree-create-name-input.test.js b/services/web/test/frontend/features/file-tree/components/file-tree-create/file-tree-create-name-input.test.js new file mode 100644 index 0000000000..9a9431b764 --- /dev/null +++ b/services/web/test/frontend/features/file-tree/components/file-tree-create/file-tree-create-name-input.test.js @@ -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('', function() { + const sandbox = sinon.createSandbox() + + beforeEach(function() { + sandbox.spy(window, 'requestAnimationFrame') + }) + + afterEach(function() { + sandbox.restore() + cleanup() + }) + + it('renders an empty input', async function() { + render( + + + + + + ) + + await screen.getByLabelText('File Name') + await screen.getByPlaceholderText('File Name') + }) + + it('renders a custom label and placeholder', async function() { + render( + + + + + + ) + + await screen.getByLabelText('File name in this project') + await screen.getByPlaceholderText('Enter a file name…') + }) + + it('uses an initial name', async function() { + render( + + + + + + ) + + const input = await screen.getByLabelText('File Name') + expect(input.value).to.equal('test.tex') + }) + + it('focuses the name', async function() { + render( + + + + + + ) + + 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 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) + }) +}) diff --git a/services/web/test/frontend/features/file-tree/components/file-tree-create/file-tree-modal-create-file.test.js b/services/web/test/frontend/features/file-tree/components/file-tree-create/file-tree-modal-create-file.test.js new file mode 100644 index 0000000000..d23ad7cf43 --- /dev/null +++ b/services/web/test/frontend/features/file-tree/components/file-tree-create/file-tree-modal-create-file.test.js @@ -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('', function() { + afterEach(function() { + fetchMock.restore() + cleanup() + }) + + it('handles invalid file names', async function() { + render( + + + + ) + + 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( + + + + ) + + 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( + + + + ) + + 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( + + + + ) + + 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( + + + + ) + + 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( + + + + ) + + // 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( + + + + ) + + 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( + + + + ) + + // 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 +} +OpenWithMode.propTypes = { + mode: PropTypes.string.isRequired +} diff --git a/services/web/test/frontend/features/file-tree/components/file-tree-doc.test.js b/services/web/test/frontend/features/file-tree/components/file-tree-doc.test.js index 14e4fdb217..6158f61939 100644 --- a/services/web/test/frontend/features/file-tree/components/file-tree-doc.test.js +++ b/services/web/test/frontend/features/file-tree/components/file-tree-doc.test.js @@ -17,7 +17,18 @@ describe('', function() { it('renders selected', function() { renderWithContext( - + , + { + contextProps: { + rootFolder: [ + { + docs: [{ _id: '123abc' }], + fileRefs: [], + folders: [] + } + ] + } + } ) const treeitem = screen.getByRole('treeitem', { selected: false }) @@ -41,7 +52,17 @@ describe('', function() { }) it('selects', function() { - renderWithContext() + renderWithContext(, { + contextProps: { + rootFolder: [ + { + docs: [{ _id: '123abc' }], + fileRefs: [], + folders: [] + } + ] + } + }) const treeitem = screen.getByRole('treeitem', { selected: false }) fireEvent.click(treeitem) @@ -50,7 +71,17 @@ describe('', function() { }) it('multi-selects', function() { - renderWithContext() + renderWithContext(, { + contextProps: { + rootFolder: [ + { + docs: [{ _id: '123abc' }], + fileRefs: [], + folders: [] + } + ] + } + }) const treeitem = screen.getByRole('treeitem') diff --git a/services/web/test/frontend/features/file-tree/components/file-tree-folder-list.test.js b/services/web/test/frontend/features/file-tree/components/file-tree-folder-list.test.js index a97cb35875..f05e1eef1b 100644 --- a/services/web/test/frontend/features/file-tree/components/file-tree-folder-list.test.js +++ b/services/web/test/frontend/features/file-tree/components/file-tree-folder-list.test.js @@ -41,7 +41,18 @@ describe('', function() { ] renderWithContext( , - { 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('', function() { { _id: '3', name: '3.tex' } ] renderWithContext( - + , + { + contextProps: { + rootFolder: [ + { + docs: [{ _id: '1' }, { _id: '2' }, { _id: '3' }], + fileRefs: [], + folders: [] + } + ] + } + } ) const treeitem1 = screen.getByRole('treeitem', { name: '1.tex' }) diff --git a/services/web/test/frontend/features/file-tree/components/file-tree-folder.test.js b/services/web/test/frontend/features/file-tree/components/file-tree-folder.test.js index 0fffed7083..a5f0bce36d 100644 --- a/services/web/test/frontend/features/file-tree/components/file-tree-folder.test.js +++ b/services/web/test/frontend/features/file-tree/components/file-tree-folder.test.js @@ -33,7 +33,18 @@ describe('', function() { folders={[]} docs={[]} files={[]} - /> + />, + { + contextProps: { + rootFolder: [ + { + docs: [{ _id: '123abc' }], + fileRefs: [], + folders: [] + } + ] + } + } ) const treeitem = screen.getByRole('treeitem', { selected: false }) @@ -58,7 +69,18 @@ describe('', function() { folders={[]} docs={[]} files={[]} - /> + />, + { + contextProps: { + rootFolder: [ + { + docs: [{ _id: '123abc' }], + fileRefs: [], + folders: [] + } + ] + } + } ) screen.getByRole('treeitem') @@ -76,7 +98,18 @@ describe('', function() { folders={[]} docs={[]} files={[]} - /> + />, + { + contextProps: { + rootFolder: [ + { + docs: [{ _id: '123abc' }], + fileRefs: [], + folders: [] + } + ] + } + } ) expect(screen.queryByRole('tree')).to.not.exist diff --git a/services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-name.test.js b/services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-name.test.js index 36db80749c..b63a13992d 100644 --- a/services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-name.test.js +++ b/services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-name.test.js @@ -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('', 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('', 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) }) diff --git a/services/web/test/frontend/features/file-tree/components/file-tree-root.test.js b/services/web/test/frontend/features/file-tree/components/file-tree-root.test.js index 6612f07820..9e77889fd1 100644 --- a/services/web/test/frontend/features/file-tree/components/file-tree-root.test.js +++ b/services/web/test/frontend/features/file-tree/components/file-tree-root.test.js @@ -36,6 +36,11 @@ describe('', 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('', 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('', function() { onSelect={onSelect} onInit={onInit} isConnected={false} + hasFeature={() => true} + refProviders={{}} + reindexReferences={() => null} + setRefProviderEnabled={() => null} + setStartedFreeTrial={() => null} /> ) @@ -127,6 +142,11 @@ describe('', 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('', function() { projectId="123abc" rootDocId="456def" hasWritePermissions={false} + hasFeature={() => true} + refProviders={{}} + reindexReferences={() => null} + setRefProviderEnabled={() => null} + setStartedFreeTrial={() => null} onSelect={onSelect} onInit={onInit} isConnected diff --git a/services/web/test/frontend/features/file-tree/flows/context-menu.test.js b/services/web/test/frontend/features/file-tree/flows/context-menu.test.js index 819429606e..077cad3ce1 100644 --- a/services/web/test/frontend/features/file-tree/flows/context-menu.test.js +++ b/services/web/test/frontend/features/file-tree/flows/context-menu.test.js @@ -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} diff --git a/services/web/test/frontend/features/file-tree/flows/create-folder.test.js b/services/web/test/frontend/features/file-tree/flows/create-folder.test.js index 55d9a5006c..cd10a2e29f 100644 --- a/services/web/test/frontend/features/file-tree/flows/create-folder.test.js +++ b/services/web/test/frontend/features/file-tree/flows/create-folder.test.js @@ -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 diff --git a/services/web/test/frontend/features/file-tree/flows/delete-entity.test.js b/services/web/test/frontend/features/file-tree/flows/delete-entity.test.js index 3273576a5d..913c96c58b 100644 --- a/services/web/test/frontend/features/file-tree/flows/delete-entity.test.js +++ b/services/web/test/frontend/features/file-tree/flows/delete-entity.test.js @@ -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 diff --git a/services/web/test/frontend/features/file-tree/flows/rename-entity.test.js b/services/web/test/frontend/features/file-tree/flows/rename-entity.test.js index 42200f3ea3..860c1c6306 100644 --- a/services/web/test/frontend/features/file-tree/flows/rename-entity.test.js +++ b/services/web/test/frontend/features/file-tree/flows/rename-entity.test.js @@ -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 diff --git a/services/web/test/frontend/features/file-tree/helpers/render-with-context.js b/services/web/test/frontend/features/file-tree/helpers/render-with-context.js index 42c9e030b9..4dcd249f94 100644 --- a/services/web/test/frontend/features/file-tree/helpers/render-with-context.js +++ b/services/web/test/frontend/features/file-tree/helpers/render-with-context.js @@ -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 } diff --git a/services/web/webpack.config.js b/services/web/webpack.config.js index 10c9c9b2c5..cc5e478c6f 100644 --- a/services/web/webpack.config.js +++ b/services/web/webpack.config.js @@ -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)$/,