Skip to content

Commit 7df17d9

Browse files
author
javoconsultant
committed
feat(taxonomy): add CompetencyManagementPage
1 parent 3e6d312 commit 7df17d9

13 files changed

Lines changed: 308 additions & 5 deletions

File tree

src/index.jsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,12 @@ import CourseAuthoringRoutes from './CourseAuthoringRoutes';
3636
import Head from './head/Head';
3737
import { StudioHome } from './studio-home';
3838
import CourseRerun from './course-rerun';
39-
import { TaxonomyLayout, TaxonomyDetailPage, TaxonomyListPage } from './taxonomy';
39+
import {
40+
CompetencyManagementPage,
41+
TaxonomyDetailPage,
42+
TaxonomyLayout,
43+
TaxonomyListPage,
44+
} from './taxonomy';
4045
import { ContentTagsDrawer } from './content-tags-drawer';
4146
import AccessibilityPage from './accessibility-page';
4247
import { ToastProvider } from './generic/toast-context';
@@ -110,6 +115,7 @@ const App = () => {
110115
</Route>
111116
<Route path="/taxonomy" element={<TaxonomyLayout />}>
112117
<Route path="/taxonomy/:taxonomyId" element={<TaxonomyDetailPage />} />
118+
<Route path="/taxonomy/:taxonomyId/competencies" element={<CompetencyManagementPage />} />
113119
</Route>
114120
<Route
115121
path="/tagging/components/widget/:contentId"
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { initializeMocks, render, type RouteOptions } from '@src/testUtils';
2+
import { apiUrls } from '../data/api';
3+
import { CompetencyManagementPage } from '.';
4+
5+
const taxonomyId = 1;
6+
7+
const route: RouteOptions = {
8+
path: '/taxonomy/:taxonomyId/competencies',
9+
params: { taxonomyId: `${taxonomyId}` },
10+
};
11+
12+
const taxonomyResponse = {
13+
id: taxonomyId,
14+
name: 'Test taxonomy',
15+
description: 'This is a description',
16+
taxonomy_type: 'competency',
17+
read_only: false,
18+
can_change_taxonomy: true,
19+
can_delete_taxonomy: true,
20+
};
21+
22+
describe('<CompetencyManagementPage />', () => {
23+
let axiosMock;
24+
25+
beforeEach(() => {
26+
({ axiosMock } = initializeMocks());
27+
});
28+
29+
it('shows the spinner before the query is complete', () => {
30+
// Use an unresolved promise to keep the Loading visible
31+
axiosMock.onGet(apiUrls.taxonomy(taxonomyId)).reply(() => new Promise(() => {}));
32+
33+
const { getByRole } = render(<CompetencyManagementPage />, route);
34+
35+
expect(getByRole('status').textContent).toEqual('Loading...');
36+
});
37+
38+
it('shows the connection error component if no taxonomy is returned', async () => {
39+
// Use an empty response to trigger the error. Returning an error does not
40+
// work because the query will retry.
41+
axiosMock.onGet(apiUrls.taxonomy(taxonomyId)).reply(200);
42+
43+
const { findByTestId } = render(<CompetencyManagementPage />, route);
44+
45+
expect(await findByTestId('connectionErrorAlert')).toBeInTheDocument();
46+
});
47+
48+
it('shows the taxonomy name as the title, under a breadcrumb back to the list', async () => {
49+
axiosMock.onGet(apiUrls.taxonomy(taxonomyId)).reply(200, taxonomyResponse);
50+
51+
const { findByRole, getByRole, queryByRole } = render(<CompetencyManagementPage />, route);
52+
53+
expect(await findByRole('heading')).toHaveTextContent('Test taxonomy');
54+
expect(getByRole('link', { name: 'Taxonomies' })).toHaveAttribute('href', '/taxonomies/');
55+
// The taxonomy name is the breadcrumb's active step, so it is text rather than a link
56+
expect(queryByRole('link', { name: 'Test taxonomy' })).not.toBeInTheDocument();
57+
});
58+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { useIntl } from '@edx/frontend-platform/i18n';
2+
import { Breadcrumb, Container } from '@openedx/paragon';
3+
import { Helmet } from 'react-helmet';
4+
import { Link, useParams } from 'react-router-dom';
5+
6+
import ConnectionErrorAlert from '@src/generic/ConnectionErrorAlert';
7+
import Loading from '@src/generic/Loading';
8+
import SubHeader from '@src/generic/sub-header/SubHeader';
9+
import getPageHeadTitle from '@src/generic/utils';
10+
import taxonomyMessages from '../messages';
11+
import { useTaxonomyDetails } from '../data/apiHooks';
12+
13+
/**
14+
* Page where competencies of a taxonomy are managed and applied to course content.
15+
*
16+
* The page is a placeholder for now: it carries the breadcrumb and the title, the same
17+
* way the taxonomy detail page does. The competency tree and its actions are added by a
18+
* later ticket.
19+
*/
20+
export const CompetencyManagementPage = () => {
21+
const intl = useIntl();
22+
const { taxonomyId: taxonomyIdString } = useParams();
23+
const taxonomyId = Number(taxonomyIdString);
24+
25+
const {
26+
data: taxonomy,
27+
isError,
28+
isFetched,
29+
} = useTaxonomyDetails(taxonomyId);
30+
31+
if (!isFetched) {
32+
return <Loading />;
33+
}
34+
35+
if (isError || !taxonomy) {
36+
return <ConnectionErrorAlert />;
37+
}
38+
39+
return (
40+
<>
41+
<Helmet>
42+
<title>{getPageHeadTitle(intl.formatMessage(taxonomyMessages.headerTitle), taxonomy.name)}</title>
43+
</Helmet>
44+
<div className="pt-4.5 pr-4.5 pl-4.5 pb-2 bg-light-100 box-shadow-down-2">
45+
<Container size="xl">
46+
<Breadcrumb
47+
links={[
48+
{ label: intl.formatMessage(taxonomyMessages.headerTitle), to: '/taxonomies/' },
49+
]}
50+
activeLabel={taxonomy.name}
51+
linkAs={Link}
52+
/>
53+
<SubHeader
54+
title={taxonomy.name}
55+
hideBorder
56+
/>
57+
</Container>
58+
</div>
59+
</>
60+
);
61+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { CompetencyManagementPage } from './CompetencyManagementPage';

src/taxonomy/data/utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { TaxonomyType } from './constants';
2+
3+
/** Whether a taxonomy holds competencies rather than plain tags */
4+
export const isCompetencyTaxonomy = (
5+
taxonomy: { taxonomyType?: TaxonomyType; },
6+
) => taxonomy.taxonomyType === TaxonomyType.Competency;

src/taxonomy/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export { CompetencyManagementPage } from './competency-management';
12
export { TaxonomyDetailPage } from './taxonomy-detail';
23
export { TaxonomyLayout } from './TaxonomyLayout';
34
export { TaxonomyListPage } from './TaxonomyListPage';

src/taxonomy/taxonomy-card/TaxonomyCard.scss

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
width: 400px;
33
height: 317px;
44

5+
&.pgn__card {
6+
display: flex;
7+
flex-direction: column;
8+
}
9+
510
.taxonomy-card-title-text {
611
/* Allow the title to shrink (and truncate) next to the taxonomy type icon */
712
min-width: 0;
@@ -27,4 +32,25 @@
2732
max-height: 190px;
2833
-webkit-line-clamp: 6;
2934
}
35+
36+
.pgn__card-footer {
37+
margin-top: auto;
38+
}
39+
40+
/*
41+
The footer takes two lines' worth of room away from the description. As above, each
42+
max-height is the section's 20px top padding plus whole 28px lines (the bottom
43+
padding is what overflow hides), so the clamp's ellipsis lands on a visible line.
44+
*/
45+
.taxonomy-card-body-with-footer {
46+
&.taxonomy-card-body-overflow-m {
47+
max-height: 162px;
48+
-webkit-line-clamp: 5;
49+
}
50+
51+
&.taxonomy-card-body-overflow-sm {
52+
max-height: 134px;
53+
-webkit-line-clamp: 4;
54+
}
55+
}
3056
}

src/taxonomy/taxonomy-card/TaxonomyCard.test.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ import { TaxonomyType } from '../data/constants';
1010
import { TaxonomyCard } from '.';
1111
import { TaxonomyCardData } from './TaxonomyCard';
1212

13+
const mockNavigate = jest.fn();
14+
jest.mock('react-router-dom', () => ({
15+
...jest.requireActual('react-router-dom'),
16+
useNavigate: () => mockNavigate,
17+
}));
18+
1319
let store;
1420
const taxonomyId = 1;
1521

@@ -39,6 +45,7 @@ const TaxonomyCardComponent = ({ original }: { original: TaxonomyCardData; }) =>
3945

4046
describe('<TaxonomyCard />', () => {
4147
beforeEach(async () => {
48+
jest.clearAllMocks();
4249
initializeMockApp({
4350
authenticatedUser: {
4451
userId: 3,
@@ -128,6 +135,38 @@ describe('<TaxonomyCard />', () => {
128135
expect(queryByTestId('taxonomy-type-icon-competency')).not.toBeInTheDocument();
129136
});
130137

138+
const applyCompetenciesLabel = 'Apply Competencies';
139+
140+
it('shows a footer button that opens the competency management page of a competency taxonomy', () => {
141+
const cardData = { ...data, taxonomyType: TaxonomyType.Competency };
142+
143+
const { getByRole } = render(<TaxonomyCardComponent original={cardData} />);
144+
fireEvent.click(getByRole('button', { name: applyCompetenciesLabel }));
145+
146+
expect(mockNavigate).toHaveBeenCalledWith(`/taxonomy/${taxonomyId}/competencies`);
147+
});
148+
149+
it('still links the card itself to the taxonomy editing page', () => {
150+
const cardData = { ...data, taxonomyType: TaxonomyType.Competency };
151+
152+
const { getByRole } = render(<TaxonomyCardComponent original={cardData} />);
153+
expect(getByRole('link')).toHaveAttribute('href', `/taxonomy/${taxonomyId}/`);
154+
});
155+
156+
it('does not show the footer button on tags taxonomies', () => {
157+
const cardData = { ...data, taxonomyType: TaxonomyType.Tags };
158+
159+
const { queryByRole } = render(<TaxonomyCardComponent original={cardData} />);
160+
expect(queryByRole('button', { name: applyCompetenciesLabel })).not.toBeInTheDocument();
161+
});
162+
163+
it('does not show the footer button to users who cannot change the taxonomy', () => {
164+
const cardData = { ...data, taxonomyType: TaxonomyType.Competency, canChangeTaxonomy: false };
165+
166+
const { queryByRole } = render(<TaxonomyCardComponent original={cardData} />);
167+
expect(queryByRole('button', { name: applyCompetenciesLabel })).not.toBeInTheDocument();
168+
});
169+
131170
it('shows a type icon even when the taxonomy has no type, along with the read-only badge', () => {
132171
const cardData = { ...data, readOnly: true };
133172

src/taxonomy/taxonomy-card/TaxonomyCard.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1-
import { Card } from '@openedx/paragon';
2-
import { NavLink } from 'react-router-dom';
1+
import type { MouseEvent } from 'react';
2+
import { useIntl } from '@edx/frontend-platform/i18n';
3+
import { Button, Card } from '@openedx/paragon';
4+
import { NavLink, useNavigate } from 'react-router-dom';
35
import classNames from 'classnames';
46

57
import { TaxonomyMenu } from '../taxonomy-menu';
68
import { TaxonomyCardHeaderSubtitle } from './TaxonomyCardHeaderSubtitle';
79
import { TaxonomyCardHeaderTitle } from './TaxonomyCardHeaderTitle';
10+
import messages from './messages';
811
import { orgsCountEnabled } from './utils';
912
import { TaxonomyType } from '../data/constants';
13+
import { isCompetencyTaxonomy } from '../data/utils';
1014
import { TaxonomyData } from '../data/types';
1115

1216
type TaxonomyCardFields = Pick<
@@ -33,8 +37,14 @@ export const TaxonomyCard = ({ className = '', original }: TaxonomyCardProps) =>
3337
readOnly,
3438
orgsCount,
3539
taxonomyType,
40+
canChangeTaxonomy,
3641
} = original;
3742

43+
const intl = useIntl();
44+
const navigate = useNavigate();
45+
46+
const showApplyCompetencies = canChangeTaxonomy && isCompetencyTaxonomy(original);
47+
3848
return (
3949
<Card
4050
isClickable
@@ -68,12 +78,28 @@ export const TaxonomyCard = ({ className = '', original }: TaxonomyCardProps) =>
6878
className={classNames('taxonomy-card-body', {
6979
'taxonomy-card-body-overflow-m': !readOnly && !orgsCountEnabled(orgsCount),
7080
'taxonomy-card-body-overflow-sm': readOnly || orgsCountEnabled(orgsCount),
81+
'taxonomy-card-body-with-footer': showApplyCompetencies,
7182
})}
7283
>
7384
<Card.Section>
7485
{description}
7586
</Card.Section>
7687
</Card.Body>
88+
{showApplyCompetencies && (
89+
<Card.Footer className="justify-content-end">
90+
<Button
91+
variant="primary"
92+
// The whole card is a link, so stop the click here instead of nesting another one inside it.
93+
onClick={(e: MouseEvent) => {
94+
e.preventDefault();
95+
e.stopPropagation();
96+
navigate(`/taxonomy/${id}/competencies`);
97+
}}
98+
>
99+
{intl.formatMessage(messages.applyCompetenciesButton)}
100+
</Button>
101+
</Card.Footer>
102+
)}
77103
</Card>
78104
);
79105
};

src/taxonomy/taxonomy-card/messages.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ const messages = defineMessages({
55
id: 'course-authoring.taxonomy-list.orgs-count.label',
66
defaultMessage: 'Assigned to {orgsCount} orgs',
77
},
8+
applyCompetenciesButton: {
9+
id: 'course-authoring.taxonomy-list.button.apply-competencies.label',
10+
defaultMessage: 'Apply Competencies',
11+
description: 'Button on a competency taxonomy card that opens its competency management page.',
12+
},
813
});
914

1015
export default messages;

0 commit comments

Comments
 (0)