feat(client,api): add a per module reset (#62547)

This commit is contained in:
Mrugesh Mohapatra
2026-05-28 18:56:39 +05:30
committed by GitHub
parent 473d660134
commit 5a2606db1c
29 changed files with 2415 additions and 176 deletions
+1
View File
@@ -203,3 +203,4 @@ api/logs/
### Turborepo
.turbo
test-results
+341
View File
@@ -35,6 +35,7 @@ import {
seedEnvExamAttempt,
seedExamEnvExamAuthToken
} from '../../../__fixtures__/exam-environment-exam.js';
import * as getChallengesModule from '../../utils/get-challenges.js';
import { getMsTranscriptApiUrl } from './user.js';
const mockedFetch = vi.fn();
@@ -771,6 +772,345 @@ describe('userRoutes', () => {
test.todo('POST resets the user to the default state');
});
describe('/account/reset-module', () => {
const testChallengesBlockOne = [
{
id: 'block-one-challenge-1',
completedDate: 1520002973119,
solution: null,
challengeType: 5,
files: []
},
{
id: 'block-one-challenge-2',
completedDate: 1520002973120,
solution: null,
challengeType: 5,
files: []
}
];
const testChallengesBlockTwo = [
{
id: 'block-two-challenge-1',
completedDate: 1520002973121,
solution: null,
challengeType: 5,
files: []
},
{
id: 'block-two-challenge-2',
completedDate: 1520002973122,
solution: null,
challengeType: 5,
files: []
}
];
const savedChallengesBlockOne = [
{
id: 'block-one-challenge-1',
lastSavedDate: 123,
files: [
{
contents: 'test-contents',
ext: 'js',
history: ['indexjs'],
key: 'indexjs',
name: 'test-name'
}
]
}
];
const partiallyCompletedChallengesBlockOne = [
{
id: 'block-one-challenge-1',
completedDate: 1520002973119
},
{
id: 'block-one-challenge-2',
completedDate: 1520002973120
}
];
let getChallengeIdsByBlockSpy: MockInstance;
beforeEach(async () => {
// Mock getChallengeIdsByBlock to return test challenge IDs
getChallengeIdsByBlockSpy = vi
.spyOn(getChallengesModule, 'getChallengeIdsByBlock')
.mockImplementation((blockId: string) => {
if (blockId === 'block-one') {
return ['block-one-challenge-1', 'block-one-challenge-2'];
}
if (blockId === 'block-two') {
return ['block-two-challenge-1', 'block-two-challenge-2'];
}
return [];
});
await fastifyTestInstance.prisma.user.updateMany({
where: { email: testUserData.email },
data: {
completedChallenges: [
...testChallengesBlockOne,
...testChallengesBlockTwo
],
savedChallenges: savedChallengesBlockOne,
partiallyCompletedChallenges: partiallyCompletedChallengesBlockOne,
isRespWebDesignCert: true
}
});
});
afterEach(() => {
getChallengeIdsByBlockSpy.mockRestore();
});
test('DELETE returns 400 for missing blockIds', async () => {
const response = await superDelete('/account/reset-module').send({});
expect(response.status).toBe(400);
});
test('DELETE returns 400 for empty blockIds array', async () => {
const response = await superDelete('/account/reset-module').send({
blockIds: []
});
expect(response.status).toBe(400);
});
test('DELETE returns 400 for blockIds containing an empty string', async () => {
const response = await superDelete('/account/reset-module').send({
blockIds: ['']
});
expect(response.status).toBe(400);
});
test('DELETE returns 400 when blockIds exceeds maxItems', async () => {
const tooMany = Array.from({ length: 501 }, (_, i) => `block-${i}`);
const response = await superDelete('/account/reset-module').send({
blockIds: tooMany
});
expect(response.status).toBe(400);
});
test('DELETE returns 200 with removedChallengeIds', async () => {
const response = await superDelete('/account/reset-module').send({
blockIds: ['block-one']
});
expect(response.status).toBe(200);
expect(response.body).toStrictEqual({
removedChallengeIds: expect.arrayContaining([
'block-one-challenge-1',
'block-one-challenge-2'
])
});
});
test('DELETE removes only challenges from the specified block', async () => {
await superDelete('/account/reset-module').send({
blockIds: ['block-one']
});
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: testUserData.email }
});
expect(user?.completedChallenges).toHaveLength(2);
const challengeIds = (
user?.completedChallenges as { id: string }[]
).map(c => c.id);
expect(challengeIds).toContain('block-two-challenge-1');
expect(challengeIds).toContain('block-two-challenge-2');
expect(challengeIds).not.toContain('block-one-challenge-1');
expect(challengeIds).not.toContain('block-one-challenge-2');
});
test('DELETE removes saved challenges from the specified block', async () => {
await superDelete('/account/reset-module').send({
blockIds: ['block-one']
});
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: testUserData.email }
});
expect(user?.savedChallenges).toHaveLength(0);
});
test('DELETE removes partially completed challenges from the specified block', async () => {
await superDelete('/account/reset-module').send({
blockIds: ['block-one']
});
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: testUserData.email }
});
expect(user?.partiallyCompletedChallenges).toHaveLength(0);
});
test('DELETE keeps certifications intact', async () => {
await superDelete('/account/reset-module').send({
blockIds: ['block-one']
});
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: testUserData.email }
});
expect(user?.isRespWebDesignCert).toBe(true);
});
test('DELETE keeps progress timestamps intact', async () => {
const userBefore = await fastifyTestInstance.prisma.user.findFirst({
where: { email: testUserData.email }
});
await superDelete('/account/reset-module').send({
blockIds: ['block-one']
});
const userAfter = await fastifyTestInstance.prisma.user.findFirst({
where: { email: testUserData.email }
});
expect(userAfter?.progressTimestamps).toEqual(
userBefore?.progressTimestamps
);
});
test('DELETE does not delete userTokens', async () => {
await fastifyTestInstance.prisma.userToken.create({
data: {
created: new Date(),
id: '123',
ttl: 1000,
userId: defaultUserId
}
});
await superDelete('/account/reset-module').send({
blockIds: ['block-one']
});
expect(await fastifyTestInstance.prisma.userToken.count()).toBe(1);
await fastifyTestInstance.prisma.userToken.deleteMany({
where: { userId: defaultUserId }
});
});
test('DELETE does not delete surveys', async () => {
await fastifyTestInstance.prisma.survey.create({
data: {
userId: defaultUserId,
title: 'Test Survey',
responses: []
}
});
await superDelete('/account/reset-module').send({
blockIds: ['block-one']
});
expect(await fastifyTestInstance.prisma.survey.count()).toBe(1);
await fastifyTestInstance.prisma.survey.deleteMany({
where: { userId: defaultUserId }
});
});
test('DELETE handles multiple blocks in a single call', async () => {
const response = await superDelete('/account/reset-module').send({
blockIds: ['block-one', 'block-two']
});
expect(response.status).toBe(200);
expect(response.body.removedChallengeIds).toEqual(
expect.arrayContaining([
'block-one-challenge-1',
'block-one-challenge-2',
'block-two-challenge-1',
'block-two-challenge-2'
])
);
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: testUserData.email }
});
expect(user?.completedChallenges).toHaveLength(0);
});
test('DELETE dedupes overlapping blockIds', async () => {
const response = await superDelete('/account/reset-module').send({
blockIds: ['block-one', 'block-one']
});
expect(response.status).toBe(200);
expect(response.body.removedChallengeIds).toHaveLength(2);
});
test('DELETE proceeds when only some blockIds are valid', async () => {
const response = await superDelete('/account/reset-module').send({
blockIds: ['block-one', 'non-existent-block']
});
expect(response.status).toBe(200);
expect(response.body.removedChallengeIds).toEqual(
expect.arrayContaining([
'block-one-challenge-1',
'block-one-challenge-2'
])
);
});
test('DELETE only affects the authenticated user', async () => {
await fastifyTestInstance.prisma.user.create({
data: {
...testUserData,
email: 'another@user.com',
completedChallenges: testChallengesBlockOne
}
});
await superDelete('/account/reset-module').send({
blockIds: ['block-one']
});
const otherUser = await fastifyTestInstance.prisma.user.findFirst({
where: { email: 'another@user.com' }
});
expect(otherUser?.completedChallenges).toHaveLength(2);
await fastifyTestInstance.prisma.user.deleteMany({
where: { email: 'another@user.com' }
});
});
test('DELETE returns 400 for non-existent blockId', async () => {
const response = await superDelete('/account/reset-module').send({
blockIds: ['non-existent-block']
});
expect(response.status).toBe(400);
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: testUserData.email }
});
expect(user?.completedChallenges).toHaveLength(4);
});
});
describe('/user/user-token', () => {
beforeEach(async () => {
await fastifyTestInstance.prisma.userToken.create({
@@ -1611,6 +1951,7 @@ Thanks and regards,
{ path: `/users/${otherUserId}`, method: 'DELETE' },
{ path: '/account/delete', method: 'POST' },
{ path: '/account/reset-progress', method: 'POST' },
{ path: '/account/reset-module', method: 'DELETE' },
{ path: '/user/user-token', method: 'DELETE' },
{ path: '/user/user-token', method: 'POST' },
{ path: '/user/ms-username', method: 'DELETE' },
+68 -1
View File
@@ -21,7 +21,11 @@ import {
normalizeBluesky,
removeNulls
} from '../../utils/normalize.js';
import { mapErr, type UpdateReqType } from '../../utils/index.js';
import {
mapErr,
type UpdateReqType,
type UpdateReplyType
} from '../../utils/index.js';
import {
getCalendar,
getPoints,
@@ -35,6 +39,7 @@ import {
getExams
} from '../../exam-environment/routes/exam-environment.js';
import { ERRORS } from '../../exam-environment/utils/errors.js';
import { getChallengeIdsByBlock } from '../../utils/get-challenges.js';
/**
* Helper function to get the api url from the shared transcript link.
@@ -196,6 +201,14 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
return {};
}
);
fastify.delete(
'/account/reset-module',
{
schema: schemas.resetModule
},
deleteResetModule
);
// TODO(Post-MVP): POST -> PUT
fastify.post('/user/user-token', async (req, reply) => {
const logger = fastify.log.child({ req, res: reply });
@@ -577,6 +590,60 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
done();
};
async function deleteResetModule(
this: FastifyInstance,
req: UpdateReqType<typeof schemas.resetModule>,
reply: UpdateReplyType<typeof schemas.resetModule>
) {
const logger = this.log.child({ req, res: reply });
const { blockIds } = req.body;
logger.info(
`User ${req.user?.id} requested module reset for blocks: ${blockIds.join(', ')}`
);
const resetSet = new Set(blockIds.flatMap(getChallengeIdsByBlock));
if (resetSet.size === 0) {
void reply.code(400);
return { message: 'No matching blocks found', type: 'error' };
}
const user = await this.prisma.user.findUniqueOrThrow({
where: { id: req.user!.id },
select: {
completedChallenges: true,
savedChallenges: true,
partiallyCompletedChallenges: true
}
});
const filteredCompletedChallenges = normalizeChallenges(
user.completedChallenges
).filter(c => !resetSet.has(c.id));
const filteredSavedChallenges = user.savedChallenges.filter(
c => !resetSet.has(c.id)
);
const filteredPartiallyCompletedChallenges =
user.partiallyCompletedChallenges.filter(c => !resetSet.has(c.id));
await this.prisma.user.update({
where: { id: req.user!.id },
data: {
completedChallenges: filteredCompletedChallenges,
savedChallenges: filteredSavedChallenges,
partiallyCompletedChallenges: filteredPartiallyCompletedChallenges
},
select: {
id: true
}
});
return { removedChallengeIds: Array.from(resetSet) };
}
/**
* Generate a new authorization token for the given user, and invalidates any existing tokens.
*
+1
View File
@@ -46,6 +46,7 @@ export { getSessionUser } from './schemas/user/get-session-user.js';
export { postMsUsername } from './schemas/user/post-ms-username.js';
export { reportUser } from './schemas/user/report-user.js';
export { resetMyProgress } from './schemas/user/reset-my-progress.js';
export { resetModule } from './schemas/user/reset-module.js';
export { submitSurvey } from './schemas/user/submit-survey.js';
export {
userExamEnvironmentToken,
+21
View File
@@ -0,0 +1,21 @@
import { Type } from '@fastify/type-provider-typebox';
import { genericError } from '../types.js';
export const resetModule = {
body: Type.Object({
blockIds: Type.Array(Type.String({ minLength: 1 }), {
minItems: 1,
maxItems: 500
})
}),
response: {
200: Type.Object({
removedChallengeIds: Type.Array(Type.String())
}),
400: Type.Object({
message: Type.String(),
type: Type.String()
}),
default: genericError
}
};
+26 -1
View File
@@ -1,5 +1,5 @@
import { describe, test, expect } from 'vitest';
import { getChallenges } from './get-challenges.js';
import { getChallenges, getChallengeIdsByBlock } from './get-challenges.js';
import { isObjectID } from './validation.js';
describe('getChallenges', () => {
@@ -21,3 +21,28 @@ describe('getChallenges', () => {
}
});
});
describe('getChallengeIdsByBlock', () => {
test('returns challenge IDs for a valid block', () => {
const ids = getChallengeIdsByBlock('responsive-web-design-principles');
expect(ids).toContain('587d78b0367417b2b2512b08');
});
test('returns a non-empty array of strings', () => {
const ids = getChallengeIdsByBlock('responsive-web-design-principles');
expect(ids.length).toBeGreaterThan(0);
for (const id of ids) {
expect(typeof id).toBe('string');
}
});
test('returns empty array for non-existent block', () => {
const ids = getChallengeIdsByBlock('non-existent-block');
expect(ids).toEqual([]);
});
test('returns empty array for empty string', () => {
const ids = getChallengeIdsByBlock('');
expect(ids).toEqual([]);
});
});
+18
View File
@@ -81,3 +81,21 @@ const examChallenges = challenges.reduce((acc, curr) => {
* @returns A boolean indicating if the challenge id is an exam challenge.
*/
export const isExamId = (id: string): boolean => examChallenges.has(id);
/**
* Get all challenge IDs for a specific block.
* @param blockId The dashedName of the block.
* @returns An array of challenge IDs for the block, or empty array if block not found.
*/
export function getChallengeIdsByBlock(blockId: string): string[] {
const curricula = Object.values(curriculum);
for (const superBlock of curricula) {
const block = superBlock.blocks[blockId];
if (block) {
return block.challenges.map(challenge => challenge.id);
}
}
return [];
}
@@ -571,6 +571,19 @@
"reset": "Reset this lesson?",
"reset-warn": "Are you sure you wish to reset this lesson ({{title}})? The code editors and tests will be reset.",
"reset-warn-2": "This cannot be undone.",
"reset-progress-heading": "Reset Progress for {{label}}",
"reset-progress-description": "This will permanently delete and reset the '{{label}}' section. Your progress through each step/challenge, including any saved code will be lost, forever.",
"reset-progress-warning": "We won't be able to recover any of it for you later, even if you change your mind.",
"reset-progress-nevermind": "Nevermind, I don't want to reset my progress",
"reset-progress-confirm": "Reset my progress. I understand this cannot be undone",
"reset-progress-verify": "I agree to reset my progress",
"reset-progress-aria-chapter": "Reset progress for {{chapterLabel}}",
"reset-progress-aria-module": "Reset progress for {{moduleLabel}}",
"reset-progress-aria-block": "Reset progress for {{blockLabel}}",
"reset-progress-in-flight": "Resetting your progress, please wait...",
"reset-progress-success": "Your progress for '{{label}}' has been reset.",
"reset-progress-failure": "We could not reset your progress. Please try again, or contact support if the problem persists.",
"reset-progress-dismiss": "Dismiss",
"revert-warn": "Are you sure you wish to revert this lesson? Your latest changes will be undone and the code reverted to the most recently saved version.",
"scrimba-tip": "Tip: If the mini-browser is covering the code, click and drag to move it. Also, feel free to stop and edit the code in the video at any time.",
"chal-preview": "Challenge Preview",
@@ -1064,6 +1077,7 @@
"went-wrong": "Something went wrong, please check and try again",
"account-deleted": "Your account has been successfully deleted",
"progress-reset": "Your progress has been reset",
"module-reset": "Your module progress has been reset",
"not-authorized": "You are not authorized to continue on this route",
"could-not-find": "We couldn't find what you were looking for. Please check and try again",
"wrong-updating": "Something went wrong updating your account. Please check and try again",
+4 -16
View File
@@ -1,21 +1,9 @@
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTrashCan } from '@fortawesome/free-solid-svg-icons';
function Reset(
props: JSX.IntrinsicAttributes & React.SVGProps<SVGSVGElement>
): JSX.Element {
return (
<svg
fill='currentColor'
xmlns='http://www.w3.org/2000/svg'
height='1rem'
width='0.9rem'
aria-hidden='true'
{...props}
viewBox='4 4 16 16'
>
<path d='M12 20q-3.35 0-5.675-2.325Q4 15.35 4 12q0-3.35 2.325-5.675Q8.65 4 12 4q1.725 0 3.3.713 1.575.712 2.7 2.037V4h2v7h-7V9h4.2q-.8-1.4-2.187-2.2Q13.625 6 12 6 9.5 6 7.75 7.75T6 12q0 2.5 1.75 4.25T12 18q1.925 0 3.475-1.1T17.65 14h2.1q-.7 2.65-2.85 4.325Q14.75 20 12 20Z' />
</svg>
);
function Reset(): JSX.Element {
return <FontAwesomeIcon icon={faTrashCan} aria-hidden='true' />;
}
Reset.displayName = 'Reset';
+1
View File
@@ -35,6 +35,7 @@ export const actionTypes = createTypes(
'updateComplete',
'updateFailed',
'updateDonationFormState',
'removeModuleChallenges',
'updateUserToken',
'postChargeProcessing',
'updateCardRedirecting',
+3
View File
@@ -79,6 +79,9 @@ export const reportUser = createAction(actionTypes.reportUser);
export const reportUserComplete = createAction(actionTypes.reportUserComplete);
export const reportUserError = createAction(actionTypes.reportUserError);
export const removeModuleChallenges = createAction(
actionTypes.removeModuleChallenges
);
export const resetUserData = createAction(actionTypes.resetUserData);
export const routeUpdated = createAction(actionTypes.routeUpdated);
+27
View File
@@ -264,6 +264,33 @@ export const reducer = handleActions(
...state,
isRandomCompletionThreshold: payload
}),
[actionTypes.removeModuleChallenges]: (
state,
{ payload: { removedChallengeIds } }
) => {
const removedSet = new Set(removedChallengeIds);
const sessionUser = state.user.sessionUser;
if (!sessionUser) return state;
return {
...state,
user: {
...state.user,
sessionUser: {
...sessionUser,
completedChallenges: sessionUser.completedChallenges.filter(
c => !removedSet.has(c.id)
),
savedChallenges: sessionUser.savedChallenges.filter(
c => !removedSet.has(c.id)
),
partiallyCompletedChallenges:
sessionUser.partiallyCompletedChallenges.filter(
c => !removedSet.has(c.id)
)
}
}
};
},
[actionTypes.resetUserData]: state => ({
...state,
user: { ...state.user, sessionUser: null }
@@ -0,0 +1,109 @@
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest';
import { reducer, initialState } from './index.js';
import { actionTypes } from './action-types';
const makeAction = removedChallengeIds => ({
type: actionTypes.removeModuleChallenges,
payload: { removedChallengeIds }
});
const baseSessionUser = {
username: 'testuser',
email: 'test@example.com',
completedChallenges: [
{ id: 'a1', completedDate: 1 },
{ id: 'a2', completedDate: 2 },
{ id: 'a3', completedDate: 3 }
],
savedChallenges: [
{ id: 'a1', files: [] },
{ id: 'b1', files: [] }
],
partiallyCompletedChallenges: [
{ id: 'a2', completedDate: 4 },
{ id: 'b2', completedDate: 5 }
]
};
const stateWithUser = {
...initialState,
user: { ...initialState.user, sessionUser: baseSessionUser }
};
describe('removeModuleChallenges reducer', () => {
it('filters completedChallenges by removed IDs', () => {
const result = reducer(stateWithUser, makeAction(['a1', 'a3']));
expect(result.user.sessionUser.completedChallenges).toEqual([
{ id: 'a2', completedDate: 2 }
]);
});
it('filters savedChallenges by removed IDs', () => {
const result = reducer(stateWithUser, makeAction(['a1']));
expect(result.user.sessionUser.savedChallenges).toEqual([
{ id: 'b1', files: [] }
]);
});
it('filters partiallyCompletedChallenges by removed IDs', () => {
const result = reducer(stateWithUser, makeAction(['a2']));
expect(result.user.sessionUser.partiallyCompletedChallenges).toEqual([
{ id: 'b2', completedDate: 5 }
]);
});
it('returns unchanged state when sessionUser is null', () => {
const nullUserState = {
...initialState,
user: { ...initialState.user, sessionUser: null }
};
const result = reducer(nullUserState, makeAction(['a1']));
expect(result).toBe(nullUserState);
});
it('handles empty removedChallengeIds array', () => {
const result = reducer(stateWithUser, makeAction([]));
expect(result.user.sessionUser.completedChallenges).toEqual(
baseSessionUser.completedChallenges
);
expect(result.user.sessionUser.savedChallenges).toEqual(
baseSessionUser.savedChallenges
);
expect(result.user.sessionUser.partiallyCompletedChallenges).toEqual(
baseSessionUser.partiallyCompletedChallenges
);
});
it('handles IDs not present in any array', () => {
const result = reducer(stateWithUser, makeAction(['zzz', 'yyy']));
expect(result.user.sessionUser.completedChallenges).toEqual(
baseSessionUser.completedChallenges
);
expect(result.user.sessionUser.savedChallenges).toEqual(
baseSessionUser.savedChallenges
);
expect(result.user.sessionUser.partiallyCompletedChallenges).toEqual(
baseSessionUser.partiallyCompletedChallenges
);
});
it('preserves other sessionUser properties', () => {
const result = reducer(stateWithUser, makeAction(['a1']));
expect(result.user.sessionUser.username).toBe('testuser');
expect(result.user.sessionUser.email).toBe('test@example.com');
});
it('filters from all three arrays simultaneously', () => {
const result = reducer(stateWithUser, makeAction(['a1', 'a2']));
expect(result.user.sessionUser.completedChallenges).toEqual([
{ id: 'a3', completedDate: 3 }
]);
expect(result.user.sessionUser.savedChallenges).toEqual([
{ id: 'b1', files: [] }
]);
expect(result.user.sessionUser.partiallyCompletedChallenges).toEqual([
{ id: 'b2', completedDate: 5 }
]);
});
});
@@ -1,9 +1,20 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import BlockHeader from './block-header';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { blockLabel?: string }) => {
if (key === 'learn.reset-progress-aria-block') {
return `Reset progress for ${options?.blockLabel || ''}`;
}
return key;
}
})
}));
const defaultProps = {
blockDashed: 'test-block',
blockTitle: 'Test Block Title',
@@ -171,4 +182,66 @@ describe('<BlockHeader />', () => {
screen.queryByText('Introduction paragraph 1')
).not.toBeInTheDocument();
});
describe('Reset Button', () => {
it('should render reset button', () => {
render(<BlockHeader {...defaultProps} onResetClick={vi.fn()} />);
const resetButton = screen.getByRole('button', {
name: /reset progress for/i
});
expect(resetButton).toBeInTheDocument();
});
it('should disable reset button when isResetDisabled is true', () => {
render(
<BlockHeader
{...defaultProps}
isResetDisabled={true}
onResetClick={vi.fn()}
/>
);
const resetButton = screen.getByRole('button', {
name: /reset progress for/i
});
expect(resetButton).toBeDisabled();
});
it('should enable reset button when isResetDisabled is false', () => {
render(
<BlockHeader
{...defaultProps}
isResetDisabled={false}
onResetClick={vi.fn()}
/>
);
const resetButton = screen.getByRole('button', {
name: /reset progress for/i
});
expect(resetButton).not.toBeDisabled();
});
it('should call onResetClick when reset button is clicked', () => {
const mockOnResetClick = vi.fn();
render(<BlockHeader {...defaultProps} onResetClick={mockOnResetClick} />);
const resetButton = screen.getByRole('button', {
name: /reset progress for/i
});
fireEvent.click(resetButton);
expect(mockOnResetClick).toHaveBeenCalledTimes(1);
});
it('should have correct aria-label with block title', () => {
render(<BlockHeader {...defaultProps} onResetClick={vi.fn()} />);
const resetButton = screen.getByRole('button', {
name: 'Reset progress for Test Block Title'
});
expect(resetButton).toBeInTheDocument();
});
});
});
@@ -1,4 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { isEmpty } from 'lodash';
import { Button } from '@freecodecamp/ui';
import { Link } from '../../../components/helpers';
@@ -6,6 +7,7 @@ import { Link } from '../../../components/helpers';
import type { BlockLabel as BlockLabelType } from '@freecodecamp/shared/config/blocks';
import { ProgressBar } from '../../../components/Progress/progress-bar';
import DropDown from '../../../assets/icons/dropdown';
import Reset from '../../../assets/icons/reset';
import CheckMark from './check-mark';
import BlockLabel from './block-label';
import BlockIntros from './block-intros';
@@ -22,6 +24,8 @@ interface BaseBlockHeaderProps {
percentageCompleted: number;
blockIntroArr?: string[];
accordion?: boolean;
onResetClick?: () => void;
isResetDisabled?: boolean;
}
interface BlockHeaderButtonProps extends BaseBlockHeaderProps {
@@ -48,8 +52,12 @@ function BlockHeader({
blockIntroArr,
accordion,
blockUrl,
onLinkClick
onLinkClick,
onResetClick,
isResetDisabled
}: BlockHeaderProps): JSX.Element {
const { t } = useTranslation();
const InnerBlockHeader = () => (
<>
<span className='block-header-button-text map-title'>
@@ -77,22 +85,38 @@ function BlockHeader({
return (
<>
<h3 className='block-grid-title'>
{accordion && blockUrl ? (
<Link className='block-header' to={blockUrl} onClick={onLinkClick}>
<InnerBlockHeader />
</Link>
) : (
<Button
aria-expanded={isExpanded ? 'true' : 'false'}
aria-controls={`${blockDashed}-panel`}
className='block-header'
onClick={handleClick}
<div className='block-header-wrapper'>
<h3 className='block-grid-title'>
{accordion && blockUrl ? (
<Link className='block-header' to={blockUrl} onClick={onLinkClick}>
<InnerBlockHeader />
</Link>
) : (
<Button
aria-expanded={isExpanded ? 'true' : 'false'}
aria-controls={`${blockDashed}-panel`}
className='block-header'
onClick={handleClick}
data-playwright-test-label='block-header-button'
>
<InnerBlockHeader />
</Button>
)}
</h3>
{onResetClick && (
<button
className='block-reset-button'
onClick={onResetClick}
aria-label={t('learn.reset-progress-aria-block', {
blockLabel: blockTitle
})}
type='button'
disabled={isResetDisabled}
>
<InnerBlockHeader />
</Button>
<Reset />
</button>
)}
</h3>
</div>
{isExpanded && !isEmpty(blockIntroArr) && (
<BlockIntros intros={blockIntroArr as string[]} />
)}
@@ -15,3 +15,79 @@
.accordion-block-expanded .map-challenges-grid {
margin: 0 15px 18px;
}
.block-header-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.block-header-wrapper .block-grid-title {
flex: 1;
min-width: 0;
}
.block-reset-button {
background: transparent;
border: none;
cursor: pointer;
padding: 8px;
margin-left: 8px;
margin-right: 8px;
color: var(--foreground-primary);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
opacity: 0;
transition:
opacity 0.2s,
color 0.2s,
background-color 0.2s;
}
.block-header-wrapper:hover .block-reset-button,
.chapter-header-wrapper:hover .block-reset-button,
.module-header-wrapper:hover .block-reset-button {
opacity: 1;
color: var(--primary-color);
}
.block-reset-button:focus-visible {
opacity: 1;
color: var(--primary-color);
outline: 2px solid var(--focus-outline-color);
outline-offset: 2px;
}
.block-header-wrapper .block-reset-button:hover,
.chapter-header-wrapper .block-reset-button:hover,
.module-header-wrapper .block-reset-button:hover {
color: var(--danger-color);
background-color: var(--quaternary-background);
}
.block-reset-button:disabled {
opacity: 0;
cursor: not-allowed;
}
.block-header-wrapper:hover .block-reset-button:disabled,
.chapter-header-wrapper:hover .block-reset-button:disabled,
.module-header-wrapper:hover .block-reset-button:disabled,
.block-reset-button:disabled:focus-visible {
opacity: 0.3;
color: var(--primary-color);
}
.block-reset-button:disabled:hover {
color: var(--primary-color);
background-color: transparent;
}
@media screen and (max-width: 767px) {
.block-reset-button {
display: none;
}
}
@@ -22,6 +22,21 @@ vi.mock('@freecodecamp/shared/utils/is-audited', () => ({
vi.mock('../../../utils/get-words');
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, string>) => {
if (key === 'learn.reset-progress-aria-block') {
return `Reset progress for ${options?.blockLabel || ''}`;
}
return key;
}
}),
withTranslation:
() =>
<P extends object>(Component: React.ComponentType<P>) =>
Component
}));
const defaultProps = {
block: 'test-block',
blockLabel: null,
@@ -85,7 +100,9 @@ const defaultProps = {
isExpanded: true,
t: vi.fn((key: string) => [key]) as unknown as TFunction,
superBlock: SuperBlocks.FullStackDeveloperV9,
toggleBlock: vi.fn()
toggleBlock: vi.fn(),
resetModule: vi.fn(),
removeModuleChallenges: vi.fn()
};
describe('<Block />', () => {
@@ -96,19 +113,25 @@ describe('<Block />', () => {
it('should expand the block when isExpanded is true and expandAll is false', () => {
(isAuditedSuperBlock as Mock).mockReturnValue(true);
render(<Block {...defaultProps} isExpanded={true} expandAll={false} />);
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('button', { expanded: true })).toHaveAttribute(
'aria-expanded',
'true'
);
});
it('should expand the block when expandAll is true and isExpanded is false', () => {
(isAuditedSuperBlock as Mock).mockReturnValue(true);
render(<Block {...defaultProps} isExpanded={false} expandAll={true} />);
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('button', { expanded: true })).toHaveAttribute(
'aria-expanded',
'true'
);
});
it('should not expand the block when both expandAll and isExpanded are false', () => {
(isAuditedSuperBlock as Mock).mockReturnValue(true);
render(<Block {...defaultProps} isExpanded={false} expandAll={false} />);
expect(screen.getByRole('button')).toHaveAttribute(
expect(screen.getByRole('button', { expanded: false })).toHaveAttribute(
'aria-expanded',
'false'
);
@@ -134,4 +157,37 @@ describe('<Block />', () => {
render(<Block {...defaultProps} />);
expect(screen.getByText(/misc.translation-pending/)).toBeInTheDocument();
});
describe('Reset functionality', () => {
it('renders a reset button for the block', () => {
render(<Block {...defaultProps} />);
const resetButton = screen.getByRole('button', {
name: /reset progress for/i
});
expect(resetButton).toBeInTheDocument();
});
it('disables the reset button when no challenges are completed', () => {
render(<Block {...defaultProps} completedChallengeIds={[]} />);
const resetButton = screen.getByRole('button', {
name: /reset progress for/i
});
expect(resetButton).toBeDisabled();
});
it('calls removeModuleChallenges with correct payload when handleResetConfirm is triggered', () => {
const mockRemove = vi.fn();
render(<Block {...defaultProps} removeModuleChallenges={mockRemove} />);
expect(
screen.getByRole('button', { name: /reset progress for/i })
).toBeInTheDocument();
const removedIds = ['id-1', 'id-2'];
mockRemove({ removedChallengeIds: removedIds });
expect(mockRemove).toHaveBeenCalledWith({
removedChallengeIds: removedIds
});
});
});
});
@@ -20,6 +20,7 @@ import { completedChallengesSelector } from '../../../redux/selectors';
import { playTone } from '../../../utils/tone';
import { makeExpandedBlockSelector, toggleBlock } from '../redux';
import { isProjectBased } from '../../../utils/curriculum-layout';
import { removeModuleChallenges } from '../../../redux/actions';
import {
BlockLayouts,
BlockLabel as BlockLabelType
@@ -32,6 +33,7 @@ import {
} from './challenges';
import BlockLabel from './block-label';
import BlockHeader from './block-header';
import ResetProgressModal from './reset-progress-modal';
import '../intro.css';
import './block.css';
@@ -52,7 +54,7 @@ const mapStateToProps = (state: unknown, ownProps: { block: string }) => {
};
const mapDispatchToProps = (dispatch: Dispatch) =>
bindActionCreators({ toggleBlock }, dispatch);
bindActionCreators({ toggleBlock, removeModuleChallenges }, dispatch);
interface ChallengeInfo {
id: string;
@@ -73,6 +75,7 @@ interface BlockProps {
superBlock: SuperBlocks;
t: TFunction;
toggleBlock: typeof toggleBlock;
removeModuleChallenges: typeof removeModuleChallenges;
accordion?: boolean;
/**
* When true, expands all chapters and modules and hides those with no matching challenges.
@@ -81,13 +84,23 @@ interface BlockProps {
expandAll?: boolean;
}
export class Block extends Component<BlockProps> {
interface BlockState {
showResetModal: boolean;
}
export class Block extends Component<BlockProps, BlockState> {
static displayName: string;
constructor(props: BlockProps) {
super(props);
this.state = {
showResetModal: false
};
this.handleBlockClick = this.handleBlockClick.bind(this);
this.handleChallengeClick = this.handleChallengeClick.bind(this);
this.handleResetClick = this.handleResetClick.bind(this);
this.handleResetConfirm = this.handleResetConfirm.bind(this);
this.handleResetModalClose = this.handleResetModalClose.bind(this);
}
handleBlockClick = (): void => {
@@ -105,6 +118,19 @@ export class Block extends Component<BlockProps> {
window.history.pushState(null, '', `#${dashedBlock}`);
};
handleResetClick = (): void => {
this.setState({ showResetModal: true });
};
handleResetConfirm = (removedChallengeIds: string[]): void => {
const { removeModuleChallenges } = this.props;
removeModuleChallenges({ removedChallengeIds });
};
handleResetModalClose = (): void => {
this.setState({ showResetModal: false });
};
render(): ReactNode {
const {
block,
@@ -283,6 +309,8 @@ export class Block extends Component<BlockProps> {
percentageCompleted={percentageCompleted}
accordion={accordion}
blockIntroArr={!accordion ? blockIntroArr : undefined}
isResetDisabled={completedCount === 0}
onResetClick={this.handleResetClick}
/>
{isExpanded && (
@@ -333,6 +361,8 @@ export class Block extends Component<BlockProps> {
percentageCompleted={percentageCompleted}
accordion={accordion}
blockIntroArr={!accordion ? blockIntroArr : undefined}
isResetDisabled={completedCount === 0}
onResetClick={this.handleResetClick}
/>
{isExpanded && (
@@ -430,6 +460,8 @@ export class Block extends Component<BlockProps> {
isExpanded={isExpanded}
percentageCompleted={percentageCompleted}
accordion={accordion}
isResetDisabled={completedCount === 0}
onResetClick={this.handleResetClick}
/>
{isExpanded && (
@@ -487,6 +519,8 @@ export class Block extends Component<BlockProps> {
percentageCompleted={percentageCompleted}
accordion={accordion}
blockIntroArr={blockIntroArr}
isResetDisabled={completedCount === 0}
onResetClick={this.handleResetClick}
/>
{isExpanded && (
@@ -544,6 +578,8 @@ export class Block extends Component<BlockProps> {
percentageCompleted={percentageCompleted}
accordion={accordion}
blockUrl={challenges?.[0]?.fields?.slug ?? ''}
isResetDisabled={completedCount === 0}
onResetClick={this.handleResetClick}
/>
</>
);
@@ -566,6 +602,14 @@ export class Block extends Component<BlockProps> {
{!chapterBasedSuperBlocks.includes(superBlock) && (
<Spacer size='xs' />
)}
<ResetProgressModal
blockTitle={blockTitle}
blockDashedName={block}
superBlock={superBlock}
show={this.state.showResetModal}
onHide={this.handleResetModalClose}
onResetComplete={this.handleResetConfirm}
/>
</>
)
);
@@ -0,0 +1,368 @@
import React from 'react';
import {
render,
screen,
fireEvent,
waitFor,
act
} from '@testing-library/react';
import {
describe,
it,
expect,
vi,
beforeAll,
beforeEach,
afterEach
} from 'vitest';
import { SuperBlocks } from '@freecodecamp/shared/config/curriculum';
import ResetProgressModal from './reset-progress-modal';
import { deleteResetModule, type ResponseWithData } from '../../../utils/ajax';
type ResetResponse = ResponseWithData<{ removedChallengeIds: string[] }>;
const mockResponse = (
ok: boolean,
status: number,
statusText: string,
removedChallengeIds: string[] = []
): ResetResponse =>
({
response: { ok, status, statusText },
data: { removedChallengeIds }
}) as ResetResponse;
type ResizeObserverMockInstance = {
observe: ResizeObserver['observe'];
unobserve: ResizeObserver['unobserve'];
disconnect: ResizeObserver['disconnect'];
};
beforeAll(() => {
Object.defineProperty(window, 'ResizeObserver', {
writable: true,
value: vi.fn(function (
this: ResizeObserverMockInstance,
_cb: ResizeObserverCallback
) {
this.observe = vi.fn();
this.unobserve = vi.fn();
this.disconnect = vi.fn();
})
});
});
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, string | number | undefined>) => {
const translations: Record<string, string> = {
'learn.reset-progress-heading': `Reset Progress for ${options?.label || ''}`,
'learn.reset-progress-description': `This will permanently delete progress for ${options?.label || ''}:`,
'learn.reset-progress-warning': 'Cannot be recovered.',
'learn.reset-progress-nevermind': 'Nevermind',
'learn.reset-progress-confirm': 'Reset my progress',
'learn.reset-progress-verify': 'I agree to reset my progress',
'learn.reset-progress-in-flight': 'Resetting your progress…',
'learn.reset-progress-success': `Progress for '${options?.label || ''}' has been reset.`,
'learn.reset-progress-failure': 'We could not reset your progress.',
'learn.reset-progress-dismiss': 'Dismiss',
'settings.danger.verify-text': `Type "${options?.verifyText || ''}" to verify`
};
return translations[key] || key;
}
})
}));
vi.mock('../../../utils/ajax', () => ({
deleteResetModule: vi.fn().mockResolvedValue({
response: { ok: true, status: 200, statusText: 'OK' },
data: { removedChallengeIds: ['challenge-1', 'challenge-2'] }
})
}));
const mockOnHide = vi.fn();
const mockOnResetComplete = vi.fn();
const defaultProps = {
blockTitle: 'Basic HTML',
blockDashedName: 'basic-html',
superBlock: SuperBlocks.RespWebDesign,
onHide: mockOnHide,
onResetComplete: mockOnResetComplete,
show: true
};
const renderModal = (props = {}) => {
return render(<ResetProgressModal {...defaultProps} {...props} />);
};
const typeVerifyPhrase = () => {
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'I agree to reset my progress' }
});
};
const clickConfirm = () => {
fireEvent.click(screen.getByRole('button', { name: /reset my progress/i }));
};
describe('ResetProgressModal', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders when show is true', () => {
renderModal();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('displays the block title in the heading', () => {
renderModal();
expect(
screen.getByText(/Reset Progress for Basic HTML/)
).toBeInTheDocument();
});
it('disables the reset button when verify text is empty', () => {
renderModal();
const resetButton = screen.getByRole('button', {
name: /reset my progress/i
});
expect(resetButton).toHaveAttribute('aria-disabled', 'true');
});
it('enables the reset button when the correct phrase is typed', () => {
renderModal();
typeVerifyPhrase();
expect(
screen.getByRole('button', { name: /reset my progress/i })
).not.toHaveAttribute('aria-disabled', 'true');
});
it('sends a single bulk DELETE with the wrapped blockId', async () => {
renderModal();
typeVerifyPhrase();
clickConfirm();
await waitFor(() => {
expect(deleteResetModule).toHaveBeenCalledTimes(1);
});
expect(deleteResetModule).toHaveBeenCalledWith({
blockIds: ['basic-html']
});
});
it('sends every blockId in one call when given an array', async () => {
renderModal({ blockDashedName: ['block-a', 'block-b'] });
typeVerifyPhrase();
clickConfirm();
await waitFor(() => {
expect(deleteResetModule).toHaveBeenCalledTimes(1);
});
expect(deleteResetModule).toHaveBeenCalledWith({
blockIds: ['block-a', 'block-b']
});
});
it('shows the success state with a dismiss button after a successful reset', async () => {
renderModal();
typeVerifyPhrase();
clickConfirm();
await waitFor(
() => {
expect(
screen.getByText(/Progress for 'Basic HTML' has been reset/)
).toBeInTheDocument();
},
{ timeout: 2000 }
);
expect(mockOnResetComplete).toHaveBeenCalledWith([
'challenge-1',
'challenge-2'
]);
expect(
screen.getByRole('button', { name: /^dismiss$/i })
).toBeInTheDocument();
expect(mockOnHide).not.toHaveBeenCalled();
});
it('closes when the dismiss button is clicked from success state', async () => {
renderModal();
typeVerifyPhrase();
clickConfirm();
const dismissButton = await screen.findByRole(
'button',
{ name: /dismiss/i },
{ timeout: 2000 }
);
fireEvent.click(dismissButton);
expect(mockOnHide).toHaveBeenCalledTimes(1);
});
it('shows the error state when the API responds non-ok', async () => {
vi.mocked(deleteResetModule).mockResolvedValueOnce(
mockResponse(false, 500, 'Server Error')
);
renderModal();
typeVerifyPhrase();
clickConfirm();
await waitFor(
() => {
expect(
screen.getByText(/We could not reset your progress/)
).toBeInTheDocument();
},
{ timeout: 2000 }
);
expect(
screen.getByRole('button', { name: /dismiss/i })
).toBeInTheDocument();
expect(mockOnResetComplete).not.toHaveBeenCalled();
expect(mockOnHide).not.toHaveBeenCalled();
});
it('keeps the in-flight state visible for at least 1 second even when the API is fast', async () => {
vi.useFakeTimers();
try {
renderModal();
typeVerifyPhrase();
clickConfirm();
await act(async () => {
await Promise.resolve();
});
expect(screen.getByText(/Resetting your progress/)).toBeInTheDocument();
expect(
screen.queryByText(/Progress for 'Basic HTML' has been reset/)
).not.toBeInTheDocument();
await act(async () => {
await vi.advanceTimersByTimeAsync(900);
});
expect(screen.getByText(/Resetting your progress/)).toBeInTheDocument();
await act(async () => {
await vi.advanceTimersByTimeAsync(200);
});
expect(
screen.getByText(/Progress for 'Basic HTML' has been reset/)
).toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
it('does not auto-close the resolved modal — requires explicit dismiss', async () => {
vi.useFakeTimers();
try {
renderModal();
typeVerifyPhrase();
clickConfirm();
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
expect(
screen.getByRole('button', { name: /^dismiss$/i })
).toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(60_000);
});
expect(mockOnHide).not.toHaveBeenCalled();
expect(
screen.getByRole('button', { name: /^dismiss$/i })
).toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
it('calls onHide and not onResetComplete when nevermind is clicked', () => {
renderModal();
fireEvent.click(screen.getByRole('button', { name: /nevermind/i }));
expect(mockOnHide).toHaveBeenCalled();
expect(mockOnResetComplete).not.toHaveBeenCalled();
expect(deleteResetModule).not.toHaveBeenCalled();
});
it('shows the loader and hides action buttons while resetting', async () => {
let resolveReset!: (value: ResetResponse) => void;
vi.mocked(deleteResetModule).mockReturnValueOnce(
new Promise<ResetResponse>(resolve => {
resolveReset = resolve;
})
);
renderModal();
typeVerifyPhrase();
clickConfirm();
await waitFor(() => {
expect(screen.getByText(/Resetting your progress/)).toBeInTheDocument();
});
expect(
screen.queryByRole('button', { name: /reset my progress/i })
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /nevermind/i })
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /dismiss/i })
).not.toBeInTheDocument();
resolveReset(mockResponse(true, 200, 'OK'));
await waitFor(
() => {
expect(
screen.getByRole('button', { name: /dismiss/i })
).toBeInTheDocument();
},
{ timeout: 2000 }
);
});
it('ignores close attempts while resetting', async () => {
let resolveReset!: (value: ResetResponse) => void;
vi.mocked(deleteResetModule).mockReturnValueOnce(
new Promise<ResetResponse>(resolve => {
resolveReset = resolve;
})
);
renderModal();
typeVerifyPhrase();
clickConfirm();
await waitFor(() => {
expect(screen.getByText(/Resetting your progress/)).toBeInTheDocument();
});
expect(mockOnHide).not.toHaveBeenCalled();
resolveReset(mockResponse(true, 200, 'OK'));
await waitFor(
() => {
expect(
screen.getByRole('button', { name: /dismiss/i })
).toBeInTheDocument();
},
{ timeout: 2000 }
);
expect(mockOnHide).not.toHaveBeenCalled();
});
});
afterEach(() => {
vi.useRealTimers();
});
@@ -0,0 +1,205 @@
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Alert,
Button,
ControlLabel,
FormControl,
FormGroup,
Modal,
Spacer
} from '@freecodecamp/ui';
import { type SuperBlocks } from '@freecodecamp/shared/config/curriculum';
import { deleteResetModule } from '../../../utils/ajax';
import Loader from '../../../components/helpers/loader';
const MIN_IN_FLIGHT_MS = 1000;
const STATE_RESET_AFTER_CLOSE_MS = 300;
type Status = 'idle' | 'in-flight' | 'success' | 'error';
type ResetProgressModalProps = {
blockTitle: string;
blockDashedName: string | string[];
superBlock: SuperBlocks;
onHide: () => void;
onResetComplete: (removedChallengeIds: string[]) => void;
show: boolean;
};
function ResetProgressModal({
blockTitle,
blockDashedName,
superBlock: _superBlock,
onHide,
onResetComplete,
show
}: ResetProgressModalProps): JSX.Element {
const { t } = useTranslation();
const [verifyText, setVerifyText] = useState('');
const [status, setStatus] = useState<Status>('idle');
const isMountedRef = useRef(true);
useEffect(
() => () => {
isMountedRef.current = false;
},
[]
);
const verifyPhrase = t('learn.reset-progress-verify');
const blockIds = Array.isArray(blockDashedName)
? blockDashedName
: [blockDashedName];
const handleDismiss = () => {
onHide();
};
useEffect(() => {
if (show) return;
const id = window.setTimeout(() => {
setStatus('idle');
setVerifyText('');
}, STATE_RESET_AFTER_CLOSE_MS);
return () => clearTimeout(id);
}, [show]);
const handleVerifyTextChange = (
event: React.ChangeEvent<HTMLInputElement>
) => {
setVerifyText(event.target.value);
};
const handleReset = async () => {
setStatus('in-flight');
const startedAt = Date.now();
let nextStatus: Status = 'success';
try {
const { response, data } = await deleteResetModule({ blockIds });
if (!response.ok) {
throw new Error(
`HTTP Error: ${response.status} ${response.statusText}`
);
}
onResetComplete(data.removedChallengeIds ?? []);
} catch (err) {
console.error('Failed to reset module:', err);
nextStatus = 'error';
}
const elapsed = Date.now() - startedAt;
if (elapsed < MIN_IN_FLIGHT_MS) {
await new Promise(resolve =>
setTimeout(resolve, MIN_IN_FLIGHT_MS - elapsed)
);
}
if (isMountedRef.current) {
setStatus(nextStatus);
}
};
const handleHeaderClose = () => {
if (status === 'in-flight') return;
handleDismiss();
};
const showCloseButton = status !== 'in-flight';
const isResolved = status === 'success' || status === 'error';
return (
<Modal
size='large'
onClose={handleHeaderClose}
variant='danger'
open={show}
>
<Modal.Header
showCloseButton={showCloseButton}
closeButtonClassNames='close'
>
{t('learn.reset-progress-heading', { label: blockTitle })}
</Modal.Header>
<Modal.Body alignment='start' borderless>
<Spacer size='xs' />
{status === 'in-flight' && (
<div className='reset-progress-container text-center'>
<Loader />
<Spacer size='xs' />
<p>{t('learn.reset-progress-in-flight')}</p>
</div>
)}
{status === 'success' && (
<Alert variant='success'>
{t('learn.reset-progress-success', { label: blockTitle })}
</Alert>
)}
{status === 'error' && (
<Alert variant='danger'>{t('learn.reset-progress-failure')}</Alert>
)}
{status === 'idle' && (
<>
<p>
{t('learn.reset-progress-description', { label: blockTitle })}
</p>
<p>{t('learn.reset-progress-warning')}</p>
</>
)}
</Modal.Body>
<Modal.Footer>
{isResolved && (
<Button
block={true}
size='large'
variant='primary'
onClick={handleDismiss}
type='button'
>
{t('learn.reset-progress-dismiss')}
</Button>
)}
{status === 'idle' && (
<>
<Button
block={true}
size='large'
variant='primary'
onClick={handleDismiss}
type='button'
>
{t('learn.reset-progress-nevermind')}
</Button>
<Spacer size='xs' />
<FormGroup controlId='verify-reset-progress'>
<ControlLabel htmlFor='verify-reset-progress-input'>
{t('settings.danger.verify-text', {
verifyText: verifyPhrase
})}
</ControlLabel>
<Spacer size='xs' />
<FormControl
onChange={handleVerifyTextChange}
value={verifyText}
id='verify-reset-progress-input'
/>
</FormGroup>
<Spacer size='xs' />
<Button
block={true}
size='large'
variant='danger'
disabled={verifyText !== verifyPhrase}
onClick={() => void handleReset()}
type='button'
>
{t('learn.reset-progress-confirm')}
</Button>
</>
)}
</Modal.Footer>
</Modal>
);
}
ResetProgressModal.displayName = 'ResetProgressModal';
export default ResetProgressModal;
@@ -22,13 +22,45 @@
margin-bottom: 16px;
}
.super-block-accordion .chapter-header-wrapper,
.super-block-accordion .module-header-wrapper {
display: flex;
align-items: center;
}
.super-block-accordion .chapter-header-wrapper .chapter-button-main,
.super-block-accordion .module-header-wrapper .module-button-main {
flex: 1;
min-width: 0;
}
.super-block-accordion .chapter-header-wrapper .chapter-button-toggle,
.super-block-accordion .module-header-wrapper .module-button-toggle {
flex-shrink: 0;
}
.super-block-accordion .chapter-header-wrapper {
background-color: var(--primary-background);
}
.super-block-accordion .chapter-button {
border: none;
background-color: var(--primary-background);
width: 100%;
background-color: transparent;
padding: 16px;
display: flex;
align-items: center;
}
.super-block-accordion .chapter-button-toggle {
padding-left: 0;
}
.super-block-accordion .chapter-link {
text-decoration: none;
display: flex;
align-items: center;
background-color: var(--primary-background);
padding: 16px;
justify-content: space-between;
}
@@ -41,7 +73,8 @@
max-height: 40px;
}
.super-block-accordion .chapter-button .chapter-button-left {
.super-block-accordion .chapter-button .chapter-button-left,
.super-block-accordion .chapter-link .chapter-button-left {
display: flex;
align-items: center;
text-align: start;
@@ -71,7 +104,8 @@ button .block-header-button-text {
justify-content: start;
}
.super-block-accordion .chapter-button .chapter-button-right {
.super-block-accordion .chapter-button .chapter-button-right,
.super-block-accordion .chapter-link .chapter-button-right {
display: flex;
align-items: center;
column-gap: 15px;
@@ -91,14 +125,21 @@ button .block-header-button-text {
.super-block-accordion .block-header {
border: none;
background-color: transparent;
width: 100%;
padding: 10px;
display: flex;
align-items: center;
justify-content: space-between;
font-weight: normal;
gap: 10px;
color: var(--primary-text);
color: var(--primary-color);
}
.super-block-accordion .module-button-toggle {
padding-left: 0;
}
.super-block-accordion .block-header {
width: 100%;
justify-content: space-between;
}
.super-block-accordion .module-button-left,
@@ -121,11 +162,24 @@ button .block-header-button-text {
color: var(--quaternary-color);
}
.super-block-accordion .chapter-button:hover,
.super-block-accordion .module-button:hover,
.super-block-accordion .chapter-header-wrapper:hover,
.super-block-accordion .module-header-wrapper:hover {
background-color: var(--tertiary-background);
}
.super-block-accordion .chapter-header-wrapper:hover .chapter-button,
.super-block-accordion .module-header-wrapper:hover .module-button {
color: var(--primary-color);
}
.super-block-accordion .chapter-link:hover {
background-color: var(--tertiary-background);
color: var(--primary-color);
}
.super-block-accordion .block-header:hover {
background-color: var(--tertiary-background);
color: var(--primary-text);
color: var(--primary-color);
}
.super-block-accordion .chapter-button[aria-expanded='false'] .dropdown-icon,
@@ -1,7 +1,9 @@
import React from 'react';
import { render, screen, within } from '@testing-library/react';
import { render, screen, within, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { SuperBlocks } from '@freecodecamp/shared/config/curriculum';
import { SuperBlockAccordion } from './super-block-accordion';
import { BlockLabel, BlockLayouts } from '@freecodecamp/shared/config/blocks';
@@ -11,6 +13,58 @@ vi.mock('./block', () => ({
React.createElement('div', { 'data-testid': `block-${block}` })
}));
vi.mock('./reset-progress-modal', () => ({
default: ({
show,
blockTitle
}: {
show: boolean;
blockTitle: string;
onHide: () => void;
onResetComplete: (ids: string[]) => void;
}) =>
show ? (
<div role='dialog' aria-label={`Reset ${blockTitle}`}>
Mock Reset Modal
</div>
) : null
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, string>) => {
// Only translate aria labels for reset buttons, return key for everything else
if (key === 'learn.reset-progress-aria-chapter') {
return `Reset progress for ${options?.chapterLabel || ''}`;
}
if (key === 'learn.reset-progress-aria-module') {
return `Reset progress for ${options?.moduleLabel || ''}`;
}
if (key.startsWith('intro:')) {
return key.split('.').pop() || key;
}
return key;
}
}),
withTranslation:
() =>
<P extends object>(Component: React.ComponentType<P>) =>
Component
}));
// Create a minimal mock store for testing
const createMockStore = () =>
configureStore({
reducer: {
app: () => ({})
}
});
const renderWithProvider = (ui: React.ReactElement) => {
const store = createMockStore();
return render(<Provider store={store}>{ui}</Provider>);
};
const mockStructure = {
superBlock: SuperBlocks.RespWebDesign,
chapters: [
@@ -41,7 +95,7 @@ const mockChallenge = {
describe('SuperBlockAccordion', () => {
it('does not show completed checkmark when there are zero challenges in a chapter', () => {
render(
renderWithProvider(
<SuperBlockAccordion
challenges={[]}
superBlock={SuperBlocks.RespWebDesign}
@@ -81,7 +135,7 @@ describe('SuperBlockAccordion', () => {
]
};
render(
renderWithProvider(
<SuperBlockAccordion
challenges={[mockChallenge]}
superBlock={SuperBlocks.RespWebDesign}
@@ -91,12 +145,8 @@ describe('SuperBlockAccordion', () => {
/>
);
const moduleButtons = screen.getAllByRole('button', {
name: /test-module/i
});
const moduleButton = moduleButtons[0];
const moduleRight = within(moduleButton).getByTestId('module-button-right');
// The module-button-right is now a separate toggle button with the testid
const moduleRight = screen.getByTestId('module-button-right');
const moduleSteps = within(moduleRight).getByText(
/learn\.steps-completed/i
);
@@ -124,7 +174,7 @@ describe('SuperBlockAccordion', () => {
]
};
render(
renderWithProvider(
<SuperBlockAccordion
challenges={[mockChallenge]}
superBlock={SuperBlocks.RespWebDesign}
@@ -134,12 +184,8 @@ describe('SuperBlockAccordion', () => {
/>
);
const moduleButtons = screen.getAllByRole('button', {
name: /test-module/i
});
const moduleButton = moduleButtons[0];
const moduleRight = within(moduleButton).getByTestId('module-button-right');
// The module-button-right is now a separate toggle button with the testid
const moduleRight = screen.getByTestId('module-button-right');
expect(within(moduleRight).queryByText(/steps/i)).not.toBeInTheDocument();
});
@@ -163,7 +209,7 @@ describe('SuperBlockAccordion', () => {
]
};
render(
renderWithProvider(
<SuperBlockAccordion
challenges={[]}
superBlock={SuperBlocks.RespWebDesign}
@@ -173,15 +219,305 @@ describe('SuperBlockAccordion', () => {
/>
);
const moduleButtons = screen.getAllByRole('button', {
name: /test-module/i
});
const moduleButton = moduleButtons[0];
const moduleRight = within(moduleButton).getByTestId('module-button-right');
// The module-button-right is now a separate toggle button with the testid
const moduleRight = screen.getByTestId('module-button-right');
expect(within(moduleRight).queryByText(/steps/i)).not.toBeInTheDocument();
});
describe('Reset Button', () => {
it('renders chapter reset button when chapter has progress', () => {
const structureWithProgress = {
superBlock: SuperBlocks.RespWebDesign,
chapters: [
{
dashedName: 'test-chapter',
comingSoon: false,
modules: [
{
dashedName: 'test-module',
blocks: ['test-block'],
comingSoon: false,
totalSteps: 10,
completedSteps: 5
}
]
}
]
};
renderWithProvider(
<SuperBlockAccordion
challenges={[mockChallenge]}
superBlock={SuperBlocks.RespWebDesign}
structure={structureWithProgress}
chosenBlock={''}
completedChallengeIds={['test-challenge-id']}
/>
);
const resetButton = screen.getByRole('button', {
name: /reset progress for.*test-chapter/i
});
expect(resetButton).toBeInTheDocument();
});
it('disables chapter reset button when no challenges are completed', () => {
const structureNoProgress = {
superBlock: SuperBlocks.RespWebDesign,
chapters: [
{
dashedName: 'test-chapter',
comingSoon: false,
modules: [
{
dashedName: 'test-module',
blocks: ['test-block'],
comingSoon: false,
totalSteps: 10,
completedSteps: 0
}
]
}
]
};
renderWithProvider(
<SuperBlockAccordion
challenges={[mockChallenge]}
superBlock={SuperBlocks.RespWebDesign}
structure={structureNoProgress}
chosenBlock={''}
completedChallengeIds={[]}
/>
);
const resetButton = screen.getByRole('button', {
name: /reset progress for.*test-chapter/i
});
expect(resetButton).toBeDisabled();
});
it('does not render chapter reset button when chapter is comingSoon', () => {
const structureComingSoon = {
superBlock: SuperBlocks.RespWebDesign,
chapters: [
{
dashedName: 'test-chapter',
comingSoon: true,
modules: [
{
dashedName: 'test-module',
blocks: ['test-block'],
comingSoon: true,
totalSteps: 10,
completedSteps: 5
}
]
}
]
};
renderWithProvider(
<SuperBlockAccordion
challenges={[mockChallenge]}
superBlock={SuperBlocks.RespWebDesign}
structure={structureComingSoon}
chosenBlock={''}
completedChallengeIds={['test-challenge-id']}
/>
);
const resetButton = screen.queryByRole('button', {
name: /reset progress for.*test-chapter/i
});
expect(resetButton).not.toBeInTheDocument();
});
it('renders module reset button when module has progress', () => {
const structureWithProgress = {
superBlock: SuperBlocks.RespWebDesign,
chapters: [
{
dashedName: 'test-chapter',
comingSoon: false,
modules: [
{
dashedName: 'test-module',
blocks: ['test-block'],
comingSoon: false,
totalSteps: 10,
completedSteps: 5
}
]
}
]
};
renderWithProvider(
<SuperBlockAccordion
challenges={[mockChallenge]}
superBlock={SuperBlocks.RespWebDesign}
structure={structureWithProgress}
chosenBlock={''}
completedChallengeIds={['test-challenge-id']}
/>
);
const resetButton = screen.getByRole('button', {
name: /reset progress for.*test-module/i
});
expect(resetButton).toBeInTheDocument();
});
it('disables module reset button when no steps are completed', () => {
const structureNoProgress = {
superBlock: SuperBlocks.RespWebDesign,
chapters: [
{
dashedName: 'test-chapter',
comingSoon: false,
modules: [
{
dashedName: 'test-module',
blocks: ['test-block'],
comingSoon: false,
totalSteps: 10,
completedSteps: 0
}
]
}
]
};
renderWithProvider(
<SuperBlockAccordion
challenges={[mockChallenge]}
superBlock={SuperBlocks.RespWebDesign}
structure={structureNoProgress}
chosenBlock={''}
completedChallengeIds={[]}
/>
);
const resetButton = screen.getByRole('button', {
name: /reset progress for.*test-module/i
});
expect(resetButton).toBeDisabled();
});
it('does not render module reset button when module is comingSoon', () => {
const structureComingSoon = {
superBlock: SuperBlocks.RespWebDesign,
chapters: [
{
dashedName: 'test-chapter',
comingSoon: false,
modules: [
{
dashedName: 'test-module',
blocks: ['test-block'],
comingSoon: true,
totalSteps: 10,
completedSteps: 5
}
]
}
]
};
renderWithProvider(
<SuperBlockAccordion
challenges={[mockChallenge]}
superBlock={SuperBlocks.RespWebDesign}
structure={structureComingSoon}
chosenBlock={''}
completedChallengeIds={['test-challenge-id']}
/>
);
const resetButton = screen.queryByRole('button', {
name: /reset progress for.*test-module/i
});
expect(resetButton).not.toBeInTheDocument();
});
it('opens reset modal when chapter reset button is clicked', () => {
const structureWithProgress = {
superBlock: SuperBlocks.RespWebDesign,
chapters: [
{
dashedName: 'test-chapter',
comingSoon: false,
modules: [
{
dashedName: 'test-module',
blocks: ['test-block'],
comingSoon: false,
totalSteps: 10,
completedSteps: 5
}
]
}
]
};
renderWithProvider(
<SuperBlockAccordion
challenges={[mockChallenge]}
superBlock={SuperBlocks.RespWebDesign}
structure={structureWithProgress}
chosenBlock={''}
completedChallengeIds={['test-challenge-id']}
/>
);
const resetButton = screen.getByRole('button', {
name: /reset progress for.*test-chapter/i
});
fireEvent.click(resetButton);
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('opens reset modal when module reset button is clicked', () => {
const structureWithProgress = {
superBlock: SuperBlocks.RespWebDesign,
chapters: [
{
dashedName: 'test-chapter',
comingSoon: false,
modules: [
{
dashedName: 'test-module',
blocks: ['test-block'],
comingSoon: false,
totalSteps: 10,
completedSteps: 5
}
]
}
]
};
renderWithProvider(
<SuperBlockAccordion
challenges={[mockChallenge]}
superBlock={SuperBlocks.RespWebDesign}
structure={structureWithProgress}
chosenBlock={''}
completedChallengeIds={['test-challenge-id']}
/>
);
const resetButton = screen.getByRole('button', {
name: /reset progress for.*test-module/i
});
fireEvent.click(resetButton);
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
});
it('should expand all chapters when expandAll is true', () => {
const multiChapterStructure = {
superBlock: SuperBlocks.RespWebDesign,
@@ -197,7 +533,7 @@ describe('SuperBlockAccordion', () => {
]
};
render(
renderWithProvider(
<SuperBlockAccordion
challenges={[
{ ...mockChallenge, block: 'block-one', id: 'id-1' },
@@ -211,13 +547,13 @@ describe('SuperBlockAccordion', () => {
/>
);
// When expandAll=true, both chapters are open so their module buttons are visible
const moduleButtons = screen.getAllByRole('button', { name: /mod/i });
expect(moduleButtons).toHaveLength(2);
// When expandAll=true, both chapters are open so their module main buttons are visible
expect(screen.getByRole('button', { name: 'mod-one' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'mod-two' })).toBeInTheDocument();
});
it('should not render a module when all its challenges are filtered out', () => {
render(
renderWithProvider(
<SuperBlockAccordion
// Only challenges for block-one are passed; mod-two has no challenges
challenges={[{ ...mockChallenge, block: 'block-one', id: 'id-1' }]}
@@ -240,14 +576,12 @@ describe('SuperBlockAccordion', () => {
/>
);
// mod-one has a challenge — its button should render
expect(
screen.getByRole('button', { name: /mod-one/i })
).toBeInTheDocument();
// mod-one has a challenge — its main module button should render
expect(screen.getByRole('button', { name: 'mod-one' })).toBeInTheDocument();
// mod-two has no challenges — its button should not render
// mod-two has no challenges — its main module button should not render
expect(
screen.queryByRole('button', { name: /mod-two/i })
screen.queryByRole('button', { name: 'mod-two' })
).not.toBeInTheDocument();
});
});
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { SuperBlocks } from '@freecodecamp/shared/config/curriculum';
import DropDown from '../../../assets/icons/dropdown';
import Reset from '../../../assets/icons/reset';
import type { ChapterBasedSuperBlockStructure } from '../../../redux/prop-types';
import { ChapterIcon } from '../../../assets/chapter-icon';
import { type Chapter } from '@freecodecamp/shared/config/chapters';
@@ -11,9 +12,12 @@ import { BlockLayouts, BlockLabel } from '@freecodecamp/shared/config/blocks';
import { FsdChapters } from '@freecodecamp/shared/config/chapters';
import { type Module } from '@freecodecamp/shared/config/modules';
import envData from '../../../../config/env.json';
import { useDispatch } from 'react-redux';
import { removeModuleChallenges } from '../../../redux/actions';
import Block from './block';
import CheckMark from './check-mark';
import { default as BlockLabelComponent } from './block-label';
import ResetProgressModal from './reset-progress-modal';
import './super-block-accordion.css';
@@ -29,6 +33,8 @@ interface ChapterProps {
superBlock: SuperBlocks;
isLinkChapter?: boolean;
examSlug?: string;
blockDashedNames: string[];
onResetComplete: (removedChallengeIds: string[]) => void;
}
interface ModuleProps {
@@ -39,6 +45,8 @@ interface ModuleProps {
completedSteps: number;
superBlock: SuperBlocks;
comingSoon: boolean;
blockDashedNames: string[];
onResetComplete: (removedChallengeIds: string[]) => void;
}
interface Challenge {
@@ -94,55 +102,89 @@ const Chapter = ({
completedSteps,
superBlock,
isLinkChapter,
examSlug
examSlug,
blockDashedNames,
onResetComplete
}: ChapterProps) => {
const { t } = useTranslation();
const [open, setOpen] = useState(isExpanded);
const [showResetModal, setShowResetModal] = useState(false);
useEffect(() => {
setOpen(isExpanded);
}, [isExpanded]);
const panelId = `chapter-panel-${dashedName}`;
const isComplete = completedSteps === totalSteps && totalSteps > 0;
const chapterLabel = t(`intro:${superBlock}.chapters.${dashedName}`);
const toggleLabel = open
? t('intro:misc-text.collapse')
: t('intro:misc-text.expand');
const showResetButton = !comingSoon && !isLinkChapter;
const isResetDisabled = completedSteps === 0;
const chapterButtonContent = (
<>
<div className='chapter-button-left'>
<span className='checkmark-wrap chapter-checkmark-wrap'>
<CheckMark isCompleted={isComplete} />
const handleResetClick = (e: React.MouseEvent) => {
e.stopPropagation();
setShowResetModal(true);
};
const handleResetModalClose = () => {
setShowResetModal(false);
};
const toggleOpen = () => setOpen(o => !o);
const resetButton = showResetButton ? (
<button
className='block-reset-button'
onClick={handleResetClick}
aria-label={t('learn.reset-progress-aria-chapter', {
chapterLabel
})}
type='button'
disabled={isResetDisabled}
>
<Reset />
</button>
) : null;
const chapterButtonLeftContent = (
<div className='chapter-button-left'>
<span className='checkmark-wrap chapter-checkmark-wrap'>
<CheckMark isCompleted={isComplete} />
</span>
<ChapterIcon className='map-icon' chapter={dashedName as FsdChapters} />
{chapterLabel}
{isLinkChapter && examSlug && (
<BlockLabelComponent blockLabel={BlockLabel.exam} />
)}
</div>
);
const chapterButtonRightContent = (
<div className='chapter-button-right'>
{!comingSoon && !isLinkChapter && (
<span className='chapter-steps'>
{t('learn.steps-completed', {
totalSteps,
completedSteps
})}
</span>
<ChapterIcon className='map-icon' chapter={dashedName as FsdChapters} />
{chapterLabel}
{isLinkChapter && examSlug && (
<BlockLabelComponent blockLabel={BlockLabel.exam} />
)}
</div>
<div className='chapter-button-right'>
{!comingSoon && !isLinkChapter && (
<span className='chapter-steps'>
{t('learn.steps-completed', {
totalSteps,
completedSteps
})}
</span>
)}
<span className='dropdown-wrap'>{!isLinkChapter && <DropDown />}</span>
</div>
</>
)}
<span className='dropdown-wrap'>{!isLinkChapter && <DropDown />}</span>
</div>
);
if (isLinkChapter && examSlug) {
return (
<li className='chapter'>
<Link
className='chapter-button'
className='chapter-header-wrapper chapter-link'
data-playwright-test-label='chapter-button'
to={examSlug}
>
{chapterButtonContent}
{chapterButtonLeftContent}
{chapterButtonRightContent}
</Link>
</li>
);
@@ -150,21 +192,43 @@ const Chapter = ({
return (
<li className='chapter'>
<button
aria-controls={panelId}
aria-expanded={open}
className='chapter-button'
data-playwright-test-label='chapter-button'
onClick={() => setOpen(o => !o)}
type='button'
>
{chapterButtonContent}
</button>
<div className='chapter-header-wrapper'>
<button
aria-controls={panelId}
aria-expanded={open}
className='chapter-button chapter-button-main'
data-playwright-test-label='chapter-button'
onClick={toggleOpen}
type='button'
>
{chapterButtonLeftContent}
</button>
{resetButton}
<button
aria-controls={panelId}
aria-expanded={open}
aria-label={`${toggleLabel} ${chapterLabel}`}
className='chapter-button chapter-button-toggle'
data-playwright-test-label='chapter-button-toggle'
onClick={toggleOpen}
type='button'
>
{chapterButtonRightContent}
</button>
</div>
{open && (
<ul className='chapter-panel' id={panelId}>
{children}
</ul>
)}
<ResetProgressModal
blockTitle={chapterLabel}
blockDashedName={blockDashedNames}
superBlock={superBlock}
show={showResetModal}
onHide={handleResetModalClose}
onResetComplete={onResetComplete}
/>
</li>
);
};
@@ -176,10 +240,24 @@ const Module = ({
totalSteps,
completedSteps,
superBlock,
comingSoon
comingSoon,
blockDashedNames,
onResetComplete
}: ModuleProps) => {
const { t } = useTranslation();
const [open, setOpen] = useState(isExpanded);
const [showResetModal, setShowResetModal] = useState(false);
useEffect(() => {
setOpen(isExpanded);
}, [isExpanded]);
const panelId = `module-panel-${dashedName}`;
const isComplete = totalSteps === 0 ? false : completedSteps === totalSteps;
const moduleLabel = t(`intro:${superBlock}.modules.${dashedName}`);
const toggleLabel = open
? t('intro:misc-text.collapse')
: t('intro:misc-text.expand');
const { note, intro } = t(`intro:${superBlock}.module-intros.${dashedName}`, {
returnObjects: true
}) as {
@@ -188,44 +266,74 @@ const Module = ({
};
const showModuleContent = !(comingSoon && !showUpcomingChanges);
const showResetButton = !comingSoon;
const isResetDisabled = completedSteps === 0;
const [open, setOpen] = useState(isExpanded);
const handleResetClick = (e: React.MouseEvent) => {
e.stopPropagation();
setShowResetModal(true);
};
useEffect(() => {
setOpen(isExpanded);
}, [isExpanded]);
const handleResetModalClose = () => {
setShowResetModal(false);
};
const panelId = `module-panel-${dashedName}`;
const toggleOpen = () => setOpen(o => !o);
const resetButton = showResetButton ? (
<button
className='block-reset-button'
onClick={handleResetClick}
aria-label={t('learn.reset-progress-aria-module', { moduleLabel })}
type='button'
disabled={isResetDisabled}
>
<Reset />
</button>
) : null;
return (
<li>
<button
aria-controls={panelId}
aria-expanded={open}
className='module-button'
onClick={() => setOpen(o => !o)}
type='button'
>
<div className='module-button-left'>
<span className='dropdown-wrap'>
<DropDown />
</span>
<span className='checkmark-wrap'>
<CheckMark isCompleted={isComplete} />
</span>
{t(`intro:${superBlock}.modules.${dashedName}`)}
</div>
<div className='module-button-right' data-testid='module-button-right'>
{!comingSoon && !!totalSteps && (
<span className='module-steps'>
{t('learn.steps-completed', {
totalSteps,
completedSteps
})}
<div className='module-header-wrapper'>
<button
aria-controls={panelId}
aria-expanded={open}
className='module-button module-button-main'
onClick={toggleOpen}
type='button'
>
<div className='module-button-left'>
<span className='dropdown-wrap'>
<DropDown />
</span>
)}
</div>
</button>
<span className='checkmark-wrap'>
<CheckMark isCompleted={isComplete} />
</span>
{moduleLabel}
</div>
</button>
{resetButton}
<button
aria-controls={panelId}
aria-expanded={open}
aria-label={`${toggleLabel} ${moduleLabel}`}
className='module-button module-button-toggle'
data-testid='module-button-right'
onClick={toggleOpen}
type='button'
>
<div className='module-button-right'>
{!comingSoon && !!totalSteps && (
<span className='module-steps'>
{t('learn.steps-completed', {
totalSteps,
completedSteps
})}
</span>
)}
</div>
</button>
</div>
{open && (
<ul className='module-panel' id={panelId}>
{comingSoon && (
@@ -241,6 +349,14 @@ const Module = ({
{showModuleContent && children}
</ul>
)}
<ResetProgressModal
blockTitle={moduleLabel}
blockDashedName={blockDashedNames}
superBlock={superBlock}
show={showResetModal}
onHide={handleResetModalClose}
onResetComplete={onResetComplete}
/>
</li>
);
};
@@ -357,6 +473,11 @@ export const SuperBlockAccordion = ({
const expandedChapter = blockToChapterMap.get(chosenBlock);
const expandedModule = blockToModuleMap.get(chosenBlock);
const accordion = true;
const dispatch = useDispatch();
const handleResetComplete = (removedChallengeIds: string[]) => {
dispatch(removeModuleChallenges({ removedChallengeIds }));
};
return (
<ul className='super-block-accordion'>
@@ -391,6 +512,10 @@ export const SuperBlockAccordion = ({
? firstModuleBlock?.challenges[0]?.fields.slug
: undefined;
const chapterBlockDashedNames = chapter.modules.flatMap(module =>
module.blocks.map(block => block.name)
);
return (
<Chapter
key={chapter.name}
@@ -406,6 +531,8 @@ export const SuperBlockAccordion = ({
superBlock={superBlock}
isLinkChapter={isLinkChapter}
examSlug={examSlug}
blockDashedNames={chapterBlockDashedNames}
onResetComplete={handleResetComplete}
>
{chapter.modules.map(module => {
if (module.comingSoon && !showUpcomingChanges) {
@@ -439,6 +566,10 @@ export const SuperBlockAccordion = ({
completedChallengeIds.filter(id => moduleStepIdsSet.has(id))
).size;
const moduleBlockDashedNames = module.blocks.map(
block => block.name
);
return (
<Module
key={module.name}
@@ -448,6 +579,8 @@ export const SuperBlockAccordion = ({
completedSteps={completedStepsInModule}
superBlock={superBlock}
comingSoon={!!module.comingSoon}
blockDashedNames={moduleBlockDashedNames}
onResetComplete={handleResetComplete}
>
{module.blocks.map(block => (
// maybe TODO: allow blocks to be "coming soon"
+6
View File
@@ -355,6 +355,12 @@ export function postResetProgress(): Promise<ResponseWithData<void>> {
return post('/account/reset-progress', {});
}
export function deleteResetModule(body: {
blockIds: string[];
}): Promise<ResponseWithData<{ removedChallengeIds: string[] }>> {
return deleteRequest('/account/reset-module', body);
}
export function postUserToken(): Promise<ResponseWithData<void>> {
return post('/user/user-token', {});
}
+1 -1
View File
@@ -37,7 +37,7 @@
"insert-challenge": "tsx --tsconfig ../tools/challenge-helper-scripts/tsconfig.json ../tools/challenge-helper-scripts/insert-challenge",
"insert-step": "tsx --tsconfig ../tools/challenge-helper-scripts/tsconfig.json ../tools/challenge-helper-scripts/insert-step",
"insert-task": "tsx --tsconfig ../tools/challenge-helper-scripts/tsconfig.json ../tools/challenge-helper-scripts/insert-task",
"install-puppeteer": "puppeteer browsers install chrome",
"install-puppeteer": "puppeteer browsers install chrome --force",
"delete-step": "tsx --tsconfig ../tools/challenge-helper-scripts/tsconfig.json ../tools/challenge-helper-scripts/delete-step",
"delete-challenge": "tsx --tsconfig ../tools/challenge-helper-scripts/tsconfig.json ../tools/challenge-helper-scripts/delete-challenge",
"delete-task": "tsx --tsconfig ../tools/challenge-helper-scripts/tsconfig.json ../tools/challenge-helper-scripts/delete-task",
+1 -1
View File
@@ -55,7 +55,7 @@ test.describe('Block Navigation - Hash Updates', () => {
}) => {
await page.goto('/learn/javascript-v9');
await page.getByRole('button', { name: 'Build a Greeting Bot' }).click();
await page.getByRole('button', { name: /^Build a Greeting Bot/ }).click();
// Click on step 1 in the accordion
const step1Link = page.getByRole('link', { name: 'Step 1 Not Passed' });
+1 -1
View File
@@ -3,7 +3,7 @@ import { test, expect } from '@playwright/test';
test.describe('Certification intro page', () => {
test('Should render and toggle correctly', async ({ page }) => {
const firstBlockToggle = page.getByRole('button', {
name: 'Learn HTML by Building a Cat Photo App'
name: /^Learn HTML by Building a Cat Photo App/
});
const firstBlockText = page.getByText(
+250
View File
@@ -0,0 +1,250 @@
import { exec } from 'child_process';
import { promisify } from 'util';
import { test, expect } from '@playwright/test';
import translations from '../client/i18n/locales/english/translations.json';
const execP = promisify(exec);
// Seed certified user before all tests to ensure progress exists
test.beforeAll(async () => {
await execP('node ./tools/scripts/seed/seed-demo-user --certified-user', {
cwd: process.cwd().replace('/e2e', '')
});
});
// Re-seed after each test to restore progress (in case a test modifies it)
test.afterEach(async () => {
await execP('node ./tools/scripts/seed/seed-demo-user --certified-user', {
cwd: process.cwd().replace('/e2e', '')
});
});
test.describe('Module reset - Block level (v8 grid layout)', () => {
test.beforeEach(async ({ page }) => {
// Navigate to a superblock where certified user has completed challenges
// Certified user has isRespWebDesignCert: true with completed challenges
await page.goto('/learn/2022/responsive-web-design');
});
test('should show reset button on block hover', async ({ page }) => {
// First block should have progress for certified user
const blockHeader = page.locator('.block-header-wrapper').first();
// Reset button should be hidden initially (opacity: 0)
const resetButton = blockHeader.locator('.block-reset-button');
await expect(resetButton).toHaveCSS('opacity', '0');
// Hover over the block header
await blockHeader.hover();
// Reset button should become visible on hover (opacity: 1 for enabled, 0.3 for disabled)
await expect(resetButton).toBeVisible();
});
test('should open reset modal when clicking reset button', async ({
page
}) => {
// Find a block with an enabled reset button (has progress)
const blockHeader = page
.locator('.block-header-wrapper')
.filter({ has: page.locator('.block-reset-button:not(:disabled)') })
.first();
await blockHeader.hover();
// Click the reset button
const resetButton = blockHeader.locator('.block-reset-button');
await resetButton.click();
// Modal should be visible
await expect(page.getByRole('dialog')).toBeVisible();
// Modal should contain the reset heading
await expect(
page.getByText(
translations.learn['reset-progress-description'].split("'")[0],
{ exact: false }
)
).toBeVisible();
});
test('should close modal when clicking cancel button', async ({ page }) => {
// Find a block with an enabled reset button
const blockHeader = page
.locator('.block-header-wrapper')
.filter({ has: page.locator('.block-reset-button:not(:disabled)') })
.first();
await blockHeader.hover();
await blockHeader.locator('.block-reset-button').click();
// Modal should be visible
await expect(page.getByRole('dialog')).toBeVisible();
// Click the cancel button
await page
.getByRole('button', {
name: translations.learn['reset-progress-nevermind']
})
.click();
// Modal should be hidden
await expect(page.getByRole('dialog')).toBeHidden();
});
test('should disable reset button until verification phrase is entered', async ({
page
}) => {
// Find a block with an enabled reset button
const blockHeader = page
.locator('.block-header-wrapper')
.filter({ has: page.locator('.block-reset-button:not(:disabled)') })
.first();
await blockHeader.hover();
await blockHeader.locator('.block-reset-button').click();
// Confirm button should be disabled initially
const confirmButton = page.getByRole('button', {
name: translations.learn['reset-progress-confirm']
});
await expect(confirmButton).toBeDisabled();
// Enter incorrect text
const verifyInput = page.getByRole('textbox');
await verifyInput.fill('wrong text');
await expect(confirmButton).toBeDisabled();
// Enter correct verification phrase
await verifyInput.fill(translations.learn['reset-progress-verify']);
await expect(confirmButton).toBeEnabled();
});
});
test.describe('Module reset - Full reset flow', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/learn/2022/responsive-web-design');
});
test('should reset block progress when confirmed', async ({ page }) => {
// Find a block with an enabled reset button (has progress)
const blockHeader = page
.locator('.block-header-wrapper')
.filter({ has: page.locator('.block-reset-button:not(:disabled)') })
.first();
// Open the reset modal
await blockHeader.hover();
await blockHeader.locator('.block-reset-button').click();
await expect(page.getByRole('dialog')).toBeVisible();
// Enter verification phrase
const verifyInput = page.getByRole('textbox');
await verifyInput.fill(translations.learn['reset-progress-verify']);
const confirmButton = page.getByRole('button', {
name: translations.learn['reset-progress-confirm']
});
await confirmButton.click();
await expect(
page.getByText(
translations.learn['reset-progress-success'].split("'")[0],
{ exact: false }
)
).toBeVisible();
await page
.getByRole('button', {
name: translations.learn['reset-progress-dismiss']
})
.click();
await expect(page.getByRole('dialog')).toBeHidden();
});
});
// Skip: full-stack-developer page is not built in dev environment
test.describe.skip('Module reset - Chapter level (v9 accordion layout)', () => {
test.beforeEach(async ({ page }) => {
// Navigate to a chapter-based superblock
await page.goto('/learn/full-stack-developer');
});
test('should show reset button on chapter hover', async ({ page }) => {
// Find a chapter header wrapper
const chapterHeader = page.locator('.chapter-header-wrapper').first();
// Reset button should be hidden initially
const resetButton = chapterHeader.locator('.block-reset-button');
await expect(resetButton).toHaveCSS('opacity', '0');
// Hover over the chapter header
await chapterHeader.hover();
// Reset button should become visible on hover
await expect(resetButton).toHaveCSS('opacity', '1');
});
test('should show reset button on module hover', async ({ page }) => {
// Expand a chapter first
const chapterButton = page.locator('.chapter-button-main').first();
await chapterButton.click();
// Find a module header wrapper
const moduleHeader = page.locator('.module-header-wrapper').first();
// Hover over the module header
await moduleHeader.hover();
// Reset button should become visible on hover
const resetButton = moduleHeader.locator('.block-reset-button');
await expect(resetButton).toHaveCSS('opacity', '1');
});
test('should open reset modal for chapter with correct title', async ({
page
}) => {
// Hover over a chapter to reveal the reset button
const chapterHeader = page.locator('.chapter-header-wrapper').first();
await chapterHeader.hover();
// Click the reset button
const resetButton = chapterHeader.locator('.block-reset-button');
await resetButton.click();
// Modal should be visible with the chapter name in the heading
await expect(page.getByRole('dialog')).toBeVisible();
});
});
test.describe('Module reset - Accessibility', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/learn/2022/responsive-web-design');
});
test('reset button should have proper aria-label', async ({ page }) => {
// Find a reset button and check its aria-label
const resetButton = page.locator('.block-reset-button').first();
const ariaLabel = await resetButton.getAttribute('aria-label');
// Should contain "Reset progress for"
expect(ariaLabel).toContain('Reset progress for');
});
test('modal should be keyboard accessible', async ({ page }) => {
// Find a block with an enabled reset button
const blockHeader = page
.locator('.block-header-wrapper')
.filter({ has: page.locator('.block-reset-button:not(:disabled)') })
.first();
await blockHeader.hover();
await blockHeader.locator('.block-reset-button').click();
// Modal should be visible
await expect(page.getByRole('dialog')).toBeVisible();
// Press Escape to close
await page.keyboard.press('Escape');
// Modal should be hidden
await expect(page.getByRole('dialog')).toBeHidden();
});
});
+20 -20
View File
@@ -32,7 +32,7 @@ test.describe('Super Block Page - Authenticated User', () => {
await expect(
page.getByRole('button', {
name: 'Learn Basic JavaScript by Building a Role Playing Game'
name: /^Learn Basic JavaScript by Building a Role Playing Game/
})
).toHaveAttribute('aria-expanded', 'true');
});
@@ -51,7 +51,7 @@ test.describe('Super Block Page - Authenticated User', () => {
await expect(
page.getByRole('button', {
name: 'Learn Basic JavaScript by Building a Role Playing Game'
name: /^Learn Basic JavaScript by Building a Role Playing Game/
})
).toHaveAttribute('aria-expanded', 'true');
});
@@ -66,13 +66,13 @@ test.describe('Super Block Page - Authenticated User', () => {
// The first block is expanded by default
await expect(
page.getByRole('button', {
name: 'Learn Introductory JavaScript by Building a Pyramid Generator'
name: /^Learn Introductory JavaScript by Building a Pyramid Generator/
})
).toHaveAttribute('aria-expanded', 'true');
await expect(
page.getByRole('button', {
name: 'Learn Basic JavaScript by Building a Role Playing Game'
name: /^Learn Basic JavaScript by Building a Role Playing Game/
})
).toHaveAttribute('aria-expanded', 'false');
@@ -90,13 +90,13 @@ test.describe('Super Block Page - Authenticated User', () => {
await expect(
page.getByRole('button', {
name: 'Learn Introductory JavaScript by Building a Pyramid Generator'
name: /^Learn Introductory JavaScript by Building a Pyramid Generator/
})
).toHaveAttribute('aria-expanded', 'false');
await expect(
page.getByRole('button', {
name: 'Learn Basic JavaScript by Building a Role Playing Game'
name: /^Learn Basic JavaScript by Building a Role Playing Game/
})
).toHaveAttribute('aria-expanded', 'true');
});
@@ -128,14 +128,14 @@ test.describe('Super Block Page - Authenticated User', () => {
// Module
await expect(
page.getByRole('button', {
name: 'Basic CSS'
name: /^Basic CSS/
})
).toHaveAttribute('aria-expanded', 'true');
// Block
await expect(
page.getByRole('button', {
name: 'Design a Cafe Menu'
name: /^Design a Cafe Menu/
})
).toHaveAttribute('aria-expanded', 'true');
});
@@ -160,14 +160,14 @@ test.describe('Super Block Page - Authenticated User', () => {
// Basic HTML module
await expect(
page.getByRole('button', {
name: 'Basic HTML'
name: /^Basic HTML/
})
).toHaveAttribute('aria-expanded', 'true');
// Understanding HTML Attributes block
await expect(
page.getByRole('button', {
name: 'Understanding HTML Attributes'
name: /^Understanding HTML Attributes/
})
).toHaveAttribute('aria-expanded', 'true');
});
@@ -187,14 +187,14 @@ test.describe('Super Block Page - Authenticated User', () => {
// First module
await expect(
page.getByRole('button', {
name: 'Basic HTML'
name: /^Basic HTML/
})
).toHaveAttribute('aria-expanded', 'true');
// First block
await expect(
page.getByRole('button', {
name: 'Build a Curriculum Outline'
name: /^Build a Curriculum Outline/
})
).toHaveAttribute('aria-expanded', 'true');
@@ -218,7 +218,7 @@ test.describe('Super Block Page - Authenticated User', () => {
// Cat Blog Page block
await expect(
page.getByRole('button', {
name: 'Build a Cat Blog Page'
name: /^Build a Cat Blog Page/
})
).toHaveAttribute('aria-expanded', 'true');
});
@@ -236,7 +236,7 @@ test.describe('Super Block Page - Unauthenticated User', () => {
await expect(
page.getByRole('button', {
name: 'Learn Introductory JavaScript by Building a Pyramid Generator'
name: /^Learn Introductory JavaScript by Building a Pyramid Generator/
})
).toHaveAttribute('aria-expanded', 'true');
@@ -244,7 +244,7 @@ test.describe('Super Block Page - Unauthenticated User', () => {
await expect(
page.getByRole('button', {
name: 'Learn Greetings in your First Day at the Office'
name: /^Learn Greetings in your First Day at the Office/
})
).toHaveAttribute('aria-expanded', 'true');
});
@@ -264,14 +264,14 @@ test.describe('Super Block Page - Unauthenticated User', () => {
// First module
await expect(
page.getByRole('button', {
name: 'Basic HTML'
name: /^Basic HTML/
})
).toHaveAttribute('aria-expanded', 'true');
// First block
await expect(
page.getByRole('button', {
name: 'Build a Curriculum Outline'
name: /^Build a Curriculum Outline/
})
).toHaveAttribute('aria-expanded', 'true');
});
@@ -321,7 +321,7 @@ test.describe('Super Block Page - Search Lessons', () => {
await page.goto('/learn/javascript-v9/');
await expect(
page.getByRole('button', { name: 'Booleans and Numbers' })
page.getByRole('button', { name: /^Booleans and Numbers$/ })
).toBeVisible();
await page
@@ -332,7 +332,7 @@ test.describe('Super Block Page - Search Lessons', () => {
page.getByRole('heading', { name: 'Build a Greeting Bot' })
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Booleans and Numbers' })
page.getByRole('button', { name: /^Booleans and Numbers$/ })
).not.toBeVisible();
await expect(
page.getByText(
@@ -343,7 +343,7 @@ test.describe('Super Block Page - Search Lessons', () => {
await page.getByRole('button', { name: 'Clear search terms' }).click();
await expect(
page.getByRole('button', { name: 'Booleans and Numbers' })
page.getByRole('button', { name: /^Booleans and Numbers$/ })
).toBeVisible();
});
});