Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React, { useContext, useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import { useParams } from 'react-router-dom';
import { Col, Container, Row } from '@openedx/paragon';
import { logError } from '@edx/frontend-platform/logging';
import { camelCaseObject } from '@edx/frontend-platform/utils';
import { getProgramProgressData } from '../data/api';
import { ProgramProgressContext, ProgramProgressContextValueType } from './ProgramProgressProvider.tsx';
import ProgramProgressCourses from './ProgramProgressCourses';
import ProgramProgressHeader from './ProgramProgressHeader';
import ProgramProgressSidebar from './ProgramProgressSidebar';
import ProgramProgressInfo from './ProgramProgressInfo';

import './index.scss';

// TODO: write tests for this file once all child components are done
const ProgramProgress: React.FC = () => {
const {
programProgressData,
setProgramProgressData,
} = useContext<ProgramProgressContextValueType>(ProgramProgressContext);

const [programProgressEndpointError, setProgramProgressEndpointError] = useState<Boolean>(false);
const hasProgramProgressData : Boolean = programProgressData?.courseData
&& programProgressData.programData
&& programProgressData.urls;

const { uuid } = useParams() as { uuid: string };
useEffect(() => {
getProgramProgressData(uuid)
.then(responseData => {
setProgramProgressData(camelCaseObject(responseData.data));
})
.catch(err => {
logError(err);
setProgramProgressEndpointError(true);
});
}, [uuid, setProgramProgressData]);

if (programProgressEndpointError) {
return (
<div>Not found page</div>
);
}

if (!hasProgramProgressData) {
return (
<div>Loading...</div>
);
}

const programData = programProgressData?.programData;
const courseData = programProgressData?.courseData;

const totalCoursesInProgram = (courseData.notStarted?.length || 0)
+ (courseData.completed?.length || 0)
+ (courseData.inProgress?.length || 0);

const allCoursesCompleted = !courseData.notStarted?.length
&& !courseData.inProgress?.length
&& courseData.completed?.length;

return (
<>
<Helmet title={`${programData?.title}`} />
<Container fluid={false} size="lg" className="p-4.5">
<ProgramProgressHeader
programTitle={programData?.title}
programType={programData?.type}
authoringOrganizations={programData?.authoringOrganizations}
/>
<Row>
<Col sm={12} md={8} className="px-4.5">
<ProgramProgressInfo
allCoursesCompleted={allCoursesCompleted}
totalCoursesInProgram={totalCoursesInProgram}
/>
<ProgramProgressCourses courseData={courseData} />
</Col>
<Col sm={12} md={4}>
<ProgramProgressSidebar />
</Col>
</Row>
</Container>
</>
);
};

export default ProgramProgress;
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { render, screen, RenderResult } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';

import ProgramProgressHeader from './ProgramProgressHeader';
import { getProgramIcon } from '../data/util';

jest.mock('../data/util', () => ({
getProgramIcon: jest.fn(),
}));

const mockProgramType = 'Degree';
const mockProgramTitle = 'Full Stack Development Degree';
const mockProgramIconUrl = 'icon-degree.svg';

const mockOrganizations = [
{
uuid: 'org-1-uuid',
key: 'UniversityAx',
name: 'University A',
logoImageUrl: 'logo-a.png',
certificateLogoImageUrl: 'cert-logo-a.png',
},
{
uuid: 'org-2-uuid',
key: 'TechInstituteBx',
name: 'Tech Institute B',
logoImageUrl: 'logo-b.png',
certificateLogoImageUrl: null,
},
];

const defaultProps = {
programTitle: mockProgramTitle,
programType: mockProgramType,
authoringOrganizations: mockOrganizations,
};

const renderComponent = (props = {}): RenderResult => {
(getProgramIcon as jest.Mock).mockReturnValue(mockProgramIconUrl);

return render(
<IntlProvider locale="en">
<ProgramProgressHeader {...defaultProps} {...props} />;
</IntlProvider>,
);
};

describe('ProgramProgressHeader', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('renders the program title and uses getProgramIcon correctly', () => {
renderComponent();

expect(screen.getByRole('heading', { name: mockProgramTitle })).toBeInTheDocument();

expect(getProgramIcon).toHaveBeenCalledWith(mockProgramType);

const programIcon = screen.getByAltText(`${mockProgramType} icon`);
expect(programIcon).toBeInTheDocument();
expect(programIcon).toHaveAttribute('src', mockProgramIconUrl);
});

it('renders the institutions header text', () => {
renderComponent();

expect(screen.getByText('Institutions')).toBeInTheDocument();
});

it('renders all organization logos and uses certificateLogoImageUrl first', () => {
renderComponent();

const orgImages = screen.getAllByRole('img', { name: /logo/i });
expect(orgImages).toHaveLength(mockOrganizations.length);

const logoA = screen.getByAltText("University A's logo");
expect(logoA).toHaveAttribute('src', mockOrganizations[0].certificateLogoImageUrl);

const logoB = screen.getByAltText("Tech Institute B's logo");
expect(logoB).toHaveAttribute('src', mockOrganizations[1].logoImageUrl);
});

it('does NOT render the institutions column if authoringOrganizations is empty', () => {
renderComponent({ authoringOrganizations: [] });

expect(screen.queryByText('Institutions')).not.toBeInTheDocument();
});

it('does NOT render the institutions column if authoringOrganizations is null', () => {
renderComponent({ authoringOrganizations: null });

expect(screen.queryByText('Institutions')).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -1,25 +1,44 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Row, Col } from '@openedx/paragon';
import { ProgramProgressHeaderProps } from '../data/types';
import { getProgramIcon } from '../data/util';

import messages from './messages';

const ProgramProgressHeader: React.FC<ProgramProgressHeaderProps> = ({
programTitle, programType, authoringOrganizations,
}) => {
const { formatMessage } = useIntl();
const programIcon = getProgramIcon(programType);

return (
<div>
<p>
{programTitle}
</p>
<p>
{programType}
</p>
<p>
{authoringOrganizations && authoringOrganizations.length}
</p>
<img src={programIcon} alt={`${programType} icon`} />
</div>
<Row className="justify-content-between pb-4.5">
<Col sm={8} xs={12}>
<img src={programIcon} alt={`${programType} icon`} className="program-icon" />
<h2 className="program-title">
{programTitle}
</h2>
</Col>
{authoringOrganizations && authoringOrganizations?.length > 0 && (
<Col sm={4} className="text-center">
<div className="font-weight-bold">
{formatMessage(messages.programProgressInstitutions)}
</div>
<div>
{authoringOrganizations.map(org => (
<img
key={org.uuid}
id="org-image"
src={org.certificateLogoImageUrl || org.logoImageUrl}
className="container-mw-md"
alt={`${org.name}'s logo`}
/>
))}
</div>
</Col>
)}
</Row>
);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { render, RenderResult, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';

import ProgramProgressInfo from './ProgramProgressInfo';
import messages from './messages';

jest.mock('./UpgradeButton', () => ({
UpgradeAllButton: 'UpgradeAllButton',
}));

const defaultProps = {
allCoursesCompleted: true,
totalCoursesInProgram: 3,
};

const renderComponent = (props = {}): RenderResult => render(
<IntlProvider locale="en">
<ProgramProgressInfo {...defaultProps} {...props} />
</IntlProvider>,
);

describe('ProramProgressInfo', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('renders the correct text when all courses have been completed for a program', () => {
renderComponent();

expect(screen.getByText(messages.programProgressCompleteHeader.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.programProgressCompleteText.defaultMessage)).toBeInTheDocument();
});

it('renders the correct text when the user has NOT completed all of the courses in a program', () => {
renderComponent({
...defaultProps,
allCoursesCompleted: false,
});

expect(screen.getByText(messages.programProgressIncompleteHeader.defaultMessage)).toBeInTheDocument();
expect(screen.getByTestId('program-incomplete-info-text')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -1,20 +1,36 @@
/* eslint-disable max-len */
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { ProgramProgressInfoProps } from '../data/types';
import messages from './messages';

import { UpgradeAllButton } from './UpgradeButton';
// TODO: for review: this component could be wrapped in a PluginSlot to allow us to inject the UpgradeAllButton while using the default content

const ProgramProgressInfo: React.FC<ProgramProgressInfoProps> = ({
allCoursesCompleted, totalCoursesInProgram,
}) => (
allCoursesCompleted
? (
<div>
Render all courses completed text
</div>
)
: (
<div>
Render upgrade button and info about the {totalCoursesInProgram} courses in this program
</div>
)
);
}) => {
const { formatMessage } = useIntl();
return (
<div className="pb-4.5">
{allCoursesCompleted
? (
<>
<h3>{formatMessage(messages.programProgressCompleteHeader)}</h3>
<p>{formatMessage(messages.programProgressCompleteText)}</p>
</>
)
: (
<>
<h3>{formatMessage(messages.programProgressIncompleteHeader)}</h3>
<p data-testid="program-incomplete-info-text">
{formatMessage(messages.programProgressIncompleteText, { totalCoursesInProgram })}
</p>
<UpgradeAllButton />
</>
)}
</div>
);
};

export default ProgramProgressInfo;
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React, {
createContext, ReactNode, useMemo, useState,
} from 'react';
import { ProgramProgressData } from '../data/types';

export interface ProgramProgressProviderProps {
children: ReactNode,
}

export interface ProgramProgressContextValueType {
programProgressData: ProgramProgressData
setProgramProgressData: (data: ProgramProgressData) => void,
}

const defaultContextValue: ProgramProgressContextValueType = {
programProgressData: {
urls: {
programListingUrl: undefined,
trackSelectionUrl: undefined,
commerceApiUrl: undefined,
buyButtonUrl: undefined,
programRecordUrl: undefined,
},
programData: null,
courseData: null,
},
setProgramProgressData: () => {},
};

export const ProgramProgressContext = createContext<ProgramProgressContextValueType>(defaultContextValue);

export const ProgramProgressContextProvider: React.FC<ProgramProgressProviderProps> = ({ children }) => {
const [programProgressData, setProgramProgressData] = useState(defaultContextValue.programProgressData);

const memoValue = useMemo(():ProgramProgressContextValueType => ({
programProgressData,
setProgramProgressData,
}), [programProgressData, setProgramProgressData]);

return (
<ProgramProgressContext.Provider
value={memoValue}
>
{children}
</ProgramProgressContext.Provider>
);
};

export default {
ProgramProgressContextProvider,
ProgramProgressContext,
};
Loading