[web] Add new admin tool for surveys (#8356)

* Setup survey module and admin page skeleton

* Replace survey staff access permission with admin-only

* Manage survey config with admin tool

* Display configurable survey in project list + add preview in admin

* Fix linting errors and unit tests

* Add acceptance tests for survey module

* Move survey-form to survey components

* Add configuration option for Recurly group subscription users on surveys

* Change survey pre-link text to a lighter gray for accessibility

* Cleanup survey options implementation after review

GitOrigin-RevId: 8f621951efeae458d1ab081fe98b8d0d539cca1a
This commit is contained in:
Alexandre Bourdin 2022-06-22 11:34:25 +02:00 committed by Copybot
parent 21c8b9a47a
commit 3d26c4bb6f
14 changed files with 259 additions and 0 deletions

View file

@ -42,6 +42,7 @@ const UserPrimaryEmailCheckHandler = require('../User/UserPrimaryEmailCheckHandl
const { hasAdminAccess } = require('../Helpers/AdminAuthorizationHelper')
const InstitutionsFeatures = require('../Institutions/InstitutionsFeatures')
const SubscriptionViewModelBuilder = require('../Subscription/SubscriptionViewModelBuilder')
const SurveyHandler = require('../Survey/SurveyHandler')
const _ssoAvailable = (affiliation, session, linkedInstitutionIds) => {
if (!affiliation.institution) return false
@ -471,6 +472,17 @@ const ProjectController = {
}
)
},
survey(cb) {
SurveyHandler.getSurvey(userId, (err, survey) => {
if (err) {
logger.warn({ err }, 'failed to get survey')
// do not fail loading the project list if we fail to load the survey
cb(null, null)
} else {
cb(null, survey)
}
})
},
},
(err, results) => {
if (err != null) {
@ -630,6 +642,7 @@ const ProjectController = {
metadata: { viewport: false },
showThinFooter: true, // don't show the fat footer on the projects dashboard, as there's a fixed space available
usersBestSubscription: results.usersBestSubscription,
survey: results.survey,
}
const paidUser =

View file

@ -40,6 +40,22 @@ const SubscriptionLocator = {
.exec(callback)
},
hasRecurlyGroupSubscription(userOrId, callback) {
const userId = SubscriptionLocator._getUserId(userOrId)
Subscription.exists(
{
groupPlan: true,
recurlySubscription_id: { $exists: true },
$or: [
{ member_ids: userId },
{ manager_ids: userId },
{ admin_id: userId },
],
},
callback
)
},
getSubscription(subscriptionId, callback) {
Subscription.findOne({ _id: subscriptionId }, callback)
},
@ -110,5 +126,8 @@ SubscriptionLocator.promises = {
SubscriptionLocator.getUserDeletedSubscriptions
),
getDeletedSubscription: promisify(SubscriptionLocator.getDeletedSubscription),
hasRecurlyGroupSubscription: promisify(
SubscriptionLocator.hasRecurlyGroupSubscription
),
}
module.exports = SubscriptionLocator

View file

@ -0,0 +1,25 @@
const SurveyManager = require('./SurveyManager')
const { Survey } = require('../../models/Survey')
const { CacheLoader } = require('cache-flow')
class SurveyCache extends CacheLoader {
constructor() {
super('survey', {
expirationTime: 60, // 1min in seconds
})
}
async load() {
return await SurveyManager.getSurvey()
}
serialize(value) {
return value?.toObject()
}
deserialize(value) {
return new Survey(value)
}
}
module.exports = new SurveyCache()

View file

@ -0,0 +1,25 @@
const SurveyCache = require('./SurveyCache')
const SubscriptionLocator = require('../Subscription/SubscriptionLocator')
const { callbackify } = require('../../util/promises')
async function getSurvey(userId) {
const survey = await SurveyCache.get(true)
if (survey) {
if (survey.options?.hasRecurlyGroupSubscription) {
const hasRecurlyGroupSubscription =
await SubscriptionLocator.promises.hasRecurlyGroupSubscription(userId)
if (!hasRecurlyGroupSubscription) {
return
}
}
const { name, preText, linkText, url } = survey?.toObject() || {}
return { name, preText, linkText, url }
}
}
module.exports = {
getSurvey: callbackify(getSurvey),
promises: {
getSurvey,
},
}

View file

@ -0,0 +1,37 @@
const { Survey } = require('../../models/Survey')
const OError = require('@overleaf/o-error')
async function getSurvey() {
try {
return await Survey.findOne().exec()
} catch (error) {
throw OError.tag(error, 'Failed to get survey')
}
}
async function updateSurvey({ name, preText, linkText, url, options }) {
let survey = await getSurvey()
if (!survey) {
survey = new Survey()
}
survey.name = name
survey.preText = preText
survey.linkText = linkText
survey.url = url
survey.options = options
await survey.save()
return survey
}
async function deleteSurvey() {
const survey = await getSurvey()
if (survey) {
await survey.remove()
}
}
module.exports = {
getSurvey,
updateSurvey,
deleteSurvey,
}

View file

@ -64,6 +64,7 @@ async function setupDb() {
db.spellingPreferences = internalDb.collection('spellingPreferences')
db.splittests = internalDb.collection('splittests')
db.subscriptions = internalDb.collection('subscriptions')
db.surveys = internalDb.collection('surveys')
db.systemmessages = internalDb.collection('systemmessages')
db.tags = internalDb.collection('tags')
db.teamInvites = internalDb.collection('teamInvites')

View file

@ -0,0 +1,49 @@
const mongoose = require('../infrastructure/Mongoose')
const { Schema } = mongoose
const MIN_NAME_LENGTH = 3
const MAX_NAME_LENGTH = 200
const NAME_REGEX = /^[a-z0-9-]+$/
const SurveySchema = new Schema(
{
name: {
type: String,
minLength: MIN_NAME_LENGTH,
maxlength: MAX_NAME_LENGTH,
required: true,
validate: {
validator: function (input) {
return input !== null && NAME_REGEX.test(input)
},
message: `invalid, must match: ${NAME_REGEX}`,
},
},
preText: {
type: String,
required: true,
},
linkText: {
type: String,
required: true,
},
url: {
type: String,
required: true,
},
options: {
hasRecurlyGroupSubscription: {
type: Boolean,
default: false,
},
},
},
{
collection: 'surveys',
}
)
module.exports = {
Survey: mongoose.model('Survey', SurveySchema),
SurveySchema,
}

View file

@ -18,6 +18,8 @@ nav.navbar.navbar-default.navbar-main
- var canDisplayAdminMenu = hasAdminAccess()
- var canDisplayAdminRedirect = canRedirectToAdminDomain()
- var canDisplaySplitTestMenu = hasFeature('saas') && (canDisplayAdminMenu || (getSessionUser() && getSessionUser().staffAccess && (getSessionUser().staffAccess.splitTestMetrics || getSessionUser().staffAccess.splitTestManagement)))
- var canDisplaySurveyMenu = hasFeature('saas') && canDisplayAdminMenu
.navbar-collapse.collapse(data-ol-navbar-main-collapse)
ul.nav.navbar-nav.navbar-right
if (canDisplayAdminMenu || canDisplayAdminRedirect || canDisplaySplitTestMenu)
@ -45,6 +47,9 @@ nav.navbar.navbar-default.navbar-main
if canDisplaySplitTestMenu
li
a(href="/admin/split-test") Manage Split Tests
if canDisplaySurveyMenu
li
a(href="/admin/survey") Manage Surveys
// loop over header_extras
each item in ((splitTestVariants && (splitTestVariants['unified-navigation'] === 'show-unified-navigation')) ? nav.header_extras_unified : nav.header_extras)

View file

@ -14,6 +14,7 @@ nav.navbar.navbar-default.navbar-main
- var canDisplayAdminMenu = hasAdminAccess()
- var canDisplayAdminRedirect = canRedirectToAdminDomain()
- var canDisplaySplitTestMenu = hasFeature('saas') && (canDisplayAdminMenu || (getSessionUser() && getSessionUser().staffAccess && (getSessionUser().staffAccess.splitTestMetrics || getSessionUser().staffAccess.splitTestManagement)))
- var canDisplaySurveyMenu = hasFeature('saas') && canDisplayAdminMenu
if (typeof(suppressNavbarRight) == "undefined")
.navbar-collapse.collapse(collapse="navCollapsed")
@ -37,6 +38,9 @@ nav.navbar.navbar-default.navbar-main
if canDisplaySplitTestMenu
li
a(href="/admin/split-test") Manage Split Tests
if canDisplaySurveyMenu
li
a(href="/admin/survey") Manage Surveys
// loop over header_extras
each item in ((splitTestVariants && (splitTestVariants['unified-navigation'] === 'show-unified-navigation')) ? nav.header_extras_unified : nav.header_extras)

View file

@ -13,6 +13,7 @@ block append meta
meta(name="ol-userHasNoSubscription" data-type="boolean" content=!!(settings.enableSubscriptions && !hasSubscription))
meta(name="ol-allInReconfirmNotificationPeriods" data-type="json" content=allInReconfirmNotificationPeriods)
meta(name="ol-reconfirmedViaSAML" content=reconfirmedViaSAML)
meta(name="ol-survey-name" data-type="string" content=(survey ? survey.name : undefined))
block content
@ -43,6 +44,26 @@ block content
aside.project-list-sidebar
include ./list/side-bar
if (survey && survey.name)
.project-list-sidebar-survey(
ng-if="shouldShowSurveyLink"
ng-cloak
)
| #{survey.preText}
a.project-list-sidebar-survey-link(
href=survey.url
target="_blank"
rel="noreferrer noopener"
) #{survey.linkText}
button.project-list-sidebar-survey-dismiss(
type="button"
title="Dismiss Overleaf survey"
ng-click="dismissSurvey()"
)
span(
aria-hidden="true"
) ×
.project-list-main.col-md-10.col-xs-9
include ./list/notifications
include ./list/project-list

View file

@ -1,6 +1,7 @@
import _ from 'lodash'
import App from '../../base'
import './services/project-list'
import getMeta from '../../utils/meta'
App.controller(
'ProjectPageController',
function (
@ -29,6 +30,14 @@ App.controller(
newValue === 'ownerName' ? ownerNameComparator : defaultComparator
})
const surveyName = getMeta('ol-survey-name')
$scope.shouldShowSurveyLink =
localStorage(`dismissed-${surveyName}`) !== true
$scope.dismissSurvey = () => {
localStorage(`dismissed-${surveyName}`, true)
$scope.shouldShowSurveyLink = false
}
$timeout(() => recalculateProjectListHeight(), 10)
$scope.$watch(

View file

@ -11,6 +11,52 @@
padding-right: 15px;
}
.project-list-sidebar-survey {
position: relative;
font-size: @font-size-small;
background-color: @v2-dash-pane-bg;
color: @v2-dash-pane-subdued-color;
padding: @folders-menu-item-v-padding 20px @folders-menu-item-v-padding
@folders-menu-item-h-padding;
&::before {
content: '';
display: block;
height: 15px;
background-image: linear-gradient(to top, rgba(0, 0, 0, 0.1), transparent);
position: absolute;
bottom: 100%;
width: 100%;
left: 0;
}
}
.project-list-sidebar-survey-link {
color: @v2-dash-pane-color;
font-weight: bold;
&:hover,
&:active,
&:focus {
text-decoration: none;
color: @v2-dash-pane-color;
}
}
.project-list-sidebar-survey-dismiss {
.btn-inline-link;
position: absolute;
top: @folders-menu-item-v-padding;
right: @folders-menu-item-v-padding;
font-size: @font-size-base;
line-height: 1;
color: @v2-dash-pane-color;
&:hover,
&:active,
&:focus {
text-decoration: none;
color: @v2-dash-pane-color;
}
}
.project-list-sidebar-v2-pane {
flex-grow: 0;
flex-shrink: 0;

View file

@ -933,6 +933,7 @@
@v2-dash-pane-bg: @ol-blue-gray-4;
@v2-dash-pane-link-color: #fff;
@v2-dash-pane-color: #fff;
@v2-dash-pane-subdued-color: @ol-blue-gray-1;
@v2-dash-pane-toggle-color: #fff;
@v2-dash-pane-btn-bg: @ol-blue-gray-5;
@v2-dash-pane-btn-hover-bg: @ol-blue-gray-6;

View file

@ -140,6 +140,9 @@ describe('ProjectController', function () {
this.SubscriptionViewModelBuilder = {
getBestSubscription: sinon.stub().yields(null, { type: 'free' }),
}
this.SurveyHandler = {
getSurvey: sinon.stub().yields(null, {}),
}
this.ProjectController = SandboxedModule.require(MODULE_PATH, {
requires: {
@ -185,6 +188,7 @@ describe('ProjectController', function () {
getUserDictionary: sinon.stub().yields(null, []),
},
'../Institutions/InstitutionsFeatures': this.InstitutionsFeatures,
'../Survey/SurveyHandler': this.SurveyHandler,
},
})