Skip to content
Closed
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
31 changes: 31 additions & 0 deletions src/generic/CompetencyIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from 'react';

/**
* The competency target icon. Kept in this repo because Paragon does not
* ship an equivalent icon.
*/
const CompetencyIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M10.8289 2.34375C12.2696 2.34375 13.6445 2.62528 14.9017 3.13602V5.15819L14.4088 5.65031C13.3242 5.13316 12.1106 4.84258 10.8289 4.84258C6.22872 4.84258 2.4995 8.5718 2.4995 13.172C2.4995 17.7722 6.22872 21.5015 10.8289 21.5015C15.4292 21.5015 19.1584 17.7722 19.1584 13.172C19.1584 11.8575 18.8523 10.6148 18.31 9.50918L18.8102 9.00974H20.8283C21.3623 10.2911 21.6572 11.6971 21.6572 13.172C21.6572 19.1523 16.8092 24.0003 10.8289 24.0003C4.84865 24.0003 0.000671387 19.1523 0.000671387 13.172C0.000671387 7.19173 4.84865 2.34375 10.8289 2.34375Z"
fill="currentColor"
/>
<path
d="M10.8289 6.50847C11.6411 6.50847 12.4193 6.65402 13.1391 6.92006L11.134 8.92514C11.0382 8.91859 10.9414 8.91457 10.8444 8.91457C8.60832 8.91503 6.782 10.6711 6.66991 12.88L6.66422 13.0956C6.66422 13.1083 6.66492 13.1211 6.66503 13.1338C6.66492 13.1465 6.66422 13.1593 6.66422 13.172C6.66422 15.4721 8.52883 17.3367 10.8289 17.3367C12.5608 17.3367 14.0451 16.2794 14.6732 14.7753C14.8993 14.261 15.026 13.6931 15.0262 13.0956C15.0262 12.9978 15.0215 12.9008 15.0148 12.8044L17.0459 10.7732C17.3334 11.5177 17.4925 12.3262 17.4925 13.172C17.4925 16.8522 14.5091 19.8356 10.8289 19.8356C7.14876 19.8356 4.16539 16.8522 4.16539 13.172C4.16539 9.49184 7.14876 6.50847 10.8289 6.50847Z"
fill="currentColor"
/>
<path
d="M19.7053 4.2421H23.8755L20.7741 7.34354H18.12L13.2142 12.2494C13.3085 12.5137 13.3597 12.7988 13.3597 13.0955C13.3595 14.4842 12.2338 15.6101 10.845 15.6101C9.45609 15.6101 8.32977 14.4838 8.32977 13.0949C8.32986 11.7062 9.45579 10.5805 10.8444 10.5802C11.1408 10.5802 11.4259 10.631 11.69 10.7252L16.5676 5.8475V3.1371L19.7047 0L19.7053 4.2421Z"
fill="currentColor"
/>
</svg>
);

export default CompetencyIcon;
8 changes: 7 additions & 1 deletion src/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ import CourseAuthoringRoutes from './CourseAuthoringRoutes';
import Head from './head/Head';
import { StudioHome } from './studio-home';
import CourseRerun from './course-rerun';
import { TaxonomyLayout, TaxonomyDetailPage, TaxonomyListPage } from './taxonomy';
import {
CompetencyManagementPage,
TaxonomyDetailPage,
TaxonomyLayout,
TaxonomyListPage,
} from './taxonomy';
import { ContentTagsDrawer } from './content-tags-drawer';
import AccessibilityPage from './accessibility-page';
import { ToastProvider } from './generic/toast-context';
Expand Down Expand Up @@ -110,6 +115,7 @@ const App = () => {
</Route>
<Route path="/taxonomy" element={<TaxonomyLayout />}>
<Route path="/taxonomy/:taxonomyId" element={<TaxonomyDetailPage />} />
<Route path="/taxonomy/:taxonomyId/competencies" element={<CompetencyManagementPage />} />
</Route>
<Route
path="/tagging/components/widget/:contentId"
Expand Down
17 changes: 17 additions & 0 deletions src/taxonomy/TaxonomyListPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,23 @@ describe('<TaxonomyListPage />', () => {
expect(getByTestId('taxonomy-card-1')).toBeInTheDocument();
});

it('shows the taxonomy type icon of each taxonomy', async () => {
axiosMock.onGet(listTaxonomiesUrl).reply(200, {
results: [
{ ...taxonomies[0], id: 1, taxonomy_type: 'competency' },
{ ...taxonomies[0], id: 2, taxonomy_type: 'tags' },
],
canAddTaxonomy: false,
});
const { getByTestId, queryByText } = render(<TaxonomyListPage />);
await waitFor(() => {
expect(queryByText('Loading')).toEqual(null);
});

expect(getByTestId('taxonomy-card-1')).toContainElement(getByTestId('taxonomy-type-icon-competency'));
expect(getByTestId('taxonomy-card-2')).toContainElement(getByTestId('taxonomy-type-icon-tags'));
});

it.each(['csv', 'json'] as const)('downloads the taxonomy template %s', async (fileFormat) => {
axiosMock.onGet(listTaxonomiesUrl).reply(200, { results: taxonomies, canAddTaxonomy: false });
const { findByRole, queryByText } = render(<TaxonomyListPage />);
Expand Down
2 changes: 1 addition & 1 deletion src/taxonomy/TaxonomyListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { ALL_TAXONOMIES, apiUrls, UNASSIGNED } from './data/api';
import { useTaxonomyList } from './data/apiHooks';
import { ImportTagsWizard } from './import-tags';
import messages from './messages';
import TaxonomyCard from './taxonomy-card';
import { TaxonomyCard } from './taxonomy-card';

const TaxonomyListHeaderButtons = (props: { canAddTaxonomy: boolean; }) => {
const intl = useIntl();
Expand Down
4 changes: 4 additions & 0 deletions src/taxonomy/__mocks__/taxonomyListMock.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ module.exports = {
id: -2,
name: 'Content Authors',
description: 'Allows tags for any user ID created on the instance.',
taxonomyType: 'tags',
enabled: true,
allowMultiple: false,
allowFreeText: false,
Expand All @@ -23,6 +24,7 @@ module.exports = {
id: -1,
name: 'Languages',
description: 'lang lang lang lang lang lang lang lang',
taxonomyType: 'tags',
enabled: true,
allowMultiple: false,
allowFreeText: false,
Expand All @@ -35,6 +37,7 @@ module.exports = {
id: 1,
name: 'Taxonomy',
description: 'This is a Description',
taxonomyType: 'competency',
enabled: true,
allowMultiple: false,
allowFreeText: false,
Expand All @@ -47,6 +50,7 @@ module.exports = {
id: 2,
name: 'Taxonomy long long long long long long long long long long long long long long long long long long long',
description: 'This is a Description long lon',
taxonomyType: 'tags',
enabled: true,
allowMultiple: false,
allowFreeText: false,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { initializeMocks, render, type RouteOptions } from '@src/testUtils';
import { apiUrls } from '../data/api';
import { CompetencyManagementPage } from '.';

const taxonomyId = 1;

const route: RouteOptions = {
path: '/taxonomy/:taxonomyId/competencies',
params: { taxonomyId: `${taxonomyId}` },
};

const taxonomyResponse = {
id: taxonomyId,
name: 'Test taxonomy',
description: 'This is a description',
taxonomy_type: 'competency',
read_only: false,
can_change_taxonomy: true,
can_delete_taxonomy: true,
};

describe('<CompetencyManagementPage />', () => {
let axiosMock;

beforeEach(() => {
({ axiosMock } = initializeMocks());
});

it('shows the spinner before the query is complete', () => {
// Use an unresolved promise to keep the Loading visible
axiosMock.onGet(apiUrls.taxonomy(taxonomyId)).reply(() => new Promise(() => {}));

const { getByRole } = render(<CompetencyManagementPage />, route);

expect(getByRole('status').textContent).toEqual('Loading...');
});

it('shows the connection error component if no taxonomy is returned', async () => {
// Use an empty response to trigger the error. Returning an error does not
// work because the query will retry.
axiosMock.onGet(apiUrls.taxonomy(taxonomyId)).reply(200);

const { findByTestId } = render(<CompetencyManagementPage />, route);

expect(await findByTestId('connectionErrorAlert')).toBeInTheDocument();
});

it('shows the taxonomy name as the title, under a breadcrumb back to the list', async () => {
axiosMock.onGet(apiUrls.taxonomy(taxonomyId)).reply(200, taxonomyResponse);

const { findByRole, getByRole, queryByRole } = render(<CompetencyManagementPage />, route);

expect(await findByRole('heading')).toHaveTextContent('Test taxonomy');
expect(getByRole('link', { name: 'Taxonomies' })).toHaveAttribute('href', '/taxonomies/');
// The taxonomy name is the breadcrumb's active step, so it is text rather than a link
expect(queryByRole('link', { name: 'Test taxonomy' })).not.toBeInTheDocument();
});
});
61 changes: 61 additions & 0 deletions src/taxonomy/competency-management/CompetencyManagementPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { Breadcrumb, Container } from '@openedx/paragon';
import { Helmet } from 'react-helmet';
import { Link, useParams } from 'react-router-dom';

import ConnectionErrorAlert from '@src/generic/ConnectionErrorAlert';
import Loading from '@src/generic/Loading';
import SubHeader from '@src/generic/sub-header/SubHeader';
import getPageHeadTitle from '@src/generic/utils';
import taxonomyMessages from '../messages';
import { useTaxonomyDetails } from '../data/apiHooks';

/**
* Page where competencies of a taxonomy are managed and applied to course content.
*
* The page is a placeholder for now: it carries the breadcrumb and the title, the same
* way the taxonomy detail page does. The competency tree and its actions are added by a
* later ticket.
*/
export const CompetencyManagementPage = () => {
const intl = useIntl();
const { taxonomyId: taxonomyIdString } = useParams();
const taxonomyId = Number(taxonomyIdString);

const {
data: taxonomy,
isError,
isFetched,
} = useTaxonomyDetails(taxonomyId);

if (!isFetched) {
return <Loading />;
}

if (isError || !taxonomy) {
return <ConnectionErrorAlert />;
}

return (
<>
<Helmet>
<title>{getPageHeadTitle(intl.formatMessage(taxonomyMessages.headerTitle), taxonomy.name)}</title>
</Helmet>
<div className="pt-4.5 pr-4.5 pl-4.5 pb-2 bg-light-100 box-shadow-down-2">
<Container size="xl">
<Breadcrumb
links={[
{ label: intl.formatMessage(taxonomyMessages.headerTitle), to: '/taxonomies/' },
]}
activeLabel={taxonomy.name}
linkAs={Link}
/>
<SubHeader
title={taxonomy.name}
hideBorder
/>
</Container>
</div>
</>
);
};
1 change: 1 addition & 0 deletions src/taxonomy/competency-management/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { CompetencyManagementPage } from './CompetencyManagementPage';
74 changes: 40 additions & 34 deletions src/taxonomy/data/apiHooks.test.jsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
// @ts-check
import React from 'react'; // Required to use JSX syntax without type errors

import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

import MockAdapter from 'axios-mock-adapter';

import { initializeMocks } from '@src/testUtils';
import { apiUrls } from './api';

import {
Expand All @@ -17,6 +14,7 @@ import {
useImportTags,
useImportNewTaxonomy,
} from './apiHooks';
import { TaxonomyType } from './constants';

let axiosMock;

Expand All @@ -30,49 +28,57 @@ const queryClient = new QueryClient({

const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en">
{children}
</IntlProvider>
<IntlProvider locale="en">{children}</IntlProvider>
</QueryClientProvider>
);

const emptyFile = new File([], 'empty.csv');

const taxonomyTypes = Object.values(TaxonomyType);

describe('import taxonomy api calls', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
({ axiosMock } = initializeMocks());
});

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

it('should call import new taxonomy', async () => {
const mockResult = {
id: 8,
name: 'Taxonomy name',
exportId: 'taxonomy_export_id',
description: 'Taxonomy description',
};
axiosMock.onPost(apiUrls.createTaxonomyFromImport()).reply(201, mockResult);
const { result } = renderHook(() => useImportNewTaxonomy(), { wrapper });
const mutateResult = await result.current.mutateAsync({
name: 'Taxonomy name',
description: 'Taxonomy description',
file: emptyFile,
});

expect(axiosMock.history.post[0].url).toEqual(apiUrls.createTaxonomyFromImport());
expect(mutateResult).toEqual(mockResult);
});
it.each(taxonomyTypes)(
'should call import new taxonomy with the %s type',
async (taxonomyType) => {
const mockResult = {
id: 8,
name: 'Taxonomy name',
exportId: 'taxonomy_export_id',
description: 'Taxonomy description',
};
axiosMock
.onPost(apiUrls.createTaxonomyFromImport())
.reply(201, mockResult);
const { result } = renderHook(() => useImportNewTaxonomy(), {
wrapper,
});
const mutateResult = await result.current.mutateAsync({
name: 'Taxonomy name',
description: 'Taxonomy description',
taxonomyType,
file: emptyFile,
});

expect(axiosMock.history.post[0].url).toEqual(
apiUrls.createTaxonomyFromImport(),
);
const formData = axiosMock.history.post[0].data;
expect(formData.get('taxonomy_name')).toEqual('Taxonomy name');
expect(formData.get('taxonomy_description')).toEqual(
'Taxonomy description',
);
expect(formData.get('taxonomy_type')).toEqual(taxonomyType);
expect(mutateResult).toEqual(mockResult);
},
);

it('should call import tags', async () => {
const taxonomy = { id: 1, name: 'taxonomy name' };
Expand Down
Loading