فهرست منبع

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

Enforce a maximum participant cap on experiments

GitOrigin-RevId: 1d9263cd34a3d0c831c0ed43867bb4e6430eb06c
Jimmy Domagala-Tang 2 سال پیش
والد
کامیت
837fea03b9

+ 1 - 1
services/web/app/src/Features/GlobalMetrics/GlobalMetricsManager.js

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

+ 2 - 0
services/web/frontend/extracted-translations.json

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

+ 39 - 20
services/web/frontend/js/shared/components/labs/labs-experiments-widget.tsx

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

+ 1 - 0
services/web/frontend/js/utils/meta.ts

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

+ 19 - 0
services/web/frontend/stylesheets/modules/labs.less

@@ -81,3 +81,22 @@
   margin-top: 8em;
   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);
+  }
+}

+ 2 - 0
services/web/locales/en.json

@@ -584,6 +584,7 @@
   "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.",
   "expand": "Expand",
+  "experiment_full": "Sorry, this experiment is full",
   "expired": "Expired",
   "expired_confirmation_code": "Your confirmation code has expired. Click <0>Resend confirmation code</0> to get a new one.",
   "expires": "Expires",
@@ -2017,6 +2018,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 can’t yet support some elements of the table in the table preview. Or there may be an error in the table’s LaTeX code.",
+  "this_experiment_isnt_accepting_new_participants": "This experiment isn’t accepting new participants.",
   "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_is_a_labs_experiment": "This is a Labs experiment",