Merge pull request #19448 from overleaf/jdt-experiments-max-subscribers

Enforce a maximum participant cap on experiments

GitOrigin-RevId: 1d9263cd34a3d0c831c0ed43867bb4e6430eb06c
This commit is contained in:
Jimmy Domagala-Tang 2024-07-29 11:27:30 -04:00 committed by Copybot
parent 01b7896717
commit 837fea03b9
6 changed files with 64 additions and 21 deletions

View file

@ -12,7 +12,7 @@ async function getMetric(key, defaultValue = 0) {
if (!metric) { if (!metric) {
return defaultValue return defaultValue
} }
return metric return metric.value
} }
async function setMetric(key, value) { async function setMetric(key, value) {

View file

@ -406,6 +406,7 @@
"example_project": "", "example_project": "",
"existing_plan_active_until_term_end": "", "existing_plan_active_until_term_end": "",
"expand": "", "expand": "",
"experiment_full": "",
"expired": "", "expired": "",
"expired_confirmation_code": "", "expired_confirmation_code": "",
"expires": "", "expires": "",
@ -1401,6 +1402,7 @@
"this_action_cannot_be_undone": "", "this_action_cannot_be_undone": "",
"this_address_will_be_shown_on_the_invoice": "", "this_address_will_be_shown_on_the_invoice": "",
"this_could_be_because_we_cant_support_some_elements_of_the_table": "", "this_could_be_because_we_cant_support_some_elements_of_the_table": "",
"this_experiment_isnt_accepting_new_participants": "",
"this_field_is_required": "", "this_field_is_required": "",
"this_grants_access_to_features_2": "", "this_grants_access_to_features_2": "",
"this_is_a_labs_experiment": "", "this_is_a_labs_experiment": "",

View file

@ -1,14 +1,11 @@
import { ReactNode, useCallback, useState } from 'react' import { ReactNode, useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Badge from '@/shared/components/badge' import Badge from '@/shared/components/badge'
import Tooltip from '@/shared/components/tooltip'
import { postJSON } from '@/infrastructure/fetch-json' import { postJSON } from '@/infrastructure/fetch-json'
import { Button } from 'react-bootstrap' import { Button } from 'react-bootstrap'
import getMeta from '@/utils/meta' import getMeta from '@/utils/meta'
export type UserFeatures = {
[key: string]: boolean
}
type IntegrationLinkingWidgetProps = { type IntegrationLinkingWidgetProps = {
logo: ReactNode logo: ReactNode
title: string title: string
@ -17,6 +14,8 @@ type IntegrationLinkingWidgetProps = {
labsEnabled?: boolean labsEnabled?: boolean
experimentName: string experimentName: string
setErrorMessage: (message: string) => void setErrorMessage: (message: string) => void
optedIn: boolean
setOptedIn: (optedIn: boolean) => void
} }
export function LabsExperimentWidget({ export function LabsExperimentWidget({
@ -27,45 +26,47 @@ export function LabsExperimentWidget({
labsEnabled, labsEnabled,
experimentName, experimentName,
setErrorMessage, setErrorMessage,
optedIn,
setOptedIn,
}: IntegrationLinkingWidgetProps) { }: IntegrationLinkingWidgetProps) {
const { t } = useTranslation() const { t } = useTranslation()
const userFeatures = getMeta('ol-features') as UserFeatures
const [enabled, setEnabled] = useState(() => {
return userFeatures[experimentName] === true
})
const experimentsErrorMessage = t( const experimentsErrorMessage = t(
'we_are_unable_to_opt_you_into_this_experiment' 'we_are_unable_to_opt_you_into_this_experiment'
) )
const allowedExperiments = getMeta('ol-allowedExperiments')
const disabled = !allowedExperiments.includes(experimentName) && !optedIn
const handleEnable = useCallback(async () => { const handleEnable = useCallback(async () => {
try { try {
const enablePath = `/labs/participate/experiments/${experimentName}/opt-in` const enablePath = `/labs/participate/experiments/${experimentName}/opt-in`
await postJSON(enablePath) await postJSON(enablePath)
setEnabled(true) setOptedIn(true)
} catch (err) { } catch (err) {
setErrorMessage(experimentsErrorMessage) setErrorMessage(experimentsErrorMessage)
} }
}, [experimentName, setErrorMessage, experimentsErrorMessage]) }, [experimentName, setErrorMessage, experimentsErrorMessage, setOptedIn])
const handleDisable = useCallback(async () => { const handleDisable = useCallback(async () => {
try { try {
const disablePath = `/labs/participate/experiments/${experimentName}/opt-out` const disablePath = `/labs/participate/experiments/${experimentName}/opt-out`
await postJSON(disablePath) await postJSON(disablePath)
setEnabled(false) setOptedIn(false)
} catch (err) { } catch (err) {
setErrorMessage(experimentsErrorMessage) setErrorMessage(experimentsErrorMessage)
} }
}, [experimentName, setErrorMessage, experimentsErrorMessage]) }, [experimentName, setErrorMessage, experimentsErrorMessage, setOptedIn])
return ( return (
<div className="labs-experiment-widget-container"> <div
className={`labs-experiment-widget-container ${disabled ? 'disabled-experiment' : ''}`}
>
<div className="p-2">{logo}</div> <div className="p-2">{logo}</div>
<div className="description-container"> <div className="description-container">
<div className="title-row"> <div className="title-row">
<h3 className="h4">{title}</h3> <h3 className="h4">{title}</h3>
{enabled && <Badge bsStyle="info">{t('enabled')}</Badge>} {optedIn && <Badge bsStyle="info">{t('enabled')}</Badge>}
</div> </div>
<p className="small"> <p className="small">
{description}{' '} {description}{' '}
@ -76,12 +77,16 @@ export function LabsExperimentWidget({
)} )}
</p> </p>
</div> </div>
{disabled && (
<div className="disabled-explanation">{t('experiment_full')}</div>
)}
<div> <div>
{labsEnabled && ( {labsEnabled && (
<ActionButton <ActionButton
enabled={enabled} optedIn={optedIn}
handleDisable={handleDisable} handleDisable={handleDisable}
handleEnable={handleEnable} handleEnable={handleEnable}
disabled={disabled}
/> />
)} )}
</div> </div>
@ -90,19 +95,21 @@ export function LabsExperimentWidget({
} }
type ActionButtonProps = { type ActionButtonProps = {
enabled?: boolean optedIn?: boolean
disabled?: boolean
handleEnable: () => void handleEnable: () => void
handleDisable: () => void handleDisable: () => void
} }
function ActionButton({ function ActionButton({
enabled, optedIn,
disabled,
handleEnable, handleEnable,
handleDisable, handleDisable,
}: ActionButtonProps) { }: ActionButtonProps) {
const { t } = useTranslation() const { t } = useTranslation()
if (enabled) { if (optedIn) {
return ( return (
<Button <Button
bsStyle="secondary" bsStyle="secondary"
@ -112,6 +119,18 @@ function ActionButton({
{t('turn_off')} {t('turn_off')}
</Button> </Button>
) )
} else if (disabled) {
return (
<Tooltip
id="experiment-disabled"
description={t('this_experiment_isnt_accepting_new_participants')}
overlayProps={{ delay: 0 }}
>
<Button bsStyle="secondary" className="btn btn-primary" disabled>
{t('turn_on')}
</Button>
</Tooltip>
)
} else { } else {
return ( return (
<Button <Button

View file

@ -52,6 +52,7 @@ import { isSplitTestEnabled } from '@/utils/splitTestUtils'
export interface Meta { export interface Meta {
'ol-ExposedSettings': ExposedSettings 'ol-ExposedSettings': ExposedSettings
'ol-allInReconfirmNotificationPeriods': UserEmailData[] 'ol-allInReconfirmNotificationPeriods': UserEmailData[]
'ol-allowedExperiments': string[]
'ol-allowedImageNames': AllowedImageName[] 'ol-allowedImageNames': AllowedImageName[]
'ol-anonymous': boolean 'ol-anonymous': boolean
'ol-bootstrapVersion': 3 | 5 'ol-bootstrapVersion': 3 | 5

View file

@ -81,3 +81,22 @@
margin-top: 8em; margin-top: 8em;
margin-bottom: 12em; margin-bottom: 12em;
} }
.labs-experiment-widget-container.disabled-experiment {
grid-template-columns: 40px 3fr 1fr auto;
.disabled-explanation {
color: @content-secondary;
}
h3,
p {
color: @content-disabled;
}
}
.disabled-experiment {
.ai-error-assistant-avatar {
filter: grayscale(0.6);
}
}

View file

@ -584,6 +584,7 @@
"exclusive_access_with_labs": "Exclusive access to early-stage experiments", "exclusive_access_with_labs": "Exclusive access to early-stage experiments",
"existing_plan_active_until_term_end": "Your existing plan and its features will remain active until the end of the current billing period.", "existing_plan_active_until_term_end": "Your existing plan and its features will remain active until the end of the current billing period.",
"expand": "Expand", "expand": "Expand",
"experiment_full": "Sorry, this experiment is full",
"expired": "Expired", "expired": "Expired",
"expired_confirmation_code": "Your confirmation code has expired. Click <0>Resend confirmation code</0> to get a new one.", "expired_confirmation_code": "Your confirmation code has expired. Click <0>Resend confirmation code</0> to get a new one.",
"expires": "Expires", "expires": "Expires",
@ -2017,6 +2018,7 @@
"this_action_cannot_be_undone": "This action cannot be undone.", "this_action_cannot_be_undone": "This action cannot be undone.",
"this_address_will_be_shown_on_the_invoice": "This address will be shown on the invoice", "this_address_will_be_shown_on_the_invoice": "This address will be shown on the invoice",
"this_could_be_because_we_cant_support_some_elements_of_the_table": "This could be because we cant yet support some elements of the table in the table preview. Or there may be an error in the tables LaTeX code.", "this_could_be_because_we_cant_support_some_elements_of_the_table": "This could be because we cant yet support some elements of the table in the table preview. Or there may be an error in the tables LaTeX code.",
"this_experiment_isnt_accepting_new_participants": "This experiment isnt accepting new participants.",
"this_field_is_required": "This field is required", "this_field_is_required": "This field is required",
"this_grants_access_to_features_2": "This grants you access to <0>__appName__</0> <0>__featureType__</0> features.", "this_grants_access_to_features_2": "This grants you access to <0>__appName__</0> <0>__featureType__</0> features.",
"this_is_a_labs_experiment": "This is a Labs experiment", "this_is_a_labs_experiment": "This is a Labs experiment",