Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: update useCourseContext #2309

Merged
merged 4 commits into from
Sep 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions client/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
SolutionOutlined,
NotificationOutlined,
} from '@ant-design/icons';
import { Course } from 'services/models';
import { GithubAvatar } from 'components/GithubAvatar';
import { SolidarityUkraine } from './SolidarityUkraine';
import { SessionContext } from 'modules/Course/contexts';
Expand All @@ -19,7 +18,6 @@ import { useActiveCourseContext } from 'modules/Course/contexts/ActiveCourseCont
type Props = {
showCourseName?: boolean;
title?: string;
course?: Course;
};

const MENU_ITEMS = [
Expand All @@ -45,13 +43,13 @@ const MENU_ITEMS = [
},
];

export function Header({ title, showCourseName, course }: Props) {
export function Header({ title, showCourseName }: Props) {
const { asPath: currentRoute } = useRouter();
const menuActiveItemStyle = { backgroundColor: '#e0f2ff' };

const session = useContext(SessionContext);
const activeCourse = useActiveCourseContext().course ?? course;
const courseLinks = useMemo(() => getNavigationItems(session, activeCourse ?? null), [course]);
const { course } = useActiveCourseContext();
const courseLinks = useMemo(() => getNavigationItems(session, course ?? null), [course]);

const menu = (
<Menu>
Expand Down Expand Up @@ -96,7 +94,7 @@ export function Header({ title, showCourseName, course }: Props) {
<SolidarityUkraine />
</Space>
<div className="title">
<b>{title}</b> {showCourseName ? activeCourse?.name : null}
<b>{title}</b> {showCourseName ? course?.name : null}
</div>
<div className="profile">
<a target="_blank" href="https://docs.app.rs.school">
Expand Down
9 changes: 3 additions & 6 deletions client/src/components/PageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ type Props = {
noData?: boolean;
background?: string;
withMargin?: boolean;
course?: Course;
};

export function PageLayout(props: Props) {
Expand All @@ -22,7 +21,7 @@ export function PageLayout(props: Props) {

return (
<Layout style={{ background: props.background ?? 'transparent', minHeight: '100vh' }}>
<Header title={props.title} course={props.course} showCourseName={props.showCourseName} />
<Header title={props.title} showCourseName={props.showCourseName} />
{props.error ? (
<Result
status="500"
Expand All @@ -46,7 +45,7 @@ export function PageLayout(props: Props) {
export function PageLayoutSimple(props: Props) {
return (
<Layout style={{ background: 'transparent' }}>
<Header title={props.title} course={props.course} showCourseName={props.showCourseName} />
<Header title={props.title} showCourseName={props.showCourseName} />
<Layout.Content>
{props.noData ? (
<div>no data</div>
Expand Down Expand Up @@ -81,11 +80,9 @@ export function AdminPageLayout({
courses: Course[];
styles?: React.CSSProperties;
}>) {
const [course] = courses;

return (
<Layout style={{ minHeight: '100vh' }}>
<Header title={title} showCourseName={showCourseName} course={course} />
<Header title={title} showCourseName={showCourseName} />
<Layout style={{ background: '#e5e5e5' }}>
<AdminSider courses={courses} />
<Layout.Content style={{ background: '#fff', margin: 16, padding: 16, ...styles }}>
Expand Down
41 changes: 28 additions & 13 deletions client/src/modules/Course/contexts/ActiveCourseContext.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import { ProfileCourseDto } from 'api';
import { LoadingScreen } from 'components/LoadingScreen';
import { useRouter } from 'next/router';
import React, { useContext, useEffect } from 'react';
import React, { useContext, useEffect, useState } from 'react';
import { useAsync, useLocalStorage } from 'react-use';
import { UserService } from 'services/user';
import { WelcomeCard } from 'components/WelcomeCard';
import { Alert, Col, Row } from 'antd';

const ActiveCourseContext = React.createContext<{ course: ProfileCourseDto; courses: ProfileCourseDto[] }>(
{} as { course: ProfileCourseDto; courses: ProfileCourseDto[] },
);
type ActiveCourseContextType = {
course: ProfileCourseDto;
courses: ProfileCourseDto[];
setCourse: (course: ProfileCourseDto) => void;
};

const ActiveCourseContext = React.createContext<ActiveCourseContextType>({
course: {} as ProfileCourseDto,
courses: [],
setCourse: () => {},
});

export const useActiveCourseContext = () => {
return useContext(ActiveCourseContext);
Expand All @@ -23,12 +31,9 @@ export const ActiveCourseProvider = ({ children }: Props) => {
const router = useRouter();
const alias = router.query.course;
const [storageCourseId] = useLocalStorage('activeCourseId');
const [activeCourse, setActiveCourse] = useState<ProfileCourseDto>();

const {
value: course,
error,
loading,
} = useAsync(async () => {
const { error, loading } = useAsync(async () => {
if (!coursesCache) {
coursesCache = await new UserService().getCourses();
}
Expand All @@ -37,9 +42,17 @@ export const ActiveCourseProvider = ({ children }: Props) => {
coursesCache.find(course => course.alias === alias) ??
coursesCache.find(course => course.id === storageCourseId) ??
coursesCache[0];

setActiveCourse(course);

return course;
}, []);

const setCourse = (course: ProfileCourseDto) => {
setActiveCourse(course);
localStorage.setItem('activeCourseId', course.id.toString());
};

useEffect(() => {
if (!error) {
return;
Expand All @@ -49,11 +62,11 @@ export const ActiveCourseProvider = ({ children }: Props) => {
router.push('/login', { pathname: '/login', query: { url: redirectUrl } });
}, [error]);

if (!loading && !course && !coursesCache?.length) {
if (!loading && !activeCourse && !coursesCache?.length) {
return <WelcomeCard />;
}

if (alias && course && course.alias !== alias) {
if (alias && activeCourse && activeCourse.alias !== alias) {
return (
<Row justify="center">
<Col md={12} xs={18} style={{ marginTop: '60px' }}>
Expand All @@ -67,9 +80,11 @@ export const ActiveCourseProvider = ({ children }: Props) => {
);
}

if (course && coursesCache) {
if (activeCourse && coursesCache) {
return (
<ActiveCourseContext.Provider value={{ course, courses: coursesCache }}>{children}</ActiveCourseContext.Provider>
<ActiveCourseContext.Provider value={{ course: activeCourse, courses: coursesCache, setCourse }}>
{children}
</ActiveCourseContext.Provider>
);
}

Expand Down
66 changes: 66 additions & 0 deletions client/src/modules/Course/contexts/SessionContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { render, screen } from '@testing-library/react';
import { SessionProvider } from './';
import Router from 'next/router';
import { useAsync } from 'react-use';
import { useActiveCourseContext } from './ActiveCourseContext';

jest.mock('axios');
jest.mock('next/router', () => ({ push: jest.fn() }));
jest.mock('./ActiveCourseContext', () => ({
useActiveCourseContext: jest.fn(),
}));
jest.mock('react-use', () => ({
useAsync: jest.fn(),
}));

describe('<SessionProvider />', () => {
const mockChildren = <div>Child Component</div>;

const mockSession = { isAdmin: true, courses: { 1: { roles: ['student'] } } };
const mockCourse = { id: 1 };
const mockActiveCourse = { course: mockCourse };

afterEach(() => {
jest.clearAllMocks();
});

beforeEach(() => {
(useActiveCourseContext as jest.Mock).mockReturnValue(mockActiveCourse);
});

it('should render loading screen', () => {
(useAsync as jest.Mock).mockReturnValue({ loading: true });
render(<SessionProvider>{mockChildren}</SessionProvider>);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});

it('should handle error and redirect to login', () => {
(useAsync as jest.Mock).mockReturnValue({ error: true });
render(<SessionProvider>{mockChildren}</SessionProvider>);
expect(Router.push).toHaveBeenCalledWith('/login', expect.anything());
});

it('should render children for admin user for admin-only pages', () => {
(useAsync as jest.Mock).mockReturnValue({ value: mockSession });
render(<SessionProvider adminOnly={true}>{mockChildren}</SessionProvider>);
expect(screen.getByText('Child Component')).toBeInTheDocument();
});

it('should render warning for non-admin user for admin-only pages', () => {
(useAsync as jest.Mock).mockReturnValue({ value: { ...mockSession, isAdmin: false } });
render(<SessionProvider adminOnly={true}>{mockChildren}</SessionProvider>);
expect(screen.getByText(/You don't have required role to access this page/)).toBeInTheDocument();
});

it('should render children for user with allowed roles', () => {
(useAsync as jest.Mock).mockReturnValue({ value: mockSession });
render(<SessionProvider allowedRoles={['student']}>{mockChildren}</SessionProvider>);
expect(screen.getByText('Child Component')).toBeInTheDocument();
});

it('should render warning for user without allowed roles', () => {
(useAsync as jest.Mock).mockReturnValue({ value: { ...mockSession, isAdmin: false } });
render(<SessionProvider allowedRoles={['mentor']}>{mockChildren}</SessionProvider>);
expect(screen.getByText(/You don't have required role to access this page/)).toBeInTheDocument();
});
});
24 changes: 15 additions & 9 deletions client/src/modules/Home/pages/HomePage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { CourseSelector } from 'modules/Home/components/CourseSelector';
import { RegistryBanner } from 'modules/Home/components/RegistryBanner';
import { SystemAlerts } from 'modules/Home/components/SystemAlerts';
import { getCourseLinks } from 'modules/Home/data/links';
import { useActiveCourse } from 'modules/Home/hooks/useActiveCourse';
import { useStudentSummary } from 'modules/Home/hooks/useStudentSummary';
import Link from 'next/link';
import { useMemo, useState, useContext } from 'react';
Expand All @@ -26,21 +25,19 @@ const mentorRegistryService = new MentorRegistryService();
const alertService = new AlertsApi();

export function HomePage() {
const { courses = [] } = useActiveCourseContext();
const { courses = [], setCourse, course } = useActiveCourseContext();
const session = useContext(SessionContext);
const plannedCourses = (courses || []).filter(course => course.planned && !course.inviteOnly);
const wasMentor = isAnyMentor(session);
const hasRegistryBanner =
wasMentor && plannedCourses.length > 0 && plannedCourses.every(course => session.courses[course.id] == null);

const isPowerUser = isAnyCoursePowerUser(session) || isAnyCourseDementor(session);

const [activeCourse, saveActiveCourseId] = useActiveCourse(courses);
const [allCourses, setAllCourses] = useState<Course[]>([]);
const [preselectedCourses, setPreselectedCourses] = useState<Course[]>([]);
const [alerts, setAlerts] = useState<AlertDto[]>([]);

const courseLinks = useMemo(() => getCourseLinks(session, activeCourse), [activeCourse]);
const courseLinks = useMemo(() => getCourseLinks(session, course), [course]);
const [approvedCourse] = preselectedCourses.filter(course => !session.courses?.[course.id]);

useAsync(async () => {
Expand All @@ -59,15 +56,24 @@ export function HomePage() {
setPreselectedCourses(preselectedCourses);
});

const { courseTasks, studentSummary } = useStudentSummary(session, activeCourse);
const handleChangeCourse = (courseId: number) => {
const course = courses.find(course => {
return course.id === courseId;
});
if (course) {
setCourse(course);
}
};

const { courseTasks, studentSummary } = useStudentSummary(session, course);

return (
<Layout style={{ minHeight: '100vh', background: '#fff' }}>
<Header />
<Layout style={{ background: '#fff' }}>
{isPowerUser && <AdminSider courses={courses} activeCourse={activeCourse} />}
{isPowerUser && <AdminSider courses={courses} activeCourse={course} />}
<Content style={{ margin: 16, marginBottom: 32 }}>
{!activeCourse && <NoCourse courses={allCourses} preselectedCourses={preselectedCourses} />}
{!course && <NoCourse courses={allCourses} preselectedCourses={preselectedCourses} />}

{approvedCourse && (
<div style={{ margin: '16px 0' }}>
Expand All @@ -88,7 +94,7 @@ export function HomePage() {

{hasRegistryBanner && <RegistryBanner style={{ margin: '16px 0' }} />}

<CourseSelector course={activeCourse} onChangeCourse={saveActiveCourseId} courses={courses} />
<CourseSelector course={course} onChangeCourse={handleChangeCourse} courses={courses} />

<Row gutter={24}>
<Col xs={24} sm={12} md={10} lg={8} style={{ marginBottom: 16 }}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Course } from 'services/models';
import { CourseInfo, Session } from 'components/withSession';
import { INSTRUCTIONS_TEXT } from '../Instructions';
import { MentorDashboardProps } from 'pages/course/mentor/dashboard';
import { SessionContext } from 'modules/Course/contexts';

jest.mock('modules/Mentor/hooks/useMentorDashboard', () => ({
useMentorDashboard: jest.fn().mockReturnValue([[], false]),
Expand All @@ -13,19 +14,6 @@ jest.mock('next/router', () => ({
}));

const PROPS_MOCK: MentorDashboardProps = {
session: {
id: 1,
isActivist: false,
isAdmin: true,
isHirer: false,
githubId: 'github-id',
courses: {
'400': {
mentorId: 1,
roles: ['mentor'],
} as CourseInfo,
},
} as Session,
course: {
id: 400,
} as Course,
Expand All @@ -36,15 +24,55 @@ const PROPS_MOCK: MentorDashboardProps = {

describe('MentorDashboard', () => {
it('should render instructions when mentor has no students for this course', () => {
render(<MentorDashboard {...PROPS_MOCK} studentsCount={0} />);
render(
<SessionContext.Provider
value={
{
id: 1,
isActivist: false,
isAdmin: true,
isHirer: false,
githubId: 'github-id',
courses: {
'400': {
mentorId: 1,
roles: ['mentor'],
} as CourseInfo,
},
} as Session
}
>
<MentorDashboard {...PROPS_MOCK} studentsCount={0} />
</SessionContext.Provider>,
);

const instructionsTitle = screen.getByText(INSTRUCTIONS_TEXT.title);

expect(instructionsTitle).toBeInTheDocument();
});

it('should render empty table when mentor has students for this course', () => {
render(<MentorDashboard {...PROPS_MOCK} />);
render(
<SessionContext.Provider
value={
{
id: 1,
isActivist: false,
isAdmin: true,
isHirer: false,
githubId: 'github-id',
courses: {
'400': {
mentorId: 1,
roles: ['mentor'],
} as CourseInfo,
},
} as Session
}
>
<MentorDashboard {...PROPS_MOCK} />
</SessionContext.Provider>,
);

const emptyTable = screen.getByText(/No Data/i);

Expand Down
Loading