diff --git a/awx/ui_next/package-lock.json b/awx/ui_next/package-lock.json index 7c8b645522..75b693e4d8 100644 --- a/awx/ui_next/package-lock.json +++ b/awx/ui_next/package-lock.json @@ -13202,6 +13202,12 @@ "yallist": "^2.1.2" } }, + "luxon": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.22.0.tgz", + "integrity": "sha512-3sLvlfbFo+AxVEY3IqxymbumtnlgBwjDExxK60W3d+trrUzErNAz/PfvPT+mva+vEUrdIodeCOs7fB6zHtRSrw==", + "optional": true + }, "make-dir": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", @@ -16217,6 +16223,22 @@ "inherits": "^2.0.1" } }, + "rrule": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.6.4.tgz", + "integrity": "sha512-sLdnh4lmjUqq8liFiOUXD5kWp/FcnbDLPwq5YAc/RrN6120XOPb86Ae5zxF7ttBVq8O3LxjjORMEit1baluahA==", + "requires": { + "luxon": "^1.21.3", + "tslib": "^1.10.0" + }, + "dependencies": { + "tslib": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.11.1.tgz", + "integrity": "sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA==" + } + } + }, "rst-selector-parser": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz", diff --git a/awx/ui_next/package.json b/awx/ui_next/package.json index cdd79cd2d7..2d290c7c15 100644 --- a/awx/ui_next/package.json +++ b/awx/ui_next/package.json @@ -77,6 +77,7 @@ "react-dom": "^16.13.0", "react-router-dom": "^5.1.2", "react-virtualized": "^9.21.1", + "rrule": "^2.6.4", "styled-components": "^4.2.0" } } diff --git a/awx/ui_next/src/api/models/Schedules.js b/awx/ui_next/src/api/models/Schedules.js index e5581d2875..01bff4891e 100644 --- a/awx/ui_next/src/api/models/Schedules.js +++ b/awx/ui_next/src/api/models/Schedules.js @@ -5,6 +5,14 @@ class Schedules extends Base { super(http); this.baseUrl = '/api/v2/schedules/'; } + + createPreview(data) { + return this.http.post(`${this.baseUrl}preview/`, data); + } + + readCredentials(resourceId, params) { + return this.http.get(`${this.baseUrl}${resourceId}/credentials/`, params); + } } export default Schedules; diff --git a/awx/ui_next/src/components/MultiButtonToggle/MultiButtonToggle.jsx b/awx/ui_next/src/components/MultiButtonToggle/MultiButtonToggle.jsx index 9c72cfbab3..b76c4c6a61 100644 --- a/awx/ui_next/src/components/MultiButtonToggle/MultiButtonToggle.jsx +++ b/awx/ui_next/src/components/MultiButtonToggle/MultiButtonToggle.jsx @@ -21,6 +21,7 @@ function MultiButtonToggle({ buttons, value, onChange }) { {buttons && buttons.map(([buttonValue, buttonLabel]) => ( setValue(buttonValue)} variant={buttonValue === value ? 'primary' : 'secondary'} diff --git a/awx/ui_next/src/components/Schedule/Schedule.jsx b/awx/ui_next/src/components/Schedule/Schedule.jsx new file mode 100644 index 0000000000..852f811b40 --- /dev/null +++ b/awx/ui_next/src/components/Schedule/Schedule.jsx @@ -0,0 +1,140 @@ +import React, { useEffect, useState } from 'react'; +import { t } from '@lingui/macro'; +import { withI18n } from '@lingui/react'; + +import { + Switch, + Route, + Link, + Redirect, + useLocation, + useParams, +} from 'react-router-dom'; +import { CardActions } from '@patternfly/react-core'; +import { CaretLeftIcon } from '@patternfly/react-icons'; +import CardCloseButton from '@components/CardCloseButton'; +import RoutedTabs from '@components/RoutedTabs'; +import ContentError from '@components/ContentError'; +import ContentLoading from '@components/ContentLoading'; +import { TabbedCardHeader } from '@components/Card'; +import { ScheduleDetail } from '@components/Schedule'; +import { SchedulesAPI } from '@api'; + +function Schedule({ i18n, setBreadcrumb, unifiedJobTemplate }) { + const [schedule, setSchedule] = useState(null); + const [contentLoading, setContentLoading] = useState(true); + const [contentError, setContentError] = useState(null); + const { scheduleId } = useParams(); + const location = useLocation(); + const { pathname } = location; + const pathRoot = pathname.substr(0, pathname.indexOf('schedules')); + + useEffect(() => { + const loadData = async () => { + try { + const { data } = await SchedulesAPI.readDetail(scheduleId); + setSchedule(data); + setBreadcrumb(unifiedJobTemplate, data); + } catch (err) { + setContentError(err); + } finally { + setContentLoading(false); + } + }; + + loadData(); + }, [location.pathname, scheduleId, unifiedJobTemplate, setBreadcrumb]); + + const tabsArray = [ + { + name: ( + <> + + {i18n._(t`Back to Schedules`)} + + ), + link: `${pathRoot}schedules`, + id: 99, + }, + { + name: i18n._(t`Details`), + link: `${pathRoot}schedules/${schedule && schedule.id}/details`, + id: 0, + }, + ]; + + if (contentLoading) { + return ; + } + + if ( + schedule.summary_fields.unified_job_template.id !== + parseInt(unifiedJobTemplate.id, 10) + ) { + return ( + + {schedule && ( + {i18n._(t`View Schedules`)} + )} + + ); + } + + if (contentError) { + return ; + } + + let cardHeader = null; + if ( + location.pathname.includes('schedules/') && + !location.pathname.endsWith('edit') + ) { + cardHeader = ( + + + + + + + ); + } + return ( + <> + {cardHeader} + + + {schedule && [ + { + return ; + }} + />, + ]} + { + return ( + + {unifiedJobTemplate && ( + + {i18n._(t`View Details`)} + + )} + + ); + }} + /> + + + ); +} + +export { Schedule as _Schedule }; +export default withI18n()(Schedule); diff --git a/awx/ui_next/src/components/Schedule/Schedule.test.jsx b/awx/ui_next/src/components/Schedule/Schedule.test.jsx new file mode 100644 index 0000000000..3ed3c5b025 --- /dev/null +++ b/awx/ui_next/src/components/Schedule/Schedule.test.jsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { SchedulesAPI } from '@api'; +import { Route } from 'react-router-dom'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import Schedule from './Schedule'; + +jest.mock('@api/models/Schedules'); + +SchedulesAPI.readDetail.mockResolvedValue({ + data: { + 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: 1, + name: 'Mock JT', + description: '', + unified_job_type: 'job', + }, + user_capabilities: { + edit: true, + delete: true, + }, + created_by: { + id: 1, + username: 'admin', + first_name: '', + last_name: '', + }, + modified_by: { + id: 1, + username: 'admin', + first_name: '', + last_name: '', + }, + }, + created: '2020-03-03T20:38:54.210306Z', + modified: '2020-03-03T20:38:54.210336Z', + name: 'Mock JT Schedule', + next_run: '2020-02-20T05:00:00Z', + }, +}); + +SchedulesAPI.createPreview.mockResolvedValue({ + data: { + local: [], + utc: [], + }, +}); + +SchedulesAPI.readCredentials.mockResolvedValue({ + data: { + count: 0, + results: [], + }, +}); + +describe('', () => { + let wrapper; + let history; + const unifiedJobTemplate = { id: 1, name: 'Mock JT' }; + beforeAll(async () => { + history = createMemoryHistory({ + initialEntries: ['/templates/job_template/1/schedules/1/details'], + }); + await act(async () => { + wrapper = mountWithContexts( + ( + {}} + unifiedJobTemplate={unifiedJobTemplate} + /> + )} + />, + { + context: { + router: { + history, + route: { + location: history.location, + match: { + params: { id: 1 }, + }, + }, + }, + }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + afterAll(() => { + wrapper.unmount(); + }); + test('renders successfully', async () => { + expect(wrapper.length).toBe(1); + }); + test('expect all tabs to exist, including Back to Schedules', async () => { + expect( + wrapper.find('button[link="/templates/job_template/1/schedules"]').length + ).toBe(1); + expect(wrapper.find('button[aria-label="Details"]').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx new file mode 100644 index 0000000000..c6ae0b97c4 --- /dev/null +++ b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx @@ -0,0 +1,202 @@ +import React, { useCallback, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { rrulestr } from 'rrule'; +import styled from 'styled-components'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Schedule } from '@types'; +import { Chip, ChipGroup, Title } from '@patternfly/react-core'; +import { CardBody } from '@components/Card'; +import ContentError from '@components/ContentError'; +import ContentLoading from '@components/ContentLoading'; +import CredentialChip from '@components/CredentialChip'; +import { DetailList, Detail, UserDateDetail } from '@components/DetailList'; +import { ScheduleOccurrences, ScheduleToggle } from '@components/Schedule'; +import { formatDateString } from '@util/dates'; +import useRequest from '@util/useRequest'; +import { SchedulesAPI } from '@api'; + +const PromptTitle = styled(Title)` + --pf-c-title--m-md--FontWeight: 700; +`; + +function ScheduleDetail({ schedule, i18n }) { + const { + id, + created, + description, + diff_mode, + dtend, + dtstart, + job_tags, + job_type, + inventory, + limit, + modified, + name, + next_run, + rrule, + scm_branch, + skip_tags, + summary_fields, + timezone, + } = schedule; + + const { + result: [credentials, preview], + isLoading, + error, + request: fetchCredentialsAndPreview, + } = useRequest( + useCallback(async () => { + const [{ data }, { data: schedulePreview }] = await Promise.all([ + SchedulesAPI.readCredentials(id), + SchedulesAPI.createPreview({ + rrule, + }), + ]); + return [data.results, schedulePreview]; + }, [id, rrule]), + [] + ); + + useEffect(() => { + fetchCredentialsAndPreview(); + }, [fetchCredentialsAndPreview]); + + const rule = rrulestr(rrule); + const repeatFrequency = + rule.options.freq === 3 && dtstart === dtend + ? i18n._(t`None (Run Once)`) + : rule.toText().replace(/^\w/, c => c.toUpperCase()); + const showPromptedFields = + (credentials && credentials.length > 0) || + job_type || + (inventory && summary_fields.inventory) || + scm_branch || + limit || + typeof diff_mode === 'boolean' || + (job_tags && job_tags.length > 0) || + (skip_tags && skip_tags.length > 0); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + return ( + + + + + + + + + + + + + + {showPromptedFields && ( + <> + + {i18n._(t`Prompted Fields`)} + + + {inventory && summary_fields.inventory && ( + + {summary_fields.inventory.name} + + } + /> + )} + + + {typeof diff_mode === 'boolean' && ( + + )} + {credentials && credentials.length > 0 && ( + + {credentials.map(c => ( + + ))} + + } + /> + )} + {job_tags && job_tags.length > 0 && ( + + {job_tags.split(',').map(jobTag => ( + + {jobTag} + + ))} + + } + /> + )} + {skip_tags && skip_tags.length > 0 && ( + + {skip_tags.split(',').map(skipTag => ( + + {skipTag} + + ))} + + } + /> + )} + + )} + + + ); +} + +ScheduleDetail.propTypes = { + schedule: Schedule.isRequired, +}; + +export default withI18n()(ScheduleDetail); diff --git a/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.test.jsx new file mode 100644 index 0000000000..d67ff21fc2 --- /dev/null +++ b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.test.jsx @@ -0,0 +1,261 @@ +import React from 'react'; +import { SchedulesAPI } from '@api'; +import { Route } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import ScheduleDetail from './ScheduleDetail'; + +jest.mock('@api/models/Schedules'); + +const schedule = { + 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: 1, + name: 'Mock JT', + description: '', + unified_job_type: 'job', + }, + user_capabilities: { + edit: true, + delete: true, + }, + created_by: { + id: 1, + username: 'admin', + first_name: '', + last_name: '', + }, + modified_by: { + id: 1, + username: 'admin', + first_name: '', + last_name: '', + }, + inventory: { + id: 1, + name: 'Test Inventory', + }, + }, + created: '2020-03-03T20:38:54.210306Z', + modified: '2020-03-03T20:38:54.210336Z', + name: 'Mock JT Schedule', + enabled: false, + description: 'A good schedule', + timezone: 'America/New_York', + dtstart: '2020-03-16T04:00:00Z', + dtend: '2020-07-06T04:00:00Z', + next_run: '2020-03-16T04:00:00Z', +}; + +SchedulesAPI.createPreview.mockResolvedValue({ + data: { + local: [], + utc: [], + }, +}); + +describe('', () => { + let wrapper; + const history = createMemoryHistory({ + initialEntries: ['/templates/job_template/1/schedules/1/details'], + }); + afterEach(() => { + wrapper.unmount(); + }); + test('details should render with the proper values without prompts', async () => { + SchedulesAPI.readCredentials.mockResolvedValueOnce({ + data: { + count: 0, + results: [], + }, + }); + await act(async () => { + wrapper = mountWithContexts( + } + />, + { + context: { + router: { + history, + route: { + location: history.location, + match: { params: { id: 1 } }, + }, + }, + }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect( + wrapper + .find('Detail[label="Name"]') + .find('dd') + .text() + ).toBe('Mock JT Schedule'); + expect( + wrapper + .find('Detail[label="Description"]') + .find('dd') + .text() + ).toBe('A good schedule'); + expect(wrapper.find('Detail[label="First Run"]').length).toBe(1); + expect(wrapper.find('Detail[label="Next Run"]').length).toBe(1); + expect(wrapper.find('Detail[label="Last Run"]').length).toBe(1); + expect( + wrapper + .find('Detail[label="Local Time Zone"]') + .find('dd') + .text() + ).toBe('America/New_York'); + expect(wrapper.find('Detail[label="Repeat Frequency"]').length).toBe(1); + expect(wrapper.find('Detail[label="Created"]').length).toBe(1); + expect(wrapper.find('Detail[label="Last Modified"]').length).toBe(1); + expect(wrapper.find('Title[children="Prompted Fields"]').length).toBe(0); + expect(wrapper.find('Detail[label="Job Type"]').length).toBe(0); + expect(wrapper.find('Detail[label="Inventory"]').length).toBe(0); + expect(wrapper.find('Detail[label="SCM Branch"]').length).toBe(0); + expect(wrapper.find('Detail[label="Limit"]').length).toBe(0); + expect(wrapper.find('Detail[label="Show Changes"]').length).toBe(0); + expect(wrapper.find('Detail[label="Credentials"]').length).toBe(0); + expect(wrapper.find('Detail[label="Job Tags"]').length).toBe(0); + expect(wrapper.find('Detail[label="Skip Tags"]').length).toBe(0); + }); + test('details should render with the proper values with prompts', async () => { + SchedulesAPI.readCredentials.mockResolvedValueOnce({ + data: { + count: 2, + results: [ + { + id: 1, + name: 'Cred 1', + }, + { + id: 2, + name: 'Cred 2', + }, + ], + }, + }); + const scheduleWithPrompts = { + ...schedule, + job_type: 'run', + inventory: 1, + job_tags: 'tag1', + skip_tags: 'tag2', + scm_branch: 'foo/branch', + limit: 'localhost', + diff_mode: true, + verbosity: 1, + }; + await act(async () => { + wrapper = mountWithContexts( + } + />, + { + context: { + router: { + history, + route: { + location: history.location, + match: { params: { id: 1 } }, + }, + }, + }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect( + wrapper + .find('Detail[label="Name"]') + .find('dd') + .text() + ).toBe('Mock JT Schedule'); + expect( + wrapper + .find('Detail[label="Description"]') + .find('dd') + .text() + ).toBe('A good schedule'); + expect(wrapper.find('Detail[label="First Run"]').length).toBe(1); + expect(wrapper.find('Detail[label="Next Run"]').length).toBe(1); + expect(wrapper.find('Detail[label="Last Run"]').length).toBe(1); + expect( + wrapper + .find('Detail[label="Local Time Zone"]') + .find('dd') + .text() + ).toBe('America/New_York'); + expect(wrapper.find('Detail[label="Repeat Frequency"]').length).toBe(1); + expect(wrapper.find('Detail[label="Created"]').length).toBe(1); + expect(wrapper.find('Detail[label="Last Modified"]').length).toBe(1); + expect(wrapper.find('Title[children="Prompted Fields"]').length).toBe(1); + expect( + wrapper + .find('Detail[label="Job Type"]') + .find('dd') + .text() + ).toBe('run'); + expect(wrapper.find('Detail[label="Inventory"]').length).toBe(1); + expect( + wrapper + .find('Detail[label="SCM Branch"]') + .find('dd') + .text() + ).toBe('foo/branch'); + expect( + wrapper + .find('Detail[label="Limit"]') + .find('dd') + .text() + ).toBe('localhost'); + expect(wrapper.find('Detail[label="Show Changes"]').length).toBe(1); + expect(wrapper.find('Detail[label="Credentials"]').length).toBe(1); + expect(wrapper.find('Detail[label="Job Tags"]').length).toBe(1); + expect(wrapper.find('Detail[label="Skip Tags"]').length).toBe(1); + }); + test('error shown when error encountered fetching credentials', async () => { + SchedulesAPI.readCredentials.mockRejectedValueOnce( + new Error({ + response: { + config: { + method: 'get', + url: '/api/v2/job_templates/1/schedules/1/credentials', + }, + data: 'An error occurred', + status: 500, + }, + }) + ); + await act(async () => { + wrapper = mountWithContexts( + } + />, + { + context: { + router: { + history, + route: { + location: history.location, + match: { params: { id: 1 } }, + }, + }, + }, + } + ); + }); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + }); +}); diff --git a/awx/ui_next/src/components/Schedule/ScheduleDetail/index.js b/awx/ui_next/src/components/Schedule/ScheduleDetail/index.js new file mode 100644 index 0000000000..dc7a5b7477 --- /dev/null +++ b/awx/ui_next/src/components/Schedule/ScheduleDetail/index.js @@ -0,0 +1 @@ +export { default } from './ScheduleDetail'; diff --git a/awx/ui_next/src/components/ScheduleList/ScheduleList.jsx b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx similarity index 100% rename from awx/ui_next/src/components/ScheduleList/ScheduleList.jsx rename to awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx diff --git a/awx/ui_next/src/components/ScheduleList/ScheduleList.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.test.jsx similarity index 99% rename from awx/ui_next/src/components/ScheduleList/ScheduleList.test.jsx rename to awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.test.jsx index 25101677c8..85ceffb759 100644 --- a/awx/ui_next/src/components/ScheduleList/ScheduleList.test.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.test.jsx @@ -3,7 +3,7 @@ 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'; +import mockSchedules from '../data.schedules.json'; jest.mock('@api/models/Schedules'); diff --git a/awx/ui_next/src/components/ScheduleList/ScheduleListItem.jsx b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleListItem.jsx similarity index 98% rename from awx/ui_next/src/components/ScheduleList/ScheduleListItem.jsx rename to awx/ui_next/src/components/Schedule/ScheduleList/ScheduleListItem.jsx index 79ff96f6e1..ff2b3d05b5 100644 --- a/awx/ui_next/src/components/ScheduleList/ScheduleListItem.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleListItem.jsx @@ -15,10 +15,10 @@ import { } from '@patternfly/react-core'; import { PencilAltIcon } from '@patternfly/react-icons'; import { DetailList, Detail } from '@components/DetailList'; +import { ScheduleToggle } from '@components/Schedule'; import styled from 'styled-components'; import { Schedule } from '@types'; import { formatDateString } from '@util/dates'; -import ScheduleToggle from './ScheduleToggle'; const DataListAction = styled(_DataListAction)` align-items: center; diff --git a/awx/ui_next/src/components/ScheduleList/ScheduleListItem.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleListItem.test.jsx similarity index 100% rename from awx/ui_next/src/components/ScheduleList/ScheduleListItem.test.jsx rename to awx/ui_next/src/components/Schedule/ScheduleList/ScheduleListItem.test.jsx diff --git a/awx/ui_next/src/components/ScheduleList/index.js b/awx/ui_next/src/components/Schedule/ScheduleList/index.js similarity index 100% rename from awx/ui_next/src/components/ScheduleList/index.js rename to awx/ui_next/src/components/Schedule/ScheduleList/index.js diff --git a/awx/ui_next/src/components/Schedule/ScheduleOccurrences/ScheduleOccurrences.jsx b/awx/ui_next/src/components/Schedule/ScheduleOccurrences/ScheduleOccurrences.jsx new file mode 100644 index 0000000000..13e7835eec --- /dev/null +++ b/awx/ui_next/src/components/Schedule/ScheduleOccurrences/ScheduleOccurrences.jsx @@ -0,0 +1,79 @@ +import React, { useState } from 'react'; +import { shape } from 'prop-types'; +import styled from 'styled-components'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { formatDateString, formatDateStringUTC } from '@util/dates'; +import { Split, SplitItem, TextListItemVariants } from '@patternfly/react-core'; +import { DetailName, DetailValue } from '@components/DetailList'; +import MultiButtonToggle from '@components/MultiButtonToggle'; + +const OccurrencesLabel = styled.div` + display: inline-block; + font-size: var(--pf-c-form__label--FontSize); + font-weight: var(--pf-c-form__label--FontWeight); + line-height: var(--pf-c-form__label--LineHeight); + color: var(--pf-c-form__label--Color); + + span:first-of-type { + font-weight: var(--pf-global--FontWeight--bold); + margin-right: 10px; + } +`; + +function ScheduleOccurrences({ preview = { local: [], utc: [] }, i18n }) { + const [mode, setMode] = useState('local'); + + if (preview.local.length < 2) { + return null; + } + + return ( + <> + + + + + {i18n._(t`Occurrences`)} + {i18n._(t`(Limited to first 10)`)} + + + + setMode(newMode)} + /> + + + + + {preview[mode].map(dateStr => ( +
+ {mode === 'local' + ? formatDateString(dateStr) + : formatDateStringUTC(dateStr)} +
+ ))} +
+ + ); +} + +ScheduleOccurrences.propTypes = { + preview: shape(), +}; + +ScheduleOccurrences.defaultProps = { + preview: { local: [], utc: [] }, +}; + +export default withI18n()(ScheduleOccurrences); diff --git a/awx/ui_next/src/components/Schedule/ScheduleOccurrences/ScheduleOccurrences.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleOccurrences/ScheduleOccurrences.test.jsx new file mode 100644 index 0000000000..46cc795995 --- /dev/null +++ b/awx/ui_next/src/components/Schedule/ScheduleOccurrences/ScheduleOccurrences.test.jsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import ScheduleOccurrences from './ScheduleOccurrences'; + +describe('', () => { + let wrapper; + describe('At least two dates passed in', () => { + beforeAll(() => { + wrapper = mountWithContexts( + + ); + }); + afterAll(() => { + wrapper.unmount(); + }); + test('Local option initially set', async () => { + expect(wrapper.find('MultiButtonToggle').props().value).toBe('local'); + }); + test('It renders the correct number of dates', async () => { + expect(wrapper.find('dd').children().length).toBe(2); + }); + test('Clicking UTC button toggles the dates to utc', async () => { + wrapper.find('button[aria-label="UTC"]').simulate('click'); + expect(wrapper.find('MultiButtonToggle').props().value).toBe('utc'); + expect(wrapper.find('dd').children().length).toBe(2); + expect( + wrapper + .find('dd') + .children() + .at(0) + .text() + ).toBe('3/16/2020, 4:00:00 AM'); + expect( + wrapper + .find('dd') + .children() + .at(1) + .text() + ).toBe('3/30/2020, 4:00:00 AM'); + }); + }); + describe('Only one date passed in', () => { + test('Component should not render chldren', async () => { + wrapper = mountWithContexts( + + ); + expect(wrapper.find('ScheduleOccurrences').children().length).toBe(0); + wrapper.unmount(); + }); + }); +}); diff --git a/awx/ui_next/src/components/Schedule/ScheduleOccurrences/index.js b/awx/ui_next/src/components/Schedule/ScheduleOccurrences/index.js new file mode 100644 index 0000000000..2b21bebcce --- /dev/null +++ b/awx/ui_next/src/components/Schedule/ScheduleOccurrences/index.js @@ -0,0 +1 @@ +export { default } from './ScheduleOccurrences'; diff --git a/awx/ui_next/src/components/ScheduleList/ScheduleToggle.jsx b/awx/ui_next/src/components/Schedule/ScheduleToggle/ScheduleToggle.jsx similarity index 100% rename from awx/ui_next/src/components/ScheduleList/ScheduleToggle.jsx rename to awx/ui_next/src/components/Schedule/ScheduleToggle/ScheduleToggle.jsx diff --git a/awx/ui_next/src/components/ScheduleList/ScheduleToggle.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleToggle/ScheduleToggle.test.jsx similarity index 100% rename from awx/ui_next/src/components/ScheduleList/ScheduleToggle.test.jsx rename to awx/ui_next/src/components/Schedule/ScheduleToggle/ScheduleToggle.test.jsx diff --git a/awx/ui_next/src/components/Schedule/ScheduleToggle/index.js b/awx/ui_next/src/components/Schedule/ScheduleToggle/index.js new file mode 100644 index 0000000000..65573a4fde --- /dev/null +++ b/awx/ui_next/src/components/Schedule/ScheduleToggle/index.js @@ -0,0 +1 @@ +export { default } from './ScheduleToggle'; diff --git a/awx/ui_next/src/components/Schedule/Schedules.jsx b/awx/ui_next/src/components/Schedule/Schedules.jsx new file mode 100644 index 0000000000..4866aba404 --- /dev/null +++ b/awx/ui_next/src/components/Schedule/Schedules.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { Switch, Route, useRouteMatch } from 'react-router-dom'; +import { Schedule, ScheduleList } from '@components/Schedule'; + +function Schedules({ + setBreadcrumb, + unifiedJobTemplate, + loadSchedules, + loadScheduleOptions, +}) { + const match = useRouteMatch(); + + return ( + + ( + + )} + /> + { + return ( + + ); + }} + /> + + ); +} + +export { Schedules as _Schedules }; +export default withI18n()(Schedules); diff --git a/awx/ui_next/src/components/Schedule/Schedules.test.jsx b/awx/ui_next/src/components/Schedule/Schedules.test.jsx new file mode 100644 index 0000000000..4589403c01 --- /dev/null +++ b/awx/ui_next/src/components/Schedule/Schedules.test.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import Schedules from './Schedules'; + +describe('', () => { + test('initially renders successfully', async () => { + let wrapper; + const history = createMemoryHistory({ + initialEntries: ['/templates/job_template/1/schedules'], + }); + const jobTemplate = { id: 1, name: 'Mock JT' }; + + await act(async () => { + wrapper = mountWithContexts( + {}} + jobTemplate={jobTemplate} + loadSchedules={() => {}} + loadScheduleOptions={() => {}} + />, + + { + context: { + router: { history, route: { location: history.location } }, + }, + } + ); + }); + expect(wrapper.length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/components/ScheduleList/data.schedules.json b/awx/ui_next/src/components/Schedule/data.schedules.json similarity index 100% rename from awx/ui_next/src/components/ScheduleList/data.schedules.json rename to awx/ui_next/src/components/Schedule/data.schedules.json diff --git a/awx/ui_next/src/components/Schedule/index.js b/awx/ui_next/src/components/Schedule/index.js new file mode 100644 index 0000000000..f734f868d2 --- /dev/null +++ b/awx/ui_next/src/components/Schedule/index.js @@ -0,0 +1,6 @@ +export { default as Schedule } from './Schedule'; +export { default as Schedules } from './Schedules'; +export { default as ScheduleList } from './ScheduleList'; +export { default as ScheduleOccurrences } from './ScheduleOccurrences'; +export { default as ScheduleToggle } from './ScheduleToggle'; +export { default as ScheduleDetail } from './ScheduleDetail'; diff --git a/awx/ui_next/src/screens/Project/Project.jsx b/awx/ui_next/src/screens/Project/Project.jsx index de6b609bdb..0016acdc39 100644 --- a/awx/ui_next/src/screens/Project/Project.jsx +++ b/awx/ui_next/src/screens/Project/Project.jsx @@ -9,7 +9,7 @@ import RoutedTabs from '@components/RoutedTabs'; import ContentError from '@components/ContentError'; import NotificationList from '@components/NotificationList'; import { ResourceAccessList } from '@components/ResourceAccessList'; -import ScheduleList from '@components/ScheduleList'; +import { Schedules } from '@components/Schedule'; import ProjectDetail from './ProjectDetail'; import ProjectEdit from './ProjectEdit'; import ProjectJobTemplatesList from './ProjectJobTemplatesList'; @@ -116,7 +116,7 @@ class Project extends Component { } render() { - const { location, match, me, i18n } = this.props; + const { location, match, me, i18n, setBreadcrumb } = this.props; const { project, @@ -175,7 +175,10 @@ class Project extends Component { cardHeader = null; } - if (location.pathname.endsWith('edit')) { + if ( + location.pathname.endsWith('edit') || + location.pathname.includes('schedules/') + ) { cardHeader = null; } @@ -247,7 +250,9 @@ class Project extends Component { ( - diff --git a/awx/ui_next/src/screens/Schedule/Schedules.jsx b/awx/ui_next/src/screens/Schedule/AllSchedules.jsx similarity index 88% rename from awx/ui_next/src/screens/Schedule/Schedules.jsx rename to awx/ui_next/src/screens/Schedule/AllSchedules.jsx index 514f4b4392..1f7ffa3188 100644 --- a/awx/ui_next/src/screens/Schedule/Schedules.jsx +++ b/awx/ui_next/src/screens/Schedule/AllSchedules.jsx @@ -4,11 +4,11 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import Breadcrumbs from '@components/Breadcrumbs'; -import ScheduleList from '@components/ScheduleList'; +import { ScheduleList } from '@components/Schedule'; import { SchedulesAPI } from '@api'; import { PageSection, Card } from '@patternfly/react-core'; -function Schedules({ i18n }) { +function AllSchedules({ i18n }) { const loadScheduleOptions = () => { return SchedulesAPI.readOptions(); }; @@ -41,4 +41,4 @@ function Schedules({ i18n }) { ); } -export default withI18n()(Schedules); +export default withI18n()(AllSchedules); diff --git a/awx/ui_next/src/screens/Schedule/Schedules.test.jsx b/awx/ui_next/src/screens/Schedule/AllSchedules.test.jsx similarity index 79% rename from awx/ui_next/src/screens/Schedule/Schedules.test.jsx rename to awx/ui_next/src/screens/Schedule/AllSchedules.test.jsx index ebdc67a046..50e4b76f66 100644 --- a/awx/ui_next/src/screens/Schedule/Schedules.test.jsx +++ b/awx/ui_next/src/screens/Schedule/AllSchedules.test.jsx @@ -1,9 +1,9 @@ import React from 'react'; import { mountWithContexts } from '@testUtils/enzymeHelpers'; import { createMemoryHistory } from 'history'; -import Schedules from './Schedules'; +import AllSchedules from './AllSchedules'; -describe('', () => { +describe('', () => { let wrapper; afterEach(() => { @@ -11,7 +11,7 @@ describe('', () => { }); test('initially renders succesfully', () => { - wrapper = mountWithContexts(); + wrapper = mountWithContexts(); }); test('should display schedule list breadcrumb heading', () => { @@ -19,7 +19,7 @@ describe('', () => { initialEntries: ['/schedules'], }); - wrapper = mountWithContexts(, { + wrapper = mountWithContexts(, { context: { router: { history, diff --git a/awx/ui_next/src/screens/Schedule/index.js b/awx/ui_next/src/screens/Schedule/index.js index 64f2dedc84..3e38a47a40 100644 --- a/awx/ui_next/src/screens/Schedule/index.js +++ b/awx/ui_next/src/screens/Schedule/index.js @@ -1 +1 @@ -export { default } from './Schedules'; +export { default } from './AllSchedules'; diff --git a/awx/ui_next/src/screens/Template/Template.jsx b/awx/ui_next/src/screens/Template/Template.jsx index 9de406abaf..7cdd8ce032 100644 --- a/awx/ui_next/src/screens/Template/Template.jsx +++ b/awx/ui_next/src/screens/Template/Template.jsx @@ -10,7 +10,7 @@ import ContentError from '@components/ContentError'; import JobList from '@components/JobList'; import NotificationList from '@components/NotificationList'; import RoutedTabs from '@components/RoutedTabs'; -import ScheduleList from '@components/ScheduleList'; +import { Schedules } from '@components/Schedule'; import { ResourceAccessList } from '@components/ResourceAccessList'; import JobTemplateDetail from './JobTemplateDetail'; import JobTemplateEdit from './JobTemplateEdit'; @@ -96,7 +96,7 @@ class Template extends Component { } render() { - const { i18n, location, match, me } = this.props; + const { i18n, location, match, me, setBreadcrumb } = this.props; const { contentError, hasContentLoading, @@ -126,6 +126,10 @@ class Template extends Component { } tabsArray.push( + { + name: i18n._(t`Schedules`), + link: `${match.url}/schedules`, + }, { name: i18n._(t`Completed Jobs`), link: `${match.url}/completed_jobs`, @@ -149,7 +153,10 @@ class Template extends Component { ); - if (location.pathname.endsWith('edit')) { + if ( + location.pathname.endsWith('edit') || + location.pathname.includes('schedules/') + ) { cardHeader = null; } @@ -211,6 +218,20 @@ class Template extends Component { )} /> )} + {template && ( + ( + + )} + /> + )} {canSeeNotificationsTab && ( )} - {template && ( - ( - - )} - /> - )} {template && ( ', () => { const wrapper = mountWithContexts(