feat: bare bones taxonomy detail page [FC-0036] (#655)

* feat: System-defined tooltip added

* feat: Taxonomy card menu added. Export menu item added

* feat: Modal for export taxonomy

* feat: Connect with export API

* test: Tests for API and selectors

* feat: Use windows.location.href to call the export endpoint

* test: ExportModal.test added

* style: Delete unnecesary code

* docs: README updated with taxonomy feature

* style: TaxonomyCard updated to a better code style

* style: injectIntl replaced by useIntl on taxonomy pages and components

* refactor: Move and rename taxonomy UI components to match 0002 ADR

* refactor: Move api to data to match with 0002 ADR

* test: Refactor ExportModal tests

* chore: Fix validations

* chore: Lint

* refactor: Moving hooks to apiHooks

* feat: add taxonomy detail page

* fix: address nits in PR review

* refactor: move data/selectors to data/apiHooks

and fix tests to mock useQuery.

* fix: address nits in PR review

* fix: replace taxonomy menu ModalPopup with Dropdown menu

Avoids clicking through to the card when using the menu button to hide
a card's menu.

* fix: change taxonomy URLs

* /taxonomy-list is now /taxonomies, and there's a temporary redirect
* /taxonomy-list/🆔 is now /taxonomy/🆔

---------

Co-authored-by: Christofer <christofer@opencraft.com>
Co-authored-by: XnpioChV <xnpiochv@gmail.com>
Co-authored-by: Christofer Chavez <christofer@example.com>
Co-authored-by: Jillian Vogel <jill@opencraft.com>
Co-authored-by: Braden MacDonald <braden@opencraft.com>
This commit is contained in:
Rômulo Penido
2023-11-20 17:15:31 -03:00
committed by GitHub
parent 375006deb1
commit 02cdccc77c
30 changed files with 963 additions and 55 deletions

View File

@@ -7,7 +7,7 @@ import {
import { AppProvider, ErrorPage } from '@edx/frontend-platform/react';
import React, { useEffect } from 'react';
import ReactDOM from 'react-dom';
import { Route, Routes } from 'react-router-dom';
import { Navigate, Route, Routes } from 'react-router-dom';
import {
QueryClient,
QueryClientProvider,
@@ -22,7 +22,7 @@ import CourseAuthoringRoutes from './CourseAuthoringRoutes';
import Head from './head/Head';
import { StudioHome } from './studio-home';
import CourseRerun from './course-rerun';
import { TaxonomyListPage } from './taxonomy';
import { TaxonomyLayout, TaxonomyDetailPage, TaxonomyListPage } from './taxonomy';
import { ContentTagsDrawer } from './content-tags-drawer';
import 'react-datepicker/dist/react-datepicker.css';
@@ -55,10 +55,14 @@ const App = () => {
<Route path="/course_rerun/:courseId" element={<CourseRerun />} />
{process.env.ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && (
<>
<Route
path="/taxonomy-list"
element={<TaxonomyListPage />}
/>
{/* TODO: remove this redirect once Studio's link is updated */}
<Route path="/taxonomy-list" element={<Navigate to="/taxonomies" />} />
<Route path="/taxonomies" element={<TaxonomyLayout />}>
<Route index element={<TaxonomyListPage />} />
</Route>
<Route path="/taxonomy" element={<TaxonomyLayout />}>
<Route path="/taxonomy/:taxonomyId" element={<TaxonomyDetailPage />} />
</Route>
<Route
path="/tagging/components/widget/:contentId"
element={<ContentTagsDrawer />}

View File

@@ -0,0 +1,14 @@
import { StudioFooter } from '@edx/frontend-component-footer';
import { Outlet } from 'react-router-dom';
import Header from '../header';
const TaxonomyLayout = () => (
<div className="bg-light-400">
<Header isHiddenMainMenu />
<Outlet />
<StudioFooter />
</div>
);
export default TaxonomyLayout;

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { render } from '@testing-library/react';
import initializeStore from '../store';
import TaxonomyLayout from './TaxonomyLayout';
let store;
jest.mock('../header', () => jest.fn(() => <div data-testid="mock-header" />));
jest.mock('@edx/frontend-component-footer', () => ({
StudioFooter: jest.fn(() => <div data-testid="mock-footer" />),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
Outlet: jest.fn(() => <div data-testid="mock-content" />),
}));
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<TaxonomyLayout />
</IntlProvider>
</AppProvider>
);
describe('<TaxonomyLayout />', async () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
});
it('should render page correctly', async () => {
const { getByTestId } = render(<RootWrapper />);
expect(getByTestId('mock-header')).toBeInTheDocument();
expect(getByTestId('mock-content')).toBeInTheDocument();
expect(getByTestId('mock-footer')).toBeInTheDocument();
});
});

View File

@@ -5,9 +5,7 @@ import {
DataTable,
Spinner,
} from '@edx/paragon';
import { StudioFooter } from '@edx/frontend-component-footer';
import { useIntl } from '@edx/frontend-platform/i18n';
import Header from '../header';
import SubHeader from '../generic/sub-header/SubHeader';
import messages from './messages';
import TaxonomyCard from './taxonomy-card';
@@ -37,14 +35,6 @@ const TaxonomyListPage = () => {
return (
<>
<style>
{`
body {
background-color: #E9E6E4; /* light-400 */
}
`}
</style>
<Header isHiddenMainMenu />
<div className="pt-4.5 pr-4.5 pl-4.5 pb-2 bg-light-100 box-shadow-down-2">
<Container size="xl">
<SubHeader
@@ -93,7 +83,6 @@ const TaxonomyListPage = () => {
)}
</Container>
</div>
<StudioFooter />
</>
);
};

View File

@@ -67,7 +67,11 @@ const ExportModal = ({
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages.taxonomyModalsCancelLabel)}
</ModalDialog.CloseButton>
<Button variant="primary" onClick={onClickExport}>
<Button
variant="primary"
onClick={onClickExport}
data-testid={`export-button-${taxonomyId}`}
>
{intl.formatMessage(messages.exportModalSubmitButtonLabel)}
</Button>
</ActionRow>

View File

@@ -1,2 +1,3 @@
// eslint-disable-next-line import/prefer-default-export
export { default as TaxonomyListPage } from './TaxonomyListPage';
export { default as TaxonomyLayout } from './TaxonomyLayout';
export { TaxonomyDetailPage } from './taxonomy-detail';

View File

@@ -0,0 +1,56 @@
// ts-check
import { useIntl } from '@edx/frontend-platform/i18n';
import {
DataTable,
} from '@edx/paragon';
import _ from 'lodash';
import Proptypes from 'prop-types';
import { useState } from 'react';
import messages from './messages';
import { useTagListDataResponse, useTagListDataStatus } from './data/apiHooks';
const TagListTable = ({ taxonomyId }) => {
const intl = useIntl();
const [options, setOptions] = useState({
pageIndex: 0,
});
const { isLoading } = useTagListDataStatus(taxonomyId, options);
const tagList = useTagListDataResponse(taxonomyId, options);
const fetchData = (args) => {
if (!_.isEqual(args, options)) {
setOptions({ ...args });
}
};
return (
<DataTable
isLoading={isLoading}
isPaginated
manualPagination
fetchData={fetchData}
data={tagList?.results || []}
itemCount={tagList?.count || 0}
pageCount={tagList?.numPages || 0}
initialState={options}
columns={[
{
Header: intl.formatMessage(messages.tagListColumnValueHeader),
accessor: 'value',
},
]}
>
<DataTable.TableControlBar />
<DataTable.Table />
<DataTable.EmptyTable content={intl.formatMessage(messages.noResultsFoundMessage)} />
<DataTable.TableFooter />
</DataTable>
);
};
TagListTable.propTypes = {
taxonomyId: Proptypes.string.isRequired,
};
export default TagListTable;

View File

@@ -0,0 +1,67 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { render } from '@testing-library/react';
import { useTagListData } from './data/api';
import initializeStore from '../../store';
import TagListTable from './TagListTable';
let store;
jest.mock('./data/api', () => ({
useTagListData: jest.fn(),
}));
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<TagListTable taxonomyId="1" />
</IntlProvider>
</AppProvider>
);
describe('<TagListPage />', async () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
});
it('shows the spinner before the query is complete', async () => {
useTagListData.mockReturnValue({
isLoading: true,
isFetched: false,
});
const { getByRole } = render(<RootWrapper />);
const spinner = getByRole('status');
expect(spinner.textContent).toEqual('loading');
});
it('should render page correctly', async () => {
useTagListData.mockReturnValue({
isSuccess: true,
isFetched: true,
isError: false,
data: {
count: 3,
numPages: 1,
results: [
{ value: 'Tag 1' },
{ value: 'Tag 2' },
{ value: 'Tag 3' },
],
},
});
const { getAllByRole } = render(<RootWrapper />);
const rows = getAllByRole('row');
expect(rows.length).toBe(3 + 1); // 3 items plus header
});
});

View File

@@ -0,0 +1,27 @@
// @ts-check
import { useQuery } from '@tanstack/react-query';
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
const getTagListApiUrl = (taxonomyId, page) => new URL(
`api/content_tagging/v1/taxonomies/${taxonomyId}/tags/?page=${page + 1}`,
getApiBaseUrl(),
).href;
// ToDo: fix types
/**
* @param {number} taxonomyId
* @param {import('./types.mjs').QueryOptions} options
* @returns {import('@tanstack/react-query').UseQueryResult<import('./types.mjs').TagListData>}
*/ // eslint-disable-next-line import/prefer-default-export
export const useTagListData = (taxonomyId, options) => {
const { pageIndex } = options;
return useQuery({
queryKey: ['tagList', taxonomyId, pageIndex],
queryFn: async () => {
const { data } = await getAuthenticatedHttpClient().get(getTagListApiUrl(taxonomyId, pageIndex));
return camelCaseObject(data);
},
});
};

View File

@@ -0,0 +1,27 @@
import { useQuery } from '@tanstack/react-query';
import {
useTagListData,
} from './api';
const mockHttpClient = {
get: jest.fn(),
};
jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn(),
}));
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(() => mockHttpClient),
}));
describe('useTagListData', () => {
it('should call useQuery with the correct parameters', () => {
useTagListData('1', { pageIndex: 3 });
expect(useQuery).toHaveBeenCalledWith({
queryKey: ['tagList', '1', 3],
queryFn: expect.any(Function),
});
});
});

View File

@@ -0,0 +1,41 @@
// @ts-check
import {
useTagListData,
} from './api';
/* eslint-disable max-len */
/**
* @param {number} taxonomyId
* @param {import("./types.mjs").QueryOptions} options
* @returns {Pick<import('@tanstack/react-query').UseQueryResult, "error" | "isError" | "isFetched" | "isLoading" | "isSuccess" >}
*/ /* eslint-enable max-len */
export const useTagListDataStatus = (taxonomyId, options) => {
const {
error,
isError,
isFetched,
isLoading,
isSuccess,
} = useTagListData(taxonomyId, options);
return {
error,
isError,
isFetched,
isLoading,
isSuccess,
};
};
/**
* @param {number} taxonomyId
* @param {import("./types.mjs").QueryOptions} options
* @returns {import("./types.mjs").TagListData | undefined}
*/
export const useTagListDataResponse = (taxonomyId, options) => {
const { isSuccess, data } = useTagListData(taxonomyId, options);
if (isSuccess) {
return data;
}
return undefined;
};

View File

@@ -0,0 +1,45 @@
import { useQuery } from '@tanstack/react-query';
import {
useTagListDataStatus,
useTagListDataResponse,
} from './apiHooks';
jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn(),
}));
describe('useTagListDataStatus', () => {
it('should return status values', () => {
const status = {
error: undefined,
isError: false,
isFetched: true,
isLoading: true,
isSuccess: true,
};
useQuery.mockReturnValueOnce(status);
const result = useTagListDataStatus(0, {});
expect(result).toEqual(status);
});
});
describe('useTagListDataResponse', () => {
it('should return data when status is success', () => {
useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
const result = useTagListDataResponse(0, {});
expect(result).toEqual('data');
});
it('should return undefined when status is not success', () => {
useQuery.mockReturnValueOnce({ isSuccess: false });
const result = useTagListDataResponse(0, {});
expect(result).toBeUndefined();
});
});

View File

@@ -0,0 +1,28 @@
// @ts-check
/**
* @typedef {Object} QueryOptions
* @property {number} pageIndex
*/
/**
* @typedef {Object} TagListData
* @property {number} childCount
* @property {number} depth
* @property {string} externalId
* @property {number} id
* @property {string | null} parentValue
* @property {string | null} subTagsUrl
* @property {string} value
*/
/**
* @typedef {Object} TagData
* @property {number} count
* @property {number} currentPage
* @property {string} next
* @property {number} numPages
* @property {string} previous
* @property {TagListData[]} results
* @property {number} start
*/

View File

@@ -0,0 +1,2 @@
/* eslint-disable import/prefer-default-export */
export { default as TagListTable } from './TagListTable';

View File

@@ -0,0 +1,14 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
noResultsFoundMessage: {
id: 'course-authoring.tag-list.no-results-found.message',
defaultMessage: 'No results found',
},
tagListColumnValueHeader: {
id: 'course-authoring.tag-list.column.value.header',
defaultMessage: 'Value',
},
});
export default messages;

View File

@@ -89,22 +89,27 @@ describe('<TaxonomyCard />', async () => {
});
test('should open and close menu on button click', () => {
const { getByTestId, getByText } = render(<TaxonomyCardComponent original={data} />);
const { getByTestId } = render(<TaxonomyCardComponent original={data} />);
// Menu closed
// Menu closed/doesn't exist yet
expect(() => getByTestId('taxonomy-card-menu-1')).toThrow();
// Click on the menu button to open
fireEvent.click(getByTestId('taxonomy-card-menu-button-1'));
// Menu opened
expect(getByTestId('taxonomy-card-menu-1')).toBeInTheDocument();
expect(getByTestId('taxonomy-card-menu-1')).toBeVisible();
// Click on any element to close the menu
fireEvent.click(getByText('Export'));
// Click on button again to close the menu
fireEvent.click(getByTestId('taxonomy-card-menu-button-1'));
// Menu closed
expect(() => getByTestId('taxonomy-card-menu-1')).toThrow();
// Jest bug: toBeVisible() isn't checking opacity correctly
// expect(getByTestId('taxonomy-card-menu-1')).not.toBeVisible();
expect(getByTestId('taxonomy-card-menu-1').style.opacity).toEqual('0');
// Menu button still visible
expect(getByTestId('taxonomy-card-menu-button-1')).toBeVisible();
});
test('should open export modal on export menu click', () => {
@@ -115,7 +120,7 @@ describe('<TaxonomyCard />', async () => {
// Click on export menu
fireEvent.click(getByTestId('taxonomy-card-menu-button-1'));
fireEvent.click(getByText('Export'));
fireEvent.click(getByTestId('taxonomy-card-menu-export-1'));
// Modal opened
expect(getByText('Select format to export')).toBeInTheDocument();
@@ -132,11 +137,11 @@ describe('<TaxonomyCard />', async () => {
// Click on export menu
fireEvent.click(getByTestId('taxonomy-card-menu-button-1'));
fireEvent.click(getByText('Export'));
fireEvent.click(getByTestId('taxonomy-card-menu-export-1'));
// Select JSON format and click on export
fireEvent.click(getByText('JSON file'));
fireEvent.click(getByText('Export'));
fireEvent.click(getByTestId('export-button-1'));
// Modal closed
expect(() => getByText('Select format to export')).toThrow();

View File

@@ -1,10 +1,8 @@
import React, { useState } from 'react';
import React from 'react';
import {
Dropdown,
IconButton,
ModalPopup,
Menu,
Icon,
MenuItem,
} from '@edx/paragon';
import { MoreVert } from '@edx/paragon/icons';
import PropTypes from 'prop-types';
@@ -15,38 +13,34 @@ const TaxonomyCardMenu = ({
id, name, onClickMenuItem,
}) => {
const intl = useIntl();
const [menuIsOpen, setMenuIsOpen] = useState(false);
const [menuTarget, setMenuTarget] = useState(null);
const onClickItem = (menuName) => {
setMenuIsOpen(false);
const onClickItem = (e, menuName) => {
e.preventDefault();
onClickMenuItem(menuName);
};
return (
<>
<IconButton
variant="primary"
onClick={() => setMenuIsOpen(true)}
ref={setMenuTarget}
<Dropdown onToggle={(isOpen, ev) => ev.preventDefault()}>
<Dropdown.Toggle
as={IconButton}
src={MoreVert}
iconAs={Icon}
variant="primary"
alt={intl.formatMessage(messages.taxonomyMenuAlt, { name })}
id={`taxonomy-card-menu-button-${id}`}
data-testid={`taxonomy-card-menu-button-${id}`}
/>
<ModalPopup
positionRef={menuTarget}
isOpen={menuIsOpen}
onClose={() => setMenuIsOpen(false)}
>
<Menu data-testid={`taxonomy-card-menu-${id}`}>
{/* Add more menu items here */}
<MenuItem className="taxonomy-menu-item" onClick={() => onClickItem('export')}>
{intl.formatMessage(messages.taxonomyCardExportMenu)}
</MenuItem>
</Menu>
</ModalPopup>
</>
<Dropdown.Menu data-testid={`taxonomy-card-menu-${id}`}>
{/* Add more menu items here */}
<Dropdown.Item
className="taxonomy-menu-item"
data-testid={`taxonomy-card-menu-export-${id}`}
onClick={(e) => onClickItem(e, 'export')}
>
{intl.formatMessage(messages.taxonomyCardExportMenu)}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
);
};

View File

@@ -6,6 +6,7 @@ import {
Popover,
} from '@edx/paragon';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import classNames from 'classnames';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
@@ -109,7 +110,13 @@ const TaxonomyCard = ({ className, original }) => {
return (
<>
<Card className={classNames('taxonomy-card', className)} data-testid={`taxonomy-card-${id}`}>
<Card
isClickable
as={Link}
to={`/taxonomy/${id}`}
className={classNames('taxonomy-card', className)}
data-testid={`taxonomy-card-${id}`}
>
<Card.Header
title={name}
subtitle={(

View File

@@ -0,0 +1,41 @@
// ts-check
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Dropdown,
DropdownButton,
} from '@edx/paragon';
import PropTypes from 'prop-types';
import messages from './messages';
const TaxonomyDetailMenu = ({
id, name, disabled, onClickMenuItem,
}) => {
const intl = useIntl();
return (
<DropdownButton
id={id}
title={intl.formatMessage(messages.actionsButtonLabel)}
alt={intl.formatMessage(messages.actionsButtonAlt, { name })}
disabled={disabled}
>
<Dropdown.Item onClick={() => onClickMenuItem('export')}>
{intl.formatMessage(messages.exportMenu)}
</Dropdown.Item>
</DropdownButton>
);
};
TaxonomyDetailMenu.propTypes = {
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
disabled: PropTypes.bool,
onClickMenuItem: PropTypes.func.isRequired,
};
TaxonomyDetailMenu.defaultProps = {
disabled: false,
};
export default TaxonomyDetailMenu;

View File

@@ -0,0 +1,116 @@
// ts-check
import React, { useState } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Breadcrumb,
Container,
Layout,
} from '@edx/paragon';
import { Link, useParams } from 'react-router-dom';
import ConnectionErrorAlert from '../../generic/ConnectionErrorAlert';
import Loading from '../../generic/Loading';
import SubHeader from '../../generic/sub-header/SubHeader';
import taxonomyMessages from '../messages';
import TaxonomyDetailMenu from './TaxonomyDetailMenu';
import TaxonomyDetailSideCard from './TaxonomyDetailSideCard';
import { TagListTable } from '../tag-list';
import ExportModal from '../export-modal';
import { useTaxonomyDetailDataResponse, useTaxonomyDetailDataStatus } from './data/apiHooks';
const TaxonomyDetailPage = () => {
const intl = useIntl();
const { taxonomyId } = useParams();
const { isError, isFetched } = useTaxonomyDetailDataStatus(taxonomyId);
const taxonomy = useTaxonomyDetailDataResponse(taxonomyId);
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
if (!isFetched) {
return (
<Loading />
);
}
if (isError || !taxonomy) {
return (
<ConnectionErrorAlert />
);
}
const renderModals = () => isExportModalOpen && (
<ExportModal
isOpen={isExportModalOpen}
onClose={() => setIsExportModalOpen(false)}
taxonomyId={taxonomy.id}
taxonomyName={taxonomy.name}
/>
);
const onClickMenuItem = (menuName) => {
switch (menuName) {
case 'export':
setIsExportModalOpen(true);
break;
default:
break;
}
};
const getHeaderActions = () => (
<TaxonomyDetailMenu
id={taxonomy.id}
name={taxonomy.name}
disabled={
// We don't show the export menu, because the system-taxonomies
// can't be exported. The API returns and error.
// The entire menu has been disabled because currently only
// the export menu exists.
// ToDo: When adding more menus, change this logic to hide only the export menu.
taxonomy.systemDefined
}
onClickMenuItem={onClickMenuItem}
/>
);
return (
<>
<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
headerActions={getHeaderActions()}
/>
</Container>
</div>
<div className="bg-light-400 m-4">
<Container size="xl">
<Layout
lg={[{ span: 9 }, { span: 3 }]}
md={[{ span: 9 }, { span: 3 }]}
sm={[{ span: 9 }, { span: 3 }]}
xs={[{ span: 9 }, { span: 3 }]}
xl={[{ span: 9 }, { span: 3 }]}
>
<Layout.Element>
<TagListTable taxonomyId={taxonomyId} />
</Layout.Element>
<Layout.Element>
<TaxonomyDetailSideCard taxonomy={taxonomy} />
</Layout.Element>
</Layout>
</Container>
</div>
{renderModals()}
</>
);
};
export default TaxonomyDetailPage;

View File

@@ -0,0 +1,111 @@
import React from 'react';
import { initializeMockApp } from '@edx/frontend-platform';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { fireEvent, render } from '@testing-library/react';
import { useTaxonomyDetailData } from './data/api';
import initializeStore from '../../store';
import TaxonomyDetailPage from './TaxonomyDetailPage';
let store;
jest.mock('./data/api', () => ({
useTaxonomyDetailData: jest.fn(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), // use actual for all non-hook parts
useParams: () => ({
taxonomyId: '1',
}),
}));
jest.mock('./TaxonomyDetailSideCard', () => jest.fn(() => <>Mock TaxonomyDetailSideCard</>));
jest.mock('../tag-list/TagListTable', () => jest.fn(() => <>Mock TagListTable</>));
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<TaxonomyDetailPage />
</IntlProvider>
</AppProvider>
);
describe('<TaxonomyDetailPage />', async () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
});
it('shows the spinner before the query is complete', async () => {
useTaxonomyDetailData.mockReturnValue({
isFetched: false,
});
const { getByRole } = render(<RootWrapper />);
const spinner = getByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});
it('shows the connector error component if got some error', async () => {
useTaxonomyDetailData.mockReturnValue({
isFetched: true,
isError: true,
});
const { getByTestId } = render(<RootWrapper />);
expect(getByTestId('connectionErrorAlert')).toBeInTheDocument();
});
it('should render page and page title correctly', async () => {
useTaxonomyDetailData.mockReturnValue({
isSuccess: true,
isFetched: true,
isError: false,
data: {
id: 1,
name: 'Test taxonomy',
description: 'This is a description',
systemDefined: false,
},
});
const { getByRole } = render(<RootWrapper />);
expect(getByRole('heading')).toHaveTextContent('Test taxonomy');
});
it('should open export modal on export menu click', () => {
useTaxonomyDetailData.mockReturnValue({
isSuccess: true,
isFetched: true,
isError: false,
data: {
id: 1,
name: 'Test taxonomy',
description: 'This is a description',
},
});
const { getByRole, getByText } = render(<RootWrapper />);
// Modal closed
expect(() => getByText('Select format to export')).toThrow();
// Click on export menu
fireEvent.click(getByRole('button'));
fireEvent.click(getByText('Export'));
// Modal opened
expect(getByText('Select format to export')).toBeInTheDocument();
// Click on cancel button
fireEvent.click(getByText('Cancel'));
// Modal closed
expect(() => getByText('Select format to export')).toThrow();
});
});

View File

@@ -0,0 +1,32 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Card,
} from '@edx/paragon';
import Proptypes from 'prop-types';
import messages from './messages';
const TaxonomyDetailSideCard = ({ taxonomy }) => {
const intl = useIntl();
return (
<Card>
<Card.Header title={intl.formatMessage(messages.taxonomyDetailsHeader)} />
<Card.Section title={intl.formatMessage(messages.taxonomyDetailsName)}>
{taxonomy.name}
</Card.Section>
<Card.Divider className="ml-3 mr-3" />
<Card.Section title={intl.formatMessage(messages.taxonomyDetailsDescription)}>
{taxonomy.description}
</Card.Section>
</Card>
);
};
TaxonomyDetailSideCard.propTypes = {
taxonomy: Proptypes.shape({
name: Proptypes.string.isRequired,
description: Proptypes.string.isRequired,
}).isRequired,
};
export default TaxonomyDetailSideCard;

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { render } from '@testing-library/react';
import PropTypes from 'prop-types';
import initializeStore from '../../store';
import TaxonomyDetailSideCard from './TaxonomyDetailSideCard';
let store;
const data = {
id: 1,
name: 'Taxonomy 1',
description: 'This is a description',
};
const TaxonomyCardComponent = ({ taxonomy }) => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<TaxonomyDetailSideCard taxonomy={taxonomy} />
</IntlProvider>
</AppProvider>
);
TaxonomyCardComponent.propTypes = {
taxonomy: PropTypes.shape({
name: PropTypes.string,
description: PropTypes.string,
}).isRequired,
};
describe('<TaxonomyDetailSideCard/>', async () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
});
it('should render title and description of the card', () => {
const { getByText } = render(<TaxonomyCardComponent taxonomy={data} />);
expect(getByText(data.name)).toBeInTheDocument();
expect(getByText(data.description)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,23 @@
// @ts-check
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { useQuery } from '@tanstack/react-query';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
const getTaxonomyDetailApiUrl = (taxonomyId) => new URL(
`api/content_tagging/v1/taxonomies/${taxonomyId}/`,
getApiBaseUrl(),
).href;
/**
* @param {number} taxonomyId
* @returns {import('@tanstack/react-query').UseQueryResult<import('./types.mjs').TaxonomyData>}
*/ // eslint-disable-next-line import/prefer-default-export
export const useTaxonomyDetailData = (taxonomyId) => (
useQuery({
queryKey: ['taxonomyDetail', taxonomyId],
queryFn: () => getAuthenticatedHttpClient().get(getTaxonomyDetailApiUrl(taxonomyId))
.then((response) => response.data)
.then(camelCaseObject),
})
);

View File

@@ -0,0 +1,27 @@
import { useQuery } from '@tanstack/react-query';
import {
useTaxonomyDetailData,
} from './api';
const mockHttpClient = {
get: jest.fn(),
};
jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn(),
}));
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(() => mockHttpClient),
}));
describe('useTaxonomyDetailData', () => {
it('should call useQuery with the correct parameters', () => {
useTaxonomyDetailData('1');
expect(useQuery).toHaveBeenCalledWith({
queryKey: ['taxonomyDetail', '1'],
queryFn: expect.any(Function),
});
});
});

View File

@@ -0,0 +1,36 @@
// @ts-check
import {
useTaxonomyDetailData,
} from './api';
/**
* @param {number} taxonomyId
* @returns {Pick<import('@tanstack/react-query').UseQueryResult, "error" | "isError" | "isFetched" | "isSuccess">}
*/
export const useTaxonomyDetailDataStatus = (taxonomyId) => {
const {
isError,
error,
isFetched,
isSuccess,
} = useTaxonomyDetailData(taxonomyId);
return {
isError,
error,
isFetched,
isSuccess,
};
};
/**
* @param {number} taxonomyId
* @returns {import("./types.mjs").TaxonomyData | undefined}
*/
export const useTaxonomyDetailDataResponse = (taxonomyId) => {
const { isSuccess, data } = useTaxonomyDetailData(taxonomyId);
if (isSuccess) {
return data;
}
return undefined;
};

View File

@@ -0,0 +1,44 @@
import { useQuery } from '@tanstack/react-query';
import {
useTaxonomyDetailDataStatus,
useTaxonomyDetailDataResponse,
} from './apiHooks';
jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn(),
}));
describe('useTaxonomyDetailDataStatus', () => {
it('should return status values', () => {
const status = {
isError: false,
error: undefined,
isFetched: true,
isSuccess: true,
};
useQuery.mockReturnValueOnce(status);
const result = useTaxonomyDetailDataStatus(0);
expect(result).toEqual(status);
});
});
describe('useTaxonomyDetailDataResponse', () => {
it('should return data when status is success', () => {
useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
const result = useTaxonomyDetailDataResponse();
expect(result).toEqual('data');
});
it('should return undefined when status is not success', () => {
useQuery.mockReturnValueOnce({ isSuccess: false });
const result = useTaxonomyDetailDataResponse();
expect(result).toBeUndefined();
});
});

View File

@@ -0,0 +1,19 @@
// @ts-check
/**
* @typedef {Object} TaxonomyData
* @property {number} id
* @property {string} name
* @property {boolean} enabled
* @property {boolean} allowMultiple
* @property {boolean} allowFreeText
* @property {boolean} systemDefined
* @property {boolean} visibleToAuthors
* @property {string[]} orgs
*/
/**
* @typedef {Object} UseQueryResult
* @property {Object} data
* @property {string} status
*/

View File

@@ -0,0 +1,2 @@
// ts-check
export { default as TaxonomyDetailPage } from './TaxonomyDetailPage'; // eslint-disable-line import/prefer-default-export

View File

@@ -0,0 +1,31 @@
// ts-check
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
taxonomyDetailsHeader: {
id: 'course-authoring.taxonomy-detail.side-card.header',
defaultMessage: 'Taxonomy details',
},
taxonomyDetailsName: {
id: 'course-authoring.taxonomy-detail.side-card.name',
defaultMessage: 'Title',
},
taxonomyDetailsDescription: {
id: 'course-authoring.taxonomy-detail.side-card.description',
defaultMessage: 'Description',
},
actionsButtonLabel: {
id: 'course-authoring.taxonomy-detail.action.button.label',
defaultMessage: 'Actions',
},
actionsButtonAlt: {
id: 'course-authoring.taxonomy-detail.action.button.alt',
defaultMessage: '{name} actions',
},
exportMenu: {
id: 'course-authoring.taxonomy-detail.action.export',
defaultMessage: 'Export',
},
});
export default messages;