Skip to content

Commit 5ce5a16

Browse files
General course info card created (#61)
* feat: setting up query provider and course info hook and api * feat: general course info card created * refactor: adaptations to work with te real api and some code improvements * refactor: updating run variable name to match api response name * chore: removing duplicated courseId on route for course info page
1 parent 1f03df6 commit 5ce5a16

20 files changed

+433
-58
lines changed

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Main.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import { CurrentAppProvider, getAppConfig } from '@openedx/frontend-base';
22

33
import { appId } from './constants';
44

5-
import './main.scss';
65
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
76
import { Outlet } from 'react-router-dom';
87
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
8+
import './main.scss';
99

1010
const queryClient = new QueryClient();
1111

src/app.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const app: App = {
1111
slots: [],
1212
config: {
1313
NODE_ENV: 'development',
14-
LMS_BASE_URL: 'http://localhost:18000'
14+
LMS_BASE_URL: 'http://local.openedx.io:8000'
1515
}
1616
};
1717

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,33 @@
11
import { screen } from '@testing-library/react';
2+
import { BrowserRouter } from 'react-router-dom';
3+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
24
import CourseInfoPage from './CourseInfoPage';
35
import { renderWithIntl } from '../testUtils';
46

7+
jest.mock('./components/generalCourseInfo', () => ({
8+
GeneralCourseInfo: () => <div>General Course Info Component</div>,
9+
}));
10+
511
describe('CourseInfoPage', () => {
6-
it('renders course information', () => {
7-
renderWithIntl(<CourseInfoPage />);
8-
expect(screen.getByText('Course Info')).toBeInTheDocument();
12+
const renderComponent = () => {
13+
const queryClient = new QueryClient({
14+
defaultOptions: {
15+
queries: { retry: false },
16+
mutations: { retry: false },
17+
},
18+
});
19+
20+
return renderWithIntl(
21+
<QueryClientProvider client={queryClient}>
22+
<BrowserRouter>
23+
<CourseInfoPage />
24+
</BrowserRouter>
25+
</QueryClientProvider>
26+
);
27+
};
28+
29+
it('renders GeneralCourseInfo component', () => {
30+
renderComponent();
31+
expect(screen.getByText('General Course Info Component')).toBeInTheDocument();
932
});
1033
});

src/courseInfo/CourseInfoPage.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import EnrollmentInformation from './components/enrollmentInformation/EnrollmentInformation';
1+
import { Container } from '@openedx/paragon';
2+
import { GeneralCourseInfo } from './components/generalCourseInfo';
23

34
const CourseInfoPage = () => {
45
return (
5-
<div>
6-
<h1>Course Info</h1>
7-
<EnrollmentInformation />
8-
</div>
6+
<Container className="mt-4.5 mb-4" fluid="xl">
7+
<GeneralCourseInfo />
8+
</Container>
99
);
1010
};
1111

src/courseInfo/components/enrollmentInformation/EnrollmentInformation.tsx

Lines changed: 0 additions & 14 deletions
This file was deleted.

src/courseInfo/components/enrollmentInformation/messages.ts

Lines changed: 0 additions & 11 deletions
This file was deleted.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { screen } from '@testing-library/react';
2+
import { BrowserRouter } from 'react-router-dom';
3+
import { GeneralCourseInfo } from './GeneralCourseInfo';
4+
import { useCourseInfo } from '../../../data/apiHook';
5+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
6+
import { createQueryMock, renderWithIntl } from '../../../testUtils';
7+
8+
jest.mock('../../../data/apiHook');
9+
jest.mock('react-router', () => ({
10+
useParams: () => ({ courseId: 'test-course-id' }),
11+
}));
12+
13+
const mockUseCourseInfo = useCourseInfo as jest.MockedFunction<typeof useCourseInfo>;
14+
15+
const mockCourseInfo = {
16+
org: 'TestOrg',
17+
courseId: 'CS101',
18+
courseRun: '2024_T1',
19+
displayName: 'Introduction to Computer Science',
20+
start: '2024-01-15T00:00:00Z',
21+
end: '2024-06-15T00:00:00Z',
22+
hasStarted: true,
23+
hasEnded: false,
24+
};
25+
26+
describe('GeneralCourseInfo', () => {
27+
let queryClient: QueryClient;
28+
const renderComponent = () => {
29+
return renderWithIntl(
30+
<QueryClientProvider client={queryClient}>
31+
<BrowserRouter>
32+
<GeneralCourseInfo />
33+
</BrowserRouter>
34+
</QueryClientProvider>
35+
);
36+
};
37+
38+
beforeEach(() => {
39+
queryClient = new QueryClient({
40+
defaultOptions: {
41+
queries: { retry: false },
42+
mutations: { retry: false },
43+
},
44+
});
45+
jest.clearAllMocks();
46+
});
47+
48+
it('displays skeleton when is in loading state', () => {
49+
mockUseCourseInfo.mockReturnValue(createQueryMock(undefined, true));
50+
const { container } = renderComponent();
51+
expect(container.querySelector('.react-loading-skeleton')).toBeInTheDocument();
52+
});
53+
54+
it('renders course information', () => {
55+
mockUseCourseInfo.mockReturnValue(createQueryMock(mockCourseInfo));
56+
renderComponent();
57+
58+
expect(screen.getByText(mockCourseInfo.org)).toBeInTheDocument();
59+
expect(screen.getByText(mockCourseInfo.courseId)).toBeInTheDocument();
60+
expect(screen.getByText(mockCourseInfo.courseRun)).toBeInTheDocument();
61+
expect(screen.getByText(mockCourseInfo.displayName)).toBeInTheDocument();
62+
});
63+
64+
it('displays active status for ongoing course', () => {
65+
mockUseCourseInfo.mockReturnValue(createQueryMock(mockCourseInfo));
66+
renderComponent();
67+
expect(screen.getByText('Active')).toBeInTheDocument();
68+
});
69+
70+
it('displays upcoming status for future course', () => {
71+
const upcomingCourse = { ...mockCourseInfo, hasStarted: false };
72+
mockUseCourseInfo.mockReturnValue(createQueryMock(upcomingCourse));
73+
renderComponent();
74+
expect(screen.getByText('Upcoming')).toBeInTheDocument();
75+
});
76+
77+
it('displays archived status for ended course', () => {
78+
const archivedCourse = { ...mockCourseInfo, hasStarted: true, hasEnded: true };
79+
mockUseCourseInfo.mockReturnValue(createQueryMock(archivedCourse));
80+
renderComponent();
81+
expect(screen.getByText('Archived')).toBeInTheDocument();
82+
});
83+
84+
it('displays formatted dates', () => {
85+
mockUseCourseInfo.mockReturnValue(createQueryMock(mockCourseInfo));
86+
renderComponent();
87+
expect(screen.getByText(/Jan 15, 2024/)).toBeInTheDocument();
88+
expect(screen.getByText(/Jun 15, 2024/)).toBeInTheDocument();
89+
});
90+
91+
it('displays "Not set" fallback for all missing course data fields', () => {
92+
const incompleteCourseInfo = {
93+
org: null,
94+
courseId: null,
95+
run: null,
96+
displayName: null,
97+
start: null,
98+
end: null,
99+
};
100+
mockUseCourseInfo.mockReturnValue(createQueryMock(incompleteCourseInfo));
101+
renderComponent();
102+
const notSetElements = screen.getAllByText(/Not Set/);
103+
expect(notSetElements.length).toBeGreaterThanOrEqual(6); // org, courseId, run, status, displayName, start date, end date
104+
});
105+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { Card, Skeleton } from '@openedx/paragon';
2+
import { StatusBadge } from './StatusBadge';
3+
import { useCourseInfo } from '../../../data/apiHook';
4+
import { useParams } from 'react-router';
5+
import { FormattedDate, useIntl } from '@openedx/frontend-base';
6+
import { useCallback } from 'react';
7+
import messages from './messages';
8+
9+
const COURSE_STATUS = {
10+
ACTIVE: 'Active',
11+
ARCHIVED: 'Archived',
12+
UPCOMING: 'Upcoming',
13+
};
14+
15+
const GeneralCourseInfo = () => {
16+
const intl = useIntl();
17+
const { courseId = '' } = useParams();
18+
const { data: courseInfo, isLoading } = useCourseInfo(courseId);
19+
const NOT_SET_FALLBACK = intl.formatMessage(messages.courseInfoNotSetFallback);
20+
21+
const getCourseStatus = useCallback((courseInfo) => {
22+
if (!courseInfo || (courseInfo.hasStarted === undefined && courseInfo.hasEnded === undefined)) return NOT_SET_FALLBACK;
23+
if (!courseInfo.hasStarted) return COURSE_STATUS.UPCOMING;
24+
if (courseInfo.hasStarted && !courseInfo.hasEnded) return COURSE_STATUS.ACTIVE;
25+
if (courseInfo.hasStarted && courseInfo.hasEnded) return COURSE_STATUS.ARCHIVED;
26+
return NOT_SET_FALLBACK;
27+
}, [NOT_SET_FALLBACK]);
28+
29+
const renderDate = (date: string | null) => {
30+
if (!date) return NOT_SET_FALLBACK;
31+
/// Added UTC timezone because it was showing the date with 1 day offset
32+
return <FormattedDate value={date} year="numeric" month="short" day="2-digit" timeZone="UTC" />;
33+
};
34+
35+
if (isLoading && !courseInfo) {
36+
return (
37+
<Card className="general-course-info">
38+
<Card.Section>
39+
<Skeleton count={3} />
40+
</Card.Section>
41+
</Card>
42+
);
43+
}
44+
45+
return (
46+
<Card className="general-course-info">
47+
<Card.Section>
48+
<div className="x-small mb-1.5">
49+
<span className="mr-2">{courseInfo.org ?? NOT_SET_FALLBACK}</span>/
50+
<span className="mx-2">{courseInfo.courseId ?? NOT_SET_FALLBACK}</span>/
51+
<span className="ml-2">{courseInfo.courseRun ?? NOT_SET_FALLBACK}</span>
52+
</div>
53+
<h3 className="text-primary-700 mb-3">{courseInfo.displayName ?? NOT_SET_FALLBACK}</h3>
54+
<div className="d-flex align-items-center">
55+
<div className="mr-4">
56+
<StatusBadge status={getCourseStatus(courseInfo)} />
57+
</div>
58+
<div className="x-small text-body">
59+
<p className="mb-0">
60+
{renderDate(courseInfo.start)}
61+
{' – '}
62+
{renderDate(courseInfo.end)}
63+
</p>
64+
</div>
65+
</div>
66+
</Card.Section>
67+
</Card>
68+
);
69+
};
70+
71+
export { GeneralCourseInfo };
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { StatusBadge } from './StatusBadge';
3+
4+
describe('StatusBadge', () => {
5+
it('renders status text', () => {
6+
render(<StatusBadge status="active" />);
7+
expect(screen.getByText('active')).toBeInTheDocument();
8+
});
9+
10+
it('applies success variant for active status', () => {
11+
render(<StatusBadge status="active" />);
12+
const badge = screen.getByText('active');
13+
expect(badge).toHaveClass('badge-success');
14+
});
15+
16+
it('applies light variant for archived status', () => {
17+
render(<StatusBadge status="archived" />);
18+
const badge = screen.getByText('archived');
19+
expect(badge).toHaveClass('badge-light');
20+
});
21+
22+
it('applies warning variant for upcoming status', () => {
23+
render(<StatusBadge status="upcoming" />);
24+
const badge = screen.getByText('upcoming');
25+
expect(badge).toHaveClass('badge-warning');
26+
});
27+
28+
it('applies light variant for unknown status', () => {
29+
render(<StatusBadge status="unknown" />);
30+
const badge = screen.getByText('unknown');
31+
expect(badge).toHaveClass('badge-light');
32+
});
33+
34+
it('handles case insensitive status matching', () => {
35+
render(<StatusBadge status="ACTIVE" />);
36+
const badge = screen.getByText('ACTIVE');
37+
expect(badge).toHaveClass('badge-success');
38+
});
39+
40+
it('applies correct CSS classes', () => {
41+
render(<StatusBadge status="active" />);
42+
const badge = screen.getByText('active');
43+
expect(badge).toHaveClass('py-1.5', 'px-3', 'x-small', 'font-weight-normal');
44+
});
45+
});

0 commit comments

Comments
 (0)