mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-04-07 22:00:56 -04:00
feat: add socrates (#65430)
Co-authored-by: Mrugesh Mohapatra <noreply@mrugesh.dev>
This commit is contained in:
@@ -173,6 +173,7 @@ model user {
|
||||
savedChallenges SavedChallenge[] // Undefined | SavedChallenge[]
|
||||
// Nullable tri-state: null (likely new user), true (subscribed), false (unsubscribed)
|
||||
sendQuincyEmail Boolean?
|
||||
socrates Boolean?
|
||||
theme String? // Undefined
|
||||
timezone String? // Undefined
|
||||
twitter String? // Null | Undefined
|
||||
@@ -228,6 +229,17 @@ model Donation {
|
||||
@@index([userId], map: "userId_1")
|
||||
}
|
||||
|
||||
model SocratesUsage {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
userId String @db.ObjectId
|
||||
/// UTC date representing the day of usage (midnight).
|
||||
date DateTime @db.Date
|
||||
/// Number of hints used on this day.
|
||||
count Int @default(0)
|
||||
|
||||
@@unique([userId, date])
|
||||
}
|
||||
|
||||
model UserToken {
|
||||
id String @id @map("_id")
|
||||
created DateTime @db.Date
|
||||
|
||||
@@ -191,6 +191,7 @@ export const build = async (
|
||||
|
||||
await fastify.register(protectedRoutes.challengeRoutes);
|
||||
await fastify.register(protectedRoutes.donateRoutes);
|
||||
await fastify.register(protectedRoutes.socratesRoutes);
|
||||
await fastify.register(protectedRoutes.protectedCertificateRoutes);
|
||||
await fastify.register(protectedRoutes.settingRoutes);
|
||||
await fastify.register(protectedRoutes.userRoutes);
|
||||
|
||||
@@ -93,6 +93,7 @@ export const newUser = (email: string) => ({
|
||||
rand: null, // TODO(Post-MVP): delete from schema (it's not used or required).
|
||||
savedChallenges: [],
|
||||
sendQuincyEmail: null,
|
||||
socrates: null,
|
||||
theme: 'default',
|
||||
timezone: null,
|
||||
twitter: null,
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from './challenge.js';
|
||||
export * from './donate.js';
|
||||
export * from './settings.js';
|
||||
export * from './user.js';
|
||||
export * from './socrates.js';
|
||||
|
||||
@@ -616,6 +616,34 @@ ${isLinkSentWithinLimitTTL}`
|
||||
}
|
||||
);
|
||||
|
||||
fastify.put(
|
||||
'/update-socrates',
|
||||
{
|
||||
schema: schemas.updateSocrates
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
try {
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: req.user?.id },
|
||||
data: {
|
||||
socrates: req.body.socrates
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'flash.socrates-updated',
|
||||
type: 'success'
|
||||
} as const;
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
fastify.Sentry.captureException(err);
|
||||
void reply.code(500);
|
||||
return { message: 'flash.wrong-updating', type: 'danger' } as const;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
fastify.put(
|
||||
'/update-my-honesty',
|
||||
{
|
||||
|
||||
538
api/src/routes/protected/socrates.test.ts
Normal file
538
api/src/routes/protected/socrates.test.ts
Normal file
@@ -0,0 +1,538 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
import {
|
||||
describe,
|
||||
test,
|
||||
expect,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import {
|
||||
devLogin,
|
||||
setupServer,
|
||||
createSuperRequest,
|
||||
defaultUserId
|
||||
} from '../../../vitest.utils.js';
|
||||
|
||||
const mockedFetch = vi.fn();
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(mockedFetch);
|
||||
|
||||
const validPayload = {
|
||||
description: 'Make the text say hello',
|
||||
userInput: 'Hello world',
|
||||
seed: '<h1>Hello</h1>',
|
||||
hints: [{ text: 'Check your spelling', failed: true }]
|
||||
};
|
||||
|
||||
describe('socratesRoutes', () => {
|
||||
setupServer();
|
||||
|
||||
describe('Authenticated user', () => {
|
||||
let superPut: ReturnType<typeof createSuperRequest>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const setCookies = await devLogin();
|
||||
superPut = createSuperRequest({ method: 'PUT', setCookies });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('PUT /socrates/get-hint', () => {
|
||||
test('should return 403 when user has socrates explicitly disabled', async () => {
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: { socrates: false }
|
||||
});
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toStrictEqual({
|
||||
error: 'socrates-no-access',
|
||||
type: 'danger',
|
||||
attempts: 0,
|
||||
limit: 0
|
||||
});
|
||||
expect(mockedFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should allow access when socrates is null (default)', async () => {
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: { socrates: null }
|
||||
});
|
||||
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({ hint: 'Try adding a closing tag.' })
|
||||
)
|
||||
});
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('hint');
|
||||
});
|
||||
|
||||
describe('with socrates enabled', () => {
|
||||
beforeEach(async () => {
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: { socrates: true }
|
||||
});
|
||||
await fastifyTestInstance.prisma.socratesUsage.deleteMany({
|
||||
where: { userId: defaultUserId }
|
||||
});
|
||||
});
|
||||
|
||||
test('should return hint on successful Socrates API response', async () => {
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({ hint: 'Try adding a closing tag.' })
|
||||
)
|
||||
});
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toStrictEqual({
|
||||
hint: 'Try adding a closing tag.',
|
||||
attempts: 1,
|
||||
limit: 3
|
||||
});
|
||||
});
|
||||
|
||||
test('should pass session userId, not client-supplied userId', async () => {
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify({ hint: 'A hint.' }))
|
||||
});
|
||||
|
||||
await superPut('/socrates/get-hint').send({
|
||||
...validPayload
|
||||
});
|
||||
|
||||
const fetchCall = mockedFetch.mock.calls[0]!;
|
||||
const body = JSON.parse(fetchCall[1].body as string) as {
|
||||
userId: string;
|
||||
};
|
||||
expect(body.userId).toBe(defaultUserId);
|
||||
});
|
||||
|
||||
test('should use session userId even when userId is sent in body', async () => {
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify({ hint: 'A hint.' }))
|
||||
});
|
||||
|
||||
await superPut('/socrates/get-hint').send({
|
||||
...validPayload,
|
||||
userId: 'attacker-id'
|
||||
});
|
||||
|
||||
const fetchCall = mockedFetch.mock.calls[0]!;
|
||||
const body = JSON.parse(fetchCall[1].body as string) as {
|
||||
userId: string;
|
||||
};
|
||||
expect(body.userId).toBe(defaultUserId);
|
||||
expect(body.userId).not.toBe('attacker-id');
|
||||
});
|
||||
|
||||
test('should return 429 when Socrates API rate limits', async () => {
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 429,
|
||||
text: () => Promise.resolve('')
|
||||
});
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
expect(response.body).toStrictEqual({
|
||||
error: 'socrates-rate-limit',
|
||||
type: 'info',
|
||||
attempts: 0,
|
||||
limit: 3
|
||||
});
|
||||
});
|
||||
|
||||
test('should forward upstream error message on 400', async () => {
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({ error: 'Input too short for analysis.' })
|
||||
)
|
||||
});
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toStrictEqual({
|
||||
error: 'Input too short for analysis.',
|
||||
type: 'info',
|
||||
attempts: 0,
|
||||
limit: 3
|
||||
});
|
||||
});
|
||||
|
||||
test('should use fallback message on 400 with no upstream error', async () => {
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: () => Promise.resolve('')
|
||||
});
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toStrictEqual({
|
||||
error: 'socrates-unable-to-generate',
|
||||
type: 'info',
|
||||
attempts: 0,
|
||||
limit: 3
|
||||
});
|
||||
});
|
||||
|
||||
test('should return 500 on other Socrates API errors', async () => {
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
text: () => Promise.resolve('Service Unavailable')
|
||||
});
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toStrictEqual({
|
||||
error: 'socrates-unavailable',
|
||||
type: 'danger',
|
||||
attempts: 0,
|
||||
limit: 3
|
||||
});
|
||||
});
|
||||
|
||||
test('should return 500 when Socrates API returns invalid JSON', async () => {
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve('not json')
|
||||
});
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.type).toBe('danger');
|
||||
expect(response.body.attempts).toBe(0);
|
||||
expect(response.body.limit).toBe(3);
|
||||
});
|
||||
|
||||
test('should return 500 when Socrates API returns no hint', async () => {
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify({ foo: 'bar' }))
|
||||
});
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.type).toBe('danger');
|
||||
expect(response.body.attempts).toBe(0);
|
||||
expect(response.body.limit).toBe(3);
|
||||
});
|
||||
|
||||
test('should return 500 when fetch throws', async () => {
|
||||
mockedFetch.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toStrictEqual({
|
||||
error: 'socrates-unavailable',
|
||||
type: 'danger',
|
||||
attempts: 0,
|
||||
limit: 3
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('daily usage entitlements', () => {
|
||||
beforeEach(async () => {
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: { socrates: true, isDonating: false }
|
||||
});
|
||||
await fastifyTestInstance.prisma.socratesUsage.deleteMany({
|
||||
where: { userId: defaultUserId }
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should return attempts=1 and limit=3 on first hint for non-donor', async () => {
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify({ hint: 'A hint.' }))
|
||||
});
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.attempts).toBe(1);
|
||||
expect(response.body.limit).toBe(3);
|
||||
});
|
||||
|
||||
test('should increment attempts on each request', async () => {
|
||||
mockedFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify({ hint: 'A hint.' }))
|
||||
});
|
||||
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.attempts).toBe(2);
|
||||
});
|
||||
|
||||
test('should return 429 when non-donor exceeds 3 hints/day', async () => {
|
||||
mockedFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify({ hint: 'A hint.' }))
|
||||
});
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
}
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
expect(response.body.attempts).toBe(3);
|
||||
expect(response.body.limit).toBe(3);
|
||||
expect(response.body.error).toBe('socrates-daily-limit');
|
||||
expect(mockedFetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test('should not inflate count beyond limit on repeated 429s', async () => {
|
||||
mockedFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify({ hint: 'A hint.' }))
|
||||
});
|
||||
|
||||
// Exhaust the non-donor limit
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
}
|
||||
|
||||
// Make extra requests that should all be 429
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const res = await superPut('/socrates/get-hint').send(validPayload);
|
||||
expect(res.status).toBe(429);
|
||||
}
|
||||
|
||||
// Upgrade to donor (limit: 10) and verify access is restored
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: { isDonating: true }
|
||||
});
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.attempts).toBe(4);
|
||||
expect(response.body.limit).toBe(10);
|
||||
});
|
||||
|
||||
test('should allow 10 hints/day for donors', async () => {
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: { isDonating: true }
|
||||
});
|
||||
|
||||
mockedFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify({ hint: 'A hint.' }))
|
||||
});
|
||||
|
||||
let response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
for (let i = 1; i < 10; i++) {
|
||||
response = await superPut('/socrates/get-hint').send(validPayload);
|
||||
expect(response.status).toBe(200);
|
||||
}
|
||||
|
||||
expect(response.body.attempts).toBe(10);
|
||||
expect(response.body.limit).toBe(10);
|
||||
|
||||
response = await superPut('/socrates/get-hint').send(validPayload);
|
||||
expect(response.status).toBe(429);
|
||||
});
|
||||
|
||||
test('should not consume an attempt on upstream API error', async () => {
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve('Server Error')
|
||||
});
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.attempts).toBe(0);
|
||||
expect(response.body.limit).toBe(3);
|
||||
});
|
||||
|
||||
test('should not count yesterday usage against today limit', async () => {
|
||||
const yesterday = new Date();
|
||||
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
|
||||
const yesterdayUTC = new Date(
|
||||
Date.UTC(
|
||||
yesterday.getUTCFullYear(),
|
||||
yesterday.getUTCMonth(),
|
||||
yesterday.getUTCDate()
|
||||
)
|
||||
);
|
||||
|
||||
await fastifyTestInstance.prisma.socratesUsage.create({
|
||||
data: { userId: defaultUserId, date: yesterdayUTC, count: 3 }
|
||||
});
|
||||
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify({ hint: 'A hint.' }))
|
||||
});
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.attempts).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
beforeEach(async () => {
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: { socrates: true }
|
||||
});
|
||||
});
|
||||
|
||||
test('should return 400 when userInput is empty string', async () => {
|
||||
const response = await superPut('/socrates/get-hint').send({
|
||||
...validPayload,
|
||||
userInput: ''
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toStrictEqual({
|
||||
error: 'socrates-invalid-request',
|
||||
type: 'info',
|
||||
attempts: 0,
|
||||
limit: 0
|
||||
});
|
||||
expect(mockedFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should accept request without userInput', async () => {
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({ hint: 'Try adding a closing tag.' })
|
||||
)
|
||||
});
|
||||
|
||||
const { userInput: _unused, ...payloadWithoutUserInput } =
|
||||
validPayload;
|
||||
const response = await superPut('/socrates/get-hint').send(
|
||||
payloadWithoutUserInput
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('hint');
|
||||
expect(mockedFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should return 400 when seed is empty', async () => {
|
||||
const response = await superPut('/socrates/get-hint').send({
|
||||
...validPayload,
|
||||
seed: ''
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(mockedFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should return 400 when description is empty', async () => {
|
||||
const response = await superPut('/socrates/get-hint').send({
|
||||
...validPayload,
|
||||
description: ''
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(mockedFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should return 400 when required fields are missing', async () => {
|
||||
const response = await superPut('/socrates/get-hint').send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(mockedFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unauthenticated user', () => {
|
||||
test('should not return a hint for unauthenticated requests', async () => {
|
||||
const response = await createSuperRequest({ method: 'PUT' })(
|
||||
'/socrates/get-hint'
|
||||
).send(validPayload);
|
||||
|
||||
// Unauthenticated requests fail before reaching the route handler
|
||||
expect(response.status).not.toBe(200);
|
||||
expect(response.body).not.toHaveProperty('hint');
|
||||
});
|
||||
});
|
||||
});
|
||||
217
api/src/routes/protected/socrates.ts
Normal file
217
api/src/routes/protected/socrates.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||
|
||||
import * as schemas from '../../schemas.js';
|
||||
import { SOCRATES_API_KEY, SOCRATES_ENDPOINT } from '../../utils/env.js';
|
||||
|
||||
const DAILY_LIMITS = { donor: 10, nonDonor: 3 } as const;
|
||||
|
||||
function getDailyLimit(isDonating: boolean): number {
|
||||
return isDonating ? DAILY_LIMITS.donor : DAILY_LIMITS.nonDonor;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param fastify The Fastify instance.
|
||||
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
|
||||
* @param done The callback to signal that the plugin is ready.
|
||||
*/
|
||||
export const socratesRoutes: FastifyPluginCallbackTypebox = (
|
||||
fastify,
|
||||
_options,
|
||||
done
|
||||
) => {
|
||||
// Socrates plugin
|
||||
fastify.put(
|
||||
'/socrates/get-hint',
|
||||
{
|
||||
schema: schemas.askSocrates,
|
||||
errorHandler(error, req, reply) {
|
||||
if (error.validation) {
|
||||
void reply.status(400).send({
|
||||
error: 'socrates-invalid-request',
|
||||
type: 'info',
|
||||
attempts: 0,
|
||||
limit: 0
|
||||
});
|
||||
} else {
|
||||
fastify.errorHandler(error, req, reply);
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
if (!req.user || req.user.socrates === false) {
|
||||
return reply.status(403).send({
|
||||
error: 'socrates-no-access',
|
||||
type: 'danger',
|
||||
attempts: 0,
|
||||
limit: 0
|
||||
});
|
||||
}
|
||||
|
||||
const limit = getDailyLimit(req.user.isDonating);
|
||||
const now = new Date();
|
||||
const todayUTC = new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
|
||||
);
|
||||
|
||||
const existing = await fastify.prisma.socratesUsage.findUnique({
|
||||
where: {
|
||||
userId_date: { userId: req.user.id, date: todayUTC }
|
||||
}
|
||||
});
|
||||
|
||||
if (existing && existing.count >= limit) {
|
||||
return reply.status(429).send({
|
||||
error: 'socrates-daily-limit',
|
||||
type: 'info',
|
||||
attempts: limit,
|
||||
limit
|
||||
});
|
||||
}
|
||||
|
||||
const usage = await fastify.prisma.socratesUsage.upsert({
|
||||
where: {
|
||||
userId_date: { userId: req.user.id, date: todayUTC }
|
||||
},
|
||||
create: {
|
||||
userId: req.user.id,
|
||||
date: todayUTC,
|
||||
count: 1
|
||||
},
|
||||
update: {
|
||||
count: { increment: 1 }
|
||||
}
|
||||
});
|
||||
|
||||
const attempts = usage.count;
|
||||
|
||||
const rollbackUsage = async () => {
|
||||
await fastify.prisma.socratesUsage.update({
|
||||
where: {
|
||||
userId_date: { userId: req.user!.id, date: todayUTC }
|
||||
},
|
||||
data: { count: { decrement: 1 } }
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(SOCRATES_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': SOCRATES_API_KEY
|
||||
},
|
||||
body: JSON.stringify({
|
||||
description: req.body.description,
|
||||
userInput: req.body.userInput,
|
||||
seed: req.body.seed,
|
||||
hints: req.body.hints,
|
||||
userId: req.user.id
|
||||
})
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
req.log.error(
|
||||
{
|
||||
status: response.status,
|
||||
response: responseText || undefined
|
||||
},
|
||||
'Socrates API returned an error response.'
|
||||
);
|
||||
|
||||
await rollbackUsage();
|
||||
|
||||
if (response.status === 429) {
|
||||
return reply.status(429).send({
|
||||
error: 'socrates-rate-limit',
|
||||
type: 'info',
|
||||
attempts: attempts - 1,
|
||||
limit
|
||||
});
|
||||
}
|
||||
|
||||
if (response.status === 400) {
|
||||
let upstreamMessage: string | undefined;
|
||||
try {
|
||||
const parsed = responseText
|
||||
? (JSON.parse(responseText) as { error?: string })
|
||||
: null;
|
||||
upstreamMessage = parsed?.error;
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
return reply.status(400).send({
|
||||
error: upstreamMessage || 'socrates-unable-to-generate',
|
||||
type: 'info',
|
||||
attempts: attempts - 1,
|
||||
limit
|
||||
});
|
||||
}
|
||||
|
||||
return reply.status(500).send({
|
||||
error: 'socrates-unavailable',
|
||||
type: 'danger',
|
||||
attempts: attempts - 1,
|
||||
limit
|
||||
});
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = responseText ? JSON.parse(responseText) : null;
|
||||
} catch (error) {
|
||||
req.log.error({
|
||||
err: error,
|
||||
response: responseText || undefined
|
||||
});
|
||||
await rollbackUsage();
|
||||
return reply.status(500).send({
|
||||
error: 'socrates-unavailable',
|
||||
type: 'danger',
|
||||
attempts: attempts - 1,
|
||||
limit
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!payload ||
|
||||
typeof payload !== 'object' ||
|
||||
typeof (payload as { hint?: unknown }).hint !== 'string'
|
||||
) {
|
||||
req.log.error(
|
||||
{
|
||||
response: payload
|
||||
},
|
||||
'Socrates API did not return a hint.'
|
||||
);
|
||||
await rollbackUsage();
|
||||
return reply.status(500).send({
|
||||
error: 'socrates-unavailable',
|
||||
type: 'danger',
|
||||
attempts: attempts - 1,
|
||||
limit
|
||||
});
|
||||
}
|
||||
|
||||
const { hint } = payload as { hint: string };
|
||||
|
||||
return { hint, attempts, limit } as const;
|
||||
} catch (error) {
|
||||
req.log.error(
|
||||
{ err: error },
|
||||
'Failed to fetch hint from Socrates API.'
|
||||
);
|
||||
await rollbackUsage();
|
||||
return reply.status(500).send({
|
||||
error: 'socrates-unavailable',
|
||||
type: 'danger',
|
||||
attempts: attempts - 1,
|
||||
limit
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
done();
|
||||
};
|
||||
@@ -318,6 +318,7 @@ const publicUserData = {
|
||||
portfolio: testUserData.portfolio,
|
||||
profileUI: testUserData.profileUI,
|
||||
savedChallenges: testUserData.savedChallenges,
|
||||
socrates: true,
|
||||
twitter: 'https://x.com/foobar',
|
||||
bluesky: 'https://bsky.app/profile/foobar',
|
||||
sendQuincyEmail: testUserData.sendQuincyEmail,
|
||||
@@ -1052,6 +1053,7 @@ describe('userRoutes', () => {
|
||||
keyboardShortcuts: false,
|
||||
location: '',
|
||||
name: '',
|
||||
socrates: true,
|
||||
theme: 'default'
|
||||
};
|
||||
|
||||
|
||||
@@ -733,6 +733,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = (
|
||||
progressTimestamps: true,
|
||||
savedChallenges: true,
|
||||
sendQuincyEmail: true,
|
||||
socrates: true,
|
||||
theme: true,
|
||||
twitter: true,
|
||||
bluesky: true,
|
||||
@@ -785,6 +786,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = (
|
||||
name,
|
||||
theme,
|
||||
experience,
|
||||
socrates,
|
||||
...publicUser
|
||||
} = rest;
|
||||
|
||||
@@ -821,7 +823,8 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = (
|
||||
userToken: encodedToken,
|
||||
completedSurveys: normalizeSurveys(completedSurveys),
|
||||
experience: experience.map(removeNulls),
|
||||
msUsername: msUsername?.msUsername
|
||||
msUsername: msUsername?.msUsername,
|
||||
socrates: socrates ?? true
|
||||
}
|
||||
},
|
||||
result: user.username
|
||||
|
||||
@@ -31,6 +31,7 @@ export { updateMyPortfolio } from './schemas/settings/update-my-portfolio.js';
|
||||
export { updateMyPrivacyTerms } from './schemas/settings/update-my-privacy-terms.js';
|
||||
export { updateMyProfileUI } from './schemas/settings/update-my-profile-ui.js';
|
||||
export { updateMyQuincyEmail } from './schemas/settings/update-my-quincy-email.js';
|
||||
export { updateSocrates } from './schemas/settings/update-socrates.js';
|
||||
export { updateMySocials } from './schemas/settings/update-my-socials.js';
|
||||
export { updateMyTheme } from './schemas/settings/update-my-theme.js';
|
||||
export { updateMyUsername } from './schemas/settings/update-my-username.js';
|
||||
@@ -39,6 +40,7 @@ export {
|
||||
deleteMyAccount,
|
||||
deleteUser
|
||||
} from './schemas/user/delete-my-account.js';
|
||||
export { askSocrates } from './schemas/socrates/ask-socrates.js';
|
||||
export { deleteUserToken } from './schemas/user/delete-user-token.js';
|
||||
export { getSessionUser } from './schemas/user/get-session-user.js';
|
||||
export { postMsUsername } from './schemas/user/post-ms-username.js';
|
||||
|
||||
21
api/src/schemas/settings/update-socrates.ts
Normal file
21
api/src/schemas/settings/update-socrates.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
|
||||
export const updateSocrates = {
|
||||
body: Type.Object({
|
||||
socrates: Type.Boolean()
|
||||
}),
|
||||
response: {
|
||||
200: Type.Object({
|
||||
message: Type.Literal('flash.socrates-updated'),
|
||||
type: Type.Literal('success')
|
||||
}),
|
||||
400: Type.Object({
|
||||
message: Type.Literal('flash.wrong-updating'),
|
||||
type: Type.Literal('danger')
|
||||
}),
|
||||
500: Type.Object({
|
||||
message: Type.Literal('flash.wrong-updating'),
|
||||
type: Type.Literal('danger')
|
||||
})
|
||||
}
|
||||
};
|
||||
49
api/src/schemas/socrates/ask-socrates.ts
Normal file
49
api/src/schemas/socrates/ask-socrates.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
|
||||
const socratesHint = Type.Object({
|
||||
text: Type.String(),
|
||||
failed: Type.Optional(Type.Boolean())
|
||||
});
|
||||
|
||||
const usageFields = {
|
||||
attempts: Type.Integer(),
|
||||
limit: Type.Integer()
|
||||
};
|
||||
|
||||
export const askSocrates = {
|
||||
body: Type.Object(
|
||||
{
|
||||
description: Type.String({ minLength: 1, maxLength: 2000 }),
|
||||
userInput: Type.Optional(Type.String({ minLength: 1, maxLength: 50000 })),
|
||||
seed: Type.String({ minLength: 1, maxLength: 50000 }),
|
||||
hints: Type.Array(socratesHint, { maxItems: 200 })
|
||||
},
|
||||
{ additionalProperties: false }
|
||||
),
|
||||
response: {
|
||||
200: Type.Object({
|
||||
hint: Type.String(),
|
||||
...usageFields
|
||||
}),
|
||||
400: Type.Object({
|
||||
error: Type.String(),
|
||||
type: Type.Literal('info'),
|
||||
...usageFields
|
||||
}),
|
||||
403: Type.Object({
|
||||
error: Type.String(),
|
||||
type: Type.Literal('danger'),
|
||||
...usageFields
|
||||
}),
|
||||
429: Type.Object({
|
||||
error: Type.String(),
|
||||
type: Type.Literal('info'),
|
||||
...usageFields
|
||||
}),
|
||||
500: Type.Object({
|
||||
error: Type.String(),
|
||||
type: Type.Literal('danger'),
|
||||
...usageFields
|
||||
})
|
||||
}
|
||||
};
|
||||
@@ -126,6 +126,7 @@ export const getSessionUser = {
|
||||
experience: Type.Optional(Type.Array(experience)),
|
||||
profileUI,
|
||||
sendQuincyEmail: Type.Union([Type.Null(), Type.Boolean()]), // // Tri-state: null (likely new user), true (subscribed), false (unsubscribed)
|
||||
socrates: Type.Optional(Type.Boolean()),
|
||||
theme: Type.String(),
|
||||
twitter: Type.Optional(Type.String()),
|
||||
bluesky: Type.Optional(Type.String()),
|
||||
|
||||
@@ -63,6 +63,8 @@ assert.ok(process.env.JWT_SECRET);
|
||||
assert.ok(process.env.STRIPE_SECRET_KEY);
|
||||
assert.ok(process.env.MONGOHQ_URL);
|
||||
assert.ok(process.env.COOKIE_SECRET);
|
||||
assert.ok(process.env.SOCRATES_API_KEY);
|
||||
assert.ok(process.env.SOCRATES_ENDPOINT);
|
||||
|
||||
const LOG_LEVELS: LogLevel[] = [
|
||||
'fatal',
|
||||
@@ -217,6 +219,8 @@ export const GROWTHBOOK_FASTIFY_API_HOST =
|
||||
process.env.GROWTHBOOK_FASTIFY_API_HOST;
|
||||
export const GROWTHBOOK_FASTIFY_CLIENT_KEY =
|
||||
process.env.GROWTHBOOK_FASTIFY_CLIENT_KEY;
|
||||
export const SOCRATES_API_KEY = process.env.SOCRATES_API_KEY;
|
||||
export const SOCRATES_ENDPOINT = process.env.SOCRATES_ENDPOINT;
|
||||
|
||||
function undefinedOrBool(val: string | undefined): undefined | boolean {
|
||||
if (!val) {
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
"SES_REGION",
|
||||
"SES_SECRET",
|
||||
"SHOW_UPCOMING_CHANGES",
|
||||
"SOCRATES_API_KEY",
|
||||
"SOCRATES_ENDPOINT",
|
||||
"STRIPE_SECRET_KEY"
|
||||
]
|
||||
},
|
||||
@@ -72,6 +74,8 @@
|
||||
"SES_REGION",
|
||||
"SES_SECRET",
|
||||
"SHOW_UPCOMING_CHANGES",
|
||||
"SOCRATES_API_KEY",
|
||||
"SOCRATES_ENDPOINT",
|
||||
"STRIPE_SECRET_KEY"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -124,6 +124,15 @@ const indexData: IndexData[] = [
|
||||
collection: 'MsUsername',
|
||||
indexes: [{ key: { userId: 1, id: 1 }, name: 'userId_1__id_1' }]
|
||||
},
|
||||
{
|
||||
collection: 'SocratesUsage',
|
||||
indexes: [
|
||||
{
|
||||
key: { userId: 1, date: 1 },
|
||||
name: 'userId_date_unique'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
collection: 'Survey',
|
||||
indexes: [{ key: { userId: 1 }, name: 'userId_1' }]
|
||||
|
||||
@@ -22,14 +22,8 @@
|
||||
"hashAttribute": "id",
|
||||
"seed": "landing-top-skill-focused",
|
||||
"hashVersion": 2,
|
||||
"variations": [
|
||||
false,
|
||||
true
|
||||
],
|
||||
"weights": [
|
||||
0.5,
|
||||
0.5
|
||||
],
|
||||
"variations": [false, true],
|
||||
"weights": [0.5, 0.5],
|
||||
"key": "landing-top-skill-focused",
|
||||
"meta": [
|
||||
{
|
||||
@@ -54,14 +48,8 @@
|
||||
"hashAttribute": "id",
|
||||
"seed": "replace-20-with-25",
|
||||
"hashVersion": 2,
|
||||
"variations": [
|
||||
false,
|
||||
true
|
||||
],
|
||||
"weights": [
|
||||
0.5,
|
||||
0.5
|
||||
],
|
||||
"variations": [false, true],
|
||||
"weights": [0.5, 0.5],
|
||||
"key": "replace-20-with-25",
|
||||
"meta": [
|
||||
{
|
||||
@@ -86,14 +74,8 @@
|
||||
"hashAttribute": "id",
|
||||
"seed": "show-modal-randomly",
|
||||
"hashVersion": 2,
|
||||
"variations": [
|
||||
false,
|
||||
true
|
||||
],
|
||||
"weights": [
|
||||
0.5,
|
||||
0.5
|
||||
],
|
||||
"variations": [false, true],
|
||||
"weights": [0.5, 0.5],
|
||||
"key": "show-modal-randomly",
|
||||
"meta": [
|
||||
{
|
||||
@@ -118,14 +100,8 @@
|
||||
"hashAttribute": "id",
|
||||
"seed": "landing-two-button-cta",
|
||||
"hashVersion": 2,
|
||||
"variations": [
|
||||
false,
|
||||
true
|
||||
],
|
||||
"weights": [
|
||||
0.5,
|
||||
0.5
|
||||
],
|
||||
"variations": [false, true],
|
||||
"weights": [0.5, 0.5],
|
||||
"key": "landing-two-button-cta",
|
||||
"meta": [
|
||||
{
|
||||
@@ -144,5 +120,8 @@
|
||||
},
|
||||
"disabled_blocks": {
|
||||
"defaultValue": []
|
||||
},
|
||||
"show-socrates": {
|
||||
"defaultValue": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"command-enter": "⌘ + Enter",
|
||||
"ctrl-enter": "Ctrl + Enter",
|
||||
"reset": "Reset",
|
||||
"ask-socrates": "Ask Socrates (beta)",
|
||||
"reset-step": "Reset This Step",
|
||||
"help": "Help",
|
||||
"get-help": "Get Help",
|
||||
@@ -381,6 +382,10 @@
|
||||
"confirm": "Confirm New Email",
|
||||
"weekly": "Send me Quincy's weekly email"
|
||||
},
|
||||
"socrates": {
|
||||
"p1": "Socrates",
|
||||
"p2": "Offers tailored hints based on your input in workshops. You can turn this off at any time."
|
||||
},
|
||||
"honesty": {
|
||||
"p1": "Before you can claim a verified certification, you must accept our Academic Honesty Pledge, which reads:",
|
||||
"p2": "\"I understand that plagiarism means copying someone else’s work and presenting the work as if it were my own, without clearly attributing the original author.\"",
|
||||
@@ -533,6 +538,18 @@
|
||||
"syntax-error": "Your code raised an error before any tests could run. Please fix it and try again.",
|
||||
"indentation-error": "Your code has an indentation error. You may need to add <code>pass</code> on a new line to form a valid block of code.",
|
||||
"sign-in-save": "Sign in to save your progress",
|
||||
"hints-used-today": "hints used today",
|
||||
"socrates-not-enabled": "Socrates is not enabled for your account.",
|
||||
"socrates-check-code-first": "Check your code before asking Socrates for a hint.",
|
||||
"socrates-code-passes": "Congratulations, your code passes! Press submit and continue to the next challenge.",
|
||||
"socrates-write-code-first": "Please write some code before asking Socrates for a hint.",
|
||||
"socrates-generic-error": "Something went wrong while asking Socrates. Please try again.",
|
||||
"socrates-no-access": "You do not have access to Socrates.",
|
||||
"socrates-daily-limit": "You have reached the daily hint limit. Please try again tomorrow.",
|
||||
"socrates-rate-limit": "You have reached the hint limit. Please wait a moment before trying again.",
|
||||
"socrates-unable-to-generate": "Socrates was unable to generate a hint. Please try again.",
|
||||
"socrates-unavailable": "Socrates is temporarily unavailable. Please try again later.",
|
||||
"socrates-invalid-request": "Something went wrong with your request. Please try again.",
|
||||
"download-solution": "Download my solution",
|
||||
"download-results": "Download my results",
|
||||
"percent-complete": "{{percent}}% complete",
|
||||
@@ -1037,6 +1054,7 @@
|
||||
"updated-themes": "We have updated your theme",
|
||||
"keyboard-shortcut-updated": "We have updated your keyboard shortcuts settings",
|
||||
"subscribe-to-quincy-updated": "We have updated your subscription to Quincy's email",
|
||||
"socrates-updated": "We have updated your Socrates settings",
|
||||
"portfolio-item-updated": "We have updated your portfolio",
|
||||
"experience-updated": "We have updated your experience",
|
||||
"email-invalid": "Email format is invalid",
|
||||
|
||||
31
client/src/assets/icons/stars.tsx
Normal file
31
client/src/assets/icons/stars.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
|
||||
function Stars(
|
||||
props: JSX.IntrinsicAttributes & React.SVGProps<SVGSVGElement>
|
||||
): JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='1.3rem'
|
||||
height='1.3rem'
|
||||
viewBox='0 0 15 15'
|
||||
fill='none'
|
||||
{...props}
|
||||
>
|
||||
<g clipPath='url(#clip0_2310_64)'>
|
||||
<path
|
||||
d='M9 3C10.1053 7.18421 10.8158 8.21053 15 9C10.7368 9.86842 10.1053 10.8158 9.1579 15C8.28947 10.7368 7.57895 10.0263 3 9C7.5 8.05263 7.97368 7.26316 9 3Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
<path
|
||||
d='M3 0C3.55263 2.09211 3.90789 2.60526 6 3C3.86842 3.43421 3.55263 3.90789 3.07895 6C2.64474 3.86842 2.28947 3.51316 0 3C2.25 2.52632 2.48684 2.13158 3 0Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
Stars.displayName = 'Stars';
|
||||
|
||||
export default Stars;
|
||||
@@ -11,6 +11,9 @@ import { initialState } from '../redux';
|
||||
const testUsername = 'testuser';
|
||||
|
||||
vi.mock('../utils/get-words');
|
||||
vi.mock('@growthbook/growthbook-react', () => ({
|
||||
useFeature: () => ({ on: false })
|
||||
}));
|
||||
|
||||
const { apiLocation } = envData;
|
||||
|
||||
|
||||
@@ -161,7 +161,8 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
|
||||
isHonest,
|
||||
sendQuincyEmail,
|
||||
username,
|
||||
keyboardShortcuts
|
||||
keyboardShortcuts,
|
||||
socrates
|
||||
} = user;
|
||||
|
||||
const sound = (store.get('fcc-sound') as boolean) ?? false;
|
||||
@@ -199,6 +200,7 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
|
||||
resetEditorLayout={resetEditorLayout}
|
||||
toggleKeyboardShortcuts={toggleKeyboardShortcuts}
|
||||
toggleSoundMode={toggleSoundMode}
|
||||
socrates={socrates}
|
||||
/>
|
||||
</ScrollElement>
|
||||
<Spacer size='m' />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFeature } from '@growthbook/growthbook-react';
|
||||
import { Button, Spacer } from '@freecodecamp/ui';
|
||||
import { FullWidthRow } from '../helpers';
|
||||
|
||||
@@ -7,11 +8,13 @@ import SoundSettings from './sound';
|
||||
import KeyboardShortcutsSettings from './keyboard-shortcuts';
|
||||
import ScrollbarWidthSettings from './scrollbar-width';
|
||||
import SectionHeader from './section-header';
|
||||
import SocratesSettings from './socrates';
|
||||
|
||||
type MiscSettingsProps = {
|
||||
keyboardShortcuts: boolean;
|
||||
sound: boolean;
|
||||
editorLayout: boolean | null;
|
||||
socrates: boolean;
|
||||
toggleKeyboardShortcuts: (keyboardShortcuts: boolean) => void;
|
||||
toggleSoundMode: (sound: boolean) => void;
|
||||
resetEditorLayout: () => void;
|
||||
@@ -23,14 +26,17 @@ const MiscSettings = ({
|
||||
editorLayout,
|
||||
resetEditorLayout,
|
||||
toggleKeyboardShortcuts,
|
||||
toggleSoundMode
|
||||
toggleSoundMode,
|
||||
socrates
|
||||
}: MiscSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const showSocratesFlag = useFeature('show-socrates').on;
|
||||
|
||||
return (
|
||||
<div className='account-settings'>
|
||||
<SectionHeader>{t('settings.headings.account')}</SectionHeader>
|
||||
<FullWidthRow>
|
||||
{showSocratesFlag && <SocratesSettings socrates={socrates} />}
|
||||
<SoundSettings sound={sound} toggleSoundMode={toggleSoundMode} />
|
||||
<KeyboardShortcutsSettings
|
||||
keyboardShortcuts={keyboardShortcuts}
|
||||
|
||||
58
client/src/components/settings/misc-settings.tsx
Normal file
58
client/src/components/settings/misc-settings.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Spacer } from '@freecodecamp/ui';
|
||||
import { FullWidthRow } from '../helpers';
|
||||
|
||||
import SoundSettings from '../../components/settings/sound';
|
||||
import KeyboardShortcutsSettings from '../../components/settings/keyboard-shortcuts';
|
||||
import ScrollbarWidthSettings from '../../components/settings/scrollbar-width';
|
||||
|
||||
type MiscSettingsProps = {
|
||||
keyboardShortcuts: boolean;
|
||||
sound: boolean;
|
||||
editorLayout: boolean | null;
|
||||
toggleKeyboardShortcuts: (keyboardShortcuts: boolean) => void;
|
||||
toggleSoundMode: (sound: boolean) => void;
|
||||
resetEditorLayout: () => void;
|
||||
};
|
||||
|
||||
const MiscSettings = ({
|
||||
keyboardShortcuts,
|
||||
sound,
|
||||
editorLayout,
|
||||
resetEditorLayout,
|
||||
toggleKeyboardShortcuts,
|
||||
toggleSoundMode
|
||||
}: MiscSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spacer size='m' />
|
||||
<FullWidthRow>
|
||||
<SoundSettings sound={sound} toggleSoundMode={toggleSoundMode} />
|
||||
<KeyboardShortcutsSettings
|
||||
keyboardShortcuts={keyboardShortcuts}
|
||||
toggleKeyboardShortcuts={toggleKeyboardShortcuts}
|
||||
explain={t('settings.shortcuts-explained')?.toString()}
|
||||
/>
|
||||
<ScrollbarWidthSettings />
|
||||
<label htmlFor='reset-layout-btn'>
|
||||
{t('settings.reset-editor-layout-tooltip')}
|
||||
</label>
|
||||
<Spacer size='xs' />
|
||||
<Button
|
||||
onClick={resetEditorLayout}
|
||||
id='reset-layout-btn'
|
||||
data-playwright-test-label='reset-layout-btn'
|
||||
disabled={!editorLayout}
|
||||
aria-disabled={!editorLayout}
|
||||
>
|
||||
{t('settings.reset-editor-layout')}
|
||||
</Button>
|
||||
</FullWidthRow>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MiscSettings;
|
||||
46
client/src/components/settings/socrates.tsx
Normal file
46
client/src/components/settings/socrates.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { withTranslation } from 'react-i18next';
|
||||
import { connect } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import type { Dispatch } from 'redux';
|
||||
|
||||
import { updateMySocrates as updateMySocratesAction } from '../../redux/settings/actions';
|
||||
import ToggleButtonSetting from './toggle-button-setting';
|
||||
|
||||
const mapStateToProps = () => ({});
|
||||
const mapDispatchToProps = (dispatch: Dispatch) =>
|
||||
bindActionCreators({ updateMySocrates: updateMySocratesAction }, dispatch);
|
||||
|
||||
type SocratesProps = {
|
||||
socrates: boolean;
|
||||
t: TFunction;
|
||||
updateMySocrates: (socrates: { socrates: boolean }) => void;
|
||||
};
|
||||
|
||||
function SocratesSettings({
|
||||
socrates,
|
||||
t,
|
||||
updateMySocrates
|
||||
}: SocratesProps): JSX.Element {
|
||||
return (
|
||||
<div className='socrates-settings'>
|
||||
<ToggleButtonSetting
|
||||
action={t('settings.socrates.p1')}
|
||||
explain={t('settings.socrates.p2')}
|
||||
flag={!!socrates}
|
||||
flagName='socrates'
|
||||
offLabel={t('buttons.off')}
|
||||
onLabel={t('buttons.on')}
|
||||
toggleFlag={() => updateMySocrates({ socrates: !socrates })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SocratesSettings.displayName = 'SocratesSettings';
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(withTranslation()(SocratesSettings));
|
||||
@@ -487,6 +487,8 @@ export const reducer = handleActions(
|
||||
payload ? spreadThePayloadOnUser(state, payload) : state,
|
||||
[settingsTypes.updateMyQuincyEmailComplete]: (state, { payload }) =>
|
||||
payload ? spreadThePayloadOnUser(state, payload) : state,
|
||||
[settingsTypes.updateMySocratesComplete]: (state, { payload }) =>
|
||||
payload ? spreadThePayloadOnUser(state, payload) : state,
|
||||
[settingsTypes.updateMyPortfolioComplete]: (state, { payload }) =>
|
||||
payload ? spreadThePayloadOnUser(state, payload) : state,
|
||||
[settingsTypes.updateMyExperienceComplete]: (state, { payload }) =>
|
||||
|
||||
@@ -451,6 +451,7 @@ export type User = {
|
||||
sound: boolean;
|
||||
theme: UserThemes;
|
||||
keyboardShortcuts: boolean;
|
||||
socrates: boolean;
|
||||
twitter: string;
|
||||
bluesky: string;
|
||||
username: string;
|
||||
@@ -516,6 +517,7 @@ export type ChallengeMeta = {
|
||||
title?: string;
|
||||
challengeType?: number;
|
||||
helpCategory: string;
|
||||
description?: string;
|
||||
disableLoopProtectTests: boolean;
|
||||
disableLoopProtectPreview: boolean;
|
||||
saveSubmissionToDB?: boolean;
|
||||
|
||||
@@ -27,6 +27,7 @@ export const isDonatingSelector = state => userSelector(state)?.isDonating;
|
||||
export const isOnlineSelector = state => state[MainApp].isOnline;
|
||||
export const isServerOnlineSelector = state => state[MainApp].isServerOnline;
|
||||
export const isSignedInSelector = state => !!userSelector(state);
|
||||
export const isSocratesOnSelector = state => userSelector(state)?.socrates;
|
||||
export const isDonationModalOpenSelector = state =>
|
||||
state[MainApp].showDonationModal;
|
||||
export const isSignoutModalOpenSelector = state =>
|
||||
|
||||
@@ -13,6 +13,7 @@ export const actionTypes = createTypes(
|
||||
...createAsyncTypes('updateMyKeyboardShortcuts'),
|
||||
...createAsyncTypes('updateMyHonesty'),
|
||||
...createAsyncTypes('updateMyQuincyEmail'),
|
||||
...createAsyncTypes('updateMySocrates'),
|
||||
...createAsyncTypes('updateMyPortfolio'),
|
||||
...createAsyncTypes('updateMyExperience'),
|
||||
...createAsyncTypes('submitProfileUI'),
|
||||
|
||||
@@ -73,6 +73,13 @@ export const updateMyQuincyEmailError = createAction(
|
||||
types.updateMyQuincyEmailError
|
||||
);
|
||||
|
||||
export const updateMySocrates = createAction(types.updateMySocrates);
|
||||
export const updateMySocratesComplete = createAction(
|
||||
types.updateMySocratesComplete,
|
||||
checkForSuccessPayload
|
||||
);
|
||||
export const updateMySocratesError = createAction(types.updateMySocratesError);
|
||||
|
||||
export const updateMyPortfolio = createAction(types.updateMyPortfolio);
|
||||
export const updateMyPortfolioComplete = createAction(
|
||||
types.updateMyPortfolioComplete,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
putUpdateMyExperience,
|
||||
putUpdateMyProfileUI,
|
||||
putUpdateMyQuincyEmail,
|
||||
putUpdateMySocrates,
|
||||
putUpdateMySocials,
|
||||
putUpdateMyUsername,
|
||||
putVerifyCert
|
||||
@@ -46,6 +47,8 @@ import {
|
||||
updateMyExperienceError,
|
||||
updateMyQuincyEmailComplete,
|
||||
updateMyQuincyEmailError,
|
||||
updateMySocratesComplete,
|
||||
updateMySocratesError,
|
||||
updateMySocialsComplete,
|
||||
updateMySocialsError,
|
||||
updateMySoundComplete,
|
||||
@@ -162,6 +165,16 @@ function* updateMyQuincyEmailSaga({ payload: update }) {
|
||||
}
|
||||
}
|
||||
|
||||
function* updateMySocratesSaga({ payload: update }) {
|
||||
try {
|
||||
const { data } = yield call(putUpdateMySocrates, update);
|
||||
yield put(updateMySocratesComplete({ ...data, payload: update }));
|
||||
yield put(createFlashMessage({ ...data }));
|
||||
} catch {
|
||||
yield put(updateMySocratesError);
|
||||
}
|
||||
}
|
||||
|
||||
function* updateMyPortfolioSaga({ payload: update }) {
|
||||
try {
|
||||
const { data } = yield call(putUpdateMyPortfolio, update);
|
||||
@@ -250,6 +263,7 @@ export function createSettingsSagas(types) {
|
||||
takeEvery(types.resetMyEditorLayout, resetMyEditorLayoutSaga),
|
||||
takeEvery(types.updateMyKeyboardShortcuts, updateMyKeyboardShortcutsSaga),
|
||||
takeEvery(types.updateMyQuincyEmail, updateMyQuincyEmailSaga),
|
||||
takeEvery(types.updateMySocrates, updateMySocratesSaga),
|
||||
takeEvery(types.updateMyPortfolio, updateMyPortfolioSaga),
|
||||
takeEvery(types.updateMyExperience, updateMyExperienceSaga),
|
||||
takeLatest(types.submitNewAbout, submitNewAboutSaga),
|
||||
|
||||
@@ -400,6 +400,7 @@ function ShowClassic({
|
||||
title,
|
||||
challengeType,
|
||||
helpCategory,
|
||||
description,
|
||||
...challengePaths
|
||||
});
|
||||
challengeMounted(challengeMeta.id);
|
||||
|
||||
@@ -150,7 +150,6 @@ function ShowCodeAlly({
|
||||
}
|
||||
}
|
||||
} = data;
|
||||
|
||||
const blockNameTitle = `${t(
|
||||
`intro:${superBlock}.blocks.${block}.title`
|
||||
)}: ${title}`;
|
||||
@@ -174,6 +173,7 @@ function ShowCodeAlly({
|
||||
title,
|
||||
challengeType,
|
||||
helpCategory,
|
||||
description,
|
||||
...challengePaths
|
||||
});
|
||||
challengeMounted(challengeMeta.id);
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: row;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.independent-lower-jaw .hint-container .hint-header svg {
|
||||
@@ -67,9 +68,7 @@
|
||||
}
|
||||
|
||||
.independent-lower-jaw .hint-container button {
|
||||
height: 30px;
|
||||
font-size: 1.5rem;
|
||||
min-width: 30px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -102,6 +101,7 @@
|
||||
font-size: 1rem;
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
width: max-content;
|
||||
top: -60px;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -113,6 +113,8 @@
|
||||
transition: opacity 0.5s ease 1s;
|
||||
}
|
||||
|
||||
/* .independent-lower-jaw */
|
||||
|
||||
.tooltiptext::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -143,6 +145,52 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.socrates-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.skeleton-line-1 {
|
||||
background-color: var(--tertiary-background);
|
||||
width: 100%;
|
||||
animation: pulse-1 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeleton-line-2 {
|
||||
background-color: var(--tertiary-background);
|
||||
width: 85%;
|
||||
animation: pulse-2 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-1 {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-2 {
|
||||
0% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.share-button-wrapper a {
|
||||
border: 1px solid var(--quaternary-color);
|
||||
padding: 2px 6px;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { useStaticQuery } from 'gatsby';
|
||||
|
||||
@@ -11,6 +12,11 @@ import { mockCurriculumData } from '../utils/__fixtures__/curriculum-data';
|
||||
import { render } from '../../../../utils/test-utils';
|
||||
|
||||
vi.mock('../../../components/Progress');
|
||||
|
||||
let showSocratesFlag = true;
|
||||
vi.mock('@growthbook/growthbook-react', () => ({
|
||||
useFeature: () => ({ on: showSocratesFlag })
|
||||
}));
|
||||
vi.mock('../utils/fetch-all-curriculum-data', () => ({
|
||||
useSubmit: () => vi.fn()
|
||||
}));
|
||||
@@ -30,19 +36,30 @@ const baseProps = {
|
||||
openHelpModal: vi.fn(),
|
||||
openResetModal: vi.fn(),
|
||||
executeChallenge: vi.fn(),
|
||||
submitChallenge: vi.fn(),
|
||||
askSocrates: vi.fn(),
|
||||
saveChallenge: vi.fn(),
|
||||
tests: passingTests,
|
||||
isSignedIn: true,
|
||||
challengeMeta: baseChallengeMeta,
|
||||
completedPercent: 100,
|
||||
completedChallengeIds: ['id-1', 'test-challenge-id'],
|
||||
currentBlockIds: ['id-1', 'test-challenge-id']
|
||||
currentBlockIds: ['id-1', 'test-challenge-id'],
|
||||
hasSocratesAccess: false,
|
||||
socratesHintState: {
|
||||
hint: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
attempts: null,
|
||||
limit: null
|
||||
}
|
||||
};
|
||||
|
||||
vi.mock('../../../utils/get-words');
|
||||
|
||||
describe('<IndependentLowerJaw />', () => {
|
||||
beforeEach(() => {
|
||||
showSocratesFlag = true;
|
||||
vi.mocked(useStaticQuery).mockReturnValue(mockCurriculumData);
|
||||
});
|
||||
|
||||
@@ -83,4 +100,61 @@ describe('<IndependentLowerJaw />', () => {
|
||||
|
||||
expect(screen.queryByTestId('share-on-x')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows socrates button when hasSocratesAccess is true and flag is on', () => {
|
||||
render(
|
||||
<IndependentLowerJaw {...baseProps} hasSocratesAccess={true} />,
|
||||
createStore()
|
||||
);
|
||||
|
||||
expect(screen.getByText('buttons.ask-socrates')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides socrates button when show-socrates flag is off', () => {
|
||||
showSocratesFlag = false;
|
||||
|
||||
render(
|
||||
<IndependentLowerJaw {...baseProps} hasSocratesAccess={true} />,
|
||||
createStore()
|
||||
);
|
||||
|
||||
expect(screen.queryByText('buttons.ask-socrates')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides socrates button when hasSocratesAccess is false', () => {
|
||||
render(
|
||||
<IndependentLowerJaw {...baseProps} hasSocratesAccess={false} />,
|
||||
createStore()
|
||||
);
|
||||
|
||||
expect(screen.queryByText('buttons.ask-socrates')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays usage counter when attempts and limit are set', async () => {
|
||||
const failingTests: Test[] = [
|
||||
{ pass: false, err: 'fail', text: 'test', testString: 'test' }
|
||||
];
|
||||
|
||||
render(
|
||||
<IndependentLowerJaw
|
||||
{...baseProps}
|
||||
tests={failingTests}
|
||||
hasSocratesAccess={true}
|
||||
socratesHintState={{
|
||||
hint: 'Try a closing tag.',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
attempts: 2,
|
||||
limit: 3
|
||||
}}
|
||||
/>,
|
||||
createStore()
|
||||
);
|
||||
|
||||
// Click the socrates button to open the results panel
|
||||
await userEvent.click(screen.getByRole('button', { name: /ask-socrates/ }));
|
||||
|
||||
expect(screen.getByText(/2\/3/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/learn\.hints-used-today/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,9 @@ import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import { Button, Spacer } from '@freecodecamp/ui';
|
||||
import { useFeature } from '@growthbook/growthbook-react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faLightbulb,
|
||||
@@ -15,17 +17,19 @@ import {
|
||||
import Progress from '../../../components/Progress';
|
||||
import {
|
||||
completedChallengesIdsSelector,
|
||||
isSignedInSelector
|
||||
isSignedInSelector,
|
||||
isSocratesOnSelector
|
||||
} from '../../../redux/selectors';
|
||||
import { ChallengeMeta, Test } from '../../../redux/prop-types';
|
||||
import {
|
||||
challengeMetaSelector,
|
||||
challengeTestsSelector,
|
||||
completedPercentageSelector,
|
||||
currentBlockIdsSelector
|
||||
currentBlockIdsSelector,
|
||||
socratesHintStateSelector
|
||||
} from '../redux/selectors';
|
||||
import { apiLocation } from '../../../../config/env.json';
|
||||
import { openModal, executeChallenge } from '../redux/actions';
|
||||
import { openModal, executeChallenge, askSocrates } from '../redux/actions';
|
||||
import { saveChallenge } from '../../../redux/actions';
|
||||
import Help from '../../../assets/icons/help';
|
||||
import callGA from '../../../analytics/call-ga';
|
||||
@@ -33,6 +37,15 @@ import { Share } from '../../../components/share';
|
||||
import { useSubmit } from '../utils/fetch-all-curriculum-data';
|
||||
|
||||
import './independent-lower-jaw.css';
|
||||
import Stars from '../../../assets/icons/stars';
|
||||
|
||||
type SocratesHintState = {
|
||||
hint: null | string;
|
||||
isLoading: boolean;
|
||||
error: null | string;
|
||||
attempts: null | number;
|
||||
limit: null | number;
|
||||
};
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
challengeTestsSelector,
|
||||
@@ -41,26 +54,33 @@ const mapStateToProps = createSelector(
|
||||
completedPercentageSelector,
|
||||
completedChallengesIdsSelector,
|
||||
currentBlockIdsSelector,
|
||||
socratesHintStateSelector,
|
||||
isSocratesOnSelector,
|
||||
(
|
||||
tests: Test[],
|
||||
isSignedIn: boolean,
|
||||
challengeMeta: ChallengeMeta,
|
||||
completedPercent: number,
|
||||
completedChallengeIds: string[],
|
||||
currentBlockIds: string[]
|
||||
currentBlockIds: string[],
|
||||
socratesHintState: SocratesHintState,
|
||||
hasSocratesAccess: boolean
|
||||
) => ({
|
||||
tests,
|
||||
isSignedIn,
|
||||
challengeMeta,
|
||||
completedPercent,
|
||||
completedChallengeIds,
|
||||
currentBlockIds
|
||||
currentBlockIds,
|
||||
socratesHintState,
|
||||
hasSocratesAccess
|
||||
})
|
||||
);
|
||||
|
||||
const mapDispatchToProps = {
|
||||
openHelpModal: () => openModal('help'),
|
||||
openResetModal: () => openModal('reset'),
|
||||
askSocrates: () => askSocrates(),
|
||||
executeChallenge,
|
||||
saveChallenge
|
||||
};
|
||||
@@ -69,6 +89,7 @@ interface IndependentLowerJawProps {
|
||||
openHelpModal: () => void;
|
||||
openResetModal: () => void;
|
||||
executeChallenge: () => void;
|
||||
askSocrates: () => void;
|
||||
saveChallenge: () => void;
|
||||
tests: Test[];
|
||||
isSignedIn: boolean;
|
||||
@@ -76,10 +97,13 @@ interface IndependentLowerJawProps {
|
||||
completedPercent: number;
|
||||
completedChallengeIds: string[];
|
||||
currentBlockIds: string[];
|
||||
socratesHintState: SocratesHintState;
|
||||
hasSocratesAccess: boolean;
|
||||
}
|
||||
export function IndependentLowerJaw({
|
||||
openHelpModal,
|
||||
openResetModal,
|
||||
askSocrates,
|
||||
executeChallenge,
|
||||
saveChallenge,
|
||||
tests,
|
||||
@@ -87,13 +111,17 @@ export function IndependentLowerJaw({
|
||||
challengeMeta,
|
||||
completedPercent,
|
||||
completedChallengeIds,
|
||||
currentBlockIds
|
||||
currentBlockIds,
|
||||
socratesHintState,
|
||||
hasSocratesAccess
|
||||
}: IndependentLowerJawProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const showSocratesFlag = useFeature('show-socrates').on;
|
||||
const submitChallenge = useSubmit();
|
||||
const firstFailedTest = tests.find(test => !!test.err);
|
||||
const hint = firstFailedTest?.message;
|
||||
const [showHint, setShowHint] = React.useState(false);
|
||||
const [showSocratesResults, setShowSocratesResults] = React.useState(false);
|
||||
const [showSubmissionHint, setShowSubmissionHint] = React.useState(true);
|
||||
const signInLinkRef = React.useRef<HTMLAnchorElement>(null);
|
||||
const submitButtonRef = React.useRef<HTMLButtonElement>(null);
|
||||
@@ -132,6 +160,7 @@ export function IndependentLowerJaw({
|
||||
|
||||
const handleCheckButtonClick = () => {
|
||||
setWasCheckButtonClicked(true);
|
||||
setShowSocratesResults(false);
|
||||
executeChallenge();
|
||||
};
|
||||
|
||||
@@ -141,6 +170,14 @@ export function IndependentLowerJaw({
|
||||
? t('buttons.command-enter')
|
||||
: t('buttons.ctrl-enter');
|
||||
|
||||
const askSocratesAttempt = () => {
|
||||
setShowSocratesResults(true);
|
||||
setShowHint(false);
|
||||
setShowSubmissionHint(false);
|
||||
if (socratesHintState.isLoading) return;
|
||||
askSocrates();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className='independent-lower-jaw'
|
||||
@@ -164,7 +201,53 @@ export function IndependentLowerJaw({
|
||||
<span className='tooltiptext'> {t('buttons.close')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div dangerouslySetInnerHTML={{ __html: hint }} />
|
||||
<div
|
||||
className='hint-body'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHtml(hint, {
|
||||
allowedTags: ['b', 'i', 'em', 'strong', 'code', 'wbr']
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showSocratesResults && (
|
||||
<div className='hint-container'>
|
||||
<div className='hint-header'>
|
||||
<Stars />
|
||||
<button
|
||||
className={'tooltip'}
|
||||
onClick={() => setShowSocratesResults(false)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faClose} />
|
||||
<span className='tooltiptext'> {t('buttons.close')}</span>
|
||||
</button>
|
||||
</div>
|
||||
{socratesHintState.isLoading ? (
|
||||
<div className='socrates-skeleton'>
|
||||
<div className='skeleton-line skeleton-line-1' />
|
||||
<div className='skeleton-line skeleton-line-2' />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className='hint-body'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHtml(
|
||||
socratesHintState.hint || socratesHintState.error || '',
|
||||
{
|
||||
allowedTags: ['b', 'i', 'em', 'strong', 'code', 'wbr']
|
||||
}
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{socratesHintState.attempts !== null &&
|
||||
socratesHintState.limit !== null && (
|
||||
<div className='socrates-usage-info'>
|
||||
{socratesHintState.attempts}/{socratesHintState.limit}{' '}
|
||||
{t('learn.hints-used-today')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isChallengeComplete && showSubmissionHint && (
|
||||
@@ -254,6 +337,16 @@ export function IndependentLowerJaw({
|
||||
)}
|
||||
</div>
|
||||
<div className='action-row-right'>
|
||||
{hasSocratesAccess && showSocratesFlag && (
|
||||
<button
|
||||
type='button'
|
||||
className='icon-button tooltip socrates-button'
|
||||
onClick={askSocratesAttempt}
|
||||
>
|
||||
<Stars />
|
||||
<span className='tooltiptext'>{t('buttons.ask-socrates')}</span>
|
||||
</button>
|
||||
)}
|
||||
{showRevertButton ? (
|
||||
<>
|
||||
<button
|
||||
@@ -291,7 +384,7 @@ export function IndependentLowerJaw({
|
||||
)}
|
||||
<button
|
||||
type='button'
|
||||
className='icon-botton tooltip'
|
||||
className='icon-button tooltip'
|
||||
data-playwright-test-label='independentLowerJaw-help-button'
|
||||
aria-label={t('buttons.help')}
|
||||
onClick={openHelpModal}
|
||||
|
||||
@@ -185,7 +185,7 @@ function ShowExam(props: ShowExamProps) {
|
||||
challengeMounted,
|
||||
data: {
|
||||
challengeNode: {
|
||||
challenge: { tests, challengeType, helpCategory, title }
|
||||
challenge: { tests, challengeType, helpCategory, description, title }
|
||||
}
|
||||
},
|
||||
pageContext: { challengeMeta },
|
||||
@@ -201,6 +201,7 @@ function ShowExam(props: ShowExamProps) {
|
||||
title,
|
||||
challengeType,
|
||||
helpCategory,
|
||||
description,
|
||||
...challengePaths
|
||||
});
|
||||
challengeMounted(challengeMeta.id);
|
||||
|
||||
@@ -128,6 +128,7 @@ const ShowFillInTheBlank = ({
|
||||
title,
|
||||
challengeType,
|
||||
helpCategory,
|
||||
description,
|
||||
...challengePaths
|
||||
});
|
||||
challengeMounted(challengeMeta.id);
|
||||
|
||||
@@ -157,6 +157,7 @@ const ShowGeneric = ({
|
||||
title,
|
||||
challengeType,
|
||||
helpCategory,
|
||||
description,
|
||||
...challengePaths
|
||||
});
|
||||
challengeMounted(challengeMeta.id);
|
||||
|
||||
@@ -98,7 +98,7 @@ function MsTrophy(props: MsTrophyProps) {
|
||||
challengeMounted,
|
||||
data: {
|
||||
challengeNode: {
|
||||
challenge: { tests, title, challengeType, helpCategory }
|
||||
challenge: { tests, title, challengeType, helpCategory, description }
|
||||
}
|
||||
},
|
||||
pageContext: { challengeMeta },
|
||||
@@ -114,6 +114,7 @@ function MsTrophy(props: MsTrophyProps) {
|
||||
title,
|
||||
challengeType,
|
||||
helpCategory,
|
||||
description,
|
||||
...challengePaths
|
||||
});
|
||||
challengeMounted(challengeMeta.id);
|
||||
|
||||
@@ -112,7 +112,7 @@ const ShowBackEnd = (props: BackEndProps) => {
|
||||
updateChallengeMeta,
|
||||
data: {
|
||||
challengeNode: {
|
||||
challenge: { challengeType, helpCategory, tests, title }
|
||||
challenge: { challengeType, helpCategory, description, tests, title }
|
||||
}
|
||||
},
|
||||
pageContext: { challengeMeta }
|
||||
@@ -127,6 +127,7 @@ const ShowBackEnd = (props: BackEndProps) => {
|
||||
title,
|
||||
challengeType,
|
||||
helpCategory,
|
||||
description,
|
||||
...challengePaths
|
||||
});
|
||||
challengeMounted(challengeMeta.id);
|
||||
|
||||
@@ -86,7 +86,7 @@ const ShowFrontEndProject = (props: ProjectProps) => {
|
||||
challengeMounted,
|
||||
data: {
|
||||
challengeNode: {
|
||||
challenge: { tests, title, challengeType, helpCategory }
|
||||
challenge: { tests, title, challengeType, helpCategory, description }
|
||||
}
|
||||
},
|
||||
pageContext: { challengeMeta },
|
||||
@@ -102,6 +102,7 @@ const ShowFrontEndProject = (props: ProjectProps) => {
|
||||
title,
|
||||
challengeType,
|
||||
helpCategory,
|
||||
description,
|
||||
...challengePaths
|
||||
});
|
||||
challengeMounted(challengeMeta.id);
|
||||
|
||||
@@ -236,6 +236,7 @@ const ShowQuiz = ({
|
||||
title,
|
||||
challengeType,
|
||||
helpCategory,
|
||||
description,
|
||||
...challengePaths
|
||||
});
|
||||
challengeMounted(challengeMeta.id);
|
||||
|
||||
@@ -44,7 +44,8 @@ export const actionTypes = createTypes(
|
||||
'setEditorFocusability',
|
||||
'toggleVisibleEditor',
|
||||
...createAsyncTypes('submitChallenge'),
|
||||
...createAsyncTypes('executeChallenge')
|
||||
...createAsyncTypes('executeChallenge'),
|
||||
...createAsyncTypes('askSocrates')
|
||||
],
|
||||
ns
|
||||
);
|
||||
|
||||
@@ -59,6 +59,13 @@ export const executeChallenge = createAction(actionTypes.executeChallenge);
|
||||
export const executeChallengeComplete = createAction(
|
||||
actionTypes.executeChallengeComplete
|
||||
);
|
||||
|
||||
export const askSocrates = createAction(actionTypes.askSocrates);
|
||||
export const askSocratesComplete = createAction(
|
||||
actionTypes.askSocratesComplete
|
||||
);
|
||||
export const askSocratesError = createAction(actionTypes.askSocratesError);
|
||||
|
||||
export const resetChallenge = createAction(actionTypes.resetChallenge);
|
||||
export const stopResetting = createAction(actionTypes.stopResetting);
|
||||
export const submitChallenge = createAction(actionTypes.submitChallenge);
|
||||
|
||||
129
client/src/templates/Challenges/redux/ask-socrates-saga.js
Normal file
129
client/src/templates/Challenges/redux/ask-socrates-saga.js
Normal file
@@ -0,0 +1,129 @@
|
||||
import i18next from 'i18next';
|
||||
import { takeEvery, select, call, put } from 'redux-saga/effects';
|
||||
import {
|
||||
challengeDataSelector,
|
||||
challengeTestsSelector,
|
||||
challengeMetaSelector
|
||||
} from './selectors';
|
||||
|
||||
import { buildChallenge } from '@freecodecamp/challenge-builder/build';
|
||||
import { getSocratesHint } from '../../../utils/ajax';
|
||||
|
||||
import { isSocratesOnSelector } from '../../../redux/selectors';
|
||||
import { askSocratesError, askSocratesComplete } from './actions';
|
||||
|
||||
// Maps server-side error keys to client-side translation keys.
|
||||
const serverErrorKeyMap = {
|
||||
'socrates-no-access': 'learn.socrates-no-access',
|
||||
'socrates-daily-limit': 'learn.socrates-daily-limit',
|
||||
'socrates-rate-limit': 'learn.socrates-rate-limit',
|
||||
'socrates-unable-to-generate': 'learn.socrates-unable-to-generate',
|
||||
'socrates-unavailable': 'learn.socrates-unavailable',
|
||||
'socrates-invalid-request': 'learn.socrates-invalid-request'
|
||||
};
|
||||
|
||||
function translateServerError(errorKey) {
|
||||
const translationKey = serverErrorKeyMap[errorKey];
|
||||
return translationKey ? i18next.t(translationKey) : errorKey;
|
||||
}
|
||||
|
||||
export function* askSocratesSaga() {
|
||||
const isSocratesOn = yield select(isSocratesOnSelector);
|
||||
if (!isSocratesOn) {
|
||||
yield put(
|
||||
askSocratesError({
|
||||
error: i18next.t('learn.socrates-not-enabled')
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const challengeData = yield select(challengeDataSelector);
|
||||
const tests = yield select(challengeTestsSelector);
|
||||
const { description } = yield select(challengeMetaSelector);
|
||||
|
||||
const hasCheckedCode = tests.some(test => test.pass || test.err);
|
||||
if (!hasCheckedCode) {
|
||||
yield put(
|
||||
askSocratesError({
|
||||
error: i18next.t('learn.socrates-check-code-first')
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const allTestsPass = tests.every(test => test.pass);
|
||||
if (allTestsPass) {
|
||||
yield put(
|
||||
askSocratesError({
|
||||
error: i18next.t('learn.socrates-code-passes')
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const buildData = yield call(buildChallenge, challengeData);
|
||||
const { sources, build } = buildData;
|
||||
const seed = build;
|
||||
const userInput = sources?.editableContents;
|
||||
|
||||
if (!seed) {
|
||||
yield put(
|
||||
askSocratesError({
|
||||
error: i18next.t('learn.socrates-write-code-first')
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const hints = Array.isArray(tests)
|
||||
? tests.map(({ text, err }) => {
|
||||
const item = { text };
|
||||
if (err) item.failed = true;
|
||||
return item;
|
||||
})
|
||||
: [];
|
||||
|
||||
const optimizedPayload = {
|
||||
seed,
|
||||
description,
|
||||
hints
|
||||
};
|
||||
|
||||
if (userInput) {
|
||||
optimizedPayload.userInput = userInput;
|
||||
}
|
||||
|
||||
const response = yield call(getSocratesHint, optimizedPayload);
|
||||
const responseData = response?.data;
|
||||
const error = responseData?.error;
|
||||
if (error) {
|
||||
yield put(
|
||||
askSocratesError({
|
||||
error: translateServerError(error),
|
||||
attempts: responseData?.attempts,
|
||||
limit: responseData?.limit
|
||||
})
|
||||
);
|
||||
} else {
|
||||
yield put(
|
||||
askSocratesComplete({
|
||||
hint: responseData.hint,
|
||||
attempts: responseData.attempts,
|
||||
limit: responseData.limit
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
yield put(
|
||||
askSocratesError({
|
||||
error: i18next.t('learn.socrates-generic-error')
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function createAskSocratesSaga(types) {
|
||||
return [takeEvery(types.askSocrates, askSocratesSaga)];
|
||||
}
|
||||
262
client/src/templates/Challenges/redux/ask-socrates-saga.test.js
Normal file
262
client/src/templates/Challenges/redux/ask-socrates-saga.test.js
Normal file
@@ -0,0 +1,262 @@
|
||||
/* eslint-disable vitest/expect-expect */
|
||||
import { expectSaga } from 'redux-saga-test-plan';
|
||||
import * as matchers from 'redux-saga-test-plan/matchers';
|
||||
import { throwError } from 'redux-saga-test-plan/providers';
|
||||
import { describe, it, vi } from 'vitest';
|
||||
|
||||
import { askSocratesSaga } from './ask-socrates-saga';
|
||||
|
||||
vi.mock('i18next', async () => ({
|
||||
default: {
|
||||
t: key => key
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('@freecodecamp/challenge-builder/build', () => ({
|
||||
buildChallenge: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/ajax', () => ({
|
||||
getSocratesHint: vi.fn()
|
||||
}));
|
||||
|
||||
const baseState = {
|
||||
app: {
|
||||
user: {
|
||||
sessionUser: {
|
||||
socrates: true
|
||||
}
|
||||
}
|
||||
},
|
||||
challenge: {
|
||||
challengeTests: [
|
||||
{ text: 'Test 1', pass: false, err: 'Expected true' },
|
||||
{ text: 'Test 2', pass: true }
|
||||
],
|
||||
challengeMeta: { description: 'Make the text say hello' },
|
||||
challengeFiles: {
|
||||
indexhtml: {
|
||||
contents: '<h1>Hello</h1>',
|
||||
editableContents: 'Hello world',
|
||||
ext: 'html',
|
||||
key: 'indexhtml'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function reducer(state = baseState) {
|
||||
return state;
|
||||
}
|
||||
|
||||
describe('askSocratesSaga', () => {
|
||||
it('dispatches error when socrates is not enabled', () => {
|
||||
const state = {
|
||||
...baseState,
|
||||
app: {
|
||||
user: {
|
||||
sessionUser: {
|
||||
socrates: false
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return expectSaga(askSocratesSaga)
|
||||
.withReducer(reducer, state)
|
||||
.put({
|
||||
type: 'challenge.askSocratesError',
|
||||
payload: { error: 'learn.socrates-not-enabled' }
|
||||
})
|
||||
.silentRun();
|
||||
});
|
||||
|
||||
it('dispatches error when code has not been checked', () => {
|
||||
const state = {
|
||||
...baseState,
|
||||
challenge: {
|
||||
...baseState.challenge,
|
||||
challengeTests: [
|
||||
{ text: 'Test 1', pass: false },
|
||||
{ text: 'Test 2', pass: false }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
return expectSaga(askSocratesSaga)
|
||||
.withReducer(reducer, state)
|
||||
.put({
|
||||
type: 'challenge.askSocratesError',
|
||||
payload: {
|
||||
error: 'learn.socrates-check-code-first'
|
||||
}
|
||||
})
|
||||
.silentRun();
|
||||
});
|
||||
|
||||
it('dispatches error when all tests pass', () => {
|
||||
const state = {
|
||||
...baseState,
|
||||
challenge: {
|
||||
...baseState.challenge,
|
||||
challengeTests: [
|
||||
{ text: 'Test 1', pass: true },
|
||||
{ text: 'Test 2', pass: true }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
return expectSaga(askSocratesSaga)
|
||||
.withReducer(reducer, state)
|
||||
.put({
|
||||
type: 'challenge.askSocratesError',
|
||||
payload: {
|
||||
error: 'learn.socrates-code-passes'
|
||||
}
|
||||
})
|
||||
.silentRun();
|
||||
});
|
||||
|
||||
it('dispatches error when buildChallenge returns no seed', async () => {
|
||||
const { buildChallenge } = await import(
|
||||
'@freecodecamp/challenge-builder/build'
|
||||
);
|
||||
|
||||
return expectSaga(askSocratesSaga)
|
||||
.withReducer(reducer)
|
||||
.provide([
|
||||
[
|
||||
matchers.call.fn(buildChallenge),
|
||||
{ sources: { editableContents: 'Hello world' }, build: '' }
|
||||
]
|
||||
])
|
||||
.put({
|
||||
type: 'challenge.askSocratesError',
|
||||
payload: {
|
||||
error: 'learn.socrates-write-code-first'
|
||||
}
|
||||
})
|
||||
.silentRun();
|
||||
});
|
||||
|
||||
it('dispatches complete without userInput when editableContents is empty', async () => {
|
||||
const { buildChallenge } = await import(
|
||||
'@freecodecamp/challenge-builder/build'
|
||||
);
|
||||
const { getSocratesHint } = await import('../../../utils/ajax');
|
||||
|
||||
return expectSaga(askSocratesSaga)
|
||||
.withReducer(reducer)
|
||||
.provide([
|
||||
[
|
||||
matchers.call.fn(buildChallenge),
|
||||
{
|
||||
sources: { editableContents: '', contents: '' },
|
||||
build: '<h1>Hello</h1>'
|
||||
}
|
||||
],
|
||||
[
|
||||
matchers.call.fn(getSocratesHint),
|
||||
{
|
||||
data: { hint: 'A hint.', attempts: 1, limit: 3 }
|
||||
}
|
||||
]
|
||||
])
|
||||
.put({
|
||||
type: 'challenge.askSocratesComplete',
|
||||
payload: { hint: 'A hint.', attempts: 1, limit: 3 }
|
||||
})
|
||||
.silentRun();
|
||||
});
|
||||
|
||||
it('dispatches complete with hint on successful API response', async () => {
|
||||
const { buildChallenge } = await import(
|
||||
'@freecodecamp/challenge-builder/build'
|
||||
);
|
||||
const { getSocratesHint } = await import('../../../utils/ajax');
|
||||
|
||||
return expectSaga(askSocratesSaga)
|
||||
.withReducer(reducer)
|
||||
.provide([
|
||||
[
|
||||
matchers.call.fn(buildChallenge),
|
||||
{
|
||||
sources: { editableContents: 'Hello world' },
|
||||
build: '<h1>Hello</h1>'
|
||||
}
|
||||
],
|
||||
[
|
||||
matchers.call.fn(getSocratesHint),
|
||||
{
|
||||
data: { hint: 'Try adding a closing tag.', attempts: 1, limit: 3 }
|
||||
}
|
||||
]
|
||||
])
|
||||
.put({
|
||||
type: 'challenge.askSocratesComplete',
|
||||
payload: { hint: 'Try adding a closing tag.', attempts: 1, limit: 3 }
|
||||
})
|
||||
.silentRun();
|
||||
});
|
||||
|
||||
it('dispatches error with attempts/limit on API error response', async () => {
|
||||
const { buildChallenge } = await import(
|
||||
'@freecodecamp/challenge-builder/build'
|
||||
);
|
||||
const { getSocratesHint } = await import('../../../utils/ajax');
|
||||
|
||||
return expectSaga(askSocratesSaga)
|
||||
.withReducer(reducer)
|
||||
.provide([
|
||||
[
|
||||
matchers.call.fn(buildChallenge),
|
||||
{
|
||||
sources: { editableContents: 'Hello world' },
|
||||
build: '<h1>Hello</h1>'
|
||||
}
|
||||
],
|
||||
[
|
||||
matchers.call.fn(getSocratesHint),
|
||||
{
|
||||
data: {
|
||||
error: 'Daily limit reached.',
|
||||
attempts: 3,
|
||||
limit: 3
|
||||
}
|
||||
}
|
||||
]
|
||||
])
|
||||
.put({
|
||||
type: 'challenge.askSocratesError',
|
||||
payload: { error: 'Daily limit reached.', attempts: 3, limit: 3 }
|
||||
})
|
||||
.silentRun();
|
||||
});
|
||||
|
||||
it('dispatches generic error when API call throws', async () => {
|
||||
const { buildChallenge } = await import(
|
||||
'@freecodecamp/challenge-builder/build'
|
||||
);
|
||||
const { getSocratesHint } = await import('../../../utils/ajax');
|
||||
|
||||
return expectSaga(askSocratesSaga)
|
||||
.withReducer(reducer)
|
||||
.provide([
|
||||
[
|
||||
matchers.call.fn(buildChallenge),
|
||||
{
|
||||
sources: { editableContents: 'Hello world' },
|
||||
build: '<h1>Hello</h1>'
|
||||
}
|
||||
],
|
||||
[matchers.call.fn(getSocratesHint), throwError(new Error('Network'))]
|
||||
])
|
||||
.put({
|
||||
type: 'challenge.askSocratesError',
|
||||
payload: {
|
||||
error: 'learn.socrates-generic-error'
|
||||
}
|
||||
})
|
||||
.silentRun();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import codeStorageEpic from './code-storage-epic';
|
||||
import completionEpic from './completion-epic';
|
||||
import createQuestionEpic from './create-question-epic';
|
||||
import { createCurrentChallengeSaga } from './current-challenge-saga';
|
||||
import { createAskSocratesSaga } from './ask-socrates-saga';
|
||||
import { createExecuteChallengeSaga } from './execute-challenge-saga';
|
||||
|
||||
export { ns };
|
||||
@@ -26,7 +27,8 @@ const initialState = {
|
||||
nextChallengePath: '/',
|
||||
prevChallengePath: '/',
|
||||
challengeType: -1,
|
||||
saveSubmissionToDB: false
|
||||
saveSubmissionToDB: false,
|
||||
description: ''
|
||||
},
|
||||
challengeTests: [],
|
||||
consoleOut: [],
|
||||
@@ -58,14 +60,22 @@ const initialState = {
|
||||
successMessage: 'Happy Coding!',
|
||||
isAdvancing: false,
|
||||
chapterSlug: '',
|
||||
isSubmitting: false
|
||||
isSubmitting: false,
|
||||
socratesHintState: {
|
||||
hint: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
attempts: null,
|
||||
limit: null
|
||||
}
|
||||
};
|
||||
|
||||
export const epics = [completionEpic, createQuestionEpic, codeStorageEpic];
|
||||
|
||||
export const sagas = [
|
||||
...createExecuteChallengeSaga(actionTypes),
|
||||
...createCurrentChallengeSaga(actionTypes)
|
||||
...createCurrentChallengeSaga(actionTypes),
|
||||
...createAskSocratesSaga(actionTypes)
|
||||
];
|
||||
|
||||
export const reducer = handleActions(
|
||||
@@ -277,6 +287,36 @@ export const reducer = handleActions(
|
||||
...state,
|
||||
isExecuting: false
|
||||
}),
|
||||
[actionTypes.askSocrates]: state => ({
|
||||
...state,
|
||||
socratesHintState: {
|
||||
hint: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
attempts: state.socratesHintState.attempts,
|
||||
limit: state.socratesHintState.limit
|
||||
}
|
||||
}),
|
||||
[actionTypes.askSocratesComplete]: (state, { payload }) => ({
|
||||
...state,
|
||||
socratesHintState: {
|
||||
hint: payload.hint,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
attempts: payload.attempts,
|
||||
limit: payload.limit
|
||||
}
|
||||
}),
|
||||
[actionTypes.askSocratesError]: (state, { payload }) => ({
|
||||
...state,
|
||||
socratesHintState: {
|
||||
hint: null,
|
||||
isLoading: false,
|
||||
error: payload.error,
|
||||
attempts: payload.attempts ?? state.socratesHintState.attempts,
|
||||
limit: payload.limit ?? state.socratesHintState.limit
|
||||
}
|
||||
}),
|
||||
[actionTypes.setEditorFocusability]: (state, { payload }) => ({
|
||||
...state,
|
||||
canFocusEditor: payload
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ns } from './action-types';
|
||||
|
||||
export const challengeFilesSelector = state => state[ns].challengeFiles;
|
||||
export const challengeMetaSelector = state => state[ns].challengeMeta;
|
||||
export const socratesHintStateSelector = state => state[ns].socratesHintState;
|
||||
export const challengeHooksSelector = state => state[ns].challengeHooks;
|
||||
export const challengeTestsSelector = state => state[ns].challengeTests;
|
||||
export const consoleOutputSelector = state => {
|
||||
|
||||
103
client/src/templates/Challenges/redux/socrates-reducer.test.js
Normal file
103
client/src/templates/Challenges/redux/socrates-reducer.test.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { reducer } from './index';
|
||||
import { actionTypes } from './action-types';
|
||||
|
||||
vi.mock('../../../utils/get-words');
|
||||
|
||||
const baseState = {
|
||||
socratesHintState: {
|
||||
hint: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
attempts: null,
|
||||
limit: null
|
||||
}
|
||||
};
|
||||
|
||||
describe('socratesHintState reducer', () => {
|
||||
it('sets isLoading true and preserves attempts/limit on askSocrates', () => {
|
||||
const state = {
|
||||
...baseState,
|
||||
socratesHintState: {
|
||||
...baseState.socratesHintState,
|
||||
attempts: 2,
|
||||
limit: 3
|
||||
}
|
||||
};
|
||||
|
||||
const result = reducer(state, { type: actionTypes.askSocrates });
|
||||
|
||||
expect(result.socratesHintState).toEqual({
|
||||
hint: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
attempts: 2,
|
||||
limit: 3
|
||||
});
|
||||
});
|
||||
|
||||
it('stores hint, attempts, limit on askSocratesComplete', () => {
|
||||
const state = {
|
||||
...baseState,
|
||||
socratesHintState: { ...baseState.socratesHintState, isLoading: true }
|
||||
};
|
||||
|
||||
const result = reducer(state, {
|
||||
type: actionTypes.askSocratesComplete,
|
||||
payload: { hint: 'Try a closing tag.', attempts: 1, limit: 3 }
|
||||
});
|
||||
|
||||
expect(result.socratesHintState).toEqual({
|
||||
hint: 'Try a closing tag.',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
attempts: 1,
|
||||
limit: 3
|
||||
});
|
||||
});
|
||||
|
||||
it('stores error with attempts/limit on askSocratesError', () => {
|
||||
const state = {
|
||||
...baseState,
|
||||
socratesHintState: { ...baseState.socratesHintState, isLoading: true }
|
||||
};
|
||||
|
||||
const result = reducer(state, {
|
||||
type: actionTypes.askSocratesError,
|
||||
payload: { error: 'Daily limit reached.', attempts: 3, limit: 3 }
|
||||
});
|
||||
|
||||
expect(result.socratesHintState).toEqual({
|
||||
hint: null,
|
||||
isLoading: false,
|
||||
error: 'Daily limit reached.',
|
||||
attempts: 3,
|
||||
limit: 3
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves previous attempts/limit when error payload omits them', () => {
|
||||
const state = {
|
||||
...baseState,
|
||||
socratesHintState: {
|
||||
...baseState.socratesHintState,
|
||||
isLoading: true,
|
||||
attempts: 2,
|
||||
limit: 3
|
||||
}
|
||||
};
|
||||
|
||||
const result = reducer(state, {
|
||||
type: actionTypes.askSocratesError,
|
||||
payload: { error: 'Something went wrong.' }
|
||||
});
|
||||
|
||||
expect(result.socratesHintState).toEqual({
|
||||
hint: null,
|
||||
isLoading: false,
|
||||
error: 'Something went wrong.',
|
||||
attempts: 2,
|
||||
limit: 3
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -106,7 +106,7 @@ type SavedChallengeFromApi = {
|
||||
files: Array<Omit<SavedChallengeFile, 'fileKey'> & { key: string }>;
|
||||
} & Omit<SavedChallenge, 'challengeFiles'>;
|
||||
|
||||
type ApiUser = Omit<User, 'completedChallenges' & 'savedChallenges'> & {
|
||||
type ApiUser = Omit<User, 'completedChallenges' | 'savedChallenges'> & {
|
||||
completedChallenges?: CompleteChallengeFromApi[];
|
||||
savedChallenges?: SavedChallengeFromApi[];
|
||||
};
|
||||
@@ -259,6 +259,21 @@ interface Donation {
|
||||
customerId: string;
|
||||
startDate: Date;
|
||||
}
|
||||
|
||||
interface SocratesHintPayload {
|
||||
userInput: string;
|
||||
seed: string;
|
||||
description: string;
|
||||
hints: Array<{ text: string; failed?: boolean }>;
|
||||
}
|
||||
|
||||
interface SocratesHintResponse {
|
||||
hint?: string;
|
||||
error?: string;
|
||||
type?: string;
|
||||
attempts?: number;
|
||||
limit?: number;
|
||||
}
|
||||
// TODO: Verify if the body has and needs this Donation type. The api seems to
|
||||
// just need the body to exist, but doesn't seem to use the properties.
|
||||
export function addDonation(body: Donation): Promise<ResponseWithData<void>> {
|
||||
@@ -286,6 +301,11 @@ export function generateExamToken(): Promise<
|
||||
> {
|
||||
return post('/user/exam-environment/token', {});
|
||||
}
|
||||
export function getSocratesHint(
|
||||
body: SocratesHintPayload
|
||||
): Promise<ResponseWithData<SocratesHintResponse>> {
|
||||
return put('/socrates/get-hint', body);
|
||||
}
|
||||
|
||||
type PaymentIntentResponse = Promise<
|
||||
ResponseWithData<
|
||||
@@ -403,6 +423,12 @@ export function putUpdateMyQuincyEmail(update: {
|
||||
return put('/update-my-quincy-email', update);
|
||||
}
|
||||
|
||||
export function putUpdateMySocrates(update: {
|
||||
socrates: boolean;
|
||||
}): Promise<ResponseWithData<void>> {
|
||||
return put('/update-socrates', { socrates: update.socrates });
|
||||
}
|
||||
|
||||
export function putUpdateMyPortfolio(
|
||||
update: Record<string, string>
|
||||
): Promise<ResponseWithData<void>> {
|
||||
|
||||
@@ -35,6 +35,10 @@ GROWTHBOOK_URI=api_URI_from_Growthbook_dashboard
|
||||
GROWTHBOOK_FASTIFY_API_HOST=fastify_api_sdk_api_host_from_growthbook_dashboard
|
||||
GROWTHBOOK_FASTIFY_CLIENT_KEY=fastify_api_sdk_client_key_from_growthbook_dashboard
|
||||
|
||||
# Socrates (AI-powered hints)
|
||||
SOCRATES_API_KEY=socrates_api_key_from_dashboard
|
||||
SOCRATES_ENDPOINT=https://librarian-v1.freecodecamp.org/hint
|
||||
|
||||
# Application paths
|
||||
HOME_LOCATION=http://localhost:8000
|
||||
API_LOCATION=http://localhost:3000
|
||||
|
||||
Reference in New Issue
Block a user