diff --git a/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateList.jsx b/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateList.jsx new file mode 100644 index 0000000000..e0a210f8c0 --- /dev/null +++ b/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateList.jsx @@ -0,0 +1,280 @@ +import React, { Fragment, useEffect, useState, useCallback } from 'react'; +import { useLocation } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Card } from '@patternfly/react-core'; + +import { + JobTemplatesAPI, + UnifiedJobTemplatesAPI, + WorkflowJobTemplatesAPI, +} from '../../../api'; +import AlertModal from '../../../components/AlertModal'; +import DatalistToolbar from '../../../components/DataListToolbar'; +import ErrorDetail from '../../../components/ErrorDetail'; +import PaginatedDataList, { + ToolbarDeleteButton, +} from '../../../components/PaginatedDataList'; +import useRequest, { useDeleteItems } from '../../../util/useRequest'; +import { getQSConfig, parseQueryString } from '../../../util/qs'; +import useWsTemplates from '../../../util/useWsTemplates'; +import AddDropDownButton from '../../../components/AddDropDownButton'; + +import DashboardTemplateListItem from './DashboardTemplateListItem'; + +const QS_CONFIG = getQSConfig( + 'template', + { + page: 1, + page_size: 5, + order_by: 'name', + type: 'job_template,workflow_job_template', + }, + ['id', 'page', 'page_size'] +); + +function DashboardTemplateList({ i18n }) { + // The type value in const QS_CONFIG below does not have a space between job_template and + // workflow_job_template so the params sent to the API match what the api expects. + + const location = useLocation(); + + const [selected, setSelected] = useState([]); + + const { + result: { + results, + count, + jtActions, + wfjtActions, + relatedSearchableKeys, + searchableKeys, + }, + error: contentError, + isLoading, + request: fetchTemplates, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + const responses = await Promise.all([ + UnifiedJobTemplatesAPI.read(params), + JobTemplatesAPI.readOptions(), + WorkflowJobTemplatesAPI.readOptions(), + UnifiedJobTemplatesAPI.readOptions(), + ]); + return { + results: responses[0].data.results, + count: responses[0].data.count, + jtActions: responses[1].data.actions, + wfjtActions: responses[2].data.actions, + relatedSearchableKeys: ( + responses[3]?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), + searchableKeys: Object.keys( + responses[3].data.actions?.GET || {} + ).filter(key => responses[3].data.actions?.GET[key].filterable), + }; + }, [location]), + { + results: [], + count: 0, + jtActions: {}, + wfjtActions: {}, + relatedSearchableKeys: [], + searchableKeys: [], + } + ); + + useEffect(() => { + fetchTemplates(); + }, [fetchTemplates]); + + const templates = useWsTemplates(results); + + const isAllSelected = + selected.length === templates.length && selected.length > 0; + const { + isLoading: isDeleteLoading, + deleteItems: deleteTemplates, + deletionError, + clearDeletionError, + } = useDeleteItems( + useCallback(async () => { + return Promise.all( + selected.map(({ type, id }) => { + if (type === 'job_template') { + return JobTemplatesAPI.destroy(id); + } + if (type === 'workflow_job_template') { + return WorkflowJobTemplatesAPI.destroy(id); + } + return false; + }) + ); + }, [selected]), + { + qsConfig: QS_CONFIG, + allItemsSelected: isAllSelected, + fetchItems: fetchTemplates, + } + ); + + const handleTemplateDelete = async () => { + await deleteTemplates(); + setSelected([]); + }; + + const handleSelectAll = isSelected => { + setSelected(isSelected ? [...templates] : []); + }; + + const handleSelect = template => { + if (selected.some(s => s.id === template.id)) { + setSelected(selected.filter(s => s.id !== template.id)); + } else { + setSelected(selected.concat(template)); + } + }; + + const canAddJT = + jtActions && Object.prototype.hasOwnProperty.call(jtActions, 'POST'); + const canAddWFJT = + wfjtActions && Object.prototype.hasOwnProperty.call(wfjtActions, 'POST'); + // spreading Set() returns only unique keys + const addButtonOptions = []; + + if (canAddJT) { + addButtonOptions.push({ + label: i18n._(t`Job Template`), + url: `/templates/job_template/add/`, + }); + } + + if (canAddWFJT) { + addButtonOptions.push({ + label: i18n._(t`Workflow Template`), + url: `/templates/workflow_job_template/add/`, + }); + } + + const addButton = ( + + ); + + return ( + + + ( + , + ]} + /> + )} + renderItem={template => ( + handleSelect(template)} + isSelected={selected.some(row => row.id === template.id)} + fetchTemplates={fetchTemplates} + /> + )} + emptyStateControls={(canAddJT || canAddWFJT) && addButton} + /> + + + {i18n._(t`Failed to delete one or more templates.`)} + + + + ); +} + +export default withI18n()(DashboardTemplateList); diff --git a/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateList.test.jsx b/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateList.test.jsx new file mode 100644 index 0000000000..5b33d3d717 --- /dev/null +++ b/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateList.test.jsx @@ -0,0 +1,336 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { + JobTemplatesAPI, + UnifiedJobTemplatesAPI, + WorkflowJobTemplatesAPI, +} from '../../../api'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; + +import DashboardTemplateList from './DashboardTemplateList'; + +jest.mock('../../../api'); + +const mockTemplates = [ + { + id: 1, + name: 'Job Template 1', + url: '/templates/job_template/1', + type: 'job_template', + summary_fields: { + user_capabilities: { + delete: true, + edit: true, + copy: true, + }, + }, + }, + { + id: 2, + name: 'Job Template 2', + url: '/templates/job_template/2', + type: 'job_template', + summary_fields: { + user_capabilities: { + delete: true, + }, + }, + }, + { + id: 3, + name: 'Job Template 3', + url: '/templates/job_template/3', + type: 'job_template', + summary_fields: { + user_capabilities: { + delete: true, + }, + }, + }, + { + id: 4, + name: 'Workflow Job Template 1', + url: '/templates/workflow_job_template/4', + type: 'workflow_job_template', + summary_fields: { + user_capabilities: { + delete: true, + }, + }, + }, + { + id: 5, + name: 'Workflow Job Template 2', + url: '/templates/workflow_job_template/5', + type: 'workflow_job_template', + summary_fields: { + user_capabilities: { + delete: false, + }, + }, + }, +]; + +describe('', () => { + let debug; + beforeEach(() => { + UnifiedJobTemplatesAPI.read.mockResolvedValue({ + data: { + count: mockTemplates.length, + results: mockTemplates, + }, + }); + + UnifiedJobTemplatesAPI.readOptions.mockResolvedValue({ + data: { + actions: [], + }, + }); + debug = global.console.debug; // eslint-disable-line prefer-destructuring + global.console.debug = () => {}; + }); + + afterEach(() => { + jest.clearAllMocks(); + global.console.debug = debug; + }); + + test('initially renders successfully', async () => { + await act(async () => { + mountWithContexts( + + ); + }); + }); + + test('Templates are retrieved from the api and the components finishes loading', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + expect(UnifiedJobTemplatesAPI.read).toBeCalled(); + await act(async () => { + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + expect(wrapper.find('DashboardTemplateListItem').length).toEqual(5); + }); + + test('handleSelect is called when a template list item is selected', async () => { + const wrapper = mountWithContexts(); + await act(async () => { + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + const checkBox = wrapper + .find('DashboardTemplateListItem') + .at(1) + .find('input'); + + checkBox.simulate('change', { + target: { + id: 2, + name: 'Job Template 2', + url: '/templates/job_template/2', + type: 'job_template', + summary_fields: { user_capabilities: { delete: true } }, + }, + }); + + expect( + wrapper + .find('DashboardTemplateListItem') + .at(1) + .prop('isSelected') + ).toBe(true); + }); + + test('handleSelectAll is called when a template list item is selected', async () => { + const wrapper = mountWithContexts(); + await act(async () => { + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + expect(wrapper.find('Checkbox#select-all').prop('isChecked')).toBe(false); + + const toolBarCheckBox = wrapper.find('Checkbox#select-all'); + act(() => { + toolBarCheckBox.prop('onChange')(true); + }); + wrapper.update(); + expect(wrapper.find('Checkbox#select-all').prop('isChecked')).toBe(true); + }); + + test('delete button is disabled if user does not have delete capabilities on a selected template', async () => { + const wrapper = mountWithContexts(); + await act(async () => { + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + const deleteableItem = wrapper + .find('DashboardTemplateListItem') + .at(0) + .find('input'); + const nonDeleteableItem = wrapper + .find('DashboardTemplateListItem') + .at(4) + .find('input'); + + deleteableItem.simulate('change', { + id: 1, + name: 'Job Template 1', + url: '/templates/job_template/1', + type: 'job_template', + summary_fields: { + user_capabilities: { + delete: true, + }, + }, + }); + + expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe( + false + ); + deleteableItem.simulate('change', { + id: 1, + name: 'Job Template 1', + url: '/templates/job_template/1', + type: 'job_template', + summary_fields: { + user_capabilities: { + delete: true, + }, + }, + }); + expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe( + true + ); + nonDeleteableItem.simulate('change', { + id: 5, + name: 'Workflow Job Template 2', + url: '/templates/workflow_job_template/5', + type: 'workflow_job_template', + summary_fields: { + user_capabilities: { + delete: false, + }, + }, + }); + expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe( + true + ); + }); + + test('api is called to delete templates for each selected template.', async () => { + const wrapper = mountWithContexts(); + await act(async () => { + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + const jobTemplate = wrapper + .find('DashboardTemplateListItem') + .at(1) + .find('input'); + const workflowJobTemplate = wrapper + .find('DashboardTemplateListItem') + .at(3) + .find('input'); + + jobTemplate.simulate('change', { + target: { + id: 2, + name: 'Job Template 2', + url: '/templates/job_template/2', + type: 'job_template', + summary_fields: { user_capabilities: { delete: true } }, + }, + }); + + workflowJobTemplate.simulate('change', { + target: { + id: 4, + name: 'Workflow Job Template 1', + url: '/templates/workflow_job_template/4', + type: 'workflow_job_template', + summary_fields: { + user_capabilities: { + delete: true, + }, + }, + }, + }); + + await act(async () => { + wrapper.find('button[aria-label="Delete"]').prop('onClick')(); + }); + wrapper.update(); + await act(async () => { + await wrapper + .find('button[aria-label="confirm delete"]') + .prop('onClick')(); + }); + expect(JobTemplatesAPI.destroy).toBeCalledWith(2); + expect(WorkflowJobTemplatesAPI.destroy).toBeCalledWith(4); + }); + + test('error is shown when template not successfully deleted from api', async () => { + JobTemplatesAPI.destroy.mockRejectedValue( + new Error({ + response: { + config: { + method: 'delete', + url: '/api/v2/job_templates/1', + }, + data: 'An error occurred', + }, + }) + ); + const wrapper = mountWithContexts(); + await act(async () => { + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + const checkBox = wrapper + .find('DashboardTemplateListItem') + .at(1) + .find('input'); + + checkBox.simulate('change', { + target: { + id: 'a', + name: 'Job Template 2', + url: '/templates/job_template/2', + type: 'job_template', + summary_fields: { user_capabilities: { delete: true } }, + }, + }); + await act(async () => { + wrapper.find('button[aria-label="Delete"]').prop('onClick')(); + }); + wrapper.update(); + await act(async () => { + await wrapper + .find('button[aria-label="confirm delete"]') + .prop('onClick')(); + }); + + await waitForElement( + wrapper, + 'Modal[aria-label="Deletion Error"]', + el => el.props().isOpen === true && el.props().title === 'Error!' + ); + }); + test('should properly copy template', async () => { + JobTemplatesAPI.copy.mockResolvedValue({}); + const wrapper = mountWithContexts(); + await act(async () => { + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + await act(async () => + wrapper.find('Button[aria-label="Copy"]').prop('onClick')() + ); + expect(JobTemplatesAPI.copy).toHaveBeenCalled(); + expect(UnifiedJobTemplatesAPI.read).toHaveBeenCalled(); + wrapper.update(); + }); +}); diff --git a/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateListItem.jsx b/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateListItem.jsx new file mode 100644 index 0000000000..f06cf71aeb --- /dev/null +++ b/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateListItem.jsx @@ -0,0 +1,179 @@ +import 'styled-components/macro'; +import React, { useState, useCallback } from 'react'; +import { Link } from 'react-router-dom'; +import { + Button, + DataListAction as _DataListAction, + DataListCheck, + DataListItem, + DataListItemRow, + DataListItemCells, + Tooltip, +} from '@patternfly/react-core'; +import { t } from '@lingui/macro'; +import { withI18n } from '@lingui/react'; +import { + ExclamationTriangleIcon, + PencilAltIcon, + ProjectDiagramIcon, + RocketIcon, +} from '@patternfly/react-icons'; +import styled from 'styled-components'; + +import DataListCell from '../../../components/DataListCell'; +import { timeOfDay } from '../../../util/dates'; +import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../../api'; +import LaunchButton from '../../../components/LaunchButton'; +import Sparkline from '../../../components/Sparkline'; +import { toTitleCase } from '../../../util/strings'; +import CopyButton from '../../../components/CopyButton'; + +const DataListAction = styled(_DataListAction)` + align-items: center; + display: grid; + grid-gap: 16px; + grid-template-columns: repeat(4, 40px); +`; + +function DashboardTemplateListItem({ + i18n, + template, + isSelected, + onSelect, + detailUrl, + fetchTemplates, +}) { + const [isDisabled, setIsDisabled] = useState(false); + const labelId = `check-action-${template.id}`; + + const copyTemplate = useCallback(async () => { + if (template.type === 'job_template') { + await JobTemplatesAPI.copy(template.id, { + name: `${template.name} @ ${timeOfDay()}`, + }); + } else { + await WorkflowJobTemplatesAPI.copy(template.id, { + name: `${template.name} @ ${timeOfDay()}`, + }); + } + await fetchTemplates(); + }, [fetchTemplates, template.id, template.name, template.type]); + + const handleCopyStart = useCallback(() => { + setIsDisabled(true); + }, []); + + const handleCopyFinish = useCallback(() => { + setIsDisabled(false); + }, []); + + const missingResourceIcon = + template.type === 'job_template' && + (!template.summary_fields.project || + (!template.summary_fields.inventory && + !template.ask_inventory_on_launch)); + return ( + + + + + + + {template.name} + + + {missingResourceIcon && ( + + + + + + )} + , + + {toTitleCase(template.type)} + , + + + , + ]} + /> + + {template.type === 'workflow_job_template' && ( + + + + )} + {template.summary_fields.user_capabilities.start && ( + + + {({ handleLaunch }) => ( + + )} + + + )} + {template.summary_fields.user_capabilities.edit && ( + + + + )} + {template.summary_fields.user_capabilities.copy && ( + + )} + + + + ); +} + +export { DashboardTemplateListItem as _TemplateListItem }; +export default withI18n()(DashboardTemplateListItem); diff --git a/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateListItem.test.jsx b/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateListItem.test.jsx new file mode 100644 index 0000000000..571ef260c0 --- /dev/null +++ b/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateListItem.test.jsx @@ -0,0 +1,268 @@ +import React from 'react'; +import { createMemoryHistory } from 'history'; +import { act } from 'react-dom/test-utils'; + +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { JobTemplatesAPI } from '../../../api'; + +import mockJobTemplateData from './data.job_template.json'; +import DashboardTemplateListItem from './DashboardTemplateListItem'; + +jest.mock('../../../api'); + +describe('', () => { + test('launch button shown to users with start capabilities', () => { + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('LaunchButton').exists()).toBeTruthy(); + }); + test('launch button hidden from users without start capabilities', () => { + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('LaunchButton').exists()).toBeFalsy(); + }); + test('edit button shown to users with edit capabilities', () => { + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy(); + }); + test('edit button hidden from users without edit capabilities', () => { + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); + }); + test('missing resource icon is shown.', () => { + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(true); + }); + test('missing resource icon is not shown when there is a project and an inventory.', () => { + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false); + }); + test('missing resource icon is not shown when inventory is prompt_on_launch, and a project', () => { + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false); + }); + test('missing resource icon is not shown type is workflow_job_template', () => { + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false); + }); + test('clicking on template from templates list navigates properly', () => { + const history = createMemoryHistory({ + initialEntries: ['/templates'], + }); + const wrapper = mountWithContexts( + , + { context: { router: { history } } } + ); + wrapper.find('Link').simulate('click', { button: 0 }); + expect(history.location.pathname).toEqual( + '/templates/job_template/1/details' + ); + }); + test('should call api to copy template', async () => { + JobTemplatesAPI.copy.mockResolvedValue(); + + const wrapper = mountWithContexts( + + ); + await act(async () => + wrapper.find('Button[aria-label="Copy"]').prop('onClick')() + ); + expect(JobTemplatesAPI.copy).toHaveBeenCalled(); + jest.clearAllMocks(); + }); + + test('should render proper alert modal on copy error', async () => { + JobTemplatesAPI.copy.mockRejectedValue(new Error()); + + const wrapper = mountWithContexts( + + ); + await act(async () => + wrapper.find('Button[aria-label="Copy"]').prop('onClick')() + ); + wrapper.update(); + expect(wrapper.find('Modal').prop('isOpen')).toBe(true); + jest.clearAllMocks(); + }); + + test('should not render copy button', async () => { + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('CopyButton').length).toBe(0); + }); + + test('should render visualizer button for workflow', async () => { + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('ProjectDiagramIcon').length).toBe(1); + }); + + test('should not render visualizer button for job template', async () => { + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('ProjectDiagramIcon').length).toBe(0); + }); +}); diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx index 455bf4ce6c..c225b6ec49 100644 --- a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx +++ b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx @@ -1,8 +1,8 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { Fragment, useEffect, useState, useCallback } from 'react'; import { useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { Card, PageSection } from '@patternfly/react-core'; +import { Card } from '@patternfly/react-core'; import { JobTemplatesAPI, @@ -17,7 +17,7 @@ import PaginatedDataList, { } from '../../../components/PaginatedDataList'; import useRequest, { useDeleteItems } from '../../../util/useRequest'; import { getQSConfig, parseQueryString } from '../../../util/qs'; -import useWsTemplates from './useWsTemplates'; +import useWsTemplates from '../../../util/useWsTemplates'; import AddDropDownButton from '../../../components/AddDropDownButton'; import TemplateListItem from './TemplateListItem'; @@ -156,7 +156,7 @@ function TemplateList({ i18n }) { ); return ( - + - + ); } diff --git a/awx/ui_next/src/screens/Template/Templates.jsx b/awx/ui_next/src/screens/Template/Templates.jsx index 0b529a95af..d63d8f7d46 100644 --- a/awx/ui_next/src/screens/Template/Templates.jsx +++ b/awx/ui_next/src/screens/Template/Templates.jsx @@ -2,6 +2,7 @@ import React, { Component } from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Route, withRouter, Switch } from 'react-router-dom'; +import { PageSection } from '@patternfly/react-core'; import { Config } from '../../contexts/Config'; import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; @@ -120,7 +121,9 @@ class Templates extends Component { )} /> - + + + diff --git a/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.js b/awx/ui_next/src/util/useWsTemplates.jsx similarity index 96% rename from awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.js rename to awx/ui_next/src/util/useWsTemplates.jsx index fa10424c85..3c6a8d4d9c 100644 --- a/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.js +++ b/awx/ui_next/src/util/useWsTemplates.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import useWebsocket from '../../../util/useWebsocket'; +import useWebsocket from './useWebsocket'; export default function useWsTemplates(initialTemplates) { const [templates, setTemplates] = useState(initialTemplates); diff --git a/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.test.jsx b/awx/ui_next/src/util/useWsTemplates.test.jsx similarity index 97% rename from awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.test.jsx rename to awx/ui_next/src/util/useWsTemplates.test.jsx index 61bc6e042e..9b32fb3eac 100644 --- a/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.test.jsx +++ b/awx/ui_next/src/util/useWsTemplates.test.jsx @@ -1,14 +1,14 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import WS from 'jest-websocket-mock'; -import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { mountWithContexts } from '../../testUtils/enzymeHelpers'; import useWsTemplates from './useWsTemplates'; /* Jest mock timers don’t play well with jest-websocket-mock, so we'll stub out throttling to resolve immediately */ -jest.mock('../../../util/useThrottle', () => ({ +jest.mock('./useThrottle', () => ({ __esModule: true, default: jest.fn(val => val), }));