diff --git a/src/taxonomy/__mocks__/index.js b/src/taxonomy/__mocks__/index.ts similarity index 100% rename from src/taxonomy/__mocks__/index.js rename to src/taxonomy/__mocks__/index.ts diff --git a/src/taxonomy/__mocks__/taxonomyListMock.js b/src/taxonomy/__mocks__/taxonomyListMock.ts similarity index 70% rename from src/taxonomy/__mocks__/taxonomyListMock.js rename to src/taxonomy/__mocks__/taxonomyListMock.ts index 0e7b24e4f7..e21af82ec2 100644 --- a/src/taxonomy/__mocks__/taxonomyListMock.js +++ b/src/taxonomy/__mocks__/taxonomyListMock.ts @@ -1,6 +1,8 @@ -module.exports = { - next: null, - previous: null, +import type { TaxonomyListData } from '../data/types'; + +const taxonomyListMock: TaxonomyListData = { + next: '', + previous: '', count: 4, numPages: 1, currentPage: 1, @@ -18,6 +20,11 @@ module.exports = { visibleToAuthors: false, canChangeTaxonomy: false, canDeleteTaxonomy: false, + exportId: 'CA', + tagsCount: 0, + allOrgs: true, + canTagObject: true, + orgs: [], }, { id: -1, @@ -30,6 +37,11 @@ module.exports = { visibleToAuthors: true, canChangeTaxonomy: false, canDeleteTaxonomy: false, + exportId: 'L', + tagsCount: 15, + allOrgs: true, + canTagObject: true, + orgs: [], }, { id: 1, @@ -42,6 +54,11 @@ module.exports = { visibleToAuthors: true, canChangeTaxonomy: true, canDeleteTaxonomy: true, + exportId: 'T1', + tagsCount: 15, + allOrgs: false, + canTagObject: true, + orgs: ['org'], }, { id: 2, @@ -54,6 +71,13 @@ module.exports = { visibleToAuthors: true, canChangeTaxonomy: true, canDeleteTaxonomy: true, + exportId: 'T2', + tagsCount: 15, + allOrgs: false, + canTagObject: true, + orgs: ['org'], }, ], }; + +export default taxonomyListMock; diff --git a/src/taxonomy/data/apiHooks.ts b/src/taxonomy/data/apiHooks.ts index f8856c86ba..98335e3c41 100644 --- a/src/taxonomy/data/apiHooks.ts +++ b/src/taxonomy/data/apiHooks.ts @@ -157,7 +157,7 @@ export const useImportTags = () => { * @param taxonomyId The ID of the taxonomy whose tags we're updating. * @param file The file that we want to import */ -export const useImportPlan = (taxonomyId: number, file: File | null) => useQuery({ +export const useImportPlan = (taxonomyId: number | undefined, file: File | null) => useQuery({ queryKey: taxonomyQueryKeys.importPlan(taxonomyId, file ? `${file.name}${file.lastModified}${file.size}` : ''), queryFn: async (): Promise => { if (!taxonomyId || file === null) { diff --git a/src/taxonomy/delete-dialog/index.jsx b/src/taxonomy/delete-dialog/index.tsx similarity index 87% rename from src/taxonomy/delete-dialog/index.jsx rename to src/taxonomy/delete-dialog/index.tsx index 07fe1356b4..ea3f42df94 100644 --- a/src/taxonomy/delete-dialog/index.jsx +++ b/src/taxonomy/delete-dialog/index.tsx @@ -1,4 +1,3 @@ -// @ts-check import React, { useCallback, useState } from 'react'; import { ActionRow, @@ -8,12 +7,19 @@ import { ModalDialog, Icon, } from '@openedx/paragon'; -import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Warning } from '@openedx/paragon/icons'; import messages from './messages'; -const DeleteDialog = ({ +interface DeleteDialogProps { + taxonomyName: string; + tagsCount: number; + isOpen: boolean; + onClose: () => void; + onDelete: () => void; +} + +const DeleteDialog: React.FC = ({ taxonomyName, tagsCount, isOpen, @@ -24,13 +30,13 @@ const DeleteDialog = ({ const [deleteButtonDisabled, setDeleteButtonDisabled] = useState(true); const deleteLabel = intl.formatMessage(messages.deleteDialogConfirmDeleteLabel); - const handleInputChange = useCallback((event) => { + const handleInputChange = useCallback((event: React.ChangeEvent) => { if (event.target.value === deleteLabel) { setDeleteButtonDisabled(false); } else { setDeleteButtonDisabled(true); } - }, []); + }, [deleteLabel]); const onClickDelete = React.useCallback(() => { onClose(); @@ -90,12 +96,4 @@ const DeleteDialog = ({ ); }; -DeleteDialog.propTypes = { - taxonomyName: PropTypes.string.isRequired, - tagsCount: PropTypes.number.isRequired, - isOpen: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, - onDelete: PropTypes.func.isRequired, -}; - export default DeleteDialog; diff --git a/src/taxonomy/export-modal/index.jsx b/src/taxonomy/export-modal/index.tsx similarity index 87% rename from src/taxonomy/export-modal/index.jsx rename to src/taxonomy/export-modal/index.tsx index e396640b4d..e04db02c0f 100644 --- a/src/taxonomy/export-modal/index.jsx +++ b/src/taxonomy/export-modal/index.tsx @@ -1,4 +1,3 @@ -// @ts-check import React, { useState } from 'react'; import { ActionRow, @@ -7,18 +6,25 @@ import { Form, ModalDialog, } from '@openedx/paragon'; -import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; import { getTaxonomyExportFile } from '../data/api'; -const ExportModal = ({ +interface ExportModalProps { + taxonomyId: number; + isOpen: boolean; + onClose: () => void; +} + +type OutputFormat = 'csv' | 'json'; + +const ExportModal: React.FC = ({ taxonomyId, isOpen, onClose, }) => { const intl = useIntl(); - const [outputFormat, setOutputFormat] = useState(/** @type {'csv'|'json'} */('csv')); + const [outputFormat, setOutputFormat] = useState('csv'); const onClickExport = React.useCallback(() => { onClose(); @@ -50,7 +56,7 @@ const ExportModal = ({ setOutputFormat(e.target.value)} + onChange={(e: React.ChangeEvent) => setOutputFormat(e.target.value as OutputFormat)} > ({ ...jest.requireActual('../data/api'), @@ -33,7 +33,7 @@ const mockSetAlertError = jest.fn(); const context = { toastMessage: null, setToastMessage: mockSetToastMessage, - alertProps: null, + alertError: null, setAlertError: mockSetAlertError, }; @@ -46,7 +46,13 @@ const sampleTaxonomy = { name: 'Test Taxonomy', }; -const RootWrapper = ({ onClose, reimport, taxonomy }) => ( +interface RootWrapperProps { + onClose: () => void; + reimport: boolean; + taxonomy: typeof sampleTaxonomy | undefined; +} + +const RootWrapper: React.FC = ({ onClose, reimport, taxonomy }) => ( @@ -58,15 +64,6 @@ const RootWrapper = ({ onClose, reimport, taxonomy }) => ( ); -RootWrapper.propTypes = { - onClose: PropTypes.func.isRequired, - reimport: PropTypes.bool.isRequired, - taxonomy: PropTypes.shape({ - id: PropTypes.number.isRequired, - name: PropTypes.string.isRequired, - }).isRequired, -}; - describe('', () => { beforeEach(() => { initializeMockApp({ @@ -140,7 +137,7 @@ describe('', () => { expect(getByTestId('dropzone')).toBeInTheDocument(); expect(importButton).toHaveAttribute('aria-disabled', 'true'); - const makeJson = (filename) => new File(['{}'], filename, { type: 'application/json' }); + const makeJson = (filename: string) => new File(['{}'], filename, { type: 'application/json' }); // Correct file type axiosMock.onPut(planImportUrl).replyOnce(200, { plan: 'Import plan' }); @@ -248,7 +245,7 @@ describe('', () => { const onClose = jest.fn(); const { findByTestId, getByRole, getByTestId, getByText, queryByTestId, - } = render(); + } = render(); // Check that there is no export step expect(await queryByTestId('export-step')).not.toBeInTheDocument(); @@ -269,7 +266,7 @@ describe('', () => { expect(getByTestId('dropzone')).toBeInTheDocument(); expect(continueButton).toHaveAttribute('aria-disabled', 'true'); - const makeJson = (filename) => new File(['{}'], filename, { type: 'application/json' }); + const makeJson = (filename: string) => new File(['{}'], filename, { type: 'application/json' }); // Correct file type fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [makeJson('example1.json')], types: ['Files'] } }); diff --git a/src/taxonomy/import-tags/ImportTagsWizard.jsx b/src/taxonomy/import-tags/ImportTagsWizard.tsx similarity index 86% rename from src/taxonomy/import-tags/ImportTagsWizard.jsx rename to src/taxonomy/import-tags/ImportTagsWizard.tsx index 31e7b1b70d..22176eb283 100644 --- a/src/taxonomy/import-tags/ImportTagsWizard.jsx +++ b/src/taxonomy/import-tags/ImportTagsWizard.tsx @@ -1,4 +1,3 @@ -// @ts-check import React, { useState, useContext, useMemo } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { @@ -19,7 +18,6 @@ import { InsertDriveFile, Warning, } from '@openedx/paragon/icons'; -import PropTypes from 'prop-types'; import LoadingButton from '../../generic/loading-button'; import { LoadingSpinner } from '../../generic/Loading'; @@ -31,12 +29,18 @@ import messages from './messages'; const linebreak = <>

; -const TaxonomyProp = PropTypes.shape({ - id: PropTypes.number.isRequired, - name: PropTypes.string.isRequired, -}); +type StepKey = 'export' | 'upload' | 'populate' | 'plan' | 'confirm'; -const ExportStep = ({ taxonomy }) => { +interface TaxonomyProp { + id: number; + name: string; +} + +interface ExportStepProps { + taxonomy: TaxonomyProp; +} + +const ExportStep: React.FC = ({ taxonomy }) => { const intl = useIntl(); return ( @@ -66,15 +70,18 @@ const ExportStep = ({ taxonomy }) => { ); }; -ExportStep.propTypes = { - taxonomy: TaxonomyProp.isRequired, -}; +interface UploadStepProps { + file: File | null; + setFile: (file: File | null) => void; + importPlanError: string | null; + reimport?: boolean; +} -const UploadStep = ({ +const UploadStep: React.FC = ({ file, setFile, importPlanError, - reimport, + reimport = false, }) => { const intl = useIntl(); @@ -86,12 +93,12 @@ const UploadStep = ({ {intl.formatMessage(messages.jsonTemplateTitle)} ); - /** @type {(args: {fileData: FormData}) => void} */ - const handleFileLoad = ({ fileData }) => { - setFile(fileData.get('file')); + const handleFileLoad = ({ fileData }: { fileData: FormData }) => { + const uploadedFile = fileData.get('file') as File; + setFile(uploadedFile); }; - const clearFile = (e) => { + const clearFile = (e: React.MouseEvent) => { e.stopPropagation(); setFile(null); }; @@ -155,35 +162,29 @@ const UploadStep = ({ ); }; -UploadStep.propTypes = { - file: PropTypes.shape({ - name: PropTypes.string.isRequired, - size: PropTypes.number.isRequired, - }), - setFile: PropTypes.func.isRequired, - importPlanError: PropTypes.string, - reimport: PropTypes.bool, -}; +interface TaxonomyPopulateData { + taxonomyName: string; + taxonomyDesc: string; +} -UploadStep.defaultProps = { - file: null, - importPlanError: null, - reimport: false, -}; +interface PopulateStepProps { + taxonomyPopulateData: TaxonomyPopulateData; + setTaxonomyPopulateData: (data: TaxonomyPopulateData) => void; +} -const PopulateStep = ({ +const PopulateStep: React.FC = ({ taxonomyPopulateData, setTaxonomyPopulateData, }) => { const intl = useIntl(); - const handleNameChange = (e) => { + const handleNameChange = (e: React.ChangeEvent) => { const updatedState = { ...taxonomyPopulateData }; updatedState.taxonomyName = e.target.value; setTaxonomyPopulateData(updatedState); }; - const handleDescChange = (e) => { + const handleDescChange = (e: React.ChangeEvent) => { const updatedState = { ...taxonomyPopulateData }; updatedState.taxonomyDesc = e.target.value; setTaxonomyPopulateData(updatedState); @@ -210,15 +211,11 @@ const PopulateStep = ({ ); }; -PopulateStep.propTypes = { - taxonomyPopulateData: PropTypes.shape({ - taxonomyName: PropTypes.string.isRequired, - taxonomyDesc: PropTypes.string.isRequired, - }).isRequired, - setTaxonomyPopulateData: PropTypes.func.isRequired, -}; +interface PlanStepProps { + importPlan: string[] | null; +} -const PlanStep = ({ importPlan }) => { +const PlanStep: React.FC = ({ importPlan }) => { const intl = useIntl(); return ( @@ -237,15 +234,11 @@ const PlanStep = ({ importPlan }) => { ); }; -PlanStep.propTypes = { - importPlan: PropTypes.arrayOf(PropTypes.string), -}; +interface ConfirmStepProps { + importPlan: string[] | null; +} -PlanStep.defaultProps = { - importPlan: null, -}; - -const ConfirmStep = ({ importPlan }) => { +const ConfirmStep: React.FC = ({ importPlan }) => { const intl = useIntl(); return ( @@ -260,40 +253,39 @@ const ConfirmStep = ({ importPlan }) => { ); }; -ConfirmStep.propTypes = { - importPlan: PropTypes.arrayOf(PropTypes.string), -}; +interface DefaultModalHeaderProps { + children: string; +} -ConfirmStep.defaultProps = { - importPlan: null, -}; - -const DefaultModalHeader = ({ children }) => ( +const DefaultModalHeader: React.FC = ({ children }) => ( {children} ); -DefaultModalHeader.propTypes = { - children: PropTypes.string.isRequired, -}; +interface ImportTagsWizardProps { + taxonomy?: TaxonomyProp; + isOpen: boolean; + onClose: () => void; + reimport?: boolean; +} -const ImportTagsWizard = ({ +const ImportTagsWizard: React.FC = ({ taxonomy, isOpen, onClose, - reimport, + reimport = false, }) => { const intl = useIntl(); const { setToastMessage, setAlertError } = useContext(TaxonomyContext); - const [currentStep, setCurrentStep] = useState(reimport ? 'export' : 'upload'); + const [currentStep, setCurrentStep] = useState(reimport ? 'export' : 'upload'); - const [file, setFile] = useState(/** @type {null|File} */ (null)); + const [file, setFile] = useState(null); const [isDialogDisabled, disableDialog, enableDialog] = useToggle(false); - const [taxonomyPopulateData, setTaxonomyPopulateData] = useState({ + const [taxonomyPopulateData, setTaxonomyPopulateData] = useState({ taxonomyName: '', taxonomyDesc: '', }); @@ -314,7 +306,7 @@ const ImportTagsWizard = ({ if (setToastMessage) { setToastMessage(intl.formatMessage(messages.importNewTaxonomyToast, { name: taxonomyName })); } - } catch (/** @type {unknown} */ error) { + } catch (error: unknown) { if (setAlertError) { setAlertError({ title: intl.formatMessage(messages.importTaxonomyErrorAlert), @@ -339,7 +331,7 @@ const ImportTagsWizard = ({ 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); + return planArray; }, [importPlanResult.data]); const importTagsMutation = useImportTags(); @@ -355,16 +347,16 @@ const ImportTagsWizard = ({ const confirmImportTags = async () => { disableDialog(); try { - if (file) { + if (file && taxonomy) { await importTagsMutation.mutateAsync({ taxonomyId: taxonomy.id, file, }); } - if (setToastMessage) { - setToastMessage(intl.formatMessage(messages.importTaxonomyToast, { name: taxonomy?.name })); + if (setToastMessage && taxonomy) { + setToastMessage(intl.formatMessage(messages.importTaxonomyToast, { name: taxonomy.name })); } - } catch (/** @type {unknown} */ error) { + } catch (error: unknown) { if (setAlertError) { setAlertError({ title: intl.formatMessage(messages.importTaxonomyErrorAlert), @@ -377,7 +369,7 @@ const ImportTagsWizard = ({ } }; - const stepHeaders = { + const stepHeaders: Record = { export: ( {intl.formatMessage(messages.importWizardStepExportTitle, { name: taxonomy?.name })} @@ -433,11 +425,11 @@ const ImportTagsWizard = ({ - {reimport && } + {reimport && taxonomy && } ({ ...jest.requireActual('../data/api'), - getTaxonomy: jest.fn().mockResolvedValue(taxonomy), + getTaxonomy: jest.fn().mockResolvedValue({ + id: 1, + name: 'Test Taxonomy', + allOrgs: false, + orgs: ['org1', 'org2'], + }), })); jest.mock('../../generic/data/api', () => ({ ...jest.requireActual('../../generic/data/api'), - getOrganizations: jest.fn().mockResolvedValue(orgs), + getOrganizations: jest.fn().mockResolvedValue(['org1', 'org2', 'org3', 'org4', 'org5']), })); const mockUseManageOrgsMutate = jest.fn(); @@ -45,33 +39,43 @@ jest.mock('./data/api', () => ({ })), })); +let store: any; + +// Define mockTaxonomy after the jest.mock calls for use in the component and test assertions +const mockTaxonomy = { + id: 1, + name: 'Test Taxonomy', + allOrgs: false, + orgs: ['org1', 'org2'], +}; + const mockSetToastMessage = jest.fn(); const mockSetAlertProps = jest.fn(); const context = { toastMessage: null, setToastMessage: mockSetToastMessage, - alertProps: null, - setAlertProps: mockSetAlertProps, + alertError: null, + setAlertError: mockSetAlertProps, }; const queryClient = new QueryClient(); -const RootWrapper = ({ onClose }) => ( +interface RootWrapperProps { + onClose: () => void; +} + +const RootWrapper: React.FC = ({ onClose }) => ( - + ); -RootWrapper.propTypes = { - onClose: PropTypes.func.isRequired, -}; - describe('', () => { beforeEach(() => { initializeMockApp({ @@ -90,7 +94,7 @@ describe('', () => { queryClient.clear(); }); - const checkDialogRender = async (getByText) => { + const checkDialogRender = async (getByText: Function) => { await waitFor(() => { // Dialog title expect(getByText('Assign to organizations')).toBeInTheDocument(); @@ -151,7 +155,7 @@ describe('', () => { await waitFor(() => { expect(mockUseManageOrgsMutate).toHaveBeenCalledWith({ - taxonomyId: taxonomy.id, + taxonomyId: mockTaxonomy.id, orgs: ['org1', 'org3'], allOrgs: false, }); @@ -174,7 +178,7 @@ describe('', () => { await waitFor(() => { expect(mockUseManageOrgsMutate).toHaveBeenCalledWith({ - taxonomyId: taxonomy.id, + taxonomyId: mockTaxonomy.id, allOrgs: true, }); }); @@ -190,9 +194,9 @@ describe('', () => { await checkDialogRender(getByText); // Remove org1 - fireEvent.click(getByText('org1').nextSibling); + fireEvent.click(getByText('org1').nextSibling as HTMLElement); // Remove org2 - fireEvent.click(getByText('org2').nextSibling); + fireEvent.click(getByText('org2').nextSibling as HTMLElement); fireEvent.click(getByRole('button', { name: 'Save' })); @@ -205,7 +209,7 @@ describe('', () => { await waitFor(() => { expect(mockUseManageOrgsMutate).toHaveBeenCalledWith({ - taxonomyId: taxonomy.id, + taxonomyId: mockTaxonomy.id, allOrgs: false, orgs: [], }); diff --git a/src/taxonomy/manage-orgs/ManageOrgsModal.jsx b/src/taxonomy/manage-orgs/ManageOrgsModal.tsx similarity index 87% rename from src/taxonomy/manage-orgs/ManageOrgsModal.jsx rename to src/taxonomy/manage-orgs/ManageOrgsModal.tsx index 0478c11c1b..02f3e54479 100644 --- a/src/taxonomy/manage-orgs/ManageOrgsModal.jsx +++ b/src/taxonomy/manage-orgs/ManageOrgsModal.tsx @@ -1,4 +1,3 @@ -// @ts-check import React, { useContext, useEffect, useState } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { @@ -16,7 +15,6 @@ import { Close, Warning, } from '@openedx/paragon/icons'; -import PropTypes from 'prop-types'; import { useOrganizationListData } from '../../generic/data/apiHooks'; import { TaxonomyContext } from '../common/context'; @@ -25,7 +23,14 @@ import { useManageOrgs } from './data/api'; import messages from './messages'; import './ManageOrgsModal.scss'; -const ConfirmModal = ({ +interface ConfirmModalProps { + isOpen: boolean; + onClose: () => void; + confirm: () => void; + taxonomyName: string; +} + +const ConfirmModal: React.FC = ({ isOpen, onClose, confirm, @@ -57,14 +62,13 @@ const ConfirmModal = ({ ); }; -ConfirmModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, - confirm: PropTypes.func.isRequired, - taxonomyName: PropTypes.string.isRequired, -}; +interface ManageOrgsModalProps { + taxonomyId: number; + isOpen: boolean; + onClose: () => void; +} -const ManageOrgsModal = ({ +const ManageOrgsModal: React.FC = ({ taxonomyId, isOpen, onClose, @@ -72,8 +76,8 @@ const ManageOrgsModal = ({ const intl = useIntl(); const { setToastMessage } = useContext(TaxonomyContext); - const [selectedOrgs, setSelectedOrgs] = useState(/** @type {null|string[]} */(null)); - const [allOrgs, setAllOrgs] = useState(/** @type {null|boolean} */(null)); + const [selectedOrgs, setSelectedOrgs] = useState(null); + const [allOrgs, setAllOrgs] = useState(null); const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false); @@ -126,15 +130,14 @@ const ManageOrgsModal = ({ setAllOrgs(taxonomy.allOrgs); } } - }, [taxonomy]); + }, [taxonomy, selectedOrgs, allOrgs]); useEffect(() => { if (selectedOrgs) { // This is a hack to force the Form.Autosuggest to clear its value after a selection is made. - const inputRef = /** @type {null|HTMLInputElement} */ (document.querySelector('.manage-orgs .pgn__form-group input')); + const inputRef = document.querySelector('.manage-orgs .pgn__form-group input') as HTMLInputElement | null; if (inputRef) { - // @ts-ignore value can be null - inputRef.value = null; + inputRef.value = ''; const event = new Event('change', { bubbles: true }); inputRef.dispatchEvent(event); } @@ -198,7 +201,7 @@ const ManageOrgsModal = ({ { + onChange={({ selectionValue }: { selectionValue: string }) => { if (selectionValue) { setSelectedOrgs([...selectedOrgs, selectionValue]); } @@ -210,7 +213,10 @@ const ManageOrgsModal = ({ )) : [] } - setAllOrgs(e.target.checked)}> + ) => setAllOrgs(e.target.checked)} + > {intl.formatMessage(messages.assignAll)} @@ -238,10 +244,4 @@ const ManageOrgsModal = ({ ); }; -ManageOrgsModal.propTypes = { - taxonomyId: PropTypes.number.isRequired, - isOpen: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, -}; - export default ManageOrgsModal; diff --git a/src/taxonomy/manage-orgs/data/api.test.jsx b/src/taxonomy/manage-orgs/data/api.test.tsx similarity index 95% rename from src/taxonomy/manage-orgs/data/api.test.jsx rename to src/taxonomy/manage-orgs/data/api.test.tsx index 45562c5538..0ed433a214 100644 --- a/src/taxonomy/manage-orgs/data/api.test.jsx +++ b/src/taxonomy/manage-orgs/data/api.test.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { initializeMockApp } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { renderHook } from '@testing-library/react'; @@ -8,10 +9,9 @@ import MockAdapter from 'axios-mock-adapter'; import { getManageOrgsApiUrl, useManageOrgs, - } from './api'; -let axiosMock; +let axiosMock: MockAdapter; const queryClient = new QueryClient({ defaultOptions: { @@ -21,7 +21,7 @@ const queryClient = new QueryClient({ }, }); -const wrapper = ({ children }) => ( +const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} diff --git a/src/taxonomy/manage-orgs/data/api.js b/src/taxonomy/manage-orgs/data/api.ts similarity index 71% rename from src/taxonomy/manage-orgs/data/api.js rename to src/taxonomy/manage-orgs/data/api.ts index 499aa3c731..387c97dc91 100644 --- a/src/taxonomy/manage-orgs/data/api.js +++ b/src/taxonomy/manage-orgs/data/api.ts @@ -1,37 +1,27 @@ -// @ts-check import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { useQueryClient, useMutation } from '@tanstack/react-query'; const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; -/** - * @param {number} taxonomyId - * @returns {string} - */ -export const getManageOrgsApiUrl = (taxonomyId) => new URL( +export const getManageOrgsApiUrl = (taxonomyId: number): string => new URL( `api/content_tagging/v1/taxonomies/${taxonomyId}/orgs/`, getApiBaseUrl(), ).href; +interface ManageOrgsParams { + taxonomyId: number; + orgs?: string[]; + allOrgs: boolean; +} + /** * Build the mutation to assign organizations to a taxonomy. */ export const useManageOrgs = () => { const queryClient = useQueryClient(); return useMutation({ - /** - * @type {import("@tanstack/react-query").MutateFunction< - * any, - * any, - * { - * taxonomyId: number, - * orgs?: string[], - * allOrgs: boolean, - * } - * >} - */ - mutationFn: async ({ taxonomyId, orgs, allOrgs }) => { + mutationFn: async ({ taxonomyId, orgs, allOrgs }: ManageOrgsParams) => { const { data } = await getAuthenticatedHttpClient().put( getManageOrgsApiUrl(taxonomyId), { diff --git a/src/taxonomy/system-defined-badge/index.jsx b/src/taxonomy/system-defined-badge/index.tsx similarity index 85% rename from src/taxonomy/system-defined-badge/index.jsx rename to src/taxonomy/system-defined-badge/index.tsx index ed18bdef2a..84fb9af02e 100644 --- a/src/taxonomy/system-defined-badge/index.jsx +++ b/src/taxonomy/system-defined-badge/index.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Badge, @@ -8,7 +7,11 @@ import { } from '@openedx/paragon'; import messages from './messages'; -const SystemDefinedBadge = ({ taxonomyId }) => { +interface SystemDefinedBadgeProps { + taxonomyId: number; +} + +const SystemDefinedBadge: React.FC = ({ taxonomyId }) => { const intl = useIntl(); const getToolTip = () => ( @@ -34,8 +37,4 @@ const SystemDefinedBadge = ({ taxonomyId }) => { ); }; -SystemDefinedBadge.propTypes = { - taxonomyId: PropTypes.number.isRequired, -}; - export default SystemDefinedBadge; diff --git a/src/taxonomy/tag-list/TagListTable.test.jsx b/src/taxonomy/tag-list/TagListTable.test.tsx similarity index 94% rename from src/taxonomy/tag-list/TagListTable.test.jsx rename to src/taxonomy/tag-list/TagListTable.test.tsx index ac37792e18..e71b3a6b13 100644 --- a/src/taxonomy/tag-list/TagListTable.test.jsx +++ b/src/taxonomy/tag-list/TagListTable.test.tsx @@ -12,11 +12,11 @@ import MockAdapter from 'axios-mock-adapter'; import initializeStore from '../../store'; import TagListTable from './TagListTable'; -let store; -let axiosMock; +let store: any; +let axiosMock: MockAdapter; const queryClient = new QueryClient(); -const RootWrapper = () => ( +const RootWrapper: React.FC = () => ( @@ -84,6 +84,7 @@ const subTagsResponse = { depth: 1, value: 'the child tag', child_count: 0, + descendant_count: 0, _id: 1111, sub_tags_url: null, }, @@ -110,8 +111,8 @@ describe('', () => { it('shows the spinner before the query is complete', async () => { // Simulate an actual slow response from the API: - let resolveResponse; - const promise = new Promise(resolve => { resolveResponse = resolve; }); + let resolveResponse!: (value: [number, any]) => void; + const promise = new Promise<[number, any]>(resolve => { resolveResponse = resolve; }); axiosMock.onGet(rootTagsListUrl).reply(() => promise); render(); const spinner = screen.getByRole('status'); diff --git a/src/taxonomy/tag-list/TagListTable.jsx b/src/taxonomy/tag-list/TagListTable.tsx similarity index 77% rename from src/taxonomy/tag-list/TagListTable.jsx rename to src/taxonomy/tag-list/TagListTable.tsx index 013fcb6530..fb85784d99 100644 --- a/src/taxonomy/tag-list/TagListTable.jsx +++ b/src/taxonomy/tag-list/TagListTable.tsx @@ -1,15 +1,19 @@ -// @ts-check import React, { useState } from 'react'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { DataTable } from '@openedx/paragon'; import { isEqual } from 'lodash'; -import Proptypes from 'prop-types'; import { LoadingSpinner } from '../../generic/Loading'; import messages from './messages'; import { useTagListData, useSubTags } from '../data/apiHooks'; +import { TagData, QueryOptions } from '../data/types'; -const SubTagsExpanded = ({ taxonomyId, parentTagValue }) => { +interface SubTagsExpandedProps { + taxonomyId: number; + parentTagValue: string; +} + +const SubTagsExpanded: React.FC = ({ taxonomyId, parentTagValue }) => { const subTagsData = useSubTags(taxonomyId, parentTagValue); if (subTagsData.isLoading) { @@ -30,47 +34,50 @@ const SubTagsExpanded = ({ taxonomyId, parentTagValue }) => { ); }; -SubTagsExpanded.propTypes = { - taxonomyId: Proptypes.number.isRequired, - parentTagValue: Proptypes.string.isRequired, -}; +interface OptionalExpandLinkProps { + row: { + original: TagData; + toggleRowExpanded: () => void; + getToggleRowExpandedProps: () => any; + }; +} /** * An "Expand" toggle to show/hide subtags, but one which is hidden if the given tag row has no subtags. */ -const OptionalExpandLink = ({ row }) => ( +const OptionalExpandLink: React.FC = ({ row }) => ( row.original.childCount > 0 ?
: null ); -OptionalExpandLink.propTypes = DataTable.ExpandRow.propTypes; + +interface TagValueProps { + row: { + original: TagData; + }; +} /** * Custom DataTable cell to join tag value with child count */ -const TagValue = ({ row }) => ( +const TagValue: React.FC = ({ row }) => ( <> {row.original.value} {` (${row.original.descendantCount})`} ); -TagValue.propTypes = { - row: Proptypes.shape({ - original: Proptypes.shape({ - value: Proptypes.string.isRequired, - childCount: Proptypes.number.isRequired, - descendantCount: Proptypes.number.isRequired, - }).isRequired, - }).isRequired, -}; -const TagListTable = ({ taxonomyId }) => { +interface TagListTableProps { + taxonomyId: number; +} + +const TagListTable: React.FC = ({ taxonomyId }) => { const intl = useIntl(); - const [options, setOptions] = useState({ + const [options, setOptions] = useState({ pageIndex: 0, pageSize: 100, }); const { isLoading, data: tagList } = useTagListData(taxonomyId, options); - const fetchData = (args) => { + const fetchData = (args: QueryOptions) => { if (!isEqual(args, options)) { setOptions({ ...args }); } @@ -114,8 +121,4 @@ const TagListTable = ({ taxonomyId }) => { ); }; -TagListTable.propTypes = { - taxonomyId: Proptypes.number.isRequired, -}; - export default TagListTable; diff --git a/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx b/src/taxonomy/taxonomy-card/TaxonomyCard.test.tsx similarity index 88% rename from src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx rename to src/taxonomy/taxonomy-card/TaxonomyCard.test.tsx index 413623e5bc..97d404861b 100644 --- a/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx +++ b/src/taxonomy/taxonomy-card/TaxonomyCard.test.tsx @@ -6,12 +6,13 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { fireEvent, render } from '@testing-library/react'; import initializeStore from '../../store'; +import { TaxonomyData } from '../data/types'; import TaxonomyCard from '.'; -let store; +let store: any; const taxonomyId = 1; -const data = { +const data: Partial & { orgsCount?: number } = { id: taxonomyId, name: 'Taxonomy 1', description: 'This is a description', @@ -23,7 +24,11 @@ const data = { const queryClient = new QueryClient(); -const TaxonomyCardComponent = ({ original }) => ( +interface TaxonomyCardComponentProps { + original: Partial & { orgsCount?: number }; +} + +const TaxonomyCardComponent: React.FC = ({ original }) => ( @@ -35,8 +40,6 @@ const TaxonomyCardComponent = ({ original }) => ( ); -TaxonomyCardComponent.propTypes = TaxonomyCard.propTypes; - describe('', () => { beforeEach(async () => { initializeMockApp({ @@ -52,8 +55,8 @@ describe('', () => { it('should render title and description of the card', () => { const { getByText } = render(); - expect(getByText(data.name)).toBeInTheDocument(); - expect(getByText(data.description)).toBeInTheDocument(); + expect(getByText(data.name!)).toBeInTheDocument(); + expect(getByText(data.description!)).toBeInTheDocument(); }); it('should show the ⋮ menu', () => { diff --git a/src/taxonomy/taxonomy-card/index.jsx b/src/taxonomy/taxonomy-card/index.tsx similarity index 62% rename from src/taxonomy/taxonomy-card/index.jsx rename to src/taxonomy/taxonomy-card/index.tsx index 591cb06423..ea299f5c95 100644 --- a/src/taxonomy/taxonomy-card/index.jsx +++ b/src/taxonomy/taxonomy-card/index.tsx @@ -4,7 +4,6 @@ import { 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'; @@ -12,10 +11,17 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { TaxonomyMenu } from '../taxonomy-menu'; import messages from './messages'; import SystemDefinedBadge from '../system-defined-badge'; +import { TaxonomyData } from '../data/types'; -const orgsCountEnabled = (orgsCount) => orgsCount !== undefined && orgsCount !== 0; +const orgsCountEnabled = (orgsCount?: number) => orgsCount !== undefined && orgsCount !== 0; -const HeaderSubtitle = ({ +interface HeaderSubtitleProps { + id: number; + showSystemBadge: boolean; + orgsCount?: number; +} + +const HeaderSubtitle: React.FC = ({ id, showSystemBadge, orgsCount, }) => { const intl = useIntl(); @@ -38,25 +44,22 @@ const HeaderSubtitle = ({ return null; }; -HeaderSubtitle.defaultProps = { - orgsCount: undefined, -}; - -HeaderSubtitle.propTypes = { - id: PropTypes.number.isRequired, - showSystemBadge: PropTypes.bool.isRequired, - orgsCount: PropTypes.number, -}; +interface HeaderTitleProps { + taxonomyId: number; + title: string; +} -const HeaderTitle = ({ taxonomyId, title }) => { - const containerRef = useRef(null); - const textRef = useRef(null); +const HeaderTitle: React.FC = ({ 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); + if (containerRef.current && textRef.current) { + const containerWidth = containerRef.current.clientWidth; + const textWidth = textRef.current.offsetWidth; + setIsTruncated(textWidth > containerWidth); + } }, [title]); const getToolTip = () => ( @@ -84,21 +87,19 @@ const HeaderTitle = ({ taxonomyId, title }) => { ); }; -HeaderTitle.propTypes = { - taxonomyId: PropTypes.number.isRequired, - title: PropTypes.string.isRequired, -}; +interface TaxonomyCardProps { + className?: string; + original: Partial & { orgsCount?: number }; +} -const TaxonomyCard = ({ className, original }) => { +const TaxonomyCard: React.FC = ({ className = '', original }) => { const { id, name, description, systemDefined, orgsCount, } = original; - const intl = useIntl(); - const getHeaderActions = () => ( ); @@ -112,13 +113,12 @@ const TaxonomyCard = ({ className, original }) => { data-testid={`taxonomy-card-${id}`} > } + title={} subtitle={( )} actions={getHeaderActions()} @@ -136,22 +136,4 @@ const TaxonomyCard = ({ className, original }) => { ); }; -TaxonomyCard.defaultProps = { - className: '', -}; - -TaxonomyCard.propTypes = { - className: PropTypes.string, - original: PropTypes.shape({ - id: PropTypes.number, - name: PropTypes.string, - description: PropTypes.string, - systemDefined: PropTypes.bool, - orgsCount: PropTypes.number, - tagsCount: PropTypes.number, - canChangeTaxonomy: PropTypes.bool, - canDeleteTaxonomy: PropTypes.bool, - }).isRequired, -}; - export default TaxonomyCard; diff --git a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.tsx similarity index 97% rename from src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx rename to src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.tsx index 49df3d8a7e..0a9793a66b 100644 --- a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx +++ b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import MockAdapter from 'axios-mock-adapter'; import { initializeMockApp } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; @@ -10,10 +11,10 @@ import { apiUrls } from '../data/api'; import initializeStore from '../../store'; import TaxonomyDetailPage from './TaxonomyDetailPage'; -let store; +let store: any; const mockNavigate = jest.fn(); const mockMutate = jest.fn(); -let axiosMock; +let axiosMock: MockAdapter; jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), // use actual for all non-hook parts @@ -32,7 +33,7 @@ jest.mock('../tag-list/TagListTable', () => jest.fn(() => <>Mock TagListTable const queryClient = new QueryClient(); -const RootWrapper = () => ( +const RootWrapper: React.FC = () => ( @@ -64,7 +65,7 @@ describe('', () => { it('shows the spinner before the query is complete', () => { // Use unresolved promise to keep the Loading visible - axiosMock.onGet(apiUrls.taxonomy(1)).reply(() => new Promise()); + axiosMock.onGet(apiUrls.taxonomy(1)).reply(() => new Promise(() => {})); const { getByRole } = render(); const spinner = getByRole('status'); expect(spinner.textContent).toEqual('Loading...'); diff --git a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.tsx similarity index 95% rename from src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx rename to src/taxonomy/taxonomy-detail/TaxonomyDetailPage.tsx index bb9fd89c46..269f7c6da1 100644 --- a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx +++ b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.tsx @@ -1,4 +1,3 @@ -// @ts-check import React from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { @@ -20,9 +19,9 @@ import TaxonomyDetailSideCard from './TaxonomyDetailSideCard'; import { useTaxonomyDetails } from '../data/apiHooks'; import SystemDefinedBadge from '../system-defined-badge'; -const TaxonomyDetailPage = () => { +const TaxonomyDetailPage: React.FC = () => { const intl = useIntl(); - const { taxonomyId: taxonomyIdString } = useParams(); + const { taxonomyId: taxonomyIdString } = useParams<{ taxonomyId: string }>(); const taxonomyId = Number(taxonomyIdString); const { diff --git a/src/taxonomy/taxonomy-detail/TaxonomyDetailSideCard.test.jsx b/src/taxonomy/taxonomy-detail/TaxonomyDetailSideCard.test.tsx similarity index 80% rename from src/taxonomy/taxonomy-detail/TaxonomyDetailSideCard.test.jsx rename to src/taxonomy/taxonomy-detail/TaxonomyDetailSideCard.test.tsx index bee3c39a4a..88ed461b1c 100644 --- a/src/taxonomy/taxonomy-detail/TaxonomyDetailSideCard.test.jsx +++ b/src/taxonomy/taxonomy-detail/TaxonomyDetailSideCard.test.tsx @@ -5,18 +5,23 @@ import { AppProvider } from '@edx/frontend-platform/react'; import { render } from '@testing-library/react'; import initializeStore from '../../store'; - +import { TaxonomyData } from '../data/types'; import TaxonomyDetailSideCard from './TaxonomyDetailSideCard'; -let store; +let store: any; const data = { id: 1, name: 'Taxonomy 1', description: 'This is a description', + exportId: 'export-id-1', }; -const TaxonomyCardComponent = ({ taxonomy }) => ( +interface TaxonomyCardComponentProps { + taxonomy: Pick; +} + +const TaxonomyCardComponent: React.FC = ({ taxonomy }) => ( @@ -24,8 +29,6 @@ const TaxonomyCardComponent = ({ taxonomy }) => ( ); -TaxonomyCardComponent.propTypes = TaxonomyDetailSideCard.propTypes; - describe('', () => { beforeEach(async () => { initializeMockApp({ diff --git a/src/taxonomy/taxonomy-detail/TaxonomyDetailSideCard.jsx b/src/taxonomy/taxonomy-detail/TaxonomyDetailSideCard.tsx similarity index 71% rename from src/taxonomy/taxonomy-detail/TaxonomyDetailSideCard.jsx rename to src/taxonomy/taxonomy-detail/TaxonomyDetailSideCard.tsx index 9cfe3c669a..3363c365ab 100644 --- a/src/taxonomy/taxonomy-detail/TaxonomyDetailSideCard.jsx +++ b/src/taxonomy/taxonomy-detail/TaxonomyDetailSideCard.tsx @@ -1,12 +1,17 @@ +import React from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Card, } from '@openedx/paragon'; -import PropTypes from 'prop-types'; +import { TaxonomyData } from '../data/types'; import messages from './messages'; -const TaxonomyDetailSideCard = ({ taxonomy }) => { +interface TaxonomyDetailSideCardProps { + taxonomy: Pick; +} + +const TaxonomyDetailSideCard: React.FC = ({ taxonomy }) => { const intl = useIntl(); return ( @@ -25,12 +30,4 @@ const TaxonomyDetailSideCard = ({ taxonomy }) => { ); }; -TaxonomyDetailSideCard.propTypes = { - taxonomy: PropTypes.shape({ - name: PropTypes.string.isRequired, - exportId: PropTypes.string.isRequired, - description: PropTypes.string.isRequired, - }).isRequired, -}; - export default TaxonomyDetailSideCard; diff --git a/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx b/src/taxonomy/taxonomy-menu/TaxonomyMenu.tsx similarity index 77% rename from src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx rename to src/taxonomy/taxonomy-menu/TaxonomyMenu.tsx index fce069a95d..231a6460a0 100644 --- a/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx +++ b/src/taxonomy/taxonomy-menu/TaxonomyMenu.tsx @@ -1,4 +1,3 @@ -// @ts-check import React, { useCallback, useContext } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { @@ -10,7 +9,6 @@ import { } from '@openedx/paragon'; import { MoreVert } from '@openedx/paragon/icons'; import { pickBy } from 'lodash'; -import PropTypes from 'prop-types'; import { useNavigate } from 'react-router-dom'; import ExportModal from '../export-modal'; @@ -20,19 +18,22 @@ import DeleteDialog from '../delete-dialog'; import { ImportTagsWizard } from '../import-tags'; import { ManageOrgsModal } from '../manage-orgs'; import messages from './messages'; +import { TaxonomyData } from '../data/types'; -/** @typedef {import('../data/types.js').TaxonomyData} TaxonomyData */ -// Note: to make mocking easier for tests, the types below only specify the subset of TaxonomyData that we actually use. +interface TaxonomyMenuProps { + taxonomy: Pick; + iconMenu?: boolean; +} -/** - * A menu that provides actions for editing a specific taxonomy. - * @type {React.FC<{ - * taxonomy: Pick, - * iconMenu?: boolean - * }>} - */ -const TaxonomyMenu = ({ - taxonomy, iconMenu, +interface MenuItem { + title: string; + action: () => void; + show?: boolean; +} + +const TaxonomyMenu: React.FC = ({ + taxonomy, + iconMenu = false, }) => { const intl = useIntl(); const navigate = useNavigate(); @@ -52,23 +53,14 @@ const TaxonomyMenu = ({ // TODO: display the error to the user }, }); - }, [setToastMessage, taxonomy]); + }, [setToastMessage, taxonomy, deleteTaxonomy, intl, navigate]); const [isDeleteDialogOpen, deleteDialogOpen, deleteDialogClose] = useToggle(false); const [isExportModalOpen, exportModalOpen, exportModalClose] = useToggle(false); const [isImportModalOpen, importModalOpen, importModalClose] = useToggle(false); const [isManageOrgsModalOpen, manageOrgsModalOpen, manageOrgsModalClose] = useToggle(false); - /** - * @typedef {Object} MenuItem - * @property {string} title - The title of the menu item - * @property {() => void} action - The action to perform when the menu item is clicked - * @property {boolean} [show] - Whether or not to show the menu item - * - * @constant - * @type {Record} - */ - let menuItems = { + let menuItems: Record = { import: { title: intl.formatMessage(messages.importMenu), action: importModalOpen, @@ -172,20 +164,4 @@ const TaxonomyMenu = ({ ); }; -TaxonomyMenu.propTypes = { - taxonomy: PropTypes.shape({ - id: PropTypes.number.isRequired, - name: PropTypes.string.isRequired, - tagsCount: PropTypes.number.isRequired, - systemDefined: PropTypes.bool.isRequired, - canChangeTaxonomy: PropTypes.bool.isRequired, - canDeleteTaxonomy: PropTypes.bool.isRequired, - }).isRequired, - iconMenu: PropTypes.bool, -}; - -TaxonomyMenu.defaultProps = { - iconMenu: false, -}; - export default TaxonomyMenu;