Просмотр исходного кода

Revert "Revert "Rolling TexLive builds experiment"" (#28603)

* Revert "Revert "Rolling TexLive builds experiment""

* adding rolling image to allowed ones in staging (#28588)

GitOrigin-RevId: f377cacd6e4811a4bd9116c793ecbb29a7b72c33
Jimmy Domagala-Tang 1 год назад
Родитель
Сommit
d5f11f54be

+ 2 - 2
services/web/app/src/Features/Project/ProjectController.mjs

@@ -513,7 +513,7 @@ const _ProjectController = {
         req,
         req,
         projectId
         projectId
       )
       )
-      const allowedImageNames = ProjectHelper.getAllowedImagesForUser(user)
+      const imageNames = ProjectHelper.getAllowedImagesForUser(user)
 
 
       const privilegeLevel =
       const privilegeLevel =
         await AuthorizationManager.promises.getPrivilegeLevelForProject(
         await AuthorizationManager.promises.getPrivilegeLevelForProject(
@@ -847,7 +847,7 @@ const _ProjectController = {
         maxReconnectGracefullyIntervalMs:
         maxReconnectGracefullyIntervalMs:
           Settings.maxReconnectGracefullyIntervalMs,
           Settings.maxReconnectGracefullyIntervalMs,
         brandVariation,
         brandVariation,
-        allowedImageNames,
+        imageNames,
         gitBridgePublicBaseUrl: Settings.gitBridgePublicBaseUrl,
         gitBridgePublicBaseUrl: Settings.gitBridgePublicBaseUrl,
         gitBridgeEnabled: Features.hasFeature('git-bridge'),
         gitBridgeEnabled: Features.hasFeature('git-bridge'),
         wsUrl,
         wsUrl,

+ 22 - 6
services/web/app/src/Features/Project/ProjectHelper.js

@@ -148,11 +148,27 @@ function _addNumericSuffixToProjectName(name, allProjectNames, maxLength) {
   return null
   return null
 }
 }
 
 
-function getAllowedImagesForUser(user) {
-  const images = Settings.allowedImageNames || []
-  if (user?.alphaProgram) {
-    return images
-  } else {
-    return images.filter(image => !image.alphaOnly)
+function _imageAllowed(user, image) {
+  if (image.alphaOnly) {
+    return Boolean(user?.alphaProgram)
+  }
+  if (image.monthlyExperimental) {
+    return Boolean(
+      user?.labsProgram && user.labsExperiments.includes('monthly-texlive')
+    )
   }
   }
+  return true
+}
+
+function getAllowedImagesForUser(user) {
+  let images = Settings.allowedImageNames || []
+
+  images = images.map(image => {
+    return {
+      ...image,
+      allowed: _imageAllowed(user, image),
+    }
+  })
+
+  return images
 }
 }

+ 12 - 12
services/web/app/views/project/editor/_meta.pug

@@ -1,6 +1,6 @@
 meta(name="ol-project_id" content=project_id)
 meta(name="ol-project_id" content=project_id)
 meta(name="ol-projectName" content=projectName)
 meta(name="ol-projectName" content=projectName)
-meta(name="ol-projectOwnerHasPremiumOnPageLoad" data-type="boolean" content=projectOwnerHasPremiumOnPageLoad)
+meta(name="ol-projectOwnerHasPremiumOnPageLoad" content=projectOwnerHasPremiumOnPageLoad)
 meta(name="ol-userSettings" data-type="json" content=userSettings)
 meta(name="ol-userSettings" data-type="json" content=userSettings)
 meta(name="ol-user" data-type="json" content=user)
 meta(name="ol-user" data-type="json" content=user)
 meta(name="ol-labsExperiments" data-type="json" content=labsExperiments)
 meta(name="ol-labsExperiments" data-type="json" content=labsExperiments)
@@ -26,16 +26,16 @@ meta(name="ol-showSymbolPalette" data-type="boolean" content=showSymbolPalette)
 meta(name="ol-symbolPaletteAvailable" data-type="boolean" content=symbolPaletteAvailable)
 meta(name="ol-symbolPaletteAvailable" data-type="boolean" content=symbolPaletteAvailable)
 meta(name="ol-showAiErrorAssistant" data-type="boolean" content=showAiErrorAssistant)
 meta(name="ol-showAiErrorAssistant" data-type="boolean" content=showAiErrorAssistant)
 meta(name="ol-detachRole" data-type="string" content=detachRole)
 meta(name="ol-detachRole" data-type="string" content=detachRole)
-meta(name="ol-allowedImageNames" data-type="json" content=allowedImageNames)
+meta(name="ol-imageNames" data-type="json" content=imageNames)
 meta(name="ol-languages" data-type="json" content=languages)
 meta(name="ol-languages" data-type="json" content=languages)
 meta(name="ol-editorThemes" data-type="json" content=editorThemes)
 meta(name="ol-editorThemes" data-type="json" content=editorThemes)
 meta(name="ol-legacyEditorThemes" data-type="json" content=legacyEditorThemes)
 meta(name="ol-legacyEditorThemes" data-type="json" content=legacyEditorThemes)
 meta(name="ol-showUpgradePrompt" data-type="boolean" content=showUpgradePrompt)
 meta(name="ol-showUpgradePrompt" data-type="boolean" content=showUpgradePrompt)
-meta(name="ol-showSupport", data-type="boolean" content=showSupport)
-meta(name="ol-showTemplatesServerPro", data-type="boolean" content=showTemplatesServerPro)
-meta(name="ol-hasTrackChangesFeature", data-type="boolean" content=hasTrackChangesFeature)
-meta(name="ol-otMigrationStage", data-type="number" content=otMigrationStage)
-meta(name="ol-inactiveTutorials", data-type="json" content=user.inactiveTutorials)
+meta(name="ol-showSupport" data-type="boolean" content=showSupport)
+meta(name="ol-showTemplatesServerPro" data-type="boolean" content=showTemplatesServerPro)
+meta(name="ol-hasTrackChangesFeature" data-type="boolean" content=hasTrackChangesFeature)
+meta(name="ol-otMigrationStage" data-type="number" content=otMigrationStage)
+meta(name="ol-inactiveTutorials" data-type="json" content=user.inactiveTutorials)
 meta(name="ol-projectTags" data-type="json" content=projectTags)
 meta(name="ol-projectTags" data-type="json" content=projectTags)
 meta(name="ol-ro-mirror-on-client-no-local-storage" data-type="boolean" content=roMirrorOnClientNoLocalStorage)
 meta(name="ol-ro-mirror-on-client-no-local-storage" data-type="boolean" content=roMirrorOnClientNoLocalStorage)
 meta(name="ol-isSaas" data-type="boolean" content=isSaas)
 meta(name="ol-isSaas" data-type="boolean" content=isSaas)
@@ -44,12 +44,12 @@ meta(name='ol-customerIoEnabled' data-type="boolean" content=customerIoEnabled)
 meta(name='ol-compileSettings' data-type="json" content=compileSettings)
 meta(name='ol-compileSettings' data-type="json" content=compileSettings)
 if(isOverleafAssistBundleEnabled)
 if(isOverleafAssistBundleEnabled)
 	//- expose plans info to show prices in paywall-change-compile-timeout test 
 	//- expose plans info to show prices in paywall-change-compile-timeout test 
-	meta(name="ol-addonPrices", data-type="json" content=addonPrices)
+	meta(name="ol-addonPrices" data-type="json" content=addonPrices)
 // translations for the loading page, before i18n has loaded in the client
 // translations for the loading page, before i18n has loaded in the client
-meta(name="ol-loadingText", data-type="string" content=translate("loading"))
-meta(name="ol-translationIoNotLoaded", data-type="string" content=translate("could_not_connect_to_websocket_server"))
-meta(name="ol-translationLoadErrorMessage", data-type="string" content=translate("could_not_load_translations"))
-meta(name="ol-translationUnableToJoin", data-type="string" content=translate("could_not_connect_to_collaboration_server"))
+meta(name="ol-loadingText" data-type="string" content=translate("loading"))
+meta(name="ol-translationIoNotLoaded" data-type="string" content=translate("could_not_connect_to_websocket_server"))
+meta(name="ol-translationLoadErrorMessage" data-type="string" content=translate("could_not_load_translations"))
+meta(name="ol-translationUnableToJoin" data-type="string" content=translate("could_not_connect_to_collaboration_server"))
 
 
 if (settings.overleaf != null)
 if (settings.overleaf != null)
 	meta(name="ol-overallThemes" data-type="json" content=overallThemes)
 	meta(name="ol-overallThemes" data-type="json" content=overallThemes)

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

@@ -1484,6 +1484,7 @@
   "revoke_invite": "",
   "revoke_invite": "",
   "right": "",
   "right": "",
   "role": "",
   "role": "",
+  "rolling_texlive_build": "",
   "saml_auth_error": "",
   "saml_auth_error": "",
   "saml_identity_exists_error": "",
   "saml_identity_exists_error": "",
   "saml_invalid_signature_error": "",
   "saml_invalid_signature_error": "",
@@ -1778,6 +1779,7 @@
   "test": "",
   "test": "",
   "test_configuration": "",
   "test_configuration": "",
   "test_configuration_successful": "",
   "test_configuration_successful": "",
+  "test_more_recent_versions_of_texlive": "",
   "tex_live_version": "",
   "tex_live_version": "",
   "texgpt": "",
   "texgpt": "",
   "thank_you": "",
   "thank_you": "",

+ 9 - 9
services/web/frontend/js/features/editor-left-menu/components/settings/settings-image-name.tsx

@@ -11,18 +11,18 @@ export default function SettingsImageName() {
   const { imageName, setImageName } = useProjectSettingsContext()
   const { imageName, setImageName } = useProjectSettingsContext()
   const { write } = usePermissionsContext()
   const { write } = usePermissionsContext()
 
 
-  const allowedImageNames = useMemo(
-    () => getMeta('ol-allowedImageNames') || [],
-    []
-  )
+  const allowedImageNames = useMemo(() => getMeta('ol-imageNames') || [], [])
 
 
   const options: Array<Option> = useMemo(
   const options: Array<Option> = useMemo(
     () =>
     () =>
-      allowedImageNames.map(({ imageName, imageDesc }) => ({
-        value: imageName,
-        label: imageDesc,
-      })),
-    [allowedImageNames]
+      allowedImageNames
+        // filter out images that aren't allowed, unless thats the current image the project is on
+        .filter(image => image.allowed || image.imageName === imageName)
+        .map(({ imageName, imageDesc }) => ({
+          value: imageName,
+          label: imageDesc,
+        })),
+    [allowedImageNames, imageName]
   )
   )
 
 
   if (allowedImageNames.length === 0) {
   if (allowedImageNames.length === 0) {

+ 10 - 10
services/web/frontend/js/features/ide-redesign/components/settings/compiler-settings/image-name-setting.tsx

@@ -11,21 +11,21 @@ export default function ImageNameSetting() {
   const { t } = useTranslation()
   const { t } = useTranslation()
   const { write } = usePermissionsContext()
   const { write } = usePermissionsContext()
 
 
-  const allowedImageNames = useMemo(
-    () => getMeta('ol-allowedImageNames') || [],
-    []
-  )
+  const imageNames = useMemo(() => getMeta('ol-imageNames') || [], [])
 
 
   const options: Array<Option> = useMemo(
   const options: Array<Option> = useMemo(
     () =>
     () =>
-      allowedImageNames.map(({ imageName, imageDesc }) => ({
-        value: imageName,
-        label: imageDesc,
-      })),
-    [allowedImageNames]
+      imageNames
+        // filter out images that aren't allowed, unless thats the current image the project is on
+        .filter(image => image.allowed || image.imageName === imageName)
+        .map(({ imageName, imageDesc }) => ({
+          value: imageName,
+          label: imageDesc,
+        })),
+    [imageNames, imageName]
   )
   )
 
 
-  if (allowedImageNames.length === 0) {
+  if (imageNames.length === 0) {
     return null
     return null
   }
   }
 
 

+ 44 - 0
services/web/frontend/js/features/monthly-texlive/labs-widget.tsx

@@ -0,0 +1,44 @@
+import { useState } from 'react'
+import LabsExperimentWidget from '../../shared/components/labs/labs-experiments-widget'
+import { isInExperiment } from '@/utils/labs-utils'
+import { useTranslation } from 'react-i18next'
+import MaterialIcon from '@/shared/components/material-icon'
+import { isSplitTestEnabled } from '@/utils/splitTestUtils'
+
+const MonthlyTexliveLabsWidget = ({
+  labsProgram,
+  setErrorMessage,
+}: {
+  labsProgram: boolean
+  setErrorMessage: (err: string) => void
+}) => {
+  const { t } = useTranslation()
+  const [optedIn, setOptedIn] = useState(isInExperiment('monthly-texlive'))
+
+  const monthlyTexLiveSplitTestEnabled = isSplitTestEnabled('monthly-texlive')
+  if (!monthlyTexLiveSplitTestEnabled) {
+    return null
+  }
+
+  const logo = (
+    <MaterialIcon
+      type="construction"
+      size="2x"
+      className="rounded bg-primary-subtle"
+    />
+  )
+  return (
+    <LabsExperimentWidget
+      description={t('test_more_recent_versions_of_texlive')}
+      experimentName="monthly-texlive"
+      logo={logo}
+      labsEnabled={labsProgram}
+      setErrorMessage={setErrorMessage}
+      optedIn={optedIn}
+      setOptedIn={setOptedIn}
+      title={t('rolling_texlive_build')}
+    />
+  )
+}
+
+export default MonthlyTexliveLabsWidget

+ 1 - 1
services/web/frontend/js/utils/labs-utils.ts

@@ -2,7 +2,7 @@ import getMeta from './meta'
 
 
 // Should be `never` when no experiments are active. Otherwise it should be a
 // Should be `never` when no experiments are active. Otherwise it should be a
 // union of active experiment names e.g. `'experiment1' | 'experiment2'`
 // union of active experiment names e.g. `'experiment1' | 'experiment2'`
-export type ActiveExperiment = never
+export type ActiveExperiment = 'monthly-texlive'
 
 
 export const isInExperiment = (experiment: ActiveExperiment): boolean => {
 export const isInExperiment = (experiment: ActiveExperiment): boolean => {
   const experiments = getMeta('ol-labsExperiments')
   const experiments = getMeta('ol-labsExperiments')

+ 2 - 2
services/web/frontend/js/utils/meta.ts

@@ -5,7 +5,7 @@ import { UserSettings } from '../../../types/user-settings'
 import { OAuthProviders } from '../../../types/oauth-providers'
 import { OAuthProviders } from '../../../types/oauth-providers'
 import { ExposedSettings } from '../../../types/exposed-settings'
 import { ExposedSettings } from '../../../types/exposed-settings'
 import {
 import {
-  type AllowedImageName,
+  type ImageName,
   OverallThemeMeta,
   OverallThemeMeta,
   type SpellCheckLanguage,
   type SpellCheckLanguage,
 } from '../../../types/project-settings'
 } from '../../../types/project-settings'
@@ -80,7 +80,6 @@ export interface Meta {
   'ol-algolia': AlgoliaConfig | undefined
   'ol-algolia': AlgoliaConfig | undefined
   'ol-allInReconfirmNotificationPeriods': UserEmailData[]
   'ol-allInReconfirmNotificationPeriods': UserEmailData[]
   'ol-allowedExperiments': string[]
   'ol-allowedExperiments': string[]
-  'ol-allowedImageNames': AllowedImageName[]
   'ol-anonymous': boolean
   'ol-anonymous': boolean
   'ol-baseAssetPath': string
   'ol-baseAssetPath': string
   'ol-brandVariation': Record<string, any>
   'ol-brandVariation': Record<string, any>
@@ -163,6 +162,7 @@ export interface Meta {
     owned?: boolean
     owned?: boolean
   }[]
   }[]
   'ol-i18n': { currentLangCode: string }
   'ol-i18n': { currentLangCode: string }
+  'ol-imageNames': ImageName[]
   'ol-inactiveTutorials': string[]
   'ol-inactiveTutorials': string[]
   'ol-institutionEmailNonCanonical': string | undefined
   'ol-institutionEmailNonCanonical': string | undefined
   'ol-institutionLinked': InstitutionLink | undefined
   'ol-institutionLinked': InstitutionLink | undefined

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

@@ -1935,6 +1935,7 @@
   "right": "Right",
   "right": "Right",
   "ro": "Romanian",
   "ro": "Romanian",
   "role": "Role",
   "role": "Role",
+  "rolling_texlive_build": "Rolling TexLive Build",
   "ru": "Russian",
   "ru": "Russian",
   "saml": "SAML",
   "saml": "SAML",
   "saml_auth_error": "Sorry, your identity provider responded with an error.  Please contact your administrator for more information.",
   "saml_auth_error": "Sorry, your identity provider responded with an error.  Please contact your administrator for more information.",
@@ -2289,6 +2290,7 @@
   "test": "Test",
   "test": "Test",
   "test_configuration": "Test configuration",
   "test_configuration": "Test configuration",
   "test_configuration_successful": "Test configuration successful",
   "test_configuration_successful": "Test configuration successful",
+  "test_more_recent_versions_of_texlive": "Test more recent versions of TexLive before they get turned into our annual release. This experiment adds the compiler option for a monthly build of the current TexLive version. This experimental version is primarily for testing changes to TexLive, packages, and previewing new accessibility features before they’re compiled into our annual TexLive release. Note that these images change month to month, and are untested by our team. Important projects should continue to be built on our yearly release to avoid issues that may occur when the monthly build changes.",
   "tex_live_version": "TeX Live version",
   "tex_live_version": "TeX Live version",
   "texgpt": "TeXGPT",
   "texgpt": "TeXGPT",
   "text": "Text",
   "text": "Text",

+ 5 - 3
services/web/test/frontend/components/editor-left-menu/editor-left-menu.spec.tsx

@@ -1,6 +1,6 @@
 import EditorLeftMenu from '../../../../frontend/js/features/editor-left-menu/components/editor-left-menu'
 import EditorLeftMenu from '../../../../frontend/js/features/editor-left-menu/components/editor-left-menu'
 import {
 import {
-  AllowedImageName,
+  ImageName,
   OverallThemeMeta,
   OverallThemeMeta,
   SpellCheckLanguage,
   SpellCheckLanguage,
 } from '../../../../types/project-settings'
 } from '../../../../types/project-settings'
@@ -35,20 +35,22 @@ describe('<EditorLeftMenu />', function () {
       },
       },
     ]
     ]
 
 
-    const allowedImageNames: AllowedImageName[] = [
+    const ImageNames: ImageName[] = [
       {
       {
         imageDesc: 'Image 1',
         imageDesc: 'Image 1',
         imageName: 'img-1',
         imageName: 'img-1',
+        allowed: true,
       },
       },
       {
       {
         imageDesc: 'Image 2',
         imageDesc: 'Image 2',
         imageName: 'img-2',
         imageName: 'img-2',
+        allowed: true,
       },
       },
     ]
     ]
 
 
     beforeEach(function () {
     beforeEach(function () {
       window.metaAttributesCache.set('ol-overallThemes', overallThemes)
       window.metaAttributesCache.set('ol-overallThemes', overallThemes)
-      window.metaAttributesCache.set('ol-allowedImageNames', allowedImageNames)
+      window.metaAttributesCache.set('ol-imageNames', ImageNames)
       window.metaAttributesCache.set('ol-anonymous', false)
       window.metaAttributesCache.set('ol-anonymous', false)
       window.metaAttributesCache.set('ol-gitBridgeEnabled', true)
       window.metaAttributesCache.set('ol-gitBridgeEnabled', true)
       window.metaAttributesCache.set('ol-showSupport', true)
       window.metaAttributesCache.set('ol-showSupport', true)

+ 6 - 4
services/web/test/frontend/features/editor-left-menu/components/settings/settings-image-name.test.tsx

@@ -2,24 +2,26 @@ import { screen, within, render } from '@testing-library/react'
 import { expect } from 'chai'
 import { expect } from 'chai'
 import fetchMock from 'fetch-mock'
 import fetchMock from 'fetch-mock'
 import SettingsImageName from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-image-name'
 import SettingsImageName from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-image-name'
-import type { AllowedImageName } from '../../../../../../types/project-settings'
+import type { ImageName } from '../../../../../../types/project-settings'
 import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
 import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
 import { EditorProviders } from '../../../../helpers/editor-providers'
 import { EditorProviders } from '../../../../helpers/editor-providers'
 
 
 describe('<SettingsImageName />', function () {
 describe('<SettingsImageName />', function () {
-  const allowedImageNames: AllowedImageName[] = [
+  const imageNames: ImageName[] = [
     {
     {
       imageDesc: 'Image 1',
       imageDesc: 'Image 1',
       imageName: 'img-1',
       imageName: 'img-1',
+      allowed: true,
     },
     },
     {
     {
       imageDesc: 'Image 2',
       imageDesc: 'Image 2',
       imageName: 'img-2',
       imageName: 'img-2',
+      allowed: true,
     },
     },
   ]
   ]
 
 
   beforeEach(function () {
   beforeEach(function () {
-    window.metaAttributesCache.set('ol-allowedImageNames', allowedImageNames)
+    window.metaAttributesCache.set('ol-imageNames', imageNames)
   })
   })
 
 
   afterEach(function () {
   afterEach(function () {
@@ -37,7 +39,7 @@ describe('<SettingsImageName />', function () {
 
 
     const select = screen.getByLabelText('TeX Live version')
     const select = screen.getByLabelText('TeX Live version')
 
 
-    for (const { imageName, imageDesc } of allowedImageNames) {
+    for (const { imageName, imageDesc } of imageNames) {
       const option = within(select).getByText(imageDesc)
       const option = within(select).getByText(imageDesc)
       expect(option.getAttribute('value')).to.equal(imageName)
       expect(option.getAttribute('value')).to.equal(imageName)
     }
     }

+ 52 - 12
services/web/test/unit/src/Project/ProjectHelperTests.js

@@ -4,6 +4,12 @@ const { ObjectId } = require('mongodb-legacy')
 
 
 const MODULE_PATH = '../../../../app/src/Features/Project/ProjectHelper.js'
 const MODULE_PATH = '../../../../app/src/Features/Project/ProjectHelper.js'
 
 
+function _mapToAllowed(images) {
+  return images.map(image => {
+    return { imageName: image.imageName, allowed: image.allowed }
+  })
+}
+
 describe('ProjectHelper', function () {
 describe('ProjectHelper', function () {
   beforeEach(function () {
   beforeEach(function () {
     this.project = {
     this.project = {
@@ -14,6 +20,8 @@ describe('ProjectHelper', function () {
       _id: '588f3ddae8ebc1bac07c9fa4',
       _id: '588f3ddae8ebc1bac07c9fa4',
       first_name: 'bjkdsjfk',
       first_name: 'bjkdsjfk',
       features: {},
       features: {},
+      labsProgram: true,
+      labsExperiments: ['monthly-texlive'],
     }
     }
 
 
     this.adminUser = {
     this.adminUser = {
@@ -32,6 +40,11 @@ describe('ProjectHelper', function () {
           imageDesc: 'TeX Live 2020',
           imageDesc: 'TeX Live 2020',
           alphaOnly: true,
           alphaOnly: true,
         },
         },
+        {
+          imageName: 'texlive-full:2021.1',
+          imageDesc: 'TeX Live 2021',
+          monthlyExperimental: true,
+        },
       ],
       ],
     }
     }
 
 
@@ -128,31 +141,58 @@ describe('ProjectHelper', function () {
   })
   })
 
 
   describe('getAllowedImagesForUser', function () {
   describe('getAllowedImagesForUser', function () {
-    it('filters out alpha-only images when the user is anonymous', function () {
+    it('marks alpha only images as not allowed when the user is anonymous', function () {
+      const images = this.ProjectHelper.getAllowedImagesForUser(null)
+      const imageNames = _mapToAllowed(images)
+      expect(imageNames).to.deep.equal([
+        { imageName: 'texlive-full:2018.1', allowed: true },
+        { imageName: 'texlive-full:2019.1', allowed: true },
+        { imageName: 'texlive-full:2020.1', allowed: false },
+        { imageName: 'texlive-full:2021.1', allowed: false },
+      ])
+    })
+
+    it('marks monthly labs images as not allowed when the user is anonymous', function () {
       const images = this.ProjectHelper.getAllowedImagesForUser(null)
       const images = this.ProjectHelper.getAllowedImagesForUser(null)
-      const imageNames = images.map(image => image.imageName)
+      const imageNames = _mapToAllowed(images)
+      expect(imageNames).to.deep.equal([
+        { imageName: 'texlive-full:2018.1', allowed: true },
+        { imageName: 'texlive-full:2019.1', allowed: true },
+        { imageName: 'texlive-full:2020.1', allowed: false },
+        { imageName: 'texlive-full:2021.1', allowed: false },
+      ])
+    })
+
+    it('marks monthly labs images as allowed when the user is enrolled', function () {
+      const images = this.ProjectHelper.getAllowedImagesForUser(this.user)
+      const imageNames = _mapToAllowed(images)
       expect(imageNames).to.deep.equal([
       expect(imageNames).to.deep.equal([
-        'texlive-full:2018.1',
-        'texlive-full:2019.1',
+        { imageName: 'texlive-full:2018.1', allowed: true },
+        { imageName: 'texlive-full:2019.1', allowed: true },
+        { imageName: 'texlive-full:2020.1', allowed: false },
+        { imageName: 'texlive-full:2021.1', allowed: true },
       ])
       ])
     })
     })
 
 
-    it('filters out alpha-only images when the user is not admin', function () {
+    it('marks alpha only images as not allowed when when the user is not admin', function () {
       const images = this.ProjectHelper.getAllowedImagesForUser(this.user)
       const images = this.ProjectHelper.getAllowedImagesForUser(this.user)
-      const imageNames = images.map(image => image.imageName)
+      const imageNames = _mapToAllowed(images)
       expect(imageNames).to.deep.equal([
       expect(imageNames).to.deep.equal([
-        'texlive-full:2018.1',
-        'texlive-full:2019.1',
+        { imageName: 'texlive-full:2018.1', allowed: true },
+        { imageName: 'texlive-full:2019.1', allowed: true },
+        { imageName: 'texlive-full:2020.1', allowed: false },
+        { imageName: 'texlive-full:2021.1', allowed: true },
       ])
       ])
     })
     })
 
 
     it('returns all images when the user is admin', function () {
     it('returns all images when the user is admin', function () {
       const images = this.ProjectHelper.getAllowedImagesForUser(this.adminUser)
       const images = this.ProjectHelper.getAllowedImagesForUser(this.adminUser)
-      const imageNames = images.map(image => image.imageName)
+      const imageNames = _mapToAllowed(images)
       expect(imageNames).to.deep.equal([
       expect(imageNames).to.deep.equal([
-        'texlive-full:2018.1',
-        'texlive-full:2019.1',
-        'texlive-full:2020.1',
+        { imageName: 'texlive-full:2018.1', allowed: true },
+        { imageName: 'texlive-full:2019.1', allowed: true },
+        { imageName: 'texlive-full:2020.1', allowed: true },
+        { imageName: 'texlive-full:2021.1', allowed: false },
       ])
       ])
     })
     })
   })
   })

+ 2 - 1
services/web/types/project-settings.ts

@@ -1,9 +1,10 @@
 import { Brand } from './helpers/brand'
 import { Brand } from './helpers/brand'
 import { OverallTheme } from '@/shared/utils/styles'
 import { OverallTheme } from '@/shared/utils/styles'
 
 
-export type AllowedImageName = {
+export type ImageName = {
   imageDesc: string
   imageDesc: string
   imageName: string
   imageName: string
+  allowed: boolean
 }
 }
 
 
 export type DocId = Brand<string, 'DocId'>
 export type DocId = Brand<string, 'DocId'>