mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-28 21:23:17 -05:00
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:
parent
3687ca60bb
commit
a0fabee3b4
118 changed files with 8441 additions and 69 deletions
2
package-lock.json
generated
2
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
538
services/web/app/src/Features/Project/ProjectListController.js
Normal file
538
services/web/app/src/Features/Project/ProjectListController.js
Normal 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),
|
||||
}
|
38
services/web/app/src/Features/Project/types.d.ts
vendored
Normal file
38
services/web/app/src/Features/Project/types.d.ts
vendored
Normal 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[]
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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) {
|
||||
|
|
6
services/web/app/src/Features/Tags/types.d.ts
vendored
Normal file
6
services/web/app/src/Features/Tags/types.d.ts
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
export type Tag = {
|
||||
_id: string
|
||||
user_id: string
|
||||
name: string
|
||||
project_ids?: string[]
|
||||
}
|
|
@ -410,6 +410,7 @@ module.exports = function (webRouter, privateApiRouter, publicApiRouter) {
|
|||
Settings.analytics.ga &&
|
||||
Settings.analytics.ga.tokenV4,
|
||||
cookieDomain: Settings.cookieDomain,
|
||||
templateLinks: Settings.templateLinks,
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
|
|
@ -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
|
||||
|
|
21
services/web/app/views/project/list-react.pug
Normal file
21
services/web/app/views/project/list-react.pug
Normal 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
|
|
@ -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(
|
||||
|
|
|
@ -783,6 +783,7 @@ module.exports = {
|
|||
sourceEditorCompletionSources: [],
|
||||
integrationLinkingWidgets: [],
|
||||
referenceLinkingWidgets: [],
|
||||
importProjectFromGithubModalWrapper: [],
|
||||
},
|
||||
|
||||
moduleImportSequence: ['launchpad', 'server-ce-scripts', 'user-activate'],
|
||||
|
|
|
@ -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": "",
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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">×</span>
|
||||
<span className="sr-only">{t('close')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Close
|
|
@ -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 }}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Icon type="refresh" spin fw /> {t('sending')}…
|
||||
</>
|
||||
) : (
|
||||
<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')}…
|
||||
</>
|
||||
) : (
|
||||
t('confirm_affiliation')
|
||||
)}
|
||||
</Button>
|
||||
<Trans
|
||||
i18nKey="are_you_still_at"
|
||||
components={[<b />]} // eslint-disable-line react/jsx-key
|
||||
values={{ institutionName: institution.name }}
|
||||
/>
|
||||
|
||||
<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}`} />]}
|
||||
/>
|
||||
|
||||
<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
|
|
@ -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
|
|
@ -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')}…
|
||||
</>
|
||||
) : (
|
||||
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
|
|
@ -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')}
|
||||
…
|
||||
</>
|
||||
) : 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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}
|
||||
<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> ×</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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} />
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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
|
|
@ -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)
|
|
@ -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')}
|
||||
<a href="/templates">{t('templates').toLowerCase()}</a>
|
||||
{t('or')}
|
||||
<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>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
66
services/web/frontend/js/features/project-list/util/api.ts
Normal file
66
services/web/frontend/js/features/project-list/util/api.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
|
@ -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 ''
|
||||
}
|
|
@ -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
|
||||
}
|
21
services/web/frontend/js/features/project-list/util/user.ts
Normal file
21
services/web/frontend/js/features/project-list/util/user.ts
Normal 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'
|
||||
}
|
|
@ -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>
|
||||
|
|
18
services/web/frontend/js/pages/project-list.js
Normal file
18
services/web/frontend/js/pages/project-list.js
Normal 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)
|
||||
}
|
|
@ -32,4 +32,5 @@ export default function ControlledDropdown(props) {
|
|||
ControlledDropdown.propTypes = {
|
||||
children: PropTypes.any,
|
||||
defaultOpen: PropTypes.bool,
|
||||
id: PropTypes.string,
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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]
|
||||
)
|
||||
|
||||
|
|
13
services/web/frontend/js/utils/dates.js
Normal file
13
services/web/frontend/js/utils/dates.js
Normal 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()
|
||||
}
|
|
@ -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,
|
||||
}
|
147
services/web/frontend/stories/project-list/helpers/emails.ts
Normal file
147
services/web/frontend/stories/project-list/helpers/emails.ts
Normal 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,
|
||||
})
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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>
|
||||
),
|
||||
],
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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';
|
||||
|
|
417
services/web/frontend/stylesheets/app/project-list-react.less
Normal file
417
services/web/frontend/stylesheets/app/project-list-react.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -50,3 +50,7 @@
|
|||
.affix {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.w-100 {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
@ -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 don’t have any repositories"
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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: /you’re 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: /you’re 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: /you’re 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: /you’re 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: /you’re 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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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 you’re 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 you’re 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(/you’ve 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
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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 won’t 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()
|
||||
})
|
||||
})
|
|
@ -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
|
||||
})
|
||||
})
|
|
@ -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
|
||||
})
|
||||
})
|
|
@ -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`
|
||||
)
|
||||
})
|
||||
})
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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 won’t 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
Loading…
Reference in a new issue