diff --git a/src/generic/CompetencyIcon.tsx b/src/generic/CompetencyIcon.tsx new file mode 100644 index 0000000000..7098518ed9 --- /dev/null +++ b/src/generic/CompetencyIcon.tsx @@ -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> = (props) => ( + + + + + +); + +export default CompetencyIcon; diff --git a/src/taxonomy/TaxonomyListPage.test.tsx b/src/taxonomy/TaxonomyListPage.test.tsx index 9ea7a8dd58..225c1400b9 100644 --- a/src/taxonomy/TaxonomyListPage.test.tsx +++ b/src/taxonomy/TaxonomyListPage.test.tsx @@ -68,6 +68,23 @@ describe('', () => { 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(); + 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(); diff --git a/src/taxonomy/TaxonomyListPage.tsx b/src/taxonomy/TaxonomyListPage.tsx index a5982cc2c3..fbd8e24151 100644 --- a/src/taxonomy/TaxonomyListPage.tsx +++ b/src/taxonomy/TaxonomyListPage.tsx @@ -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(); diff --git a/src/taxonomy/__mocks__/taxonomyListMock.js b/src/taxonomy/__mocks__/taxonomyListMock.js index 78dc39ef0d..3829b299eb 100644 --- a/src/taxonomy/__mocks__/taxonomyListMock.js +++ b/src/taxonomy/__mocks__/taxonomyListMock.js @@ -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, @@ -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, @@ -35,6 +37,7 @@ module.exports = { id: 1, name: 'Taxonomy', description: 'This is a Description', + taxonomyType: 'competency', enabled: true, allowMultiple: false, allowFreeText: false, @@ -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, diff --git a/src/taxonomy/data/apiHooks.test.jsx b/src/taxonomy/data/apiHooks.test.jsx index f80d2c9025..d655902ee4 100644 --- a/src/taxonomy/data/apiHooks.test.jsx +++ b/src/taxonomy/data/apiHooks.test.jsx @@ -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 { @@ -17,6 +14,7 @@ import { useImportTags, useImportNewTaxonomy, } from './apiHooks'; +import { TaxonomyType } from './constants'; let axiosMock; @@ -30,49 +28,57 @@ const queryClient = new QueryClient({ const wrapper = ({ children }) => ( - - {children} - + {children} ); 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' }; diff --git a/src/taxonomy/data/apiHooks.ts b/src/taxonomy/data/apiHooks.ts index fd40a10097..4483ca9ab5 100644 --- a/src/taxonomy/data/apiHooks.ts +++ b/src/taxonomy/data/apiHooks.ts @@ -16,6 +16,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { apiUrls, ALL_TAXONOMIES, getApiErrorMessage } from './api'; import * as api from './api'; import type { QueryOptions, TagListData } from './types'; +import { TaxonomyType } from './constants'; // Query key patterns. Allows an easy way to clear all data related to a given taxonomy. // https://github.com/openedx/frontend-app-admin-portal/blob/2ba315d/docs/decisions/0006-tanstack-react-query.rst @@ -69,7 +70,12 @@ export const taxonomyQueryKeys = { * @param taxonomyId ID of the taxonomy * @param fileId Some string to uniquely identify the file we want to upload */ - importPlan: (taxonomyId: number, fileId: string) => [...taxonomyQueryKeys.all, 'importPlan', taxonomyId, fileId], + importPlan: (taxonomyId: number | undefined, fileId: string) => [ + ...taxonomyQueryKeys.all, + 'importPlan', + taxonomyId ?? '', + fileId, + ], } satisfies Record (string | number)[])>; /** @@ -116,11 +122,13 @@ export const useImportNewTaxonomy = () => { mutationFn: async ({ name, description, + taxonomyType, file, - }: { name: string; description: string; file: File; }) => { + }: { name: string; description: string; taxonomyType: TaxonomyType; file: File; }) => { const formData = new FormData(); formData.append('taxonomy_name', name); formData.append('taxonomy_description', description); + formData.append('taxonomy_type', taxonomyType); formData.append('file', file); const { data } = await getAuthenticatedHttpClient().post(apiUrls.createTaxonomyFromImport(), formData); @@ -165,10 +173,11 @@ export const useImportTags = () => { /** * Preview the results of importing the given file into an existing taxonomy. - * @param taxonomyId The ID of the taxonomy whose tags we're updating. + * @param taxonomyId The ID of the taxonomy whose tags we're updating, or undefined if a new taxonomy is + * being created (in which case there is no plan to preview). * @param file The file that we want to import */ -export const useImportPlan = (taxonomyId: number, file: File | null) => +export const useImportPlan = (taxonomyId: number | undefined, file: File | null) => useQuery({ queryKey: taxonomyQueryKeys.importPlan(taxonomyId, file ? `${file.name}${file.lastModified}${file.size}` : ''), queryFn: async (): Promise => { diff --git a/src/taxonomy/data/constants.ts b/src/taxonomy/data/constants.ts index dc205768a2..0ce4e8efbd 100644 --- a/src/taxonomy/data/constants.ts +++ b/src/taxonomy/data/constants.ts @@ -6,3 +6,16 @@ * will be incomplete because the backend only supports a taxonomy size of 10,000 items or fewer. */ export const MAX_TAXONOMY_ITEMS = 10000; + +export enum TaxonomyType { + /** + * A taxonomy whose tags are only labels for content. They say what a piece of content is about, + * and carry no rules about demonstrating mastery of what they describe. + */ + Tags = 'tags', + /** + * A taxonomy of skills. Beyond labelling content, choosing this type enables the Competency + * Management page, where the rules used to demonstrate mastery of those skills are configured. + */ + Competency = 'competency', +} diff --git a/src/taxonomy/data/types.ts b/src/taxonomy/data/types.ts index 5a91cfdbf8..e4d653cefd 100644 --- a/src/taxonomy/data/types.ts +++ b/src/taxonomy/data/types.ts @@ -1,9 +1,12 @@ +import type { TaxonomyType } from './constants'; + /** Metadata about a taxonomy */ export interface TaxonomyData { id: number; name: string; description: string; exportId: string; + taxonomyType: TaxonomyType; enabled: boolean; allowMultiple: boolean; allowFreeText: boolean; diff --git a/src/taxonomy/import-tags/ImportTagsWizard.jsx b/src/taxonomy/import-tags/ImportTagsWizard.jsx deleted file mode 100644 index 3fedf6c0c9..0000000000 --- a/src/taxonomy/import-tags/ImportTagsWizard.jsx +++ /dev/null @@ -1,564 +0,0 @@ -// @ts-check -import React, { useState, useContext, useMemo } from 'react'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { - useToggle, - Button, - Container, - Dropzone, - Icon, - IconButton, - ModalDialog, - Stack, - Stepper, - Form, -} from '@openedx/paragon'; -import { - DeleteOutline, - Download, - InsertDriveFile, - Warning, -} from '@openedx/paragon/icons'; -import PropTypes from 'prop-types'; - -import LoadingButton from '../../generic/loading-button'; -import { LoadingSpinner } from '../../generic/Loading'; -import { getFileSizeToClosestByte } from '../../utils'; -import { TaxonomyContext } from '../common/context'; -import { getTaxonomyExportFile, apiUrls } from '../data/api'; -import { useImportTags, useImportPlan, useImportNewTaxonomy } from '../data/apiHooks'; -import messages from './messages'; - -const linebreak = ( - <> -

- -); - -const TaxonomyProp = PropTypes.shape({ - id: PropTypes.number.isRequired, - name: PropTypes.string.isRequired, -}); - -const ExportStep = ({ taxonomy }) => { - const intl = useIntl(); - - return ( - - -

{intl.formatMessage(messages.importWizardStepExportBody, { br: linebreak })}

- - - - -
-
- ); -}; - -ExportStep.propTypes = { - taxonomy: TaxonomyProp.isRequired, -}; - -const UploadStep = ({ - file, - setFile, - importPlanError, - reimport, -}) => { - const intl = useIntl(); - - const csvTemplateUrl = ( - {intl.formatMessage(messages.csvTemplateTitle)} - ); - - const jsonTemplateUrl = ( - {intl.formatMessage(messages.jsonTemplateTitle)} - ); - - /** @type {(args: {fileData: FormData}) => void} */ - const handleFileLoad = ({ fileData }) => { - setFile(fileData.get('file')); - }; - - const clearFile = (e) => { - e.stopPropagation(); - setFile(null); - }; - - return ( - - -

- {reimport - ? intl.formatMessage(messages.importWizardStepReuploadBody, { br: linebreak }) - : intl.formatMessage( - messages.importWizardStepUploadBody, - { csvTemplateUrl, jsonTemplateUrl, br: linebreak }, - )} -

-
- {!file ? - ( - - ) : - ( - - - -
{file.name}
-
{getFileSizeToClosestByte(file.size)}
-
- -
- )} -
- - {importPlanError && {importPlanError}} -
-
- ); -}; - -UploadStep.propTypes = { - file: PropTypes.shape({ - name: PropTypes.string.isRequired, - size: PropTypes.number.isRequired, - }), - setFile: PropTypes.func.isRequired, - importPlanError: PropTypes.string, - reimport: PropTypes.bool, -}; - -UploadStep.defaultProps = { - file: null, - importPlanError: null, - reimport: false, -}; - -const PopulateStep = ({ - taxonomyPopulateData, - setTaxonomyPopulateData, -}) => { - const intl = useIntl(); - - const handleNameChange = (e) => { - const updatedState = { ...taxonomyPopulateData }; - updatedState.taxonomyName = e.target.value; - setTaxonomyPopulateData(updatedState); - }; - - const handleDescChange = (e) => { - const updatedState = { ...taxonomyPopulateData }; - updatedState.taxonomyDesc = e.target.value; - setTaxonomyPopulateData(updatedState); - }; - - return ( - - - - {intl.formatMessage(messages.importWizardStepPopulateTaxonomyName)} - - - - {intl.formatMessage(messages.importWizardStepPopulateTaxonomyDesc)} - - - - - ); -}; - -PopulateStep.propTypes = { - taxonomyPopulateData: PropTypes.shape({ - taxonomyName: PropTypes.string.isRequired, - taxonomyDesc: PropTypes.string.isRequired, - }).isRequired, - setTaxonomyPopulateData: PropTypes.func.isRequired, -}; - -const PlanStep = ({ importPlan }) => { - const intl = useIntl(); - - return ( - - - {intl.formatMessage(messages.importWizardStepPlanBody, { br: linebreak, changeCount: importPlan?.length })} -
    - {importPlan?.length ? - ( - importPlan.map((line) =>
  • {line}
  • ) - ) : -
  • {intl.formatMessage(messages.importWizardStepPlanNoChanges)}
  • } -
-
-
- ); -}; - -PlanStep.propTypes = { - importPlan: PropTypes.arrayOf(PropTypes.string), -}; - -PlanStep.defaultProps = { - importPlan: null, -}; - -const ConfirmStep = ({ importPlan }) => { - const intl = useIntl(); - - return ( - - - {intl.formatMessage( - messages.importWizardStepConfirmBody, - { br: linebreak, changeCount: importPlan?.length }, - )} - - - ); -}; - -ConfirmStep.propTypes = { - importPlan: PropTypes.arrayOf(PropTypes.string), -}; - -ConfirmStep.defaultProps = { - importPlan: null, -}; - -const DefaultModalHeader = ({ children }) => ( - - {children} - -); - -DefaultModalHeader.propTypes = { - children: PropTypes.string.isRequired, -}; - -const ImportTagsWizard = ({ - taxonomy, - isOpen, - onClose, - reimport, -}) => { - const intl = useIntl(); - const { setToastMessage, setAlertError } = useContext(TaxonomyContext); - - const [currentStep, setCurrentStep] = useState(reimport ? 'export' : 'upload'); - - const [file, setFile] = useState(/** @type {null|File} */ (null)); - - const [isDialogDisabled, disableDialog, enableDialog] = useToggle(false); - - const [taxonomyPopulateData, setTaxonomyPopulateData] = useState({ - taxonomyName: '', - taxonomyDesc: '', - }); - - const importNewTaxonomyMutation = useImportNewTaxonomy(); - - const importNewTaxonomy = async () => { - disableDialog(); - try { - const { taxonomyName, taxonomyDesc } = taxonomyPopulateData; - if (file) { - await importNewTaxonomyMutation.mutateAsync({ - name: taxonomyName, - description: taxonomyDesc, - file, - }); - } - if (setToastMessage) { - setToastMessage(intl.formatMessage(messages.importNewTaxonomyToast, { name: taxonomyName })); - } - } catch (/** @type {unknown} */ error) { - if (setAlertError) { - setAlertError({ - title: intl.formatMessage(messages.importTaxonomyErrorAlert), - error, - }); - } - } finally { - enableDialog(); - onClose(); - } - }; - - const importPlanResult = useImportPlan(taxonomy?.id, file); - - const importPlan = useMemo(() => { - if (!importPlanResult.data) { - return null; - } - let planArrayTemp = importPlanResult.data.split('\n'); - planArrayTemp = planArrayTemp.slice(2); // Removes the first two lines - planArrayTemp = planArrayTemp.slice(0, -1); // Removes the last line - const planArray = planArrayTemp - .filter((line) => !(line.includes('No changes'))) // Removes the "No changes" lines - .map((line) => line.split(':')[1].trim()); // Get only the action message - return /** @type {string[]} */ (planArray); - }, [importPlanResult.data]); - - const importTagsMutation = useImportTags(); - - const generatePlan = React.useCallback(() => { - setCurrentStep('plan'); - }, []); - - const populateData = React.useCallback(() => { - setCurrentStep('populate'); - }, []); - - const confirmImportTags = async () => { - disableDialog(); - try { - if (file) { - await importTagsMutation.mutateAsync({ - taxonomyId: taxonomy.id, - file, - }); - } - if (setToastMessage) { - setToastMessage(intl.formatMessage(messages.importTaxonomyToast, { name: taxonomy?.name })); - } - } catch (/** @type {unknown} */ error) { - if (setAlertError) { - setAlertError({ - title: intl.formatMessage(messages.importTaxonomyErrorAlert), - error, - }); - } - } finally { - enableDialog(); - onClose(); - } - }; - - const stepHeaders = { - export: ( - - {intl.formatMessage(messages.importWizardStepExportTitle, { name: taxonomy?.name })} - - ), - upload: ( - - {intl.formatMessage(messages.importWizardStepUploadTitle)} - - ), - populate: ( - - {intl.formatMessage(messages.importWizardStepPopulateTitle)} - - ), - plan: ( - - {intl.formatMessage(messages.importWizardStepPlanTitle)} - - ), - confirm: ( - - - - - {intl.formatMessage(messages.importWizardStepConfirmTitle, { changeCount: importPlan?.length })} - - - - ), - }; - - return ( - e.stopPropagation() /* This prevents calling onClick handler from the parent */} - > - - {isDialogDisabled && ( - // This div is used to prevent the user from interacting with the dialog while the import is happening -
- )} - - {stepHeaders[currentStep]} - -
- - - - {reimport && } - - - - - - -
- - - - - - - - - {reimport - && ( - - )} - - - {importPlanResult.isLoading ? - - : ( - - )} - - - - - - - - - - - - - - - - - - {reimport - && ( - - )} - - - - - -
- - - ); -}; - -ImportTagsWizard.defaultProps = { - taxonomy: null, - reimport: false, -}; - -ImportTagsWizard.propTypes = { - taxonomy: TaxonomyProp, - isOpen: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, - reimport: PropTypes.bool, -}; - -export { ImportTagsWizard }; diff --git a/src/taxonomy/import-tags/ImportTagsWizard.test.jsx b/src/taxonomy/import-tags/ImportTagsWizard.test.tsx similarity index 58% rename from src/taxonomy/import-tags/ImportTagsWizard.test.jsx rename to src/taxonomy/import-tags/ImportTagsWizard.test.tsx index 613d953c03..6d03c11354 100644 --- a/src/taxonomy/import-tags/ImportTagsWizard.test.jsx +++ b/src/taxonomy/import-tags/ImportTagsWizard.test.tsx @@ -1,94 +1,109 @@ -import MockAdapter from 'axios-mock-adapter'; -import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { initializeMockApp } from '@edx/frontend-platform'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { AppProvider } from '@edx/frontend-platform/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type MockAdapter from 'axios-mock-adapter'; + import { act, fireEvent, + initializeMocks, render, - waitFor, screen, -} from '@testing-library/react'; -import PropTypes from 'prop-types'; - -import initializeStore from '../../store'; -import { getTaxonomyExportFile } from '../data/api'; -import { TaxonomyContext } from '../common/context'; + userEvent, + waitFor, + within, +} from '@src/testUtils'; +import { getTaxonomyExportFile } from '@src/taxonomy/data/api'; +import { TaxonomyContext } from '@src/taxonomy/common/context'; +import type { TaxonomyContextData } from '@src/taxonomy/common/context'; +import { TaxonomyType } from '@src/taxonomy/data/constants'; import { ImportTagsWizard } from './ImportTagsWizard'; +import type { ImportTaxonomy } from './types'; -let store; +let axiosMock: MockAdapter; -const queryClient = new QueryClient(); -let axiosMock; - -jest.mock('../data/api', () => ({ - ...jest.requireActual('../data/api'), +jest.mock('@src/taxonomy/data/api', () => ({ + ...jest.requireActual('@src/taxonomy/data/api'), getTaxonomyExportFile: jest.fn(), })); const mockSetToastMessage = jest.fn(); const mockSetAlertError = jest.fn(); -const context = { +const context: TaxonomyContextData = { toastMessage: null, setToastMessage: mockSetToastMessage, - alertProps: null, + alertError: null, setAlertError: mockSetAlertError, }; +const TaxonomyContextProvider = ({ children }: { children: React.ReactNode; }) => ( + {children} +); + const planImportUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/1/tags/import/plan/'; const doImportUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/1/tags/import/'; const doImportNewTaxonomyUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/import/'; -const sampleTaxonomy = { +const sampleTaxonomy: ImportTaxonomy = { id: 1, name: 'Test Taxonomy', }; -const RootWrapper = ({ onClose, reimport, taxonomy }) => ( - - - - - - - - - -); +interface RenderWizardProps { + onClose: () => void; + reimport?: boolean; + taxonomy?: ImportTaxonomy | null; +} + +const renderWizard = ({ onClose, reimport, taxonomy }: RenderWizardProps) => + render( + , + { extraWrapper: TaxonomyContextProvider }, + ); + +const makeJson = (filename: string) => new File(['{}'], filename, { type: 'application/json' }); + +/** + * Drop a valid file onto the upload step of the "create a new taxonomy" flow, then continue to the + * populate step. + */ +const goToPopulateStep = async () => { + fireEvent.drop(screen.getByTestId('dropzone'), { + dataTransfer: { files: [makeJson('example1.json')], types: ['Files'] }, + }); + expect(await screen.findByTestId('file-info')).toBeInTheDocument(); + + const continueButton = await screen.findByRole('button', { name: 'Continue' }); + await waitFor(() => { + expect(continueButton).not.toHaveAttribute('aria-disabled', 'true'); + }); + fireEvent.click(continueButton); -RootWrapper.propTypes = { - onClose: PropTypes.func.isRequired, - reimport: PropTypes.bool.isRequired, - taxonomy: PropTypes.shape({ - id: PropTypes.number.isRequired, - name: PropTypes.string.isRequired, - }).isRequired, + expect(await screen.findByTestId('populate-step')).toBeInTheDocument(); }; -describe('', () => { - beforeEach(() => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - store = initializeStore(); - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); +/** Fill in the name and description of the populate step, which the Import button requires. */ +const fillInRequiredFields = (name: string) => { + fireEvent.change(screen.getByLabelText('Taxonomy Name'), { target: { value: name } }); + fireEvent.change(screen.getByLabelText('Taxonomy Description'), { target: { value: `${name} Description` } }); +}; + +/** Click Import on the populate step, once enabled, and wait for the request to be made. */ +const clickImport = async () => { + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Import' })).not.toHaveAttribute('aria-disabled', 'true'); + }); + + act(() => { + fireEvent.click(screen.getByRole('button', { name: 'Import' })); }); +}; - afterEach(() => { - jest.clearAllMocks(); - queryClient.clear(); +describe('', () => { + beforeEach(() => { + ({ axiosMock } = initializeMocks()); }); it('render the dialog in the reimport first step can close on cancel', async () => { const onClose = jest.fn(); - const { findByTestId, getByTestId } = render(); + const { findByTestId, getByTestId } = renderWizard({ taxonomy: sampleTaxonomy, onClose, reimport: true }); expect(await findByTestId('export-step')).toBeInTheDocument(); @@ -99,7 +114,7 @@ describe('', () => { it('can export taxonomies from the reimport dialog', async () => { const onClose = jest.fn(); - const { findByTestId, getByTestId } = render(); + const { findByTestId, getByTestId } = renderWizard({ taxonomy: sampleTaxonomy, onClose, reimport: true }); expect(await findByTestId('export-step')).toBeInTheDocument(); @@ -121,7 +136,7 @@ describe('', () => { getAllByTestId, getByTestId, getByText, - } = render(); + } = renderWizard({ taxonomy: sampleTaxonomy, onClose, reimport: true }); expect(await findByTestId('export-step')).toBeInTheDocument(); @@ -145,8 +160,6 @@ describe('', () => { expect(getByTestId('dropzone')).toBeInTheDocument(); expect(importButton).toHaveAttribute('aria-disabled', 'true'); - const makeJson = (filename) => new File(['{}'], filename, { type: 'application/json' }); - // Correct file type axiosMock.onPut(planImportUrl).replyOnce(200, { plan: 'Import plan' }); fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [makeJson('example1.json')], types: ['Files'] } }); @@ -259,7 +272,7 @@ describe('', () => { getByTestId, getByText, queryByTestId, - } = render(); + } = renderWizard({ taxonomy: null, onClose }); // Check that there is no export step expect(queryByTestId('export-step')).not.toBeInTheDocument(); @@ -270,8 +283,7 @@ describe('', () => { expect(getByTestId('upload-step')).toBeInTheDocument(); // Continue flow - await waitFor(() => expect(screen.getByRole('button', { name: 'Continue' })).toBeInTheDocument()); - let continueButton = getByRole('button', { name: 'Continue' }); + let continueButton = await screen.findByRole('button', { name: 'Continue' }); expect(continueButton).toHaveAttribute('aria-disabled', 'true'); // Invalid file type @@ -280,8 +292,6 @@ describe('', () => { expect(getByTestId('dropzone')).toBeInTheDocument(); expect(continueButton).toHaveAttribute('aria-disabled', 'true'); - const makeJson = (filename) => new File(['{}'], filename, { type: 'application/json' }); - // Correct file type fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [makeJson('example1.json')], types: ['Files'] } }); expect(await findByTestId('file-info')).toBeInTheDocument(); @@ -297,8 +307,7 @@ describe('', () => { expect(getByText('example1.json')).toBeInTheDocument(); // Click continue once button enabled - await waitFor(() => expect(screen.getByRole('button', { name: 'Continue' })).toBeInTheDocument()); - continueButton = getByRole('button', { name: 'Continue' }); + continueButton = await screen.findByRole('button', { name: 'Continue' }); await waitFor(() => { expect(continueButton).not.toHaveAttribute('aria-disabled', 'true'); }); @@ -313,26 +322,11 @@ describe('', () => { expect(await findByTestId('populate-step')).toBeInTheDocument(); // Check import button is disabled when fields not populated - const importButton = getByRole('button', { name: 'Import' }); - expect(importButton).toHaveAttribute('aria-disabled', 'true'); + expect(getByRole('button', { name: 'Import' })).toHaveAttribute('aria-disabled', 'true'); - // Populate new taxonomy information + // Populate new taxonomy information, leaving the taxonomy type at its default. const newTaxonomyName = 'New Taxonomy'; - const taxonomyNameInputEl = screen.getByLabelText('Taxonomy Name'); - fireEvent.change(taxonomyNameInputEl, { - target: { value: newTaxonomyName }, - }); - const taxonomyDescInputEl = screen.getByLabelText('Taxonomy Description'); - fireEvent.change(taxonomyDescInputEl, { - target: { value: 'New Taxonomy Description' }, - }); - - // Test back button - fireEvent.click(getByTestId('back-button')); - expect(getByTestId('upload-step')).toBeInTheDocument(); - fireEvent.click(getByRole('button', { name: 'Continue' })); - - expect(getByTestId('populate-step')).toBeInTheDocument(); + fillInRequiredFields(newTaxonomyName); if (expectedResult === 'success') { axiosMock.onPost(doImportNewTaxonomyUrl).replyOnce(200, {}); @@ -340,19 +334,15 @@ describe('', () => { axiosMock.onPost(doImportNewTaxonomyUrl).replyOnce(400); } - await waitFor(() => { - expect(getByRole('button', { name: 'Import' })).not.toHaveAttribute('aria-disabled', 'true'); - }); - - act(() => { - fireEvent.click(getByRole('button', { name: 'Import' })); - }); + await clickImport(); if (expectedResult === 'success') { // Toast message shown await waitFor(() => { expect(mockSetToastMessage).toHaveBeenCalledWith(`"${newTaxonomyName}" imported`); }); + // The default taxonomy type is submitted when the user never touches the dropdown. + expect(axiosMock.history.post[0].data.get('taxonomy_type')).toEqual(TaxonomyType.Tags); } else { // Alert message shown await waitFor(() => { @@ -365,4 +355,82 @@ describe('', () => { }); } }); + + describe('taxonomy type dropdown', () => { + it('defaults to Tags and offers every taxonomy type', async () => { + renderWizard({ taxonomy: null, onClose: jest.fn() }); + await goToPopulateStep(); + + // The dropdown is a native ``, so + // the browser (not the page) draws and controls the option list, and jsdom implements none of + // that -- arrow keys, Enter and type-ahead all leave `select.value` untouched under jsdom. + // What the component owns, and what is asserted above and in the tests before this one, is + // that the control is focusable, labelled, and carries every option with the right value. + await user.selectOptions(select, TaxonomyType.Competency); + expect(select).toHaveValue(TaxonomyType.Competency); + expect(select).toHaveFocus(); + }); + }); }); diff --git a/src/taxonomy/import-tags/ImportTagsWizard.tsx b/src/taxonomy/import-tags/ImportTagsWizard.tsx new file mode 100644 index 0000000000..5ba668c873 --- /dev/null +++ b/src/taxonomy/import-tags/ImportTagsWizard.tsx @@ -0,0 +1,308 @@ +import React, { useContext, useMemo, useState } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + useToggle, + Button, + Container, + Icon, + ModalDialog, + Stack, + Stepper, +} from '@openedx/paragon'; +import { Warning } from '@openedx/paragon/icons'; + +import LoadingButton from '@src/generic/loading-button'; +import { LoadingSpinner } from '@src/generic/Loading'; +import { TaxonomyContext } from '@src/taxonomy/common/context'; +import { TaxonomyType } from '@src/taxonomy/data/constants'; +import { useImportNewTaxonomy, useImportPlan, useImportTags } from '@src/taxonomy/data/apiHooks'; +import { ConfirmStep } from './steps/ConfirmStep'; +import { ExportStep } from './steps/ExportStep'; +import { PlanStep } from './steps/PlanStep'; +import { PopulateStep } from './steps/PopulateStep'; +import { UploadStep } from './steps/UploadStep'; +import messages from './messages'; +import type { ImportTaxonomy, ImportWizardStep, TaxonomyPopulateData } from './types'; + +/** + * The header shown at the top of most of the wizard's steps. + */ +export const DefaultModalHeader = ({ children }: { children: string; }) => ( + + {children} + +); + +interface ImportTagsWizardProps { + /** The taxonomy to import tags into. Only used (and required) when `reimport` is true. */ + taxonomy?: ImportTaxonomy | null; + isOpen: boolean; + onClose: () => void; + /** True to import tags into `taxonomy`; false to create a new taxonomy from the uploaded file. */ + reimport?: boolean; +} + +/** + * A wizard that imports a taxonomy from a file, either creating a new taxonomy or replacing the + * tags of an existing one. + */ +export const ImportTagsWizard = ({ + taxonomy = null, + isOpen, + onClose, + reimport = false, +}: ImportTagsWizardProps) => { + const intl = useIntl(); + const { setToastMessage, setAlertError } = useContext(TaxonomyContext); + + const [currentStep, setCurrentStep] = useState(reimport ? 'export' : 'upload'); + + const [file, setFile] = useState(null); + + const [isDialogDisabled, disableDialog, enableDialog] = useToggle(false); + + const [taxonomyPopulateData, setTaxonomyPopulateData] = useState({ + taxonomyName: '', + taxonomyDesc: '', + taxonomyType: TaxonomyType.Tags, + }); + + const importNewTaxonomyMutation = useImportNewTaxonomy(); + + const importNewTaxonomy = async () => { + disableDialog(); + try { + const { taxonomyName, taxonomyDesc, taxonomyType } = taxonomyPopulateData; + if (file) { + await importNewTaxonomyMutation.mutateAsync({ + name: taxonomyName, + description: taxonomyDesc, + taxonomyType, + file, + }); + } + if (setToastMessage) { + setToastMessage(intl.formatMessage(messages.importNewTaxonomyToast, { name: taxonomyName })); + } + } catch (error) { + if (setAlertError) { + setAlertError({ + title: intl.formatMessage(messages.importTaxonomyErrorAlert), + error, + }); + } + } finally { + enableDialog(); + onClose(); + } + }; + + const importPlanResult = useImportPlan(taxonomy?.id, file); + + const importPlan = useMemo(() => { + if (!importPlanResult.data) { + return null; + } + let planArrayTemp = importPlanResult.data.split('\n'); + planArrayTemp = planArrayTemp.slice(2); // Removes the first two lines + planArrayTemp = planArrayTemp.slice(0, -1); // Removes the last line + return planArrayTemp + .filter((line) => !(line.includes('No changes'))) // Removes the "No changes" lines + .map((line) => line.split(':')[1].trim()); // Get only the action message + }, [importPlanResult.data]); + + const importTagsMutation = useImportTags(); + + const generatePlan = React.useCallback(() => { + setCurrentStep('plan'); + }, []); + + const populateData = React.useCallback(() => { + setCurrentStep('populate'); + }, []); + + const confirmImportTags = async () => { + disableDialog(); + try { + if (file && taxonomy) { + await importTagsMutation.mutateAsync({ + taxonomyId: taxonomy.id, + file, + }); + } + if (setToastMessage) { + setToastMessage(intl.formatMessage(messages.importTaxonomyToast, { name: taxonomy?.name })); + } + } catch (error) { + if (setAlertError) { + setAlertError({ + title: intl.formatMessage(messages.importTaxonomyErrorAlert), + error, + }); + } + } finally { + enableDialog(); + onClose(); + } + }; + + const stepHeaders: Record = { + export: ( + + {intl.formatMessage(messages.importWizardStepExportTitle, { name: taxonomy?.name })} + + ), + upload: ( + + {intl.formatMessage(messages.importWizardStepUploadTitle)} + + ), + populate: ( + + {intl.formatMessage(messages.importWizardStepPopulateTitle)} + + ), + plan: ( + + {intl.formatMessage(messages.importWizardStepPlanTitle)} + + ), + confirm: ( + + + + + {intl.formatMessage(messages.importWizardStepConfirmTitle, { changeCount: importPlan?.length })} + + + + ), + }; + + return ( + e.stopPropagation()}> + + {isDialogDisabled && ( + // This div is used to prevent the user from interacting with the dialog while the import is happening +
+ )} + + {stepHeaders[currentStep]} + +
+ + + + {reimport && taxonomy && } + + + + + + +
+ + + + + + + + + {reimport + && ( + + )} + + + {importPlanResult.isLoading ? + + : ( + + )} + + + + + + + + + + + + + + + + + + {reimport + && ( + + )} + + + + + +
+ + + ); +}; diff --git a/src/taxonomy/import-tags/messages.ts b/src/taxonomy/import-tags/messages.ts index f6f0b86726..ab99b48913 100644 --- a/src/taxonomy/import-tags/messages.ts +++ b/src/taxonomy/import-tags/messages.ts @@ -29,13 +29,20 @@ const messages = defineMessages({ id: 'course-authoring.import-tags.wizard.step-export.title', defaultMessage: 'Update "{name}"', }, - importWizardStepExportBody: { - id: 'course-authoring.import-tags.wizard.step-export.body', + importWizardStepExportReplaceWarning: { + id: 'course-authoring.import-tags.wizard.step-export.replace-warning', defaultMessage: 'To update this taxonomy you need to import a new CSV or JSON file. The current taxonomy will ' + 'be completely replaced by the contents of the imported file (e.g. if a tag in the current taxonomy is not ' + 'present in the imported file, it will be removed - both from the taxonomy and from any tagged course ' - + 'content).' - + '{br}You may wish to export the taxonomy in its current state before importing the new file.', + + 'content).', + description: 'Warning on the first step of the re-import wizard, explaining that the imported file replaces the ' + + 'whole taxonomy rather than being merged into it.', + }, + importWizardStepExportBackupSuggestion: { + id: 'course-authoring.import-tags.wizard.step-export.backup-suggestion', + defaultMessage: 'You may wish to export the taxonomy in its current state before importing the new file.', + description: 'Advice on the first step of the re-import wizard, suggesting the user export a backup of the ' + + 'taxonomy before replacing it.', }, importWizardStepExportCSVButton: { id: 'course-authoring.import-tags.wizard.step-export.button-csv', @@ -53,18 +60,26 @@ const messages = defineMessages({ id: 'course-authoring.import-tags.wizard.step-upload.clear-file', defaultMessage: 'Clear file', }, - importWizardStepUploadBody: { - id: 'course-authoring.import-tags.wizard.step-upload.body', + importWizardStepUploadFormatInfo: { + id: 'course-authoring.import-tags.wizard.step-upload.format-info', defaultMessage: 'You can upload a CSV or JSON file to create a new taxonomy. You may use any spreadsheet tool ' + '(for CSV files), or any text editor (for JSON files) to create the file that you wish to import. ' - + 'For an example of the required format, download the {csvTemplateUrl} or {jsonTemplateUrl}.' - + '{br}Once the file is ready to be imported, drag and drop it into the box below, or click to upload.', + + 'For an example of the required format, download the {csvTemplateTitle} or ' + + '{jsonTemplateTitle}.', + description: 'Explains which file formats can be used to create a new taxonomy. The link text comes from the "CSV ' + + 'template" and "JSON template" messages.', }, - importWizardStepReuploadBody: { - id: 'course-authoring.import-tags.wizard.step-reupload.body', + importWizardStepReuploadFormatInfo: { + id: 'course-authoring.import-tags.wizard.step-reupload.format-info', defaultMessage: 'You may use any spreadsheet tool (for CSV files), or any text editor (for JSON files) to create ' - + 'the file that you wish to import.' - + '{br}Once the file is ready to be imported, drag and drop it into the box below, or click to upload.', + + 'the file that you wish to import.', + description: 'Explains which tools can be used to prepare the file when re-importing tags into an existing ' + + 'taxonomy.', + }, + importWizardStepUploadDropInstruction: { + id: 'course-authoring.import-tags.wizard.step-upload.drop-instruction', + defaultMessage: 'Once the file is ready to be imported, drag and drop it into the box below, or click to upload.', + description: 'Instruction above the drag-and-drop area of the upload step.', }, csvTemplateTitle: { id: 'course-authoring.import-tags.wizard.step-upload.csv-template', @@ -86,15 +101,36 @@ const messages = defineMessages({ id: 'course-authoring.import-tags.wizard.step-populate.desc', defaultMessage: 'Taxonomy Description', }, + importWizardStepPopulateTaxonomyType: { + id: 'course-authoring.import-tags.wizard.step-populate.type', + defaultMessage: 'Taxonomy Type', + description: 'Label for the dropdown where the user selects the type of taxonomy being imported.', + }, + importWizardStepPopulateTaxonomyTypeTags: { + id: 'course-authoring.import-tags.wizard.step-populate.type.tags', + defaultMessage: 'Tags', + description: 'Option in the Taxonomy Type dropdown for a standard tag taxonomy.', + }, + importWizardStepPopulateTaxonomyTypeCompetency: { + id: 'course-authoring.import-tags.wizard.step-populate.type.competency', + defaultMessage: 'Competency', + description: 'Option in the Taxonomy Type dropdown for a competency taxonomy.', + }, importWizardStepPlanTitle: { id: 'course-authoring.import-tags.wizard.step-plan.title', defaultMessage: 'Differences between files', }, - importWizardStepPlanBody: { - id: 'course-authoring.import-tags.wizard.step-plan.body', + importWizardStepPlanSummary: { + id: 'course-authoring.import-tags.wizard.step-plan.summary', defaultMessage: 'Importing this file will make {changeCount} updates to the existing taxonomy. ' - + 'The content of the imported file will replace any existing values that do not match the new values.' - + '{br}Importing this file will cause the following updates:', + + 'The content of the imported file will replace any existing values that do not match the new values.', + description: 'Summary at the top of the wizard step that previews an import. {changeCount} is the number of ' + + 'changes the import will make.', + }, + importWizardStepPlanListLabel: { + id: 'course-authoring.import-tags.wizard.step-plan.list-label', + defaultMessage: 'Importing this file will cause the following updates:', + description: 'Introduces the list of individual changes an import will make, shown on the preview step.', }, importWizardStepPlanNoChanges: { id: 'course-authoring.import-tags.wizard.step-plan.no-changes', @@ -104,11 +140,17 @@ const messages = defineMessages({ id: 'course-authoring.import-tags.wizard.step-confirm.title', defaultMessage: 'Import and replace tags', }, - importWizardStepConfirmBody: { - id: 'course-authoring.import-tags.wizard.step-confirm.body', + importWizardStepConfirmWarning: { + id: 'course-authoring.import-tags.wizard.step-confirm.warning', defaultMessage: 'Warning! You are about to make {changeCount} changes to the existing taxonomy. Any tags applied ' - + 'to course content will be updated or removed. This cannot be undone.' - + '{br}Are you sure you want to continue importing this file?', + + 'to course content will be updated or removed. This cannot be undone.', + description: 'Warning on the final confirmation step, shown before the import is applied. {changeCount} is the ' + + 'number of changes the import will make.', + }, + importWizardStepConfirmQuestion: { + id: 'course-authoring.import-tags.wizard.step-confirm.question', + defaultMessage: 'Are you sure you want to continue importing this file?', + description: 'Question on the final confirmation step, asking the user to confirm the import.', }, promptTaxonomyName: { id: 'course-authoring.import-tags.prompt.taxonomy-name', diff --git a/src/taxonomy/import-tags/steps/ConfirmStep.tsx b/src/taxonomy/import-tags/steps/ConfirmStep.tsx new file mode 100644 index 0000000000..1f1fa52589 --- /dev/null +++ b/src/taxonomy/import-tags/steps/ConfirmStep.tsx @@ -0,0 +1,35 @@ +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { + Stack, + Stepper, +} from '@openedx/paragon'; + +import messages from '../messages'; + +interface ConfirmStepProps { + /** The changes that importing the file will make, one per line. */ + importPlan?: string[] | null; +} + +/** + * Wizard step where the user confirms that the previewed changes should be applied. + */ +export const ConfirmStep = ({ importPlan = null }: ConfirmStepProps) => { + const intl = useIntl(); + + return ( + + +

+ +

+

+ +

+
+
+ ); +}; diff --git a/src/taxonomy/import-tags/steps/ExportStep.tsx b/src/taxonomy/import-tags/steps/ExportStep.tsx new file mode 100644 index 0000000000..bc0fca43bc --- /dev/null +++ b/src/taxonomy/import-tags/steps/ExportStep.tsx @@ -0,0 +1,55 @@ +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { + Button, + Stack, + Stepper, +} from '@openedx/paragon'; +import { Download } from '@openedx/paragon/icons'; + +import { getTaxonomyExportFile } from '@src/taxonomy/data/api'; + +import messages from '../messages'; +import { ImportTaxonomy } from '../types'; + +interface ExportStepProps { + taxonomy: ImportTaxonomy; +} + +/** + * Wizard step that offers to export the existing taxonomy, so that the user can edit the exported + * file and then re-import it. + */ +export const ExportStep = ({ taxonomy }: ExportStepProps) => { + const intl = useIntl(); + + return ( + + +

+ +

+

+ +

+ + + + +
+
+ ); +}; diff --git a/src/taxonomy/import-tags/steps/PlanStep.tsx b/src/taxonomy/import-tags/steps/PlanStep.tsx new file mode 100644 index 0000000000..7f9e785e6b --- /dev/null +++ b/src/taxonomy/import-tags/steps/PlanStep.tsx @@ -0,0 +1,42 @@ +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { + Stack, + Stepper, +} from '@openedx/paragon'; + +import messages from '../messages'; + +interface PlanStepProps { + /** The changes that importing the file will make, one per line. */ + importPlan?: string[] | null; +} + +/** + * Wizard step that previews the changes that importing the file will make. + */ +export const PlanStep = ({ importPlan = null }: PlanStepProps) => { + const intl = useIntl(); + + return ( + + +

+ +

+

+ +

+
    + {importPlan?.length ? + ( + importPlan.map((line) =>
  • {line}
  • ) + ) : +
  • {intl.formatMessage(messages.importWizardStepPlanNoChanges)}
  • } +
+
+
+ ); +}; diff --git a/src/taxonomy/import-tags/steps/PopulateStep.tsx b/src/taxonomy/import-tags/steps/PopulateStep.tsx new file mode 100644 index 0000000000..78a6aeeb92 --- /dev/null +++ b/src/taxonomy/import-tags/steps/PopulateStep.tsx @@ -0,0 +1,72 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Form, + Stack, + Stepper, +} from '@openedx/paragon'; + +import type { TaxonomyType } from '@src/taxonomy/data/constants'; +import messages from '../messages'; +import type { TaxonomyPopulateData } from '../types'; +import { TAXONOMY_TYPE_OPTIONS } from './constants'; + +interface PopulateStepProps { + taxonomyPopulateData: TaxonomyPopulateData; + setTaxonomyPopulateData: (data: TaxonomyPopulateData) => void; +} + +/** + * Wizard step where the user describes the new taxonomy that will be created from the uploaded file. + */ +export const PopulateStep = ({ taxonomyPopulateData, setTaxonomyPopulateData }: PopulateStepProps) => { + const intl = useIntl(); + + const handleNameChange = (e: React.ChangeEvent) => { + setTaxonomyPopulateData({ ...taxonomyPopulateData, taxonomyName: e.target.value }); + }; + + const handleDescChange = (e: React.ChangeEvent) => { + setTaxonomyPopulateData({ ...taxonomyPopulateData, taxonomyDesc: e.target.value }); + }; + + const handleTypeChange = (e: React.ChangeEvent) => { + setTaxonomyPopulateData({ ...taxonomyPopulateData, taxonomyType: e.target.value as TaxonomyType }); + }; + + return ( + + + + {intl.formatMessage(messages.importWizardStepPopulateTaxonomyName)} + + + + {intl.formatMessage(messages.importWizardStepPopulateTaxonomyDesc)} + + + + + {intl.formatMessage(messages.importWizardStepPopulateTaxonomyType)} + + + {TAXONOMY_TYPE_OPTIONS.map(({ value, message }) => ( + + ))} + + + + + ); +}; diff --git a/src/taxonomy/import-tags/steps/UploadStep.tsx b/src/taxonomy/import-tags/steps/UploadStep.tsx new file mode 100644 index 0000000000..af0e2b6025 --- /dev/null +++ b/src/taxonomy/import-tags/steps/UploadStep.tsx @@ -0,0 +1,124 @@ +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { + Container, + Dropzone, + Icon, + IconButton, + Stack, + Stepper, +} from '@openedx/paragon'; +import { + DeleteOutline, + InsertDriveFile, +} from '@openedx/paragon/icons'; + +import { apiUrls } from '@src/taxonomy/data/api'; +import { getFileSizeToClosestByte } from '@src/utils'; +import messages from '../messages'; + +const csvLink = (chunks) => {chunks}; + +const jsonLink = (chunks) => {chunks}; + +interface UploadStepProps { + file: File | null; + setFile: (file: File | null) => void; + /** Error that occurred while previewing the import, if any. */ + importPlanError?: string; + /** True when importing into an existing taxonomy, rather than creating a new one. */ + reimport?: boolean; +} + +/** + * Wizard step where the user selects the file to import. + */ +export const UploadStep = ({ + file, + setFile, + importPlanError, + reimport = false, +}: UploadStepProps) => { + const intl = useIntl(); + + const handleFileLoad = ({ fileData }: { fileData: FormData; }) => { + setFile(fileData.get('file') as File); + }; + + const clearFile = (e: React.MouseEvent) => { + e.stopPropagation(); + setFile(null); + }; + + return ( + + +

+ {reimport + ? + : ( + + )} +

+

+ +

+
+ {!file ? + ( + + ) : + ( + + + +
{file.name}
+
{getFileSizeToClosestByte(file.size)}
+
+ +
+ )} +
+ + {importPlanError && {importPlanError}} +
+
+ ); +}; diff --git a/src/taxonomy/import-tags/steps/constants.ts b/src/taxonomy/import-tags/steps/constants.ts new file mode 100644 index 0000000000..de6ce7d128 --- /dev/null +++ b/src/taxonomy/import-tags/steps/constants.ts @@ -0,0 +1,21 @@ +import { TaxonomyType } from '@src/taxonomy/data/constants'; +import messages from '../messages'; + +/** + * The taxonomy types a user can pick when importing a taxonomy. + * + * A `Tags` taxonomy is just a way to label content: its tags carry no rules for demonstrating + * mastery of what they describe. A `Competency` taxonomy is a taxonomy of skills, and choosing it + * enables the Competency Management page, where the rules used to demonstrate mastery of those + * skills are configured. + */ +export const TAXONOMY_TYPE_OPTIONS = [ + { + value: TaxonomyType.Tags, + message: messages.importWizardStepPopulateTaxonomyTypeTags, + }, + { + value: TaxonomyType.Competency, + message: messages.importWizardStepPopulateTaxonomyTypeCompetency, + }, +]; diff --git a/src/taxonomy/import-tags/types.ts b/src/taxonomy/import-tags/types.ts new file mode 100644 index 0000000000..26e7c0c0c4 --- /dev/null +++ b/src/taxonomy/import-tags/types.ts @@ -0,0 +1,17 @@ +import type { TaxonomyType } from '@src/taxonomy/data/constants'; +import type { TaxonomyData } from '@src/taxonomy/data/types'; + +/** + * The taxonomy that tags are being imported into. + */ +export type ImportTaxonomy = Pick; + +/** The steps of the import wizard, in the order the user goes through them. */ +export type ImportWizardStep = 'export' | 'upload' | 'populate' | 'plan' | 'confirm'; + +/** The details of the new taxonomy that is being created from the uploaded file. */ +export interface TaxonomyPopulateData { + taxonomyName: string; + taxonomyDesc: string; + taxonomyType: TaxonomyType; +} diff --git a/src/taxonomy/taxonomy-card/TaxonomyCard.scss b/src/taxonomy/taxonomy-card/TaxonomyCard.scss index 6ed11c542b..8f479f27c2 100644 --- a/src/taxonomy/taxonomy-card/TaxonomyCard.scss +++ b/src/taxonomy/taxonomy-card/TaxonomyCard.scss @@ -2,6 +2,11 @@ width: 400px; height: 317px; + .taxonomy-card-title-text { + /* Allow the title to shrink (and truncate) next to the taxonomy type icon */ + min-width: 0; + } + .taxonomy-card-body { /* Set overflow to description diff --git a/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx b/src/taxonomy/taxonomy-card/TaxonomyCard.test.tsx similarity index 70% rename from src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx rename to src/taxonomy/taxonomy-card/TaxonomyCard.test.tsx index 756ce5333c..09356221f5 100644 --- a/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx +++ b/src/taxonomy/taxonomy-card/TaxonomyCard.test.tsx @@ -6,12 +6,14 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { fireEvent, render } from '@testing-library/react'; import initializeStore from '../../store'; -import TaxonomyCard from '.'; +import { TaxonomyType } from '../data/constants'; +import { TaxonomyCard } from '.'; +import { TaxonomyCardData } from './TaxonomyCard'; let store; const taxonomyId = 1; -const data = { +const data: TaxonomyCardData = { id: taxonomyId, name: 'Taxonomy 1', description: 'This is a description', @@ -23,7 +25,7 @@ const data = { const queryClient = new QueryClient(); -const TaxonomyCardComponent = ({ original }) => ( +const TaxonomyCardComponent = ({ original }: { original: TaxonomyCardData; }) => ( @@ -35,8 +37,6 @@ const TaxonomyCardComponent = ({ original }) => ( ); -TaxonomyCardComponent.propTypes = TaxonomyCard.propTypes; - describe('', () => { beforeEach(async () => { initializeMockApp({ @@ -111,4 +111,28 @@ describe('', () => { const { getByText } = render(); expect(getByText('Assigned to 6 orgs')).toBeInTheDocument(); }); + + it('shows the competency type icon with competency taxonomies', () => { + const cardData = { ...data, taxonomyType: TaxonomyType.Competency }; + + const { getByTestId, queryByTestId } = render(); + expect(getByTestId('taxonomy-type-icon-competency')).toBeInTheDocument(); + expect(queryByTestId('taxonomy-type-icon-tags')).not.toBeInTheDocument(); + }); + + it('shows the tags type icon with tags taxonomies', () => { + const cardData = { ...data, taxonomyType: TaxonomyType.Tags }; + + const { getByTestId, queryByTestId } = render(); + expect(getByTestId('taxonomy-type-icon-tags')).toBeInTheDocument(); + expect(queryByTestId('taxonomy-type-icon-competency')).not.toBeInTheDocument(); + }); + + it('shows a type icon even when the taxonomy has no type, along with the read-only badge', () => { + const cardData = { ...data, readOnly: true }; + + const { getByText, getByTestId } = render(); + expect(getByTestId('taxonomy-type-icon-tags')).toBeInTheDocument(); + expect(getByText(readOnlyBadgeText)).toBeInTheDocument(); + }); }); diff --git a/src/taxonomy/taxonomy-card/TaxonomyCard.tsx b/src/taxonomy/taxonomy-card/TaxonomyCard.tsx new file mode 100644 index 0000000000..a93a858235 --- /dev/null +++ b/src/taxonomy/taxonomy-card/TaxonomyCard.tsx @@ -0,0 +1,79 @@ +import { Card } from '@openedx/paragon'; +import { NavLink } from 'react-router-dom'; +import classNames from 'classnames'; + +import { TaxonomyMenu } from '../taxonomy-menu'; +import { TaxonomyCardHeaderSubtitle } from './TaxonomyCardHeaderSubtitle'; +import { TaxonomyCardHeaderTitle } from './TaxonomyCardHeaderTitle'; +import { orgsCountEnabled } from './utils'; +import { TaxonomyType } from '../data/constants'; +import { TaxonomyData } from '../data/types'; + +type TaxonomyCardFields = Pick< + TaxonomyData, + 'id' | 'name' | 'description' | 'readOnly' | 'tagsCount' | 'canChangeTaxonomy' | 'canDeleteTaxonomy' +>; + +/** The data of the taxonomy shown on a taxonomy card */ +export interface TaxonomyCardData extends TaxonomyCardFields { + taxonomyType?: TaxonomyType; + orgsCount?: number; +} + +interface TaxonomyCardProps { + className?: string; + original: TaxonomyCardData; +} + +export const TaxonomyCard = ({ className = '', original }: TaxonomyCardProps) => { + const { + id, + name, + description, + readOnly, + orgsCount, + taxonomyType, + } = original; + + return ( + + + } + subtitle={ + + } + actions={ + + } + /> + + + {description} + + + + ); +}; diff --git a/src/taxonomy/taxonomy-card/TaxonomyCardHeaderSubtitle.tsx b/src/taxonomy/taxonomy-card/TaxonomyCardHeaderSubtitle.tsx new file mode 100644 index 0000000000..9bf92c947b --- /dev/null +++ b/src/taxonomy/taxonomy-card/TaxonomyCardHeaderSubtitle.tsx @@ -0,0 +1,34 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; +import { orgsCountEnabled } from './utils'; +import { ReadOnlyBadge } from '../read-only-badge'; + +interface TaxonomyCardHeaderSubtitleProps { + showReadOnlyBadge: boolean; + orgsCount?: number; +} + +/** + * The subtitle of a taxonomy card + */ +export const TaxonomyCardHeaderSubtitle = ({ + showReadOnlyBadge, + orgsCount, +}: TaxonomyCardHeaderSubtitleProps) => { + const intl = useIntl(); + + if (showReadOnlyBadge) { + return ; + } + + if (orgsCountEnabled(orgsCount)) { + return ( +
+ {intl.formatMessage(messages.assignedToOrgsLabel, { orgsCount })} +
+ ); + } + + return null; +}; diff --git a/src/taxonomy/taxonomy-card/TaxonomyCardHeaderTitle.tsx b/src/taxonomy/taxonomy-card/TaxonomyCardHeaderTitle.tsx new file mode 100644 index 0000000000..2152e2a1f5 --- /dev/null +++ b/src/taxonomy/taxonomy-card/TaxonomyCardHeaderTitle.tsx @@ -0,0 +1,61 @@ +import { useEffect, useRef, useState } from 'react'; +import { + OverlayTrigger, + Popover, +} from '@openedx/paragon'; + +import { TaxonomyTypeIcon } from './TaxonomyTypeIcon'; +import { TaxonomyType } from '../data/constants'; + +interface TaxonomyCardHeaderTitleProps { + taxonomyId: number; + title: string; + taxonomyType?: TaxonomyType; +} + +/** + * The title of a taxonomy card: the type icon plus the name of the taxonomy, + * truncated with a tooltip when it doesn't fit. + */ +export const TaxonomyCardHeaderTitle = ({ + title, + taxonomyId, + taxonomyType, +}: TaxonomyCardHeaderTitleProps) => { + const containerRef = useRef(null); + const textRef = useRef(null); + const [isTruncated, setIsTruncated] = useState(false); + + useEffect(() => { + const containerWidth = containerRef.current?.clientWidth ?? 0; + const textWidth = textRef.current?.offsetWidth ?? 0; + setIsTruncated(textWidth > containerWidth); + }, [title]); + + const getToolTip = () => ( + + + {title} + + + ); + + return ( +
+ + +
+ {title} +
+
+
+ ); +}; diff --git a/src/taxonomy/taxonomy-card/TaxonomyTypeIcon.tsx b/src/taxonomy/taxonomy-card/TaxonomyTypeIcon.tsx new file mode 100644 index 0000000000..d7b6c4a26f --- /dev/null +++ b/src/taxonomy/taxonomy-card/TaxonomyTypeIcon.tsx @@ -0,0 +1,36 @@ +import { Icon } from '@openedx/paragon'; +import { Tag } from '@openedx/paragon/icons'; + +import CompetencyIcon from '@src/generic/CompetencyIcon'; +import { TaxonomyType } from '../data/constants'; + +const taxonomyTypeIcons = { + [TaxonomyType.Competency]: CompetencyIcon, + [TaxonomyType.Tags]: Tag, +}; + +interface TaxonomyTypeIconProps { + taxonomyType?: TaxonomyType; + className?: string; +} + +/** + * Icon that tells apart the two types of taxonomy: competency and tags. + * Taxonomies without a known type are shown as tags taxonomies, so that + * every taxonomy gets exactly one icon. + */ +export const TaxonomyTypeIcon = ({ taxonomyType, className }: TaxonomyTypeIconProps) => { + const iconType = taxonomyType && taxonomyType in taxonomyTypeIcons + ? taxonomyType + : TaxonomyType.Tags; + + const src = taxonomyTypeIcons[iconType]; + + return ( + + ); +}; diff --git a/src/taxonomy/taxonomy-card/index.jsx b/src/taxonomy/taxonomy-card/index.jsx deleted file mode 100644 index 53640e160d..0000000000 --- a/src/taxonomy/taxonomy-card/index.jsx +++ /dev/null @@ -1,160 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { - Card, - OverlayTrigger, - Popover, -} from '@openedx/paragon'; -import PropTypes from 'prop-types'; -import { NavLink } from 'react-router-dom'; -import classNames from 'classnames'; -import { useIntl } from '@edx/frontend-platform/i18n'; - -import { TaxonomyMenu } from '../taxonomy-menu'; -import messages from './messages'; -import { ReadOnlyBadge } from '../read-only-badge'; - -const orgsCountEnabled = (orgsCount) => orgsCount !== undefined && orgsCount !== 0; - -const HeaderSubtitle = ({ - showReadOnlyBadge, - orgsCount, -}) => { - const intl = useIntl(); - - // Show system defined badge - if (showReadOnlyBadge) { - return ; - } - - // Or show orgs count - if (orgsCountEnabled(orgsCount)) { - return ( -
- {intl.formatMessage(messages.assignedToOrgsLabel, { orgsCount })} -
- ); - } - - // Or none - return null; -}; - -HeaderSubtitle.defaultProps = { - orgsCount: undefined, -}; - -HeaderSubtitle.propTypes = { - id: PropTypes.number.isRequired, - showReadOnlyBadge: PropTypes.bool.isRequired, - orgsCount: PropTypes.number, -}; - -const HeaderTitle = ({ taxonomyId, title }) => { - const containerRef = useRef(null); - const textRef = useRef(null); - const [isTruncated, setIsTruncated] = useState(false); - - useEffect(() => { - const containerWidth = containerRef.current.clientWidth; - const textWidth = textRef.current.offsetWidth; - setIsTruncated(textWidth > containerWidth); - }, [title]); - - const getToolTip = () => ( - - - {title} - - - ); - - return ( - -
- {title} -
-
- ); -}; - -HeaderTitle.propTypes = { - taxonomyId: PropTypes.number.isRequired, - title: PropTypes.string.isRequired, -}; - -const TaxonomyCard = ({ className, original }) => { - const { - id, - name, - description, - readOnly, - orgsCount, - } = original; - - const getHeaderActions = () => ( - - ); - - return ( - - } - subtitle={ - - } - actions={getHeaderActions()} - /> - - - {description} - - - - ); -}; - -TaxonomyCard.defaultProps = { - className: '', -}; - -TaxonomyCard.propTypes = { - className: PropTypes.string, - original: PropTypes.shape({ - id: PropTypes.number, - name: PropTypes.string, - description: PropTypes.string, - readOnly: PropTypes.bool, - orgsCount: PropTypes.number, - tagsCount: PropTypes.number, - canChangeTaxonomy: PropTypes.bool, - canDeleteTaxonomy: PropTypes.bool, - }).isRequired, -}; - -export default TaxonomyCard; diff --git a/src/taxonomy/taxonomy-card/index.ts b/src/taxonomy/taxonomy-card/index.ts new file mode 100644 index 0000000000..f1636da192 --- /dev/null +++ b/src/taxonomy/taxonomy-card/index.ts @@ -0,0 +1 @@ +export { TaxonomyCard } from './TaxonomyCard'; diff --git a/src/taxonomy/taxonomy-card/utils.ts b/src/taxonomy/taxonomy-card/utils.ts new file mode 100644 index 0000000000..cb0f5de681 --- /dev/null +++ b/src/taxonomy/taxonomy-card/utils.ts @@ -0,0 +1,2 @@ +/** Whether we know the number of orgs of a taxonomy, and it is assigned to at least one */ +export const orgsCountEnabled = (orgsCount?: number) => orgsCount !== undefined && orgsCount !== 0;