diff --git a/Jenkinsfile b/Jenkinsfile index 0dc35d669c8bf2..663064f06fe2fe 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -12,7 +12,7 @@ if (!branchfilter.contains(env.BRANCH_NAME)) { return } -// Define branch specific vars +// Define dev-specific vars if (env.BRANCH_NAME == 'dev') { DEPLOY_ENV = 'DEV' LOGICAL_ENV = 'dev' @@ -23,6 +23,7 @@ if (env.BRANCH_NAME == 'dev') { ENABLE_CACHE = false } +// Define prod-specific vars if (env.BRANCH_NAME == 'prod') { DEPLOY_ENV = 'PROD' LOGICAL_ENV = 'prod' diff --git a/client/i18n/locales/english/intro.json b/client/i18n/locales/english/intro.json index 0dbf1faf6a1a0b..99abfa7cf79713 100644 --- a/client/i18n/locales/english/intro.json +++ b/client/i18n/locales/english/intro.json @@ -67,7 +67,7 @@ } }, "2022/responsive-web-design": { - "title": "(New) Responsive Web Design", + "title": "Responsive Web Design", "intro": [ "In this Responsive Web Design Certification, you'll learn the languages that developers use to build webpages: HTML (Hypertext Markup Language) for content, and CSS (Cascading Style Sheets) for design.", "First, you'll build a cat photo app to learn the basics of HTML and CSS. Later, you'll learn modern techniques like CSS variables by building a penguin, and best practices for accessibility by building a quiz site.", diff --git a/client/package.json b/client/package.json index 3c18ca5c6525d2..27dffaed536a63 100644 --- a/client/package.json +++ b/client/package.json @@ -96,6 +96,7 @@ "react": "16.14.0", "react-dom": "16.14.0", "react-final-form": "6.5.9", + "react-gtm-module": "^2.0.11", "react-ga": "3.3.1", "react-helmet": "6.1.0", "react-hotkeys": "2.0.0", diff --git a/client/src/analytics/google-tag-manater/GoogleTagManager.tsx b/client/src/analytics/google-tag-manater/GoogleTagManager.tsx new file mode 100644 index 00000000000000..0027871fd47d72 --- /dev/null +++ b/client/src/analytics/google-tag-manater/GoogleTagManager.tsx @@ -0,0 +1,29 @@ +import { FC } from 'react'; +import TagManager from 'react-gtm-module'; + +import { + devTagManagerId, + prodTagManagerId +} from '../../../../config/analytics-settings'; + +import envData from '../../../../config/env.json'; + +/* eslint-disable @typescript-eslint/ban-types */ +const GoogleTagManager: FC<{}> = () => { + // if we have an ID + // then tags are supported in this environment, + // so initialize them + const segmentId = + envData.deploymentEnv === 'staging' ? devTagManagerId : prodTagManagerId; + if (segmentId) { + /* eslint-disable @typescript-eslint/no-unsafe-member-access */ + /* eslint-disable @typescript-eslint/no-unsafe-call */ + TagManager.initialize({ + gtmId: segmentId + }); + } + + return null; +}; + +export default GoogleTagManager; diff --git a/client/src/analytics/google-tag-manater/index.ts b/client/src/analytics/google-tag-manater/index.ts new file mode 100644 index 00000000000000..aa72e99ec3d0b2 --- /dev/null +++ b/client/src/analytics/google-tag-manater/index.ts @@ -0,0 +1 @@ +export { default as GoogleTagManager } from './GoogleTagManager'; diff --git a/client/src/analytics/segment/Segment.tsx b/client/src/analytics/segment/Segment.tsx new file mode 100644 index 00000000000000..e1f966ce616216 --- /dev/null +++ b/client/src/analytics/segment/Segment.tsx @@ -0,0 +1,31 @@ +import { FC } from 'react'; + +import { + devSegmentId, + prodSegmentId +} from '../../../../config/analytics-settings'; +import envData from '../../../../config/env.json'; +import segment from './segment-snippet'; + +interface SegmentModel { + load: (id: string) => void; + page: () => void; +} + +const segmentModel: SegmentModel = segment as unknown as SegmentModel; + +/* eslint-disable @typescript-eslint/ban-types */ +const Segment: FC<{}> = () => { + // if we have a key for this environment, load it + const segmentId = ( + envData.deploymentEnv === 'staging' ? devSegmentId : prodSegmentId + ) as string; + if (segmentId) { + segmentModel.load(segmentId); + segmentModel.page(); + } + + return null; +}; + +export default Segment; diff --git a/client/src/analytics/segment/index.ts b/client/src/analytics/segment/index.ts new file mode 100644 index 00000000000000..67a18436e91638 --- /dev/null +++ b/client/src/analytics/segment/index.ts @@ -0,0 +1 @@ +export { default as Segment } from './Segment'; diff --git a/client/src/analytics/segment/segment-snippet.js b/client/src/analytics/segment/segment-snippet.js new file mode 100644 index 00000000000000..831e9ef409094c --- /dev/null +++ b/client/src/analytics/segment/segment-snippet.js @@ -0,0 +1,65 @@ +function SegmentSnippet() { + var analytics = []; + + if (analytics.initialize) { + return; + } + if (analytics.invoked) { + window.console && + console.error && + console.error('Segment snippet included twice.'); + return; + } + analytics.invoked = !0; + analytics.methods = [ + 'trackSubmit', + 'trackClick', + 'trackLink', + 'trackForm', + 'pageview', + 'identify', + 'reset', + 'group', + 'track', + 'ready', + 'alias', + 'debug', + 'page', + 'once', + 'off', + 'on', + 'addSourceMiddleware', + 'addIntegrationMiddleware', + 'setAnonymousId', + 'addDestinationMiddleware' + ]; + analytics.factory = function (t) { + return function () { + var e = Array.prototype.slice.call(arguments); + e.unshift(t); + analytics.push(e); + return analytics; + }; + }; + for (var t = 0; t < analytics.methods.length; t++) { + var e = analytics.methods[t]; + analytics[e] = analytics.factory(e); + } + analytics.load = function (t, e) { + var n = document.createElement('script'); + n.type = 'text/javascript'; + n.async = !0; + n.src = + 'https://cdn.segment.com/analytics.js/v1/' + t + '/analytics.min.js'; + var a = document.getElementsByTagName('script')[0]; + a.parentNode.insertBefore(n, a); + analytics._loadOptions = e; + }; + analytics.SNIPPET_VERSION = '4.1.0'; + // analytics.load("SEGMENT_ANALYTICS_KEY"); - don't load here and let the component decide to load or not + // analytics.page(); - don't call the page, each app should call it when it loads a page by itself + + return { ...analytics }; +} + +export default SegmentSnippet(); diff --git a/client/src/components/OfflineWarning/offline-warning.tsx b/client/src/components/OfflineWarning/offline-warning.tsx index 42d3605a282f5f..ceafc25ce31554 100644 --- a/client/src/components/OfflineWarning/offline-warning.tsx +++ b/client/src/components/OfflineWarning/offline-warning.tsx @@ -29,7 +29,7 @@ function OfflineWarning({ t('misc.offline') ) : ( - placeholder + placeholder ); timeout(); diff --git a/client/src/components/formHelpers/block-save-button.tsx b/client/src/components/formHelpers/block-save-button.tsx index 1a57838e3c2945..2adad66ffd39c1 100644 --- a/client/src/components/formHelpers/block-save-button.tsx +++ b/client/src/components/formHelpers/block-save-button.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; function BlockSaveButton(props?: Record): JSX.Element { const { t } = useTranslation(); return ( - ); diff --git a/client/src/components/formHelpers/form-fields.tsx b/client/src/components/formHelpers/form-fields.tsx index 4b43881894d956..a34fd4c669f1c6 100644 --- a/client/src/components/formHelpers/form-fields.tsx +++ b/client/src/components/formHelpers/form-fields.tsx @@ -1,6 +1,5 @@ import { Alert, - Col, ControlLabel, FormControl, FormGroup, @@ -72,7 +71,7 @@ function FormFields(props: FormFieldsProps): JSX.Element { ) : null; }; return ( -
+ <> {formFields .filter(formField => !ignored.includes(formField.name)) .map(({ name, label }) => ( @@ -85,35 +84,33 @@ function FormFields(props: FormFieldsProps): JSX.Element { name in placeholders ? placeholders[name] : ''; const isURL = types[name] === 'url'; return ( - - - {type === 'hidden' ? null : ( - {label} - )} - - {nullOrWarning( - value as string, - !pristine && error, - isURL, - name - )} - - + + {type === 'hidden' ? null : ( + {label} + )} + + {nullOrWarning( + value as string, + !pristine && error, + isURL, + name + )} + ); }} ))} -
+ ); } diff --git a/client/src/components/formHelpers/form.tsx b/client/src/components/formHelpers/form.tsx index 3b9f800c2a1009..c136bd73efcde9 100644 --- a/client/src/components/formHelpers/form.tsx +++ b/client/src/components/formHelpers/form.tsx @@ -6,7 +6,6 @@ import { ValidatedValues, FormFields, BlockSaveButton, - BlockSaveWrapper, formatUrlValues } from '../formHelpers/index'; @@ -54,15 +53,13 @@ function DynamicForm({ style={{ width: '100%' }} > - - {hideButton ? null : ( - - {buttonText ? buttonText : null} - - )} - + {!hideButton && ( + + {buttonText ? buttonText : null} + + )} )} diff --git a/client/src/components/layouts/global.css b/client/src/components/layouts/global.css index 1dabb4ee93cee2..038fc6c2f4c5bb 100644 --- a/client/src/components/layouts/global.css +++ b/client/src/components/layouts/global.css @@ -491,13 +491,43 @@ fieldset[disabled] .btn-primary.focus { } } -.button-group .btn:not(:last-child) { +.button-group { + margin-bottom: -10px; +} +.button-group .btn { margin-bottom: 10px; } strong { color: var(--secondary-color); } +.form-group.embedded { + border: 1px solid var(--tc-black-40); + padding: 8px 10px 2px; + border-radius: 4px; + position: relative; + max-width: 320px; +} + +.form-group.embedded .control-label { + display: block; + font-size: 11px; + font-family: 'Roboto'; + line-height: 10px; + color: var(--tc-turq-160); + margin-bottom: 4px; +} + +.form-group.embedded .form-control { + border: 0 none; + padding: 0; + height: 22px; + font-size: 14px; + line-height: 22px; + font-family: 'Roboto'; + color: var(--tc-black-100); +} + .form-control { color: var(--primary-color); outline: none; diff --git a/client/src/components/layouts/learn.css b/client/src/components/layouts/learn.css index a96b2c77b08f52..292b987c9c36fa 100644 --- a/client/src/components/layouts/learn.css +++ b/client/src/components/layouts/learn.css @@ -24,3 +24,7 @@ #learn-app-wrapper .reflex-container.horizontal > .reflex-splitter { height: 5px; } + +#learn-app-wrapper .reflex-container > .reflex-element:first-child:last-child { + flex: 1 1 auto !important; +} diff --git a/client/src/components/layouts/prism-night.css b/client/src/components/layouts/prism-night.css index 04197d19ac2a74..5ddb302d8b613a 100644 --- a/client/src/components/layouts/prism-night.css +++ b/client/src/components/layouts/prism-night.css @@ -6,7 +6,8 @@ */ .dark-palette code[class*='language-'], -.dark-palette pre[class*='language-'] { +.dark-palette code[class*='language-'], +pre[class*='language-'].dark-palette { color: var(--secondary-color); font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; font-size: 1em; @@ -28,11 +29,13 @@ text-shadow: none; } +pre[class*='language-'].dark-palette code[class*='language-'], .dark-palette pre[class*='language-'] code[class*='language-'] { color: var(--quaternary-color); } /* Code blocks */ +pre[class*='language-'].dark-palette, .dark-palette pre[class*='language-'] { padding: 1em; margin: 0.5em 0; @@ -47,12 +50,15 @@ padding in night mode */ } .dark-palette :not(pre) > code[class*='language-'], +pre[class*='language-'].dark-palette, .dark-palette pre[class*='language-'] { background: var(--primary-background); } .dark-palette pre[class*='language-']::selection, +pre[class*='language-'].dark-palette::selection, .dark-palette pre[class*='language-'] ::selection, +pre[class*='language-'].dark-palette ::selection, .dark-palette code[class*='language-']::selection, .dark-palette code[class*='language-'] ::selection { background: var(--selection-color); diff --git a/client/src/components/layouts/prism.css b/client/src/components/layouts/prism.css index df0b319f65d4ca..5a165b2581bdd6 100644 --- a/client/src/components/layouts/prism.css +++ b/client/src/components/layouts/prism.css @@ -1,3 +1,9 @@ +pre[class*='language-'].line-numbers.line-numbers { + border-radius: 8px; + font-size: 14px; + line-height: 22px; +} + code .token.operator { background: none; } @@ -7,6 +13,10 @@ pre[class*='language-'] { background: var(--primary-background); } +.line-numbers > p { + color: var(--tc-black-100); +} + .default pre[class*='language-']::selection, .default pre[class*='language-'] ::selection, .default code[class*='language-']::selection, diff --git a/client/src/components/layouts/tc-integration.tsx b/client/src/components/layouts/tc-integration.tsx index 6f1703ac4d299b..136cd7f7acd1a3 100755 --- a/client/src/components/layouts/tc-integration.tsx +++ b/client/src/components/layouts/tc-integration.tsx @@ -97,6 +97,7 @@ class TcIntegrationLayout extends Component { window.addEventListener('online', this.updateOnlineStatus); window.addEventListener('offline', this.updateOnlineStatus); + window.addEventListener('click', this.externalLinkHandler); } componentDidUpdate(prevProps: TcIntegrationLayoutProps) { @@ -112,6 +113,64 @@ class TcIntegrationLayout extends Component { window.removeEventListener('offline', this.updateOnlineStatus); } + externalLinkHandler = (event: MouseEvent) => { + // prettier is the worst + + // if we're not clicking an anchor tag, there's nothing to do + const eventTarget = event.target as HTMLElement; + const anchorTag = eventTarget.closest('a'); + if (!anchorTag) { + return; + } + + // if the target of the click isn't external, there's nothing to do + const target = anchorTag; + const url = new URL(target.href); + if (url.host === window.location.host) { + return; + } + + // stop the click so we can alter it + event.stopPropagation(); + event.preventDefault(); + + // if this is a freecodecamp lesson, change its domain and path + const fccHost = 'freecodecamp.org'; + if (url.host.endsWith(fccHost)) { + // TODO: it would be nice to not require that the FCC + // app knows about the paths in the platform UI, but + // creating a way to share this info would be complex and + // time consuming, so we can handle it when we get another + // provider. + + // set the pathname for the 2 flavors of lesson URL + const platformPathPrefix = 'learn/freecodecamp'; + const learnPrefix = '/learn/'; + let updateHost = false; + if (url.host === `learn.${fccHost}`) { + url.pathname = `${platformPathPrefix}${url.pathname}`; + updateHost = true; + } else if ( + url.host === `www.${fccHost}` && + url.pathname.startsWith(learnPrefix) + ) { + url.pathname = url.pathname.replace( + learnPrefix, + `/${platformPathPrefix}/` + ); + updateHost = true; + } + + // set the host to the iframe's parent domain + if (updateHost) { + url.host = new URL(document.referrer).host; + } + } + + // now open the url in a new tab + window.open(url, '_blank'); + }; + updateOnlineStatus = () => { const { onlineStatusChange } = this.props; const isOnline = diff --git a/client/src/components/layouts/variables.css b/client/src/components/layouts/variables.css index 294bc1799e77a0..2d757df343facc 100644 --- a/client/src/components/layouts/variables.css +++ b/client/src/components/layouts/variables.css @@ -37,6 +37,8 @@ --tc-link-blue-light: #5fb7ee; --tc-black: #000; --tc-black-100: #2a2a2a; + --tc-black-60: #7f7f7f; + --tc-black-40: #aaaaaa; --tc-black-20: #d4d4d4; --tc-black-10: #e9e9e9; --tc-black-5: #f4f4f4; diff --git a/client/src/templates/Challenges/classic/action-row.tsx b/client/src/templates/Challenges/classic/action-row.tsx index 8d41e8222512c1..7e2b6f4a74bc01 100644 --- a/client/src/templates/Challenges/classic/action-row.tsx +++ b/client/src/templates/Challenges/classic/action-row.tsx @@ -7,6 +7,7 @@ import EditorTabs from './editor-tabs'; interface ActionRowProps { block: string; hasNotes: boolean; + hasPreview: boolean; isProjectBasedChallenge: boolean; showConsole: boolean; showNotes: boolean; @@ -19,6 +20,7 @@ interface ActionRowProps { const ActionRow = ({ hasNotes, + hasPreview, togglePane, showNotes, showPreview, @@ -70,15 +72,17 @@ const ActionRow = ({ {t('learn.editor-tabs.notes')} )} - + {hasPreview && ( + + )} ); diff --git a/client/src/templates/Challenges/classic/desktop-layout.tsx b/client/src/templates/Challenges/classic/desktop-layout.tsx index 4d875322c88804..e41dbfd73e81e4 100644 --- a/client/src/templates/Challenges/classic/desktop-layout.tsx +++ b/client/src/templates/Challenges/classic/desktop-layout.tsx @@ -2,7 +2,8 @@ import { first } from 'lodash-es'; import React, { useState, ReactElement } from 'react'; import { ReflexContainer, ReflexSplitter, ReflexElement } from 'react-reflex'; import { sortChallengeFiles } from '../../../../../utils/sort-challengefiles'; -import { challengeTypes } from '../../../../utils/challenge-types'; +import { GoogleTagManager } from '../../../analytics/google-tag-manater'; +import { Segment } from '../../../analytics/segment'; import { ChallengeFile, ChallengeFiles, @@ -34,6 +35,7 @@ interface DesktopLayoutProps { resizeProps: ResizeProps; superBlock: string; testOutput: ReactElement; + visibleEditors: { [key: string]: boolean }; } const reflexProps = { @@ -75,7 +77,6 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => { const { block, - challengeType, resizeProps, instructions, editor, @@ -86,20 +87,18 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => { notes, preview, hasEditableBoundaries, - superBlock + superBlock, + visibleEditors } = props; const challengeFile = getChallengeFile(); const projectBasedChallenge = hasEditableBoundaries; - const isMultifileCertProject = - challengeType === challengeTypes.multifileCertProject; - const displayPreview = - projectBasedChallenge || isMultifileCertProject - ? showPreview && hasPreview - : hasPreview; + const displayPreview = showPreview && hasPreview; const displayNotes = projectBasedChallenge ? showNotes && hasNotes : false; - const displayConsole = - projectBasedChallenge || isMultifileCertProject ? showConsole : true; + const displayConsole = showConsole; + const displayEditor = Object.entries(visibleEditors).some( + ([, visible]) => visible + ); const { codePane, editorPane, @@ -110,85 +109,91 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => { } = layoutState; return ( -
- -
- - {!projectBasedChallenge && showInstructions && ( - - {instructions} - - )} - {!projectBasedChallenge && ( - - )} - - - {challengeFile && ( - - - {editor} - - {displayNotes && ( - - )} - {displayNotes && ( - - {notes} - - )} - + <> +
+ +
+ + {!projectBasedChallenge && showInstructions && ( + + {instructions} + + )} + {!projectBasedChallenge && displayEditor && ( + )} - - - {(displayPreview || displayConsole) && ( - - )} - {(displayPreview || displayConsole) && ( - - - {displayPreview && ( - - {preview} - - )} - {displayConsole && displayPreview && ( - - )} - {displayConsole && ( + {challengeFile && displayEditor && ( + + - {testOutput} + {editor} - )} - - - )} - + {displayNotes && ( + + )} + {displayNotes && ( + + {notes} + + )} + + + )} + + {(displayPreview || displayConsole) && ( + + )} + + {(displayPreview || displayConsole) && ( + + + {displayPreview && ( + + {preview} + + )} + {displayConsole && displayPreview && ( + + )} + {displayConsole && ( + + {testOutput} + + )} + + + )} + +
-
+ + + + ); }; diff --git a/client/src/templates/Challenges/classic/editor.tsx b/client/src/templates/Challenges/classic/editor.tsx index bac527d68d21da..6231bd1e9aa97e 100644 --- a/client/src/templates/Challenges/classic/editor.tsx +++ b/client/src/templates/Challenges/classic/editor.tsx @@ -340,7 +340,7 @@ const Editor = (props: EditorProps): JSX.Element => { dataRef.current.model || monaco.editor.createModel( challengeFile?.contents ?? '', - modeMap[challengeFile?.ext ?? 'html'] + modeMap[(challengeFile?.ext ?? 'html') as keyof typeof modeMap] ); dataRef.current.model = model; diff --git a/client/src/templates/Challenges/classic/show.tsx b/client/src/templates/Challenges/classic/show.tsx index f332e4077fcb67..3e689eb9df304b 100644 --- a/client/src/templates/Challenges/classic/show.tsx +++ b/client/src/templates/Challenges/classic/show.tsx @@ -39,6 +39,7 @@ import { challengeMounted, challengeTestsSelector, consoleOutputSelector, + visibleEditorsSelector, createFiles, executeChallenge, initConsole, @@ -59,6 +60,10 @@ import MobileLayout from './mobile-layout'; import './classic.css'; import '../components/test-frame.css'; +type VisibleEditors = { + [key: string]: boolean; +}; + // Redux Setup const mapStateToProps = createStructuredSelector({ challengeFiles: challengeFilesSelector, @@ -66,7 +71,8 @@ const mapStateToProps = createStructuredSelector({ testsRunning: testsRunningSelector, output: consoleOutputSelector, isChallengeCompleted: isChallengeCompletedSelector, - savedChallenges: savedChallengesSelector + savedChallenges: savedChallengesSelector, + visibleEditors: visibleEditorsSelector }); const mapDispatchToProps = (dispatch: Dispatch) => @@ -113,6 +119,7 @@ interface ShowClassicProps { setEditorFocusability: (canFocus: boolean) => void; previewMounted: () => void; savedChallenges: CompletedChallenge[]; + visibleEditors: VisibleEditors; } interface ShowClassicState { @@ -358,6 +365,7 @@ class ShowClassic extends Component { block={block} description={description} instructions={instructions} + title={title} /> } guideUrl={getGuideUrl({ forumTopicId, title })} @@ -453,6 +461,7 @@ class ShowClassic extends Component { challengeMeta: { nextChallengePath, prevChallengePath } }, challengeFiles, + visibleEditors, t } = this.props; @@ -505,6 +514,7 @@ class ShowClassic extends Component { resizeProps={this.resizeProps} superBlock={superBlock} testOutput={this.renderTestOutput()} + visibleEditors={visibleEditors} /> { attemptRef.current.attempts++; } - const tryToSubmitChallenge = debounce(props.submitChallenge, 2000); + const tryToSubmitChallenge = debounce(props.submitChallenge, 2000, { + leading: true + }); function resetMarginDecorations() { const { model, insideEditDecId } = dataRef.current; diff --git a/client/src/templates/Challenges/classic/tc-layout.tsx b/client/src/templates/Challenges/classic/tc-layout.tsx index 66776ef5a163c4..edf9a605054df0 100755 --- a/client/src/templates/Challenges/classic/tc-layout.tsx +++ b/client/src/templates/Challenges/classic/tc-layout.tsx @@ -1,7 +1,10 @@ import { first } from 'lodash-es'; import React, { ReactElement } from 'react'; import { ReflexContainer, ReflexSplitter, ReflexElement } from 'react-reflex'; + import { sortChallengeFiles } from '../../../../../utils/sort-challengefiles'; +import { GoogleTagManager } from '../../../analytics/google-tag-manater'; +import Segment from '../../../analytics/segment/Segment'; import { ChallengeFile, ChallengeFiles, @@ -71,62 +74,67 @@ const TcLayout = (props: TcLayoutProps): JSX.Element => { } = layoutState; return ( -
- - - {instructions} - - - - - - - {challengeFile && ( - - +
+ + + {instructions} + + + + + + + {challengeFile && ( + - {editor} + + {editor} + + + )} + + + {displayNotes && ( + + )} + {displayNotes && ( + + {notes} + + )} + + {hasPreview && ( + <> + + + {preview} - + )} - - {displayNotes && ( - )} - {displayNotes && ( - - {notes} + + {testOutput} - )} + + + +
- {hasPreview && ( - <> - - - {preview} - - - )} - - - - {testOutput} - -
-
-
-
+ + + ); }; diff --git a/client/src/templates/Challenges/codeally/show.tsx b/client/src/templates/Challenges/codeally/show.tsx index e4e0f33ed27dba..ffdd7bd0f706e7 100644 --- a/client/src/templates/Challenges/codeally/show.tsx +++ b/client/src/templates/Challenges/codeally/show.tsx @@ -33,6 +33,7 @@ import { isChallengeCompletedSelector, updateChallengeMeta, openModal, + submitChallenge, updateSolutionFormValues } from '../redux'; import { createFlashMessage } from '../../../components/Flash/redux'; @@ -76,6 +77,7 @@ const mapDispatchToProps = (dispatch: Dispatch) => bindActionCreators( { challengeMounted, + submitChallenge, createFlashMessage, hideCodeAlly, openCompletionModal: () => openModal('completion'), @@ -93,6 +95,7 @@ interface ShowCodeAllyProps { createFlashMessage: typeof createFlashMessage; data: { challengeNode: ChallengeNode }; hideCodeAlly: () => void; + submitChallenge: () => void; isChallengeCompleted: boolean; isSignedIn: boolean; openCompletionModal: () => void; @@ -138,11 +141,7 @@ class ShowCodeAlly extends Component { this.props.hideCodeAlly(); } - handleSubmit = ({ - showCompletionModal - }: { - showCompletionModal: boolean; - }) => { + handleSubmit = ({ completed }: { completed: boolean }) => { const { completedChallenges, createFlashMessage, @@ -151,7 +150,7 @@ class ShowCodeAlly extends Component { challenge: { id: challengeId } } }, - openCompletionModal, + submitChallenge, partiallyCompletedChallenges } = this.props; @@ -168,8 +167,8 @@ class ShowCodeAlly extends Component { type: 'danger', message: FlashMessages.CompleteProjectFirst }); - } else if (showCompletionModal) { - openCompletionModal(); + } else if (completed) { + submitChallenge(); } }; diff --git a/client/src/templates/Challenges/components/Challenge-Description.tsx b/client/src/templates/Challenges/components/Challenge-Description.tsx index 59a15a3c7252b5..af6e6767f9396c 100644 --- a/client/src/templates/Challenges/components/Challenge-Description.tsx +++ b/client/src/templates/Challenges/components/Challenge-Description.tsx @@ -7,6 +7,7 @@ type Challenge = { block?: string; description?: string; instructions?: string; + title?: string; }; function ChallengeDescription(challenge: Challenge): JSX.Element { @@ -16,6 +17,12 @@ function ChallengeDescription(challenge: Challenge): JSX.Element { challenge.block ? ' ' + challenge.block : '' }`} > + {challenge.title && ( + <> + +
+ + )} {challenge.description && } {challenge.instructions && ( <> diff --git a/client/src/templates/Challenges/components/Hotkeys.tsx b/client/src/templates/Challenges/components/Hotkeys.tsx index 3a5b4f7ce1c9a3..f02944bceab3b8 100644 --- a/client/src/templates/Challenges/components/Hotkeys.tsx +++ b/client/src/templates/Challenges/components/Hotkeys.tsx @@ -110,7 +110,7 @@ function Hotkeys({ executeChallenge(); } } else { - executeChallenge({ showCompletionModal: true }); + executeChallenge(); } }, ...(keyboardShortcuts diff --git a/client/src/templates/Challenges/components/challenge-title.tsx b/client/src/templates/Challenges/components/challenge-title.tsx index f1ecf699a63023..97453deb051dbe 100644 --- a/client/src/templates/Challenges/components/challenge-title.tsx +++ b/client/src/templates/Challenges/components/challenge-title.tsx @@ -2,7 +2,6 @@ import i18next from 'i18next'; import React from 'react'; import GreenPass from '../../../assets/icons/green-pass'; import { Link } from '../../../components/helpers/index'; -import BreadCrumb from './bread-crumb'; import './challenge-title.css'; @@ -16,11 +15,8 @@ interface ChallengeTitleProps { } function ChallengeTitle({ - block, children, isCompleted, - showBreadCrumbs = true, - superBlock, translationPending }: ChallengeTitleProps): JSX.Element { return ( @@ -36,7 +32,6 @@ function ChallengeTitle({ )} - {showBreadCrumbs && }

{children}

diff --git a/client/src/templates/Challenges/components/prism-formatted.tsx b/client/src/templates/Challenges/components/prism-formatted.tsx index b71e92603eb234..dfb54222afe8fb 100644 --- a/client/src/templates/Challenges/components/prism-formatted.tsx +++ b/client/src/templates/Challenges/components/prism-formatted.tsx @@ -4,17 +4,53 @@ import React, { useRef, useEffect } from 'react'; interface PrismFormattedProps { className?: string; text: string; + lineNumbers?: boolean; + darkTheme?: boolean; } -function PrismFormatted({ className, text }: PrismFormattedProps): JSX.Element { +/** + * Add css formatting classes to the
 elements based on showLineNumbers and darkTheme params
+ * @param container
+ * @param showLineNumbers
+ * @param darkTheme
+ */
+const addFormattingClassesForPres = (
+  container: HTMLElement,
+  showLineNumbers = true,
+  darkTheme = true
+) => {
+  const codeBlocks: HTMLElement[] = [].slice.call(
+    container.querySelectorAll('[class*="language-"]')
+  );
+  // we want to formatt the 
 element, not the , get parent if current element is not PRE
+  const preElements: HTMLPreElement[] = codeBlocks.map(
+    c => (c.nodeName === 'PRE' ? c : c.parentElement) as HTMLPreElement
+  );
+
+  for (const pre of preElements) {
+    pre.classList.toggle('line-numbers', showLineNumbers);
+    pre.classList.toggle('dark-palette', darkTheme);
+  }
+};
+
+function PrismFormatted({
+  className,
+  text,
+  ...props
+}: PrismFormattedProps): JSX.Element {
   const instructionsRef = useRef(null);
 
   useEffect(() => {
     // Just in case 'current' has not been created, though it should have been.
     if (instructionsRef.current) {
+      addFormattingClassesForPres(
+        instructionsRef.current,
+        props.lineNumbers,
+        props.darkTheme
+      );
       Prism.highlightAllUnder(instructionsRef.current);
     }
-  }, []);
+  }, [props.darkTheme, props.lineNumbers]);
 
   return (
     
- {showToolPanel && ( + {showToolPanel && tests.length > 10 && ( )} - {challengeType !== challengeTypes.multifileCertProject && ( + {/* {challengeType !== challengeTypes.multifileCertProject && ( - )} + )} */}
); } diff --git a/client/src/templates/Challenges/projects/backend/Show.tsx b/client/src/templates/Challenges/projects/backend/Show.tsx index 3da304fb3b7866..f27009fc4523e4 100644 --- a/client/src/templates/Challenges/projects/backend/Show.tsx +++ b/client/src/templates/Challenges/projects/backend/Show.tsx @@ -174,13 +174,9 @@ class BackEnd extends Component { challengeMounted(challengeMeta.id); } - handleSubmit({ - showCompletionModal - }: { - showCompletionModal: boolean; - }): void { + handleSubmit(): void { this.props.executeChallenge({ - showCompletionModal + showCompletionModal: false }); } diff --git a/client/src/templates/Challenges/projects/frontend/Show.tsx b/client/src/templates/Challenges/projects/frontend/Show.tsx index f7c85a0802a909..a7e8dccf6dd29a 100644 --- a/client/src/templates/Challenges/projects/frontend/Show.tsx +++ b/client/src/templates/Challenges/projects/frontend/Show.tsx @@ -14,18 +14,14 @@ import { ChallengeNode, ChallengeMeta } from '../../../../redux/prop-types'; import ChallengeDescription from '../../components/Challenge-Description'; import Hotkeys from '../../components/Hotkeys'; import ChallengeTitle from '../../components/challenge-title'; -import CompletionModal from '../../components/completion-modal'; -import HelpModal from '../../components/help-modal'; import { challengeMounted, isChallengeCompletedSelector, + submitChallenge, updateChallengeMeta, - openModal, updateSolutionFormValues } from '../../redux'; -import { getGuideUrl } from '../../utils'; import SolutionForm from '../solution-form'; -import ProjectToolPanel from '../tool-panel'; // Redux Setup const mapStateToProps = createSelector( @@ -38,10 +34,10 @@ const mapStateToProps = createSelector( const mapDispatchToProps = (dispatch: Dispatch) => bindActionCreators( { + submitChallenge, updateChallengeMeta, challengeMounted, - updateSolutionFormValues, - openCompletionModal: () => openModal('completion') + updateSolutionFormValues }, dispatch ); @@ -51,7 +47,7 @@ interface ProjectProps { challengeMounted: (arg0: string) => void; data: { challengeNode: ChallengeNode }; isChallengeCompleted: boolean; - openCompletionModal: () => void; + submitChallenge: () => void; pageContext: { challengeMeta: ChallengeMeta; }; @@ -60,13 +56,24 @@ interface ProjectProps { updateSolutionFormValues: () => void; } +interface ProjectState { + completed: boolean; + hasErrors: boolean; +} + // Component -class Project extends Component { +class Project extends Component { static displayName: string; private _container: HTMLElement | null = null; constructor(props: ProjectProps) { super(props); + + this.state = { + completed: false, + hasErrors: false + }; + this.handleSubmit = this.handleSubmit.bind(this); } componentDidMount() { @@ -119,13 +126,12 @@ class Project extends Component { } } - handleSubmit({ - showCompletionModal - }: { - showCompletionModal: boolean; - }): void { - if (showCompletionModal) { - this.props.openCompletionModal(); + handleSubmit({ completed }: { completed: boolean }): void { + this.setState({ completed, hasErrors: !completed }); + + const { submitChallenge } = this.props; + if (completed) { + submitChallenge(); } } @@ -135,13 +141,10 @@ class Project extends Component { challengeNode: { challenge: { challengeType, - fields: { blockName }, - forumTopicId, title, description, instructions, superBlock, - certification, block, translationPending } @@ -192,19 +195,8 @@ class Project extends Component { onSubmit={this.handleSubmit} updateSolutionForm={updateSolutionFormValues} /> - -
- - diff --git a/client/src/templates/Challenges/projects/solution-form.tsx b/client/src/templates/Challenges/projects/solution-form.tsx index b5a43cce74986a..712081077fb0a0 100644 --- a/client/src/templates/Challenges/projects/solution-form.tsx +++ b/client/src/templates/Challenges/projects/solution-form.tsx @@ -12,7 +12,7 @@ import { import { Form, ValidatedValues } from '../../../components/formHelpers'; interface SubmitProps { - showCompletionModal: boolean; + completed: boolean; } interface FormProps extends WithTranslation { @@ -38,9 +38,9 @@ export class SolutionForm extends Component { // updates values on store this.props.updateSolutionForm(validatedValues.values); if (validatedValues.invalidValues.length === 0) { - this.props.onSubmit({ showCompletionModal: true }); + this.props.onSubmit({ completed: true }); } else { - this.props.onSubmit({ showCompletionModal: false }); + this.props.onSubmit({ completed: false }); } } }; @@ -57,7 +57,7 @@ export class SolutionForm extends Component { { name: 'githubLink', label: t('learn.github-link') } ]; - const buttonCopy = t('learn.i-completed'); + const buttonCopy = t('learn.submit-and-go'); const options = { types: { diff --git a/client/src/templates/Challenges/redux/execute-challenge-saga.js b/client/src/templates/Challenges/redux/execute-challenge-saga.js index 6eb99c87a32b24..0798391b84f34f 100644 --- a/client/src/templates/Challenges/redux/execute-challenge-saga.js +++ b/client/src/templates/Challenges/redux/execute-challenge-saga.js @@ -45,7 +45,7 @@ import { updateLogs, logsToConsole, updateTests, - openModal, + // openModal, isBuildEnabledSelector, disableBuildOnError, updateTestsRunning @@ -137,7 +137,8 @@ export function* executeChallengeSaga({ payload }) { playTone('tests-failed'); } if (challengeComplete && payload?.showCompletionModal) { - yield put(openModal('completion')); + // TOPCODER: do not open modal + // yield put(openModal('completion')); } yield put(updateConsole(i18next.t('learn.tests-completed'))); yield put(logsToConsole(i18next.t('learn.console-output'))); diff --git a/client/src/templates/Challenges/video/Show.tsx b/client/src/templates/Challenges/video/Show.tsx index ecf7bda07b9190..c97cc31f1056a1 100644 --- a/client/src/templates/Challenges/video/Show.tsx +++ b/client/src/templates/Challenges/video/Show.tsx @@ -11,6 +11,8 @@ import type { Dispatch } from 'redux'; import { createSelector } from 'reselect'; // Local Utilities +import Fail from '../../../assets/icons/test-fail'; +import GreenPass from '../../../assets/icons/test-pass'; import Loader from '../../../components/helpers/loader'; import Spacer from '../../../components/helpers/spacer'; import LearnLayout from '../../../components/layouts/learn'; @@ -18,14 +20,13 @@ import { ChallengeNode, ChallengeMeta } from '../../../redux/prop-types'; import ChallengeDescription from '../components/Challenge-Description'; import Hotkeys from '../components/Hotkeys'; import VideoPlayer from '../components/VideoPlayer'; -import ChallengeTitle from '../components/challenge-title'; import CompletionModal from '../components/completion-modal'; import PrismFormatted from '../components/prism-formatted'; import { isChallengeCompletedSelector, challengeMounted, updateChallengeMeta, - openModal, + submitChallenge, updateSolutionFormValues } from '../redux'; @@ -42,10 +43,10 @@ const mapStateToProps = createSelector( const mapDispatchToProps = (dispatch: Dispatch) => bindActionCreators( { + submitChallenge, updateChallengeMeta, challengeMounted, - updateSolutionFormValues, - openCompletionModal: () => openModal('completion') + updateSolutionFormValues }, dispatch ); @@ -56,16 +57,17 @@ interface ShowVideoProps { data: { challengeNode: ChallengeNode }; description: string; isChallengeCompleted: boolean; - openCompletionModal: () => void; pageContext: { challengeMeta: ChallengeMeta; }; + submitChallenge: () => void; t: TFunction; updateChallengeMeta: (arg0: ChallengeMeta) => void; updateSolutionFormValues: () => void; } interface ShowVideoState { + completed: boolean; subtitles: string; downloadURL: string | null; selectedOption: number | null; @@ -87,7 +89,8 @@ class ShowVideo extends Component { selectedOption: null, answer: 1, showWrong: false, - videoIsLoaded: false + videoIsLoaded: false, + completed: false }; this.handleSubmit = this.handleSubmit.bind(this); @@ -143,12 +146,12 @@ class ShowVideo extends Component { } } - handleSubmit(solution: number, openCompletionModal: () => void) { + handleSubmit(solution: number) { if (solution - 1 === this.state.selectedOption) { this.setState({ + completed: true, showWrong: false }); - openCompletionModal(); } else { this.setState({ showWrong: true @@ -182,7 +185,6 @@ class ShowVideo extends Component { superBlock, certification, block, - translationPending, videoId, videoLocaleIds, bilibiliIds, @@ -190,22 +192,20 @@ class ShowVideo extends Component { } } }, - openCompletionModal, pageContext: { challengeMeta: { nextChallengePath, prevChallengePath } }, t, - isChallengeCompleted + submitChallenge } = this.props; const blockNameTitle = `${t( `intro:${superBlock}.blocks.${block}.title` )} - ${title}`; + return ( { - this.handleSubmit(solution, openCompletionModal); - }} + executeChallenge={() => this.handleSubmit(solution)} innerRef={(c: HTMLElement | null) => (this._container = c)} nextChallengePath={nextChallengePath} prevChallengePath={prevChallengePath} @@ -216,16 +216,6 @@ class ShowVideo extends Component { /> - - - {title} - -
{!this.state.videoIsLoaded ? ( @@ -243,9 +233,20 @@ class ShowVideo extends Component { />
+ +

Question

+ + - +
@@ -276,28 +277,41 @@ class ShowVideo extends Component {
-
- {this.state.showWrong ? ( - {t('learn.wrong-answer')} - ) : ( +
+ {this.state.showWrong && ( + + + {t('learn.wrong-answer')} + + )} + {this.state.completed && ( + + + Great Job! + + )} + + {!this.state.completed && !this.state.showWrong && ( {t('learn.check-answer')} )}
- - + {this.state.completed ? ( + + ) : ( + + )} label { margin: 0; - align-items: center; + align-items: flex-start; overflow-x: auto; scrollbar-width: thin; scrollbar-color: var(--quaternary-background) var(--secondary-background); @@ -54,22 +54,18 @@ } .video-quiz-option-label { - padding: 20px; + padding: 10px 0; cursor: pointer; display: flex; font-weight: normal; - border-left: 4px solid var(--tertiary-background); - border-right: 4px solid var(--tertiary-background); - border-top: 2px solid var(--tertiary-background); - border-bottom: 2px solid var(--tertiary-background); } .video-quiz-option-label:first-child { - border-top: 4px solid var(--tertiary-background); + padding-top: 0; } .video-quiz-option-label:last-child { - border-bottom: 4px solid var(--tertiary-background); + padding-bottom: 0; } .video-quiz-input-hidden { @@ -78,7 +74,7 @@ } .video-quiz-input-visible { - margin-right: 15px; + margin-right: 8px; position: relative; top: 2px; display: inline-block; @@ -87,8 +83,12 @@ max-width: 20px; max-height: 20px; border-radius: 50%; - background-color: var(--secondary-background); - border: 2px solid var(--primary-color); + background-color: var(--tc-white); + border: 1px solid var(--tc-black-60); +} + +.video-quiz-input-visible:not(:empty) { + border-color: var(--tc-turq-160); } .video-quiz-selected-input { @@ -97,7 +97,7 @@ position: absolute; top: 50%; left: 50%; - background-color: var(--primary-color); + background-color: var(--tc-turq-160); border-radius: 50%; transform: translate(-50%, -50%); } @@ -114,3 +114,47 @@ /* remove default prism background */ background: none; } + +.challenge-instructions { + color: var(--tc-black-100); +} + +.challenge-instructions:not(:empty) { + margin-bottom: 18px; +} + +.video-description .line-numbers > p:first-child { + font-family: 'Roboto'; + font-weight: bold; + line-height: 26px; + font-size: 20px; + color: var(--tc-black-100); +} + +.video-quiz-cta-text { + font-size: 16px; + line-height: 24px; + font-family: 'Roboto'; + color: var(--tc-black-100); + margin-bottom: 24px; +} + +.video-quiz-cta-text > span { + display: flex; + align-items: center; + gap: 12px; +} + +.video-section-label { + font-size: 18px; + font-weight: 600; + font-family: 'Barlow', 'sans-serif'; + line-height: 22px; + color: var(--tc-black-100); + text-transform: uppercase; + + border-top: 1px solid var(--tc-black-10); + margin-top: 32px; + padding-top: 18px; + margin-bottom: 24px; +} diff --git a/config/analytics-settings.js b/config/analytics-settings.js index 106725cc3b67eb..b2b6bad8f3abbf 100644 --- a/config/analytics-settings.js +++ b/config/analytics-settings.js @@ -1,2 +1,8 @@ -exports.prodAnalyticsId = 'UA-55446531-10'; -exports.devAnalyticsId = 'UA-55446531-19'; +exports.prodAnalyticsId = null; +exports.devAnalyticsId = null; + +exports.prodSegmentId = '8fCbi94o3ruUUGxRRGxWu194t6iVq9LH'; +exports.devSegmentId = null; + +exports.prodTagManagerId = 'GTM-MXXQHG8'; +exports.devTagManagerId = 'GTM-W7B537Z'; diff --git a/curriculum/challenges/english/03-front-end-development-libraries/front-end-development-libraries-projects/build-a-25-5-clock.md b/curriculum/challenges/english/03-front-end-development-libraries/front-end-development-libraries-projects/build-a-25-5-clock.md index 61119b0bca25d8..c22604450334d3 100644 --- a/curriculum/challenges/english/03-front-end-development-libraries/front-end-development-libraries-projects/build-a-25-5-clock.md +++ b/curriculum/challenges/english/03-front-end-development-libraries/front-end-development-libraries-projects/build-a-25-5-clock.md @@ -70,10 +70,11 @@ You can use any mix of HTML, JavaScript, CSS, Bootstrap, SASS, React, Redux, and **User Story #28:** The audio element with id of `beep` must stop playing and be rewound to the beginning when the element with the id of `reset` is clicked. -You can build your project by using this CodePen template and clicking `Save` to create your own pen. Or you can use this CDN link to run the tests in any environment you like: `https://cdn.freecodecamp.org/testable-projects-fcc/v1/bundle.js` +You can build your project by using this CodePen template and clicking `Save` to create your own pen. Or you can use this CDN link to run the tests in any environment you like: `https://cdn.freecodecamp.org/testable-projects-fcc/v1/bundle.js` Once you're done, submit the URL to your working project with all its tests passing. + # --solutions-- ```js diff --git a/package-lock.json b/package-lock.json index f7aba780c362f1..78de8153449668 100644 --- a/package-lock.json +++ b/package-lock.json @@ -562,6 +562,7 @@ "react": "16.14.0", "react-dom": "16.14.0", "react-final-form": "6.5.9", + "react-gtm-module": "^2.0.11", "react-ga": "3.3.1", "react-helmet": "6.1.0", "react-hotkeys": "2.0.0", @@ -43375,6 +43376,11 @@ "react": "^15.6.2 || ^16.0 || ^17 || ^18" } }, + "node_modules/react-gtm-module": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/react-gtm-module/-/react-gtm-module-2.0.11.tgz", + "integrity": "sha512-8gyj4TTxeP7eEyc2QKawEuQoAZdjKvMY4pgWfycGmqGByhs17fR+zEBs0JUDq4US/l+vbTl+6zvUIx27iDo/Vw==" + }, "node_modules/react-helmet": { "version": "6.1.0", "license": "MIT", @@ -57190,6 +57196,7 @@ "react": "16.14.0", "react-dom": "16.14.0", "react-final-form": "6.5.9", + "react-gtm-module": "^2.0.11", "react-ga": "3.3.1", "react-helmet": "6.1.0", "react-hotkeys": "2.0.0", @@ -84488,6 +84495,11 @@ "integrity": "sha512-4Vc0W5EvXAXUN/wWyxvsAKDLLgtJ3oLmhYYssx+YzphJpejtOst6cbIHCIyF50Fdxuf5DDKqRYny24yJ2y7GFQ==", "requires": {} }, + "react-gtm-module": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/react-gtm-module/-/react-gtm-module-2.0.11.tgz", + "integrity": "sha512-8gyj4TTxeP7eEyc2QKawEuQoAZdjKvMY4pgWfycGmqGByhs17fR+zEBs0JUDq4US/l+vbTl+6zvUIx27iDo/Vw==" + }, "react-helmet": { "version": "6.1.0", "requires": { diff --git a/tools/challenge-editor/api/configs/superBlockList.ts b/tools/challenge-editor/api/configs/superBlockList.ts index cb9b1c82bb7086..ffebc33c1f9939 100644 --- a/tools/challenge-editor/api/configs/superBlockList.ts +++ b/tools/challenge-editor/api/configs/superBlockList.ts @@ -48,7 +48,7 @@ export const superBlockList = [ path: '13-relational-databases' }, { - name: '(New) Responsive Web Design', + name: 'Responsive Web Design', path: '14-responsive-web-design-22' }, {