fix(learn): restore share button on workshop completion (#65289)

Co-authored-by: ahmad abdolsaheb <ahmad.abdolsaheb@gmail.com>
This commit is contained in:
nnyouung 2026-02-18 02:43:11 +09:00 committed by GitHub
parent f0a2514783
commit 6aff62439c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 150 additions and 15 deletions

View File

@ -3,16 +3,22 @@ import { ShareTemplate } from './share-template';
import { ShareProps } from './types';
import { useShare } from './use-share';
export const Share = ({ superBlock, block }: ShareProps): JSX.Element => {
export const Share = ({
superBlock,
block,
minified
}: ShareProps): JSX.Element => {
const redirectURLs = useShare({
superBlock,
block
block,
minified
});
return (
<ShareTemplate
xRedirectURL={redirectURLs.xUrl}
blueSkyRedirectURL={redirectURLs.blueSkyUrl}
threadsRedirectURL={redirectURLs.threadsURL}
minified={minified}
/>
);
};

View File

@ -11,42 +11,43 @@ import { ShareRedirectProps } from './types';
export const ShareTemplate: React.ComponentType<ShareRedirectProps> = ({
xRedirectURL,
blueSkyRedirectURL,
threadsRedirectURL
threadsRedirectURL,
minified
}) => {
const { t } = useTranslation();
return (
<>
<a
data-testid='ShareTemplateWrapperTestID'
data-testid='share-on-x'
className='btn fade-in'
href={xRedirectURL}
target='_blank'
rel='noreferrer'
>
<FontAwesomeIcon icon={faXTwitter} size='1x' aria-hidden='true' />
{t('buttons.share-on-x')}
{!minified && t('buttons.share-on-x')}
<span className='sr-only'>{t('aria.opens-new-window')}</span>
</a>
<a
data-testid='ShareTemplateWrapperTestID'
data-testid='share-on-bluesky'
className='btn fade-in'
href={blueSkyRedirectURL}
target='_blank'
rel='noreferrer'
>
<FontAwesomeIcon icon={faBluesky} size='1x' aria-hidden='true' />
{t('buttons.share-on-bluesky')}
{!minified && t('buttons.share-on-bluesky')}
<span className='sr-only'>{t('aria.opens-new-window')}</span>
</a>
<a
data-testid='ShareTemplateWrapperTestID'
data-testid='share-on-threads'
className='btn fade-in'
href={threadsRedirectURL}
target='_blank'
rel='noreferrer'
>
<FontAwesomeIcon icon={faInstagram} size='1x' aria-hidden='true' />
{t('buttons.share-on-threads')}
{!minified && t('buttons.share-on-threads')}
<span className='sr-only'>{t('aria.opens-new-window')}</span>
</a>
</>

View File

@ -1,10 +1,12 @@
export interface ShareProps {
superBlock: string;
block: string;
minified?: boolean;
}
export interface ShareRedirectProps {
xRedirectURL: string;
blueSkyRedirectURL: string;
threadsRedirectURL: string;
minified?: boolean;
}

View File

@ -119,3 +119,9 @@
.independent-lower-jaw .tooltip .tooltiptext.left-tooltip {
left: 0;
}
.share-button-wrapper {
display: flex;
justify-content: start;
gap: 10px;
}

View File

@ -0,0 +1,65 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import type { ChallengeMeta, Test } from '../../../redux/prop-types';
import { SuperBlocks } from '@freecodecamp/shared/config/curriculum';
import { IndependentLowerJaw } from './independent-lower-jaw';
const baseChallengeMeta: ChallengeMeta = {
block: 'test-block',
id: 'test-challenge-id',
isFirstStep: false,
superBlock: SuperBlocks.RespWebDesignV9,
helpCategory: 'HTML-CSS',
disableLoopProtectTests: false,
disableLoopProtectPreview: false
};
const passingTests: Test[] = [{ pass: true, text: 'test', testString: 'test' }];
const baseProps = {
openHelpModal: vi.fn(),
openResetModal: vi.fn(),
executeChallenge: vi.fn(),
submitChallenge: vi.fn(),
tests: passingTests,
isSignedIn: true,
challengeMeta: baseChallengeMeta,
completedPercent: 100,
completedChallengeIds: ['id-1', 'test-challenge-id'],
currentBlockIds: ['id-1', 'test-challenge-id']
};
describe('<IndependentLowerJaw />', () => {
it('shows share buttons when the block is completed on the last step', () => {
render(<IndependentLowerJaw {...baseProps} />);
expect(screen.getByTestId('share-on-x')).toBeInTheDocument();
expect(screen.getByTestId('share-on-bluesky')).toBeInTheDocument();
expect(screen.getByTestId('share-on-threads')).toBeInTheDocument();
});
it('does not show share buttons when the block is not completed', () => {
render(
<IndependentLowerJaw
{...baseProps}
completedPercent={50}
completedChallengeIds={['id-1']}
/>
);
expect(screen.queryByTestId('share-on-x')).not.toBeInTheDocument();
});
it('does not show share buttons when it is not the last step', () => {
render(
<IndependentLowerJaw
{...baseProps}
currentBlockIds={[baseChallengeMeta.id, 'id-2']}
completedChallengeIds={[baseChallengeMeta.id, 'id-2']}
/>
);
expect(screen.queryByTestId('share-on-x')).not.toBeInTheDocument();
});
});

View File

@ -3,13 +3,22 @@ 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 {
completedChallengesIdsSelector,
isSignedInSelector
} from '../../../redux/selectors';
import { ChallengeMeta, Test } from '../../../redux/prop-types';
import {
challengeMetaSelector,
challengeTestsSelector,
completedPercentageSelector,
currentBlockIdsSelector
} 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 { Share } from '../../../components/share';
import './independent-lower-jaw.css';
import Reset from '../../../assets/icons/reset';
@ -17,9 +26,24 @@ import Reset from '../../../assets/icons/reset';
const mapStateToProps = createSelector(
challengeTestsSelector,
isSignedInSelector,
(tests: Test[], isSignedIn: boolean) => ({
challengeMetaSelector,
completedPercentageSelector,
completedChallengesIdsSelector,
currentBlockIdsSelector,
(
tests: Test[],
isSignedIn: boolean,
challengeMeta: ChallengeMeta,
completedPercent: number,
completedChallengeIds: string[],
currentBlockIds: string[]
) => ({
tests,
isSignedIn
isSignedIn,
challengeMeta,
completedPercent,
completedChallengeIds,
currentBlockIds
})
);
@ -37,6 +61,10 @@ interface IndependentLowerJawProps {
submitChallenge: () => void;
tests: Test[];
isSignedIn: boolean;
challengeMeta: ChallengeMeta;
completedPercent: number;
completedChallengeIds: string[];
currentBlockIds: string[];
}
export function IndependentLowerJaw({
openHelpModal,
@ -44,7 +72,11 @@ export function IndependentLowerJaw({
executeChallenge,
submitChallenge,
tests,
isSignedIn
isSignedIn,
challengeMeta,
completedPercent,
completedChallengeIds,
currentBlockIds
}: IndependentLowerJawProps): JSX.Element {
const { t } = useTranslation();
const firstFailedTest = tests.find(test => !!test.err);
@ -57,6 +89,20 @@ export function IndependentLowerJaw({
React.useState(false);
const isChallengeComplete = tests.every(test => test.pass);
const hasBlockIds = currentBlockIds.length > 0;
const isLastStepInBlock =
hasBlockIds &&
currentBlockIds[currentBlockIds.length - 1] === challengeMeta.id;
const isBlockCompletedByIds =
hasBlockIds &&
currentBlockIds.every(challengeId =>
completedChallengeIds.includes(challengeId)
);
const hasCompletedPercent = Number.isFinite(completedPercent);
const isBlockCompleted =
isBlockCompletedByIds || (hasCompletedPercent && completedPercent === 100);
const showShareButton =
isChallengeComplete && isLastStepInBlock && isBlockCompleted;
React.useEffect(() => {
setShowHint(!!hint);
@ -110,6 +156,15 @@ export function IndependentLowerJaw({
>
<div>
<p>{t('learn.congratulations-code-passes')}</p>
{isSignedIn && showShareButton && (
<div className='share-button-wrapper'>
<Share
superBlock={challengeMeta.superBlock}
block={challengeMeta.block}
minified={true}
/>
</div>
)}
{!isSignedIn && (
<a
href={`${apiLocation}/signin`}