Merge pull request #32968 from overleaf/worktree-labs-feature-preview
Add labs preview modal to editor GitOrigin-RevId: 0df33135febc8e94129bcdfdfb5c4981326dfab0
This commit is contained in:
@@ -902,6 +902,10 @@ const _ProjectController = {
|
||||
userSettings?.overallTheme
|
||||
)
|
||||
|
||||
if (user.labsProgram) {
|
||||
await Modules.promises.hooks.fire('assignLabsSplitTests', req, res)
|
||||
}
|
||||
|
||||
res.render(template, {
|
||||
title: project.name,
|
||||
priority_title: true,
|
||||
@@ -943,7 +947,6 @@ const _ProjectController = {
|
||||
},
|
||||
initialLoadingScreenTheme,
|
||||
userSettings,
|
||||
labsExperiments: user.labsExperiments ?? [],
|
||||
privilegeLevel,
|
||||
anonymous,
|
||||
isTokenMember,
|
||||
|
||||
@@ -828,6 +828,22 @@ async function _loadSplitTestInfoInLocals(locals, splitTestName, session) {
|
||||
phase,
|
||||
badgeInfo: splitTest.badgeInfo?.[phase],
|
||||
}
|
||||
|
||||
if (phase === 'labs') {
|
||||
const variant = currentVersion.variants?.[0]
|
||||
info.labsDetails = {
|
||||
title: splitTest.labsTitle || '',
|
||||
description: splitTest.labsDescription || '',
|
||||
icon: splitTest.labsIcon || '',
|
||||
surveyLink: splitTest.badgeInfo?.labs?.url || '',
|
||||
isFull: SplitTestUtils.isExperimentFull(variant),
|
||||
versionCreatedAt:
|
||||
currentVersion.createdAt instanceof Date
|
||||
? currentVersion.createdAt.toISOString()
|
||||
: currentVersion.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
if (Settings.devToolbar.enabled) {
|
||||
info.active = currentVersion.active
|
||||
info.variants = currentVersion.variants.map(variant => ({
|
||||
|
||||
@@ -329,6 +329,17 @@ async function switchToNextPhase(
|
||||
)
|
||||
}
|
||||
|
||||
// Labs phase requires labs config to be set
|
||||
if (lastVersionCopy.phase === LABS_PHASE) {
|
||||
if (!splitTest.labsTitle || !splitTest.labsDescription) {
|
||||
// Cannot be a required fields as we do not always promote to labs
|
||||
throw new Errors.InvalidError(
|
||||
'Labs phase requires Labs Title and Labs Description to be set',
|
||||
{ name }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Labs phase requires exactly one variant
|
||||
if (lastVersionCopy.phase === LABS_PHASE) {
|
||||
if (lastVersionCopy.variants.length !== 1) {
|
||||
|
||||
@@ -14,7 +14,17 @@ function getVersion(splitTest, versionNumber) {
|
||||
})
|
||||
}
|
||||
|
||||
function isExperimentFull(variant) {
|
||||
const { userLimit, userCount } = variant
|
||||
if (typeof userLimit === 'number') {
|
||||
const currentCount = userCount ?? 0
|
||||
return currentCount >= userLimit
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export default {
|
||||
getCurrentVersion,
|
||||
getVersion,
|
||||
isExperimentFull,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ meta(name="ol-projectName" content=projectName)
|
||||
meta(name="ol-canUseClsiCache" data-type="boolean" content=canUseClsiCache)
|
||||
meta(name="ol-userSettings" data-type="json" content=userSettings)
|
||||
meta(name="ol-user" data-type="json" content=user)
|
||||
meta(name="ol-labsExperiments" data-type="json" content=labsExperiments)
|
||||
meta(name="ol-learnedWords" data-type="json" content=learnedWords)
|
||||
meta(name="ol-anonymous" data-type="boolean" content=anonymous)
|
||||
meta(name="ol-brandVariation" data-type="json" content=brandVariation)
|
||||
|
||||
@@ -1083,6 +1083,8 @@ module.exports = {
|
||||
referenceIndices: [],
|
||||
railEntries: [],
|
||||
railPopovers: [],
|
||||
railActions: [],
|
||||
railModals: [],
|
||||
},
|
||||
|
||||
moduleImportSequence: [
|
||||
|
||||
@@ -641,6 +641,7 @@
|
||||
"example_project": "",
|
||||
"existing_plan_active_until_term_end": "",
|
||||
"expand": "",
|
||||
"experiment_enabled_refresh_page": "",
|
||||
"experiment_full_check_back_soon": "",
|
||||
"expired": "",
|
||||
"expired_confirmation_code": "",
|
||||
@@ -1036,6 +1037,8 @@
|
||||
"knowledge_base": "",
|
||||
"labels_help_you_to_easily_reference_your_figures": "",
|
||||
"labels_help_you_to_reference_your_tables": "",
|
||||
"labs_ongoing_experiments": "",
|
||||
"labs_settings": "",
|
||||
"language": "",
|
||||
"last_active": "",
|
||||
"last_active_description": "",
|
||||
@@ -1230,6 +1233,7 @@
|
||||
"need_to_add_new_primary_before_remove": "",
|
||||
"need_to_leave": "",
|
||||
"neither_agree_nor_disagree": "",
|
||||
"new": "",
|
||||
"new_compile_domain_notice": "",
|
||||
"new_compiles_in_this_project_will_automatically_use_the_newest_version": "",
|
||||
"new_file": "",
|
||||
@@ -1988,6 +1992,7 @@
|
||||
"tex_live_version": "",
|
||||
"thank_you": "",
|
||||
"thank_you_exclamation": "",
|
||||
"thank_you_for_participating_feedback": "",
|
||||
"thank_you_for_your_feedback": "",
|
||||
"thanks_for_confirming_your_email_address": "",
|
||||
"thanks_for_getting_in_touch": "",
|
||||
|
||||
+20
-8
@@ -14,6 +14,7 @@ type RailActionButton = {
|
||||
icon: AvailableUnfilledIcon
|
||||
title: string
|
||||
action: () => void
|
||||
indicator?: ReactElement
|
||||
hide?: boolean
|
||||
ref?: React.Ref<HTMLButtonElement>
|
||||
}
|
||||
@@ -23,6 +24,7 @@ type RailDropdown = {
|
||||
icon: AvailableUnfilledIcon
|
||||
title: string
|
||||
dropdown: ReactElement
|
||||
indicator?: ReactElement
|
||||
hide?: boolean
|
||||
ref?: React.Ref<HTMLButtonElement>
|
||||
}
|
||||
@@ -57,10 +59,9 @@ const RailActionElement = forwardRef<HTMLButtonElement, { action: RailAction }>(
|
||||
as="button"
|
||||
aria-label={action.title}
|
||||
>
|
||||
<MaterialIcon
|
||||
className="ide-rail-tab-link-icon"
|
||||
<RailActionIcon
|
||||
type={action.icon}
|
||||
unfilled
|
||||
indicator={action.indicator}
|
||||
/>
|
||||
</DropdownToggle>
|
||||
</span>
|
||||
@@ -81,11 +82,7 @@ const RailActionElement = forwardRef<HTMLButtonElement, { action: RailAction }>(
|
||||
className="ide-rail-tab-link ide-rail-tab-button"
|
||||
aria-label={action.title}
|
||||
>
|
||||
<MaterialIcon
|
||||
className="ide-rail-tab-link-icon"
|
||||
type={action.icon}
|
||||
unfilled
|
||||
/>
|
||||
<RailActionIcon type={action.icon} indicator={action.indicator} />
|
||||
</button>
|
||||
</OLTooltip>
|
||||
)
|
||||
@@ -93,6 +90,21 @@ const RailActionElement = forwardRef<HTMLButtonElement, { action: RailAction }>(
|
||||
}
|
||||
)
|
||||
|
||||
function RailActionIcon({
|
||||
type,
|
||||
indicator,
|
||||
}: {
|
||||
type: AvailableUnfilledIcon
|
||||
indicator?: ReactElement
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<MaterialIcon className="ide-rail-tab-link-icon" type={type} unfilled />
|
||||
{indicator}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
RailActionElement.displayName = 'RailActionElement'
|
||||
|
||||
export default RailActionElement
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import OLBadge from '@/shared/components/ol/ol-badge'
|
||||
|
||||
type RailIndicatorProps = {
|
||||
type: 'danger' | 'warning' | 'info'
|
||||
type: 'danger' | 'warning' | 'info' | 'success'
|
||||
count: number
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,21 @@ import {
|
||||
import { RailHelpContactUsModal } from './contact-us'
|
||||
import { RailHelpShowHotkeysModal } from './keyboard-shortcuts'
|
||||
import DictionarySettingsModal from '@/features/settings/components/editor-settings/dictionary-settings-modal'
|
||||
import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
|
||||
|
||||
const RAIL_MODALS: {
|
||||
type RailModalEntry = {
|
||||
key: RailModalKey
|
||||
modalComponentFunction: FC<{ show: boolean }>
|
||||
}[] = [
|
||||
}
|
||||
|
||||
const moduleRailModals = (
|
||||
importOverleafModules('railModals') as {
|
||||
import: { default: RailModalEntry }
|
||||
path: string
|
||||
}[]
|
||||
).map(({ import: { default: entry } }) => entry)
|
||||
|
||||
const RAIL_MODALS: RailModalEntry[] = [
|
||||
{
|
||||
key: 'keyboard-shortcuts',
|
||||
modalComponentFunction: RailHelpShowHotkeysModal,
|
||||
@@ -23,6 +33,7 @@ const RAIL_MODALS: {
|
||||
key: 'dictionary',
|
||||
modalComponentFunction: DictionarySettingsModal,
|
||||
},
|
||||
...moduleRailModals,
|
||||
]
|
||||
|
||||
export default function RailModals() {
|
||||
|
||||
@@ -56,6 +56,13 @@ const moduleRailPopovers = (
|
||||
}[]
|
||||
).map(({ import: { default: element } }) => element)
|
||||
|
||||
const moduleRailActions = (
|
||||
importOverleafModules('railActions') as {
|
||||
import: { default: RailAction }
|
||||
path: string
|
||||
}[]
|
||||
).map(({ import: { default: action } }) => action)
|
||||
|
||||
export const RailLayout = () => {
|
||||
const { sendEvent } = useEditorAnalytics()
|
||||
const { t } = useTranslation()
|
||||
@@ -152,6 +159,7 @@ export const RailLayout = () => {
|
||||
title: t('help'),
|
||||
dropdown: <RailHelpDropdown />,
|
||||
},
|
||||
...moduleRailActions,
|
||||
{
|
||||
key: 'settings',
|
||||
icon: 'settings',
|
||||
|
||||
@@ -25,7 +25,15 @@ export type RailTabKey =
|
||||
| 'full-project-search'
|
||||
| 'workbench'
|
||||
|
||||
export type RailModalKey = 'keyboard-shortcuts' | 'contact-us' | 'dictionary'
|
||||
export type RailModalKey =
|
||||
| 'keyboard-shortcuts'
|
||||
| 'contact-us'
|
||||
| 'dictionary'
|
||||
| 'labs'
|
||||
|
||||
export function dispatchOpenRailModal(key: RailModalKey) {
|
||||
window.dispatchEvent(new CustomEvent('ui:open-rail-modal', { detail: key }))
|
||||
}
|
||||
|
||||
const RailContext = createContext<
|
||||
| {
|
||||
@@ -119,6 +127,16 @@ export const RailProvider: FC<React.PropsWithChildren> = ({ children }) => {
|
||||
}, [handlePaneCollapse, selectedTab, isOpen, openTab])
|
||||
)
|
||||
|
||||
useEventListener(
|
||||
'ui:open-rail-modal',
|
||||
useCallback(
|
||||
(event: Event) => {
|
||||
setActiveModal((event as CustomEvent<RailModalKey>).detail)
|
||||
},
|
||||
[setActiveModal]
|
||||
)
|
||||
)
|
||||
|
||||
useEventListener(
|
||||
'keydown',
|
||||
useCallback(
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
|
||||
type LabsEnableButtonProps = {
|
||||
optedIn: boolean
|
||||
disabled: boolean
|
||||
loading?: boolean
|
||||
ghost?: boolean
|
||||
handleEnable: () => void
|
||||
handleDisable: () => void
|
||||
}
|
||||
|
||||
export function LabsEnableButton({
|
||||
optedIn,
|
||||
disabled,
|
||||
loading,
|
||||
ghost,
|
||||
handleEnable,
|
||||
handleDisable,
|
||||
}: LabsEnableButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const isDisabled = loading || disabled
|
||||
|
||||
if (optedIn) {
|
||||
return (
|
||||
<OLButton
|
||||
variant={ghost ? 'danger-ghost' : 'secondary'}
|
||||
onClick={handleDisable}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{t('disable')}
|
||||
</OLButton>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<OLButton
|
||||
variant={ghost ? 'secondary' : 'primary'}
|
||||
onClick={handleEnable}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{t('enable')}
|
||||
</OLButton>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { postJSON } from '@/infrastructure/fetch-json'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import getMeta from '@/utils/meta'
|
||||
import Notification from '../notification'
|
||||
import { LabsEnableButton } from '@/shared/components/labs/labs-enable-button'
|
||||
|
||||
export type LabsExperimentWidgetProps = {
|
||||
logo: ReactNode
|
||||
@@ -96,11 +97,11 @@ export function LabsExperimentWidget({
|
||||
</div>
|
||||
<div>
|
||||
{labsEnabled && (
|
||||
<ActionButton
|
||||
<LabsEnableButton
|
||||
optedIn={optedIn}
|
||||
handleDisable={handleDisable}
|
||||
handleEnable={handleEnable}
|
||||
disabled={disabled}
|
||||
handleEnable={handleEnable}
|
||||
handleDisable={handleDisable}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -119,40 +120,4 @@ export function LabsExperimentWidget({
|
||||
)
|
||||
}
|
||||
|
||||
type ActionButtonProps = {
|
||||
optedIn?: boolean
|
||||
disabled?: boolean
|
||||
handleEnable: () => void
|
||||
handleDisable: () => void
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
optedIn,
|
||||
disabled,
|
||||
handleEnable,
|
||||
handleDisable,
|
||||
}: ActionButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (optedIn) {
|
||||
return (
|
||||
<OLButton variant="secondary" onClick={handleDisable}>
|
||||
{t('disable')}
|
||||
</OLButton>
|
||||
)
|
||||
} else if (disabled) {
|
||||
return (
|
||||
<OLButton variant="primary" disabled>
|
||||
{t('enable')}
|
||||
</OLButton>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<OLButton variant="primary" onClick={handleEnable}>
|
||||
{t('enable')}
|
||||
</OLButton>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default LabsExperimentWidget
|
||||
|
||||
@@ -206,6 +206,7 @@ export interface Meta {
|
||||
surveyLink: string
|
||||
isFull: boolean
|
||||
optedIn: boolean
|
||||
versionCreatedAt: string | null
|
||||
}>
|
||||
'ol-languages': SpellCheckLanguage[]
|
||||
'ol-learnedWords': string[]
|
||||
|
||||
@@ -9,10 +9,6 @@
|
||||
}
|
||||
|
||||
#experiments-container {
|
||||
.title-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.labs-experiment-widget-container {
|
||||
border: 1px solid var(--border-divider);
|
||||
}
|
||||
@@ -60,13 +56,12 @@
|
||||
}
|
||||
|
||||
.title-row {
|
||||
margin: 0;
|
||||
margin-bottom: var(--spacing-04);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 0 var(--spacing-04) 0;
|
||||
|
||||
> h3 {
|
||||
margin: 0;
|
||||
margin-right: var(--spacing-04);
|
||||
margin: 0 var(--spacing-04) 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,14 +91,229 @@
|
||||
}
|
||||
}
|
||||
|
||||
.labs-workbench-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: var(--spacing-01);
|
||||
// Feature Preview Modal
|
||||
.labs-feature-preview-modal {
|
||||
.modal-header {
|
||||
padding-bottom: var(--spacing-04);
|
||||
border-bottom: 1px solid var(--border-divider);
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
.modal-body {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.labs-feature-preview-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-09);
|
||||
color: var(--content-secondary);
|
||||
}
|
||||
|
||||
// Experiment Browser (two-panel layout)
|
||||
.lab-experiment-modal-content {
|
||||
container-type: inline-size;
|
||||
container-name: lab-experiment-modal-content;
|
||||
}
|
||||
|
||||
.lab-experiment-modal-content-inner {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.labs-experiment-tab-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Fixed height in the feature preview modal, where the modal body constrains the layout
|
||||
.labs-feature-preview-modal .lab-experiment-modal-content-inner {
|
||||
height: 500px;
|
||||
}
|
||||
|
||||
.labs-experiment-sidebar {
|
||||
width: 320px;
|
||||
border-right: 1px solid var(--border-divider);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.labs-experiment-sidebar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-04);
|
||||
padding: var(--spacing-04) var(--spacing-05);
|
||||
text-align: left;
|
||||
color: var(--content-primary);
|
||||
font-size: var(--font-size-02);
|
||||
min-height: 44px;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-light-secondary);
|
||||
color: var(--content-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--bg-accent-03);
|
||||
color: var(--content-primary);
|
||||
|
||||
.labs-experiment-sidebar-title {
|
||||
color: var(--green-70);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.labs-experiment-sidebar-icon {
|
||||
font-size: 20px;
|
||||
color: var(--content-primary);
|
||||
}
|
||||
|
||||
.labs-experiment-sidebar-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.labs-experiment-sidebar-badge {
|
||||
// Reserve a fixed width so layout is stable whether badge is shown or not
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.labs-settings-link {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: var(--spacing-04);
|
||||
padding: var(--spacing-04) var(--spacing-05);
|
||||
text-align: left;
|
||||
color: var(--content-primary);
|
||||
font-size: var(--font-size-02);
|
||||
min-height: 44px;
|
||||
width: 100%;
|
||||
text-decoration: none;
|
||||
|
||||
&:visited {
|
||||
color: var(--content-primary);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-light-secondary);
|
||||
color: var(--content-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.labs-settings-link-external-icon {
|
||||
font-size: 20px;
|
||||
color: var(--content-secondary);
|
||||
}
|
||||
|
||||
.labs-badge {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.labs-experiment-sidebar-arrow {
|
||||
display: none;
|
||||
font-size: 18px;
|
||||
color: var(--content-secondary);
|
||||
}
|
||||
|
||||
// !important needed to override Bootstrap's d-inline-grid utility on OLIconButton
|
||||
.labs-experiment-detail-back {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.labs-experiment-detail {
|
||||
padding: var(--spacing-06);
|
||||
overflow-y: auto;
|
||||
|
||||
> .notification {
|
||||
margin-bottom: var(--spacing-06);
|
||||
|
||||
.notification-cta {
|
||||
align-self: center;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.labs-experiment-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing-06);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-05);
|
||||
}
|
||||
}
|
||||
|
||||
.labs-experiment-detail-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-04);
|
||||
margin-bottom: var(--spacing-06);
|
||||
}
|
||||
|
||||
.labs-experiment-detail-description {
|
||||
margin-bottom: var(--spacing-06);
|
||||
color: var(--content-secondary);
|
||||
font-size: var(--font-size-02);
|
||||
line-height: var(--line-height-03);
|
||||
}
|
||||
|
||||
// Narrow layout: drill-down navigation instead of side-by-side panels
|
||||
// Uses container queries so this triggers based on the browser component's own width,
|
||||
// not the viewport — works correctly whether embedded in the labs modal or the settings modal.
|
||||
@container lab-experiment-modal-content (width < 600px) {
|
||||
.lab-experiment-modal-content-inner {
|
||||
flex-direction: column;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.labs-experiment-sidebar {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.labs-experiment-sidebar-item {
|
||||
padding: var(--spacing-05) var(--spacing-06);
|
||||
}
|
||||
|
||||
.labs-experiment-sidebar-arrow {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.labs-experiment-sidebar-badge {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.labs-experiment-tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.labs-experiment-detail-back {
|
||||
display: inline-flex !important;
|
||||
margin-bottom: var(--spacing-05);
|
||||
}
|
||||
|
||||
// When an experiment is selected, show detail and hide sidebar
|
||||
.lab-experiment-modal-content.mobile-detail-active {
|
||||
.labs-experiment-sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.labs-experiment-tab-content {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -844,6 +844,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_enabled_refresh_page": "Experiment is enabled! You need to refresh the page to see the changes.",
|
||||
"experiment_full_check_back_soon": "Sorry, this experiment is full. Spaces may become available, so check back soon.",
|
||||
"expired": "Expired",
|
||||
"expired_confirmation_code": "Your confirmation code has expired. Click <0>Resend confirmation code</0> to get a new one.",
|
||||
@@ -1351,7 +1352,9 @@
|
||||
"labels_help_you_to_easily_reference_your_figures": "Labels help you to easily reference your figures throughout your document. To reference a figure within the text, reference the label using the <0>\\ref{...}</0> command. This makes it easy to reference figures without needing to manually remember the figure numbering. <1>Learn more</1>",
|
||||
"labels_help_you_to_reference_your_tables": "Labels help you to reference your tables throughout your document easily. To reference a table within the text, reference the label using the <0>\\ref{...}</0> command. This makes it easy to reference tables without manually remembering the table numbering. <1>Read about labels and cross-references</1>.",
|
||||
"labs": "Labs",
|
||||
"labs_ongoing_experiments": "Overleaf Labs - ongoing experiments",
|
||||
"labs_program_benefits": "By signing up for Overleaf Labs you can get your hands on in-development features and try them out as much as you like. All we ask in return is your honest feedback to help us develop and improve. It’s important to note that features available in this program are still being tested and actively developed. This means they could change, be removed, or become part of a premium plan.",
|
||||
"labs_settings": "Labs settings",
|
||||
"language": "Language",
|
||||
"language_suggestions": "Language suggestions",
|
||||
"larger_discounts_available": "Larger discounts available",
|
||||
@@ -1619,6 +1622,7 @@
|
||||
"need_to_add_new_primary_before_remove": "You’ll need to add a new primary email address before you can remove this one.",
|
||||
"need_to_leave": "Need to leave?",
|
||||
"neither_agree_nor_disagree": "Neither agree nor disagree",
|
||||
"new": "New",
|
||||
"new_compile_domain_notice": "Something might be blocking your browser from accessing Overleaf’s PDF download location, <0>__compilesUserContentDomain__</0>. This could be caused by network blocking or a strict browser plugin rule. Please follow our <1>troubleshooting guide</1>.",
|
||||
"new_compiles_in_this_project_will_automatically_use_the_newest_version": "New compiles in this project will automatically use the newest version. <0>Learn how to change compiler settings</0>",
|
||||
"new_file": "New file",
|
||||
@@ -2593,6 +2597,7 @@
|
||||
"thank_you_email_confirmed": "Thank you, your email is now confirmed",
|
||||
"thank_you_exclamation": "Thank you!",
|
||||
"thank_you_for_being_part_of_our_beta_program": "Thank you for being part of our beta program, where you can have <0>early access to new features</0> and help us understand your needs better",
|
||||
"thank_you_for_participating_feedback": "Thank you for participating in this experiment. We’d appreciate your feedback.",
|
||||
"thank_you_for_your_feedback": "Thank you for your feedback!",
|
||||
"thanks": "Thanks",
|
||||
"thanks_for_confirming_your_email_address": "Thanks for confirming your email address",
|
||||
|
||||
@@ -664,6 +664,255 @@ describe('SplitTestHandler', function () {
|
||||
})
|
||||
})
|
||||
|
||||
describe('_loadSplitTestInfoInLocals labsDetails population', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.AnalyticsManager.getIdsFromSession.returns({
|
||||
userId: 'abc123abc123',
|
||||
})
|
||||
})
|
||||
|
||||
it('populates labsDetails for a labs-phase split test', async function (ctx) {
|
||||
const createdAt = new Date('2024-06-15T12:00:00.000Z')
|
||||
ctx.cachedSplitTests.set('labs-info-test', {
|
||||
name: 'labs-info-test',
|
||||
labsTitle: 'My Labs Feature',
|
||||
labsDescription: 'A great feature',
|
||||
labsIcon: 'star',
|
||||
badgeInfo: { labs: { url: 'https://example.com/survey' } },
|
||||
versions: [
|
||||
{
|
||||
active: true,
|
||||
analyticsEnabled: true,
|
||||
phase: 'labs',
|
||||
versionNumber: 1,
|
||||
createdAt,
|
||||
variants: [
|
||||
{
|
||||
name: 'variant-1',
|
||||
rolloutPercent: 100,
|
||||
rolloutStripes: [{ start: 0, end: 100 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await ctx.SplitTestHandler.promises.getAssignment(
|
||||
ctx.req,
|
||||
ctx.res,
|
||||
'labs-info-test'
|
||||
)
|
||||
|
||||
expect(ctx.LocalsHelper.setSplitTestInfo).to.have.been.calledWith(
|
||||
ctx.res.locals,
|
||||
'labs-info-test',
|
||||
sinon.match({
|
||||
phase: 'labs',
|
||||
labsDetails: sinon.match({
|
||||
isFull: false,
|
||||
versionCreatedAt: createdAt.toISOString(),
|
||||
title: 'My Labs Feature',
|
||||
description: 'A great feature',
|
||||
icon: 'star',
|
||||
surveyLink: 'https://example.com/survey',
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('handles missing labsTitle defensively', async function (ctx) {
|
||||
const createdAt = new Date('2024-01-01T00:00:00.000Z')
|
||||
ctx.cachedSplitTests.set('my-labs-experiment', {
|
||||
name: 'my-labs-experiment',
|
||||
versions: [
|
||||
{
|
||||
active: true,
|
||||
analyticsEnabled: true,
|
||||
phase: 'labs',
|
||||
versionNumber: 1,
|
||||
createdAt,
|
||||
variants: [
|
||||
{
|
||||
name: 'variant-1',
|
||||
rolloutPercent: 100,
|
||||
rolloutStripes: [{ start: 0, end: 100 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await ctx.SplitTestHandler.promises.getAssignment(
|
||||
ctx.req,
|
||||
ctx.res,
|
||||
'my-labs-experiment'
|
||||
)
|
||||
|
||||
expect(ctx.LocalsHelper.setSplitTestInfo).to.have.been.calledWith(
|
||||
ctx.res.locals,
|
||||
'my-labs-experiment',
|
||||
sinon.match({
|
||||
labsDetails: sinon.match({ title: '' }),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('sets description to empty string when labsDescription is absent', async function (ctx) {
|
||||
ctx.cachedSplitTests.set('labs-nodesc', {
|
||||
name: 'labs-nodesc',
|
||||
labsTitle: 'No Description',
|
||||
versions: [
|
||||
{
|
||||
active: true,
|
||||
analyticsEnabled: true,
|
||||
phase: 'labs',
|
||||
versionNumber: 1,
|
||||
createdAt: new Date(),
|
||||
variants: [
|
||||
{
|
||||
name: 'variant-1',
|
||||
rolloutPercent: 100,
|
||||
rolloutStripes: [{ start: 0, end: 100 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await ctx.SplitTestHandler.promises.getAssignment(
|
||||
ctx.req,
|
||||
ctx.res,
|
||||
'labs-nodesc'
|
||||
)
|
||||
|
||||
expect(ctx.LocalsHelper.setSplitTestInfo).to.have.been.calledWith(
|
||||
ctx.res.locals,
|
||||
'labs-nodesc',
|
||||
sinon.match({ labsDetails: sinon.match({ description: '' }) })
|
||||
)
|
||||
})
|
||||
|
||||
it('marks isFull true when userCount >= userLimit', async function (ctx) {
|
||||
ctx.cachedSplitTests.set('labs-full', {
|
||||
name: 'labs-full',
|
||||
versions: [
|
||||
{
|
||||
active: true,
|
||||
analyticsEnabled: true,
|
||||
phase: 'labs',
|
||||
versionNumber: 1,
|
||||
createdAt: new Date(),
|
||||
variants: [
|
||||
{
|
||||
name: 'variant-1',
|
||||
rolloutPercent: 100,
|
||||
rolloutStripes: [{ start: 0, end: 100 }],
|
||||
userLimit: 10,
|
||||
userCount: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await ctx.SplitTestHandler.promises.getAssignment(
|
||||
ctx.req,
|
||||
ctx.res,
|
||||
'labs-full'
|
||||
)
|
||||
|
||||
expect(ctx.LocalsHelper.setSplitTestInfo).to.have.been.calledWith(
|
||||
ctx.res.locals,
|
||||
'labs-full',
|
||||
sinon.match({ labsDetails: sinon.match({ isFull: true }) })
|
||||
)
|
||||
})
|
||||
|
||||
it('marks isFull false when userCount < userLimit', async function (ctx) {
|
||||
ctx.cachedSplitTests.set('labs-not-full', {
|
||||
name: 'labs-not-full',
|
||||
versions: [
|
||||
{
|
||||
active: true,
|
||||
analyticsEnabled: true,
|
||||
phase: 'labs',
|
||||
versionNumber: 1,
|
||||
createdAt: new Date(),
|
||||
variants: [
|
||||
{
|
||||
name: 'variant-1',
|
||||
rolloutPercent: 100,
|
||||
rolloutStripes: [{ start: 0, end: 100 }],
|
||||
userLimit: 10,
|
||||
userCount: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await ctx.SplitTestHandler.promises.getAssignment(
|
||||
ctx.req,
|
||||
ctx.res,
|
||||
'labs-not-full'
|
||||
)
|
||||
|
||||
expect(ctx.LocalsHelper.setSplitTestInfo).to.have.been.calledWith(
|
||||
ctx.res.locals,
|
||||
'labs-not-full',
|
||||
sinon.match({ labsDetails: sinon.match({ isFull: false }) })
|
||||
)
|
||||
})
|
||||
|
||||
it('marks isFull false when no userLimit is set', async function (ctx) {
|
||||
ctx.cachedSplitTests.set('labs-no-limit', {
|
||||
name: 'labs-no-limit',
|
||||
versions: [
|
||||
{
|
||||
active: true,
|
||||
analyticsEnabled: true,
|
||||
phase: 'labs',
|
||||
versionNumber: 1,
|
||||
createdAt: new Date(),
|
||||
variants: [
|
||||
{
|
||||
name: 'variant-1',
|
||||
rolloutPercent: 100,
|
||||
rolloutStripes: [{ start: 0, end: 100 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await ctx.SplitTestHandler.promises.getAssignment(
|
||||
ctx.req,
|
||||
ctx.res,
|
||||
'labs-no-limit'
|
||||
)
|
||||
|
||||
expect(ctx.LocalsHelper.setSplitTestInfo).to.have.been.calledWith(
|
||||
ctx.res.locals,
|
||||
'labs-no-limit',
|
||||
sinon.match({ labsDetails: sinon.match({ isFull: false }) })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not set labsDetails for a release-phase split test', async function (ctx) {
|
||||
await ctx.SplitTestHandler.promises.getAssignment(
|
||||
ctx.req,
|
||||
ctx.res,
|
||||
'active-test'
|
||||
)
|
||||
|
||||
expect(ctx.LocalsHelper.setSplitTestInfo).to.have.been.calledWith(
|
||||
ctx.res.locals,
|
||||
'active-test',
|
||||
sinon.match(info => info.labsDetails === undefined)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('labs phase assignment (gradual rollout)', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.AnalyticsManager.getIdsFromSession.returns({
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { expect } from 'vitest'
|
||||
import SplitTestUtils from '../../../../app/src/Features/SplitTests/SplitTestUtils.mjs'
|
||||
|
||||
describe('SplitTestUtils', function () {
|
||||
describe('isExperimentFull', function () {
|
||||
describe('when userLimit is null or undefined', function () {
|
||||
it('should return false when userLimit is null', function () {
|
||||
const variant = {
|
||||
userLimit: null,
|
||||
userCount: 5,
|
||||
}
|
||||
expect(SplitTestUtils.isExperimentFull(variant)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when userLimit is undefined', function () {
|
||||
const variant = {
|
||||
userCount: 5,
|
||||
}
|
||||
expect(SplitTestUtils.isExperimentFull(variant)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when userLimit is not a number', function () {
|
||||
it.each([
|
||||
{ label: 'NaN', userLimit: NaN },
|
||||
{ label: 'an object', userLimit: {} },
|
||||
{ label: 'a string', userLimit: 'string' },
|
||||
])(
|
||||
'should return false when userLimit is $label',
|
||||
function ({ userLimit }) {
|
||||
const variant = {
|
||||
userLimit,
|
||||
userCount: 50,
|
||||
}
|
||||
expect(SplitTestUtils.isExperimentFull(variant)).toBe(false)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should return true when userLimit is 0', function () {
|
||||
const variant = {
|
||||
userLimit: 0,
|
||||
userCount: 5,
|
||||
}
|
||||
expect(SplitTestUtils.isExperimentFull(variant)).toBe(true)
|
||||
})
|
||||
|
||||
it('should treat undefined userCount as 0 and return false when under limit', function () {
|
||||
const variant = {
|
||||
userLimit: 100,
|
||||
}
|
||||
expect(SplitTestUtils.isExperimentFull(variant)).toBe(false)
|
||||
})
|
||||
|
||||
it('should treat null userCount as 0 and return false when under limit', function () {
|
||||
const variant = {
|
||||
userLimit: 100,
|
||||
userCount: null,
|
||||
}
|
||||
expect(SplitTestUtils.isExperimentFull(variant)).toBe(false)
|
||||
})
|
||||
|
||||
it('should treat undefined userCount as 0 and return true when limit is 0', function () {
|
||||
const variant = {
|
||||
userLimit: 0,
|
||||
}
|
||||
expect(SplitTestUtils.isExperimentFull(variant)).toBe(true)
|
||||
})
|
||||
|
||||
describe('when userCount is below the limit', function () {
|
||||
it('should return false when userCount is less than userLimit', function () {
|
||||
const variant = {
|
||||
userLimit: 100,
|
||||
userCount: 50,
|
||||
}
|
||||
expect(SplitTestUtils.isExperimentFull(variant)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when userCount is 1 and userLimit is 100', function () {
|
||||
const variant = {
|
||||
userLimit: 100,
|
||||
userCount: 1,
|
||||
}
|
||||
expect(SplitTestUtils.isExperimentFull(variant)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when userCount is 0 and userLimit is 100', function () {
|
||||
const variant = {
|
||||
userLimit: 100,
|
||||
userCount: 0,
|
||||
}
|
||||
expect(SplitTestUtils.isExperimentFull(variant)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should return true when userCount equals userLimit', function () {
|
||||
const variant = {
|
||||
userLimit: 100,
|
||||
userCount: 100,
|
||||
}
|
||||
expect(SplitTestUtils.isExperimentFull(variant)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when userCount is greater than userLimit', function () {
|
||||
const variant = {
|
||||
userLimit: 100,
|
||||
userCount: 150,
|
||||
}
|
||||
expect(SplitTestUtils.isExperimentFull(variant)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -12,4 +12,14 @@ export type SplitTestInfo = {
|
||||
url?: string
|
||||
tooltipText?: string
|
||||
}
|
||||
labsDetails?: LabsDetails
|
||||
}
|
||||
|
||||
export type LabsDetails = {
|
||||
isFull: boolean
|
||||
versionCreatedAt: string
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
surveyLink: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user