feat(client): independent lower jaw ab test (#61085)

Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
This commit is contained in:
Ahmad Abdolsaheb 2025-06-27 22:52:36 +03:00 committed by GitHub
parent ecd5582ed9
commit b7b02ee159
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 344 additions and 51 deletions

View File

@ -9,6 +9,7 @@
"edit": "Edit",
"copy": "Copy",
"view": "View",
"submit-continue": "Submit and continue",
"view-code": "View Code",
"view-project": "View Project",
"view-cert-title": "View {{certTitle}}",
@ -60,6 +61,8 @@
"check-code": "Check Your Code",
"check-code-ctrl": "Check Your Code (Ctrl + Enter)",
"check-code-cmd": "Check Your Code (Command + Enter)",
"command-enter": "⌘ + Enter",
"ctrl-enter": "Ctrl + Enter",
"reset": "Reset",
"reset-step": "Reset This Step",
"help": "Help",
@ -427,6 +430,7 @@
"ms-link": "Microsoft Link",
"submit-and-go": "Submit and go to my next challenge",
"congratulations": "Congratulations, your code passes. Submit your code to continue.",
"congratulations-code-passes": "✔ Congratulations. Your code passes.",
"i-completed": "I've completed this challenge",
"example-code": "Example Code",
"test-output": "Your test output will go here",

View File

@ -7,7 +7,7 @@ import EditorTabs from './editor-tabs';
interface ActionRowProps {
hasNotes: boolean;
hasPreview: boolean;
isProjectBasedChallenge: boolean;
areInstructionsDisplayable: boolean;
showConsole: boolean;
showNotes: boolean;
showInstructions: boolean;
@ -25,7 +25,7 @@ const ActionRow = ({
showPreviewPortal,
showConsole,
showInstructions,
isProjectBasedChallenge
areInstructionsDisplayable
}: ActionRowProps): JSX.Element => {
const { t } = useTranslation();
@ -54,7 +54,7 @@ const ActionRow = ({
return (
<div className='action-row' data-playwright-test-label='action-row'>
<div className='tabs-row' data-playwright-test-label='tabs-row'>
{!isProjectBasedChallenge && (
{areInstructionsDisplayable && (
<button
data-playwright-test-label='instructions-button'
aria-expanded={!!showInstructions}

View File

@ -53,6 +53,7 @@ interface DesktopLayoutProps {
setShowPreviewPortal: (arg: boolean) => void;
setShowPreviewPane: (arg: boolean) => void;
portalWindow: null | Window;
showIndependentLowerJaw: boolean;
}
const reflexProps = {
@ -92,7 +93,8 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => {
setShowPreviewPane,
setShowPreviewPortal,
portalWindow,
startWithConsoleShown
startWithConsoleShown,
showIndependentLowerJaw
} = props;
const initialShowState = (key: string, defaultValue: boolean): boolean => {
@ -227,6 +229,8 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => {
}, []);
const projectBasedChallenge = hasEditableBoundaries;
const areInstructionsDisplayable =
!projectBasedChallenge || showIndependentLowerJaw;
const isMultifileProject =
challengeType === challengeTypes.multifileCertProject ||
challengeType === challengeTypes.multifilePythonCertProject ||
@ -260,7 +264,7 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => {
<ActionRow
hasPreview={hasPreview}
hasNotes={!!notes}
isProjectBasedChallenge={projectBasedChallenge}
areInstructionsDisplayable={areInstructionsDisplayable}
showConsole={showConsole}
showNotes={showNotes}
showInstructions={showInstructions}
@ -274,7 +278,7 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => {
orientation='vertical'
data-playwright-test-label='main-container'
>
{!projectBasedChallenge && showInstructions && (
{areInstructionsDisplayable && showInstructions && (
<ReflexElement
flex={instructionPane.flex}
{...resizeProps}
@ -284,7 +288,7 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => {
{instructions}
</ReflexElement>
)}
{!projectBasedChallenge && showInstructions && (
{areInstructionsDisplayable && showInstructions && (
<ReflexSplitter propagate={true} {...resizeProps} />
)}

View File

@ -113,6 +113,7 @@ export interface EditorProps {
}) => void;
usesMultifileEditor: boolean;
isChallengeCompleted: boolean;
showIndependentLowerJaw?: boolean;
}
// TODO: this is grab bag of unrelated properties. There's no need for them to
@ -413,7 +414,10 @@ const Editor = (props: EditorProps): JSX.Element => {
dataRef.current.editor = editor;
if (hasEditableRegion()) {
initializeDescriptionAndOutputWidgets();
initializeRegions();
if (!props.showIndependentLowerJaw) {
addWidgetsToRegions();
}
addContentChangeListener();
resetAttempts();
showEditableRegion(editor);
@ -1021,14 +1025,6 @@ const Editor = (props: EditorProps): JSX.Element => {
}
};
function initializeDescriptionAndOutputWidgets() {
const editor = dataRef.current.editor;
if (editor) {
initializeRegions(getEditableRegionFromRedux());
addWidgetsToRegions(editor);
}
}
// Currently, only practice project parts have editable region markers
// This function is used to enable multiple editor tabs, jaws, etc.
function hasEditableRegion() {
@ -1050,11 +1046,11 @@ const Editor = (props: EditorProps): JSX.Element => {
}
}
function initializeRegions(editableRegion: number[]) {
function initializeRegions() {
const { model, editor } = dataRef.current;
const monaco = monacoRef.current;
if (!model || !monaco || !editor) return;
const editableRegion = getEditableRegionFromRedux();
const editableRange = positionsToRange(monaco, model, [
editableRegion[0] + 1,
editableRegion[1] - 1
@ -1100,7 +1096,10 @@ const Editor = (props: EditorProps): JSX.Element => {
};
};
function addWidgetsToRegions(editor: editor.IStandaloneCodeEditor) {
function addWidgetsToRegions() {
const editor = dataRef.current.editor;
if (!editor) return;
const descriptionNode = createDescription(editor);
const lowerJawNode = createLowerJawContainer(editor);
@ -1238,14 +1237,17 @@ const Editor = (props: EditorProps): JSX.Element => {
if (hasEditableRegion() && editor) {
if (props.isResetting) {
initializeDescriptionAndOutputWidgets();
initializeRegions();
if (!props.showIndependentLowerJaw) {
addWidgetsToRegions();
}
updateDescriptionZone();
showEditableRegion(editor);
resetMarginDecorations();
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.challengeFiles, props.isResetting]);
}, [props.challengeFiles, props.isResetting, props.showIndependentLowerJaw]);
useEffect(() => {
const { showProjectPreview, previewOpen } = props;

View File

@ -36,6 +36,7 @@ type MultifileEditorProps = Pick<
| 'description'
// We use dimensions to trigger a re-render of the editor
| 'dimensions'
| 'showIndependentLowerJaw'
> & {
visibleEditors: VisibleEditors;
};
@ -72,7 +73,8 @@ const MultifileEditor = (props: MultifileEditorProps) => {
mainpy
},
usesMultifileEditor,
showProjectPreview
showProjectPreview,
showIndependentLowerJaw
} = props;
// TODO: the tabs mess up the rendering (scroll doesn't work properly and
// the in-editor description)
@ -146,6 +148,7 @@ const MultifileEditor = (props: MultifileEditorProps) => {
title={title}
usesMultifileEditor={usesMultifileEditor}
showProjectPreview={showProjectPreview}
showIndependentLowerJaw={showIndependentLowerJaw}
/>
</ReflexElement>
);

View File

@ -306,6 +306,12 @@ function ShowClassic({
// AB testing Pre-fetch in the Spanish locale
const isPreFetchEnabled = useFeature('prefetch_ab_test').on;
const isIndependentLowerJawEnabled = useFeature('independent-lower-jaw').on;
// Independent lower jaw is only enabled for the urriculum outline workshop
const showIndependentLowerJaw =
blockName === 'workshop-curriculum-outline' && isIndependentLowerJawEnabled;
useEffect(() => {
if (isPreFetchEnabled && envData.clientLocale === 'espanol') {
preloadPage(nextChallengePath);
@ -416,6 +422,7 @@ function ShowClassic({
instructionsPanelRef={instructionsPanelRef}
toolPanel={toolPanel}
hasDemo={hasDemo}
showIndependentLowerJaw={showIndependentLowerJaw}
/>
);
};
@ -440,6 +447,7 @@ function ShowClassic({
title={title}
usesMultifileEditor={usesMultifileEditor}
showProjectPreview={demoType === 'onLoad'}
showIndependentLowerJaw={showIndependentLowerJaw}
/>
)
);
@ -521,6 +529,7 @@ function ShowClassic({
}
windowTitle={windowTitle}
startWithConsoleShown={openConsole}
showIndependentLowerJaw={showIndependentLowerJaw}
/>
)}
<CompletionModal />

View File

@ -0,0 +1,106 @@
.independent-lower-jaw {
position: absolute;
left: 0;
right: 0;
bottom: 0;
width: 100%;
z-index: 10;
}
.independent-lower-jaw .hint-container {
background-color: var(--background-quaternary);
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 12px;
margin: 12px;
}
.independent-lower-jaw .btn-cta {
padding: 4px 10px;
}
.independent-lower-jaw .buttons-row-container {
display: flex;
justify-content: space-between;
flex-direction: row;
background-color: var(--background-secondary);
border: var(--background-quaternary) 1px solid;
padding: 12px;
}
.action-row-right {
display: flex;
flex-direction: row;
align-items: center;
}
.independent-lower-jaw .hint-container button {
height: 30px;
font-size: 1.5rem;
min-width: 30px;
display: flex;
justify-content: center;
align-items: center;
border: none;
}
.independent-lower-jaw .action-row-right button {
height: 40px;
width: 40px;
display: flex;
justify-content: center;
align-items: center;
border: none;
margin-left: 10px;
}
.independent-lower-jaw .tooltip {
position: relative;
display: inline-block;
}
/* Tooltip text */
.independent-lower-jaw .tooltip .tooltiptext {
visibility: hidden;
opacity: 0;
background-color: var(--background-quaternary);
color: var(--foreground-primary);
border: 1px solid var(--foreground-secondary);
padding: 5px 10px;
font-size: 1rem;
text-align: center;
position: absolute;
top: -60px;
z-index: 1;
}
/* Show the tooltip text when you mouse over the tooltip container */
.independent-lower-jaw .tooltip:hover .tooltiptext {
visibility: visible;
opacity: 1;
transition: opacity 0.5s ease 0.5s;
}
.tooltiptext::after {
content: '';
position: absolute;
display: block;
width: 0.5rem;
height: 0.5rem;
border-style: solid;
border-width: 0px 1px 1px 0px;
border-color: var(--color-border-primary);
transform: rotate(45deg);
bottom: -5px;
left: calc(50% - 0.25rem);
background-image: linear-gradient(
to top left,
var(--background-quaternary) 55%,
rgba(0, 0, 0, 0) 20%
);
}
.independent-lower-jaw .tooltip .tooltiptext.left-tooltip {
left: 0;
}

View File

@ -0,0 +1,155 @@
import React from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { useTranslation } from 'react-i18next';
import { Button } from '@freecodecamp/ui';
import { Test } from '../../../redux/prop-types';
import { challengeTestsSelector } from '../redux/selectors';
import { isSignedInSelector } from '../../../redux/selectors';
import { apiLocation } from '../../../../config/env.json';
import { openModal, submitChallenge, executeChallenge } from '../redux/actions';
import Help from '../../../assets/icons/help';
import callGA from '../../../analytics/call-ga';
import './independent-lower-jaw.css';
import Reset from '../../../assets/icons/reset';
const mapStateToProps = createSelector(
challengeTestsSelector,
isSignedInSelector,
(tests: Test[], isSignedIn: boolean) => ({
tests,
isSignedIn
})
);
const mapDispatchToProps = {
openHelpModal: () => openModal('help'),
openResetModal: () => openModal('reset'),
executeChallenge,
submitChallenge
};
interface IndependentLowerJawProps {
openHelpModal: () => void;
openResetModal: () => void;
executeChallenge: () => void;
submitChallenge: () => void;
tests: Test[];
isSignedIn: boolean;
}
export function IndependentLowerJaw({
openHelpModal,
openResetModal,
executeChallenge,
submitChallenge,
tests,
isSignedIn
}: IndependentLowerJawProps): JSX.Element {
const { t } = useTranslation();
const firstFailedTest = tests.find(test => !!test.err);
const hint = firstFailedTest?.message;
const [showHint, setShowHint] = React.useState(false);
const [showSubmissionHint, setShowSubmissionHint] = React.useState(true);
const isChallengeComplete = tests.every(test => test.pass);
React.useEffect(() => {
setShowHint(!!hint);
}, [hint]);
const isMacOS = navigator.userAgent.includes('Mac OS');
const checkButtonText = isMacOS ? t('command-enter') : t('ctrl-enter');
return (
<div className='independent-lower-jaw' tabIndex={-1}>
{showHint && hint && (
<div className='hint-container'>
<div dangerouslySetInnerHTML={{ __html: hint }} />
<button className={'tooltip'} onClick={() => setShowHint(false)}>
×<span className='tooltiptext'> {t('buttons.close')}</span>
</button>
</div>
)}
{isChallengeComplete && showSubmissionHint && (
<div className='hint-container'>
<div>
<p>{t('learn.congratulations-code-passes')}</p>
{!isSignedIn && (
<a
href={`${apiLocation}/signin`}
className='btn-cta btn btn-block'
onClick={() => {
callGA({
event: 'sign_in'
});
}}
>
{t('learn.sign-in-save')}
</a>
)}
</div>
<button
className={'tooltip'}
onClick={() => setShowSubmissionHint(false)}
>
×<span className='tooltiptext'> {t('buttons.close')}</span>
</button>
</div>
)}
<div className='buttons-row-container'>
<div className='action-row-left'>
{isChallengeComplete ? (
<Button
block
className={`${isSignedIn && 'btn-cta'} tooltip`}
onClick={() => submitChallenge()}
>
{t('buttons.submit-continue')}
<span className='tooltiptext left-tooltip '>
{checkButtonText}
</span>
</Button>
) : (
<button
type='button'
className='btn-cta tooltip'
onClick={() => executeChallenge()}
>
{t('buttons.check-code')}
<span className='tooltiptext left-tooltip '>
{checkButtonText}
</span>
</button>
)}
</div>
<div className='action-row-right'>
<button
type='button'
className='icon-botton tooltip'
onClick={openResetModal}
>
<Reset />
<span className='tooltiptext'> {t('buttons.reset')}</span>
</button>
<button
type='button'
className='icon-botton tooltip'
onClick={openHelpModal}
>
<Help />
<span className='tooltiptext'> {t('buttons.help')}</span>
</button>
</div>
</div>
</div>
);
}
IndependentLowerJaw.displayName = 'IndependentLowerJaw';
export default connect(
mapStateToProps,
mapDispatchToProps
)(IndependentLowerJaw);

View File

@ -8,6 +8,7 @@ import { Test } from '../../../redux/prop-types';
import { challengeTestsSelector } from '../redux/selectors';
import { openModal } from '../redux/actions';
import TestSuite from './test-suite';
import IndependentLowerJaw from './independent-lower-jaw';
import './side-panel.css';
@ -34,6 +35,7 @@ interface SidePanelProps extends DispatchProps, StateProps {
hasDemo: boolean;
toolPanel: ReactNode;
tests: Test[];
showIndependentLowerJaw: boolean;
}
export function SidePanel({
@ -43,37 +45,45 @@ export function SidePanel({
hasDemo,
toolPanel,
tests,
openModal
openModal,
showIndependentLowerJaw
}: SidePanelProps): JSX.Element {
return (
<div
className='instructions-panel'
ref={instructionsPanelRef}
tabIndex={-1}
>
{challengeTitle}
{hasDemo && (
<p>
<Trans i18nKey='learn.example-app'>
<span
className='example-app-link'
onClick={() => openModal('projectPreview')}
role='button'
tabIndex={0}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
openModal('projectPreview');
}
}}
></span>
</Trans>
</p>
)}
{challengeDescription}
<Spacer size='m' />
{toolPanel}
<TestSuite tests={tests} />
</div>
<>
<div
className='instructions-panel'
ref={instructionsPanelRef}
tabIndex={-1}
>
{challengeTitle}
{hasDemo && (
<p>
<Trans i18nKey='learn.example-app'>
<span
className='example-app-link'
onClick={() => openModal('projectPreview')}
role='button'
tabIndex={0}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
openModal('projectPreview');
}
}}
></span>
</Trans>
</p>
)}{' '}
{challengeDescription}
{!showIndependentLowerJaw && (
<>
<Spacer size='m' />
{toolPanel}
<TestSuite tests={tests} />
</>
)}
</div>
{showIndependentLowerJaw && <IndependentLowerJaw />}
</>
);
}