Merge pull request #9245 from overleaf/integration-project-dashboard-react-migration

[Integration branch] Project Dashboard React Migration

GitOrigin-RevId: 3c3db39109a8137c57995f5f7c0ff8c800f04c4e
This commit is contained in:
Alexandre Bourdin 2022-09-13 15:57:47 +02:00 committed by Copybot
parent 3687ca60bb
commit a0fabee3b4
118 changed files with 8441 additions and 69 deletions

2
package-lock.json generated
View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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)
}

View file

@ -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<void>}
*/
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<void>}
*/
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<void>}
* @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),
}

View file

@ -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<GetProjectsResponseBody>
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[]
}

View file

@ -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<Subscription>}
*/
async function getBestSubscription(user) {
let [
individualSubscription,

View file

@ -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<Survey | undefined>}
*/
async function getSurvey(userId) {
const survey = await SurveyCache.get(true)
if (survey) {

View file

@ -0,0 +1,6 @@
export type Tag = {
_id: string
user_id: string
name: string
project_ids?: string[]
}

View file

@ -410,6 +410,7 @@ module.exports = function (webRouter, privateApiRouter, publicApiRouter) {
Settings.analytics.ga &&
Settings.analytics.ga.tokenV4,
cookieDomain: Settings.cookieDomain,
templateLinks: Settings.templateLinks,
}
next()
})

View file

@ -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

View file

@ -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

View file

@ -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(

View file

@ -783,6 +783,7 @@ module.exports = {
sourceEditorCompletionSources: [],
integrationLinkingWidgets: [],
referenceLinkingWidgets: [],
importProjectFromGithubModalWrapper: [],
},
moduleImportSequence: ['launchpad', 'server-ce-scripts', 'user-activate'],

View file

@ -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></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": "",

View file

@ -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<CommonsPlanSubscription, 'subscription' | 'plan'>
function CommonsPlan({ subscription, plan }: CommonsPlanProps) {
const { t } = useTranslation()
return (
<Tooltip
description={t('commons_plan_tooltip', {
plan: plan.name,
institution: subscription.name,
})}
id="commons-plan"
overlayProps={{ placement: 'bottom' }}
>
<a
href="/learn/how-to/Overleaf_premium_features"
className="current-plan-label"
>
<Trans i18nKey="premium_plan_label" components={{ b: <strong /> }} />{' '}
<span className="info-badge" />
</a>
</Tooltip>
)
}
export default CommonsPlan

View file

@ -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 = <FreePlan />
}
if (isIndividualPlan) {
currentPlan = (
<IndividualPlan
remainingTrialDays={usersBestSubscription.remainingTrialDays}
plan={usersBestSubscription.plan}
/>
)
}
if (isGroupPlan) {
currentPlan = (
<GroupPlan
subscription={usersBestSubscription.subscription}
remainingTrialDays={usersBestSubscription.remainingTrialDays}
plan={usersBestSubscription.plan}
/>
)
}
if (isCommonsPlan) {
currentPlan = (
<CommonsPlan
subscription={usersBestSubscription.subscription}
plan={usersBestSubscription.plan}
/>
)
}
return <div className="current-plan">{currentPlan}</div>
}
export default CurrentPlanWidget

View file

@ -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 (
<>
<Tooltip
description={t('free_plan_tooltip')}
id="free-plan"
overlayProps={{ placement: 'bottom' }}
>
<a
href="/learn/how-to/Overleaf_premium_features"
className="current-plan-label"
>
<Trans i18nKey="free_plan_label" components={{ b: <strong /> }} />{' '}
<span className="info-badge" />
</a>
</Tooltip>{' '}
<Button
bsStyle="primary"
href="/user/subscription/plans"
onClick={handleClick}
>
{t('upgrade')}
</Button>
</>
)
}
export default FreePlan

View file

@ -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 (
<Tooltip
description={t(
subscription.teamName != null
? 'group_plan_with_name_tooltip'
: 'group_plan_tooltip',
{ plan: plan.name, groupName: subscription.teamName }
)}
id="group-plan"
overlayProps={{ placement: 'bottom' }}
>
<a
href="/learn/how-to/Overleaf_premium_features"
className="current-plan-label"
>
{remainingTrialDays >= 0 ? (
remainingTrialDays === 1 ? (
<Trans i18nKey="trial_last_day" components={{ b: <strong /> }} />
) : (
<Trans
i18nKey="trial_remaining_days"
components={{ b: <strong /> }}
values={{ days: remainingTrialDays }}
/>
)
) : (
<Trans i18nKey="premium_plan_label" components={{ b: <strong /> }} />
)}{' '}
<span className="info-badge" />
</a>
</Tooltip>
)
}
export default GroupPlan

View file

@ -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 (
<Tooltip
description={t('plan_tooltip', { plan: plan.name })}
id="individual-plan"
overlayProps={{ placement: 'bottom' }}
>
<a
href="/learn/how-to/Overleaf_premium_features"
className="current-plan-label"
>
{remainingTrialDays >= 0 ? (
remainingTrialDays === 1 ? (
<Trans i18nKey="trial_last_day" components={{ b: <strong /> }} />
) : (
<Trans
i18nKey="trial_remaining_days"
components={{ b: <strong /> }}
values={{ days: remainingTrialDays }}
/>
)
) : (
<Trans i18nKey="premium_plan_label" components={{ b: <strong /> }} />
)}{' '}
<span className="info-badge" />
</a>
</Tooltip>
)
}
export default IndividualPlan

View file

@ -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<Nullable<NewProjectButtonModalVariant>>(null)
return (
<>
<ControlledDropdown id="new-project-button">
<Dropdown.Toggle
noCaret
className="new-project-button"
bsStyle="primary"
>
{buttonText || t('new_project')}
</Dropdown.Toggle>
<Dropdown.Menu>
<MenuItem onClick={() => setModal('blank_project')}>
{t('blank_project')}
</MenuItem>
<MenuItem onClick={() => setModal('example_project')}>
{t('example_project')}
</MenuItem>
<MenuItem onClick={() => setModal('upload_project')}>
{t('upload_project')}
</MenuItem>
<MenuItem onClick={() => setModal('import_from_github')}>
{t('import_from_github')}
</MenuItem>
<MenuItem divider />
<MenuItem header>{t('templates')}</MenuItem>
{templateLinks.map((templateLink, index) => (
<MenuItem
key={`new-project-button-template-${index}`}
href={templateLink.url}
>
{templateLink.name === 'view_all'
? t('view_all')
: templateLink.name}
</MenuItem>
))}
</Dropdown.Menu>
</ControlledDropdown>
<NewProjectButtonModal modal={modal} onHide={() => setModal(null)} />
</>
)
}
export default NewProjectButton

View file

@ -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 (
<AccessibleModal
show
animation
onHide={onHide}
id="blank-project-modal"
backdrop="static"
>
<ModalContentNewProjectForm onCancel={onHide} />
</AccessibleModal>
)
}
export default BlankProjectModal

View file

@ -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 (
<AccessibleModal
show
animation
onHide={onHide}
id="example-project-modal"
backdrop="static"
>
<ModalContentNewProjectForm onCancel={onHide} template="example" />
</AccessibleModal>
)
}
export default ExampleProjectModal

View file

@ -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<NewProjectData>()
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<HTMLInputElement & FormControl>
) => {
setProjectName(e.currentTarget.value)
}
return (
<>
<Modal.Header closeButton>
<Modal.Title>{t('new_project')}</Modal.Title>
</Modal.Header>
<Modal.Body>
{isError && (
<Alert bsStyle="danger">{getUserFacingMessage(error)}</Alert>
)}
<FormControl
type="text"
placeholder={t('project_name')}
onChange={handleChangeName}
value={projectName}
/>
</Modal.Body>
<Modal.Footer>
<Button onClick={onCancel}>{t('cancel')}</Button>
<Button
bsStyle="primary"
onClick={createNewProject}
disabled={projectName === '' || isLoading}
>
{t('create')}
</Button>
</Modal.Footer>
</>
)
}
export default ModalContentNewProjectForm

View file

@ -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<NewProjectButtonModalVariant>
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 <BlankProjectModal onHide={onHide} />
case 'example_project':
return <ExampleProjectModal onHide={onHide} />
case 'upload_project':
return <UploadProjectModal onHide={onHide} />
case 'import_from_github':
return <ImportProjectFromGithubModalWrapper onHide={onHide} />
default:
return null
}
}
export default NewProjectButtonModal

View file

@ -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<Uppy.StrictTypes> = 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 (
<AccessibleModal
show
animation
onHide={onHide}
id="upload-project-modal"
backdrop="static"
>
<Modal.Header closeButton>
<Modal.Title componentClass="h3">
{t('upload_zipped_project')}
</Modal.Title>
</Modal.Header>
<Modal.Body>
<Dashboard
uppy={uppy}
proudlyDisplayPoweredByUppy={false}
showLinkToFileUploadResult={false}
hideUploadButton
showSelectedFiles={false}
height={300}
locale={{
strings: {
browseFiles: 'Select a .zip file',
dropPasteFiles: '%{browseFiles} or \n\n drag a .zip file',
},
}}
className="project-list-upload-project-modal-uppy-dashboard"
/>
</Modal.Body>
<Modal.Footer>
<Button onClick={onHide}>{t('cancel')}</Button>
</Modal.Footer>
</AccessibleModal>
)
}
export default UploadProjectModal

View file

@ -0,0 +1,9 @@
import classnames from 'classnames'
function Action({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div className={classnames('notification-action', className)} {...props} />
)
}
export default Action

View file

@ -0,0 +1,9 @@
import classnames from 'classnames'
function Body({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div className={classnames('notification-body', className)} {...props} />
)
}
export default Body

View file

@ -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 (
<div className={classnames('notification-close', className)} {...props}>
<button type="button" className="close pull-right" onClick={onDismiss}>
<span aria-hidden="true">&times;</span>
<span className="sr-only">{t('close')}</span>
</button>
</div>
)
}
export default Close

View file

@ -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 (
<div className="w-100">
<Trans
i18nKey="please_check_your_inbox_to_confirm"
components={[<b />]} // eslint-disable-line react/jsx-key
values={{ institutionName: institution.name }}
/>
&nbsp;
{isLoading ? (
<>
<Icon type="refresh" spin fw /> {t('sending')}&hellip;
</>
) : (
<Button
className="btn-inline-link"
disabled={isLoading}
onClick={handleRequestReconfirmation}
>
{t('resend_confirmation_email')}
</Button>
)}
{isError && (
<>
<br />
<div>{t('generic_something_went_wrong')}</div>
</>
)}
</div>
)
}
return (
<div className="w-100">
<Icon type="warning" />
<Button
bsStyle="info"
bsSize="sm"
className="btn-reconfirm"
onClick={handleRequestReconfirmation}
disabled={isLoading || isPending}
>
{isLoading ? (
<>
<Icon type="refresh" spin fw /> {t('sending')}&hellip;
</>
) : (
t('confirm_affiliation')
)}
</Button>
<Trans
i18nKey="are_you_still_at"
components={[<b />]} // eslint-disable-line react/jsx-key
values={{ institutionName: institution.name }}
/>
&nbsp;
<Trans
i18nKey="please_reconfirm_institutional_email"
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
components={[<a href={`/user/settings?remove=${email}`} />]}
/>
&nbsp;
<a href="/learn/how-to/Institutional_Email_Reconfirmation">
{t('learn_more')}
</a>
{isError && (
<>
<br />
<div>{t('generic_something_went_wrong')}</div>
</>
)}
</div>
)
}
export default ReconfirmAffiliation

View file

@ -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 ? (
<Notification
key={`reconfirmation-period-email-${userEmail.email}`}
bsStyle="info"
>
<Notification.Body>
<div className="reconfirm-notification">
<ReconfirmAffiliation
email={userEmail.email}
institution={userEmail.affiliation.institution}
/>
</div>
</Notification.Body>
</Notification>
) : null
)}
{userEmails.map(userEmail =>
userEmail.samlProviderId === reconfirmedViaSAML &&
userEmail.affiliation?.institution ? (
<Notification
key={`samlIdentifier-email-${userEmail.email}`}
bsStyle="info"
onDismiss={() => {}}
>
<Notification.Body>
<ReconfirmationInfoSuccess
institution={userEmail.affiliation?.institution}
/>
</Notification.Body>
</Notification>
) : null
)}
</>
)
}
export default ReconfirmationInfo

View file

@ -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<User, 'features'>
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) => (
<Fragment key={index}>
{templateKey === 'notification_project_invite' ? (
<Notification
bsStyle="info"
onDismiss={() => id && handleDismiss(id)}
>
<Notification.Body>
{accepted ? (
<Trans
i18nKey="notification_project_invite_accepted_message"
components={{ b: <b /> }}
values={{ projectName: messageOpts.projectName }}
/>
) : (
<Trans
i18nKey="notification_project_invite_message"
components={{ b: <b /> }}
values={{
userName: messageOpts.userName,
projectName: messageOpts.projectName,
}}
/>
)}
</Notification.Body>
<Notification.Action>
{accepted ? (
<Button
bsStyle="info"
bsSize="sm"
className="pull-right"
href={`/project/${messageOpts.projectId}`}
>
{t('open_project')}
</Button>
) : (
<Button
bsStyle="info"
bsSize="sm"
disabled={isLoading}
onClick={() =>
handleAcceptInvite(
messageOpts.projectId,
messageOpts.token
)
}
>
{isLoading ? (
<>
<Icon type="spinner" spin /> {t('joining')}&hellip;
</>
) : (
t('join_project')
)}
</Button>
)}
</Notification.Action>
</Notification>
) : templateKey === 'wfh_2020_upgrade_offer' ? (
<Notification
bsStyle="info"
onDismiss={() => id && handleDismiss(id)}
>
<Notification.Body>
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.
</Notification.Body>
<Notification.Action>
<Button
bsStyle="info"
bsSize="sm"
className="pull-right"
href="https://www.overleaf.com/events/wfh2020"
>
View
</Button>
</Notification.Action>
</Notification>
) : templateKey === 'notification_ip_matched_affiliation' ? (
<Notification
bsStyle="info"
onDismiss={() => id && handleDismiss(id)}
>
<Notification.Body>
<Trans
i18nKey="looks_like_youre_at"
components={[<b />]} // eslint-disable-line react/jsx-key
values={{
institutionName: messageOpts.university_name,
}}
/>
<br />
{messageOpts.ssoEnabled ? (
<>
<Trans
i18nKey="you_can_now_log_in_sso"
components={[<b />]} // eslint-disable-line react/jsx-key
/>
<br />
{t('link_institutional_email_get_started')}{' '}
<a
href={
messageOpts.portalPath ||
'https://www.overleaf.com/learn/how-to/Institutional_Login'
}
>
{t('find_out_more_nt')}
</a>
</>
) : (
<>
<Trans
i18nKey="did_you_know_institution_providing_professional"
components={[<b />]} // eslint-disable-line react/jsx-key
values={{
institutionName: messageOpts.university_name,
}}
/>
<br />
{t('add_email_to_claim_features')}
</>
)}
</Notification.Body>
<Notification.Action>
<Button
bsStyle="info"
bsSize="sm"
className="pull-right"
href={
messageOpts.ssoEnabled
? `${samlInitPath}?university_id=${messageOpts.institutionId}&auto=/project`
: '/user/settings'
}
>
{messageOpts.ssoEnabled
? t('link_account')
: t('add_affiliation')}
</Button>
</Notification.Action>
</Notification>
) : templateKey === 'notification_tpds_file_limit' ? (
<Notification
bsStyle="danger"
onDismiss={() => id && handleDismiss(id)}
>
<Notification.Body>
Error: Your project {messageOpts.projectName} has gone over
the 2000 file limit using an integration (e.g. Dropbox or
GitHub) <br />
Please decrease the size of your project to prevent further
errors.
</Notification.Body>
<Notification.Action>
<Button
bsStyle="danger"
bsSize="sm"
className="pull-right"
href="/user/settings"
>
Account Settings
</Button>
</Notification.Action>
</Notification>
) : templateKey ===
'notification_dropbox_duplicate_project_names' ? (
<Notification
bsStyle="warning"
onDismiss={() => id && handleDismiss(id)}
>
<Notification.Body>
<p>
<Trans
i18nKey="dropbox_duplicate_project_names"
components={[<b />]} // eslint-disable-line react/jsx-key
values={{ projectName: messageOpts.projectName }}
/>
</p>
<p>
<Trans
i18nKey="dropbox_duplicate_project_names_suggestion"
components={[<b />]} // eslint-disable-line react/jsx-key
/>{' '}
<a href="/learn/how-to/Dropbox_Synchronization#Troubleshooting">
{t('learn_more')}
</a>
.
</p>
</Notification.Body>
</Notification>
) : templateKey ===
'notification_dropbox_unlinked_due_to_lapsed_reconfirmation' ? (
<Notification
bsStyle="info"
onDismiss={() => id && handleDismiss(id)}
>
<Notification.Body>
<Trans
i18nKey="dropbox_unlinked_premium_feature"
components={[<b />]} // eslint-disable-line react/jsx-key
/>{' '}
{user.features?.dropbox ? (
<Trans
i18nKey="can_now_relink_dropbox"
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
components={[<a href="/user/settings#project-sync" />]}
/>
) : (
t('confirm_affiliation_to_relink_dropbox')
)}{' '}
<a href="/learn/how-to/Institutional_Email_Reconfirmation">
{t('learn_more')}
</a>
</Notification.Body>
</Notification>
) : (
<Notification
bsStyle="info"
onDismiss={() => id && handleDismiss(id)}
>
<Notification.Body>{html}</Notification.Body>
</Notification>
)}
</Fragment>
)
)}
</>
)
}
export default Common

View file

@ -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 (
<Notification bsStyle="warning">
<Notification.Body>
{isLoading ? (
<>
<Icon type="spinner" spin /> {t('resending_confirmation_email')}
&hellip;
</>
) : isError ? (
<div aria-live="polite">{getUserFacingMessage(error)}</div>
) : (
<>
{t('please_confirm_email', {
emailAddress: userEmail.email,
})}
<Button
bsStyle="link"
className="btn-inline-link"
onClick={() => handleResendConfirmationEmail(userEmail)}
>
({t('resend_confirmation_email')})
</Button>
</>
)}
</Notification.Body>
</Notification>
)
}
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) ? (
<ConfirmEmailNotification
key={`confirm-email-${userEmail.email}`}
userEmail={userEmail}
/>
) : null
})}
</>
)
}
export default ConfirmEmail

View file

@ -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
) => (
<Fragment key={index}>
{templateKey === 'notification_institution_sso_available' && (
<Notification bsStyle="info">
<Notification.Body>
<p>
<Trans
i18nKey="can_link_institution_email_acct_to_institution_acct"
components={{ b: <b /> }}
values={{ appName, email, institutionName }}
/>
</p>
<div>
<Trans
i18nKey="doing_this_allow_log_in_through_institution"
components={{ b: <b /> }}
values={{ appName }}
/>{' '}
<a href="/learn/how-to/Institutional_Login">
{t('learn_more')}
</a>
</div>
</Notification.Body>
<Notification.Action>
<Button
bsStyle="info"
bsSize="sm"
href={`${samlInitPath}?university_id=${institutionId}&auto=/project&email=${email}`}
>
{t('link_account')}
</Button>
</Notification.Action>
</Notification>
)}
{templateKey === 'notification_institution_sso_linked' && (
<Notification
bsStyle="info"
onDismiss={() => id && handleDismiss(id)}
>
<Notification.Body>
<Trans
i18nKey="account_has_been_link_to_institution_account"
components={{ b: <b /> }}
values={{ appName, email, institutionName }}
/>
</Notification.Body>
</Notification>
)}
{templateKey === 'notification_institution_sso_non_canonical' && (
<Notification
bsStyle="warning"
onDismiss={() => id && handleDismiss(id)}
>
<Notification.Body>
<Icon type="exclamation-triangle" fw />
<Trans
i18nKey="tried_to_log_in_with_email"
components={{ b: <b /> }}
values={{ appName, email: requestedEmail }}
/>{' '}
<Trans
i18nKey="in_order_to_match_institutional_metadata_associated"
components={{ b: <b /> }}
values={{ email: institutionEmail }}
/>
</Notification.Body>
</Notification>
)}
{templateKey ===
'notification_institution_sso_already_registered' && (
<Notification
bsStyle="info"
onDismiss={() => id && handleDismiss(id)}
>
<Notification.Body>
<Trans
i18nKey="tried_to_register_with_email"
components={{ b: <b /> }}
values={{ appName, email }}
/>{' '}
{t('we_logged_you_in')}
</Notification.Body>
<Notification.Action>
<Button
bsStyle="info"
bsSize="sm"
href="/learn/how-to/Institutional_Login"
>
{t('find_out_more')}
</Button>
</Notification.Action>
</Notification>
)}
{templateKey === 'notification_institution_sso_error' && (
<Notification
bsStyle="danger"
onDismiss={() => id && handleDismiss(id)}
>
<Notification.Body>
<Icon type="exclamation-triangle" fw />{' '}
{t('generic_something_went_wrong')}.
<div>
{error?.translatedMessage
? error?.translatedMessage
: error?.message}
</div>
{error?.tryAgain ? `${t('try_again')}.` : null}
</Notification.Body>
</Notification>
)}
</Fragment>
)
)}
</>
)
}
export default Institution

View file

@ -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

View file

@ -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 (
<li className={classnames('notification-entry', className)} {...props}>
<Alert bsStyle={bsStyle}>
{children}
{onDismiss ? <Close onDismiss={handleDismiss} /> : null}
</Alert>
</li>
)
}
Notification.Body = Body
Notification.Action = Action
export default Notification

View file

@ -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 (
<div className="user-notifications">
<ul className="list-unstyled">
<Common />
<Institution />
<ConfirmEmail />
<ReconfirmationInfo />
</ul>
</div>
)
}
export default UserNotifications

View file

@ -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 ? (
<ProjectListProvider>
<ProjectListPageContent />
</ProjectListProvider>
) : null
}
function ProjectListPageContent() {
const { totalProjectsCount, error, isLoading, loadProgress, setSearchText } =
useProjectListContext()
return isLoading ? (
<div className="loading-container">
<LoadingBranded loadProgress={loadProgress} />
</div>
) : (
<div className="project-list-row row fill">
<div className="project-list-wrapper">
{error ? <DashApiError /> : ''}
{totalProjectsCount > 0 ? (
<>
<Col md={2} xs={3} className="project-list-sidebar-wrapper">
<aside className="project-list-sidebar">
<NewProjectButton />
<SidebarFilters />
</aside>
<SurveyWidget />
</Col>
<Col md={10} xs={9} className="project-list-main">
<Row>
<Col xs={12}>
<UserNotifications />
</Col>
</Row>
<Row>
<Col md={7} xs={12}>
<SearchForm onChange={setSearchText} />
</Col>
<Col md={5} xs={12}>
<div className="project-tools">
<div className="text-right pull-right">
<CurrentPlanWidget />
</div>
</div>
</Col>
</Row>
<Row className="row-spaced">
<Col xs={12}>
<ProjectListTable />
</Col>
</Row>
</Col>
</>
) : (
<Row className="row-spaced">
<Col
xs={8}
xsOffset={2}
md={8}
mdOffset={2}
className="project-list-empty-col"
>
<WelcomeMessage />
</Col>
</Row>
)}
</div>
</div>
)
}
function DashApiError() {
const { t } = useTranslation()
return (
<Row className="row-spaced">
<Col xs={8} xsOffset={2} aria-live="polite" className="text-center">
<div className="alert alert-danger">
{t('generic_something_went_wrong')}
</div>
</Col>
</Row>
)
}
export default ProjectListRoot

View file

@ -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<FormControl, keyof HTMLInputElement>
>
) => {
eventTracking.send(
'project-list-page-interaction',
'project-search',
'keydown'
)
setInput(e.target.value)
}
const handleClear = () => setInput('')
return (
<Form
horizontal
className="project-search"
role="search"
onSubmit={e => e.preventDefault()}
>
<FormGroup className="has-feedback has-feedback-left">
<Col xs={12}>
<FormControl
type="text"
value={input}
onChange={handleChange}
placeholder={placeholder}
aria-label={placeholder}
/>
<Icon type="search" className="form-control-feedback-left" />
{input.length ? (
<div className="form-control-feedback">
<button
type="button"
className="project-search-clear-btn btn-link"
aria-label={t('clear_search')}
onClick={handleClear}
>
<Icon type="times" />
</button>
</div>
) : null}
</Col>
</FormGroup>
</Form>
)
}
export default SearchForm

View file

@ -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<Tag>()
const [tagName, setTagName] = useState<string>()
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 (
<AccessibleModal
show
animation
onHide={onClose}
id="rename-tag-modal"
backdrop="static"
>
<Modal.Header closeButton>
<Modal.Title>{t('create_new_folder')}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form name="createTagForm" onSubmit={handleSubmit}>
<input
className="form-control"
type="text"
placeholder="New Tag Name"
name="new-tag-name"
required
onChange={e => setTagName(e.target.value)}
/>
</Form>
</Modal.Body>
<Modal.Footer>
{isError && (
<div className="modal-footer-left">
<span className="text-danger error">
{t('generic_something_went_wrong')}
</span>
</div>
)}
<Button onClick={onClose} disabled={status === 'pending'}>
{t('cancel')}
</Button>
<Button
onClick={() => runCreateTag()}
bsStyle="primary"
disabled={status === 'pending' || !tagName?.length}
>
{status === 'pending' ? t('creating') + '...' : t('create')}
</Button>
</Modal.Footer>
</AccessibleModal>
)
}

View file

@ -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 (
<AccessibleModal
show
animation
onHide={onClose}
id="delete-tag-modal"
backdrop="static"
>
<Modal.Header closeButton>
<Modal.Title>{t('delete_folder')}</Modal.Title>
</Modal.Header>
<Modal.Body>
{t('about_to_delete_folder')}
<ul>
<li>{tag.name}</li>
</ul>
</Modal.Body>
<Modal.Footer>
{isError && (
<div className="modal-footer-left">
<span className="text-danger error">
{t('generic_something_went_wrong')}
</span>
</div>
)}
<Button onClick={onClose} disabled={status === 'pending'}>
{t('cancel')}
</Button>
<Button
onClick={() => runDeleteTag(tag._id)}
bsStyle="primary"
disabled={status === 'pending'}
>
{status === 'pending' ? t('deleting') + '...' : t('delete')}
</Button>
</Modal.Footer>
</AccessibleModal>
)
}

View file

@ -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<string>()
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 (
<AccessibleModal
show
animation
onHide={onClose}
id="rename-tag-modal"
backdrop="static"
>
<Modal.Header closeButton>
<Modal.Title>{t('rename_folder')}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form name="renameTagForm" onSubmit={handleSubmit}>
<input
className="form-control"
type="text"
placeholder="Tag Name"
name="new-tag-name"
value={newTagName === undefined ? tag.name : newTagName}
required
onChange={e => setNewTageName(e.target.value)}
/>
</Form>
</Modal.Body>
<Modal.Footer>
{isError && (
<div className="modal-footer-left">
<span className="text-danger error">
{t('generic_something_went_wrong')}
</span>
</div>
)}
<Button onClick={onClose} disabled={status === 'pending'}>
{t('cancel')}
</Button>
<Button
onClick={() => runRenameTag(tag._id)}
bsStyle="primary"
disabled={status === 'pending' || !newTagName?.length}
>
{status === 'pending' ? t('renaming') + '...' : t('rename')}
</Button>
</Modal.Footer>
</AccessibleModal>
)
}

View file

@ -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 (
<li
className={
selectedTag === undefined && filter === activeFilter ? 'active' : ''
}
>
<Button onClick={() => selectFilter(filter)}>{text}</Button>
</li>
)
}
export default function SidebarFilters() {
const { t } = useTranslation()
return (
<div className="row-spaced ng-scope">
<ul className="list-unstyled folders-menu">
<SidebarFilter filter="all" text={t('all_projects')} />
<SidebarFilter filter="owned" text={t('your_projects')} />
<SidebarFilter filter="shared" text={t('shared_with_you')} />
<SidebarFilter filter="archived" text={t('archived_projects')} />
<SidebarFilter filter="trashed" text={t('trashed_projects')} />
<TagsList />
</ul>
</div>
)
}

View file

@ -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<boolean>(false)
const [renamingTag, setRenamingTag] = useState<Tag>()
const [deletingTag, setDeletingTag] = useState<Tag>()
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 (
<>
<li className="separator">
<h2>{t('tags_slash_folders')}</h2>
</li>
<li className="tag">
<Button className="tag-name" onClick={openCreateTagModal}>
<Icon type="plus" />
<span className="name">{t('new_folder')}</span>
</Button>
</li>
{_.sortBy(tags, ['name']).map((tag, index) => {
return (
<li
className={`tag ${selectedTag === tag._id ? 'active' : ''}`}
key={index}
>
<Button
className="tag-name"
onClick={e => handleSelectTag(e, tag._id)}
>
<span
style={{
color: `hsl(${ColorManager.getHueForTagId(
tag._id
)}, 70%, 45%)`,
}}
>
<Icon
type={selectedTag === tag._id ? 'folder-open' : 'folder'}
/>
</span>
<span className="name">
{tag.name}{' '}
<span className="subdued"> ({tag.project_ids?.length})</span>
</span>
</Button>
<span className="dropdown tag-menu">
<button
className="dropdown-toggle"
data-toggle="dropdown"
dropdown-toggle=""
aria-haspopup="true"
aria-expanded="false"
>
<span className="caret" />
</button>
<ul className="dropdown-menu dropdown-menu-right" role="menu">
<li>
<Button
onClick={e => handleRenameTag(e, tag._id)}
className="tag-action"
>
{t('rename')}
</Button>
</li>
<li>
<Button
onClick={e => handleDeleteTag(e, tag._id)}
className="tag-action"
>
{t('delete')}
</Button>
</li>
</ul>
</span>
</li>
)
})}
<li className={`tag untagged ${selectedTag === null ? 'active' : ''}`}>
<Button className="tag-name" onClick={() => selectTag(null)}>
<span className="name">{t('uncategorized')}</span>
<span className="subdued"> ({untaggedProjectsCount})</span>
</Button>
</li>
<CreateTagModal
show={creatingTag}
onCreate={onCreate}
onClose={() => setCreatingTag(false)}
/>
<RenameTagModal
tag={renamingTag}
onRename={onRename}
onClose={() => setRenamingTag(undefined)}
/>
<DeleteTagModal
tag={deletingTag}
onDelete={onDelete}
onClose={() => setDeletingTag(undefined)}
/>
</>
)
}

View file

@ -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 (
<div className="project-list-sidebar-survey">
{survey.preText}&nbsp;
<a
className="project-list-sidebar-survey-link"
href={survey.url}
target="_blank"
rel="noreferrer noopener"
>
{survey.linkText}
</a>
<button
className="project-list-sidebar-survey-dismiss"
type="button"
title="Dismiss Overleaf survey"
onClick={dismissSurvey}
>
<span aria-hidden> &times;</span>
</button>
</div>
)
}

View file

@ -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 (
<>
<Tooltip
key={`tooltip-archive-project-${project.id}`}
id={`tooltip-archive-project-${project.id}`}
description={text}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
>
<button
className="btn btn-link action-btn"
aria-label={text}
onClick={handleOpenModal}
>
<Icon type="inbox" />
</button>
</Tooltip>
<ProjectsActionModal
title={t('archive_projects')}
action="archive"
actionHandler={handleArchiveProject}
bodyTop={<p>{t('about_to_archive_projects')}</p>}
bodyBottom={
<p>
{t('archiving_projects_wont_affect_collaborators')}{' '}
<a
href="https://www.overleaf.com/blog/new-feature-using-archive-and-trash-to-keep-your-projects-organized"
target="_blank"
rel="noreferrer"
>
{t('find_out_more_nt')}
</a>
</p>
}
showModal={showModal}
handleCloseModal={handleCloseModal}
projects={[project]}
/>
</>
)
}
export default memo(ArchiveProjectButton)

View file

@ -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 (
<Tooltip
key={`tooltip-copy-project-${project.id}`}
id={`tooltip-copy-project-${project.id}`}
description={text}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
>
<button className="btn btn-link action-btn" aria-label={text}>
<Icon type="files-o" />
</button>
</Tooltip>
)
}
export default memo(CopyProjectButton)

View file

@ -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 (
<Tooltip
key={`tooltip-delete-project-${project.id}`}
id={`tooltip-delete-project-${project.id}`}
description={text}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
>
<button className="btn btn-link action-btn" aria-label={text}>
<Icon type="ban" />
</button>
</Tooltip>
)
}
export default memo(DeleteProjectButton)

View file

@ -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 (
<Tooltip
key={`tooltip-download-project-${project.id}`}
id={`tooltip-download-project-${project.id}`}
description={text}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
>
<button
className="btn btn-link action-btn"
aria-label={text}
onClick={downloadProject}
>
<Icon type="cloud-download" />
</button>
</Tooltip>
)
}
export default memo(DownloadProjectButton)

View file

@ -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 (
<>
<Tooltip
key={`tooltip-leave-project-${project.id}`}
id={`tooltip-leave-project-${project.id}`}
description={text}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
>
<button
className="btn btn-link action-btn"
aria-label={text}
onClick={handleOpenModal}
>
<Icon type="sign-out" />
</button>
</Tooltip>
<ProjectsActionModal
title={t('leave_projects')}
action="trash"
actionHandler={handleLeaveProject}
bodyTop={<p>{t('about_to_leave_projects')}</p>}
bodyBottom={
<div className="project-action-alert alert alert-warning">
<Icon type="exclamation-triangle" fw />{' '}
{t('this_action_cannot_be_undone')}
</div>
}
showModal={showModal}
handleCloseModal={handleCloseModal}
projects={[project]}
/>
</>
)
}
export default memo(LeaveProjectButton)

View file

@ -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 (
<>
<Tooltip
key={`tooltip-trash-project-${project.id}`}
id={`tooltip-trash-project-${project.id}`}
description={text}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
>
<button
className="btn btn-link action-btn"
aria-label={text}
onClick={handleOpenModal}
>
<Icon type="trash" />
</button>
</Tooltip>
<ProjectsActionModal
title={t('trash_projects')}
action="trash"
actionHandler={handleTrashProject}
bodyTop={<p>{t('about_to_trash_projects')}</p>}
bodyBottom={
<p>
{t('trashing_projects_wont_affect_collaborators')}{' '}
<a
href="https://www.overleaf.com/blog/new-feature-using-archive-and-trash-to-keep-your-projects-organized"
target="_blank"
rel="noreferrer"
>
{t('find_out_more_nt')}
</a>
</p>
}
showModal={showModal}
handleCloseModal={handleCloseModal}
projects={[project]}
/>
</>
)
}
export default memo(TrashProjectButton)

View file

@ -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 (
<Tooltip
key={`tooltip-unarchive-project-${project.id}`}
id={`tooltip-unarchive-project-${project.id}`}
description={text}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
>
<button
className="btn btn-link action-btn"
aria-label={text}
onClick={handleUnarchiveProject}
>
<Icon type="reply" />
</button>
</Tooltip>
)
}
export default memo(UnarchiveProjectButton)

View file

@ -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 (
<Tooltip
key={`tooltip-untrash-project-${project.id}`}
id={`tooltip-untrash-project-${project.id}`}
description={text}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
>
<button
className="btn btn-link action-btn"
aria-label={text}
onClick={handleUntrashProject}
>
<Icon type="reply" />
</button>
</Tooltip>
)
}
export default memo(UntrashProjectButton)

View file

@ -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 (
<>
<CopyProjectButton project={project} />
<DownloadProjectButton project={project} />
<ArchiveProjectButton project={project} />
<TrashProjectButton project={project} />
<UnarchiveProjectButton project={project} />
<UntrashProjectButton project={project} />
<LeaveProjectButton project={project} />
<DeleteProjectButton project={project} />
</>
)
}

View file

@ -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 (
<span>
{tags
.filter(tag => tag.project_ids?.includes(projectId))
.map((tag, index) => (
<InlineTag tag={tag} key={index} />
))}
</span>
)
}
function InlineTag({ tag }: { tag: Tag }) {
const { t } = useTranslation()
return (
<div className="tag-label">
<button
className="label label-default tag-label-name"
aria-label={t('select_tag', { tagName: tag.name })}
>
<span
style={{
color: `hsl(${ColorManager.getHueForTagId(tag._id)}, 70%, 45%)`,
}}
>
<Icon type="circle" aria-hidden="true" />
</span>{' '}
{tag.name}
</button>
<button
className="label label-default tag-label-remove"
aria-label={t('remove_tag', { tagName: tag.name })}
>
<span aria-hidden="true">×</span>
</button>
</div>
)
}
export default InlineTags

View file

@ -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 (
<Tooltip
key={`tooltip-last-updated-${project.id}`}
id={`tooltip-last-updated-${project.id}`}
description={tooltipText}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
>
{/* OverlayTrigger won't fire unless icon is wrapped in a span */}
<span>{displayText}</span>
</Tooltip>
)
}

View file

@ -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 (
<Tooltip
key={`tooltip-link-sharing-${project.id}`}
id={`tooltip-link-sharing-${project.id}`}
description={t('link_sharing')}
overlayProps={{ placement: 'right', trigger: ['hover', 'focus'] }}
>
{/* OverlayTrigger won't fire unless icon is wrapped in a span */}
<span>
{prependSpace ? ' ' : ''}
<Icon
type="link"
className="small"
accessibilityLabel={t('link_sharing')}
/>
</span>
</Tooltip>
)
}
type OwnerCellProps = {
project: Project
}
export default function OwnerCell({ project }: OwnerCellProps) {
const ownerName = getOwnerName(project)
return (
<>
{ownerName}
{project.source === 'token' ? (
<LinkSharingIcon project={project} prependSpace={!!project.owner} />
) : (
''
)}
</>
)
}

View file

@ -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 (
<tr>
<td className="dash-cell-checkbox">
<input type="checkbox" id={`select-project-${project.id}`} />
<label
htmlFor={`select-project-${project.id}`}
aria-label={t('select_project', { project: project.name })}
className="sr-only"
/>
</td>
<td className="dash-cell-name">
<a href={`/project/${project.id}`}>{project.name}</a>{' '}
<InlineTags projectId={project.id} />
</td>
<td className="dash-cell-owner">
<OwnerCell project={project} />
</td>
<td className="dash-cell-date">
<LastUpdatedCell project={project} />
</td>
<td className="dash-cell-actions">
<ActionsCell project={project} />
</td>
</tr>
)
}

View file

@ -0,0 +1,180 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import Icon from '../../../../shared/components/icon'
import ProjectListTableRow from './project-list-table-row'
import { useProjectListContext } from '../../context/project-list-context'
import {
ownerNameComparator,
defaultComparator,
} from '../../util/sort-comparators'
import { Project, Sort } from '../../../../../../types/project/dashboard/api'
import { SortingOrder } from '../../../../../../types/sorting-order'
type SortByIconTableProps = {
column: string
sort: Sort
text: string
onClick: () => void
}
function SortByButton({ column, sort, text, onClick }: SortByIconTableProps) {
const { t } = useTranslation()
let icon
let screenReaderText = t('sort_by_x', { x: text })
if (column === sort.by) {
const iconType = sort.order === 'asc' ? 'caret-up' : 'caret-down'
icon = <Icon className="tablesort" type={iconType} />
screenReaderText = t('reverse_x_sort_order', { x: text })
}
return (
<button className="btn-link table-header-sort-btn" onClick={onClick}>
{text}
{icon}
<span className="sr-only">{screenReaderText}</span>
</button>
)
}
const toggleSort = (order: SortingOrder): SortingOrder => {
return order === 'asc' ? 'desc' : 'asc'
}
const order = (order: SortingOrder, projects: Project[]) => {
return order === 'asc' ? [...projects] : projects.reverse()
}
function ProjectListTable() {
const { t } = useTranslation()
const { visibleProjects, setVisibleProjects, sort, setSort } =
useProjectListContext()
useEffect(() => {
if (sort.by === 'title') {
setVisibleProjects(prevProjects => {
const sorted = [...prevProjects].sort((...args) => {
return defaultComparator(...args, 'name')
})
return order(sort.order, sorted)
})
}
if (sort.by === 'lastUpdated') {
setVisibleProjects(prevProjects => {
const sorted = [...prevProjects].sort((...args) => {
return defaultComparator(...args, 'lastUpdated')
})
return order(sort.order, sorted)
})
}
if (sort.by === 'owner') {
setVisibleProjects(prevProjects => {
const sorted = [...prevProjects].sort(ownerNameComparator)
return order(sort.order, sorted)
})
}
}, [sort.by, sort.order, setVisibleProjects])
const handleSortClick = (by: Sort['by']) => {
setSort(prev => ({
by,
order: prev.by === by ? toggleSort(sort.order) : sort.order,
}))
}
return (
<div className="card project-list-card">
<table className="project-dash-table">
<thead>
<tr>
<th
className="dash-cell-checkbox"
aria-label={t('select_projects')}
>
<input type="checkbox" id="project-list-table-select-all" />
<label
htmlFor="project-list-table-select-all"
aria-label={t('select_all_projects')}
className="sr-only"
/>
</th>
<th
className="dash-cell-name"
aria-label={t('title')}
aria-sort={
sort.by === 'title'
? sort.order === 'asc'
? t('ascending')
: t('descending')
: undefined
}
>
<SortByButton
column="title"
text={t('title')}
sort={sort}
onClick={() => handleSortClick('title')}
/>
</th>
<th
className="dash-cell-owner"
aria-label={t('owner')}
aria-sort={
sort.by === 'owner'
? sort.order === 'asc'
? t('ascending')
: t('descending')
: undefined
}
>
<SortByButton
column="owner"
text={t('owner')}
sort={sort}
onClick={() => handleSortClick('owner')}
/>
</th>
<th
className="dash-cell-date"
aria-label={t('last_modified')}
aria-sort={
sort.by === 'lastUpdated'
? sort.order === 'asc'
? t('ascending')
: t('descending')
: undefined
}
>
<SortByButton
column="lastUpdated"
text={t('last_modified')}
sort={sort}
onClick={() => handleSortClick('lastUpdated')}
/>
</th>
<th className="dash-cell-actions">{t('actions')}</th>
</tr>
</thead>
<tbody>
{visibleProjects.length ? (
visibleProjects.map((p: Project) => (
<ProjectListTableRow project={p} key={p.id} />
))
) : (
<tr>
<td className="project-list-table-no-projects-cell" colSpan={4}>
{t('no_projects')}
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}
export default ProjectListTable

View file

@ -0,0 +1,122 @@
import { memo, useEffect, useState } from 'react'
import { Alert, Modal } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { Project } from '../../../../../../types/project/dashboard/api'
import AccessibleModal from '../../../../shared/components/accessible-modal'
import { getUserFacingMessage } from '../../../../infrastructure/fetch-json'
import useIsMounted from '../../../../shared/hooks/use-is-mounted'
import * as eventTracking from '../../../../infrastructure/event-tracking'
type ProjectsActionModalProps = {
title: string
action: 'archive' | 'trash'
actionHandler: (project: Project) => Promise<void>
handleCloseModal: () => void
bodyTop: React.ReactNode
bodyBottom: React.ReactNode
projects: Array<Project>
showModal: boolean
}
function ProjectsActionModal({
title,
action,
actionHandler,
handleCloseModal,
bodyTop,
bodyBottom,
showModal,
projects,
}: ProjectsActionModalProps) {
const { t } = useTranslation()
const [errors, setErrors] = useState<Array<any>>([])
const [isProcessing, setIsProcessing] = useState(false)
const isMounted = useIsMounted()
async function handleActionForProjects(projects: Array<Project>) {
const errored = []
setIsProcessing(true)
setErrors([])
for (const project of projects) {
try {
await actionHandler(project)
} catch (e) {
errored.push({ projectName: project.name, error: e })
}
}
if (isMounted.current) {
setIsProcessing(false)
}
if (errored.length === 0) {
handleCloseModal()
} else {
setErrors(errored)
}
}
useEffect(() => {
if (showModal) {
eventTracking.send(
'project-list-page-interaction',
'project action',
action
)
}
}, [action, showModal])
return (
<AccessibleModal
animation
show={showModal}
onHide={handleCloseModal}
id="action-project-modal"
backdrop="static"
>
<Modal.Header closeButton>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>
<Modal.Body>
{bodyTop}
<ul>
{projects.map(project => (
<li key={`projects-action-list-${project.id}`}>
<b>{project.name}</b>
</li>
))}
</ul>
{bodyBottom}
</Modal.Body>
<Modal.Footer>
{!isProcessing &&
errors.length > 0 &&
errors.map((e, i) => (
<Alert
bsStyle="danger"
key={`action-error-${i}`}
className="text-center"
aria-live="polite"
>
<b>{e.projectName}</b>
<br />
{getUserFacingMessage(e.error)}
</Alert>
))}
<button className="btn btn-default" onClick={handleCloseModal}>
{t('cancel')}
</button>
<button
className="btn btn-danger"
onClick={() => handleActionForProjects(projects)}
disabled={isProcessing}
>
{t('confirm')}
</button>
</Modal.Footer>
</AccessibleModal>
)
}
export default memo(ProjectsActionModal)

View file

@ -0,0 +1,28 @@
import { Col, Row } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import NewProjectButton from './new-project-button'
export default function WelcomeMessage() {
const { t } = useTranslation()
return (
<div className="card card-thin">
<div className="welcome text-centered">
<h2>{t('welcome_to_sl')}</h2>
<p>
{t('new_to_latex_look_at')}&nbsp;
<a href="/templates">{t('templates').toLowerCase()}</a>
&nbsp;{t('or')}&nbsp;
<a href="/learn">{t('latex_help_guide')}</a>
</p>
<Row>
<Col md={4} mdOffset={4}>
<div className="dropdown minimal-create-proj-dropdown">
<NewProjectButton buttonText={t('create_first_project')} />
</div>
</Col>
</Row>
</div>
</div>
)
}

View file

@ -0,0 +1,275 @@
import _ from 'lodash'
import {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { Tag } from '../../../../../app/src/Features/Tags/types'
import {
GetProjectsResponseBody,
Project,
Sort,
} from '../../../../../types/project/dashboard/api'
import getMeta from '../../../utils/meta'
import useAsync from '../../../shared/hooks/use-async'
import { getProjects } from '../util/api'
export type Filter = 'all' | 'owned' | 'shared' | 'archived' | 'trashed'
type FilterMap = {
[key in Filter]: Partial<Project> | ((project: Project) => boolean) // eslint-disable-line no-unused-vars
}
const filters: FilterMap = {
all: {
archived: false,
trashed: false,
},
owned: {
accessLevel: 'owner',
archived: false,
trashed: false,
},
shared: project => {
return (
project.accessLevel !== 'owner' && !project.archived && !project.trashed
)
},
archived: {
archived: true,
trashed: false,
},
trashed: {
trashed: true,
},
}
type ProjectListContextValue = {
visibleProjects: Project[]
setVisibleProjects: React.Dispatch<React.SetStateAction<Project[]>>
totalProjectsCount: number
error: Error | null
isLoading: ReturnType<typeof useAsync>['isLoading']
loadProgress: number
sort: Sort
setSort: React.Dispatch<React.SetStateAction<Sort>>
tags: Tag[]
untaggedProjectsCount: number
filter: Filter
selectFilter: (filter: Filter) => void
selectedTag?: string | null
selectTag: (tagName: string | null) => void
addTag: (tag: Tag) => void
renameTag: (tagId: string, newTagName: string) => void
deleteTag: (tagId: string) => void
updateProjectViewData: (project: Project) => void
removeProjectFromView: (project: Project) => void
setSearchText: React.Dispatch<React.SetStateAction<string>>
}
export const ProjectListContext = createContext<
ProjectListContextValue | undefined
>(undefined)
type ProjectListProviderProps = {
children: ReactNode
}
export function ProjectListProvider({ children }: ProjectListProviderProps) {
const [loadedProjects, setLoadedProjects] = useState<Project[]>([])
const [visibleProjects, setVisibleProjects] = useState<Project[]>([])
const [loadProgress, setLoadProgress] = useState(20)
const [totalProjectsCount, setTotalProjectsCount] = useState<number>(0)
const [sort, setSort] = useState<Sort>({
by: 'lastUpdated',
order: 'desc',
})
const [filter, setFilter] = useState<Filter>('all')
const [selectedTag, setSelectedTag] = useState<string | null>()
const [tags, setTags] = useState<Tag[]>([])
const [searchText, setSearchText] = useState('')
const {
isLoading: loading,
isIdle,
error,
runAsync,
} = useAsync<GetProjectsResponseBody>()
const isLoading = isIdle ? true : loading
useEffect(() => setTags(getMeta('ol-tags', []) as Tag[]), [])
useEffect(() => {
setLoadProgress(40)
runAsync(getProjects({ by: 'lastUpdated', order: 'desc' }))
.then(data => {
setLoadedProjects(data.projects)
setTotalProjectsCount(data.totalSize)
})
.catch(error => console.error(error))
.finally(() => {
setLoadProgress(100)
})
}, [runAsync])
useEffect(() => {
let filteredProjects = [...loadedProjects]
if (searchText.length) {
filteredProjects = filteredProjects.filter(project =>
project.name.toLowerCase().includes(searchText.toLowerCase())
)
}
if (selectedTag !== undefined) {
if (selectedTag === null) {
const taggedProjectIds = _.uniq(
_.flatten(tags.map(tag => tag.project_ids))
)
filteredProjects = filteredProjects.filter(
project =>
!project.archived &&
!project.trashed &&
!taggedProjectIds.includes(project.id)
)
} else {
const tag = _.find(tags, tag => tag._id === selectedTag)
filteredProjects = filteredProjects.filter(project =>
tag?.project_ids?.includes(project.id)
)
}
} else {
filteredProjects = _.filter(filteredProjects, filters[filter])
}
setVisibleProjects(filteredProjects)
}, [loadedProjects, tags, filter, selectedTag, searchText])
const untaggedProjectsCount = useMemo(() => {
const taggedProjectIds = _.uniq(_.flatten(tags.map(tag => tag.project_ids)))
return loadedProjects.filter(
project =>
!project.archived &&
!project.trashed &&
!taggedProjectIds.includes(project.id)
).length
}, [tags, loadedProjects])
const selectFilter = useCallback((filter: Filter) => {
setFilter(filter)
setSelectedTag(undefined)
}, [])
const selectTag = useCallback((tagId: string | null) => {
setSelectedTag(tagId)
}, [])
const addTag = useCallback((tag: Tag) => {
setTags(tags => _.uniqBy(_.concat(tags, [tag]), '_id'))
}, [])
const renameTag = useCallback((tagId: string, newTagName: string) => {
setTags(tags => {
const newTags = _.cloneDeep(tags)
const tag = _.find(newTags, ['_id', tagId])
if (tag) {
tag.name = newTagName
}
return newTags
})
}, [])
const deleteTag = useCallback(
(tagId: string | null) => {
setTags(tags => tags.filter(tag => tag._id !== tagId))
},
[setTags]
)
const updateProjectViewData = useCallback(
(project: Project) => {
const projects = loadedProjects.map((p: Project) => {
if (p.id === project.id) {
p = project
}
return p
})
setLoadedProjects(projects)
},
[loadedProjects]
)
const removeProjectFromView = useCallback(
(project: Project) => {
const projects = loadedProjects.filter(
(p: Project) => p.id !== project.id
)
setLoadedProjects(projects)
},
[loadedProjects]
)
const value = useMemo<ProjectListContextValue>(
() => ({
addTag,
deleteTag,
error,
filter,
isLoading,
loadProgress,
renameTag,
selectedTag,
selectFilter,
selectTag,
setSearchText,
setSort,
setVisibleProjects,
sort,
tags,
totalProjectsCount,
untaggedProjectsCount,
updateProjectViewData,
visibleProjects,
removeProjectFromView,
}),
[
addTag,
deleteTag,
error,
filter,
isLoading,
loadProgress,
renameTag,
selectedTag,
selectFilter,
selectTag,
setSearchText,
setSort,
setVisibleProjects,
sort,
tags,
totalProjectsCount,
untaggedProjectsCount,
updateProjectViewData,
visibleProjects,
removeProjectFromView,
]
)
return (
<ProjectListContext.Provider value={value}>
{children}
</ProjectListContext.Provider>
)
}
export function useProjectListContext() {
const context = useContext(ProjectListContext)
if (!context) {
throw new Error(
'ProjectListContext is only available inside ProjectListProvider'
)
}
return context
}

View file

@ -0,0 +1,66 @@
import { Tag } from '../../../../../app/src/Features/Tags/types'
import {
GetProjectsResponseBody,
Sort,
} from '../../../../../types/project/dashboard/api'
import { deleteJSON, postJSON } from '../../../infrastructure/fetch-json'
export function getProjects(sortBy: Sort): Promise<GetProjectsResponseBody> {
return postJSON('/api/project', { body: { sort: sortBy } })
}
export function createTag(tagName: string): Promise<Tag> {
return postJSON(`/tag`, {
body: { name: tagName, _csrf: window.csrfToken },
})
}
export function renameTag(tagId: string, newTagName: string) {
return postJSON(`/tag/${tagId}/rename`, {
body: { name: newTagName, _csrf: window.csrfToken },
})
}
export function deleteTag(tagId: string) {
return deleteJSON(`/tag/${tagId}`, { body: { _csrf: window.csrfToken } })
}
export function archiveProject(projectId: string) {
return postJSON(`/project/${projectId}/archive`, {
body: {
_csrf: window.csrfToken,
},
})
}
export function leaveProject(projectId: string) {
return postJSON(`/project/${projectId}/leave`, {
body: {
_csrf: window.csrfToken,
},
})
}
export function trashProject(projectId: string) {
return postJSON(`/project/${projectId}/trash`, {
body: {
_csrf: window.csrfToken,
},
})
}
export function unarchiveProject(projectId: string) {
return deleteJSON(`/project/${projectId}/archive`, {
body: {
_csrf: window.csrfToken,
},
})
}
export function untrashProject(projectId: string) {
return deleteJSON(`/project/${projectId}/trash`, {
body: {
_csrf: window.csrfToken,
},
})
}

View file

@ -0,0 +1,14 @@
import { getUserName } from './user'
import { Project } from '../../../../../types/project/dashboard/api'
export function getOwnerName(project: Project) {
if (project.accessLevel === 'owner') {
return 'You'
}
if (project.owner != null) {
return getUserName(project.owner)
}
return ''
}

View file

@ -0,0 +1,65 @@
import { getOwnerName } from './project'
import { Project } from '../../../../../types/project/dashboard/api'
import { Compare } from '../../../../../types/array/sort'
export const ownerNameComparator = (v1: Project, v2: Project) => {
const ownerNameV1 = getOwnerName(v1)
const ownerNameV2 = getOwnerName(v2)
// sorting by owner === 'You' is with highest precedence
if (ownerNameV1 === 'You') {
if (ownerNameV2 === 'You') {
return v1.lastUpdated < v2.lastUpdated
? Compare.SORT_A_BEFORE_B
: Compare.SORT_A_AFTER_B
}
return Compare.SORT_A_AFTER_B
}
// empty owner name
if (ownerNameV1 === '') {
if (ownerNameV2 === '') {
return v1.lastUpdated < v2.lastUpdated
? Compare.SORT_A_BEFORE_B
: Compare.SORT_A_AFTER_B
}
return Compare.SORT_A_BEFORE_B
}
if (ownerNameV2 === 'You') {
return Compare.SORT_A_BEFORE_B
}
if (ownerNameV2 === '') {
return Compare.SORT_A_AFTER_B
}
if (v1.source === 'token') {
return Compare.SORT_A_BEFORE_B
}
if (v2.source === 'token') {
return Compare.SORT_A_AFTER_B
}
return ownerNameV1 > ownerNameV2
? Compare.SORT_A_BEFORE_B
: Compare.SORT_A_AFTER_B
}
export const defaultComparator = (
v1: Project,
v2: Project,
key: 'name' | 'lastUpdated'
) => {
const value1 = v1[key].toLowerCase()
const value2 = v2[key].toLowerCase()
if (value1 !== value2) {
return value1 < value2 ? Compare.SORT_A_BEFORE_B : Compare.SORT_A_AFTER_B
}
return Compare.SORT_KEEP_ORDER
}

View file

@ -0,0 +1,21 @@
import { UserRef } from '../../../../../types/project/dashboard/api'
export function getUserName(user: UserRef) {
if (user?.id === window.user_id) {
return 'You'
}
if (user) {
const { firstName, lastName, email } = user
if (firstName || lastName) {
return [firstName, lastName].filter(n => n != null).join(' ')
}
if (email) {
return email
}
}
return 'None'
}

View file

@ -3,23 +3,21 @@ import { Institution } from '../../../../../../../types/institution'
type ReconfirmationInfoSuccessProps = {
institution: Institution
className?: string
}
function ReconfirmationInfoSuccess({
institution,
className,
}: ReconfirmationInfoSuccessProps) {
const { t } = useTranslation()
return (
<div>
<div className={className}>
<Trans
i18nKey="your_affiliation_is_confirmed"
values={{
institutionName: institution.name,
}}
components={
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
[<strong />]
}
values={{ institutionName: institution.name }}
components={[<strong />]} // eslint-disable-line react/jsx-key
/>{' '}
{t('thank_you_exclamation')}
</div>

View file

@ -0,0 +1,18 @@
import 'jquery'
import 'bootstrap'
import './../utils/meta'
import './../utils/webpack-public-path'
import './../infrastructure/error-reporter'
import './../i18n'
import '../cdn-load-test'
import '../features/contact-form'
import '../features/event-tracking'
import '../features/cookie-banner'
import '../features/link-helpers/slow-link'
import ReactDOM from 'react-dom'
import ProjectListRoot from '../features/project-list/components/project-list-root'
const element = document.getElementById('project-list-root')
if (element) {
ReactDOM.render(<ProjectListRoot />, element)
}

View file

@ -32,4 +32,5 @@ export default function ControlledDropdown(props) {
ControlledDropdown.propTypes = {
children: PropTypes.any,
defaultOpen: PropTypes.bool,
id: PropTypes.string,
}

View file

@ -1,14 +1,16 @@
import classNames from 'classnames'
type IconProps = {
type IconOwnProps = {
type: string
spin?: boolean
fw?: boolean
modifier?: string
className?: string
accessibilityLabel?: string
}
type IconProps = IconOwnProps &
Omit<React.ComponentProps<'i'>, keyof IconOwnProps>
function Icon({
type,
spin,
@ -16,6 +18,7 @@ function Icon({
modifier,
className = '',
accessibilityLabel,
...rest
}: IconProps) {
const iconClassName = classNames(
'fa',
@ -30,7 +33,7 @@ function Icon({
return (
<>
<i className={iconClassName} aria-hidden="true" />
<i className={iconClassName} aria-hidden="true" {...rest} />
{accessibilityLabel && (
<span className="sr-only">{accessibilityLabel}</span>
)}

View file

@ -0,0 +1,29 @@
import { useTranslation } from 'react-i18next'
type LoadingBrandedTypes = {
loadProgress: number
}
export default function LoadingBranded({ loadProgress }: LoadingBrandedTypes) {
const { t } = useTranslation()
return (
<div className="loading-screen-brand-container">
<div
className="loading-screen-brand"
style={{ height: `${loadProgress}%` }}
/>
<div className="h3 loading-screen-label" aria-live="polite">
{t('loading')}
<span className="loading-screen-ellip" aria-hidden="true">
.
</span>
<span className="loading-screen-ellip" aria-hidden="true">
.
</span>
<span className="loading-screen-ellip" aria-hidden="true">
.
</span>
</div>
</div>
)
}

View file

@ -1,27 +1,30 @@
import * as React from 'react'
import useSafeDispatch from './use-safe-dispatch'
import { Nullable } from '../../../../types/utils'
import { FetchError } from '../../infrastructure/fetch-json'
type State<T> = {
type State<T, E> = {
status: 'idle' | 'pending' | 'resolved' | 'rejected'
data: Nullable<T>
error: Nullable<Error>
error: Nullable<E>
}
type Action<T> = Partial<State<T>>
type Action<T, E> = Partial<State<T, E>>
const defaultInitialState: State<null> = {
const defaultInitialState: State<null, null> = {
status: 'idle',
data: null,
error: null,
}
function useAsync<T = any>(initialState?: Partial<State<T>>) {
function useAsync<T = any, E extends Error | FetchError = Error>(
initialState?: Partial<State<T, E>>
) {
const initialStateRef = React.useRef({
...defaultInitialState,
...initialState,
})
const [{ status, data, error }, setState] = React.useReducer(
(state: State<T>, action: Action<T>) => ({ ...state, ...action }),
(state: State<T, E>, action: Action<T, E>) => ({ ...state, ...action }),
initialStateRef.current
)
@ -33,7 +36,7 @@ function useAsync<T = any>(initialState?: Partial<State<T>>) {
)
const setError = React.useCallback(
(error: Nullable<Error>) => safeSetState({ error, status: 'rejected' }),
(error: Nullable<E>) => safeSetState({ error, status: 'rejected' }),
[safeSetState]
)

View file

@ -0,0 +1,13 @@
import moment from 'moment'
export function formatDate(date, format) {
if (!date) return 'N/A'
if (format == null) {
format = 'Do MMM YYYY, h:mm a'
}
return moment(date).format(format)
}
export function fromNowDate(date) {
return moment(date).fromNow()
}

View file

@ -0,0 +1,58 @@
import CurrentPlanWidget from '../../js/features/project-list/components/current-plan-widget/current-plan-widget'
export const FreePlan = (args: any) => {
window.metaAttributesCache.set('ol-usersBestSubscription', {
type: 'free',
})
return <CurrentPlanWidget {...args} />
}
export const PaidPlanTrialLastDay = (args: any) => {
window.metaAttributesCache.set('ol-usersBestSubscription', {
type: 'individual',
remainingTrialDays: 1,
plan: {
name: 'Individual',
},
subscription: {
name: 'Example Name',
},
})
return <CurrentPlanWidget {...args} />
}
export const PaidPlanRemainingDays = (args: any) => {
window.metaAttributesCache.set('ol-usersBestSubscription', {
type: 'individual',
remainingTrialDays: 5,
plan: {
name: 'Individual',
},
subscription: {
name: 'Example Name',
},
})
return <CurrentPlanWidget {...args} />
}
export const PaidPlanActive = (args: any) => {
window.metaAttributesCache.set('ol-usersBestSubscription', {
type: 'individual',
plan: {
name: 'Individual',
},
subscription: {
name: 'Example Name',
},
})
return <CurrentPlanWidget {...args} />
}
export default {
title: 'Project List / Current Plan Widget',
component: CurrentPlanWidget,
}

View file

@ -0,0 +1,147 @@
import { merge, cloneDeep } from 'lodash'
import { FetchMockStatic } from 'fetch-mock'
import { UserEmailData } from '../../../../types/user-email'
import {
Institution,
Notification,
} from '../../../../types/project/dashboard/notification'
import { DeepPartial, DeepReadonly } from '../../../../types/utils'
import { Project } from '../../../../types/project/dashboard/api'
const MOCK_DELAY = 1000
const fakeInstitutionData = {
email: 'email@example.com',
institutionEmail: 'institution@example.com',
institutionId: 123,
institutionName: 'Abc Institution',
requestedEmail: 'requested@example.com',
} as DeepReadonly<Institution>
export const fakeReconfirmationUsersData = {
affiliation: {
institution: {
ssoEnabled: false,
ssoBeta: false,
name: 'Abc Institution',
},
},
samlProviderId: 'Saml Provider',
email: 'reconfirmation-email@overleaf.com',
default: false,
} as DeepReadonly<UserEmailData>
const fakeNotificationData = {
messageOpts: {
projectId: '123',
projectName: 'Abc Project',
ssoEnabled: false,
institutionId: '456',
userName: 'fakeUser',
university_name: 'Abc University',
token: 'abcdef',
},
} as DeepReadonly<Notification>
export function defaultSetupMocks(fetchMock: FetchMockStatic) {
// at least one project is required to show some notifications
const projects = [{}] as Project[]
fetchMock.post(/\/api\/project/, {
status: 200,
body: {
projects,
totalSize: projects.length,
},
})
}
export function setDefaultMeta() {
window.metaAttributesCache = new Map()
window.metaAttributesCache.set('ol-ExposedSettings', {
...window.metaAttributesCache.get('ol-ExposedSettings'),
emailConfirmationDisabled: false,
samlInitPath: '/fakeSaml',
appName: 'Overleaf',
})
window.metaAttributesCache.set('ol-notificationsInstitution', [])
window.metaAttributesCache.set('ol-userEmails', [])
}
export function errorsMocks(fetchMock: FetchMockStatic) {
defaultSetupMocks(fetchMock)
fetchMock.post(/\/user\/emails\/*/, 500, { delay: MOCK_DELAY })
fetchMock.post(
/\/project\/[A-Za-z0-9]+\/invite\/token\/[A-Za-z0-9]+\/accept/,
500,
{ delay: MOCK_DELAY }
)
fetchMock.post(/\/user\/emails\/send-reconfirmation/, 500, {
delay: MOCK_DELAY,
})
}
export function setInstitutionMeta(institutionData: Partial<Institution>) {
setDefaultMeta()
window.metaAttributesCache.set('ol-notificationsInstitution', [
merge(cloneDeep(fakeInstitutionData), institutionData),
])
}
export function institutionSetupMocks(fetchMock: FetchMockStatic) {
defaultSetupMocks(fetchMock)
fetchMock.delete(/\/notifications\/*/, 200, { delay: MOCK_DELAY })
}
export function setCommonMeta(notificationData: DeepPartial<Notification>) {
setDefaultMeta()
window.metaAttributesCache.set('ol-notifications', [
merge(cloneDeep(fakeNotificationData), notificationData),
])
}
export function commonSetupMocks(fetchMock: FetchMockStatic) {
defaultSetupMocks(fetchMock)
fetchMock.post(
/\/project\/[A-Za-z0-9]+\/invite\/token\/[A-Za-z0-9]+\/accept/,
200,
{ delay: MOCK_DELAY }
)
}
export function setReconfirmationMeta() {
setDefaultMeta()
window.metaAttributesCache.set('ol-userEmails', [fakeReconfirmationUsersData])
}
export function reconfirmationSetupMocks(fetchMock: FetchMockStatic) {
defaultSetupMocks(fetchMock)
fetchMock.post(/\/user\/emails\/resend_confirmation/, 200, {
delay: MOCK_DELAY,
})
}
export function setReconfirmAffiliationMeta() {
setDefaultMeta()
window.metaAttributesCache.set(
'ol-reconfirmedViaSAML',
fakeReconfirmationUsersData.samlProviderId
)
}
export function reconfirmAffiliationSetupMocks(fetchMock: FetchMockStatic) {
defaultSetupMocks(fetchMock)
fetchMock.post(
/\/api\/project/,
{
status: 200,
body: {
projects: [{}],
totalSize: 0,
},
},
{ overwriteRoutes: true }
)
fetchMock.post(/\/user\/emails\/send-reconfirmation/, 200, {
delay: MOCK_DELAY,
})
}

View file

@ -0,0 +1,92 @@
import NewProjectButton from '../../js/features/project-list/components/new-project-button'
import useFetchMock from '../hooks/use-fetch-mock'
const templateLinks = [
{
name: 'Academic Journal',
url: '/gallery/tagged/academic-journal',
},
{
name: 'Book',
url: '/gallery/tagged/book',
},
{
name: 'Formal Letter',
url: '/gallery/tagged/formal-letter',
},
{
name: 'Homework Assignment',
url: '/gallery/tagged/homework',
},
{
name: 'Poster',
url: '/gallery/tagged/poster',
},
{
name: 'Presentation',
url: '/gallery/tagged/presentation',
},
{
name: 'Project / Lab Report',
url: '/gallery/tagged/report',
},
{
name: 'Résumé / CV ',
url: '/gallery/tagged/cv',
},
{
name: 'Thesis',
url: '/gallery/tagged/thesis',
},
{
name: 'view_all',
url: '/latex/templates',
},
]
export const Success = () => {
window.metaAttributesCache.set('ol-ExposedSettings', {
templateLinks,
})
useFetchMock(fetchMock => {
fetchMock.post(
'express:/project/new',
{
status: 200,
body: {
project_id: '123',
},
},
{ delay: 250 }
)
})
return <NewProjectButton />
}
export const Error = () => {
window.metaAttributesCache.set('ol-ExposedSettings', {
templateLinks,
})
useFetchMock(fetchMock => {
fetchMock.post(
'express:/project/new',
{
status: 400,
body: {
message: 'Something went horribly wrong!',
},
},
{ delay: 250 }
)
})
return <NewProjectButton />
}
export default {
title: 'Project List / New Project Button',
component: NewProjectButton,
}

View file

@ -0,0 +1,283 @@
import UserNotifications from '../../js/features/project-list/components/notifications/user-notifications'
import { ProjectListProvider } from '../../js/features/project-list/context/project-list-context'
import useFetchMock from '../hooks/use-fetch-mock'
import {
commonSetupMocks,
errorsMocks,
fakeReconfirmationUsersData,
institutionSetupMocks,
reconfirmAffiliationSetupMocks,
reconfirmationSetupMocks,
setCommonMeta,
setInstitutionMeta,
setReconfirmAffiliationMeta,
setReconfirmationMeta,
} from './helpers/emails'
export const ProjectInvite = (args: any) => {
useFetchMock(commonSetupMocks)
setCommonMeta({
templateKey: 'notification_project_invite',
})
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const ProjectInviteNetworkError = (args: any) => {
useFetchMock(errorsMocks)
setCommonMeta({
templateKey: 'notification_project_invite',
})
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const Wfh2020UpgradeOffer = (args: any) => {
useFetchMock(commonSetupMocks)
setCommonMeta({
_id: 1,
templateKey: 'wfh_2020_upgrade_offer',
})
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const IPMatchedAffiliationSsoEnabled = (args: any) => {
useFetchMock(commonSetupMocks)
setCommonMeta({
_id: 1,
templateKey: 'notification_ip_matched_affiliation',
messageOpts: {
ssoEnabled: true,
},
})
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const IPMatchedAffiliationSsoDisabled = (args: any) => {
useFetchMock(commonSetupMocks)
setCommonMeta({
_id: 1,
templateKey: 'notification_ip_matched_affiliation',
messageOpts: {
ssoEnabled: false,
},
})
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const TpdsFileLimit = (args: any) => {
useFetchMock(commonSetupMocks)
setCommonMeta({
_id: 1,
templateKey: 'notification_tpds_file_limit',
})
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const DropBoxDuplicateProjectNames = (args: any) => {
useFetchMock(commonSetupMocks)
setCommonMeta({
_id: 1,
templateKey: 'notification_dropbox_duplicate_project_names',
})
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const DropBoxUnlinkedDueToLapsedReconfirmation = (args: any) => {
useFetchMock(commonSetupMocks)
setCommonMeta({
_id: 1,
templateKey: 'notification_dropbox_unlinked_due_to_lapsed_reconfirmation',
})
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const NonSpecificMessage = (args: any) => {
useFetchMock(commonSetupMocks)
setCommonMeta({ _id: 1, html: 'Non specific message' })
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const InstitutionSsoAvailable = (args: any) => {
useFetchMock(institutionSetupMocks)
setInstitutionMeta({
_id: 1,
templateKey: 'notification_institution_sso_available',
})
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const InstitutionSsoLinked = (args: any) => {
useFetchMock(institutionSetupMocks)
setInstitutionMeta({
_id: 1,
templateKey: 'notification_institution_sso_linked',
})
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const InstitutionSsoNonCanonical = (args: any) => {
useFetchMock(institutionSetupMocks)
setInstitutionMeta({
_id: 1,
templateKey: 'notification_institution_sso_non_canonical',
})
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const InstitutionSsoAlreadyRegistered = (args: any) => {
useFetchMock(institutionSetupMocks)
setInstitutionMeta({
_id: 1,
templateKey: 'notification_institution_sso_already_registered',
})
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const InstitutionSsoError = (args: any) => {
useFetchMock(institutionSetupMocks)
setInstitutionMeta({
templateKey: 'notification_institution_sso_error',
error: {
message: 'message',
translatedMessage: 'Translated Message',
tryAgain: true,
},
})
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const ResendConfirmationEmail = (args: any) => {
useFetchMock(reconfirmationSetupMocks)
setReconfirmationMeta()
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const ResendConfirmationEmailNetworkError = (args: any) => {
useFetchMock(errorsMocks)
setReconfirmationMeta()
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const ReconfirmAffiliation = (args: any) => {
useFetchMock(reconfirmAffiliationSetupMocks)
setReconfirmAffiliationMeta()
window.metaAttributesCache.set('ol-allInReconfirmNotificationPeriods', [
fakeReconfirmationUsersData,
])
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const ReconfirmAffiliationNetworkError = (args: any) => {
useFetchMock(errorsMocks)
setReconfirmAffiliationMeta()
window.metaAttributesCache.set('ol-allInReconfirmNotificationPeriods', [
fakeReconfirmationUsersData,
])
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const ReconfirmedAffiliationSuccess = (args: any) => {
useFetchMock(reconfirmAffiliationSetupMocks)
setReconfirmAffiliationMeta()
window.metaAttributesCache.set('ol-userEmails', [fakeReconfirmationUsersData])
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export default {
title: 'Project List / Notifications',
component: UserNotifications,
}

View file

@ -0,0 +1,35 @@
import ProjectListTable from '../../js/features/project-list/components/table/project-list-table'
import { ProjectListProvider } from '../../js/features/project-list/context/project-list-context'
import useFetchMock from '../hooks/use-fetch-mock'
import { projectsData } from '../../../test/frontend/features/project-list/fixtures/projects-data'
const MOCK_DELAY = 500
export const Interactive = (args: any) => {
window.user_id = '624333f147cfd8002622a1d3'
useFetchMock(fetchMock => {
fetchMock.post(
/\/api\/project/,
{ projects: projectsData, totalSize: projectsData.length },
{ delay: MOCK_DELAY }
)
})
return (
<ProjectListProvider>
<ProjectListTable {...args} />
</ProjectListProvider>
)
}
export default {
title: 'Project List / Project Table',
component: ProjectListTable,
decorators: [
(Story: any) => (
<div className="project-list-react">
<Story />
</div>
),
],
}

View file

@ -0,0 +1,24 @@
import SearchForm from '../../js/features/project-list/components/search-form'
import { ProjectListProvider } from '../../js/features/project-list/context/project-list-context'
import useFetchMock from '../hooks/use-fetch-mock'
import { projectsData } from '../../../test/frontend/features/project-list/fixtures/projects-data'
export const Search = (args: any) => {
useFetchMock(fetchMock => {
fetchMock.post(/\/api\/project/, {
projects: projectsData,
totalSize: projectsData.length,
})
})
return (
<ProjectListProvider>
<SearchForm {...args} />
</ProjectListProvider>
)
}
export default {
title: 'Project List / Project Search',
component: SearchForm,
}

View file

@ -0,0 +1,32 @@
import SurveyWidget from '../../js/features/project-list/components/survey-widget'
export const Survey = (args: any) => {
localStorage.clear()
window.metaAttributesCache.set('ol-survey', {
name: 'my-survey',
preText: 'To help shape the future of Overleaf',
linkText: 'Click here!',
url: 'https://example.com/my-survey',
})
return <SurveyWidget {...args} />
}
export const UndefinedSurvey = (args: any) => {
localStorage.clear()
window.metaAttributesCache = new Map()
return <SurveyWidget {...args} />
}
export const EmptySurvey = (args: any) => {
localStorage.clear()
window.metaAttributesCache.set('ol-survey', {})
return <SurveyWidget {...args} />
}
export default {
title: 'Project List / Survey Widget',
component: SurveyWidget,
}

View file

@ -82,6 +82,7 @@
@import 'app/beta-program.less';
@import 'app/about-page.less';
@import 'app/project-list.less';
@import 'app/project-list-react.less';
@import 'app/editor.less';
@import 'app/homepage.less';
@import 'app/plans.less';

View file

@ -0,0 +1,417 @@
.project-list-react {
overflow-x: hidden;
body > .content& {
padding-top: @header-height;
padding-bottom: 0;
min-height: calc(~'100vh -' @header-height);
}
.container:before {
content: '';
display: block;
float: left;
height: 100%;
}
.fill {
position: relative;
&:after {
content: '';
display: block;
clear: left;
}
}
.project-list-row {
height: 100%;
min-height: calc(~'100vh -' @header-height);
}
.project-list-wrapper {
position: absolute;
width: 100%;
height: 100%;
}
.project-list-sidebar-wrapper {
float: left;
position: static;
.project-list-sidebar {
> .dropdown {
width: 100%;
.new-project-button {
width: 100%;
}
}
}
}
.project-list-main {
position: static;
overflow: auto;
}
ul.folders-menu {
margin: @folders-menu-margin;
.subdued {
color: @gray-light;
}
> li {
cursor: pointer;
position: relative;
> button {
display: block;
width: 100%;
font-weight: normal;
text-align: left;
color: @sidebar-link-color;
background-color: transparent;
border-radius: unset;
border: none;
border-bottom: solid 1px transparent;
padding: @folders-menu-item-v-padding @folders-menu-item-h-padding;
&:hover {
background-color: @sidebar-hover-bg;
text-decoration: @sidebar-hover-text-decoration;
}
&:focus {
text-decoration: none;
outline: none;
}
}
&.separator {
padding: @folders-menu-item-v-padding @folders-menu-item-h-padding;
cursor: auto;
}
}
> li.active {
border-radius: @sidebar-active-border-radius;
> button {
background-color: @sidebar-active-bg;
font-weight: @sidebar-active-font-weight;
color: @sidebar-active-color;
.subdued {
color: @sidebar-active-color;
}
}
}
h2 {
margin-top: @folders-title-margin-top;
margin-bottom: @folders-title-margin-bottom;
font-size: @folders-title-font-size;
color: @folders-title-color;
text-transform: @folders-title-text-transform;
padding: @folders-title-padding;
font-weight: @folders-title-font-weight;
font-family: @font-family-sans-serif;
}
> li.tag {
&.active {
.tag-menu > button {
color: white;
border-color: white;
&:hover {
background-color: @folders-tag-menu-active-hover;
}
}
}
&.untagged {
button.tag-name {
span.name {
font-style: italic;
padding-left: 0;
}
}
}
&:hover {
&:not(.active) {
background-color: @folders-tag-hover;
}
.tag-menu {
display: block;
}
}
&:not(.active) {
.tag-menu > a:hover {
background-color: @folders-tag-menu-hover;
}
}
button.tag-name {
position: relative;
padding: @folders-tag-padding;
display: @folders-tag-display;
span.name {
padding-left: 0.5em;
line-height: @folders-tag-line-height;
}
}
}
.tag-menu {
button.dropdown-toggle {
border: 1px solid @folders-tag-border-color;
border-radius: @border-radius-small;
background-color: transparent;
color: @folders-tag-menu-color;
display: block;
width: 16px;
height: 16px;
position: relative;
.caret {
position: absolute;
top: 6px;
left: 1px;
}
}
display: none;
width: auto;
position: absolute;
top: 50%;
margin-top: -8px; // Half the element height.
right: 4px;
&.open {
display: block;
}
button.tag-action {
border-radius: unset;
width: 100%;
background-color: transparent;
border-color: transparent;
color: @gray-dark;
text-align: left;
font-weight: normal;
&:hover {
color: @white;
background-color: @ol-green;
}
&:active {
outline: none;
}
}
}
}
.project-dash-table {
width: 100%;
table-layout: fixed;
tr {
border-bottom: 1px solid @structured-list-border-color;
}
th,
td {
padding: (@line-height-computed / 4) @line-height-computed / 2;
vertical-align: top;
}
tr {
th:first-child,
td:first-child,
th:last-child,
td:last-child {
padding-right: @line-height-computed - (@grid-gutter-width / 2);
}
&:hover {
background-color: @structured-list-hover-color;
}
}
thead {
tr:hover {
background-color: transparent;
}
}
tbody {
tr:last-child {
border-bottom: 0 none;
}
}
.table-header-sort-btn {
border: 0;
text-align: left;
color: @ol-type-color;
padding: 0;
font-weight: bold;
&:hover,
&:focus {
color: @ol-type-color;
text-decoration: none;
}
}
.dash-cell-checkbox {
width: 5%;
input[type='checkbox'] {
margin-top: 5px;
}
}
.dash-cell-name {
width: 50%;
overflow: hidden;
text-overflow: ellipsis;
}
.dash-cell-owner {
width: 20%;
overflow: hidden;
text-overflow: ellipsis;
}
.dash-cell-date {
width: 25%;
overflow: hidden;
text-overflow: ellipsis;
}
.dash-cell-actions {
display: none;
text-align: right;
white-space: nowrap;
}
@media (min-width: @screen-xs) {
.dash-cell-checkbox {
width: 4%;
}
.dash-cell-name {
width: 50%;
}
.dash-cell-owner {
width: 21%;
}
.dash-cell-date {
width: 25%;
}
.dash-cell-actions {
width: 0%;
}
}
@media (min-width: @screen-sm) {
.dash-cell-checkbox {
width: 3%;
}
.dash-cell-name {
width: 48%;
}
.dash-cell-owner {
width: 13%;
}
.dash-cell-date {
width: 15%;
}
.dash-cell-actions {
display: table-cell;
width: 21%;
}
}
@media (min-width: @screen-md) {
.dash-cell-checkbox {
width: 3%;
}
.dash-cell-name {
width: 50%;
}
.dash-cell-owner {
width: 13%;
}
.dash-cell-date {
width: 16%;
}
.dash-cell-actions {
width: 18%;
}
}
@media (min-width: @screen-lg) {
.dash-cell-checkbox {
width: 3%;
}
.dash-cell-name {
width: 50%;
}
.dash-cell-owner {
width: 15%;
}
.dash-cell-date {
width: 19%;
}
.dash-cell-actions {
width: 13%;
}
}
@media (min-width: 1440px) {
.dash-cell-checkbox {
width: 2%;
}
.dash-cell-name {
width: 50%;
}
.dash-cell-owner {
width: 16%;
}
.dash-cell-date {
width: 19%;
}
.dash-cell-actions {
width: 13%;
}
}
}
.loading-container {
display: flex;
align-items: center;
min-height: calc(~'100vh -' @header-height);
.loading-screen-brand-container {
margin: 0 auto;
}
}
}
.project-list-react.container,
.project-list-react .project-list-sidebar-wrapper,
.project-list-react .project-list-main {
height: 100%;
}
.current-plan {
vertical-align: middle;
line-height: @line-height-base;
a.current-plan-label {
text-decoration: none;
color: @text-color;
}
}
.project-list-upload-project-modal-uppy-dashboard .uppy-Root {
.uppy-Dashboard-AddFiles-title {
display: flex;
flex-direction: column;
color: @ol-blue-gray-3;
white-space: pre-line;
button.uppy-Dashboard-browse {
background-color: @ol-green;
color: @white;
margin-bottom: @margin-md;
padding: 4px 16px 5px;
height: 46px;
border-radius: 23px;
font-weight: 700;
display: flex;
justify-content: center;
align-items: center;
font-size: @font-size-large;
font-family: @font-family-sans-serif;
}
}
}

View file

@ -33,6 +33,10 @@
.row:first-child {
flex-grow: 1; /* fill vertical space so notifications are pushed to bottom */
}
.card {
// h2 + .card-thin top padding
padding-bottom: @line-height-computed + @line-height-computed / 2;
}
}
.sidebar-new-proj-btn {
@ -74,10 +78,6 @@
}
}
.project-search {
margin: @line-height-base 0;
}
.project-tools {
display: inline;
float: right;
@ -351,7 +351,7 @@ ul.folders-menu {
background-color: @folders-tag-menu-hover;
}
}
a.tag-name {
button.tag-name {
position: relative;
padding: @folders-tag-padding;
display: @folders-tag-display;
@ -384,6 +384,9 @@ ul.folders-menu {
display: block;
}
}
.dropdown-toggle {
float: right;
}
}
}
@ -403,6 +406,18 @@ form.project-search {
}
}
.project-search-clear-btn {
width: 100%;
padding: 0;
border: 0;
color: @ol-type-color;
&:hover,
&:active {
color: @ol-type-color;
}
}
ul.structured-list {
list-style-type: none;
margin: 0;
@ -459,9 +474,10 @@ ul.project-list {
margin-right: @line-height-computed / 4;
}
}
i.tablesort {
padding-left: 8px;
}
}
i.tablesort {
padding-left: 8px;
}
.tag-label {
@ -564,12 +580,3 @@ ul.project-list {
margin-left: -100px;
}
}
.current-plan {
vertical-align: middle;
line-height: @line-height-base;
a.current-plan-label {
text-decoration: none;
color: @text-color;
}
}

View file

@ -50,3 +50,7 @@
.affix {
position: fixed;
}
.w-100 {
width: 100%;
}

View file

@ -588,8 +588,8 @@
"tc_switch_user_tip": "Toggle track-changes for this user",
"tc_switch_guests_tip": "Toggle track-changes for all link-sharing guests",
"tc_guests": "Guests",
"select_all_projects": "Select all",
"select_project": "Select",
"select_all_projects": "Select all projects",
"select_project": "Select __project__",
"main_file_not_found": "Unknown main document",
"please_set_main_file": "Please choose the main file for this project in the project menu. ",
"link_sharing_is_off": "Link sharing is off, only invited users can view this project.",
@ -854,6 +854,8 @@
"fast": "Fast",
"rename_folder": "Rename Folder",
"delete_folder": "Delete Folder",
"select_tag": "Select tag __tagName__",
"remove_tag": "Remove tag __tagName__",
"about_to_delete_folder": "You are about to delete the following folders (any projects in them will not be deleted):",
"to_modify_your_subscription_go_to": "To modify your subscription go to",
"manage_subscription": "Manage Subscription",
@ -1837,5 +1839,13 @@
"history_entry_origin_upload": "upload",
"history_entry_origin_git": "via Git",
"history_entry_origin_github": "via GitHub",
"history_entry_origin_dropbox": "via Dropbox"
"history_entry_origin_dropbox": "via Dropbox",
"sort_by_x": "Sort by __x__",
"last_updated_date_by_x": "__lastUpdatedDate__ by __person__",
"select_projects": "Select Projects",
"ascending": "Ascending",
"descending": "Descending",
"reverse_x_sort_order" : "Reverse __x__ sort order",
"create_first_project": "Create First Project",
"you_dont_have_any_repositories": "You dont have any repositories"
}

View file

@ -37,7 +37,6 @@
"lezer-latex:generate": "if [ ! -d $(pwd)/modules/source-editor ]; then echo \"'source-editor' module is not available\"; exit 0; fi; node modules/source-editor/scripts/lezer-latex/generate.js",
"lezer-latex:run": "node modules/source-editor/scripts/lezer-latex/run.mjs",
"routes": "bin/routes",
"routes": "bin/routes",
"local:nodemon": "set -a;. ../../config/dev-environment.env;. ../../config/local.env;. ../../config/local-dev.env;. ./docker-compose.common.env;. ./local-dev.env; set +a; echo $SHARELATEX_CONFIG; WEB_PORT=13000 LISTEN_ADDRESS=0.0.0.0 npm run nodemon",
"local:webpack": "set -a;. ../../config/dev-environment.env;. ../../config/local.env;. ../../config/local-dev.env;. ./docker-compose.common.env;. ./local-dev.env; set +a; PORT=13808 SHARELATEX_CONFIG=$(pwd)/config/settings.webpack.js npm run webpack",
"local:test:acceptance:run_dir": "set -a;. $(pwd)/docker-compose.common.env;. $(pwd)/local-test.env; set +a; npm run test:acceptance:run_dir",
@ -235,6 +234,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",

View file

@ -0,0 +1,255 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { fireEvent, render, screen } from '@testing-library/react'
import {
CommonsPlanSubscription,
GroupPlanSubscription,
IndividualPlanSubscription,
} from '../../../../../types/project/dashboard/subscription'
import { DeepReadonly } from '../../../../../types/utils'
import * as eventTracking from '../../../../../frontend/js/infrastructure/event-tracking'
import CurrentPlanWidget from '../../../../../frontend/js/features/project-list/components/current-plan-widget/current-plan-widget'
describe('<CurrentPlanWidget />', function () {
const freePlanTooltipMessage =
/click to find out how you could benefit from overleaf premium features/i
const paidPlanTooltipMessage =
/click to find out how to make the most of your overleaf premium features/i
beforeEach(function () {
window.metaAttributesCache = window.metaAttributesCache || new Map()
})
describe('free plan', function () {
let sendSpy: sinon.SinonSpy
let sendMBSpy: sinon.SinonSpy
beforeEach(function () {
sendSpy = sinon.spy(eventTracking, 'send')
sendMBSpy = sinon.spy(eventTracking, 'sendMB')
window.metaAttributesCache.set('ol-usersBestSubscription', {
type: 'free',
})
render(<CurrentPlanWidget />)
})
afterEach(function () {
sendSpy.restore()
sendMBSpy.restore()
})
it('shows text and tooltip on mouseover', function () {
const link = screen.getByRole('link', {
name: /youre on the free plan/i,
})
fireEvent.mouseOver(link)
screen.getByRole('tooltip', { name: freePlanTooltipMessage })
})
it('clicks on upgrade button', function () {
const upgradeLink = screen.getByRole('link', { name: /upgrade/i })
fireEvent.click(upgradeLink)
expect(sendSpy).to.be.calledOnce
expect(sendSpy).calledWith(
'subscription-funnel',
'dashboard-top',
'upgrade'
)
expect(sendMBSpy).to.be.calledOnce
expect(sendMBSpy).calledWith('upgrade-button-click', {
source: 'dashboard-top',
})
})
})
describe('paid plan', function () {
describe('trial', function () {
const subscription = {
type: 'individual',
plan: {
name: 'Abc',
},
subscription: {
name: 'Example Name',
},
remainingTrialDays: -1,
} as DeepReadonly<IndividualPlanSubscription>
beforeEach(function () {
window.metaAttributesCache.set('ol-usersBestSubscription', {
...subscription,
})
})
it('shows remaining days', function () {
const newSubscription: IndividualPlanSubscription = {
...subscription,
remainingTrialDays: 5,
}
window.metaAttributesCache.set(
'ol-usersBestSubscription',
newSubscription
)
render(<CurrentPlanWidget />)
screen.getByRole('link', {
name: new RegExp(
`${newSubscription.remainingTrialDays} more days on your overleaf premium trial`,
'i'
),
})
})
it('shows last day message', function () {
window.metaAttributesCache.set('ol-usersBestSubscription', {
...subscription,
remainingTrialDays: 1,
})
render(<CurrentPlanWidget />)
screen.getByRole('link', {
name: /this is the last day of your overleaf premium trial/i,
})
})
})
describe('individual', function () {
const subscription = {
type: 'individual',
plan: {
name: 'Abc',
},
subscription: {
teamName: 'Example Team',
name: 'Example Name',
},
remainingTrialDays: -1,
} as DeepReadonly<IndividualPlanSubscription>
beforeEach(function () {
window.metaAttributesCache.set('ol-usersBestSubscription', {
...subscription,
})
})
it('shows text and tooltip on mouseover', function () {
render(<CurrentPlanWidget />)
const link = screen.getByRole('link', {
name: /youre using overleaf premium/i,
})
fireEvent.mouseOver(link)
screen.getByRole('tooltip', {
name: new RegExp(`on the ${subscription.plan.name}`, 'i'),
})
screen.getByRole('tooltip', { name: paidPlanTooltipMessage })
})
})
describe('group', function () {
const subscription = {
type: 'group',
plan: {
name: 'Abc',
},
subscription: {
name: 'Example Name',
},
remainingTrialDays: -1,
} as DeepReadonly<GroupPlanSubscription>
beforeEach(function () {
window.metaAttributesCache.set('ol-usersBestSubscription', {
...subscription,
})
})
it('shows text and tooltip on mouseover (without subscription team name)', function () {
render(<CurrentPlanWidget />)
const link = screen.getByRole('link', {
name: /youre using overleaf premium/i,
})
fireEvent.mouseOver(link)
expect(subscription.subscription.teamName).to.be.undefined
screen.getByRole('tooltip', {
name: new RegExp(
`on the ${subscription.plan.name} plan as a member of a group subscription`,
'i'
),
})
screen.getByRole('tooltip', { name: paidPlanTooltipMessage })
})
it('shows text and tooltip on mouseover (with subscription team name)', function () {
const newSubscription = {
...subscription,
subscription: {
teamName: 'Example Team',
},
}
window.metaAttributesCache.set(
'ol-usersBestSubscription',
newSubscription
)
render(<CurrentPlanWidget />)
const link = screen.getByRole('link', {
name: /youre using overleaf premium/i,
})
fireEvent.mouseOver(link)
screen.getByRole('tooltip', {
name: new RegExp(
`on the ${newSubscription.plan.name} plan as a member of a group subscription, ${newSubscription.subscription.teamName}`,
'i'
),
})
screen.getByRole('tooltip', { name: paidPlanTooltipMessage })
})
})
describe('commons', function () {
it('shows text and tooltip on mouseover', function () {
const subscription = {
type: 'commons',
plan: {
name: 'Abc',
},
subscription: {
name: 'Example Name',
},
} as DeepReadonly<CommonsPlanSubscription>
window.metaAttributesCache.set('ol-usersBestSubscription', {
...subscription,
})
render(<CurrentPlanWidget />)
const link = screen.getByRole('link', {
name: /youre using overleaf premium/i,
})
fireEvent.mouseOver(link)
screen.getByRole('tooltip', {
name: new RegExp(
`on the ${subscription.plan.name} plan because of your affiliation with ${subscription.subscription.name}`,
'i'
),
})
screen.getByRole('tooltip', { name: paidPlanTooltipMessage })
})
})
})
})

View file

@ -0,0 +1,72 @@
import { render, fireEvent, screen } from '@testing-library/react'
import { expect } from 'chai'
import NewProjectButton from '../../../../../frontend/js/features/project-list/components/new-project-button'
describe('<NewProjectButton />', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-ExposedSettings', {
templateLinks: [
{
name: 'Academic Journal',
url: '/gallery/tagged/academic-journal',
},
{
name: 'View All',
url: '/latex/templates',
},
],
})
render(<NewProjectButton />)
const newProjectButton = screen.getByRole('button', {
name: 'New Project',
})
fireEvent.click(newProjectButton)
})
afterEach(function () {
window.metaAttributesCache = new Map()
})
it('opens a dropdown', function () {
// static menu
screen.getByText('Blank Project')
screen.getByText('Example Project')
screen.getByText('Upload Project')
screen.getByText('Import from GitHub')
// static text
screen.getByText('Templates')
// dynamic menu based on templateLinks
screen.getByText('Academic Journal')
screen.getByText('View All')
})
it('open new project modal when clicking at Blank Project', function () {
fireEvent.click(screen.getByRole('menuitem', { name: 'Blank Project' }))
screen.getByPlaceholderText('Project Name')
})
it('open new project modal when clicking at Example Project', function () {
fireEvent.click(screen.getByRole('menuitem', { name: 'Example Project' }))
screen.getByPlaceholderText('Project Name')
})
it('close the new project modal when clicking at the top right "x" button', function () {
fireEvent.click(screen.getByRole('menuitem', { name: 'Blank Project' }))
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(screen.queryByRole('dialog')).to.be.null
})
it('close the new project modal when clicking at the Cancel button', function () {
fireEvent.click(screen.getByRole('menuitem', { name: 'Blank Project' }))
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByRole('dialog')).to.be.null
})
})

View file

@ -0,0 +1,150 @@
import { render, fireEvent, screen, waitFor } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import sinon from 'sinon'
import ModalContentNewProjectForm from '../../../../../../frontend/js/features/project-list/components/new-project-button/modal-content-new-project-form'
describe('<ModalContentNewProjectForm />', function () {
const locationStub = sinon.stub()
const originalLocation = window.location
beforeEach(function () {
Object.defineProperty(window, 'location', {
value: {
assign: locationStub,
},
})
})
afterEach(function () {
Object.defineProperty(window, 'location', {
value: originalLocation,
})
fetchMock.reset()
})
it('submits form', async function () {
const projectId = 'ab123'
const newProjectMock = fetchMock.post('/project/new', {
status: 200,
body: {
project_id: projectId,
},
})
render(<ModalContentNewProjectForm onCancel={() => {}} />)
const createButton = screen.getByRole('button', {
name: 'Create',
})
expect(createButton.getAttribute('disabled')).to.exist
fireEvent.change(screen.getByPlaceholderText('Project Name'), {
target: { value: 'Test Name' },
})
expect(createButton.getAttribute('disabled')).to.be.null
fireEvent.click(createButton)
expect(newProjectMock.called()).to.be.true
await waitFor(() => {
sinon.assert.calledOnce(locationStub)
sinon.assert.calledWith(locationStub, `/project/${projectId}`)
})
})
it('shows error when project name contains "/"', async function () {
const errorMessage = 'Project name cannot contain / characters'
const newProjectMock = fetchMock.post('/project/new', {
status: 400,
body: errorMessage,
})
render(<ModalContentNewProjectForm onCancel={() => {}} />)
fireEvent.change(screen.getByPlaceholderText('Project Name'), {
target: { value: '/' },
})
const createButton = screen.getByRole('button', {
name: 'Create',
})
fireEvent.click(createButton)
expect(newProjectMock.called()).to.be.true
await waitFor(() => {
screen.getByText(errorMessage)
})
})
it('shows error when project name contains "\\" character', async function () {
const errorMessage = 'Project name cannot contain \\ characters'
const newProjectMock = fetchMock.post('/project/new', {
status: 400,
body: errorMessage,
})
render(<ModalContentNewProjectForm onCancel={() => {}} />)
fireEvent.change(screen.getByPlaceholderText('Project Name'), {
target: { value: '\\' },
})
const createButton = screen.getByRole('button', {
name: 'Create',
})
fireEvent.click(createButton)
expect(newProjectMock.called()).to.be.true
await waitFor(() => {
screen.getByText(errorMessage)
})
})
it('shows error when project name is too long ', async function () {
const errorMessage = 'Project name is too long'
const newProjectMock = fetchMock.post('/project/new', {
status: 400,
body: errorMessage,
})
render(<ModalContentNewProjectForm onCancel={() => {}} />)
fireEvent.change(screen.getByPlaceholderText('Project Name'), {
target: {
value: `
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Arcu risus quis varius quam quisque id diam vel quam. Sit amet porttitor eget dolor morbi non arcu risus quis. In aliquam sem fringilla ut. Gravida cum sociis natoque penatibus. Semper risus in hendrerit gravida rutrum quisque non. Ut aliquam purus sit amet luctus venenatis. Neque ornare aenean euismod elementum nisi. Adipiscing bibendum est ultricies integer quis auctor elit. Nulla posuere sollicitudin aliquam ultrices sagittis. Nulla facilisi nullam vehicula ipsum a arcu cursus. Tristique senectus et netus et malesuada fames ac. Pulvinar pellentesque habitant morbi tristique senectus et netus et. Nisi scelerisque eu ultrices vitae auctor eu. Hendrerit gravida rutrum quisque non tellus orci. Volutpat blandit aliquam etiam erat velit scelerisque in dictum non. Donec enim diam vulputate ut pharetra sit amet aliquam id. Ullamcorper eget nulla facilisi etiam.
Enim praesent elementum facilisis leo vel fringilla est. Semper eget duis at tellus. Lacus luctus accumsan tortor posuere ac ut consequat semper viverra. Et malesuada fames ac turpis egestas maecenas pharetra convallis posuere. Ultrices in iaculis nunc sed augue lacus. Tellus orci ac auctor augue mauris augue. Velit scelerisque in dictum non consectetur a erat. Sed turpis tincidunt id aliquet risus. Felis eget velit aliquet sagittis id. Convallis tellus id interdum velit laoreet id.
Habitasse platea dictumst quisque sagittis. Massa sed elementum tempus egestas sed. Cursus eget nunc scelerisque viverra mauris in aliquam sem. Sociis natoque penatibus et magnis dis parturient montes nascetur. Mi in nulla posuere sollicitudin aliquam ultrices sagittis orci a. Fames ac turpis egestas sed tempus urna et pharetra. Pellentesque massa placerat duis ultricies lacus sed turpis tincidunt id. Erat pellentesque adipiscing commodo elit at imperdiet dui. Lectus magna fringilla urna porttitor rhoncus dolor purus non enim. Sagittis nisl rhoncus mattis rhoncus urna neque viverra. Nibh sed pulvinar proin gravida. Sed adipiscing diam donec adipiscing tristique risus nec feugiat in. Elit duis tristique sollicitudin nibh sit amet commodo. Vivamus arcu felis bibendum ut tristique et egestas. Tellus in hac habitasse platea dictumst vestibulum rhoncus est pellentesque. Vitae purus faucibus ornare suspendisse sed. Adipiscing elit duis tristique sollicitudin nibh sit amet commodo nulla.
Vitae congue mauris rhoncus aenean vel elit scelerisque mauris pellentesque. Erat imperdiet sed euismod nisi porta lorem mollis aliquam. Accumsan tortor posuere ac ut consequat semper viverra nam libero. Malesuada fames ac turpis egestas sed tempus urna et. Tellus mauris a diam maecenas sed enim ut sem viverra. Mauris in aliquam sem fringilla ut. Feugiat pretium nibh ipsum consequat. Nisl tincidunt eget nullam non nisi. Tortor consequat id porta nibh. Mattis rhoncus urna neque viverra justo nec ultrices dui sapien. Ac tincidunt vitae semper quis lectus nulla at. Risus quis varius quam quisque id diam. Nisl nunc mi ipsum faucibus vitae aliquet.
Fringilla phasellus faucibus scelerisque eleifend. Eget egestas purus viverra accumsan in nisl nisi scelerisque eu. Mauris commodo quis imperdiet massa tincidunt nunc. Nunc mi ipsum faucibus vitae aliquet nec ullamcorper sit. Elit duis tristique sollicitudin nibh sit amet commodo nulla facilisi. Phasellus egestas tellus rutrum tellus pellentesque eu tincidunt tortor aliquam. Mi sit amet mauris commodo quis imperdiet massa. Urna nec tincidunt praesent semper feugiat nibh sed pulvinar proin. Tempor nec feugiat nisl pretium fusce id velit ut. Morbi tristique senectus et netus et.
Accumsan in nisl nisi scelerisque eu ultrices vitae. Metus vulputate eu scelerisque felis imperdiet proin fermentum leo. Viverra tellus in hac habitasse platea dictumst vestibulum. Non arcu risus quis varius quam quisque id diam. Turpis cursus in hac habitasse platea dictumst. Erat imperdiet sed euismod nisi porta. Eu augue ut lectus arcu bibendum at varius vel pharetra. Aliquam ultrices sagittis orci a scelerisque. Amet consectetur adipiscing elit pellentesque habitant morbi tristique. Lobortis scelerisque fermentum dui faucibus in ornare quam. Commodo sed egestas egestas fringilla phasellus faucibus. Mauris augue neque gravida in fermentum. Ut eu sem integer vitae justo eget magna fermentum. Phasellus egestas tellus rutrum tellus pellentesque eu. Lorem ipsum dolor sit amet consectetur adipiscing. Nulla facilisi morbi tempus iaculis urna id. In egestas erat imperdiet sed euismod nisi porta lorem. Ipsum faucibus vitae aliquet nec ullamcorper sit amet risus.
Feugiat in fermentum posuere urna nec. Elementum eu facilisis sed odio morbi quis commodo. Vel fringilla est ullamcorper eget nulla facilisi. Nunc sed blandit libero volutpat sed cras ornare arcu dui. Tortor id aliquet lectus proin nibh nisl condimentum id venenatis. Sapien pellentesque habitant morbi tristique senectus et. Quam elementum pulvinar etiam non quam lacus suspendisse faucibus. Sem nulla pharetra diam sit amet nisl suscipit adipiscing bibendum. Porttitor leo a diam sollicitudin tempor id. In iaculis nunc sed augue.
Velit euismod in pellentesque massa placerat duis ultricies lacus sed. Dictum fusce ut placerat orci nulla pellentesque dignissim enim. Dui id ornare arcu odio. Dignissim cras tincidunt lobortis feugiat vivamus at augue. Non tellus orci ac auctor. Egestas fringilla phasellus faucibus scelerisque eleifend donec. Nisi vitae suscipit tellus mauris a diam maecenas. Orci dapibus ultrices in iaculis nunc sed. Facilisi morbi tempus iaculis urna id volutpat lacus laoreet non. Aliquam etiam erat velit scelerisque in dictum. Sed enim ut sem viverra. Eleifend donec pretium vulputate sapien nec sagittis. Quisque egestas diam in arcu cursus euismod quis. Faucibus a pellentesque sit amet porttitor eget dolor. Elementum facilisis leo vel fringilla. Pellentesque habitant morbi tristique senectus et netus. Viverra tellus in hac habitasse platea dictumst vestibulum. Tincidunt nunc pulvinar sapien et ligula ullamcorper malesuada proin. Sit amet porttitor eget dolor morbi non. Neque egestas congue quisque egestas.
Convallis posuere morbi leo urna molestie at. Posuere sollicitudin aliquam ultrices sagittis orci. Lacus vestibulum sed arcu non odio. Sit amet dictum sit amet. Nunc scelerisque viverra mauris in aliquam sem fringilla ut morbi. Vestibulum morbi blandit cursus risus at ultrices mi. Purus gravida quis blandit turpis cursus. Diam maecenas sed enim ut. Senectus et netus et malesuada fames ac turpis. Massa tempor nec feugiat nisl pretium fusce id velit. Mollis nunc sed id semper. Elit sed vulputate mi sit. Vitae et leo duis ut diam. Pellentesque sit amet porttitor eget dolor morbi non arcu risus.
Mi quis hendrerit dolor magna eget est lorem. Quam vulputate dignissim suspendisse in est ante in nibh. Nisi porta lorem mollis aliquam. Duis tristique sollicitudin nibh sit amet commodo nulla facilisi nullam. Euismod nisi porta lorem mollis aliquam ut porttitor leo a. Tempus imperdiet nulla malesuada pellentesque elit eget. Amet nisl purus in mollis nunc sed id. Id velit ut tortor pretium viverra suspendisse. Integer quis auctor elit sed. Tortor at risus viverra adipiscing. Ac auctor augue mauris augue neque gravida in. Lacus laoreet non curabitur gravida arcu ac tortor dignissim convallis. A diam sollicitudin tempor id eu nisl nunc mi. Tellus id interdum velit laoreet id donec. Lacus vestibulum sed arcu non odio euismod lacinia. Tellus at urna condimentum mattis.
`,
},
})
const createButton = screen.getByRole('button', {
name: 'Create',
})
fireEvent.click(createButton)
expect(newProjectMock.called()).to.be.true
await waitFor(() => {
screen.getByText(errorMessage)
})
})
})

View file

@ -0,0 +1,155 @@
import sinon from 'sinon'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import UploadProjectModal from '../../../../../../frontend/js/features/project-list/components/new-project-button/upload-project-modal'
import { expect } from 'chai'
describe('<UploadProjectModal />', function () {
const originalWindowCSRFToken = window.csrfToken
const locationStub = sinon.stub()
const originalLocation = window.location
const maxUploadSize = 10 * 1024 * 1024 // 10 MB
beforeEach(function () {
Object.defineProperty(window, 'location', {
value: {
assign: locationStub,
},
})
window.metaAttributesCache.set('ol-ExposedSettings', {
maxUploadSize,
})
window.csrfToken = 'token'
})
afterEach(function () {
Object.defineProperty(window, 'location', {
value: originalLocation,
})
window.metaAttributesCache = new Map()
window.csrfToken = originalWindowCSRFToken
locationStub.reset()
})
it('uploads a dropped file', async function () {
const xhr = sinon.useFakeXMLHttpRequest()
const requests: sinon.SinonFakeXMLHttpRequest[] = []
xhr.onCreate = request => {
requests.push(request)
}
render(<UploadProjectModal onHide={() => {}} />)
const uploadButton = screen.getByRole('button', {
name: 'Select a .zip file',
})
expect(uploadButton).not.to.be.null
fireEvent.drop(uploadButton, {
dataTransfer: {
files: [new File(['test'], 'test.zip', { type: 'application/zip' })],
},
})
await waitFor(() => expect(requests).to.have.length(1))
const [request] = requests
expect(request.url).to.equal('/project/new/upload')
expect(request.method).to.equal('POST')
const projectId = '123abc'
request.respond(
200,
{ 'Content-Type': 'application/json' },
JSON.stringify({ success: true, project_id: projectId })
)
await waitFor(() => {
sinon.assert.calledOnce(locationStub)
sinon.assert.calledWith(locationStub, `/project/${projectId}`)
})
xhr.restore()
})
it('shows error on file type other than zip', async function () {
render(<UploadProjectModal onHide={() => {}} />)
const uploadButton = screen.getByRole('button', {
name: 'Select a .zip file',
})
expect(uploadButton).not.to.be.null
fireEvent.drop(uploadButton, {
dataTransfer: {
files: [new File(['test'], 'test.png', { type: 'image/png' })],
},
})
await waitFor(() => screen.getByText('You can only upload: .zip'))
})
it('shows error for files bigger than maxUploadSize', async function () {
render(<UploadProjectModal onHide={() => {}} />)
const uploadButton = screen.getByRole('button', {
name: 'Select a .zip file',
})
expect(uploadButton).not.to.be.null
const filename = 'test.zip'
const file = new File(['test'], filename, { type: 'application/zip' })
Object.defineProperty(file, 'size', { value: maxUploadSize + 1 })
fireEvent.drop(uploadButton, {
dataTransfer: {
files: [file],
},
})
await waitFor(() =>
screen.getByText(`${filename} exceeds maximum allowed size of 10 MB`)
)
})
it('handles server error', async function () {
const xhr = sinon.useFakeXMLHttpRequest()
const requests: sinon.SinonFakeXMLHttpRequest[] = []
xhr.onCreate = request => {
requests.push(request)
}
render(<UploadProjectModal onHide={() => {}} />)
const uploadButton = screen.getByRole('button', {
name: 'Select a .zip file',
})
expect(uploadButton).not.to.be.null
fireEvent.drop(uploadButton, {
dataTransfer: {
files: [new File(['test'], 'test.zip', { type: 'application/zip' })],
},
})
await waitFor(() => expect(requests).to.have.length(1))
const [request] = requests
expect(request.url).to.equal('/project/new/upload')
expect(request.method).to.equal('POST')
request.respond(
422,
{ 'Content-Type': 'application/json' },
JSON.stringify({ success: false })
)
await waitFor(() => {
sinon.assert.notCalled(locationStub)
screen.getByText('Upload failed')
})
xhr.restore()
})
})

View file

@ -0,0 +1,643 @@
import { expect } from 'chai'
import sinon from 'sinon'
import {
fireEvent,
render,
screen,
waitForElementToBeRemoved,
} from '@testing-library/react'
import fetchMock from 'fetch-mock'
import { merge, cloneDeep } from 'lodash'
import {
professionalUserData,
unconfirmedUserData,
} from '../../settings/fixtures/test-user-email-data'
import {
notification,
notificationsInstitution,
} from '../fixtures/notifications-data'
import Common from '../../../../../frontend/js/features/project-list/components/notifications/groups/common'
import Institution from '../../../../../frontend/js/features/project-list/components/notifications/groups/institution'
import ConfirmEmail from '../../../../../frontend/js/features/project-list/components/notifications/groups/confirm-email'
import ReconfirmationInfo from '../../../../../frontend/js/features/project-list/components/notifications/groups/affiliation/reconfirmation-info'
import { ProjectListProvider } from '../../../../../frontend/js/features/project-list/context/project-list-context'
import {
Notification,
Institution as InstitutionType,
} from '../../../../../types/project/dashboard/notification'
import { DeepPartial } from '../../../../../types/utils'
import { Project } from '../../../../../types/project/dashboard/api'
const renderWithinProjectListProvider = (Component: React.ComponentType) => {
render(<Component />, {
wrapper: ({ children }) => (
<ProjectListProvider>
<ul className="list-unstyled">{children}</ul>
</ProjectListProvider>
),
})
}
describe('<UserNotifications />', function () {
const exposedSettings = {
samlInitPath: '/fakeSaml/',
appName: 'Overleaf',
}
beforeEach(function () {
window.metaAttributesCache = window.metaAttributesCache || new Map()
fetchMock.reset()
// at least one project is required to show some notifications
const projects = [{}] as Project[]
fetchMock.post(/\/api\/project/, {
status: 200,
body: {
projects,
totalSize: projects.length,
},
})
})
afterEach(function () {
window.metaAttributesCache = new Map()
fetchMock.reset()
})
describe('<Common>', function () {
beforeEach(function () {
window.metaAttributesCache = window.metaAttributesCache || new Map()
window.metaAttributesCache.set('ol-ExposedSettings', exposedSettings)
})
afterEach(function () {
window.metaAttributesCache = new Map()
fetchMock.reset()
})
it('accepts project invite', async function () {
const reconfiguredNotification: DeepPartial<Notification> = {
_id: 1,
templateKey: 'notification_project_invite',
}
window.metaAttributesCache.set('ol-notifications', [
merge(cloneDeep(notification), reconfiguredNotification),
])
renderWithinProjectListProvider(Common)
await fetchMock.flush(true)
const deleteMock = fetchMock.delete(
`/notifications/${reconfiguredNotification._id}`,
200
)
const acceptMock = fetchMock.post(
`project/${notification.messageOpts.projectId}/invite/token/${notification.messageOpts.token}/accept`,
200
)
screen.getByRole('alert')
screen.getByText(/would like you to join/i)
const joinBtn = screen.getByRole('button', {
name: /join project/i,
}) as HTMLButtonElement
expect(joinBtn.disabled).to.be.false
fireEvent.click(joinBtn)
expect(joinBtn.disabled).to.be.true
await waitForElementToBeRemoved(() =>
screen.getByRole('button', { name: /joining/i })
)
expect(acceptMock.called()).to.be.true
screen.getByText(/joined/i)
expect(screen.queryByRole('button', { name: /join project/i })).to.be.null
const openProject = screen.getByRole('link', { name: /open project/i })
expect(openProject.getAttribute('href')).to.equal(
`/project/${notification.messageOpts.projectId}`
)
const closeBtn = screen.getByRole('button', { name: /close/i })
fireEvent.click(closeBtn)
expect(deleteMock.called()).to.be.true
expect(screen.queryByRole('alert')).to.be.null
})
it('fails to accept project invite', async function () {
const reconfiguredNotification: DeepPartial<Notification> = {
_id: 1,
templateKey: 'notification_project_invite',
}
window.metaAttributesCache.set('ol-notifications', [
merge(cloneDeep(notification), reconfiguredNotification),
])
renderWithinProjectListProvider(Common)
await fetchMock.flush(true)
fetchMock.post(
`project/${notification.messageOpts.projectId}/invite/token/${notification.messageOpts.token}/accept`,
500
)
screen.getByRole('alert')
screen.getByText(/would like you to join/i)
const joinBtn = screen.getByRole('button', {
name: /join project/i,
}) as HTMLButtonElement
fireEvent.click(joinBtn)
await waitForElementToBeRemoved(() =>
screen.getByRole('button', { name: /joining/i })
)
expect(fetchMock.called()).to.be.true
screen.getByRole('button', { name: /join project/i })
expect(screen.queryByRole('link', { name: /open project/i })).to.be.null
})
it('shows WFH2020', async function () {
const reconfiguredNotification: DeepPartial<Notification> = {
_id: 1,
templateKey: 'wfh_2020_upgrade_offer',
}
window.metaAttributesCache.set('ol-notifications', [
merge(cloneDeep(notification), reconfiguredNotification),
])
renderWithinProjectListProvider(Common)
await fetchMock.flush(true)
fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
screen.getByRole('alert')
screen.getByText(/your free WFH2020 upgrade came to an end on/i)
const viewLink = screen.getByRole('link', { name: /view/i })
expect(viewLink.getAttribute('href')).to.equal(
'https://www.overleaf.com/events/wfh2020'
)
const closeBtn = screen.getByRole('button', { name: /close/i })
fireEvent.click(closeBtn)
expect(fetchMock.called()).to.be.true
expect(screen.queryByRole('alert')).to.be.null
})
it('shows IP matched affiliation with SSO enabled', async function () {
const reconfiguredNotification: DeepPartial<Notification> = {
_id: 1,
templateKey: 'notification_ip_matched_affiliation',
messageOpts: { ssoEnabled: true },
}
window.metaAttributesCache.set('ol-notifications', [
merge(cloneDeep(notification), reconfiguredNotification),
])
renderWithinProjectListProvider(Common)
await fetchMock.flush(true)
fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
screen.getByRole('alert')
screen.getByText(/it looks like youre at/i)
screen.getByText(/you can now log in through your institution/i)
screen.getByText(
/link an institutional email address to your account to get started/i
)
const findOutMore = screen.getByRole('link', { name: /find out more/i })
expect(findOutMore.getAttribute('href')).to.equal(
'https://www.overleaf.com/learn/how-to/Institutional_Login'
)
const linkAccount = screen.getByRole('link', { name: /link account/i })
expect(linkAccount.getAttribute('href')).to.equal(
`${exposedSettings.samlInitPath}?university_id=${notification.messageOpts.institutionId}&auto=/project`
)
const closeBtn = screen.getByRole('button', { name: /close/i })
fireEvent.click(closeBtn)
expect(fetchMock.called()).to.be.true
expect(screen.queryByRole('alert')).to.be.null
})
it('shows IP matched affiliation with SSO disabled', async function () {
const reconfiguredNotification: DeepPartial<Notification> = {
_id: 1,
templateKey: 'notification_ip_matched_affiliation',
messageOpts: { ssoEnabled: false },
}
window.metaAttributesCache.set('ol-notifications', [
merge(cloneDeep(notification), reconfiguredNotification),
])
renderWithinProjectListProvider(Common)
await fetchMock.flush(true)
fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
screen.getByRole('alert')
screen.getByText(/it looks like youre at/i)
screen.getByText(/did you know that/i)
screen.getByText(
/add an institutional email address to claim your features/i
)
const addAffiliation = screen.getByRole('link', {
name: /add affiliation/i,
})
expect(addAffiliation.getAttribute('href')).to.equal(`/user/settings`)
const closeBtn = screen.getByRole('button', { name: /close/i })
fireEvent.click(closeBtn)
expect(fetchMock.called()).to.be.true
expect(screen.queryByRole('alert')).to.be.null
})
it('shows tpds file limit', async function () {
const reconfiguredNotification: DeepPartial<Notification> = {
templateKey: 'notification_tpds_file_limit',
}
window.metaAttributesCache.set('ol-notifications', [
merge(cloneDeep(notification), reconfiguredNotification),
])
renderWithinProjectListProvider(Common)
await fetchMock.flush(true)
screen.getByRole('alert')
screen.getByText(/file limit/i)
screen.getByText(
/please decrease the size of your project to prevent further errors/i
)
const accountSettings = screen.getByRole('link', {
name: /account settings/i,
})
expect(accountSettings.getAttribute('href')).to.equal('/user/settings')
const closeBtn = screen.getByRole('button', { name: /close/i })
fireEvent.click(closeBtn)
expect(screen.queryByRole('alert')).to.be.null
})
it('shows dropbox duplicate project names warning', async function () {
const reconfiguredNotification: DeepPartial<Notification> = {
_id: 1,
templateKey: 'notification_dropbox_duplicate_project_names',
}
window.metaAttributesCache.set('ol-notifications', [
merge(cloneDeep(notification), reconfiguredNotification),
])
renderWithinProjectListProvider(Common)
await fetchMock.flush(true)
fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
screen.getByRole('alert')
screen.getByText(/you have more than one project called/i)
screen.getByText(/make your project names unique/i)
const learnMore = screen.getByRole('link', { name: /learn more/i })
expect(learnMore.getAttribute('href')).to.equal(
'/learn/how-to/Dropbox_Synchronization#Troubleshooting'
)
const closeBtn = screen.getByRole('button', { name: /close/i })
fireEvent.click(closeBtn)
expect(fetchMock.called()).to.be.true
expect(screen.queryByRole('alert')).to.be.null
})
it('shows dropbox unlinked tue to lapsed reconfirmation', async function () {
const reconfiguredNotification: DeepPartial<Notification> = {
_id: 1,
templateKey:
'notification_dropbox_unlinked_due_to_lapsed_reconfirmation',
}
window.metaAttributesCache.set('ol-notifications', [
merge(cloneDeep(notification), reconfiguredNotification),
])
renderWithinProjectListProvider(Common)
await fetchMock.flush(true)
fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
screen.getByRole('alert')
screen.getByText(/your Dropbox account has been unlinked/i)
screen.getByText(
/confirm you are still at the institution and on their license, or upgrade your account in order to relink your dropbox account/i
)
const learnMore = screen.getByRole('link', { name: /learn more/i })
expect(learnMore.getAttribute('href')).to.equal(
'/learn/how-to/Institutional_Email_Reconfirmation'
)
const closeBtn = screen.getByRole('button', { name: /close/i })
fireEvent.click(closeBtn)
expect(fetchMock.called()).to.be.true
expect(screen.queryByRole('alert')).to.be.null
})
it('shows non specific notification', async function () {
const reconfiguredNotification: DeepPartial<Notification> = {
_id: 1,
html: 'unspecific message',
}
window.metaAttributesCache.set('ol-notifications', [
merge(cloneDeep(notification), reconfiguredNotification),
])
renderWithinProjectListProvider(Common)
await fetchMock.flush(true)
fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
screen.getByRole('alert')
screen.getByText(reconfiguredNotification.html!)
const closeBtn = screen.getByRole('button', { name: /close/i })
fireEvent.click(closeBtn)
expect(fetchMock.called()).to.be.true
expect(screen.queryByRole('alert')).to.be.null
})
})
describe('<Institution>', function () {
beforeEach(function () {
window.metaAttributesCache = window.metaAttributesCache || new Map()
window.metaAttributesCache.set('ol-ExposedSettings', exposedSettings)
fetchMock.reset()
})
afterEach(function () {
fetchMock.reset()
})
it('shows sso available', function () {
const institution: DeepPartial<InstitutionType> = {
templateKey: 'notification_institution_sso_available',
}
window.metaAttributesCache.set('ol-notificationsInstitution', [
{ ...notificationsInstitution, ...institution },
])
render(<Institution />)
screen.getByRole('alert')
screen.getByText(/you can now link/i)
screen.getByText(/doing this will allow you to log in/i)
const learnMore = screen.getByRole('link', { name: /learn more/i })
expect(learnMore.getAttribute('href')).to.equal(
'/learn/how-to/Institutional_Login'
)
const action = screen.getByRole('link', { name: /link account/i })
expect(action.getAttribute('href')).to.equal(
`${exposedSettings.samlInitPath}?university_id=${notificationsInstitution.institutionId}&auto=/project&email=${notificationsInstitution.email}`
)
})
it('shows sso linked', function () {
const institution: DeepPartial<InstitutionType> = {
_id: 1,
templateKey: 'notification_institution_sso_linked',
}
window.metaAttributesCache.set('ol-notificationsInstitution', [
{ ...notificationsInstitution, ...institution },
])
render(<Institution />)
fetchMock.delete(`/notifications/${institution._id}`, 200)
screen.getByRole('alert')
screen.getByText(/has been linked to your/i)
const closeBtn = screen.getByRole('button', { name: /close/i })
fireEvent.click(closeBtn)
expect(fetchMock.called()).to.be.true
expect(screen.queryByRole('alert')).to.be.null
})
it('shows sso non canonical', function () {
const institution: DeepPartial<InstitutionType> = {
_id: 1,
templateKey: 'notification_institution_sso_non_canonical',
}
window.metaAttributesCache.set('ol-notificationsInstitution', [
{ ...notificationsInstitution, ...institution },
])
render(<Institution />)
fetchMock.delete(`/notifications/${institution._id}`, 200)
screen.getByRole('alert')
screen.getByText(/youve tried to login with/i)
screen.getByText(
/in order to match your institutional metadata, your account is associated with/i
)
const closeBtn = screen.getByRole('button', { name: /close/i })
fireEvent.click(closeBtn)
expect(fetchMock.called()).to.be.true
expect(screen.queryByRole('alert')).to.be.null
})
it('shows sso already registered', function () {
const institution: DeepPartial<InstitutionType> = {
_id: 1,
templateKey: 'notification_institution_sso_already_registered',
}
window.metaAttributesCache.set('ol-notificationsInstitution', [
{ ...notificationsInstitution, ...institution },
])
render(<Institution />)
fetchMock.delete(`/notifications/${institution._id}`, 200)
screen.getByRole('alert')
screen.getByText(/which is already registered with/i)
const action = screen.getByRole('link', { name: /find out more/i })
expect(action.getAttribute('href')).to.equal(
'/learn/how-to/Institutional_Login'
)
const closeBtn = screen.getByRole('button', { name: /close/i })
fireEvent.click(closeBtn)
expect(fetchMock.called()).to.be.true
expect(screen.queryByRole('alert')).to.be.null
})
it('shows sso error', function () {
const institution: DeepPartial<InstitutionType> = {
templateKey: 'notification_institution_sso_error',
error: {
message: 'fake message',
tryAgain: true,
},
}
window.metaAttributesCache.set('ol-notificationsInstitution', [
{ ...notificationsInstitution, ...institution },
])
render(<Institution />)
screen.getByRole('alert')
screen.getByText(/something went wrong/i)
screen.getByText(institution.error!.message!)
screen.getByText(/please try again/i)
const closeBtn = screen.getByRole('button', { name: /close/i })
fireEvent.click(closeBtn)
expect(screen.queryByRole('alert')).to.be.null
})
})
describe('<ConfirmEmail/>', function () {
beforeEach(async function () {
window.metaAttributesCache.set('ol-ExposedSettings', {
emailConfirmationDisabled: false,
})
window.metaAttributesCache.set('ol-userEmails', [unconfirmedUserData])
renderWithinProjectListProvider(ConfirmEmail)
await fetchMock.flush(true)
})
afterEach(function () {
fetchMock.reset()
})
it('sends successfully', async function () {
fetchMock.post('/user/emails/resend_confirmation', 200)
const resendButton = screen.getByRole('button', { name: /resend/i })
fireEvent.click(resendButton)
expect(screen.queryByRole('button', { name: /resend/i })).to.be.null
await waitForElementToBeRemoved(() =>
screen.getByText(/resending confirmation email/i)
)
expect(fetchMock.called()).to.be.true
expect(screen.queryByRole('alert')).to.be.null
})
it('fails to send', async function () {
fetchMock.post('/user/emails/resend_confirmation', 500)
const resendButton = screen.getByRole('button', { name: /resend/i })
fireEvent.click(resendButton)
await waitForElementToBeRemoved(() =>
screen.getByText(/resending confirmation email/i)
)
expect(fetchMock.called()).to.be.true
screen.getByText(/something went wrong/i)
})
})
describe('<Affiliation/>', function () {
const locationStub = sinon.stub()
const originalLocation = window.location
beforeEach(function () {
window.metaAttributesCache = window.metaAttributesCache || new Map()
window.metaAttributesCache.set('ol-ExposedSettings', exposedSettings)
Object.defineProperty(window, 'location', {
value: { assign: locationStub },
})
fetchMock.reset()
})
afterEach(function () {
window.metaAttributesCache = new Map()
Object.defineProperty(window, 'location', {
value: originalLocation,
})
fetchMock.reset()
})
it('shows reconfirm message with SSO disabled', async function () {
window.metaAttributesCache.set('ol-allInReconfirmNotificationPeriods', [
{ ...professionalUserData, samlProviderId: 'Saml Provider' },
])
render(<ReconfirmationInfo />)
screen.getByRole('alert')
screen.getByText(
/take a moment to confirm your institutional email address/i
)
const removeLink = screen.getByRole('link', { name: /remove it/i })
expect(removeLink.getAttribute('href')).to.equal(
`/user/settings?remove=${professionalUserData.email}`
)
const learnMore = screen.getByRole('link', { name: /learn more/i })
expect(learnMore.getAttribute('href')).to.equal(
'/learn/how-to/Institutional_Email_Reconfirmation'
)
const sendReconfirmationMock = fetchMock.post(
'/user/emails/send-reconfirmation',
200
)
fireEvent.click(
screen.getByRole('button', { name: /confirm affiliation/i })
)
await waitForElementToBeRemoved(() => screen.getByText(/sending/i))
screen.getByText(/check your email inbox to confirm/i)
expect(screen.queryByRole('button', { name: /confirm affiliation/i })).to
.be.null
expect(screen.queryByRole('link', { name: /remove it/i })).to.be.null
expect(screen.queryByRole('link', { name: /learn more/i })).to.be.null
expect(sendReconfirmationMock.called()).to.be.true
fireEvent.click(
screen.getByRole('button', { name: /resend confirmation email/i })
)
await waitForElementToBeRemoved(() => screen.getByText(/sending/i))
expect(sendReconfirmationMock.calls()).to.have.lengthOf(2)
})
it('shows reconfirm message with SSO enabled', async function () {
window.metaAttributesCache.set('ol-allInReconfirmNotificationPeriods', [
merge(cloneDeep(professionalUserData), {
affiliation: { institution: { ssoEnabled: true } },
}),
])
render(<ReconfirmationInfo />)
fireEvent.click(
screen.getByRole('button', { name: /confirm affiliation/i })
)
sinon.assert.calledOnce(locationStub)
sinon.assert.calledWithMatch(
locationStub,
`${exposedSettings.samlInitPath}?university_id=${professionalUserData.affiliation.institution.id}&reconfirm=/project`
)
})
it('shows success reconfirmation message', function () {
window.metaAttributesCache.set('ol-userEmails', [
{ ...professionalUserData, samlProviderId: 'Saml Provider' },
])
window.metaAttributesCache.set('ol-reconfirmedViaSAML', 'Saml Provider')
const { rerender } = render(<ReconfirmationInfo />)
screen.getByRole('alert')
screen.getByText(professionalUserData.affiliation!.institution!.name!)
screen.getByText(/affiliation is confirmed/i)
window.metaAttributesCache.set('ol-reconfirmedViaSAML', '')
rerender(<ReconfirmationInfo />)
expect(screen.queryByRole('alert')).to.be.null
})
})
})

View file

@ -0,0 +1,106 @@
import sinon from 'sinon'
import { render, screen, fireEvent } from '@testing-library/react'
import { renderHook } from '@testing-library/react-hooks'
import { expect } from 'chai'
import SearchForm from '../../../../../frontend/js/features/project-list/components/search-form'
import {
ProjectListProvider,
useProjectListContext,
} from '../../../../../frontend/js/features/project-list/context/project-list-context'
import * as eventTracking from '../../../../../frontend/js/infrastructure/event-tracking'
import fetchMock from 'fetch-mock'
import { projectsData } from '../fixtures/projects-data'
describe('<ProjectListTable />', function () {
beforeEach(function () {
fetchMock.reset()
})
afterEach(function () {
fetchMock.reset()
})
it('renders the search form', function () {
render(<SearchForm onChange={() => {}} />)
screen.getByRole('search')
screen.getByRole('textbox', { name: /search projects/i })
})
it('clears text when clear button is clicked', function () {
render(<SearchForm onChange={() => {}} />)
const input = screen.getByRole<HTMLInputElement>('textbox', {
name: /search projects/i,
})
expect(input.value).to.equal('')
expect(screen.queryByRole('button', { name: 'clear search' })).to.be.null // clear button
fireEvent.change(input, {
target: { value: 'abc' },
})
const clearBtn = screen.getByRole('button', { name: 'clear search' })
fireEvent.click(clearBtn)
expect(input.value).to.equal('')
})
it('changes text', function () {
const onChangeMock = sinon.stub()
const sendSpy = sinon.spy(eventTracking, 'send')
render(<SearchForm onChange={onChangeMock} />)
const input = screen.getByRole('textbox', { name: /search projects/i })
const value = 'abc'
fireEvent.change(input, { target: { value } })
expect(sendSpy).to.be.calledOnceWith(
'project-list-page-interaction',
'project-search',
'keydown'
)
expect(onChangeMock).to.be.calledWith(value)
sendSpy.restore()
})
describe('integration with projects table', function () {
it('shows only data based on the input', async function () {
const filteredProjects = projectsData.filter(
({ archived, trashed }) => !archived && !trashed
)
fetchMock.post('/api/project', {
status: 200,
body: {
projects: filteredProjects,
totalSize: filteredProjects.length,
},
})
const { result, waitForNextUpdate } = renderHook(
() => useProjectListContext(),
{
wrapper: ({ children }) => (
<ProjectListProvider>{children}</ProjectListProvider>
),
}
)
await waitForNextUpdate()
expect(result.current.visibleProjects.length).to.equal(
filteredProjects.length
)
const handleChange = result.current.setSearchText
render(<SearchForm onChange={handleChange} />)
const input = screen.getByRole('textbox', { name: /search projects/i })
const value = projectsData[0].name
fireEvent.change(input, { target: { value } })
expect(result.current.visibleProjects.length).to.equal(1)
})
})
})

View file

@ -0,0 +1,261 @@
import {
render,
fireEvent,
screen,
waitFor,
within,
} from '@testing-library/react'
import { assert, expect } from 'chai'
import fetchMock from 'fetch-mock'
import TagsList from '../../../../../../frontend/js/features/project-list/components/sidebar/tags-list'
import { ProjectListProvider } from '../../../../../../frontend/js/features/project-list/context/project-list-context'
describe('<TagsList />', function () {
beforeEach(async function () {
window.metaAttributesCache.set('ol-tags', [
{
_id: 'abc123def456',
name: 'Tag 1',
project_ids: ['456fea789bcd'],
},
{
_id: 'bcd234efg567',
name: 'Another tag',
project_ids: ['456fea789bcd', '567efa890bcd'],
},
])
fetchMock.post('/api/project', {
projects: [
{
id: '456fea789bcd',
archived: false,
trashed: false,
},
{
id: '567efa890bcd',
archived: false,
trashed: false,
},
{
id: '999fff999fff',
archived: false,
trashed: false,
},
],
})
fetchMock.post('/tag', {
_id: 'eee888eee888',
name: 'New Tag',
project_ids: [],
})
fetchMock.post('express:/tag/:tagId/rename', 200)
fetchMock.delete('express:/tag/:tagId', 200)
render(<TagsList />, {
wrapper: ({ children }) => (
<ProjectListProvider>{children}</ProjectListProvider>
),
})
await waitFor(() => expect(fetchMock.called('/api/project')))
})
afterEach(function () {
fetchMock.reset()
})
it('displays the tags list', async function () {
screen.getByRole('heading', {
name: 'Tags/Folders',
})
screen.getByRole('button', {
name: 'New Folder',
})
screen.getByRole('button', {
name: 'Tag 1 (1)',
})
screen.getByRole('button', {
name: 'Another tag (2)',
})
screen.getByRole('button', {
name: 'Uncategorized (1)',
})
})
it('selects the tag when clicked', async function () {
const tag1Button = screen.getByText('Tag 1')
assert.isFalse(tag1Button.closest('li')?.classList.contains('active'))
await fireEvent.click(tag1Button)
assert.isTrue(tag1Button.closest('li')?.classList.contains('active'))
})
it('selects uncategorized when clicked', function () {
const uncategorizedButton = screen.getByText('Uncategorized')
assert.isFalse(
uncategorizedButton.closest('li')?.classList.contains('active')
)
fireEvent.click(uncategorizedButton)
assert.isTrue(
uncategorizedButton.closest('li')?.classList.contains('active')
)
})
describe('create modal', function () {
beforeEach(async function () {
const newTagButton = screen.getByRole('button', {
name: 'New Folder',
})
await fireEvent.click(newTagButton)
})
it('modal is open', async function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
within(modal).getByRole('heading', { name: 'Create New Folder' })
})
it('click on cancel closes the modal', async function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const cancelButton = within(modal).getByRole('button', { name: 'Cancel' })
await fireEvent.click(cancelButton)
expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
})
it('Create button is disabled when input is empty', async function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const createButton = within(modal).getByRole('button', { name: 'Create' })
expect(createButton.hasAttribute('disabled')).to.be.true
})
it('filling the input and clicking Create sends a request', async function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const input = within(modal).getByRole('textbox')
fireEvent.change(input, { target: { value: 'New Tag' } })
const createButton = within(modal).getByRole('button', { name: 'Create' })
expect(createButton.hasAttribute('disabled')).to.be.false
await fireEvent.click(createButton)
await waitFor(() => expect(fetchMock.called(`/tag`)))
expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
screen.getByRole('button', {
name: 'New Tag (0)',
})
})
})
describe('rename modal', function () {
beforeEach(async function () {
const tag1Button = screen.getByText('Tag 1')
const renameButton = within(
tag1Button.closest('li') as HTMLElement
).getByRole('button', {
name: 'Rename',
})
await fireEvent.click(renameButton)
})
it('modal is open', async function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
within(modal).getByRole('heading', { name: 'Rename Folder' })
})
it('click on cancel closes the modal', async function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const cancelButton = within(modal).getByRole('button', { name: 'Cancel' })
await fireEvent.click(cancelButton)
expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
})
it('Rename button is disabled when input is empty', async function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const renameButton = within(modal).getByRole('button', { name: 'Rename' })
expect(renameButton.hasAttribute('disabled')).to.be.true
})
it('filling the input and clicking Rename sends a request', async function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const input = within(modal).getByRole('textbox')
fireEvent.change(input, { target: { value: 'New Tag Name' } })
const renameButton = within(modal).getByRole('button', { name: 'Rename' })
expect(renameButton.hasAttribute('disabled')).to.be.false
await fireEvent.click(renameButton)
await waitFor(() => expect(fetchMock.called(`/tag/abc123def456/rename`)))
expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
screen.getByRole('button', {
name: 'New Tag Name (1)',
})
})
})
describe('delete modal', function () {
beforeEach(async function () {
const tag1Button = screen.getByText('Another tag')
const renameButton = within(
tag1Button.closest('li') as HTMLElement
).getByRole('button', {
name: 'Delete',
})
await fireEvent.click(renameButton)
})
it('modal is open', async function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
within(modal).getByRole('heading', { name: 'Delete Folder' })
})
it('click on Cancel closes the modal', async function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const cancelButton = within(modal).getByRole('button', { name: 'Cancel' })
await fireEvent.click(cancelButton)
expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
})
it('clicking Delete sends a request', async function () {
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const deleteButton = within(modal).getByRole('button', { name: 'Delete' })
await fireEvent.click(deleteButton)
await waitFor(() => expect(fetchMock.called(`/tag/bcd234efg567`)))
expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
expect(
screen.queryByRole('button', {
name: 'Another Tag (2)',
})
).to.be.null
})
it('a failed request displays an error message', async function () {
fetchMock.delete('express:/tag/:tagId', 500, { overwriteRoutes: true })
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const deleteButton = within(modal).getByRole('button', { name: 'Delete' })
await fireEvent.click(deleteButton)
await waitFor(() => expect(fetchMock.called(`/tag/bcd234efg567`)))
within(modal).getByText('Sorry, something went wrong')
})
})
})

View file

@ -0,0 +1,92 @@
import { expect } from 'chai'
import { fireEvent, render, screen } from '@testing-library/react'
import SurveyWidget from '../../../../../frontend/js/features/project-list/components/survey-widget'
describe('<SurveyWidget />', function () {
beforeEach(function () {
this.name = 'my-survey'
this.preText = 'To help shape the future of Overleaf'
this.linkText = 'Click here!'
this.url = 'https://example.com/my-survey'
window.metaAttributesCache = new Map()
localStorage.clear()
})
describe('survey widget is visible', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-survey', {
name: this.name,
preText: this.preText,
linkText: this.linkText,
url: this.url,
})
render(<SurveyWidget />)
})
it('shows text and link', function () {
const dismissed = localStorage.getItem('dismissed-my-survey')
expect(dismissed).to.equal(null)
screen.getByText(this.preText)
const link = screen.getByRole('link', {
name: this.linkText,
}) as HTMLAnchorElement
expect(link.href).to.equal(this.url)
})
it('it is dismissed on click on the dismiss button', function () {
const dismissButton = screen.getByRole('button', {
name: 'Dismiss Overleaf survey',
})
fireEvent.click(dismissButton)
const text = screen.queryByText(this.preText)
expect(text).to.be.null
const link = screen.queryByRole('link')
expect(link).to.be.null
const dismissed = localStorage.getItem('dismissed-my-survey')
expect(dismissed).to.equal('true')
})
})
describe('survey widget is not shown when already dismissed', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-survey', {
name: this.name,
preText: this.preText,
linkText: this.linkText,
url: this.url,
})
localStorage.setItem('dismissed-my-survey', 'true')
render(<SurveyWidget />)
})
it('nothing is displayed', function () {
const text = screen.queryByText(this.preText)
expect(text).to.be.null
const link = screen.queryByRole('link')
expect(link).to.be.null
})
})
describe('survey widget is not shown when no survey is configured', function () {
beforeEach(function () {
render(<SurveyWidget />)
})
it('nothing is displayed', function () {
const text = screen.queryByText(this.preText)
expect(text).to.be.null
const link = screen.queryByRole('link')
expect(link).to.be.null
})
})
})

View file

@ -0,0 +1,73 @@
import { expect } from 'chai'
import { fireEvent, screen } from '@testing-library/react'
import ArchiveProjectButton from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/archive-project-button'
import {
archiveableProject,
archivedProject,
} from '../../../../fixtures/projects-data'
import fetchMock from 'fetch-mock'
import {
resetProjectListContextFetch,
renderWithProjectListContext,
} from '../../../../helpers/render-with-context'
describe('<ArchiveProjectButton />', function () {
afterEach(function () {
resetProjectListContextFetch()
})
it('renders tooltip for button', function () {
renderWithProjectListContext(
<ArchiveProjectButton project={archiveableProject} />
)
const btn = screen.getByLabelText('Archive')
fireEvent.mouseOver(btn)
screen.getByRole('tooltip', { name: 'Archive' })
})
it('opens the modal when clicked', function () {
renderWithProjectListContext(
<ArchiveProjectButton project={archiveableProject} />
)
const btn = screen.getByLabelText('Archive')
fireEvent.click(btn)
screen.getByText('Archive Projects')
screen.getByText(archiveableProject.name)
})
it('does not render the button when already archived', function () {
renderWithProjectListContext(
<ArchiveProjectButton project={archivedProject} />
)
expect(screen.queryByLabelText('Archive')).to.be.null
})
it('should archive the projects', async function () {
const project = Object.assign({}, archiveableProject)
fetchMock.post(
`express:/project/${project.id}/archive`,
{
status: 200,
},
{ delay: 0 }
)
renderWithProjectListContext(<ArchiveProjectButton project={project} />)
const btn = screen.getByLabelText('Archive')
fireEvent.click(btn)
screen.getByText('Archive Projects')
screen.getByText('You are about to archive the following projects:')
screen.getByText('Archiving projects wont affect your collaborators.')
const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true
// verify archived
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
const requests = fetchMock.calls()
// first mock call is to get list of projects in projectlistcontext
const [requestUrl, requestHeaders] = requests[1]
expect(requestUrl).to.equal(`/project/${project.id}/archive`)
expect(requestHeaders?.method).to.equal('POST')
fetchMock.reset()
})
})

View file

@ -0,0 +1,27 @@
import { expect } from 'chai'
import { fireEvent, render, screen } from '@testing-library/react'
import CopyProjectButton from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/copy-project-button'
import {
archivedProject,
copyableProject,
trashedProject,
} from '../../../../fixtures/projects-data'
describe('<CopyProjectButton />', function () {
it('renders tooltip for button', function () {
render(<CopyProjectButton project={copyableProject} />)
const btn = screen.getByLabelText('Copy')
fireEvent.mouseOver(btn)
screen.getByRole('tooltip', { name: 'Copy' })
})
it('does not render the button when project is archived', function () {
render(<CopyProjectButton project={archivedProject} />)
expect(screen.queryByLabelText('Copy')).to.be.null
})
it('does not render the button when project is trashed', function () {
render(<CopyProjectButton project={trashedProject} />)
expect(screen.queryByLabelText('Copy')).to.be.null
})
})

View file

@ -0,0 +1,30 @@
import { expect } from 'chai'
import { fireEvent, render, screen } from '@testing-library/react'
import DeleteProjectButton from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/delete-project-button'
import {
archiveableProject,
trashedAndNotOwnedProject,
trashedProject,
} from '../../../../fixtures/projects-data'
describe('<DeleteProjectButton />', function () {
it('renders tooltip for button', function () {
window.user_id = trashedProject?.owner?.id
render(<DeleteProjectButton project={trashedProject} />)
const btn = screen.getByLabelText('Delete')
fireEvent.mouseOver(btn)
screen.getByRole('tooltip', { name: 'Delete' })
})
it('does not render button when trashed and not owner', function () {
window.user_id = '123abc'
render(<DeleteProjectButton project={trashedAndNotOwnedProject} />)
const btn = screen.queryByLabelText('Delete')
expect(btn).to.be.null
})
it('does not render the button when project is current', function () {
render(<DeleteProjectButton project={archiveableProject} />)
expect(screen.queryByLabelText('Delete')).to.be.null
})
})

View file

@ -0,0 +1,46 @@
import { expect } from 'chai'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import sinon from 'sinon'
import DownloadProjectButton from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button'
import { projectsData } from '../../../../fixtures/projects-data'
describe('<DownloadProjectButton />', function () {
const originalLocation = window.location
const locationStub = sinon.stub()
beforeEach(function () {
Object.defineProperty(window, 'location', {
value: { assign: locationStub },
})
render(<DownloadProjectButton project={projectsData[0]} />)
})
afterEach(function () {
Object.defineProperty(window, 'location', {
value: originalLocation,
})
})
it('renders tooltip for button', function () {
const btn = screen.getByLabelText('Download')
fireEvent.mouseOver(btn)
screen.getByRole('tooltip', { name: 'Download' })
})
it('downloads the project when clicked', async function () {
const btn = screen.getByLabelText('Download') as HTMLButtonElement
fireEvent.click(btn)
await waitFor(() => {
expect(locationStub).to.have.been.called
})
sinon.assert.calledOnce(locationStub)
sinon.assert.calledWithMatch(
locationStub,
`/project/${projectsData[0].id}/download/zip`
)
})
})

View file

@ -0,0 +1,80 @@
import { expect } from 'chai'
import { fireEvent, screen } from '@testing-library/react'
import LeaveProjectButton from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/leave-project-buttton'
import {
trashedProject,
trashedAndNotOwnedProject,
archivedProject,
archiveableProject,
} from '../../../../fixtures/projects-data'
import fetchMock from 'fetch-mock'
import {
renderWithProjectListContext,
resetProjectListContextFetch,
} from '../../../../helpers/render-with-context'
describe('<LeaveProjectButtton />', function () {
afterEach(function () {
resetProjectListContextFetch()
})
it('renders tooltip for button', function () {
renderWithProjectListContext(
<LeaveProjectButton project={trashedAndNotOwnedProject} />
)
const btn = screen.getByLabelText('Leave')
fireEvent.mouseOver(btn)
screen.getByRole('tooltip', { name: 'Leave' })
})
it('does not render button when owner', function () {
window.user_id = trashedProject?.owner?.id
renderWithProjectListContext(
<LeaveProjectButton project={trashedProject} />
)
const btn = screen.queryByLabelText('Leave')
expect(btn).to.be.null
})
it('does not render the button when project is archived', function () {
renderWithProjectListContext(
<LeaveProjectButton project={archivedProject} />
)
expect(screen.queryByLabelText('Leave')).to.be.null
})
it('does not render the button when project is current', function () {
renderWithProjectListContext(
<LeaveProjectButton project={archiveableProject} />
)
expect(screen.queryByLabelText('Leave')).to.be.null
})
it('opens the modal and leaves the project', async function () {
const project = Object.assign({}, trashedAndNotOwnedProject)
fetchMock.post(
`express:/project/${project.id}/leave`,
{
status: 200,
},
{ delay: 0 }
)
renderWithProjectListContext(<LeaveProjectButton project={project} />)
const btn = screen.getByLabelText('Leave')
fireEvent.click(btn)
screen.getByText('Leave Projects')
screen.getByText('You are about to leave the following projects:')
screen.getByText('This action cannot be undone.')
const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true
// verify trashed
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
const requests = fetchMock.calls()
// first request is project list api in projectlistcontext
const [requestUrl, requestHeaders] = requests[1]
expect(requestUrl).to.equal(`/project/${project.id}/leave`)
expect(requestHeaders?.method).to.equal('POST')
fetchMock.reset()
})
})

View file

@ -0,0 +1,63 @@
import { expect } from 'chai'
import { fireEvent, screen } from '@testing-library/react'
import TrashProjectButton from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/trash-project-button'
import {
archivedProject,
trashedProject,
} from '../../../../fixtures/projects-data'
import fetchMock from 'fetch-mock'
import {
renderWithProjectListContext,
resetProjectListContextFetch,
} from '../../../../helpers/render-with-context'
describe('<TrashProjectButton />', function () {
afterEach(function () {
resetProjectListContextFetch()
})
it('renders tooltip for button', function () {
renderWithProjectListContext(
<TrashProjectButton project={archivedProject} />
)
const btn = screen.getByLabelText('Trash')
fireEvent.mouseOver(btn)
screen.getByRole('tooltip', { name: 'Trash' })
})
it('does not render the button when project is trashed', function () {
renderWithProjectListContext(
<TrashProjectButton project={trashedProject} />
)
expect(screen.queryByLabelText('Trash')).to.be.null
})
it('opens the modal and trashes the project', async function () {
const project = Object.assign({}, archivedProject)
fetchMock.post(
`express:/project/${project.id}/trash`,
{
status: 200,
},
{ delay: 0 }
)
renderWithProjectListContext(<TrashProjectButton project={project} />)
const btn = screen.getByLabelText('Trash')
fireEvent.click(btn)
screen.getByText('Trash Projects')
screen.getByText('You are about to trash the following projects:')
screen.getByText('Trashing projects wont affect your collaborators.')
const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true
// verify trashed
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
const requests = fetchMock.calls()
// first request is to get list of projects in projectlistcontext
const [requestUrl, requestHeaders] = requests[1]
expect(requestUrl).to.equal(`/project/${project.id}/trash`)
expect(requestHeaders?.method).to.equal('POST')
fetchMock.reset()
})
})

Some files were not shown because too many files have changed in this diff Show more