From 801aaf9323e37878990d85de9f589be20d7c732b Mon Sep 17 00:00:00 2001 From: mabashian Date: Thu, 13 Feb 2020 17:02:35 -0500 Subject: [PATCH 1/3] Adds the All Schedules list --- awx/ui_next/src/api/index.js | 3 + awx/ui_next/src/api/models/Schedules.js | 10 + .../Schedule/ScheduleList/ScheduleList.jsx | 205 ++++++++++++++++++ .../ScheduleList/ScheduleList.test.jsx | 163 ++++++++++++++ .../ScheduleList/ScheduleListItem.jsx | 157 ++++++++++++++ .../ScheduleList/ScheduleListItem.test.jsx | 180 +++++++++++++++ .../screens/Schedule/ScheduleList/index.js | 2 + .../src/screens/Schedule/Schedules.jsx | 38 ++-- .../src/screens/Schedule/Schedules.test.jsx | 43 ++-- .../src/screens/Schedule/data.schedules.json | 106 +++++++++ awx/ui_next/src/types.js | 29 +++ 11 files changed, 899 insertions(+), 37 deletions(-) create mode 100644 awx/ui_next/src/api/models/Schedules.js create mode 100644 awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleList.jsx create mode 100644 awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleList.test.jsx create mode 100644 awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleListItem.jsx create mode 100644 awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleListItem.test.jsx create mode 100644 awx/ui_next/src/screens/Schedule/ScheduleList/index.js create mode 100644 awx/ui_next/src/screens/Schedule/data.schedules.json diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js index cf033c16ae..e5f1f34557 100644 --- a/awx/ui_next/src/api/index.js +++ b/awx/ui_next/src/api/index.js @@ -17,6 +17,7 @@ import Organizations from './models/Organizations'; import Projects from './models/Projects'; import ProjectUpdates from './models/ProjectUpdates'; import Root from './models/Root'; +import Schedules from './models/Schedules'; import SystemJobs from './models/SystemJobs'; import Teams from './models/Teams'; import UnifiedJobTemplates from './models/UnifiedJobTemplates'; @@ -46,6 +47,7 @@ const OrganizationsAPI = new Organizations(); const ProjectsAPI = new Projects(); const ProjectUpdatesAPI = new ProjectUpdates(); const RootAPI = new Root(); +const SchedulesAPI = new Schedules(); const SystemJobsAPI = new SystemJobs(); const TeamsAPI = new Teams(); const UnifiedJobTemplatesAPI = new UnifiedJobTemplates(); @@ -76,6 +78,7 @@ export { ProjectsAPI, ProjectUpdatesAPI, RootAPI, + SchedulesAPI, SystemJobsAPI, TeamsAPI, UnifiedJobTemplatesAPI, diff --git a/awx/ui_next/src/api/models/Schedules.js b/awx/ui_next/src/api/models/Schedules.js new file mode 100644 index 0000000000..e5581d2875 --- /dev/null +++ b/awx/ui_next/src/api/models/Schedules.js @@ -0,0 +1,10 @@ +import Base from '../Base'; + +class Schedules extends Base { + constructor(http) { + super(http); + this.baseUrl = '/api/v2/schedules/'; + } +} + +export default Schedules; diff --git a/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleList.jsx b/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleList.jsx new file mode 100644 index 0000000000..36b0b9ccdd --- /dev/null +++ b/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleList.jsx @@ -0,0 +1,205 @@ +import React, { useState, useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { SchedulesAPI } from '@api'; +import { Card, PageSection } from '@patternfly/react-core'; +import AlertModal from '@components/AlertModal'; +import ErrorDetail from '@components/ErrorDetail'; +import DataListToolbar from '@components/DataListToolbar'; +import PaginatedDataList, { + ToolbarDeleteButton, +} from '@components/PaginatedDataList'; +import { getQSConfig, parseQueryString } from '@util/qs'; +import { ScheduleListItem } from '.'; + +const QS_CONFIG = getQSConfig('schedule', { + page: 1, + page_size: 20, + order_by: 'unified_job_template__polymorphic_ctype__model', +}); + +function ScheduleList({ i18n }) { + const [contentError, setContentError] = useState(null); + const [scheduleCount, setScheduleCount] = useState(0); + const [schedules, setSchedules] = useState([]); + const [deletionError, setDeletionError] = useState(null); + const [hasContentLoading, setHasContentLoading] = useState(true); + const [selected, setSelected] = useState([]); + const [toggleError, setToggleError] = useState(null); + const [toggleLoading, setToggleLoading] = useState(null); + + const location = useLocation(); + + const loadSchedules = async ({ search }) => { + const params = parseQueryString(QS_CONFIG, search); + setContentError(null); + setHasContentLoading(true); + try { + const { + data: { count, results }, + } = await SchedulesAPI.read(params); + + setSchedules(results); + setScheduleCount(count); + } catch (error) { + setContentError(error); + } finally { + setHasContentLoading(false); + } + }; + + useEffect(() => { + loadSchedules(location); + }, [location]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleSelectAll = isSelected => { + setSelected(isSelected ? [...schedules] : []); + }; + + const handleSelect = row => { + if (selected.some(s => s.id === row.id)) { + setSelected(selected.filter(s => s.id !== row.id)); + } else { + setSelected(selected.concat(row)); + } + }; + + const handleDelete = async () => { + setHasContentLoading(true); + + try { + await Promise.all( + selected.map(schedule => SchedulesAPI.destroy(schedule.id)) + ); + } catch (error) { + setDeletionError(error); + } + + const params = parseQueryString(QS_CONFIG, location.search); + try { + const { + data: { count, results }, + } = await SchedulesAPI.read(params); + + setSchedules(results); + setScheduleCount(count); + setSelected([]); + } catch (error) { + setContentError(error); + } + + setHasContentLoading(false); + }; + + const handleScheduleToggle = async scheduleToToggle => { + setToggleLoading(scheduleToToggle.id); + try { + const { data: updatedSchedule } = await SchedulesAPI.update( + scheduleToToggle.id, + { + enabled: !scheduleToToggle.enabled, + } + ); + setSchedules( + schedules.map(schedule => + schedule.id === updatedSchedule.id ? updatedSchedule : schedule + ) + ); + } catch (err) { + setToggleError(err); + } finally { + setToggleLoading(null); + } + }; + + const isAllSelected = + selected.length > 0 && selected.length === schedules.length; + + return ( + + + ( + row.id === item.id)} + key={item.id} + onSelect={() => handleSelect(item)} + onToggleSchedule={handleScheduleToggle} + schedule={item} + toggleLoading={toggleLoading === item.id} + /> + )} + toolbarSearchColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true, + }, + ]} + toolbarSortColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + }, + { + name: i18n._(t`Next Run`), + key: 'next_run', + }, + { + name: i18n._(t`Type`), + key: 'unified_job_template__polymorphic_ctype__model', + }, + ]} + renderToolbar={props => ( + , + ]} + /> + )} + /> + + {toggleError && !toggleLoading && ( + setToggleError(null)} + > + {i18n._(t`Failed to toggle schedule.`)} + + + )} + {deletionError && ( + setDeletionError(null)} + > + {i18n._(t`Failed to delete one or more schedules.`)} + + + )} + + ); +} + +export default withI18n()(ScheduleList); diff --git a/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleList.test.jsx b/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleList.test.jsx new file mode 100644 index 0000000000..aaece78d10 --- /dev/null +++ b/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleList.test.jsx @@ -0,0 +1,163 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { SchedulesAPI } from '@api'; +import ScheduleList from './ScheduleList'; +import mockSchedules from '../data.schedules.json'; + +jest.mock('@api/models/Schedules'); + +SchedulesAPI.destroy = jest.fn(); +SchedulesAPI.update.mockResolvedValue({ + data: mockSchedules.results[0], +}); + +describe('ScheduleList', () => { + let wrapper; + + afterAll(() => { + jest.clearAllMocks(); + }); + + describe('read call successful', () => { + beforeAll(async () => { + SchedulesAPI.read.mockResolvedValue({ data: mockSchedules }); + await act(async () => { + wrapper = mountWithContexts(); + }); + wrapper.update(); + }); + + afterAll(() => { + wrapper.unmount(); + }); + + test('should fetch schedules from api and render the list', () => { + expect(SchedulesAPI.read).toHaveBeenCalled(); + expect(wrapper.find('ScheduleListItem').length).toBe(5); + }); + + test('should check and uncheck the row item', async () => { + expect( + wrapper.find('PFDataListCheck[id="select-schedule-1"]').props().checked + ).toBe(false); + await act(async () => { + wrapper + .find('PFDataListCheck[id="select-schedule-1"]') + .invoke('onChange')(true); + }); + wrapper.update(); + expect( + wrapper.find('PFDataListCheck[id="select-schedule-1"]').props().checked + ).toBe(true); + await act(async () => { + wrapper + .find('PFDataListCheck[id="select-schedule-1"]') + .invoke('onChange')(false); + }); + wrapper.update(); + expect( + wrapper.find('PFDataListCheck[id="select-schedule-1"]').props().checked + ).toBe(false); + }); + + test('should check all row items when select all is checked', async () => { + wrapper.find('PFDataListCheck').forEach(el => { + expect(el.props().checked).toBe(false); + }); + await act(async () => { + wrapper.find('Checkbox#select-all').invoke('onChange')(true); + }); + wrapper.update(); + wrapper.find('PFDataListCheck').forEach(el => { + expect(el.props().checked).toBe(true); + }); + await act(async () => { + wrapper.find('Checkbox#select-all').invoke('onChange')(false); + }); + wrapper.update(); + wrapper.find('PFDataListCheck').forEach(el => { + expect(el.props().checked).toBe(false); + }); + }); + + test('should call api delete schedules for each selected schedule', async () => { + await act(async () => { + wrapper + .find('PFDataListCheck[id="select-schedule-3"]') + .invoke('onChange')(); + }); + wrapper.update(); + await act(async () => { + wrapper.find('ToolbarDeleteButton').invoke('onDelete')(); + }); + wrapper.update(); + expect(SchedulesAPI.destroy).toHaveBeenCalledTimes(1); + }); + + test('should show error modal when schedule is not successfully deleted from api', async () => { + SchedulesAPI.destroy.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + expect(wrapper.find('Modal').length).toBe(0); + await act(async () => { + wrapper + .find('PFDataListCheck[id="select-schedule-2"]') + .invoke('onChange')(); + }); + wrapper.update(); + await act(async () => { + wrapper.find('ToolbarDeleteButton').invoke('onDelete')(); + }); + wrapper.update(); + expect(wrapper.find('Modal').length).toBe(1); + await act(async () => { + wrapper.find('ModalBoxCloseButton').invoke('onClose')(); + }); + wrapper.update(); + expect(wrapper.find('Modal').length).toBe(0); + }); + + test('should call api update schedules when toggle clicked', async () => { + await act(async () => { + wrapper + .find('Switch[id="schedule-5-toggle"]') + .first() + .invoke('onChange')(); + }); + wrapper.update(); + expect(SchedulesAPI.update).toHaveBeenCalledTimes(1); + }); + + test('should show error modal when schedule is not successfully updated on toggle', async () => { + SchedulesAPI.update.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + expect(wrapper.find('Modal').length).toBe(0); + await act(async () => { + wrapper + .find('Switch[id="schedule-1-toggle"]') + .first() + .invoke('onChange')(); + }); + wrapper.update(); + expect(wrapper.find('Modal').length).toBe(1); + await act(async () => { + wrapper.find('ModalBoxCloseButton').invoke('onClose')(); + }); + wrapper.update(); + expect(wrapper.find('Modal').length).toBe(0); + }); + }); + + describe('read call unsuccessful', () => { + test('should show content error when read call unsuccessful', async () => { + SchedulesAPI.read.mockRejectedValue(new Error()); + await act(async () => { + wrapper = mountWithContexts(); + }); + wrapper.update(); + expect(wrapper.find('ContentError').length).toBe(1); + }); + }); +}); diff --git a/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleListItem.jsx b/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleListItem.jsx new file mode 100644 index 0000000000..1538d1ac91 --- /dev/null +++ b/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleListItem.jsx @@ -0,0 +1,157 @@ +import React from 'react'; +import { bool, func } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Link } from 'react-router-dom'; +import { + DataListItem, + DataListItemRow, + DataListItemCells as _DataListItemCells, + Tooltip, +} from '@patternfly/react-core'; +import { PencilAltIcon } from '@patternfly/react-icons'; + +import ActionButtonCell from '@components/ActionButtonCell'; +import { DetailList, Detail } from '@components/DetailList'; +import DataListCell from '@components/DataListCell'; +import DataListCheck from '@components/DataListCheck'; +import ListActionButton from '@components/ListActionButton'; +import Switch from '@components/Switch'; +import VerticalSeparator from '@components/VerticalSeparator'; +import styled from 'styled-components'; +import { Schedule } from '@types'; +import { formatDateString } from '@util/dates'; + +const DataListItemCells = styled(_DataListItemCells)` + ${DataListCell}:first-child { + flex-grow: 2; + } +`; + +function ScheduleListItem({ + i18n, + isSelected, + onSelect, + onToggleSchedule, + schedule, + toggleLoading, +}) { + const labelId = `check-action-${schedule.id}`; + + const jobTypeLabels = { + inventory_update: i18n._(t`Inventory Sync`), + job: i18n._(t`Playbook Run`), + project_update: i18n._(t`SCM Update`), + system_job: i18n._(t`Management Job`), + workflow_job: i18n._(t`Workflow Job`), + }; + + let scheduleBaseUrl; + + switch (schedule.summary_fields.unified_job_template.unified_job_type) { + case 'inventory_update': + scheduleBaseUrl = `/inventories/${schedule.summary_fields.inventory.id}/sources/${schedule.summary_fields.unified_job_template.id}/schedules/${schedule.id}`; + break; + case 'job': + scheduleBaseUrl = `/templates/job_template/${schedule.summary_fields.unified_job_template.id}/schedules/${schedule.id}`; + break; + case 'project_update': + scheduleBaseUrl = `/projects/${schedule.summary_fields.unified_job_template.id}/schedules/${schedule.id}`; + break; + case 'system_job': + scheduleBaseUrl = `/management_jobs/${schedule.summary_fields.unified_job_template.id}/schedules/${schedule.id}`; + break; + case 'workflow_job': + scheduleBaseUrl = `/templates/workflow_job_template/${schedule.summary_fields.unified_job_template.id}/schedules/${schedule.id}`; + break; + default: + break; + } + + return ( + + + + + + + {schedule.name} + + , + + { + jobTypeLabels[ + schedule.summary_fields.unified_job_template.unified_job_type + ] + } + , + + {schedule.next_run && ( + + + + )} + , + + + onToggleSchedule(schedule)} + aria-label={i18n._(t`Toggle schedule`)} + /> + + {schedule.summary_fields.user_capabilities.edit && ( + + + + + + )} + , + ]} + /> + + + ); +} + +ScheduleListItem.propTypes = { + isSelected: bool.isRequired, + onToggleSchedule: func.isRequired, + onSelect: func.isRequired, + schedule: Schedule.isRequired, +}; + +export default withI18n()(ScheduleListItem); diff --git a/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleListItem.test.jsx b/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleListItem.test.jsx new file mode 100644 index 0000000000..df2b55bcbb --- /dev/null +++ b/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleListItem.test.jsx @@ -0,0 +1,180 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import ScheduleListItem from './ScheduleListItem'; + +const mockSchedule = { + rrule: + 'DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1', + id: 6, + type: 'schedule', + url: '/api/v2/schedules/6/', + related: {}, + summary_fields: { + unified_job_template: { + id: 12, + name: 'Mock JT', + description: '', + unified_job_type: 'job', + }, + user_capabilities: { + edit: true, + delete: true, + }, + }, + created: '2020-02-12T21:05:08.460029Z', + modified: '2020-02-12T21:05:52.840596Z', + name: 'Mock Schedule', + description: 'every day for 1 time', + extra_data: {}, + inventory: null, + scm_branch: null, + job_type: null, + job_tags: null, + skip_tags: null, + limit: null, + diff_mode: null, + verbosity: null, + unified_job_template: 12, + enabled: true, + dtstart: '2020-02-20T05:00:00Z', + dtend: '2020-02-20T05:00:00Z', + next_run: '2020-02-20T05:00:00Z', + timezone: 'America/New_York', + until: '', +}; + +const onToggleSchedule = jest.fn(); +const onSelect = jest.fn(); + +describe('ScheduleListItem', () => { + let wrapper; + describe('User has edit permissions', () => { + beforeAll(() => { + wrapper = mountWithContexts( + + ); + }); + afterAll(() => { + wrapper.unmount(); + }); + test('Name correctly shown with correct link', () => { + expect( + wrapper + .find('DataListCell') + .first() + .text() + ).toBe('Mock Schedule'); + expect( + wrapper + .find('DataListCell') + .first() + .find('Link') + .props().to + ).toBe('/templates/job_template/12/schedules/6/details'); + }); + test('Type correctly shown', () => { + expect( + wrapper + .find('DataListCell') + .at(2) + .text() + ).toBe('Playbook Run'); + }); + test('Edit button shown with correct link', () => { + expect(wrapper.find('PencilAltIcon').length).toBe(1); + expect( + wrapper + .find('ListActionButton') + .find('Link') + .props().to + ).toBe('/templates/job_template/12/schedules/6/edit'); + }); + test('Toggle button enabled', () => { + expect( + wrapper + .find('Switch') + .first() + .props().isDisabled + ).toBe(false); + }); + test('Clicking toggle makes expected callback', () => { + wrapper + .find('Switch') + .first() + .find('input') + .simulate('change'); + expect(onToggleSchedule).toHaveBeenCalledWith(mockSchedule); + }); + test('Clicking checkbox makes expected callback', () => { + wrapper + .find('PFDataListCheck') + .first() + .find('input') + .simulate('change'); + expect(onSelect).toHaveBeenCalledTimes(1); + }); + }); + describe('User has read-only permissions', () => { + beforeAll(() => { + wrapper = mountWithContexts( + + ); + }); + afterAll(() => { + wrapper.unmount(); + }); + test('Name correctly shown with correct link', () => { + expect( + wrapper + .find('DataListCell') + .first() + .text() + ).toBe('Mock Schedule'); + expect( + wrapper + .find('DataListCell') + .first() + .find('Link') + .props().to + ).toBe('/templates/job_template/12/schedules/6/details'); + }); + test('Type correctly shown', () => { + expect( + wrapper + .find('DataListCell') + .at(2) + .text() + ).toBe('Playbook Run'); + }); + test('Edit button hidden', () => { + expect(wrapper.find('PencilAltIcon').length).toBe(0); + }); + test('Toggle button disabled', () => { + expect( + wrapper + .find('Switch') + .first() + .props().isDisabled + ).toBe(true); + }); + }); +}); diff --git a/awx/ui_next/src/screens/Schedule/ScheduleList/index.js b/awx/ui_next/src/screens/Schedule/ScheduleList/index.js new file mode 100644 index 0000000000..4f6af384b5 --- /dev/null +++ b/awx/ui_next/src/screens/Schedule/ScheduleList/index.js @@ -0,0 +1,2 @@ +export { default as ScheduleList } from './ScheduleList'; +export { default as ScheduleListItem } from './ScheduleListItem'; diff --git a/awx/ui_next/src/screens/Schedule/Schedules.jsx b/awx/ui_next/src/screens/Schedule/Schedules.jsx index b9375f86e8..930b115ecd 100644 --- a/awx/ui_next/src/screens/Schedule/Schedules.jsx +++ b/awx/ui_next/src/screens/Schedule/Schedules.jsx @@ -1,26 +1,26 @@ -import React, { Component, Fragment } from 'react'; +import React from 'react'; +import { Route, Switch } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { - PageSection, - PageSectionVariants, - Title, -} from '@patternfly/react-core'; -class Schedules extends Component { - render() { - const { i18n } = this.props; - const { light } = PageSectionVariants; +import Breadcrumbs from '@components/Breadcrumbs'; +import { ScheduleList } from './ScheduleList'; - return ( - - - {i18n._(t`Schedules`)} - - - - ); - } +function Schedules({ i18n }) { + return ( + <> + + + + + + + + ); } export default withI18n()(Schedules); diff --git a/awx/ui_next/src/screens/Schedule/Schedules.test.jsx b/awx/ui_next/src/screens/Schedule/Schedules.test.jsx index 5296ecf689..ebdc67a046 100644 --- a/awx/ui_next/src/screens/Schedule/Schedules.test.jsx +++ b/awx/ui_next/src/screens/Schedule/Schedules.test.jsx @@ -1,29 +1,36 @@ import React from 'react'; - import { mountWithContexts } from '@testUtils/enzymeHelpers'; - +import { createMemoryHistory } from 'history'; import Schedules from './Schedules'; describe('', () => { - let pageWrapper; - let pageSections; - let title; - - beforeEach(() => { - pageWrapper = mountWithContexts(); - pageSections = pageWrapper.find('PageSection'); - title = pageWrapper.find('Title'); - }); + let wrapper; afterEach(() => { - pageWrapper.unmount(); + wrapper.unmount(); }); - test('initially renders without crashing', () => { - expect(pageWrapper.length).toBe(1); - expect(pageSections.length).toBe(2); - expect(title.length).toBe(1); - expect(title.props().size).toBe('2xl'); - expect(pageSections.first().props().variant).toBe('light'); + test('initially renders succesfully', () => { + wrapper = mountWithContexts(); + }); + + test('should display schedule list breadcrumb heading', () => { + const history = createMemoryHistory({ + initialEntries: ['/schedules'], + }); + + wrapper = mountWithContexts(, { + context: { + router: { + history, + route: { + location: history.location, + }, + }, + }, + }); + + expect(wrapper.find('Crumb').length).toBe(1); + expect(wrapper.find('BreadcrumbHeading').text()).toBe('Schedules'); }); }); diff --git a/awx/ui_next/src/screens/Schedule/data.schedules.json b/awx/ui_next/src/screens/Schedule/data.schedules.json new file mode 100644 index 0000000000..13ef941811 --- /dev/null +++ b/awx/ui_next/src/screens/Schedule/data.schedules.json @@ -0,0 +1,106 @@ +{ + "count": 5, + "next": "/api/v2/schedules/", + "previous": null, + "results": [ + { + "url": "/api/v2/schedules/1", + "rrule": + "DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1", + "id": 1, + "summary_fields": { + "unified_job_template": { + "id": 6, + "name": "Mock JT", + "description": "", + "unified_job_type": "job" + }, + "user_capabilities": { + "edit": true, + "delete": true + } + }, + "name": "Mock JT Schedule", + "next_run": "2020-02-20T05:00:00Z" + }, { + "url": "/api/v2/schedules/2", + "rrule": + "DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1", + "id": 2, + "summary_fields": { + "unified_job_template": { + "id": 7, + "name": "Mock WFJT", + "description": "", + "unified_job_type": "workflow_job" + }, + "user_capabilities": { + "edit": true, + "delete": true + } + }, + "name": "Mock WFJT Schedule", + "next_run": "2020-02-20T05:00:00Z" + }, { + "url": "/api/v2/schedules/3", + "rrule": + "DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1", + "id": 3, + "summary_fields": { + "unified_job_template": { + "id": 8, + "name": "Mock Project Update", + "description": "", + "unified_job_type": "project_update" + }, + "user_capabilities": { + "edit": true, + "delete": true + } + }, + "name": "Mock Project Update Schedule", + "next_run": "2020-02-20T05:00:00Z" + }, { + "url": "/api/v2/schedules/4", + "rrule": + "DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1", + "id": 4, + "summary_fields": { + "unified_job_template": { + "id": 9, + "name": "Mock System Job", + "description": "", + "unified_job_type": "system_job" + }, + "user_capabilities": { + "edit": true, + "delete": true + } + }, + "name": "Mock System Job Schedule", + "next_run": "2020-02-20T05:00:00Z" + }, { + "url": "/api/v2/schedules/5", + "rrule": + "DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1", + "id": 5, + "summary_fields": { + "unified_job_template": { + "id": 10, + "name": "Mock Inventory Update", + "description": "", + "unified_job_type": "inventory_update" + }, + "user_capabilities": { + "edit": true, + "delete": true + }, + "inventory": { + "id": 11 + } + }, + "name": "Mock Inventory Update Schedule", + "next_run": "2020-02-20T05:00:00Z" + } + ] +} \ No newline at end of file diff --git a/awx/ui_next/src/types.js b/awx/ui_next/src/types.js index 1c072c8383..2ef6f718ba 100644 --- a/awx/ui_next/src/types.js +++ b/awx/ui_next/src/types.js @@ -271,3 +271,32 @@ export const SortColumns = arrayOf( key: string.isRequired, }) ); + +export const Schedule = shape({ + rrule: string.isRequired, + id: number.isRequired, + type: string, + url: string, + related: shape({}), + summary_fields: shape({}), + created: string, + modified: string, + name: string.isRequired, + description: string, + extra_data: shape({}), + inventory: number, + scm_branch: string, + job_type: string, + job_tags: string, + skip_tags: string, + limit: string, + diff_mode: bool, + verbosity: string, + unified_job_template: number, + enabled: bool, + dtstart: string, + dtend: string, + next_run: string, + timezone: string, + until: string, +}); From e6e31a9fc653258a31c02353c048faad2daef3af Mon Sep 17 00:00:00 2001 From: mabashian Date: Fri, 21 Feb 2020 12:40:21 -0500 Subject: [PATCH 2/3] Updates after removing PF overrides to list components. --- .../ScheduleList/ScheduleList.test.jsx | 20 +++++----- .../ScheduleList/ScheduleListItem.jsx | 37 +++++++++++-------- .../ScheduleList/ScheduleListItem.test.jsx | 8 ++-- awx/ui_next/src/types.js | 2 +- 4 files changed, 36 insertions(+), 31 deletions(-) diff --git a/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleList.test.jsx b/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleList.test.jsx index aaece78d10..706f5f1bca 100644 --- a/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleList.test.jsx +++ b/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleList.test.jsx @@ -39,44 +39,44 @@ describe('ScheduleList', () => { test('should check and uncheck the row item', async () => { expect( - wrapper.find('PFDataListCheck[id="select-schedule-1"]').props().checked + wrapper.find('DataListCheck[id="select-schedule-1"]').props().checked ).toBe(false); await act(async () => { wrapper - .find('PFDataListCheck[id="select-schedule-1"]') + .find('DataListCheck[id="select-schedule-1"]') .invoke('onChange')(true); }); wrapper.update(); expect( - wrapper.find('PFDataListCheck[id="select-schedule-1"]').props().checked + wrapper.find('DataListCheck[id="select-schedule-1"]').props().checked ).toBe(true); await act(async () => { wrapper - .find('PFDataListCheck[id="select-schedule-1"]') + .find('DataListCheck[id="select-schedule-1"]') .invoke('onChange')(false); }); wrapper.update(); expect( - wrapper.find('PFDataListCheck[id="select-schedule-1"]').props().checked + wrapper.find('DataListCheck[id="select-schedule-1"]').props().checked ).toBe(false); }); test('should check all row items when select all is checked', async () => { - wrapper.find('PFDataListCheck').forEach(el => { + wrapper.find('DataListCheck').forEach(el => { expect(el.props().checked).toBe(false); }); await act(async () => { wrapper.find('Checkbox#select-all').invoke('onChange')(true); }); wrapper.update(); - wrapper.find('PFDataListCheck').forEach(el => { + wrapper.find('DataListCheck').forEach(el => { expect(el.props().checked).toBe(true); }); await act(async () => { wrapper.find('Checkbox#select-all').invoke('onChange')(false); }); wrapper.update(); - wrapper.find('PFDataListCheck').forEach(el => { + wrapper.find('DataListCheck').forEach(el => { expect(el.props().checked).toBe(false); }); }); @@ -84,7 +84,7 @@ describe('ScheduleList', () => { test('should call api delete schedules for each selected schedule', async () => { await act(async () => { wrapper - .find('PFDataListCheck[id="select-schedule-3"]') + .find('DataListCheck[id="select-schedule-3"]') .invoke('onChange')(); }); wrapper.update(); @@ -102,7 +102,7 @@ describe('ScheduleList', () => { expect(wrapper.find('Modal').length).toBe(0); await act(async () => { wrapper - .find('PFDataListCheck[id="select-schedule-2"]') + .find('DataListCheck[id="select-schedule-2"]') .invoke('onChange')(); }); wrapper.update(); diff --git a/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleListItem.jsx b/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleListItem.jsx index 1538d1ac91..0e6207ebe8 100644 --- a/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleListItem.jsx +++ b/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleListItem.jsx @@ -4,28 +4,28 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Link } from 'react-router-dom'; import { + Button, + DataListAction as _DataListAction, + DataListCell, + DataListCheck, DataListItem, DataListItemRow, - DataListItemCells as _DataListItemCells, + DataListItemCells, + Switch, Tooltip, } from '@patternfly/react-core'; import { PencilAltIcon } from '@patternfly/react-icons'; -import ActionButtonCell from '@components/ActionButtonCell'; import { DetailList, Detail } from '@components/DetailList'; -import DataListCell from '@components/DataListCell'; -import DataListCheck from '@components/DataListCheck'; -import ListActionButton from '@components/ListActionButton'; -import Switch from '@components/Switch'; -import VerticalSeparator from '@components/VerticalSeparator'; import styled from 'styled-components'; import { Schedule } from '@types'; import { formatDateString } from '@util/dates'; -const DataListItemCells = styled(_DataListItemCells)` - ${DataListCell}:first-child { - flex-grow: 2; - } +const DataListAction = styled(_DataListAction)` + align-items: center; + display: grid; + grid-gap: 16px; + grid-template-columns: auto 40px; `; function ScheduleListItem({ @@ -84,7 +84,6 @@ function ScheduleListItem({ - {schedule.name} @@ -106,7 +105,12 @@ function ScheduleListItem({ )} , - + {schedule.summary_fields.user_capabilities.edit && ( - - + )} - , + , ]} /> diff --git a/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleListItem.test.jsx b/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleListItem.test.jsx index df2b55bcbb..a835345edc 100644 --- a/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleListItem.test.jsx +++ b/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleListItem.test.jsx @@ -81,7 +81,7 @@ describe('ScheduleListItem', () => { expect( wrapper .find('DataListCell') - .at(2) + .at(1) .text() ).toBe('Playbook Run'); }); @@ -89,7 +89,7 @@ describe('ScheduleListItem', () => { expect(wrapper.find('PencilAltIcon').length).toBe(1); expect( wrapper - .find('ListActionButton') + .find('Button') .find('Link') .props().to ).toBe('/templates/job_template/12/schedules/6/edit'); @@ -112,7 +112,7 @@ describe('ScheduleListItem', () => { }); test('Clicking checkbox makes expected callback', () => { wrapper - .find('PFDataListCheck') + .find('DataListCheck') .first() .find('input') .simulate('change'); @@ -161,7 +161,7 @@ describe('ScheduleListItem', () => { expect( wrapper .find('DataListCell') - .at(2) + .at(1) .text() ).toBe('Playbook Run'); }); diff --git a/awx/ui_next/src/types.js b/awx/ui_next/src/types.js index 2ef6f718ba..b4270c707d 100644 --- a/awx/ui_next/src/types.js +++ b/awx/ui_next/src/types.js @@ -283,7 +283,7 @@ export const Schedule = shape({ modified: string, name: string.isRequired, description: string, - extra_data: shape({}), + extra_data: oneOfType([string, shape({})]), inventory: number, scm_branch: string, job_type: string, From e6f0c01aa666d9a09ddf8b91bd18c4e27c555ba9 Mon Sep 17 00:00:00 2001 From: mabashian Date: Fri, 21 Feb 2020 16:13:21 -0500 Subject: [PATCH 3/3] Schedule list now uses useRequest hooks for fetching and deleting. Also rolled a component for schedule toggles that can be used throughout the tree. --- .../Schedule/ScheduleList/ScheduleList.jsx | 132 ++++++------------ .../ScheduleList/ScheduleListItem.jsx | 35 +---- .../ScheduleList/ScheduleListItem.test.jsx | 11 -- .../Schedule/shared/ScheduleToggle.jsx | 78 +++++++++++ .../Schedule/shared/ScheduleToggle.test.jsx | 97 +++++++++++++ 5 files changed, 223 insertions(+), 130 deletions(-) create mode 100644 awx/ui_next/src/screens/Schedule/shared/ScheduleToggle.jsx create mode 100644 awx/ui_next/src/screens/Schedule/shared/ScheduleToggle.test.jsx diff --git a/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleList.jsx b/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleList.jsx index 36b0b9ccdd..2c1197e7c7 100644 --- a/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleList.jsx +++ b/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleList.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; @@ -10,6 +10,7 @@ import DataListToolbar from '@components/DataListToolbar'; import PaginatedDataList, { ToolbarDeleteButton, } from '@components/PaginatedDataList'; +import useRequest, { useDeleteItems } from '@util/useRequest'; import { getQSConfig, parseQueryString } from '@util/qs'; import { ScheduleListItem } from '.'; @@ -20,38 +21,54 @@ const QS_CONFIG = getQSConfig('schedule', { }); function ScheduleList({ i18n }) { - const [contentError, setContentError] = useState(null); - const [scheduleCount, setScheduleCount] = useState(0); - const [schedules, setSchedules] = useState([]); - const [deletionError, setDeletionError] = useState(null); - const [hasContentLoading, setHasContentLoading] = useState(true); const [selected, setSelected] = useState([]); - const [toggleError, setToggleError] = useState(null); - const [toggleLoading, setToggleLoading] = useState(null); const location = useLocation(); - const loadSchedules = async ({ search }) => { - const params = parseQueryString(QS_CONFIG, search); - setContentError(null); - setHasContentLoading(true); - try { + const { + result: { schedules, itemCount }, + error: contentError, + isLoading, + request: fetchSchedules, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); const { data: { count, results }, } = await SchedulesAPI.read(params); - - setSchedules(results); - setScheduleCount(count); - } catch (error) { - setContentError(error); - } finally { - setHasContentLoading(false); + return { + itemCount: count, + schedules: results, + }; + }, [location]), + { + schedules: [], + itemCount: 0, } - }; + ); useEffect(() => { - loadSchedules(location); - }, [location]); // eslint-disable-line react-hooks/exhaustive-deps + fetchSchedules(); + }, [fetchSchedules]); + + const isAllSelected = + selected.length === schedules.length && selected.length > 0; + + const { + isLoading: isDeleteLoading, + deleteItems: deleteJobs, + deletionError, + clearDeletionError, + } = useDeleteItems( + useCallback(async () => { + return Promise.all(selected.map(({ id }) => SchedulesAPI.destroy(id))); + }, [selected]), + { + qsConfig: QS_CONFIG, + allItemsSelected: isAllSelected, + fetchItems: fetchSchedules, + } + ); const handleSelectAll = isSelected => { setSelected(isSelected ? [...schedules] : []); @@ -66,64 +83,18 @@ function ScheduleList({ i18n }) { }; const handleDelete = async () => { - setHasContentLoading(true); - - try { - await Promise.all( - selected.map(schedule => SchedulesAPI.destroy(schedule.id)) - ); - } catch (error) { - setDeletionError(error); - } - - const params = parseQueryString(QS_CONFIG, location.search); - try { - const { - data: { count, results }, - } = await SchedulesAPI.read(params); - - setSchedules(results); - setScheduleCount(count); - setSelected([]); - } catch (error) { - setContentError(error); - } - - setHasContentLoading(false); + await deleteJobs(); + setSelected([]); }; - const handleScheduleToggle = async scheduleToToggle => { - setToggleLoading(scheduleToToggle.id); - try { - const { data: updatedSchedule } = await SchedulesAPI.update( - scheduleToToggle.id, - { - enabled: !scheduleToToggle.enabled, - } - ); - setSchedules( - schedules.map(schedule => - schedule.id === updatedSchedule.id ? updatedSchedule : schedule - ) - ); - } catch (err) { - setToggleError(err); - } finally { - setToggleLoading(null); - } - }; - - const isAllSelected = - selected.length > 0 && selected.length === schedules.length; - return ( ( @@ -131,9 +102,7 @@ function ScheduleList({ i18n }) { isSelected={selected.some(row => row.id === item.id)} key={item.id} onSelect={() => handleSelect(item)} - onToggleSchedule={handleScheduleToggle} schedule={item} - toggleLoading={toggleLoading === item.id} /> )} toolbarSearchColumns={[ @@ -176,23 +145,12 @@ function ScheduleList({ i18n }) { )} /> - {toggleError && !toggleLoading && ( - setToggleError(null)} - > - {i18n._(t`Failed to toggle schedule.`)} - - - )} {deletionError && ( setDeletionError(null)} + onClose={clearDeletionError} > {i18n._(t`Failed to delete one or more schedules.`)} diff --git a/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleListItem.jsx b/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleListItem.jsx index 0e6207ebe8..7d34f8b52d 100644 --- a/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleListItem.jsx +++ b/awx/ui_next/src/screens/Schedule/ScheduleList/ScheduleListItem.jsx @@ -11,15 +11,14 @@ import { DataListItem, DataListItemRow, DataListItemCells, - Switch, Tooltip, } from '@patternfly/react-core'; import { PencilAltIcon } from '@patternfly/react-icons'; - import { DetailList, Detail } from '@components/DetailList'; import styled from 'styled-components'; import { Schedule } from '@types'; import { formatDateString } from '@util/dates'; +import ScheduleToggle from '../shared/ScheduleToggle'; const DataListAction = styled(_DataListAction)` align-items: center; @@ -28,14 +27,7 @@ const DataListAction = styled(_DataListAction)` grid-template-columns: auto 40px; `; -function ScheduleListItem({ - i18n, - isSelected, - onSelect, - onToggleSchedule, - schedule, - toggleLoading, -}) { +function ScheduleListItem({ i18n, isSelected, onSelect, schedule }) { const labelId = `check-action-${schedule.id}`; const jobTypeLabels = { @@ -111,27 +103,7 @@ function ScheduleListItem({ id={labelId} key="actions" > - - onToggleSchedule(schedule)} - aria-label={i18n._(t`Toggle schedule`)} - /> - + {schedule.summary_fields.user_capabilities.edit && (