From a0fabee3b4342204eb83bd68ffa195491c1faff7 Mon Sep 17 00:00:00 2001 From: Alexandre Bourdin Date: Tue, 13 Sep 2022 15:57:47 +0200 Subject: [PATCH] Merge pull request #9245 from overleaf/integration-project-dashboard-react-migration [Integration branch] Project Dashboard React Migration GitOrigin-RevId: 3c3db39109a8137c57995f5f7c0ff8c800f04c4e --- package-lock.json | 2 + .../Notifications/NotificationsHandler.js | 6 +- .../src/Features/Project/ProjectController.js | 35 +- .../app/src/Features/Project/ProjectHelper.js | 31 +- .../Features/Project/ProjectListController.js | 538 +++++++++++++++ .../web/app/src/Features/Project/types.d.ts | 38 ++ .../SubscriptionViewModelBuilder.js | 6 + .../app/src/Features/Survey/SurveyHandler.js | 8 + services/web/app/src/Features/Tags/types.d.ts | 6 + .../app/src/infrastructure/ExpressLocals.js | 1 + services/web/app/src/router.js | 11 + services/web/app/views/project/list-react.pug | 21 + services/web/app/views/project/list/item.pug | 2 +- services/web/config/settings.defaults.js | 1 + .../web/frontend/extracted-translations.json | 98 ++- .../current-plan-widget/commons-plan.tsx | 30 + .../current-plan-widget.tsx | 60 ++ .../current-plan-widget/free-plan.tsx | 40 ++ .../current-plan-widget/group-plan.tsx | 47 ++ .../current-plan-widget/individual-plan.tsx | 42 ++ .../components/new-project-button.tsx | 59 ++ .../blank-project-modal.tsx | 22 + .../example-project-modal.tsx | 22 + .../modal-content-new-project-form.tsx | 87 +++ .../new-project-button-modal.tsx | 40 ++ .../upload-project-modal.tsx | 116 ++++ .../components/notifications/action.tsx | 9 + .../components/notifications/body.tsx | 9 + .../components/notifications/close.tsx | 21 + .../affiliation/reconfirm-affiliation.tsx | 124 ++++ .../affiliation/reconfirmation-info.tsx | 54 ++ .../notifications/groups/common.tsx | 274 ++++++++ .../notifications/groups/confirm-email.tsx | 117 ++++ .../notifications/groups/institution.tsx | 158 +++++ .../notifications/hooks/useAsyncDismiss.ts | 14 + .../components/notifications/notification.tsx | 49 ++ .../notifications/user-notifications.tsx | 19 + .../components/project-list-root.tsx | 105 +++ .../project-list/components/search-form.tsx | 70 ++ .../components/sidebar/create-tag-modal.tsx | 91 +++ .../components/sidebar/delete-tag-modal.tsx | 78 +++ .../components/sidebar/rename-tag-modal.tsx | 97 +++ .../components/sidebar/sidebar-filters.tsx | 47 ++ .../components/sidebar/tags-list.tsx | 179 +++++ .../project-list/components/survey-widget.tsx | 42 ++ .../action-buttons/archive-project-button.tsx | 84 +++ .../action-buttons/copy-project-button.tsx | 31 + .../action-buttons/delete-project-button.tsx | 34 + .../download-project-button.tsx | 43 ++ .../action-buttons/leave-project-buttton.tsx | 80 +++ .../action-buttons/trash-project-button.tsx | 85 +++ .../unarchive-project-button.tsx | 46 ++ .../action-buttons/untrash-project-button.tsx | 45 ++ .../components/table/cells/actions-cell.tsx | 27 + .../components/table/cells/inline-tags.tsx | 53 ++ .../table/cells/last-updated-cell.tsx | 31 + .../components/table/cells/owner-cell.tsx | 51 ++ .../table/project-list-table-row.tsx | 41 ++ .../components/table/project-list-table.tsx | 180 +++++ .../table/projects-action-modal.tsx | 122 ++++ .../components/welcome-message.tsx | 28 + .../context/project-list-context.tsx | 275 ++++++++ .../js/features/project-list/util/api.ts | 66 ++ .../js/features/project-list/util/project.ts | 14 + .../project-list/util/sort-comparators.ts | 65 ++ .../js/features/project-list/util/user.ts | 21 + .../reconfirmation-info-success.tsx | 14 +- .../web/frontend/js/pages/project-list.js | 18 + .../shared/components/controlled-dropdown.js | 1 + .../frontend/js/shared/components/icon.tsx | 9 +- .../js/shared/components/loading-branded.tsx | 29 + .../web/frontend/js/shared/hooks/use-async.ts | 17 +- services/web/frontend/js/utils/dates.js | 13 + .../current-plan-widget.stories.tsx | 58 ++ .../stories/project-list/helpers/emails.ts | 147 ++++ .../new-project-button.stories.tsx | 92 +++ .../project-list/notifications.stories.tsx | 283 ++++++++ .../project-list/project-list.stories.tsx | 35 + .../project-list/project-search.stories.tsx | 24 + .../project-list/survey-widget.stories.tsx | 32 + .../frontend/stylesheets/_style_includes.less | 1 + .../stylesheets/app/project-list-react.less | 417 ++++++++++++ .../stylesheets/app/project-list.less | 41 +- .../frontend/stylesheets/core/utilities.less | 4 + services/web/locales/en.json | 16 +- services/web/package.json | 2 +- .../components/current-plan-widget.test.tsx | 255 +++++++ .../components/new-project-button.test.tsx | 72 ++ .../modal-content-new-project-form.test.tsx | 150 ++++ .../upload-project-modal.test.tsx | 155 +++++ .../components/notifications.test.tsx | 643 ++++++++++++++++++ .../components/project-search.test.tsx | 106 +++ .../components/sidebar/tags-list.test.tsx | 261 +++++++ .../components/survey-widget.test.tsx | 92 +++ .../archive-project-button.test.tsx | 73 ++ .../copy-project-button.test.tsx | 27 + .../delete-project-button.test.tsx | 30 + .../download-project-button.test.tsx | 46 ++ .../leave-project-button.test.tsx | 80 +++ .../trash-project-button.test.tsx | 63 ++ .../unarchive-project-button.test.tsx | 59 ++ .../untrash-project-button.test.tsx | 55 ++ .../table/project-list-table.test.tsx | 148 ++++ .../table/projects-action-modal.test.tsx | 112 +++ .../fixtures/notifications-data.ts | 25 + .../project-list/fixtures/projects-data.ts | 130 ++++ .../helpers/render-with-context.js | 25 + .../unit/sort-comparators.test.ts | 133 ++++ .../unit/src/Project/ProjectHelperTests.js | 16 - services/web/types/array/sort.ts | 6 + services/web/types/exposed-settings.ts | 6 + services/web/types/project/dashboard/api.d.ts | 50 ++ .../types/project/dashboard/notification.ts | 30 + .../types/project/dashboard/subscription.ts | 36 + .../web/types/project/dashboard/survey.d.ts | 6 + services/web/types/sorting-order.d.ts | 1 + services/web/types/utils.ts | 17 + services/web/types/window.ts | 1 + 118 files changed, 8441 insertions(+), 69 deletions(-) create mode 100644 services/web/app/src/Features/Project/ProjectListController.js create mode 100644 services/web/app/src/Features/Project/types.d.ts create mode 100644 services/web/app/src/Features/Tags/types.d.ts create mode 100644 services/web/app/views/project/list-react.pug create mode 100644 services/web/frontend/js/features/project-list/components/current-plan-widget/commons-plan.tsx create mode 100644 services/web/frontend/js/features/project-list/components/current-plan-widget/current-plan-widget.tsx create mode 100644 services/web/frontend/js/features/project-list/components/current-plan-widget/free-plan.tsx create mode 100644 services/web/frontend/js/features/project-list/components/current-plan-widget/group-plan.tsx create mode 100644 services/web/frontend/js/features/project-list/components/current-plan-widget/individual-plan.tsx create mode 100644 services/web/frontend/js/features/project-list/components/new-project-button.tsx create mode 100644 services/web/frontend/js/features/project-list/components/new-project-button/blank-project-modal.tsx create mode 100644 services/web/frontend/js/features/project-list/components/new-project-button/example-project-modal.tsx create mode 100644 services/web/frontend/js/features/project-list/components/new-project-button/modal-content-new-project-form.tsx create mode 100644 services/web/frontend/js/features/project-list/components/new-project-button/new-project-button-modal.tsx create mode 100644 services/web/frontend/js/features/project-list/components/new-project-button/upload-project-modal.tsx create mode 100644 services/web/frontend/js/features/project-list/components/notifications/action.tsx create mode 100644 services/web/frontend/js/features/project-list/components/notifications/body.tsx create mode 100644 services/web/frontend/js/features/project-list/components/notifications/close.tsx create mode 100644 services/web/frontend/js/features/project-list/components/notifications/groups/affiliation/reconfirm-affiliation.tsx create mode 100644 services/web/frontend/js/features/project-list/components/notifications/groups/affiliation/reconfirmation-info.tsx create mode 100644 services/web/frontend/js/features/project-list/components/notifications/groups/common.tsx create mode 100644 services/web/frontend/js/features/project-list/components/notifications/groups/confirm-email.tsx create mode 100644 services/web/frontend/js/features/project-list/components/notifications/groups/institution.tsx create mode 100644 services/web/frontend/js/features/project-list/components/notifications/hooks/useAsyncDismiss.ts create mode 100644 services/web/frontend/js/features/project-list/components/notifications/notification.tsx create mode 100644 services/web/frontend/js/features/project-list/components/notifications/user-notifications.tsx create mode 100644 services/web/frontend/js/features/project-list/components/project-list-root.tsx create mode 100644 services/web/frontend/js/features/project-list/components/search-form.tsx create mode 100644 services/web/frontend/js/features/project-list/components/sidebar/create-tag-modal.tsx create mode 100644 services/web/frontend/js/features/project-list/components/sidebar/delete-tag-modal.tsx create mode 100644 services/web/frontend/js/features/project-list/components/sidebar/rename-tag-modal.tsx create mode 100644 services/web/frontend/js/features/project-list/components/sidebar/sidebar-filters.tsx create mode 100644 services/web/frontend/js/features/project-list/components/sidebar/tags-list.tsx create mode 100644 services/web/frontend/js/features/project-list/components/survey-widget.tsx create mode 100644 services/web/frontend/js/features/project-list/components/table/cells/action-buttons/archive-project-button.tsx create mode 100644 services/web/frontend/js/features/project-list/components/table/cells/action-buttons/copy-project-button.tsx create mode 100644 services/web/frontend/js/features/project-list/components/table/cells/action-buttons/delete-project-button.tsx create mode 100644 services/web/frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button.tsx create mode 100644 services/web/frontend/js/features/project-list/components/table/cells/action-buttons/leave-project-buttton.tsx create mode 100644 services/web/frontend/js/features/project-list/components/table/cells/action-buttons/trash-project-button.tsx create mode 100644 services/web/frontend/js/features/project-list/components/table/cells/action-buttons/unarchive-project-button.tsx create mode 100644 services/web/frontend/js/features/project-list/components/table/cells/action-buttons/untrash-project-button.tsx create mode 100644 services/web/frontend/js/features/project-list/components/table/cells/actions-cell.tsx create mode 100644 services/web/frontend/js/features/project-list/components/table/cells/inline-tags.tsx create mode 100644 services/web/frontend/js/features/project-list/components/table/cells/last-updated-cell.tsx create mode 100644 services/web/frontend/js/features/project-list/components/table/cells/owner-cell.tsx create mode 100644 services/web/frontend/js/features/project-list/components/table/project-list-table-row.tsx create mode 100644 services/web/frontend/js/features/project-list/components/table/project-list-table.tsx create mode 100644 services/web/frontend/js/features/project-list/components/table/projects-action-modal.tsx create mode 100644 services/web/frontend/js/features/project-list/components/welcome-message.tsx create mode 100644 services/web/frontend/js/features/project-list/context/project-list-context.tsx create mode 100644 services/web/frontend/js/features/project-list/util/api.ts create mode 100644 services/web/frontend/js/features/project-list/util/project.ts create mode 100644 services/web/frontend/js/features/project-list/util/sort-comparators.ts create mode 100644 services/web/frontend/js/features/project-list/util/user.ts create mode 100644 services/web/frontend/js/pages/project-list.js create mode 100644 services/web/frontend/js/shared/components/loading-branded.tsx create mode 100644 services/web/frontend/js/utils/dates.js create mode 100644 services/web/frontend/stories/project-list/current-plan-widget.stories.tsx create mode 100644 services/web/frontend/stories/project-list/helpers/emails.ts create mode 100644 services/web/frontend/stories/project-list/new-project-button.stories.tsx create mode 100644 services/web/frontend/stories/project-list/notifications.stories.tsx create mode 100644 services/web/frontend/stories/project-list/project-list.stories.tsx create mode 100644 services/web/frontend/stories/project-list/project-search.stories.tsx create mode 100644 services/web/frontend/stories/project-list/survey-widget.stories.tsx create mode 100644 services/web/frontend/stylesheets/app/project-list-react.less create mode 100644 services/web/test/frontend/features/project-list/components/current-plan-widget.test.tsx create mode 100644 services/web/test/frontend/features/project-list/components/new-project-button.test.tsx create mode 100644 services/web/test/frontend/features/project-list/components/new-project-button/modal-content-new-project-form.test.tsx create mode 100644 services/web/test/frontend/features/project-list/components/new-project-button/upload-project-modal.test.tsx create mode 100644 services/web/test/frontend/features/project-list/components/notifications.test.tsx create mode 100644 services/web/test/frontend/features/project-list/components/project-search.test.tsx create mode 100644 services/web/test/frontend/features/project-list/components/sidebar/tags-list.test.tsx create mode 100644 services/web/test/frontend/features/project-list/components/survey-widget.test.tsx create mode 100644 services/web/test/frontend/features/project-list/components/table/cells/action-buttons/archive-project-button.test.tsx create mode 100644 services/web/test/frontend/features/project-list/components/table/cells/action-buttons/copy-project-button.test.tsx create mode 100644 services/web/test/frontend/features/project-list/components/table/cells/action-buttons/delete-project-button.test.tsx create mode 100644 services/web/test/frontend/features/project-list/components/table/cells/action-buttons/download-project-button.test.tsx create mode 100644 services/web/test/frontend/features/project-list/components/table/cells/action-buttons/leave-project-button.test.tsx create mode 100644 services/web/test/frontend/features/project-list/components/table/cells/action-buttons/trash-project-button.test.tsx create mode 100644 services/web/test/frontend/features/project-list/components/table/cells/action-buttons/unarchive-project-button.test.tsx create mode 100644 services/web/test/frontend/features/project-list/components/table/cells/action-buttons/untrash-project-button.test.tsx create mode 100644 services/web/test/frontend/features/project-list/components/table/project-list-table.test.tsx create mode 100644 services/web/test/frontend/features/project-list/components/table/projects-action-modal.test.tsx create mode 100644 services/web/test/frontend/features/project-list/fixtures/notifications-data.ts create mode 100644 services/web/test/frontend/features/project-list/fixtures/projects-data.ts create mode 100644 services/web/test/frontend/features/project-list/helpers/render-with-context.js create mode 100644 services/web/test/frontend/features/project-list/unit/sort-comparators.test.ts create mode 100644 services/web/types/array/sort.ts create mode 100644 services/web/types/project/dashboard/api.d.ts create mode 100644 services/web/types/project/dashboard/notification.ts create mode 100644 services/web/types/project/dashboard/subscription.ts create mode 100644 services/web/types/project/dashboard/survey.d.ts create mode 100644 services/web/types/sorting-order.d.ts diff --git a/package-lock.json b/package-lock.json index f5c007472a..3347f94885 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34846,6 +34846,7 @@ "@testing-library/user-event": "^14.2.0", "@types/chai": "^4.3.0", "@types/events": "^3.0.0", + "@types/express": "^4.17.13", "@types/mocha": "^9.1.0", "@types/react": "^17.0.40", "@types/react-bootstrap": "^0.32.29", @@ -42281,6 +42282,7 @@ "@testing-library/user-event": "^14.2.0", "@types/chai": "^4.3.0", "@types/events": "^3.0.0", + "@types/express": "*", "@types/mocha": "^9.1.0", "@types/react": "^17.0.40", "@types/react-bootstrap": "^0.32.29", diff --git a/services/web/app/src/Features/Notifications/NotificationsHandler.js b/services/web/app/src/Features/Notifications/NotificationsHandler.js index f6415f43b8..b704652e6a 100644 --- a/services/web/app/src/Features/Notifications/NotificationsHandler.js +++ b/services/web/app/src/Features/Notifications/NotificationsHandler.js @@ -2,6 +2,7 @@ const settings = require('@overleaf/settings') const request = require('request') const logger = require('@overleaf/logger') const _ = require('lodash') +const { promisifyAll } = require('../../util/promises') const notificationsApi = _.get(settings, ['apis', 'notifications', 'url']) const oneSecond = 1000 @@ -14,7 +15,7 @@ const makeRequest = function (opts, callback) { } } -module.exports = { +const NotificationsHandler = { getUserNotifications(userId, callback) { const opts = { uri: `${notificationsApi}/user/${userId}`, @@ -136,3 +137,6 @@ module.exports = { }) }, } + +NotificationsHandler.promises = promisifyAll(NotificationsHandler) +module.exports = NotificationsHandler diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index 34df7e60be..5978072fe5 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -43,6 +43,14 @@ const { hasAdminAccess } = require('../Helpers/AdminAuthorizationHelper') const InstitutionsFeatures = require('../Institutions/InstitutionsFeatures') const SubscriptionViewModelBuilder = require('../Subscription/SubscriptionViewModelBuilder') const SurveyHandler = require('../Survey/SurveyHandler') +const { expressify } = require('../../util/promises') +const ProjectListController = require('./ProjectListController') + +/** + * @typedef {import("./types").GetProjectsRequest} GetProjectsRequest + * @typedef {import("./types").GetProjectsResponse} GetProjectsResponse + * @typedef {import("./types").Project} Project + */ const _ssoAvailable = (affiliation, session, linkedInstitutionIds) => { if (!affiliation.institution) return false @@ -373,7 +381,28 @@ const ProjectController = { }) }, - projectListPage(req, res, next) { + async projectListPage(req, res, next) { + try { + const assignment = await SplitTestHandler.promises.getAssignment( + req, + res, + 'project-dashboard-react' + ) + if (assignment.variant === 'enabled') { + ProjectListController.projectListReactPage(req, res, next) + } else { + ProjectController._projectListAngularPage(req, res, next) + } + } catch (error) { + logger.warn( + { err: error }, + 'failed to get "project-dashboard-react" split test assignment' + ) + ProjectController._projectListAngularPage(req, res, next) + } + }, + + _projectListAngularPage(req, res, next) { const timer = new metrics.Timer('project-list') const userId = SessionManager.getLoggedInUserId(req.session) const currentUser = SessionManager.getSessionUser(req.session) @@ -1511,4 +1540,8 @@ const LEGACY_THEME_LIST = [ 'xcode', ] +ProjectController.projectListPage = expressify( + ProjectController.projectListPage +) + module.exports = ProjectController diff --git a/services/web/app/src/Features/Project/ProjectHelper.js b/services/web/app/src/Features/Project/ProjectHelper.js index d52df085af..2d1ac2ef35 100644 --- a/services/web/app/src/Features/Project/ProjectHelper.js +++ b/services/web/app/src/Features/Project/ProjectHelper.js @@ -3,6 +3,10 @@ const _ = require('lodash') const { promisify } = require('util') const Settings = require('@overleaf/settings') +/** + * @typedef {import("./types").MongoProject} MongoProject + */ + const ENGINE_TO_COMPILER_MAP = { latex_dvipdf: 'latex', pdflatex: 'pdflatex', @@ -27,26 +31,33 @@ function compilerFromV1Engine(engine) { return ENGINE_TO_COMPILER_MAP[engine] } +/** + @param {MongoProject} project + @param {string} userId + * @returns {boolean} + */ function isArchived(project, userId) { userId = ObjectId(userId) - if (Array.isArray(project.archived)) { - return project.archived.some(id => id.equals(userId)) - } else { - return !!project.archived - } + return (project.archived || []).some(id => id.equals(userId)) } +/** + * @param {MongoProject} project + * @param {string} userId + * @returns {boolean} + */ function isTrashed(project, userId) { userId = ObjectId(userId) - if (project.trashed) { - return project.trashed.some(id => id.equals(userId)) - } else { - return false - } + return (project.trashed || []).some(id => id.equals(userId)) } +/** + * @param {MongoProject} project + * @param {string} userId + * @returns {boolean} + */ function isArchivedOrTrashed(project, userId) { return isArchived(project, userId) || isTrashed(project, userId) } diff --git a/services/web/app/src/Features/Project/ProjectListController.js b/services/web/app/src/Features/Project/ProjectListController.js new file mode 100644 index 0000000000..cf86717932 --- /dev/null +++ b/services/web/app/src/Features/Project/ProjectListController.js @@ -0,0 +1,538 @@ +const _ = require('lodash') +const ProjectHelper = require('./ProjectHelper') +const ProjectGetter = require('./ProjectGetter') +const PrivilegeLevels = require('../Authorization/PrivilegeLevels') +const SessionManager = require('../Authentication/SessionManager') +const Sources = require('../Authorization/Sources') +const UserGetter = require('../User/UserGetter') +const SurveyHandler = require('../Survey/SurveyHandler') +const TagsHandler = require('../Tags/TagsHandler') +const { expressify } = require('../../util/promises') +const logger = require('@overleaf/logger') +const Features = require('../../infrastructure/Features') +const SubscriptionViewModelBuilder = require('../Subscription/SubscriptionViewModelBuilder') +const NotificationsHandler = require('../Notifications/NotificationsHandler') +const Modules = require('../../infrastructure/Modules') +const { OError, V1ConnectionError } = require('../Errors/Errors') +const { User } = require('../../models/User') +const SplitTestHandler = require('../SplitTests/SplitTestHandler') +const UserPrimaryEmailCheckHandler = require('../User/UserPrimaryEmailCheckHandler') +const UserController = require('../User/UserController') + +const _ssoAvailable = (affiliation, session, linkedInstitutionIds) => { + if (!affiliation.institution) return false + + // institution.confirmed is for the domain being confirmed, not the email + // Do not show SSO UI for unconfirmed domains + if (!affiliation.institution.confirmed) return false + + // Could have multiple emails at the same institution, and if any are + // linked to the institution then do not show notification for others + if ( + linkedInstitutionIds.indexOf(affiliation.institution.id.toString()) === -1 + ) { + if (affiliation.institution.ssoEnabled) return true + if (affiliation.institution.ssoBeta && session.samlBeta) return true + return false + } + return false +} + +/** @typedef {import("./types").GetProjectsRequest} GetProjectsRequest */ +/** @typedef {import("./types").GetProjectsResponse} GetProjectsResponse */ +/** @typedef {import("../../../../types/project/dashboard/api").Project} Project */ +/** @typedef {import("../../../../types/project/dashboard/api").Filters} Filters */ +/** @typedef {import("../../../../types/project/dashboard/api").Page} Page */ +/** @typedef {import("../../../../types/project/dashboard/api").Sort} Sort */ +/** @typedef {import("./types").AllUsersProjects} AllUsersProjects */ +/** @typedef {import("./types").MongoProject} MongoProject */ + +/** @typedef {import("../Tags/types").Tag} Tag */ + +/** + * @param {import("express").Request} req + * @param {import("express").Response} res + * @param {import("express").NextFunction} next + * @returns {Promise} + */ +async function projectListReactPage(req, res, next) { + // can have two values: + // - undefined - when there's no "saas" feature or couldn't get subscription data + // - object - the subscription data object + let usersBestSubscription + let survey + + const userId = SessionManager.getLoggedInUserId(req.session) + const user = await User.findById( + userId, + 'email emails features lastPrimaryEmailCheck signUpDate' + ) + + // Handle case of deleted user + if (user == null) { + UserController.logout(req, res, next) + return + } + + if (Features.hasFeature('saas')) { + try { + usersBestSubscription = + await SubscriptionViewModelBuilder.promises.getBestSubscription({ + _id: userId, + }) + } catch (error) { + logger.err( + { err: error, userId }, + "Failed to get user's best subscription" + ) + } + + try { + survey = await SurveyHandler.promises.getSurvey(userId) + } catch (error) { + logger.err({ err: error, userId }, 'Failed to load the active survey') + } + + try { + const assignment = await SplitTestHandler.promises.getAssignment( + req, + res, + 'primary-email-check' + ) + const primaryEmailCheckActive = assignment.variant === 'active' + + if ( + user && + primaryEmailCheckActive && + UserPrimaryEmailCheckHandler.requiresPrimaryEmailCheck(user) + ) { + return res.redirect('/user/emails/primary-email-check') + } + } catch (error) { + logger.warn( + { err: error }, + 'failed to get "primary-email-check" split test assignment' + ) + } + } + + const tags = await TagsHandler.promises.getAllTags(userId) + + let userEmailsData = { list: [], allInReconfirmNotificationPeriods: [] } + + try { + const fullEmails = await UserGetter.promises.getUserFullEmails(userId) + + if (!Features.hasFeature('affiliations')) { + userEmailsData.list = fullEmails + } else { + try { + const results = await Modules.promises.hooks.fire( + 'allInReconfirmNotificationPeriodsForUser', + fullEmails + ) + + const allInReconfirmNotificationPeriods = (results && results[0]) || [] + + userEmailsData = { + list: fullEmails, + allInReconfirmNotificationPeriods, + } + } catch (error) { + userEmailsData = error + } + } + } catch (error) { + if (!(error instanceof V1ConnectionError)) { + logger.error({ err: error, userId }, 'Failed to get user full emails') + } + } + + const userEmails = userEmailsData.list || [] + + const userAffiliations = userEmails + .filter(emailData => !!emailData.affiliation) + .map(emailData => { + const result = emailData.affiliation + result.email = emailData.email + return result + }) + + const { allInReconfirmNotificationPeriods } = userEmailsData + + const notifications = + await NotificationsHandler.promises.getUserNotifications(userId) + + for (const notification of notifications) { + notification.html = req.i18n.translate( + notification.templateKey, + notification.messageOpts + ) + } + + const notificationsInstitution = [] + // Institution SSO Notifications + let reconfirmedViaSAML + if (Features.hasFeature('saml')) { + reconfirmedViaSAML = _.get(req.session, ['saml', 'reconfirmed']) + const samlSession = req.session.saml + // Notification: SSO Available + const linkedInstitutionIds = [] + userEmails.forEach(email => { + if (email.samlProviderId) { + linkedInstitutionIds.push(email.samlProviderId) + } + }) + if (Array.isArray(userAffiliations)) { + userAffiliations.forEach(affiliation => { + if (_ssoAvailable(affiliation, req.session, linkedInstitutionIds)) { + notificationsInstitution.push({ + email: affiliation.email, + institutionId: affiliation.institution.id, + institutionName: affiliation.institution.name, + templateKey: 'notification_institution_sso_available', + }) + } + }) + } + + if (samlSession) { + // Notification: After SSO Linked + if (samlSession.linked) { + notificationsInstitution.push({ + email: samlSession.institutionEmail, + institutionName: samlSession.linked.universityName, + templateKey: 'notification_institution_sso_linked', + }) + } + + // Notification: After SSO Linked or Logging in + // The requested email does not match primary email returned from + // the institution + if ( + samlSession.requestedEmail && + samlSession.emailNonCanonical && + !samlSession.error + ) { + notificationsInstitution.push({ + institutionEmail: samlSession.emailNonCanonical, + requestedEmail: samlSession.requestedEmail, + templateKey: 'notification_institution_sso_non_canonical', + }) + } + + // Notification: Tried to register, but account already existed + // registerIntercept is set before the institution callback. + // institutionEmail is set after institution callback. + // Check for both in case SSO flow was abandoned + if ( + samlSession.registerIntercept && + samlSession.institutionEmail && + !samlSession.error + ) { + notificationsInstitution.push({ + email: samlSession.institutionEmail, + templateKey: 'notification_institution_sso_already_registered', + }) + } + + // Notification: When there is a session error + if (samlSession.error) { + notificationsInstitution.push({ + templateKey: 'notification_institution_sso_error', + error: samlSession.error, + }) + } + } + delete req.session.saml + } + + res.render('project/list-react', { + title: 'your_projects', + usersBestSubscription, + notifications, + notificationsInstitution, + user, + userEmails, + reconfirmedViaSAML, + allInReconfirmNotificationPeriods, + survey, + tags, + }) +} + +/** + * Load user's projects with pagination, sorting and filters + * + * @param {GetProjectsRequest} req the request + * @param {GetProjectsResponse} res the response + * @returns {Promise} + */ +async function getProjectsJson(req, res) { + const { filters, page, sort } = req.body + const userId = SessionManager.getLoggedInUserId(req.session) + const projectsPage = await _getProjects(userId, filters, sort, page) + res.json(projectsPage) +} + +/** + * @param {string} userId + * @param {Filters} filters + * @param {Sort} sort + * @param {Page} page + * @returns {Promise<{totalSize: number, projects: Project[]}>} + * @private + */ +async function _getProjects( + userId, + filters = {}, + sort = { by: 'lastUpdated', order: 'desc' }, + page = { size: 20 } +) { + const allProjects = + /** @type {AllUsersProjects} **/ await ProjectGetter.promises.findAllUsersProjects( + userId, + 'name lastUpdated lastUpdatedBy publicAccesLevel archived trashed owner_ref tokens' + ) + const tags = /** @type {Tag[]} **/ await TagsHandler.promises.getAllTags( + userId + ) + const formattedProjects = _formatProjects(allProjects, userId) + const filteredProjects = _applyFilters( + formattedProjects, + tags, + filters, + userId + ) + const pagedProjects = _sortAndPaginate(filteredProjects, sort, page) + + await _injectProjectUsers(pagedProjects) + + return { + totalSize: filteredProjects.length, + projects: pagedProjects, + } +} + +/** + * @param {AllUsersProjects} projects + * @param {string} userId + * @returns {Project[]} + * @private + */ +function _formatProjects(projects, userId) { + const { owned, readAndWrite, readOnly, tokenReadAndWrite, tokenReadOnly } = + projects + + const formattedProjects = /** @type {Project[]} **/ [] + for (const project of owned) { + formattedProjects.push( + _formatProjectInfo(project, 'owner', Sources.OWNER, userId) + ) + } + // Invite-access + for (const project of readAndWrite) { + formattedProjects.push( + _formatProjectInfo(project, 'readWrite', Sources.INVITE, userId) + ) + } + for (const project of readOnly) { + formattedProjects.push( + _formatProjectInfo(project, 'readOnly', Sources.INVITE, userId) + ) + } + // Token-access + // Only add these formattedProjects if they're not already present, this gives us cascading access + // from 'owner' => 'token-read-only' + for (const project of tokenReadAndWrite) { + if (!_.find(formattedProjects, ['id', project._id.toString()])) { + formattedProjects.push( + _formatProjectInfo(project, 'readAndWrite', Sources.TOKEN, userId) + ) + } + } + for (const project of tokenReadOnly) { + if (!_.find(formattedProjects, ['id', project._id.toString()])) { + formattedProjects.push( + _formatProjectInfo(project, 'readOnly', Sources.TOKEN, userId) + ) + } + } + + return formattedProjects +} + +/** + * @param {Project[]} projects + * @param {Tag[]} tags + * @param {Filters} filters + * @param {string} userId + * @returns {Project[]} + * @private + */ +function _applyFilters(projects, tags, filters, userId) { + if (!_hasActiveFilter(filters)) { + return projects + } + return projects.filter(project => _matchesFilters(project, tags, filters)) +} + +/** + * @param {Project[]} projects + * @param {Sort} sort + * @param {Page} page + * @returns {Project[]} + * @private + */ +function _sortAndPaginate(projects, sort, page) { + if ( + (sort.by && !['lastUpdated', 'title', 'owner'].includes(sort.by)) || + (sort.order && !['asc', 'desc'].includes(sort.order)) + ) { + throw new OError('Invalid sorting criteria', { sort }) + } + const sortedProjects = _.orderBy( + projects, + [sort.by || 'lastUpdated'], + [sort.order || 'desc'] + ) + // TODO handle pagination + return sortedProjects +} + +/** + * @param {MongoProject} project + * @param {string} accessLevel + * @param {'owner' | 'invite' | 'token'} source + * @param {string} userId + * @returns {object} + * @private + */ +function _formatProjectInfo(project, accessLevel, source, userId) { + const archived = ProjectHelper.isArchived(project, userId) + // If a project is simultaneously trashed and archived, we will consider it archived but not trashed. + const trashed = ProjectHelper.isTrashed(project, userId) && !archived + + const model = { + id: project._id, + name: project.name, + owner_ref: project.owner_ref, + lastUpdated: project.lastUpdated, + lastUpdatedBy: project.lastUpdatedBy, + accessLevel, + source, + archived, + trashed, + } + if (accessLevel === PrivilegeLevels.READ_ONLY && source === Sources.TOKEN) { + model.owner_ref = null + model.lastUpdatedBy = null + } + return model +} + +/** + * @param {Project[]} projects + * @returns {Promise} + * @private + */ +async function _injectProjectUsers(projects) { + const userIds = new Set() + for (const project of projects) { + if (project.owner_ref != null) { + userIds.add(project.owner_ref.toString()) + } + if (project.lastUpdatedBy != null) { + userIds.add(project.lastUpdatedBy.toString()) + } + } + + const users = {} + for (const userId of userIds) { + const { + email, + first_name: firstName, + last_name: lastName, + } = await UserGetter.promises.getUser(userId, { + first_name: 1, + last_name: 1, + email: 1, + }) + users[userId] = { + id: userId, + email, + firstName, + lastName, + } + } + for (const project of projects) { + if (project.owner_ref != null) { + project.owner = users[project.owner_ref.toString()] + } + if (project.lastUpdatedBy != null) { + project.lastUpdatedBy = users[project.lastUpdatedBy.toString()] || null + } + + delete project.owner_ref + } +} + +/** + * @param {any} project + * @param {Tag[]} tags + * @param {Filters} filters + * @private + */ +function _matchesFilters(project, tags, filters) { + if (filters.ownedByUser && project.accessLevel !== 'owner') { + return false + } + if (filters.sharedWithUser && project.accessLevel === 'owner') { + return false + } + if (filters.archived && !project.archived) { + return false + } + if (filters.trashed && !project.trashed) { + return false + } + if ( + filters.tag && + !_.find( + tags, + tag => + filters.tag === tag.name && (tag.project_ids || []).includes(project.id) + ) + ) { + return false + } + if ( + filters.search?.length && + project.name.toLowerCase().indexOf(filters.search.toLowerCase()) === -1 + ) { + return false + } + return true +} + +/** + * @param {Filters} filters + * @returns {boolean} + * @private + */ +function _hasActiveFilter(filters) { + return ( + filters.ownedByUser || + filters.sharedWithUser || + filters.archived || + filters.trashed || + filters.tag === null || + filters.tag?.length || + filters.search?.length + ) +} + +module.exports = { + projectListReactPage: expressify(projectListReactPage), + getProjectsJson: expressify(getProjectsJson), +} diff --git a/services/web/app/src/Features/Project/types.d.ts b/services/web/app/src/Features/Project/types.d.ts new file mode 100644 index 0000000000..c59f55163c --- /dev/null +++ b/services/web/app/src/Features/Project/types.d.ts @@ -0,0 +1,38 @@ +import express from 'express' +import { + GetProjectsRequestBody, + GetProjectsResponseBody, +} from '../../../../types/project/dashboard/api' + +export type GetProjectsRequest = express.Request< + unknown, + unknown, + GetProjectsRequestBody, + unknown +> + +export type GetProjectsResponse = express.Response + +export type MongoProject = { + _id: string + name: string + lastUpdated: Date + lastUpdatedBy: string + publicAccesLevel: string + archived: string[] + trashed: boolean + owner_ref: string + tokens: { + readOnly: string[] + readAndWrite: string[] + readAndWritePrefix: string[] + }[] +} + +export type AllUsersProjects = { + owned: MongoProject[] + readAndWrite: MongoProject[] + readOnly: MongoProject[] + tokenReadAndWrite: MongoProject[] + tokenReadOnly: MongoProject[] +} diff --git a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js index f1a55f3f05..934179a7f7 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js +++ b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js @@ -19,6 +19,8 @@ const { } = require('../Errors/Errors') const FeaturesHelper = require('./FeaturesHelper') +/** @typedef {import("../../../../types/project/dashboard/subscription").Subscription} Subscription */ + function buildHostedLink(type) { return `/user/subscription/recurly/${type}` } @@ -334,6 +336,10 @@ function buildUsersSubscriptionViewModel(user, callback) { ) } +/** + * @param {{_id: string}} user + * @returns {Promise} + */ async function getBestSubscription(user) { let [ individualSubscription, diff --git a/services/web/app/src/Features/Survey/SurveyHandler.js b/services/web/app/src/Features/Survey/SurveyHandler.js index 561b211c11..81b3f519c0 100644 --- a/services/web/app/src/Features/Survey/SurveyHandler.js +++ b/services/web/app/src/Features/Survey/SurveyHandler.js @@ -2,6 +2,14 @@ const SurveyCache = require('./SurveyCache') const SubscriptionLocator = require('../Subscription/SubscriptionLocator') const { callbackify } = require('../../util/promises') +/** + * @typedef {import('../../../../types/project/dashboard/survey').Survey} Survey + */ + +/** + * @param {string} userId + * @returns {Promise} + */ async function getSurvey(userId) { const survey = await SurveyCache.get(true) if (survey) { diff --git a/services/web/app/src/Features/Tags/types.d.ts b/services/web/app/src/Features/Tags/types.d.ts new file mode 100644 index 0000000000..89d00e7487 --- /dev/null +++ b/services/web/app/src/Features/Tags/types.d.ts @@ -0,0 +1,6 @@ +export type Tag = { + _id: string + user_id: string + name: string + project_ids?: string[] +} diff --git a/services/web/app/src/infrastructure/ExpressLocals.js b/services/web/app/src/infrastructure/ExpressLocals.js index d00830a4ff..1d6b8089bc 100644 --- a/services/web/app/src/infrastructure/ExpressLocals.js +++ b/services/web/app/src/infrastructure/ExpressLocals.js @@ -410,6 +410,7 @@ module.exports = function (webRouter, privateApiRouter, publicApiRouter) { Settings.analytics.ga && Settings.analytics.ga.tokenV4, cookieDomain: Settings.cookieDomain, + templateLinks: Settings.templateLinks, } next() }) diff --git a/services/web/app/src/router.js b/services/web/app/src/router.js index edd3931308..898eeb62d7 100644 --- a/services/web/app/src/router.js +++ b/services/web/app/src/router.js @@ -2,6 +2,7 @@ const AdminController = require('./Features/ServerAdmin/AdminController') const ErrorController = require('./Features/Errors/ErrorController') const ProjectController = require('./Features/Project/ProjectController') const ProjectApiController = require('./Features/Project/ProjectApiController') +const ProjectListController = require('./Features/Project/ProjectListController') const SpellingController = require('./Features/Spelling/SpellingController') const EditorRouter = require('./Features/Editor/EditorRouter') const Settings = require('@overleaf/settings') @@ -358,6 +359,16 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) { }), ProjectController.newProject ) + webRouter.post( + '/api/project', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit({ + endpointName: 'get-projects', + maxRequests: 30, + timeInterval: 60, + }), + ProjectListController.getProjectsJson + ) for (const route of [ // Keep the old route for continuous metrics diff --git a/services/web/app/views/project/list-react.pug b/services/web/app/views/project/list-react.pug new file mode 100644 index 0000000000..9ee7e4004d --- /dev/null +++ b/services/web/app/views/project/list-react.pug @@ -0,0 +1,21 @@ +extends ../layout-marketing + +block entrypointVar + - entrypoint = 'pages/project-list' + +block vars + - var suppressNavContentLinks = true + +block append meta + meta(name="ol-usersBestSubscription" data-type="json" content=usersBestSubscription) + meta(name="ol-notifications" data-type="json" content=notifications) + meta(name="ol-notificationsInstitution" data-type="json" content=notificationsInstitution) + meta(name="ol-userEmails" data-type="json" content=userEmails) + meta(name="ol-allInReconfirmNotificationPeriods" data-type="json" content=allInReconfirmNotificationPeriods) + meta(name="ol-user" data-type="json" content=user) + meta(name="ol-reconfirmedViaSAML" content=reconfirmedViaSAML) + meta(name="ol-survey" data-type="json" content=survey) + meta(name="ol-tags" data-type="json" content=tags) + +block content + main.content.content-alt.project-list-react#project-list-root diff --git a/services/web/app/views/project/list/item.pug b/services/web/app/views/project/list/item.pug index d82a84e97c..f325f0c166 100644 --- a/services/web/app/views/project/list/item.pug +++ b/services/web/app/views/project/list/item.pug @@ -5,7 +5,7 @@ td.project-list-table-name-cell type="checkbox", ng-model="project.selected" stop-propagation="click" - aria-label=translate('select_project') + " '{{ project.name }}'" + aria-label=translate('select_project', {project: '{{ project.name }}'}) ) span.project-list-table-name a.project-list-table-name-link( diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 6e4a495814..e3c9f16b45 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -783,6 +783,7 @@ module.exports = { sourceEditorCompletionSources: [], integrationLinkingWidgets: [], referenceLinkingWidgets: [], + importProjectFromGithubModalWrapper: [], }, moduleImportSequence: ['launchpad', 'server-ce-scripts', 'user-activate'], diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index be3bf517f5..fca858fcdd 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -1,19 +1,32 @@ { + "about_to_archive_projects": "", + "about_to_delete_folder": "", + "about_to_leave_projects": "", + "about_to_trash_projects": "", "access_denied": "", "access_your_projects_with_git": "", + "account_has_been_link_to_institution_account": "", "account_not_linked_to_dropbox": "", "account_settings": "", "acct_linked_to_institution_acct_2": "", + "actions": "", + "add_affiliation": "", "add_another_email": "", + "add_email_to_claim_features": "", "add_files": "", "add_new_email": "", "add_role_and_department": "", + "all_projects": "", "also": "", "anyone_with_link_can_edit": "", "anyone_with_link_can_view": "", "approaching_compile_timeout_limit_upgrade_for_more_compile_time": "", - "archived": "", + "archive": "", + "archive_projects": "", + "archived_projects": "", + "archiving_projects_wont_affect_collaborators": "", "are_you_still_at": "", + "ascending": "", "ask_proj_owner_to_upgrade_for_git_bridge": "", "ask_proj_owner_to_upgrade_for_longer_compiles": "", "ask_proj_owner_to_upgrade_for_references_search": "", @@ -26,13 +39,17 @@ "beta_program_already_participating": "", "beta_program_benefits": "<0>", "beta_program_not_participating": "", + "blank_project": "", "blocked_filename": "", "can_edit": "", + "can_link_institution_email_acct_to_institution_acct": "", "can_link_your_institution_acct_2": "", + "can_now_relink_dropbox": "", "cancel": "", "cannot_invite_non_user": "", "cannot_invite_self": "", "cannot_verify_user_not_robot": "", + "cant_see_what_youre_looking_for_question": "", "category_arrows": "", "category_greek": "", "category_misc": "", @@ -51,6 +68,7 @@ "checking_dropbox_status": "", "checking_project_github_status": "", "clear_cached_files": "", + "clear_search": "", "clone_with_git": "", "close": "", "clsi_maintenance": "", @@ -62,13 +80,16 @@ "collapse": "", "commit": "", "common": "", + "commons_plan_tooltip": "", "compile_error_entry_description": "", "compile_error_handling": "", "compile_larger_projects": "", "compile_mode": "", "compile_terminated_by_user": "", "compiling": "", + "confirm": "", "confirm_affiliation": "", + "confirm_affiliation_to_relink_dropbox": "", "confirm_new_password": "", "conflicting_paths_found": "", "connected_users": "", @@ -82,6 +103,8 @@ "copying": "", "country": "", "create": "", + "create_first_project": "", + "create_new_folder": "", "create_project_in_github": "", "created_at": "", "creating": "", @@ -91,15 +114,19 @@ "delete_account_confirmation_label": "", "delete_account_warning_message_3": "", "delete_acct_no_existing_pw": "", + "delete_folder": "", "delete_your_account": "", "deleting": "", "demonstrating_git_integration": "", "department": "", + "descending": "", "description": "", + "did_you_know_institution_providing_professional": "", "disable_stop_on_first_error": "", "dismiss": "", "dismiss_error_popup": "", "doesnt_match": "", + "doing_this_allow_log_in_through_institution": "", "doing_this_allow_log_in_through_institution_2": "", "doing_this_will_verify_affiliation_and_allow_log_in_2": "", "done": "", @@ -107,6 +134,8 @@ "download_pdf": "", "drag_here": "", "dropbox_checking_sync_status": "", + "dropbox_duplicate_project_names": "", + "dropbox_duplicate_project_names_suggestion": "", "dropbox_for_link_share_projs": "", "dropbox_sync": "", "dropbox_sync_both": "", @@ -115,6 +144,7 @@ "dropbox_sync_in": "", "dropbox_sync_out": "", "dropbox_synced": "", + "dropbox_unlinked_premium_feature": "", "duplicate_file": "", "duplicate_projects": "", "easily_manage_your_project_files_everywhere": "", @@ -130,6 +160,7 @@ "emails_and_affiliations_title": "", "error": "", "error_performing_request": "", + "example_project": "", "expand": "", "export_project_to_github": "", "fast": "", @@ -144,13 +175,17 @@ "file_name_in_this_project": "", "file_outline": "", "files_cannot_include_invalid_characters": "", + "find_out_more": "", "find_out_more_about_institution_login": "", "find_out_more_about_the_file_outline": "", + "find_out_more_nt": "", "find_the_symbols_you_need_with_premium": "", "first_name": "", "fold_line": "", "following_paths_conflict": "", "free_accounts_have_timeout_upgrade_to_increase": "", + "free_plan_label": "", + "free_plan_tooltip": "", "from_another_project": "", "from_external_url": "", "from_provider": "", @@ -178,8 +213,10 @@ "github_file_name_error": "", "github_for_link_shared_projects": "", "github_git_folder_error": "", + "github_is_premium": "", "github_large_files_error": "", "github_merge_failed": "", + "github_no_master_branch_error": "", "github_private_description": "", "github_public_description": "", "github_repository_diverged": "", @@ -228,13 +265,17 @@ "hotkeys": "", "if_error_persists_try_relinking_provider": "", "ignore_validation_errors": "", + "import_from_github": "", + "import_to_sharelatex": "", "imported_from_another_project_at_date": "", "imported_from_external_provider_at_date": "", "imported_from_mendeley_at_date": "", "imported_from_the_output_of_another_project_at_date": "", "imported_from_zotero_at_date": "", + "importing": "", "importing_and_merging_changes_in_github": "", "in_order_to_match_institutional_metadata_2": "", + "in_order_to_match_institutional_metadata_associated": "", "institution_acct_successfully_linked_2": "", "institution_and_role": "", "institutional_leavers_survey_notification": "", @@ -245,18 +286,27 @@ "invalid_request": "", "invite_not_accepted": "", "is_email_affiliated": "", + "join_project": "", + "joining": "", "last_modified": "", "last_name": "", + "last_updated_date_by_x": "", + "latex_help_guide": "", "layout": "", "layout_processing": "", "learn_how_to_make_documents_compile_quickly": "", "learn_more": "", "learn_more_about_link_sharing": "", + "leave": "", + "leave_projects": "", "let_us_know": "", "limited_offer": "", "link": "", + "link_account": "", "link_accounts": "", "link_accounts_and_add_email": "", + "link_institutional_email_get_started": "", + "link_sharing": "", "link_sharing_is_off": "", "link_sharing_is_on": "", "link_to_github": "", @@ -268,6 +318,7 @@ "linked_collabratec_description": "", "linked_file": "", "loading": "", + "loading_github_repositories": "", "loading_recent_github_commits": "", "log_entry_description": "", "log_entry_maximum_entries": "", @@ -280,6 +331,7 @@ "log_viewer_error": "", "login_with_service": "", "logs_and_output_files": "", + "looks_like_youre_at": "", "main_file_not_found": "", "make_email_primary_description": "", "make_primary": "", @@ -312,6 +364,8 @@ "new_folder": "", "new_name": "", "new_password": "", + "new_project": "", + "new_to_latex_look_at": "", "newsletter": "", "no_existing_password": "", "no_messages": "", @@ -327,12 +381,15 @@ "no_search_results": "", "no_symbols_found": "", "normal": "", + "notification_project_invite_accepted_message": "", + "notification_project_invite_message": "", "oauth_orcid_description": "", "of": "", "off": "", "official": "", "ok": "", "on": "", + "open_project": "", "optional": "", "or": "", "other_logs_and_files": "", @@ -351,10 +408,12 @@ "pdf_preview_error": "", "pdf_rendering_error": "", "pdf_viewer_error": "", + "plan_tooltip": "", "please_change_primary_to_remove": "", "please_check_your_inbox": "", "please_check_your_inbox_to_confirm": "", "please_compile_pdf_before_download": "", + "please_confirm_email": "", "please_confirm_your_email_before_making_it_default": "", "please_link_before_making_primary": "", "please_reconfirm_institutional_email": "", @@ -367,6 +426,7 @@ "plus_upgraded_accounts_receive": "", "premium_feature": "", "premium_makes_collab_easier_with_features": "", + "premium_plan_label": "", "press_shortcut_to_open_advanced_reference_search": "", "private": "", "processing": "", @@ -376,6 +436,7 @@ "project_flagged_too_many_compiles": "", "project_has_too_many_files": "", "project_layout_sharing_submission": "", + "project_name": "", "project_not_linked_to_github": "", "project_ownership_transfer_confirmation_1": "", "project_ownership_transfer_confirmation_2": "", @@ -414,10 +475,15 @@ "remote_service_error": "", "remove": "", "remove_collaborator": "", + "remove_tag": "", "rename": "", + "rename_folder": "", + "renaming": "", "repository_name": "", "resend": "", "resend_confirmation_email": "", + "resending_confirmation_email": "", + "reverse_x_sort_order": "", "review": "", "revoke": "", "revoke_invite": "", @@ -433,6 +499,7 @@ "search_match_case": "", "search_next": "", "search_previous": "", + "search_projects": "", "search_references": "", "search_regexp": "", "search_replace": "", @@ -441,10 +508,15 @@ "search_search_for": "", "select_a_file": "", "select_a_project": "", + "select_all_projects": "", "select_an_output_file": "", "select_from_output_files": "", "select_from_source_files": "", "select_from_your_computer": "", + "select_github_repository": "", + "select_project": "", + "select_projects": "", + "select_tag": "", "selected": "", "send": "", "send_first_message": "", @@ -459,6 +531,7 @@ "share": "", "share_project": "", "share_with_your_collabs": "", + "shared_with_you": "", "sharelatex_beta_program": "", "show_in_code": "", "show_in_pdf": "", @@ -471,6 +544,7 @@ "something_went_wrong_rendering_pdf": "", "something_went_wrong_server": "", "somthing_went_wrong_compiling": "", + "sort_by_x": "", "sso_link_error": "", "start_by_adding_your_email": "", "start_free_trial": "", @@ -492,11 +566,14 @@ "tab_connecting": "", "tab_no_longer_connected": "", "tags": "", + "tags_slash_folders": "", "take_short_survey": "", "template_approved_by_publisher": "", + "templates": "", "terminated": "", "thank_you_exclamation": "", "thanks_settings_updated": "", + "this_action_cannot_be_undone": "", "this_grants_access_to_features_2": "", "this_project_is_public": "", "this_project_is_public_read_only": "", @@ -516,7 +593,15 @@ "too_many_search_results": "", "too_recently_compiled": "", "total_words": "", + "trash": "", "trashed": "", + "trash_projects": "", + "trashed_projects": "", + "trashing_projects_wont_affect_collaborators": "", + "trial_last_day": "", + "trial_remaining_days": "", + "tried_to_log_in_with_email": "", + "tried_to_register_with_email": "", "try_again": "", "try_it_for_free": "", "try_premium_for_free": "", @@ -524,6 +609,8 @@ "try_to_compile_despite_errors": "", "turn_off_link_sharing": "", "turn_on_link_sharing": "", + "unarchive": "", + "uncategorized": "", "unconfirmed": "", "unfold_line": "", "university": "", @@ -538,6 +625,7 @@ "unlink_reference": "", "unlink_warning_reference": "", "unlinking": "", + "untrash": "", "update": "", "update_account_info": "", "update_dropbox_settings": "", @@ -545,21 +633,29 @@ "upgrade_for_longer_compiles": "", "upgrade_to_get_feature": "", "upload": "", + "upload_project": "", + "upload_zipped_project": "", "url_to_fetch_the_file_from": "", "use_your_own_machine": "", "user_deletion_error": "", "user_deletion_password_reset_tip": "", "validation_issue_entry_description": "", + "view_all": "", "view_logs": "", "view_pdf": "", "we_cant_find_any_sections_or_subsections_in_this_file": "", + "we_logged_you_in": "", + "welcome_to_sl": "", "with_premium_subscription_you_also_get": "", "word_count": "", "work_offline": "", "work_with_non_overleaf_users": "", + "you_can_now_log_in_sso": "", + "you_dont_have_any_repositories": "", "your_affiliation_is_confirmed": "", "your_browser_does_not_support_this_feature": "", "your_message": "", + "your_projects": "", "zotero_groups_loading_error": "", "zotero_groups_relink": "", "zotero_integration": "", diff --git a/services/web/frontend/js/features/project-list/components/current-plan-widget/commons-plan.tsx b/services/web/frontend/js/features/project-list/components/current-plan-widget/commons-plan.tsx new file mode 100644 index 0000000000..4ad90151c6 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/current-plan-widget/commons-plan.tsx @@ -0,0 +1,30 @@ +import { useTranslation, Trans } from 'react-i18next' +import { CommonsPlanSubscription } from '../../../../../../types/project/dashboard/subscription' +import Tooltip from '../../../../shared/components/tooltip' + +type CommonsPlanProps = Pick + +function CommonsPlan({ subscription, plan }: CommonsPlanProps) { + const { t } = useTranslation() + + return ( + + + }} />{' '} + + + + ) +} + +export default CommonsPlan diff --git a/services/web/frontend/js/features/project-list/components/current-plan-widget/current-plan-widget.tsx b/services/web/frontend/js/features/project-list/components/current-plan-widget/current-plan-widget.tsx new file mode 100644 index 0000000000..dce787b7a5 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/current-plan-widget/current-plan-widget.tsx @@ -0,0 +1,60 @@ +import FreePlan from './free-plan' +import IndividualPlan from './individual-plan' +import GroupPlan from './group-plan' +import CommonsPlan from './commons-plan' +import getMeta from '../../../../utils/meta' +import { Subscription } from '../../../../../../types/project/dashboard/subscription' + +function CurrentPlanWidget() { + const usersBestSubscription: Subscription | undefined = getMeta( + 'ol-usersBestSubscription' + ) + + if (!usersBestSubscription) { + return null + } + + const { type } = usersBestSubscription + const isFreePlan = type === 'free' + const isIndividualPlan = type === 'individual' + const isGroupPlan = type === 'group' + const isCommonsPlan = type === 'commons' + + let currentPlan + + if (isFreePlan) { + currentPlan = + } + + if (isIndividualPlan) { + currentPlan = ( + + ) + } + + if (isGroupPlan) { + currentPlan = ( + + ) + } + + if (isCommonsPlan) { + currentPlan = ( + + ) + } + + return
{currentPlan}
+} + +export default CurrentPlanWidget diff --git a/services/web/frontend/js/features/project-list/components/current-plan-widget/free-plan.tsx b/services/web/frontend/js/features/project-list/components/current-plan-widget/free-plan.tsx new file mode 100644 index 0000000000..161ea38528 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/current-plan-widget/free-plan.tsx @@ -0,0 +1,40 @@ +import { useTranslation, Trans } from 'react-i18next' +import { Button } from 'react-bootstrap' +import Tooltip from '../../../../shared/components/tooltip' +import * as eventTracking from '../../../../infrastructure/event-tracking' + +function FreePlan() { + const { t } = useTranslation() + + function handleClick() { + eventTracking.send('subscription-funnel', 'dashboard-top', 'upgrade') + eventTracking.sendMB('upgrade-button-click', { source: 'dashboard-top' }) + } + + return ( + <> + + + }} />{' '} + + + {' '} + + + ) +} + +export default FreePlan diff --git a/services/web/frontend/js/features/project-list/components/current-plan-widget/group-plan.tsx b/services/web/frontend/js/features/project-list/components/current-plan-widget/group-plan.tsx new file mode 100644 index 0000000000..d9ea234198 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/current-plan-widget/group-plan.tsx @@ -0,0 +1,47 @@ +import { useTranslation, Trans } from 'react-i18next' +import { GroupPlanSubscription } from '../../../../../../types/project/dashboard/subscription' +import Tooltip from '../../../../shared/components/tooltip' + +type GroupPlanProps = Pick< + GroupPlanSubscription, + 'subscription' | 'plan' | 'remainingTrialDays' +> + +function GroupPlan({ subscription, plan, remainingTrialDays }: GroupPlanProps) { + const { t } = useTranslation() + + return ( + + + {remainingTrialDays >= 0 ? ( + remainingTrialDays === 1 ? ( + }} /> + ) : ( + }} + values={{ days: remainingTrialDays }} + /> + ) + ) : ( + }} /> + )}{' '} + + + + ) +} + +export default GroupPlan diff --git a/services/web/frontend/js/features/project-list/components/current-plan-widget/individual-plan.tsx b/services/web/frontend/js/features/project-list/components/current-plan-widget/individual-plan.tsx new file mode 100644 index 0000000000..d1d0a871a8 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/current-plan-widget/individual-plan.tsx @@ -0,0 +1,42 @@ +import { useTranslation, Trans } from 'react-i18next' +import { IndividualPlanSubscription } from '../../../../../../types/project/dashboard/subscription' +import Tooltip from '../../../../shared/components/tooltip' + +type IndividualPlanProps = Pick< + IndividualPlanSubscription, + 'plan' | 'remainingTrialDays' +> + +function IndividualPlan({ plan, remainingTrialDays }: IndividualPlanProps) { + const { t } = useTranslation() + + return ( + + + {remainingTrialDays >= 0 ? ( + remainingTrialDays === 1 ? ( + }} /> + ) : ( + }} + values={{ days: remainingTrialDays }} + /> + ) + ) : ( + }} /> + )}{' '} + + + + ) +} + +export default IndividualPlan diff --git a/services/web/frontend/js/features/project-list/components/new-project-button.tsx b/services/web/frontend/js/features/project-list/components/new-project-button.tsx new file mode 100644 index 0000000000..7d848d2604 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/new-project-button.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react' +import { Dropdown, MenuItem } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import { ExposedSettings } from '../../../../../types/exposed-settings' +import ControlledDropdown from '../../../shared/components/controlled-dropdown' +import getMeta from '../../../utils/meta' +import NewProjectButtonModal, { + NewProjectButtonModalVariant, +} from './new-project-button/new-project-button-modal' + +function NewProjectButton({ buttonText }: { buttonText?: string }) { + const { t } = useTranslation() + const { templateLinks } = getMeta('ol-ExposedSettings') as ExposedSettings + const [modal, setModal] = + useState>(null) + + return ( + <> + + + {buttonText || t('new_project')} + + + setModal('blank_project')}> + {t('blank_project')} + + setModal('example_project')}> + {t('example_project')} + + setModal('upload_project')}> + {t('upload_project')} + + setModal('import_from_github')}> + {t('import_from_github')} + + + {t('templates')} + {templateLinks.map((templateLink, index) => ( + + {templateLink.name === 'view_all' + ? t('view_all') + : templateLink.name} + + ))} + + + setModal(null)} /> + + ) +} + +export default NewProjectButton diff --git a/services/web/frontend/js/features/project-list/components/new-project-button/blank-project-modal.tsx b/services/web/frontend/js/features/project-list/components/new-project-button/blank-project-modal.tsx new file mode 100644 index 0000000000..ea3dbfa496 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/new-project-button/blank-project-modal.tsx @@ -0,0 +1,22 @@ +import AccessibleModal from '../../../../shared/components/accessible-modal' +import ModalContentNewProjectForm from './modal-content-new-project-form' + +type BlankProjectModalProps = { + onHide: () => void +} + +function BlankProjectModal({ onHide }: BlankProjectModalProps) { + return ( + + + + ) +} + +export default BlankProjectModal diff --git a/services/web/frontend/js/features/project-list/components/new-project-button/example-project-modal.tsx b/services/web/frontend/js/features/project-list/components/new-project-button/example-project-modal.tsx new file mode 100644 index 0000000000..ecfa85ff60 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/new-project-button/example-project-modal.tsx @@ -0,0 +1,22 @@ +import AccessibleModal from '../../../../shared/components/accessible-modal' +import ModalContentNewProjectForm from './modal-content-new-project-form' + +type ExampleProjectModalProps = { + onHide: () => void +} + +function ExampleProjectModal({ onHide }: ExampleProjectModalProps) { + return ( + + + + ) +} + +export default ExampleProjectModal diff --git a/services/web/frontend/js/features/project-list/components/new-project-button/modal-content-new-project-form.tsx b/services/web/frontend/js/features/project-list/components/new-project-button/modal-content-new-project-form.tsx new file mode 100644 index 0000000000..415fed359f --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/new-project-button/modal-content-new-project-form.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react' +import { Alert, Button, FormControl, Modal } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import useAsync from '../../../../shared/hooks/use-async' +import { + getUserFacingMessage, + postJSON, +} from '../../../../infrastructure/fetch-json' + +type NewProjectData = { + project_id: string + owner_ref: string + owner: { + first_name: string + last_name: string + email: string + id: string + } +} + +type Props = { + onCancel: () => void + template?: string +} + +function ModalContentNewProjectForm({ onCancel, template = 'none' }: Props) { + const { t } = useTranslation() + const [projectName, setProjectName] = useState('') + const { isLoading, isError, error, runAsync } = useAsync() + + const createNewProject = () => { + runAsync( + postJSON('/project/new', { + body: { + _csrf: window.csrfToken, + projectName, + template, + }, + }) + ) + .then(data => { + if (data.project_id) { + window.location.assign(`/project/${data.project_id}`) + } + }) + .catch(() => {}) + } + + const handleChangeName = ( + e: React.ChangeEvent + ) => { + setProjectName(e.currentTarget.value) + } + + return ( + <> + + {t('new_project')} + + + + {isError && ( + {getUserFacingMessage(error)} + )} + + + + + + + + + ) +} + +export default ModalContentNewProjectForm diff --git a/services/web/frontend/js/features/project-list/components/new-project-button/new-project-button-modal.tsx b/services/web/frontend/js/features/project-list/components/new-project-button/new-project-button-modal.tsx new file mode 100644 index 0000000000..314119ee6b --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/new-project-button/new-project-button-modal.tsx @@ -0,0 +1,40 @@ +import BlankProjectModal from './blank-project-modal' +import ExampleProjectModal from './example-project-modal' +import UploadProjectModal from './upload-project-modal' +import importOverleafModules from '../../../../../macros/import-overleaf-module.macro' +import { JSXElementConstructor } from 'react' + +export type NewProjectButtonModalVariant = + | 'blank_project' + | 'example_project' + | 'upload_project' + | 'import_from_github' + +type NewProjectButtonModalProps = { + modal: Nullable + onHide: () => void +} + +function NewProjectButtonModal({ modal, onHide }: NewProjectButtonModalProps) { + const [importProjectFromGithubModalWrapper] = importOverleafModules( + 'importProjectFromGithubModalWrapper' + ) + const ImportProjectFromGithubModalWrapper: JSXElementConstructor<{ + onHide: () => void + }> = importProjectFromGithubModalWrapper?.import.default + + switch (modal) { + case 'blank_project': + return + case 'example_project': + return + case 'upload_project': + return + case 'import_from_github': + return + default: + return null + } +} + +export default NewProjectButtonModal diff --git a/services/web/frontend/js/features/project-list/components/new-project-button/upload-project-modal.tsx b/services/web/frontend/js/features/project-list/components/new-project-button/upload-project-modal.tsx new file mode 100644 index 0000000000..4b9102d48c --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/new-project-button/upload-project-modal.tsx @@ -0,0 +1,116 @@ +import { useEffect, useState } from 'react' +import { Button, Modal } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import Uppy from '@uppy/core' +import { Dashboard, useUppy } from '@uppy/react' +import XHRUpload from '@uppy/xhr-upload' +import AccessibleModal from '../../../../shared/components/accessible-modal' +import getMeta from '../../../../utils/meta' +import { ExposedSettings } from '../../../../../../types/exposed-settings' + +import '@uppy/core/dist/style.css' +import '@uppy/dashboard/dist/style.css' + +type UploadResponse = { + project_id: string +} + +type UploadProjectModalProps = { + onHide: () => void +} + +function UploadProjectModal({ onHide }: UploadProjectModalProps) { + const { t } = useTranslation() + const { maxUploadSize } = getMeta('ol-ExposedSettings') as ExposedSettings + const [ableToUpload, setAbleToUpload] = useState(true) + const [correctfileAdded, setCorrectFileAdded] = useState(false) + + const uppy: Uppy.Uppy = useUppy(() => { + return Uppy({ + allowMultipleUploads: false, + restrictions: { + maxNumberOfFiles: 1, + maxFileSize: maxUploadSize, + allowedFileTypes: ['.zip'], + }, + }) + .use(XHRUpload, { + endpoint: '/project/new/upload', + headers: { + 'X-CSRF-TOKEN': window.csrfToken, + }, + limit: 1, + fieldName: 'qqfile', // "qqfile" is needed for our express multer middleware + }) + .on('file-added', () => { + // this function can be invoked multiple times depending on maxNumberOfFiles + // in this case, since have maxNumberOfFiles = 1, this function will be invoked + // once if the correct file were added + // if user dragged more files than the maxNumberOfFiles allow, + // the rest of the files will appear on the 'restriction-failed' event callback + setCorrectFileAdded(true) + }) + .on('upload-success', async (file, response) => { + const { project_id: projectId }: UploadResponse = response.body + + if (projectId) { + window.location.assign(`/project/${projectId}`) + } + }) + .on('restriction-failed', () => { + // 'restriction-failed event will be invoked when one of the "restrictions" above + // is not complied: + // 1. maxNumberOfFiles: if the uploaded files is more than 1, the rest of the files will appear here + // for example, user drop 5 files to the uploader, this function will be invoked 4 times and the `file-added` event + // will be invoked once + // 2. maxFileSize: if the uploaded file has size > maxFileSize, it will appear here + // 3. allowedFileTypes: if the type is not .zip, it will also appear here + setAbleToUpload(false) + }) + }) + + useEffect(() => { + if (ableToUpload && correctfileAdded) { + uppy.upload() + } + }, [ableToUpload, correctfileAdded, uppy]) + + return ( + + + + {t('upload_zipped_project')} + + + + + + + + + + + ) +} + +export default UploadProjectModal diff --git a/services/web/frontend/js/features/project-list/components/notifications/action.tsx b/services/web/frontend/js/features/project-list/components/notifications/action.tsx new file mode 100644 index 0000000000..50949dda92 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/notifications/action.tsx @@ -0,0 +1,9 @@ +import classnames from 'classnames' + +function Action({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +export default Action diff --git a/services/web/frontend/js/features/project-list/components/notifications/body.tsx b/services/web/frontend/js/features/project-list/components/notifications/body.tsx new file mode 100644 index 0000000000..0333d2f0a5 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/notifications/body.tsx @@ -0,0 +1,9 @@ +import classnames from 'classnames' + +function Body({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +export default Body diff --git a/services/web/frontend/js/features/project-list/components/notifications/close.tsx b/services/web/frontend/js/features/project-list/components/notifications/close.tsx new file mode 100644 index 0000000000..32eafd8cd8 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/notifications/close.tsx @@ -0,0 +1,21 @@ +import { useTranslation } from 'react-i18next' +import classnames from 'classnames' + +type CloseProps = { + onDismiss: () => void +} & React.ComponentProps<'div'> + +function Close({ onDismiss, className, ...props }: CloseProps) { + const { t } = useTranslation() + + return ( +
+ +
+ ) +} + +export default Close diff --git a/services/web/frontend/js/features/project-list/components/notifications/groups/affiliation/reconfirm-affiliation.tsx b/services/web/frontend/js/features/project-list/components/notifications/groups/affiliation/reconfirm-affiliation.tsx new file mode 100644 index 0000000000..3420e26e45 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/notifications/groups/affiliation/reconfirm-affiliation.tsx @@ -0,0 +1,124 @@ +import { useState, useEffect } from 'react' +import { useTranslation, Trans } from 'react-i18next' +import { Button } from 'react-bootstrap' +import Icon from '../../../../../../shared/components/icon' +import getMeta from '../../../../../../utils/meta' +import useAsync from '../../../../../../shared/hooks/use-async' +import { postJSON } from '../../../../../../infrastructure/fetch-json' +import { UserEmailData } from '../../../../../../../../types/user-email' +import { ExposedSettings } from '../../../../../../../../types/exposed-settings' +import { Institution } from '../../../../../../../../types/institution' + +type ReconfirmAffiliationProps = { + email: UserEmailData['email'] + institution: Institution +} + +function ReconfirmAffiliation({ + email, + institution, +}: ReconfirmAffiliationProps) { + const { t } = useTranslation() + const { samlInitPath } = getMeta('ol-ExposedSettings') as ExposedSettings + const { isLoading, isError, isSuccess, runAsync } = useAsync() + const [hasSent, setHasSent] = useState(false) + const [isPending, setIsPending] = useState(false) + const ssoEnabled = institution.ssoEnabled + + useEffect(() => { + if (isSuccess) { + setHasSent(true) + } + }, [isSuccess]) + + const handleRequestReconfirmation = () => { + if (ssoEnabled) { + setIsPending(true) + window.location.assign( + `${samlInitPath}?university_id=${institution.id}&reconfirm=/project` + ) + } else { + runAsync( + postJSON('/user/emails/send-reconfirmation', { + body: { email }, + }) + ).catch(console.error) + } + } + + if (hasSent) { + return ( +
+ ]} // eslint-disable-line react/jsx-key + values={{ institutionName: institution.name }} + /> +   + {isLoading ? ( + <> + {t('sending')}… + + ) : ( + + )} + {isError && ( + <> +
+
{t('generic_something_went_wrong')}
+ + )} +
+ ) + } + + return ( +
+ + + ]} // eslint-disable-line react/jsx-key + values={{ institutionName: institution.name }} + /> +   + ]} + /> +   + + {t('learn_more')} + + {isError && ( + <> +
+
{t('generic_something_went_wrong')}
+ + )} +
+ ) +} + +export default ReconfirmAffiliation diff --git a/services/web/frontend/js/features/project-list/components/notifications/groups/affiliation/reconfirmation-info.tsx b/services/web/frontend/js/features/project-list/components/notifications/groups/affiliation/reconfirmation-info.tsx new file mode 100644 index 0000000000..c34434cca1 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/notifications/groups/affiliation/reconfirmation-info.tsx @@ -0,0 +1,54 @@ +import Notification from '../../notification' +import ReconfirmAffiliation from './reconfirm-affiliation' +import getMeta from '../../../../../../utils/meta' +import { UserEmailData } from '../../../../../../../../types/user-email' +import ReconfirmationInfoSuccess from '../../../../../settings/components/emails/reconfirmation-info/reconfirmation-info-success' + +function ReconfirmationInfo() { + const allInReconfirmNotificationPeriods = getMeta( + 'ol-allInReconfirmNotificationPeriods', + [] + ) as UserEmailData[] + const userEmails = getMeta('ol-userEmails', []) as UserEmailData[] + const reconfirmedViaSAML = getMeta('ol-reconfirmedViaSAML') as string + + return ( + <> + {allInReconfirmNotificationPeriods.map(userEmail => + userEmail.affiliation?.institution ? ( + + +
+ +
+
+
+ ) : null + )} + {userEmails.map(userEmail => + userEmail.samlProviderId === reconfirmedViaSAML && + userEmail.affiliation?.institution ? ( + {}} + > + + + + + ) : null + )} + + ) +} + +export default ReconfirmationInfo diff --git a/services/web/frontend/js/features/project-list/components/notifications/groups/common.tsx b/services/web/frontend/js/features/project-list/components/notifications/groups/common.tsx new file mode 100644 index 0000000000..d9f78e57df --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/notifications/groups/common.tsx @@ -0,0 +1,274 @@ +import { Fragment } from 'react' +import { useTranslation, Trans } from 'react-i18next' +import { Button } from 'react-bootstrap' +import Notification from '../notification' +import Icon from '../../../../../shared/components/icon' +import getMeta from '../../../../../utils/meta' +import useAsyncDismiss from '../hooks/useAsyncDismiss' +import { useProjectListContext } from '../../../context/project-list-context' +import useAsync from '../../../../../shared/hooks/use-async' +import { FetchError, postJSON } from '../../../../../infrastructure/fetch-json' +import { ExposedSettings } from '../../../../../../../types/exposed-settings' +import { Notification as NotificationType } from '../../../../../../../types/project/dashboard/notification' +import { User } from '../../../../../../../types/user' + +function Common() { + const { t } = useTranslation() + const { totalProjectsCount } = useProjectListContext() + const { samlInitPath } = getMeta('ol-ExposedSettings') as ExposedSettings + const notifications = getMeta('ol-notifications', []) as NotificationType[] + const user = getMeta('ol-user', []) as Pick + const { isLoading, isSuccess, error, runAsync } = useAsync< + never, + FetchError + >() + const { handleDismiss } = useAsyncDismiss() + + // 404 probably means the invite has already been accepted and deleted. Treat as success + const accepted = isSuccess || error?.response?.status === 404 + + const handleAcceptInvite = (projectId: number | string, token: string) => { + runAsync( + postJSON(`/project/${projectId}/invite/token/${token}/accept`) + ).catch(console.error) + } + + if (!totalProjectsCount || !notifications.length) { + return null + } + + return ( + <> + {notifications.map( + ({ _id: id, templateKey, messageOpts, html }, index) => ( + + {templateKey === 'notification_project_invite' ? ( + id && handleDismiss(id)} + > + + {accepted ? ( + }} + values={{ projectName: messageOpts.projectName }} + /> + ) : ( + }} + values={{ + userName: messageOpts.userName, + projectName: messageOpts.projectName, + }} + /> + )} + + + {accepted ? ( + + ) : ( + + )} + + + ) : templateKey === 'wfh_2020_upgrade_offer' ? ( + id && handleDismiss(id)} + > + + Important notice: Your free WFH2020 upgrade came to an end on + June 30th 2020. We're still providing a number of special + initiatives to help you continue collaborating throughout + 2020. + + + + + + ) : templateKey === 'notification_ip_matched_affiliation' ? ( + id && handleDismiss(id)} + > + + ]} // eslint-disable-line react/jsx-key + values={{ + institutionName: messageOpts.university_name, + }} + /> +
+ {messageOpts.ssoEnabled ? ( + <> + ]} // eslint-disable-line react/jsx-key + /> +
+ {t('link_institutional_email_get_started')}{' '} + + {t('find_out_more_nt')} + + + ) : ( + <> + ]} // eslint-disable-line react/jsx-key + values={{ + institutionName: messageOpts.university_name, + }} + /> +
+ {t('add_email_to_claim_features')} + + )} +
+ + + +
+ ) : templateKey === 'notification_tpds_file_limit' ? ( + id && handleDismiss(id)} + > + + Error: Your project {messageOpts.projectName} has gone over + the 2000 file limit using an integration (e.g. Dropbox or + GitHub)
+ Please decrease the size of your project to prevent further + errors. +
+ + + +
+ ) : templateKey === + 'notification_dropbox_duplicate_project_names' ? ( + id && handleDismiss(id)} + > + +

+ ]} // eslint-disable-line react/jsx-key + values={{ projectName: messageOpts.projectName }} + /> +

+

+ ]} // eslint-disable-line react/jsx-key + />{' '} + + {t('learn_more')} + + . +

+
+
+ ) : templateKey === + 'notification_dropbox_unlinked_due_to_lapsed_reconfirmation' ? ( + id && handleDismiss(id)} + > + + ]} // eslint-disable-line react/jsx-key + />{' '} + {user.features?.dropbox ? ( + ]} + /> + ) : ( + t('confirm_affiliation_to_relink_dropbox') + )}{' '} + + {t('learn_more')} + + + + ) : ( + id && handleDismiss(id)} + > + {html} + + )} +
+ ) + )} + + ) +} + +export default Common diff --git a/services/web/frontend/js/features/project-list/components/notifications/groups/confirm-email.tsx b/services/web/frontend/js/features/project-list/components/notifications/groups/confirm-email.tsx new file mode 100644 index 0000000000..186d45ef32 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/notifications/groups/confirm-email.tsx @@ -0,0 +1,117 @@ +import { useTranslation } from 'react-i18next' +import { Button } from 'react-bootstrap' +import Notification from '../notification' +import Icon from '../../../../../shared/components/icon' +import getMeta from '../../../../../utils/meta' +import useAsync from '../../../../../shared/hooks/use-async' +import { useProjectListContext } from '../../../context/project-list-context' +import { + postJSON, + getUserFacingMessage, +} from '../../../../../infrastructure/fetch-json' +import { ExposedSettings } from '../../../../../../../types/exposed-settings' +import { UserEmailData } from '../../../../../../../types/user-email' + +const ssoAvailable = ({ samlProviderId, affiliation }: UserEmailData) => { + const { hasSamlFeature, hasSamlBeta } = getMeta( + 'ol-ExposedSettings' + ) as ExposedSettings + + if (!hasSamlFeature) { + return false + } + if (samlProviderId) { + return true + } + if (!affiliation?.institution) { + return false + } + if (affiliation.institution.ssoEnabled) { + return true + } + if (hasSamlBeta && affiliation.institution.ssoBeta) { + return true + } + return false +} + +const showConfirmEmail = (userEmail: UserEmailData) => { + const { emailConfirmationDisabled } = getMeta( + 'ol-ExposedSettings' + ) as ExposedSettings + + return ( + !emailConfirmationDisabled && + !userEmail.confirmedAt && + !ssoAvailable(userEmail) + ) +} + +function ConfirmEmailNotification({ userEmail }: { userEmail: UserEmailData }) { + const { t } = useTranslation() + const { isLoading, isSuccess, isError, error, runAsync } = useAsync() + + const handleResendConfirmationEmail = ({ email }: UserEmailData) => { + runAsync( + postJSON('/user/emails/resend_confirmation', { + body: { email }, + }) + ).catch(console.error) + } + + if (isSuccess) { + return null + } + + return ( + + + {isLoading ? ( + <> + {t('resending_confirmation_email')} + … + + ) : isError ? ( +
{getUserFacingMessage(error)}
+ ) : ( + <> + {t('please_confirm_email', { + emailAddress: userEmail.email, + })} + + + )} +
+
+ ) +} + +function ConfirmEmail() { + const { totalProjectsCount } = useProjectListContext() + const userEmails = getMeta('ol-userEmails', []) as UserEmailData[] + + if (!totalProjectsCount || !userEmails.length) { + return null + } + + return ( + <> + {userEmails.map(userEmail => { + return showConfirmEmail(userEmail) ? ( + + ) : null + })} + + ) +} + +export default ConfirmEmail diff --git a/services/web/frontend/js/features/project-list/components/notifications/groups/institution.tsx b/services/web/frontend/js/features/project-list/components/notifications/groups/institution.tsx new file mode 100644 index 0000000000..4b28215264 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/notifications/groups/institution.tsx @@ -0,0 +1,158 @@ +import { Fragment } from 'react' +import { useTranslation, Trans } from 'react-i18next' +import { Button } from 'react-bootstrap' +import Notification from '../notification' +import Icon from '../../../../../shared/components/icon' +import getMeta from '../../../../../utils/meta' +import useAsyncDismiss from '../hooks/useAsyncDismiss' +import { ExposedSettings } from '../../../../../../../types/exposed-settings' +import { Institution as InstitutionType } from '../../../../../../../types/project/dashboard/notification' + +function Institution() { + const { t } = useTranslation() + const { samlInitPath, appName } = getMeta( + 'ol-ExposedSettings' + ) as ExposedSettings + const notificationsInstitution = getMeta( + 'ol-notificationsInstitution', + [] + ) as InstitutionType[] + const { handleDismiss } = useAsyncDismiss() + + if (!notificationsInstitution.length) { + return null + } + + return ( + <> + {notificationsInstitution.map( + ( + { + _id: id, + email, + institutionEmail, + institutionId, + institutionName, + templateKey, + requestedEmail, + error, + }, + index + ) => ( + + {templateKey === 'notification_institution_sso_available' && ( + + +

+ }} + values={{ appName, email, institutionName }} + /> +

+
+ }} + values={{ appName }} + />{' '} + + {t('learn_more')} + +
+
+ + + +
+ )} + {templateKey === 'notification_institution_sso_linked' && ( + id && handleDismiss(id)} + > + + }} + values={{ appName, email, institutionName }} + /> + + + )} + {templateKey === 'notification_institution_sso_non_canonical' && ( + id && handleDismiss(id)} + > + + + }} + values={{ appName, email: requestedEmail }} + />{' '} + }} + values={{ email: institutionEmail }} + /> + + + )} + {templateKey === + 'notification_institution_sso_already_registered' && ( + id && handleDismiss(id)} + > + + }} + values={{ appName, email }} + />{' '} + {t('we_logged_you_in')} + + + + + + )} + {templateKey === 'notification_institution_sso_error' && ( + id && handleDismiss(id)} + > + + {' '} + {t('generic_something_went_wrong')}. +
+ {error?.translatedMessage + ? error?.translatedMessage + : error?.message} +
+ {error?.tryAgain ? `${t('try_again')}.` : null} +
+
+ )} +
+ ) + )} + + ) +} + +export default Institution diff --git a/services/web/frontend/js/features/project-list/components/notifications/hooks/useAsyncDismiss.ts b/services/web/frontend/js/features/project-list/components/notifications/hooks/useAsyncDismiss.ts new file mode 100644 index 0000000000..acfee3b9f5 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/notifications/hooks/useAsyncDismiss.ts @@ -0,0 +1,14 @@ +import useAsync from '../../../../../shared/hooks/use-async' +import { deleteJSON } from '../../../../../infrastructure/fetch-json' + +function useAsyncDismiss() { + const { runAsync, ...rest } = useAsync() + + const handleDismiss = (id: number | string) => { + runAsync(deleteJSON(`/notifications/${id}`)).catch(console.error) + } + + return { handleDismiss, ...rest } +} + +export default useAsyncDismiss diff --git a/services/web/frontend/js/features/project-list/components/notifications/notification.tsx b/services/web/frontend/js/features/project-list/components/notifications/notification.tsx new file mode 100644 index 0000000000..840a28642d --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/notifications/notification.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react' +import { Alert, AlertProps } from 'react-bootstrap' +import Body from './body' +import Action from './action' +import Close from './close' +import classnames from 'classnames' + +type NotificationProps = { + bsStyle: AlertProps['bsStyle'] + children: React.ReactNode + onDismiss?: AlertProps['onDismiss'] + className?: string +} + +function Notification({ + bsStyle, + children, + onDismiss, + className, + ...props +}: NotificationProps) { + const [show, setShow] = useState(true) + + const handleDismiss = () => { + if (onDismiss) { + onDismiss() + } + + setShow(false) + } + + if (!show) { + return null + } + + return ( +
  • + + {children} + {onDismiss ? : null} + +
  • + ) +} + +Notification.Body = Body +Notification.Action = Action + +export default Notification diff --git a/services/web/frontend/js/features/project-list/components/notifications/user-notifications.tsx b/services/web/frontend/js/features/project-list/components/notifications/user-notifications.tsx new file mode 100644 index 0000000000..61fd54809e --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/notifications/user-notifications.tsx @@ -0,0 +1,19 @@ +import Common from './groups/common' +import Institution from './groups/institution' +import ConfirmEmail from './groups/confirm-email' +import ReconfirmationInfo from './groups/affiliation/reconfirmation-info' + +function UserNotifications() { + return ( +
    +
      + + + + +
    +
    + ) +} + +export default UserNotifications diff --git a/services/web/frontend/js/features/project-list/components/project-list-root.tsx b/services/web/frontend/js/features/project-list/components/project-list-root.tsx new file mode 100644 index 0000000000..4a7af12a34 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/project-list-root.tsx @@ -0,0 +1,105 @@ +import { + ProjectListProvider, + useProjectListContext, +} from '../context/project-list-context' +import { Col, Row } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import useWaitForI18n from '../../../shared/hooks/use-wait-for-i18n' +import CurrentPlanWidget from './current-plan-widget/current-plan-widget' +import NewProjectButton from './new-project-button' +import ProjectListTable from './table/project-list-table' +import SidebarFilters from './sidebar/sidebar-filters' +import SurveyWidget from './survey-widget' +import WelcomeMessage from './welcome-message' +import LoadingBranded from '../../../shared/components/loading-branded' +import UserNotifications from './notifications/user-notifications' +import SearchForm from './search-form' + +function ProjectListRoot() { + const { isReady } = useWaitForI18n() + + return isReady ? ( + + + + ) : null +} + +function ProjectListPageContent() { + const { totalProjectsCount, error, isLoading, loadProgress, setSearchText } = + useProjectListContext() + + return isLoading ? ( +
    + +
    + ) : ( +
    +
    + {error ? : ''} + {totalProjectsCount > 0 ? ( + <> + + + + + + + + + + + + + + + +
    +
    + +
    +
    + +
    + + + + + + + + ) : ( + + + + + + )} +
    +
    + ) +} + +function DashApiError() { + const { t } = useTranslation() + return ( + + +
    + {t('generic_something_went_wrong')} +
    + +
    + ) +} + +export default ProjectListRoot diff --git a/services/web/frontend/js/features/project-list/components/search-form.tsx b/services/web/frontend/js/features/project-list/components/search-form.tsx new file mode 100644 index 0000000000..fb3c70c463 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/search-form.tsx @@ -0,0 +1,70 @@ +import { useState, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { Form, FormGroup, Col, FormControl } from 'react-bootstrap' +import Icon from '../../../shared/components/icon' +import * as eventTracking from '../../../infrastructure/event-tracking' + +type SearchFormProps = { + onChange: (input: string) => void +} + +function SearchForm({ onChange }: SearchFormProps) { + const { t } = useTranslation() + const [input, setInput] = useState('') + const placeholder = `${t('search_projects')}…` + + useEffect(() => { + onChange(input) + }, [input, onChange]) + + const handleChange = ( + e: React.ChangeEvent< + HTMLInputElement & Omit + > + ) => { + eventTracking.send( + 'project-list-page-interaction', + 'project-search', + 'keydown' + ) + setInput(e.target.value) + } + + const handleClear = () => setInput('') + + return ( +
    e.preventDefault()} + > + + + + + {input.length ? ( +
    + +
    + ) : null} + +
    +
    + ) +} + +export default SearchForm diff --git a/services/web/frontend/js/features/project-list/components/sidebar/create-tag-modal.tsx b/services/web/frontend/js/features/project-list/components/sidebar/create-tag-modal.tsx new file mode 100644 index 0000000000..27b682d0c8 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/sidebar/create-tag-modal.tsx @@ -0,0 +1,91 @@ +import { useCallback, useState } from 'react' +import { Button, Form, Modal } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import { Tag } from '../../../../../../app/src/Features/Tags/types' +import AccessibleModal from '../../../../shared/components/accessible-modal' +import useAsync from '../../../../shared/hooks/use-async' +import { createTag } from '../../util/api' + +type CreateTagModalProps = { + show: boolean + onCreate: (tag: Tag) => void + onClose: () => void +} + +export default function CreateTagModal({ + show, + onCreate, + onClose, +}: CreateTagModalProps) { + const { t } = useTranslation() + const { isError, runAsync, status } = useAsync() + + const [tagName, setTagName] = useState() + + const runCreateTag = useCallback(() => { + if (tagName) { + runAsync(createTag(tagName)) + .then(tag => onCreate(tag)) + .catch(console.error) + } + }, [runAsync, tagName, onCreate]) + + const handleSubmit = useCallback( + e => { + e.preventDefault() + runCreateTag() + }, + [runCreateTag] + ) + + if (!show) { + return null + } + + return ( + + + {t('create_new_folder')} + + + +
    + setTagName(e.target.value)} + /> +
    +
    + + + {isError && ( +
    + + {t('generic_something_went_wrong')} + +
    + )} + + +
    +
    + ) +} diff --git a/services/web/frontend/js/features/project-list/components/sidebar/delete-tag-modal.tsx b/services/web/frontend/js/features/project-list/components/sidebar/delete-tag-modal.tsx new file mode 100644 index 0000000000..996aa85b71 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/sidebar/delete-tag-modal.tsx @@ -0,0 +1,78 @@ +import { useCallback } from 'react' +import { Button, Modal } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import { Tag } from '../../../../../../app/src/Features/Tags/types' +import AccessibleModal from '../../../../shared/components/accessible-modal' +import useAsync from '../../../../shared/hooks/use-async' +import { deleteTag } from '../../util/api' + +type DeleteTagModalProps = { + tag?: Tag + onDelete: (tagId: string) => void + onClose: () => void +} + +export default function DeleteTagModal({ + tag, + onDelete, + onClose, +}: DeleteTagModalProps) { + const { t } = useTranslation() + const { isError, runAsync, status } = useAsync() + + const runDeleteTag = useCallback( + (tagId: string) => { + runAsync(deleteTag(tagId)) + .then(() => { + onDelete(tagId) + }) + .catch(console.error) + }, + [runAsync, onDelete] + ) + + if (!tag) { + return null + } + + return ( + + + {t('delete_folder')} + + + + {t('about_to_delete_folder')} +
      +
    • {tag.name}
    • +
    +
    + + + {isError && ( +
    + + {t('generic_something_went_wrong')} + +
    + )} + + +
    +
    + ) +} diff --git a/services/web/frontend/js/features/project-list/components/sidebar/rename-tag-modal.tsx b/services/web/frontend/js/features/project-list/components/sidebar/rename-tag-modal.tsx new file mode 100644 index 0000000000..9ad003333c --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/sidebar/rename-tag-modal.tsx @@ -0,0 +1,97 @@ +import { useCallback, useState } from 'react' +import { Button, Form, Modal } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import { Tag } from '../../../../../../app/src/Features/Tags/types' +import AccessibleModal from '../../../../shared/components/accessible-modal' +import useAsync from '../../../../shared/hooks/use-async' +import { renameTag } from '../../util/api' + +type RenameTagModalProps = { + tag?: Tag + onRename: (tagId: string, newTagName: string) => void + onClose: () => void +} + +export default function RenameTagModal({ + tag, + onRename, + onClose, +}: RenameTagModalProps) { + const { t } = useTranslation() + const { isError, runAsync, status } = useAsync() + + const [newTagName, setNewTageName] = useState() + + const runRenameTag = useCallback( + (tagId: string) => { + if (newTagName) { + runAsync(renameTag(tagId, newTagName)) + .then(() => onRename(tagId, newTagName)) + .catch(console.error) + } + }, + [runAsync, newTagName, onRename] + ) + + const handleSubmit = useCallback( + e => { + e.preventDefault() + if (tag) { + runRenameTag(tag._id) + } + }, + [tag, runRenameTag] + ) + + if (!tag) { + return null + } + + return ( + + + {t('rename_folder')} + + + +
    + setNewTageName(e.target.value)} + /> +
    +
    + + + {isError && ( +
    + + {t('generic_something_went_wrong')} + +
    + )} + + +
    +
    + ) +} diff --git a/services/web/frontend/js/features/project-list/components/sidebar/sidebar-filters.tsx b/services/web/frontend/js/features/project-list/components/sidebar/sidebar-filters.tsx new file mode 100644 index 0000000000..e350bcd6e5 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/sidebar/sidebar-filters.tsx @@ -0,0 +1,47 @@ +import { ReactNode } from 'react' +import { useTranslation } from 'react-i18next' +import { Button } from 'react-bootstrap' +import { + Filter, + useProjectListContext, +} from '../../context/project-list-context' +import TagsList from './tags-list' + +type SidebarFilterProps = { + filter: Filter + text: ReactNode +} +function SidebarFilter({ filter, text }: SidebarFilterProps) { + const { + filter: activeFilter, + selectFilter, + selectedTag, + } = useProjectListContext() + + return ( +
  • + +
  • + ) +} + +export default function SidebarFilters() { + const { t } = useTranslation() + + return ( +
    +
      + + + + + + +
    +
    + ) +} diff --git a/services/web/frontend/js/features/project-list/components/sidebar/tags-list.tsx b/services/web/frontend/js/features/project-list/components/sidebar/tags-list.tsx new file mode 100644 index 0000000000..f15a47a17a --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/sidebar/tags-list.tsx @@ -0,0 +1,179 @@ +import _ from 'lodash' +import { useCallback, useState } from 'react' +import { Button } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import { Tag } from '../../../../../../app/src/Features/Tags/types' +import ColorManager from '../../../../ide/colors/ColorManager' +import Icon from '../../../../shared/components/icon' +import { useProjectListContext } from '../../context/project-list-context' +import CreateTagModal from './create-tag-modal' +import DeleteTagModal from './delete-tag-modal' +import RenameTagModal from './rename-tag-modal' + +export default function TagsList() { + const { t } = useTranslation() + const { + tags, + untaggedProjectsCount, + selectedTag, + selectTag, + addTag, + renameTag, + deleteTag, + } = useProjectListContext() + + const [creatingTag, setCreatingTag] = useState(false) + const [renamingTag, setRenamingTag] = useState() + const [deletingTag, setDeletingTag] = useState() + + const handleSelectTag = useCallback( + (e, tagId) => { + e.preventDefault() + selectTag(tagId) + }, + [selectTag] + ) + + const openCreateTagModal = useCallback(() => { + setCreatingTag(true) + }, [setCreatingTag]) + + const onCreate = useCallback( + (tag: Tag) => { + setCreatingTag(false) + addTag(tag) + }, + [addTag] + ) + + const handleRenameTag = useCallback( + (e, tagId) => { + e.preventDefault() + const tag = _.find(tags, ['_id', tagId]) + if (tag) { + setRenamingTag(tag) + } + }, + [tags, setRenamingTag] + ) + + const onRename = useCallback( + (tagId: string, newTagName: string) => { + renameTag(tagId, newTagName) + setRenamingTag(undefined) + }, + [renameTag, setRenamingTag] + ) + + const handleDeleteTag = useCallback( + (e, tagId) => { + e.preventDefault() + const tag = _.find(tags, ['_id', tagId]) + if (tag) { + setDeletingTag(tag) + } + }, + [tags, setDeletingTag] + ) + + const onDelete = useCallback( + tagId => { + deleteTag(tagId) + setDeletingTag(undefined) + }, + [deleteTag, setDeletingTag] + ) + + return ( + <> +
  • +

    {t('tags_slash_folders')}

    +
  • +
  • + +
  • + {_.sortBy(tags, ['name']).map((tag, index) => { + return ( +
  • + + + +
      +
    • + +
    • +
    • + +
    • +
    +
    +
  • + ) + })} +
  • + +
  • + setCreatingTag(false)} + /> + setRenamingTag(undefined)} + /> + setDeletingTag(undefined)} + /> + + ) +} diff --git a/services/web/frontend/js/features/project-list/components/survey-widget.tsx b/services/web/frontend/js/features/project-list/components/survey-widget.tsx new file mode 100644 index 0000000000..ee5e9129ca --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/survey-widget.tsx @@ -0,0 +1,42 @@ +import usePersistedState from '../../../shared/hooks/use-persisted-state' +import getMeta from '../../../utils/meta' +import { Survey } from '../../../../../types/project/dashboard/survey' +import { useCallback } from 'react' + +export default function SurveyWidget() { + const survey: Survey = getMeta('ol-survey') + const [dismissedSurvey, setDismissedSurvey] = usePersistedState( + `dismissed-${survey?.name}`, + false + ) + + const dismissSurvey = useCallback(() => { + setDismissedSurvey(true) + }, [setDismissedSurvey]) + + if (!survey?.name || dismissedSurvey) { + return null + } + + return ( +
    + {survey.preText}  + + {survey.linkText} + + +
    + ) +} diff --git a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/archive-project-button.tsx b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/archive-project-button.tsx new file mode 100644 index 0000000000..24dcd312c0 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/archive-project-button.tsx @@ -0,0 +1,84 @@ +import { useTranslation } from 'react-i18next' +import { Project } from '../../../../../../../../types/project/dashboard/api' +import { memo, useCallback, useState } from 'react' +import Icon from '../../../../../../shared/components/icon' +import Tooltip from '../../../../../../shared/components/tooltip' +import ProjectsActionModal from '../../projects-action-modal' +import useIsMounted from '../../../../../../shared/hooks/use-is-mounted' +import { useProjectListContext } from '../../../../context/project-list-context' +import { archiveProject } from '../../../../util/api' + +type ArchiveProjectButtonProps = { + project: Project +} + +function ArchiveProjectButton({ project }: ArchiveProjectButtonProps) { + const { updateProjectViewData } = useProjectListContext() + const { t } = useTranslation() + const text = t('archive') + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const handleArchiveProject = useCallback(async () => { + await archiveProject(project.id) + + // update view + project.archived = true + updateProjectViewData(project) + }, [project, updateProjectViewData]) + + if (project.archived) return null + + return ( + <> + + + + + {t('about_to_archive_projects')}

    } + bodyBottom={ +

    + {t('archiving_projects_wont_affect_collaborators')}{' '} + + {t('find_out_more_nt')} + +

    + } + showModal={showModal} + handleCloseModal={handleCloseModal} + projects={[project]} + /> + + ) +} + +export default memo(ArchiveProjectButton) diff --git a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/copy-project-button.tsx b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/copy-project-button.tsx new file mode 100644 index 0000000000..322cc9ee8f --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/copy-project-button.tsx @@ -0,0 +1,31 @@ +import { useTranslation } from 'react-i18next' +import { memo } from 'react' +import { Project } from '../../../../../../../../types/project/dashboard/api' +import Icon from '../../../../../../shared/components/icon' +import Tooltip from '../../../../../../shared/components/tooltip' + +type CopyButtonProps = { + project: Project +} + +function CopyProjectButton({ project }: CopyButtonProps) { + const { t } = useTranslation() + const text = t('copy') + + if (project.archived || project.trashed) return null + + return ( + + + + ) +} + +export default memo(CopyProjectButton) diff --git a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/delete-project-button.tsx b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/delete-project-button.tsx new file mode 100644 index 0000000000..df4e3a4d2f --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/delete-project-button.tsx @@ -0,0 +1,34 @@ +import { useTranslation } from 'react-i18next' +import { memo, useMemo } from 'react' +import { Project } from '../../../../../../../../types/project/dashboard/api' +import Icon from '../../../../../../shared/components/icon' +import Tooltip from '../../../../../../shared/components/tooltip' + +type DeleteProjectButtonProps = { + project: Project +} + +function DeleteProjectButton({ project }: DeleteProjectButtonProps) { + const { t } = useTranslation() + const text = t('delete') + const isOwner = useMemo(() => { + return project.owner && window.user_id === project.owner.id + }, [project]) + + if (!project.trashed || !isOwner) return null + + return ( + + + + ) +} + +export default memo(DeleteProjectButton) diff --git a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button.tsx b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button.tsx new file mode 100644 index 0000000000..e724dcd607 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button.tsx @@ -0,0 +1,43 @@ +import { useTranslation } from 'react-i18next' +import { memo, useCallback } from 'react' +import { Project } from '../../../../../../../../types/project/dashboard/api' +import Icon from '../../../../../../shared/components/icon' +import Tooltip from '../../../../../../shared/components/tooltip' +import * as eventTracking from '../../../../../../infrastructure/event-tracking' + +type DownloadProjectButtonProps = { + project: Project +} + +function DownloadProjectButton({ project }: DownloadProjectButtonProps) { + const { t } = useTranslation() + const text = t('download') + + const downloadProject = useCallback(() => { + eventTracking.send( + 'project-list-page-interaction', + 'project action', + 'Download Zip' + ) + window.location.assign(`/project/${project.id}/download/zip`) + }, [project]) + + return ( + + + + ) +} + +export default memo(DownloadProjectButton) diff --git a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/leave-project-buttton.tsx b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/leave-project-buttton.tsx new file mode 100644 index 0000000000..19bc6943a9 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/leave-project-buttton.tsx @@ -0,0 +1,80 @@ +import { useTranslation } from 'react-i18next' +import { memo, useCallback, useMemo, useState } from 'react' +import { Project } from '../../../../../../../../types/project/dashboard/api' +import Icon from '../../../../../../shared/components/icon' +import Tooltip from '../../../../../../shared/components/tooltip' +import { useProjectListContext } from '../../../../context/project-list-context' +import useIsMounted from '../../../../../../shared/hooks/use-is-mounted' +import ProjectsActionModal from '../../projects-action-modal' +import { leaveProject } from '../../../../util/api' + +type LeaveProjectButtonProps = { + project: Project +} + +function LeaveProjectButton({ project }: LeaveProjectButtonProps) { + const { removeProjectFromView } = useProjectListContext() + const { t } = useTranslation() + const text = t('leave') + const isOwner = useMemo(() => { + return project.owner && window.user_id === project.owner.id + }, [project]) + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const handleLeaveProject = useCallback(async () => { + await leaveProject(project.id) + + // update view + removeProjectFromView(project) + }, [project, removeProjectFromView]) + + if (!project.trashed || isOwner) return null + + return ( + <> + + + + + {t('about_to_leave_projects')}

    } + bodyBottom={ +
    + {' '} + {t('this_action_cannot_be_undone')} +
    + } + showModal={showModal} + handleCloseModal={handleCloseModal} + projects={[project]} + /> + + ) +} + +export default memo(LeaveProjectButton) diff --git a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/trash-project-button.tsx b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/trash-project-button.tsx new file mode 100644 index 0000000000..1f7eb17067 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/trash-project-button.tsx @@ -0,0 +1,85 @@ +import { useTranslation } from 'react-i18next' +import { memo, useCallback, useState } from 'react' +import { Project } from '../../../../../../../../types/project/dashboard/api' +import Icon from '../../../../../../shared/components/icon' +import Tooltip from '../../../../../../shared/components/tooltip' +import ProjectsActionModal from '../../projects-action-modal' +import useIsMounted from '../../../../../../shared/hooks/use-is-mounted' +import { useProjectListContext } from '../../../../context/project-list-context' +import { trashProject } from '../../../../util/api' + +type TrashProjectButtonProps = { + project: Project +} + +function TrashProjectButton({ project }: TrashProjectButtonProps) { + const { updateProjectViewData } = useProjectListContext() + const { t } = useTranslation() + const text = t('trash') + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const handleTrashProject = useCallback(async () => { + await trashProject(project.id) + + // update view + project.trashed = true + project.archived = false + updateProjectViewData(project) + }, [project, updateProjectViewData]) + + if (project.trashed) return null + + return ( + <> + + + + + {t('about_to_trash_projects')}

    } + bodyBottom={ +

    + {t('trashing_projects_wont_affect_collaborators')}{' '} + + {t('find_out_more_nt')} + +

    + } + showModal={showModal} + handleCloseModal={handleCloseModal} + projects={[project]} + /> + + ) +} + +export default memo(TrashProjectButton) diff --git a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/unarchive-project-button.tsx b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/unarchive-project-button.tsx new file mode 100644 index 0000000000..e2925c720a --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/unarchive-project-button.tsx @@ -0,0 +1,46 @@ +import { useTranslation } from 'react-i18next' +import { memo, useCallback } from 'react' +import { Project } from '../../../../../../../../types/project/dashboard/api' +import Icon from '../../../../../../shared/components/icon' +import Tooltip from '../../../../../../shared/components/tooltip' +import { useProjectListContext } from '../../../../context/project-list-context' +import { unarchiveProject } from '../../../../util/api' + +type UnarchiveProjectButtonProps = { + project: Project +} + +function UnarchiveProjectButton({ project }: UnarchiveProjectButtonProps) { + const { t } = useTranslation() + const text = t('unarchive') + const { updateProjectViewData } = useProjectListContext() + + const handleUnarchiveProject = useCallback(async () => { + await unarchiveProject(project.id) + + // update view + project.archived = false + updateProjectViewData(project) + }, [project, updateProjectViewData]) + + if (!project.archived) return null + + return ( + + + + ) +} + +export default memo(UnarchiveProjectButton) diff --git a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/untrash-project-button.tsx b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/untrash-project-button.tsx new file mode 100644 index 0000000000..a6cd41c1de --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/untrash-project-button.tsx @@ -0,0 +1,45 @@ +import { useTranslation } from 'react-i18next' +import { memo, useCallback } from 'react' +import { Project } from '../../../../../../../../types/project/dashboard/api' +import Icon from '../../../../../../shared/components/icon' +import Tooltip from '../../../../../../shared/components/tooltip' +import { useProjectListContext } from '../../../../context/project-list-context' +import { untrashProject } from '../../../../util/api' + +type UntrashProjectButtonProps = { + project: Project +} + +function UntrashProjectButton({ project }: UntrashProjectButtonProps) { + const { t } = useTranslation() + const text = t('untrash') + const { updateProjectViewData } = useProjectListContext() + + const handleUntrashProject = useCallback(async () => { + await untrashProject(project.id) + // update view + project.trashed = false + updateProjectViewData(project) + }, [project, updateProjectViewData]) + + if (!project.trashed) return null + + return ( + + + + ) +} + +export default memo(UntrashProjectButton) diff --git a/services/web/frontend/js/features/project-list/components/table/cells/actions-cell.tsx b/services/web/frontend/js/features/project-list/components/table/cells/actions-cell.tsx new file mode 100644 index 0000000000..fd76fabad9 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/table/cells/actions-cell.tsx @@ -0,0 +1,27 @@ +import { Project } from '../../../../../../../types/project/dashboard/api' +import CopyProjectButton from './action-buttons/copy-project-button' +import ArchiveProjectButton from './action-buttons/archive-project-button' +import TrashProjectButton from './action-buttons/trash-project-button' +import UnarchiveProjectButton from './action-buttons/unarchive-project-button' +import UntrashProjectButton from './action-buttons/untrash-project-button' +import DownloadProjectButton from './action-buttons/download-project-button' +import LeaveProjectButton from './action-buttons/leave-project-buttton' +import DeleteProjectButton from './action-buttons/delete-project-button' + +type ActionsCellProps = { + project: Project +} +export default function ActionsCell({ project }: ActionsCellProps) { + return ( + <> + + + + + + + + + + ) +} diff --git a/services/web/frontend/js/features/project-list/components/table/cells/inline-tags.tsx b/services/web/frontend/js/features/project-list/components/table/cells/inline-tags.tsx new file mode 100644 index 0000000000..f889d8b871 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/table/cells/inline-tags.tsx @@ -0,0 +1,53 @@ +import { useTranslation } from 'react-i18next' +import { Tag } from '../../../../../../../app/src/Features/Tags/types' +import ColorManager from '../../../../../ide/colors/ColorManager' +import Icon from '../../../../../shared/components/icon' +import { useProjectListContext } from '../../../context/project-list-context' + +type InlineTagsProps = { + projectId: string +} + +function InlineTags({ projectId }: InlineTagsProps) { + const { tags } = useProjectListContext() + + return ( + + {tags + .filter(tag => tag.project_ids?.includes(projectId)) + .map((tag, index) => ( + + ))} + + ) +} + +function InlineTag({ tag }: { tag: Tag }) { + const { t } = useTranslation() + + return ( +
    + + +
    + ) +} + +export default InlineTags diff --git a/services/web/frontend/js/features/project-list/components/table/cells/last-updated-cell.tsx b/services/web/frontend/js/features/project-list/components/table/cells/last-updated-cell.tsx new file mode 100644 index 0000000000..d4c98640a7 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/table/cells/last-updated-cell.tsx @@ -0,0 +1,31 @@ +import { useTranslation } from 'react-i18next' +import { formatDate, fromNowDate } from '../../../../../utils/dates' +import { Project } from '../../../../../../../types/project/dashboard/api' +import Tooltip from '../../../../../shared/components/tooltip' +import { getUserName } from '../../../util/user' + +type LastUpdatedCellProps = { + project: Project +} + +export default function LastUpdatedCell({ project }: LastUpdatedCellProps) { + const { t } = useTranslation() + const displayText = project.lastUpdatedBy + ? t('last_updated_date_by_x', { + lastUpdatedDate: fromNowDate(project.lastUpdated), + person: getUserName(project.lastUpdatedBy), + }) + : fromNowDate(project.lastUpdated) + const tooltipText = formatDate(project.lastUpdated) + return ( + + {/* OverlayTrigger won't fire unless icon is wrapped in a span */} + {displayText} + + ) +} diff --git a/services/web/frontend/js/features/project-list/components/table/cells/owner-cell.tsx b/services/web/frontend/js/features/project-list/components/table/cells/owner-cell.tsx new file mode 100644 index 0000000000..cb1b69140c --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/table/cells/owner-cell.tsx @@ -0,0 +1,51 @@ +import { useTranslation } from 'react-i18next' +import Icon from '../../../../../shared/components/icon' +import Tooltip from '../../../../../shared/components/tooltip' +import { getOwnerName } from '../../../util/project' +import { Project } from '../../../../../../../types/project/dashboard/api' + +type LinkSharingIconProps = { + prependSpace: boolean + project: Project +} + +function LinkSharingIcon({ project, prependSpace }: LinkSharingIconProps) { + const { t } = useTranslation() + return ( + + {/* OverlayTrigger won't fire unless icon is wrapped in a span */} + + {prependSpace ? ' ' : ''} + + + + ) +} + +type OwnerCellProps = { + project: Project +} + +export default function OwnerCell({ project }: OwnerCellProps) { + const ownerName = getOwnerName(project) + + return ( + <> + {ownerName} + {project.source === 'token' ? ( + + ) : ( + '' + )} + + ) +} diff --git a/services/web/frontend/js/features/project-list/components/table/project-list-table-row.tsx b/services/web/frontend/js/features/project-list/components/table/project-list-table-row.tsx new file mode 100644 index 0000000000..003ed80493 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/table/project-list-table-row.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from 'react-i18next' +import { Project } from '../../../../../../types/project/dashboard/api' +import InlineTags from './cells/inline-tags' +import OwnerCell from './cells/owner-cell' +import LastUpdatedCell from './cells/last-updated-cell' +import ActionsCell from './cells/actions-cell' + +type ProjectListTableRowProps = { + project: Project +} +export default function ProjectListTableRow({ + project, +}: ProjectListTableRowProps) { + const { t } = useTranslation() + + return ( + + + +