mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
feat(client,api): add a per module reset (#62547)
This commit is contained in:
committed by
GitHub
parent
473d660134
commit
5a2606db1c
@@ -203,3 +203,4 @@ api/logs/
|
||||
|
||||
### Turborepo
|
||||
.turbo
|
||||
test-results
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -35,6 +35,7 @@ export const actionTypes = createTypes(
|
||||
'updateComplete',
|
||||
'updateFailed',
|
||||
'updateDonationFormState',
|
||||
'removeModuleChallenges',
|
||||
'updateUserToken',
|
||||
'postChargeProcessing',
|
||||
'updateCardRedirecting',
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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', {});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user