Compare commits

...

15 Commits

Author SHA1 Message Date
Alex Corey
26a947ed31 Adds functionality to add multiple rrules to a schedule and save the form 2023-01-09 09:27:47 -05:00
Shane McDonald
b99a434dee Merge pull request #13395 from shanemcd/pin-rsyslog
Pin rsyslog to avoid crash
2023-01-04 21:54:34 +08:00
Shane McDonald
6cee99a9f9 Pin rsyslog to prevent crash
With the latest version of rsyslog we had a test failing with:

AssertionError: Response data: {'error': "b'rsyslog internal message (3,-2455): could not transfer  the  specified  internal posix  capabilities settings to the kernel, capng_apply=-5\\n [v8.2102.0-107.el9 try https://www.rsyslog.com/e/2455 ]\\n'"}

Downgrading fixes it
2023-01-04 08:19:20 -05:00
Seth Foster
ee509aea56 Merge pull request #12961 from fosterseth/fix_results_traceback
Result_traceback should not include job stdout
2023-01-03 13:34:23 -05:00
Sarah Akus
b5452a48f8 Merge pull request #13196 from keithjgrant/13189-job-traceback
Fix job error traceback in job output
2023-01-03 11:59:58 -05:00
Seth Foster
0c980fa7d5 Merge pull request #13366 from fosterseth/bump_receptorctl_1.3.0
bump receptorctl version to 1.3.0
2022-12-21 16:27:25 -05:00
Shane McDonald
e34ce8c795 Merge pull request #13365 from dsavineau/downgrade_hiredis
Pin hiredis to 2.0.0
2022-12-21 15:23:15 -05:00
Seth Foster
3543644e0e bump receptorctl version to 1.3.0 2022-12-21 13:36:11 -05:00
Seth Foster
36c0d07b30 Result_traceback should not include job stdout
If a job fails, we do receptor work results and put that output
into result_traceback.

We should only do this if
1. Receptor unit has failed
2. Runner callback processed 0 events

Otherwise we risk putting too much data into this field.
2022-12-21 13:05:44 -05:00
Dimitri Savineau
239827a9cf Pin hiredis to 2.0.0
The hiredis 2.1.0 release doesn't provide source distribution on PyPi so
users can't build that python package from sources.

Signed-off-by: Dimitri Savineau <dsavinea@redhat.com>
2022-12-21 11:57:41 -05:00
Alan Rominger
ac9871b36f Merge pull request #13361 from relrod/sanity
[collection] Run sanity tests outside of our container
2022-12-21 11:00:21 -05:00
Alan Rominger
f739908ccf Add comment about Ansible-core being installed by default
Co-authored-by: John R Barker <john@johnrbarker.com>
2022-12-21 09:57:00 -05:00
Alan Rominger
cf1ec07eab Changes to run sanity tests locally
Use a Makefile arg for the ansible-test sanity CLI args
  defaults to --docker
  in the future we probably need to customize python versions

Copy the rule exception for Ansible 2.15
  this helps people who are running from Ansible devel
2022-12-21 09:53:22 -05:00
Rick Elrod
d968b648de Run sanity tests outside of our container
Also just ignore one sanity test for the export module, instead of
ignoring all of them.

Also use latest ansible-test, and make it work on GHA (by using podman
instead of docker).

Signed-off-by: Rick Elrod <rick@elrod.me>
2022-12-20 21:40:41 -06:00
Keith J. Grant
d34f6af830 fix traceback offset/counter # in UI 2022-11-15 13:35:14 -08:00
29 changed files with 1111 additions and 1296 deletions

View File

@@ -28,9 +28,6 @@ jobs:
- name: awx-collection - name: awx-collection
command: /start_tests.sh test_collection_all command: /start_tests.sh test_collection_all
label: Run Collection Tests label: Run Collection Tests
- name: awx-collection-sanity
command: /start_tests.sh test_collection_sanity
label: Run Ansible core Collection Sanity tests
- name: api-schema - name: api-schema
label: Check API Schema label: Check API Schema
command: /start_tests.sh detect-schema-change SCHEMA_DIFF_BASE_BRANCH=${{ github.event.pull_request.base.ref }} command: /start_tests.sh detect-schema-change SCHEMA_DIFF_BASE_BRANCH=${{ github.event.pull_request.base.ref }}
@@ -148,3 +145,22 @@ jobs:
env: env:
AWX_TEST_IMAGE: awx AWX_TEST_IMAGE: awx
AWX_TEST_VERSION: ci AWX_TEST_VERSION: ci
collection-sanity:
name: awx_collection sanity
runs-on: ubuntu-latest
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v2
# The containers that GitHub Actions use have Ansible installed, so upgrade to make sure we have the latest version.
- name: Upgrade ansible-core
run: python3 -m pip install --upgrade ansible-core
- name: Run sanity tests
run: make test_collection_sanity
env:
# needed due to cgroupsv2. This is fixed, but a stable release
# with the fix has not been made yet.
ANSIBLE_TEST_PREFER_PODMAN: 1

View File

@@ -6,7 +6,7 @@ env:
on: on:
pull_request_target: pull_request_target:
types: [labeled] types: [labeled]
jobs: jobs:
e2e-test: e2e-test:
if: contains(github.event.pull_request.labels.*.name, 'qe:e2e') if: contains(github.event.pull_request.labels.*.name, 'qe:e2e')
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -107,5 +107,3 @@ jobs:
with: with:
name: AWX-logs-${{ matrix.job }} name: AWX-logs-${{ matrix.job }}
path: make-docker-compose-output.log path: make-docker-compose-output.log

View File

@@ -9,6 +9,17 @@ VERSION := $(shell $(PYTHON) tools/scripts/scm_version.py)
# ansible-test requires semver compatable version, so we allow overrides to hack it # ansible-test requires semver compatable version, so we allow overrides to hack it
COLLECTION_VERSION ?= $(shell $(PYTHON) tools/scripts/scm_version.py | cut -d . -f 1-3) COLLECTION_VERSION ?= $(shell $(PYTHON) tools/scripts/scm_version.py | cut -d . -f 1-3)
# args for the ansible-test sanity command
COLLECTION_SANITY_ARGS ?= --docker
# collection unit testing directories
COLLECTION_TEST_DIRS ?= awx_collection/test/awx
# collection integration test directories (defaults to all)
COLLECTION_TEST_TARGET ?=
# args for collection install
COLLECTION_PACKAGE ?= awx
COLLECTION_NAMESPACE ?= awx
COLLECTION_INSTALL = ~/.ansible/collections/ansible_collections/$(COLLECTION_NAMESPACE)/$(COLLECTION_PACKAGE)
COLLECTION_TEMPLATE_VERSION ?= false
# NOTE: This defaults the container image version to the branch that's active # NOTE: This defaults the container image version to the branch that's active
COMPOSE_TAG ?= $(GIT_BRANCH) COMPOSE_TAG ?= $(GIT_BRANCH)
@@ -290,13 +301,6 @@ test:
cd awxkit && $(VENV_BASE)/awx/bin/tox -re py3 cd awxkit && $(VENV_BASE)/awx/bin/tox -re py3
awx-manage check_migrations --dry-run --check -n 'missing_migration_file' awx-manage check_migrations --dry-run --check -n 'missing_migration_file'
COLLECTION_TEST_DIRS ?= awx_collection/test/awx
COLLECTION_TEST_TARGET ?=
COLLECTION_PACKAGE ?= awx
COLLECTION_NAMESPACE ?= awx
COLLECTION_INSTALL = ~/.ansible/collections/ansible_collections/$(COLLECTION_NAMESPACE)/$(COLLECTION_PACKAGE)
COLLECTION_TEMPLATE_VERSION ?= false
test_collection: test_collection:
rm -f $(shell ls -d $(VENV_BASE)/awx/lib/python* | head -n 1)/no-global-site-packages.txt rm -f $(shell ls -d $(VENV_BASE)/awx/lib/python* | head -n 1)/no-global-site-packages.txt
if [ "$(VENV_BASE)" ]; then \ if [ "$(VENV_BASE)" ]; then \
@@ -339,7 +343,7 @@ test_collection_sanity:
if ! [ -x "$(shell command -v ansible-test)" ]; then pip install ansible-core; fi if ! [ -x "$(shell command -v ansible-test)" ]; then pip install ansible-core; fi
ansible --version ansible --version
COLLECTION_VERSION=1.0.0 make install_collection COLLECTION_VERSION=1.0.0 make install_collection
cd $(COLLECTION_INSTALL) && ansible-test sanity --exclude=plugins/modules/export.py cd $(COLLECTION_INSTALL) && ansible-test sanity $(COLLECTION_SANITY_ARGS)
test_collection_integration: install_collection test_collection_integration: install_collection
cd $(COLLECTION_INSTALL) && ansible-test integration $(COLLECTION_TEST_TARGET) cd $(COLLECTION_INSTALL) && ansible-test integration $(COLLECTION_TEST_TARGET)

View File

@@ -411,9 +411,11 @@ class AWXReceptorJob:
unit_status = receptor_ctl.simple_command(f'work status {self.unit_id}') unit_status = receptor_ctl.simple_command(f'work status {self.unit_id}')
detail = unit_status.get('Detail', None) detail = unit_status.get('Detail', None)
state_name = unit_status.get('StateName', None) state_name = unit_status.get('StateName', None)
stdout_size = unit_status.get('StdoutSize', 0)
except Exception: except Exception:
detail = '' detail = ''
state_name = '' state_name = ''
stdout_size = 0
logger.exception(f'An error was encountered while getting status for work unit {self.unit_id}') logger.exception(f'An error was encountered while getting status for work unit {self.unit_id}')
if 'exceeded quota' in detail: if 'exceeded quota' in detail:
@@ -424,9 +426,16 @@ class AWXReceptorJob:
return return
try: try:
resultsock = receptor_ctl.get_work_results(self.unit_id, return_sockfile=True) receptor_output = ''
lines = resultsock.readlines() if state_name == 'Failed' and self.task.runner_callback.event_ct == 0:
receptor_output = b"".join(lines).decode() # if receptor work unit failed and no events were emitted, work results may
# contain useful information about why the job failed. In case stdout is
# massive, only ask for last 1000 bytes
startpos = max(stdout_size - 1000, 0)
resultsock, resultfile = receptor_ctl.get_work_results(self.unit_id, startpos=startpos, return_socket=True, return_sockfile=True)
resultsock.setblocking(False) # this makes resultfile reads non blocking
lines = resultfile.readlines()
receptor_output = b"".join(lines).decode()
if receptor_output: if receptor_output:
self.task.runner_callback.delay_update(result_traceback=receptor_output) self.task.runner_callback.delay_update(result_traceback=receptor_output)
elif detail: elif detail:

View File

@@ -55,7 +55,6 @@ function DateTimePicker({ dateFieldName, timeFieldName, label }) {
onChange={onDateChange} onChange={onDateChange}
/> />
<TimePicker <TimePicker
placeholder="hh:mm AM/PM"
stepMinutes={15} stepMinutes={15}
aria-label={ aria-label={
timeFieldName.startsWith('start') ? t`Start time` : t`End time` timeFieldName.startsWith('start') ? t`Start time` : t`End time`

View File

@@ -0,0 +1,93 @@
import React, { useState } from 'react';
import { t } from '@lingui/macro';
import {
Button,
Switch,
Toolbar,
ToolbarContent,
ToolbarItem,
} from '@patternfly/react-core';
import { PencilAltIcon } from '@patternfly/react-icons';
import {
TableComposable,
Tbody,
Thead,
Th,
Tr,
Td,
} from '@patternfly/react-table';
import { useField } from 'formik';
import ContentEmpty from 'components/ContentEmpty';
function FrequenciesList({ openWizard }) {
const [isShowingRules, setIsShowingRules] = useState(true);
const [frequencies] = useField('frequencies');
const list = (freq) => (
<Tr key={freq.rrule}>
<Td>{freq.frequency}</Td>
<Td>{freq.rrule}</Td>
<Td>{t`End`}</Td>
<Td>
<Button
variant="plain"
aria-label={t`Click to toggle default value`}
ouiaId={freq ? `${freq}-button` : 'new-freq-button'}
onClick={() => {
openWizard(true);
}}
>
<PencilAltIcon />
</Button>
</Td>
</Tr>
);
return (
<>
<Toolbar>
<ToolbarContent>
<ToolbarItem>
<Button
onClick={() => {
openWizard(true);
}}
variant="secondary"
>
{isShowingRules ? t`Add RRules` : t`Add Exception`}
</Button>
</ToolbarItem>
<ToolbarItem>
<Switch
label={t`Occurances`}
labelOff={t`Exceptions`}
isChecked={isShowingRules}
onChange={(isChecked) => {
setIsShowingRules(isChecked);
}}
/>
</ToolbarItem>
</ToolbarContent>
</Toolbar>
<div css="overflow: auto">
{frequencies.value[0].frequency === '' &&
frequencies.value.length < 2 ? (
<ContentEmpty title={t`RRules`} message={t`Add RRules`} />
) : (
<TableComposable aria-label={t`RRules`} ouiaId="rrules-list">
<Thead>
<Tr>
<Th>{t`Frequency`}</Th>
<Th>{t`RRule`}</Th>
<Th>{t`Ending`}</Th>
<Th>{t`Actions`}</Th>
</Tr>
</Thead>
<Tbody>{frequencies.value.map((freq, i) => list(freq, i))}</Tbody>
</TableComposable>
)}
</div>
</>
);
}
export default FrequenciesList;

View File

@@ -1,568 +0,0 @@
import 'styled-components/macro';
import React from 'react';
import styled from 'styled-components';
import { useField } from 'formik';
import { t, Trans, Plural } from '@lingui/macro';
import { RRule } from 'rrule';
import {
Checkbox as _Checkbox,
FormGroup,
Radio,
TextInput,
} from '@patternfly/react-core';
import { required, requiredPositiveInteger } from 'util/validators';
import AnsibleSelect from '../../AnsibleSelect';
import FormField from '../../FormField';
import DateTimePicker from './DateTimePicker';
const RunOnRadio = styled(Radio)`
display: flex;
align-items: center;
label {
display: block;
width: 100%;
}
:not(:last-of-type) {
margin-bottom: 10px;
}
select:not(:first-of-type) {
margin-left: 10px;
}
`;
const RunEveryLabel = styled.p`
display: flex;
align-items: center;
`;
const Checkbox = styled(_Checkbox)`
:not(:last-of-type) {
margin-right: 10px;
}
`;
const FrequencyDetailSubform = ({ frequency, prefix, isException }) => {
const id = prefix.replace('.', '-');
const [runOnDayMonth] = useField({
name: `${prefix}.runOnDayMonth`,
});
const [runOnDayNumber] = useField({
name: `${prefix}.runOnDayNumber`,
});
const [runOnTheOccurrence] = useField({
name: `${prefix}.runOnTheOccurrence`,
});
const [runOnTheDay] = useField({
name: `${prefix}.runOnTheDay`,
});
const [runOnTheMonth] = useField({
name: `${prefix}.runOnTheMonth`,
});
const [startDate] = useField(`${prefix}.startDate`);
const [daysOfWeek, daysOfWeekMeta, daysOfWeekHelpers] = useField({
name: `${prefix}.daysOfWeek`,
validate: (val) => {
if (frequency === 'week') {
return required(t`Select a value for this field`)(val?.length > 0);
}
return undefined;
},
});
const [end, endMeta] = useField({
name: `${prefix}.end`,
validate: required(t`Select a value for this field`),
});
const [interval, intervalMeta] = useField({
name: `${prefix}.interval`,
validate: requiredPositiveInteger(),
});
const [runOn, runOnMeta] = useField({
name: `${prefix}.runOn`,
validate: (val) => {
if (frequency === 'month' || frequency === 'year') {
return required(t`Select a value for this field`)(val);
}
return undefined;
},
});
const monthOptions = [
{
key: 'january',
value: 1,
label: t`January`,
},
{
key: 'february',
value: 2,
label: t`February`,
},
{
key: 'march',
value: 3,
label: t`March`,
},
{
key: 'april',
value: 4,
label: t`April`,
},
{
key: 'may',
value: 5,
label: t`May`,
},
{
key: 'june',
value: 6,
label: t`June`,
},
{
key: 'july',
value: 7,
label: t`July`,
},
{
key: 'august',
value: 8,
label: t`August`,
},
{
key: 'september',
value: 9,
label: t`September`,
},
{
key: 'october',
value: 10,
label: t`October`,
},
{
key: 'november',
value: 11,
label: t`November`,
},
{
key: 'december',
value: 12,
label: t`December`,
},
];
const updateDaysOfWeek = (day, checked) => {
const newDaysOfWeek = daysOfWeek.value ? [...daysOfWeek.value] : [];
daysOfWeekHelpers.setTouched(true);
if (checked) {
newDaysOfWeek.push(day);
daysOfWeekHelpers.setValue(newDaysOfWeek);
} else {
daysOfWeekHelpers.setValue(
newDaysOfWeek.filter((selectedDay) => selectedDay !== day)
);
}
};
const getPeriodLabel = () => {
switch (frequency) {
case 'minute':
return t`Minute`;
case 'hour':
return t`Hour`;
case 'day':
return t`Day`;
case 'week':
return t`Week`;
case 'month':
return t`Month`;
case 'year':
return t`Year`;
default:
throw new Error(t`Frequency did not match an expected value`);
}
};
const getRunEveryLabel = () => {
const intervalValue = interval.value;
switch (frequency) {
case 'minute':
return <Plural value={intervalValue} one="minute" other="minutes" />;
case 'hour':
return <Plural value={intervalValue} one="hour" other="hours" />;
case 'day':
return <Plural value={intervalValue} one="day" other="days" />;
case 'week':
return <Plural value={intervalValue} one="week" other="weeks" />;
case 'month':
return <Plural value={intervalValue} one="month" other="months" />;
case 'year':
return <Plural value={intervalValue} one="year" other="years" />;
default:
throw new Error(t`Frequency did not match an expected value`);
}
};
return (
<>
<p css="grid-column: 1/-1">
<b>{getPeriodLabel()}</b>
</p>
<FormGroup
name={`${prefix}.interval`}
fieldId={`schedule-run-every-${id}`}
helperTextInvalid={intervalMeta.error}
isRequired
validated={
!intervalMeta.touched || !intervalMeta.error ? 'default' : 'error'
}
label={isException ? t`Skip every` : t`Run every`}
>
<div css="display: flex">
<TextInput
css="margin-right: 10px;"
id={`schedule-run-every-${id}`}
type="number"
min="1"
step="1"
{...interval}
onChange={(value, event) => {
interval.onChange(event);
}}
/>
<RunEveryLabel>{getRunEveryLabel()}</RunEveryLabel>
</div>
</FormGroup>
{frequency === 'week' && (
<FormGroup
name={`${prefix}.daysOfWeek`}
fieldId={`schedule-days-of-week-${id}`}
helperTextInvalid={daysOfWeekMeta.error}
isRequired
validated={
!daysOfWeekMeta.touched || !daysOfWeekMeta.error
? 'default'
: 'error'
}
label={t`On days`}
>
<div css="display: flex">
<Checkbox
label={t`Sun`}
isChecked={daysOfWeek.value?.includes(RRule.SU)}
onChange={(checked) => {
updateDaysOfWeek(RRule.SU, checked);
}}
aria-label={t`Sunday`}
id={`schedule-days-of-week-sun-${id}`}
ouiaId={`schedule-days-of-week-sun-${id}`}
name={`${prefix}.daysOfWeek`}
/>
<Checkbox
label={t`Mon`}
isChecked={daysOfWeek.value?.includes(RRule.MO)}
onChange={(checked) => {
updateDaysOfWeek(RRule.MO, checked);
}}
aria-label={t`Monday`}
id={`schedule-days-of-week-mon-${id}`}
ouiaId={`schedule-days-of-week-mon-${id}`}
name={`${prefix}.daysOfWeek`}
/>
<Checkbox
label={t`Tue`}
isChecked={daysOfWeek.value?.includes(RRule.TU)}
onChange={(checked) => {
updateDaysOfWeek(RRule.TU, checked);
}}
aria-label={t`Tuesday`}
id={`schedule-days-of-week-tue-${id}`}
ouiaId={`schedule-days-of-week-tue-${id}`}
name={`${prefix}.daysOfWeek`}
/>
<Checkbox
label={t`Wed`}
isChecked={daysOfWeek.value?.includes(RRule.WE)}
onChange={(checked) => {
updateDaysOfWeek(RRule.WE, checked);
}}
aria-label={t`Wednesday`}
id={`schedule-days-of-week-wed-${id}`}
ouiaId={`schedule-days-of-week-wed-${id}`}
name={`${prefix}.daysOfWeek`}
/>
<Checkbox
label={t`Thu`}
isChecked={daysOfWeek.value?.includes(RRule.TH)}
onChange={(checked) => {
updateDaysOfWeek(RRule.TH, checked);
}}
aria-label={t`Thursday`}
id={`schedule-days-of-week-thu-${id}`}
ouiaId={`schedule-days-of-week-thu-${id}`}
name={`${prefix}.daysOfWeek`}
/>
<Checkbox
label={t`Fri`}
isChecked={daysOfWeek.value?.includes(RRule.FR)}
onChange={(checked) => {
updateDaysOfWeek(RRule.FR, checked);
}}
aria-label={t`Friday`}
id={`schedule-days-of-week-fri-${id}`}
ouiaId={`schedule-days-of-week-fri-${id}`}
name={`${prefix}.daysOfWeek`}
/>
<Checkbox
label={t`Sat`}
isChecked={daysOfWeek.value?.includes(RRule.SA)}
onChange={(checked) => {
updateDaysOfWeek(RRule.SA, checked);
}}
aria-label={t`Saturday`}
id={`schedule-days-of-week-sat-${id}`}
ouiaId={`schedule-days-of-week-sat-${id}`}
name={`${prefix}.daysOfWeek`}
/>
</div>
</FormGroup>
)}
{(frequency === 'month' || frequency === 'year') &&
!Number.isNaN(new Date(startDate.value)) && (
<FormGroup
name={`${prefix}.runOn`}
fieldId={`schedule-run-on-${id}`}
helperTextInvalid={runOnMeta.error}
isRequired
validated={
!runOnMeta.touched || !runOnMeta.error ? 'default' : 'error'
}
label={t`Run on`}
>
<RunOnRadio
id={`schedule-run-on-day-${id}`}
name={`${prefix}.runOn`}
label={
<div css="display: flex;align-items: center;">
{frequency === 'month' && (
<span
id="radio-schedule-run-on-day"
css="margin-right: 10px;"
>
<Trans>Day</Trans>
</span>
)}
{frequency === 'year' && (
<AnsibleSelect
id={`schedule-run-on-day-month-${id}`}
css="margin-right: 10px"
isDisabled={runOn.value !== 'day'}
data={monthOptions}
{...runOnDayMonth}
/>
)}
<TextInput
id={`schedule-run-on-day-number-${id}`}
type="number"
min="1"
max="31"
step="1"
isDisabled={runOn.value !== 'day'}
{...runOnDayNumber}
onChange={(value, event) => {
runOnDayNumber.onChange(event);
}}
/>
</div>
}
value="day"
isChecked={runOn.value === 'day'}
onChange={(value, event) => {
event.target.value = 'day';
runOn.onChange(event);
}}
/>
<RunOnRadio
id={`schedule-run-on-the-${id}`}
name={`${prefix}.runOn`}
label={
<div css="display: flex;align-items: center;">
<span
id={`radio-schedule-run-on-the-${id}`}
css="margin-right: 10px;"
>
<Trans>The</Trans>
</span>
<AnsibleSelect
id={`schedule-run-on-the-occurrence-${id}`}
isDisabled={runOn.value !== 'the'}
data={[
{ value: 1, key: 'first', label: t`First` },
{
value: 2,
key: 'second',
label: t`Second`,
},
{ value: 3, key: 'third', label: t`Third` },
{
value: 4,
key: 'fourth',
label: t`Fourth`,
},
{ value: 5, key: 'fifth', label: t`Fifth` },
{ value: -1, key: 'last', label: t`Last` },
]}
{...runOnTheOccurrence}
/>
<AnsibleSelect
id={`schedule-run-on-the-day-${id}`}
isDisabled={runOn.value !== 'the'}
data={[
{
value: 'sunday',
key: 'sunday',
label: t`Sunday`,
},
{
value: 'monday',
key: 'monday',
label: t`Monday`,
},
{
value: 'tuesday',
key: 'tuesday',
label: t`Tuesday`,
},
{
value: 'wednesday',
key: 'wednesday',
label: t`Wednesday`,
},
{
value: 'thursday',
key: 'thursday',
label: t`Thursday`,
},
{
value: 'friday',
key: 'friday',
label: t`Friday`,
},
{
value: 'saturday',
key: 'saturday',
label: t`Saturday`,
},
{ value: 'day', key: 'day', label: t`Day` },
{
value: 'weekday',
key: 'weekday',
label: t`Weekday`,
},
{
value: 'weekendDay',
key: 'weekendDay',
label: t`Weekend day`,
},
]}
{...runOnTheDay}
/>
{frequency === 'year' && (
<>
<span
id={`of-schedule-run-on-the-month-${id}`}
css="margin-left: 10px;"
>
<Trans>of</Trans>
</span>
<AnsibleSelect
id={`schedule-run-on-the-month-${id}`}
isDisabled={runOn.value !== 'the'}
data={monthOptions}
{...runOnTheMonth}
/>
</>
)}
</div>
}
value="the"
isChecked={runOn.value === 'the'}
onChange={(value, event) => {
event.target.value = 'the';
runOn.onChange(event);
}}
/>
</FormGroup>
)}
<FormGroup
name={`${prefix}.end`}
fieldId={`schedule-end-${id}`}
helperTextInvalid={endMeta.error}
isRequired
validated={!endMeta.touched || !endMeta.error ? 'default' : 'error'}
label={t`End`}
>
<Radio
id={`end-never-${id}`}
name={`${prefix}.end`}
label={t`Never`}
value="never"
isChecked={end.value === 'never'}
onChange={(value, event) => {
event.target.value = 'never';
end.onChange(event);
}}
ouiaId={`end-never-radio-button-${id}`}
/>
<Radio
id={`end-after-${id}`}
name={`${prefix}.end`}
label={t`After number of occurrences`}
value="after"
isChecked={end.value === 'after'}
onChange={(value, event) => {
event.target.value = 'after';
end.onChange(event);
}}
ouiaId={`end-after-radio-button-${id}`}
/>
<Radio
id={`end-on-date-${id}`}
name={`${prefix}.end`}
label={t`On date`}
value="onDate"
isChecked={end.value === 'onDate'}
onChange={(value, event) => {
event.target.value = 'onDate';
end.onChange(event);
}}
ouiaId={`end-on-radio-button-${id}`}
/>
</FormGroup>
{end?.value === 'after' && (
<FormField
id={`schedule-occurrences-${id}`}
label={t`Occurrences`}
name={`${prefix}.occurrences`}
type="number"
min="1"
step="1"
isRequired
/>
)}
{end?.value === 'onDate' && (
<DateTimePicker
dateFieldName={`${prefix}.endDate`}
timeFieldName={`${prefix}.endTime`}
label={t`End date/time`}
/>
)}
</>
);
};
export default FrequencyDetailSubform;

View File

@@ -1,30 +1,12 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { arrayOf, string } from 'prop-types'; import { t } from '@lingui/macro';
import { useField } from 'formik';
import { RRule } from 'rrule';
import { Select, SelectOption, SelectVariant } from '@patternfly/react-core'; import { Select, SelectOption, SelectVariant } from '@patternfly/react-core';
export default function FrequencySelect({ export default function FrequencySelect({ id, onBlur, placeholderText }) {
id,
value,
onChange,
onBlur,
placeholderText,
children,
}) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [frequency, , frequencyHelpers] = useField('freq');
const onSelect = (event, selectedValue) => {
if (selectedValue === 'none') {
onChange([]);
setIsOpen(false);
return;
}
const index = value.indexOf(selectedValue);
if (index === -1) {
onChange(value.concat(selectedValue));
} else {
onChange(value.slice(0, index).concat(value.slice(index + 1)));
}
};
const onToggle = (val) => { const onToggle = (val) => {
if (!val) { if (!val) {
@@ -35,21 +17,26 @@ export default function FrequencySelect({
return ( return (
<Select <Select
variant={SelectVariant.checkbox} onSelect={(e, v) => {
onSelect={onSelect} frequencyHelpers.setValue(v);
selections={value} setIsOpen(false);
}}
selections={frequency.value}
placeholderText={placeholderText} placeholderText={placeholderText}
onToggle={onToggle} onToggle={onToggle}
value={frequency.value}
isOpen={isOpen} isOpen={isOpen}
ouiaId={`frequency-select-${id}`} ouiaId={`frequency-select-${id}`}
onBlur={() => frequencyHelpers.setTouched(true)}
> >
{children} <SelectOption value={RRule.MINUTELY}>{t`Minute`}</SelectOption>
<SelectOption value={RRule.HOURLY}>{t`Hour`}</SelectOption>
<SelectOption value={RRule.DAILY}>{t`Day`}</SelectOption>
<SelectOption value={RRule.WEEKLY}>{t`Week`}</SelectOption>
<SelectOption value={RRule.MONTHLY}>{t`Month`}</SelectOption>
<SelectOption value={RRule.YEARLY}>{t`Year`}</SelectOption>
</Select> </Select>
); );
} }
FrequencySelect.propTypes = {
value: arrayOf(string).isRequired,
};
export { SelectOption, SelectVariant }; export { SelectOption, SelectVariant };

View File

@@ -0,0 +1,77 @@
import React from 'react';
import { t } from '@lingui/macro';
import AnsibleSelect from 'components/AnsibleSelect';
import styled from 'styled-components';
import {
FormGroup,
Checkbox as _Checkbox,
Grid,
GridItem,
} from '@patternfly/react-core';
import { useField } from 'formik';
import { bysetposOptions, monthOptions } from './scheduleFormHelpers';
const GroupWrapper = styled(FormGroup)`
&& .pf-c-form__group-control {
display: flex;
padding-top: 10px;
}
&& .pf-c-form__group-label {
padding-top: 20px;
}
`;
const Checkbox = styled(_Checkbox)`
:not(:last-of-type) {
margin-right: 10px;
}
`;
function MonthandYearForm({ id }) {
const [bySetPos, , bySetPosHelpers] = useField('bysetpos');
const [byMonth, , byMonthHelpers] = useField('bymonth');
return (
<>
<GroupWrapper
fieldId={`schedule-run-on-${id}`}
label={<b>{t`Run on a specific month`}</b>}
>
<Grid hasGutter>
{monthOptions.map((month) => (
<GridItem key={month.label} span={2} rowSpan={2}>
<Checkbox
label={month.label}
isChecked={byMonth.value?.includes(month.value)}
onChange={(checked) => {
if (checked) {
byMonthHelpers.setValue([...byMonth.value, month.value]);
} else {
const removed = byMonth.value.filter(
(i) => i !== month.value
);
byMonthHelpers.setValue(removed);
}
}}
id={`bymonth-${month.label}`}
ouiaId={`bymonth-${month.label}`}
name="bymonth"
/>
</GridItem>
))}
</Grid>
</GroupWrapper>
<GroupWrapper
label={<b>{t`Run on a specific week day at monthly intervals`}</b>}
>
<AnsibleSelect
id={`schedule-run-on-the-occurrence-${id}`}
data={bysetposOptions}
{...bySetPos}
onChange={(e, v) => {
bySetPosHelpers.setValue(v);
}}
/>
</GroupWrapper>
</>
);
}
export default MonthandYearForm;

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import { useField } from 'formik';
import { FormGroup, TextInput } from '@patternfly/react-core';
const GroupWrapper = styled(FormGroup)`
&& .pf-c-form__group-control {
display: flex;
padding-top: 10px;
}
&& .pf-c-form__group-label {
padding-top: 20px;
}
`;
function OrdinalDayForm() {
const [byMonthDay] = useField('bymonthday');
const [byYearDay] = useField('byyearday');
return (
<GroupWrapper
label={<b>{t`On a specific number day`}</b>}
name="ordinalDay"
>
<TextInput
placeholder={t`Run on a day of month`}
aria-label={t`Type a numbered day`}
type="number"
onChange={(value, event) => {
byMonthDay.onChange(event);
}}
/>
<TextInput
placeholder={t`Run on a day of year`}
aria-label={t`Type a numbered day`}
type="number"
onChange={(value, event) => {
byYearDay.onChange(event);
}}
/>
</GroupWrapper>
);
}
export default OrdinalDayForm;

View File

@@ -0,0 +1,67 @@
import React from 'react';
import { useField } from 'formik';
import { t } from '@lingui/macro';
import { FormGroup, Radio } from '@patternfly/react-core';
import FormField from 'components/FormField';
import DateTimePicker from './DateTimePicker';
function ScheduleEndForm() {
const [endType, , { setValue }] = useField('endingType');
const [count] = useField('count');
return (
<>
<FormGroup name="end" label={t`End`}>
<Radio
id="endNever"
name={t`Never End`}
label={t`Never`}
value="never"
isChecked={endType.value === 'never'}
onChange={() => {
setValue('never');
}}
/>
<Radio
name="Count"
id="after"
label={t`After number of occurrences`}
value="after"
isChecked={endType.value === 'after'}
onChange={() => {
setValue('after');
}}
/>
<Radio
name="End Date"
label={t`On date`}
value="onDate"
id="endDate"
isChecked={endType.value === 'onDate'}
onChange={() => {
setValue('onDate');
}}
/>
</FormGroup>
{endType.value === 'after' && (
<FormField
label={t`Occurrences`}
name="count"
type="number"
min="1"
step="1"
isRequired
{...count}
/>
)}
{endType.value === 'onDate' && (
<DateTimePicker
dateFieldName="endDate"
timeFieldName="endTime"
label={t`End date/time`}
/>
)}
</>
);
}
export default ScheduleEndForm;

View File

@@ -18,14 +18,9 @@ import SchedulePromptableFields from './SchedulePromptableFields';
import ScheduleFormFields from './ScheduleFormFields'; import ScheduleFormFields from './ScheduleFormFields';
import UnsupportedScheduleForm from './UnsupportedScheduleForm'; import UnsupportedScheduleForm from './UnsupportedScheduleForm';
import parseRuleObj, { UnsupportedRRuleError } from './parseRuleObj'; import parseRuleObj, { UnsupportedRRuleError } from './parseRuleObj';
import buildRuleObj from './buildRuleObj'; import ScheduleFormWizard from './ScheduleFormWizard';
import buildRuleSet from './buildRuleSet'; import FrequenciesList from './FrequenciesList';
// import { validateSchedule } from './scheduleFormHelpers';
const NUM_DAYS_PER_FREQUENCY = {
week: 7,
month: 31,
year: 365,
};
function ScheduleForm({ function ScheduleForm({
hasDaysToKeepField, hasDaysToKeepField,
@@ -40,15 +35,16 @@ function ScheduleForm({
}) { }) {
const [isWizardOpen, setIsWizardOpen] = useState(false); const [isWizardOpen, setIsWizardOpen] = useState(false);
const [isSaveDisabled, setIsSaveDisabled] = useState(false); const [isSaveDisabled, setIsSaveDisabled] = useState(false);
const [isScheduleWizardOpen, setIsScheduleWizardOpen] = useState(false);
const originalLabels = useRef([]); const originalLabels = useRef([]);
const originalInstanceGroups = useRef([]); const originalInstanceGroups = useRef([]);
let rruleError; let rruleError;
const now = DateTime.now(); const now = DateTime.now();
const closestQuarterHour = DateTime.fromMillis( const closestQuarterHour = DateTime.fromMillis(
Math.ceil(now.ts / 900000) * 900000 Math.ceil(now.ts / 900000) * 900000
); );
const tomorrow = closestQuarterHour.plus({ days: 1 });
const isTemplate = const isTemplate =
resource.type === 'workflow_job_template' || resource.type === 'workflow_job_template' ||
resource.type === 'job_template'; resource.type === 'job_template';
@@ -283,69 +279,10 @@ function ScheduleForm({
} }
const [currentDate, time] = dateToInputDateTime(closestQuarterHour.toISO()); const [currentDate, time] = dateToInputDateTime(closestQuarterHour.toISO());
const [tomorrowDate] = dateToInputDateTime(tomorrow.toISO());
const initialFrequencyOptions = {
minute: {
interval: 1,
end: 'never',
occurrences: 1,
endDate: tomorrowDate,
endTime: time,
},
hour: {
interval: 1,
end: 'never',
occurrences: 1,
endDate: tomorrowDate,
endTime: time,
},
day: {
interval: 1,
end: 'never',
occurrences: 1,
endDate: tomorrowDate,
endTime: time,
},
week: {
interval: 1,
end: 'never',
occurrences: 1,
endDate: tomorrowDate,
endTime: time,
daysOfWeek: [],
},
month: {
interval: 1,
end: 'never',
occurrences: 1,
endDate: tomorrowDate,
endTime: time,
runOn: 'day',
runOnTheOccurrence: 1,
runOnTheDay: 'sunday',
runOnDayNumber: 1,
},
year: {
interval: 1,
end: 'never',
occurrences: 1,
endDate: tomorrowDate,
endTime: time,
runOn: 'day',
runOnTheOccurrence: 1,
runOnTheDay: 'sunday',
runOnTheMonth: 1,
runOnDayMonth: 1,
runOnDayNumber: 1,
},
};
const initialValues = { const initialValues = {
description: schedule.description || '', description: schedule.description || '',
frequency: [], frequencies: [],
exceptionFrequency: [], exceptionFrequency: [],
frequencyOptions: initialFrequencyOptions,
exceptionOptions: initialFrequencyOptions,
name: schedule.name || '', name: schedule.name || '',
startDate: currentDate, startDate: currentDate,
startTime: time, startTime: time,
@@ -367,11 +304,9 @@ function ScheduleForm({
} }
initialValues.daysToKeep = initialDaysToKeep; initialValues.daysToKeep = initialDaysToKeep;
} }
let overriddenValues = {};
if (schedule.rrule) { if (schedule.rrule) {
try { try {
overriddenValues = parseRuleObj(schedule); parseRuleObj(schedule);
} catch (error) { } catch (error) {
if (error instanceof UnsupportedRRuleError) { if (error instanceof UnsupportedRRuleError) {
return ( return (
@@ -394,89 +329,33 @@ function ScheduleForm({
if (contentLoading) { if (contentLoading) {
return <ContentLoading />; return <ContentLoading />;
} }
const frequencies = [];
const validate = (values) => { frequencies.push(parseRuleObj(schedule));
const errors = {};
values.frequency.forEach((freq) => {
const options = values.frequencyOptions[freq];
const freqErrors = {};
if (
(freq === 'month' || freq === 'year') &&
options.runOn === 'day' &&
(options.runOnDayNumber < 1 || options.runOnDayNumber > 31)
) {
freqErrors.runOn = t`Please select a day number between 1 and 31.`;
}
if (options.end === 'after' && !options.occurrences) {
freqErrors.occurrences = t`Please enter a number of occurrences.`;
}
if (options.end === 'onDate') {
if (
DateTime.fromFormat(
`${values.startDate} ${values.startTime}`,
'yyyy-LL-dd h:mm a'
).toMillis() >=
DateTime.fromFormat(
`${options.endDate} ${options.endTime}`,
'yyyy-LL-dd h:mm a'
).toMillis()
) {
freqErrors.endDate = t`Please select an end date/time that comes after the start date/time.`;
}
if (
DateTime.fromISO(options.endDate)
.diff(DateTime.fromISO(values.startDate), 'days')
.toObject().days < NUM_DAYS_PER_FREQUENCY[freq]
) {
const rule = new RRule(
buildRuleObj({
startDate: values.startDate,
startTime: values.startTime,
frequency: freq,
...options,
})
);
if (rule.all().length === 0) {
errors.startDate = t`Selected date range must have at least 1 schedule occurrence.`;
freqErrors.endDate = t`Selected date range must have at least 1 schedule occurrence.`;
}
}
}
if (Object.keys(freqErrors).length > 0) {
if (!errors.frequencyOptions) {
errors.frequencyOptions = {};
}
errors.frequencyOptions[freq] = freqErrors;
}
});
if (values.exceptionFrequency.length > 0 && !scheduleHasInstances(values)) {
errors.exceptionFrequency = t`This schedule has no occurrences due to the selected exceptions.`;
}
return errors;
};
return ( return (
<Config> <Config>
{() => ( {() => (
<Formik <Formik
initialValues={{ initialValues={{
...initialValues, name: schedule.name || '',
...overriddenValues, description: schedule.description || '',
frequencyOptions: { frequencies: frequencies || [],
...initialValues.frequencyOptions, freq: RRule.DAILY,
...overriddenValues.frequencyOptions, interval: 1,
}, wkst: RRule.SU,
exceptionOptions: { byweekday: [],
...initialValues.exceptionOptions, byweekno: [],
...overriddenValues.exceptionOptions, bymonth: [],
}, bymonthday: '',
byyearday: '',
bysetpos: '',
until: schedule.until || null,
endDate: currentDate,
endTime: time,
count: 1,
endingType: 'never',
timezone: schedule.timezone || now.zoneName,
startDate: currentDate,
startTime: time,
}} }}
onSubmit={(values) => { onSubmit={(values) => {
submitSchedule( submitSchedule(
@@ -488,73 +367,90 @@ function ScheduleForm({
credentials credentials
); );
}} }}
validate={validate} validate={() => {}}
> >
{(formik) => ( {(formik) => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}> <>
<FormColumnLayout> <Form autoComplete="off" onSubmit={formik.handleSubmit}>
<ScheduleFormFields <FormColumnLayout>
hasDaysToKeepField={hasDaysToKeepField} <ScheduleFormFields
zoneOptions={zoneOptions} hasDaysToKeepField={hasDaysToKeepField}
zoneLinks={zoneLinks} zoneOptions={zoneOptions}
/> zoneLinks={zoneLinks}
{isWizardOpen && (
<SchedulePromptableFields
schedule={schedule}
credentials={credentials}
surveyConfig={surveyConfig}
launchConfig={launchConfig}
resource={resource}
onCloseWizard={() => {
setIsWizardOpen(false);
}}
onSave={() => {
setIsWizardOpen(false);
setIsSaveDisabled(false);
}}
resourceDefaultCredentials={resourceDefaultCredentials}
labels={originalLabels.current}
instanceGroups={originalInstanceGroups.current}
/> />
)} {isWizardOpen && (
<FormSubmitError error={submitError} /> <SchedulePromptableFields
<FormFullWidthLayout> schedule={schedule}
<ActionGroup> credentials={credentials}
<Button surveyConfig={surveyConfig}
ouiaId="schedule-form-save-button" launchConfig={launchConfig}
aria-label={t`Save`} resource={resource}
variant="primary" onCloseWizard={() => {
type="button" setIsWizardOpen(false);
onClick={formik.handleSubmit} }}
isDisabled={isSaveDisabled} onSave={() => {
> setIsWizardOpen(false);
{t`Save`} setIsSaveDisabled(false);
</Button> }}
resourceDefaultCredentials={resourceDefaultCredentials}
{isTemplate && showPromptButton && ( labels={originalLabels.current}
instanceGroups={originalInstanceGroups.current}
/>
)}
<FormFullWidthLayout>
<FrequenciesList openWizard={setIsScheduleWizardOpen} />
</FormFullWidthLayout>
<FormSubmitError error={submitError} />
<FormFullWidthLayout>
<ActionGroup>
<Button <Button
ouiaId="schedule-form-prompt-button" ouiaId="schedule-form-save-button"
aria-label={t`Save`}
variant="primary"
type="button"
onClick={formik.handleSubmit}
isDisabled={isSaveDisabled}
>
{t`Save`}
</Button>
<Button
onClick={() => {}}
>{t`Preview occurances`}</Button>
{isTemplate && showPromptButton && (
<Button
ouiaId="schedule-form-prompt-button"
variant="secondary"
type="button"
aria-label={t`Prompt`}
onClick={() => setIsWizardOpen(true)}
>
{t`Prompt`}
</Button>
)}
<Button
ouiaId="schedule-form-cancel-button"
aria-label={t`Cancel`}
variant="secondary" variant="secondary"
type="button" type="button"
aria-label={t`Prompt`} onClick={handleCancel}
onClick={() => setIsWizardOpen(true)}
> >
{t`Prompt`} {t`Cancel`}
</Button> </Button>
)} </ActionGroup>
<Button </FormFullWidthLayout>
ouiaId="schedule-form-cancel-button" </FormColumnLayout>
aria-label={t`Cancel`} </Form>
variant="secondary" {isScheduleWizardOpen && (
type="button" <ScheduleFormWizard
onClick={handleCancel} staticFormFormkik={formik}
> isOpen={isScheduleWizardOpen}
{t`Cancel`} handleSave={() => {}}
</Button> setIsOpen={setIsScheduleWizardOpen}
</ActionGroup> />
</FormFullWidthLayout> )}
</FormColumnLayout> </>
</Form>
)} )}
</Formik> </Formik>
)} )}
@@ -575,24 +471,3 @@ ScheduleForm.defaultProps = {
}; };
export default ScheduleForm; export default ScheduleForm;
function scheduleHasInstances(values) {
let rangeToCheck = 1;
values.frequency.forEach((freq) => {
if (NUM_DAYS_PER_FREQUENCY[freq] > rangeToCheck) {
rangeToCheck = NUM_DAYS_PER_FREQUENCY[freq];
}
});
const ruleSet = buildRuleSet(values, true);
const startDate = DateTime.fromISO(values.startDate);
const endDate = startDate.plus({ days: rangeToCheck });
const instances = ruleSet.between(
startDate.toJSDate(),
endDate.toJSDate(),
true,
(date, i) => i === 0
);
return instances.length > 0;
}

View File

@@ -1,41 +1,27 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useField } from 'formik'; import { useField } from 'formik';
import { FormGroup, Title } from '@patternfly/react-core'; import { FormGroup } from '@patternfly/react-core';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import styled from 'styled-components';
import 'styled-components/macro';
import FormField from 'components/FormField'; import FormField from 'components/FormField';
import { required } from 'util/validators'; import { required } from 'util/validators';
import { useConfig } from 'contexts/Config'; import { useConfig } from 'contexts/Config';
import Popover from '../../Popover'; import Popover from '../../Popover';
import AnsibleSelect from '../../AnsibleSelect'; import AnsibleSelect from '../../AnsibleSelect';
import FrequencySelect, { SelectOption } from './FrequencySelect';
import getHelpText from '../../../screens/Template/shared/JobTemplate.helptext'; import getHelpText from '../../../screens/Template/shared/JobTemplate.helptext';
import { SubFormLayout, FormColumnLayout } from '../../FormLayout';
import FrequencyDetailSubform from './FrequencyDetailSubform';
import DateTimePicker from './DateTimePicker'; import DateTimePicker from './DateTimePicker';
import sortFrequencies from './sortFrequencies';
const SelectClearOption = styled(SelectOption)`
& > input[type='checkbox'] {
display: none;
}
`;
export default function ScheduleFormFields({ export default function ScheduleFormFields({
hasDaysToKeepField, hasDaysToKeepField,
zoneOptions, zoneOptions,
zoneLinks, zoneLinks,
setTimeZone,
}) { }) {
const helpText = getHelpText(); const helpText = getHelpText();
const [timezone, timezoneMeta] = useField({ const [timezone, timezoneMeta] = useField({
name: 'timezone', name: 'timezone',
validate: required(t`Select a value for this field`), validate: required(t`Select a value for this field`),
}); });
const [frequency, frequencyMeta, frequencyHelper] = useField({
name: 'frequency',
validate: required(t`Select a value for this field`),
});
const [timezoneMessage, setTimezoneMessage] = useState(''); const [timezoneMessage, setTimezoneMessage] = useState('');
const warnLinkedTZ = (event, selectedValue) => { const warnLinkedTZ = (event, selectedValue) => {
if (zoneLinks[selectedValue]) { if (zoneLinks[selectedValue]) {
@@ -46,6 +32,7 @@ export default function ScheduleFormFields({
setTimezoneMessage(''); setTimezoneMessage('');
} }
timezone.onChange(event, selectedValue); timezone.onChange(event, selectedValue);
setTimeZone(zoneLinks(selectedValue));
}; };
let timezoneValidatedStatus = 'default'; let timezoneValidatedStatus = 'default';
if (timezoneMeta.touched && timezoneMeta.error) { if (timezoneMeta.touched && timezoneMeta.error) {
@@ -55,16 +42,6 @@ export default function ScheduleFormFields({
} }
const config = useConfig(); const config = useConfig();
const [exceptionFrequency, exceptionFrequencyMeta, exceptionFrequencyHelper] =
useField({
name: 'exceptionFrequency',
validate: required(t`Select a value for this field`),
});
const updateFrequency = (setFrequency) => (values) => {
setFrequency(values.sort(sortFrequencies));
};
return ( return (
<> <>
<FormField <FormField
@@ -103,33 +80,7 @@ export default function ScheduleFormFields({
onChange={warnLinkedTZ} onChange={warnLinkedTZ}
/> />
</FormGroup> </FormGroup>
<FormGroup
name="frequency"
fieldId="schedule-frequency"
helperTextInvalid={frequencyMeta.error}
validated={
!frequencyMeta.touched || !frequencyMeta.error ? 'default' : 'error'
}
label={t`Repeat frequency`}
>
<FrequencySelect
id="schedule-frequency"
onChange={updateFrequency(frequencyHelper.setValue)}
value={frequency.value}
placeholderText={
frequency.value.length ? t`Select frequency` : t`None (run once)`
}
onBlur={frequencyHelper.setTouched}
>
<SelectClearOption value="none">{t`None (run once)`}</SelectClearOption>
<SelectOption value="minute">{t`Minute`}</SelectOption>
<SelectOption value="hour">{t`Hour`}</SelectOption>
<SelectOption value="day">{t`Day`}</SelectOption>
<SelectOption value="week">{t`Week`}</SelectOption>
<SelectOption value="month">{t`Month`}</SelectOption>
<SelectOption value="year">{t`Year`}</SelectOption>
</FrequencySelect>
</FormGroup>
{hasDaysToKeepField ? ( {hasDaysToKeepField ? (
<FormField <FormField
id="schedule-days-to-keep" id="schedule-days-to-keep"
@@ -140,68 +91,6 @@ export default function ScheduleFormFields({
isRequired isRequired
/> />
) : null} ) : null}
{frequency.value.length ? (
<SubFormLayout>
<Title size="md" headingLevel="h4">
{t`Frequency Details`}
</Title>
{frequency.value.map((val) => (
<FormColumnLayout key={val} stacked>
<FrequencyDetailSubform
frequency={val}
prefix={`frequencyOptions.${val}`}
/>
</FormColumnLayout>
))}
<Title
size="md"
headingLevel="h4"
css="margin-top: var(--pf-c-card--child--PaddingRight)"
>{t`Exceptions`}</Title>
<FormColumnLayout stacked>
<FormGroup
name="exceptions"
fieldId="exception-frequency"
helperTextInvalid={exceptionFrequencyMeta.error}
validated={
!exceptionFrequencyMeta.touched || !exceptionFrequencyMeta.error
? 'default'
: 'error'
}
label={t`Add exceptions`}
>
<FrequencySelect
id="exception-frequency"
onChange={updateFrequency(exceptionFrequencyHelper.setValue)}
value={exceptionFrequency.value}
placeholderText={
exceptionFrequency.value.length
? t`Select frequency`
: t`None`
}
onBlur={exceptionFrequencyHelper.setTouched}
>
<SelectClearOption value="none">{t`None`}</SelectClearOption>
<SelectOption value="minute">{t`Minute`}</SelectOption>
<SelectOption value="hour">{t`Hour`}</SelectOption>
<SelectOption value="day">{t`Day`}</SelectOption>
<SelectOption value="week">{t`Week`}</SelectOption>
<SelectOption value="month">{t`Month`}</SelectOption>
<SelectOption value="year">{t`Year`}</SelectOption>
</FrequencySelect>
</FormGroup>
</FormColumnLayout>
{exceptionFrequency.value.map((val) => (
<FormColumnLayout key={val} stacked>
<FrequencyDetailSubform
frequency={val}
prefix={`exceptionOptions.${val}`}
isException
/>
</FormColumnLayout>
))}
</SubFormLayout>
) : null}
</> </>
); );
} }

View File

@@ -0,0 +1,199 @@
import React from 'react';
import {
Button,
FormGroup,
TextInput,
Title,
Wizard,
WizardContextConsumer,
WizardFooter,
} from '@patternfly/react-core';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import { RRule } from 'rrule';
import { useField, useFormikContext } from 'formik';
import { DateTime } from 'luxon';
import { formatDateString } from 'util/dates';
import FrequencySelect from './FrequencySelect';
import MonthandYearForm from './MonthandYearForm';
import OrdinalDayForm from './OrdinalDayForm';
import WeekdayForm from './WeekdayForm';
import ScheduleEndForm from './ScheduleEndForm';
import parseRuleObj from './parseRuleObj';
import { buildDtStartObj } from './buildRuleObj';
const GroupWrapper = styled(FormGroup)`
&& .pf-c-form__group-control {
display: flex;
padding-top: 10px;
}
&& .pf-c-form__group-label {
padding-top: 20px;
}
`;
function ScheduleFormWizard({ isOpen, setIsOpen }) {
const { values, resetForm, initialValues } = useFormikContext();
const [freq, freqMeta] = useField('freq');
const [{ value: frequenciesValue }] = useField('frequencies');
const [interval, , intervalHelpers] = useField('interval');
const handleSubmit = (goToStepById) => {
const {
name,
description,
endingType,
endTime,
endDate,
timezone,
startDate,
startTime,
frequencies,
...rest
} = values;
if (endingType === 'onDate') {
const dt = DateTime.fromFormat(
`${endDate} ${endTime}`,
'yyyy-MM-dd h:mm a',
{
zone: timezone,
}
);
rest.until = formatDateString(dt, timezone);
delete rest.count;
}
if (endingType === 'never') delete rest.count;
const rule = new RRule(rest);
const start = buildDtStartObj({
startDate: values.startDate,
startTime: values.startTime,
timezone: values.timezone,
frequency: values.freq,
});
const newFrequency = parseRuleObj({
timezone,
frequency: freq.value,
rrule: rule.toString(),
dtstart: start,
});
if (goToStepById) {
goToStepById(1);
}
resetForm({
values: {
...initialValues,
description: values.description,
name: values.name,
startDate: values.startDate,
startTime: values.startTime,
timezone: values.timezone,
frequencies: frequenciesValue[0].frequency.length
? [...frequenciesValue, newFrequency]
: [newFrequency],
},
});
};
const CustomFooter = (
<WizardFooter>
<WizardContextConsumer>
{({ activeStep, onNext, onBack, goToStepById }) => (
<>
{activeStep.id === 2 ? (
<>
<Button
variant="primary"
onClick={() => {
handleSubmit(true, goToStepById);
}}
>{t`Finish and create new`}</Button>
<Button
variant="secondary"
onClick={() => {
handleSubmit(false);
setIsOpen(false);
}}
>{t`Finish and close`}</Button>
</>
) : (
<Button variant="primary" onClick={onNext}>{t`Next`}</Button>
)}
<Button variant="secondary" onClick={onBack}>{t`Back`}</Button>
<Button
variant="plain"
onClick={() => {
setIsOpen(false);
resetForm({
values: {
...initialValues,
description: values.description,
name: values.name,
startDate: values.startDate,
startTime: values.startTime,
timezone: values.timezone,
frequencies: values.frequencies,
},
});
}}
>{t`Cancel`}</Button>
</>
)}
</WizardContextConsumer>
</WizardFooter>
);
return (
<Wizard
onClose={() => setIsOpen(false)}
isOpen={isOpen}
footer={CustomFooter}
steps={[
{
key: 'frequency',
name: 'Frequency',
id: 1,
component: (
<>
<Title size="md" headingLevel="h4">{t`Repeat frequency`}</Title>
<GroupWrapper
name="freq"
fieldId="schedule-frequency"
isRequired
helperTextInvalid={freqMeta.error}
validated={
!freqMeta.touched || !freqMeta.error ? 'default' : 'error'
}
label={<b>{t`Frequency`}</b>}
>
<FrequencySelect />
</GroupWrapper>
<GroupWrapper isRequired label={<b>{t`Interval`}</b>}>
<TextInput
type="number"
value={interval.value}
placeholder={t`Choose an interval for the schedule`}
aria-label={t`Choose an interval for the schedule`}
onChange={(v) => intervalHelpers.setValue(v)}
/>
</GroupWrapper>
<WeekdayForm />
<MonthandYearForm />
<OrdinalDayForm />
</>
),
},
{
name: 'End',
key: 'end',
id: 2,
component: <ScheduleEndForm />,
},
]}
/>
);
}
export default ScheduleFormWizard;

View File

@@ -0,0 +1,164 @@
import React, { useState } from 'react';
import { t } from '@lingui/macro';
import {
Checkbox as _Checkbox,
FormGroup,
Select,
SelectOption,
} from '@patternfly/react-core';
import { useField } from 'formik';
import { RRule } from 'rrule';
import styled from 'styled-components';
import { weekdayOptions } from './scheduleFormHelpers';
const Checkbox = styled(_Checkbox)`
:not(:last-of-type) {
margin-right: 10px;
}
`;
const GroupWrapper = styled(FormGroup)`
&& .pf-c-form__group-control {
display: flex;
padding-top: 10px;
}
&& .pf-c-form__group-label {
padding-top: 20px;
}
`;
function WeekdayForm({ id }) {
const [isOpen, setIsOpen] = useState(false);
const [daysOfWeek, daysOfWeekMeta, daysOfWeekHelpers] = useField('byweekday');
const [weekStartDay, , weekStartDayHelpers] = useField('wkst');
const updateDaysOfWeek = (day, checked) => {
const newDaysOfWeek = daysOfWeek.value ? [...daysOfWeek.value] : [];
daysOfWeekHelpers.setTouched(true);
if (checked) {
newDaysOfWeek.push(day);
daysOfWeekHelpers.setValue(newDaysOfWeek);
} else {
daysOfWeekHelpers.setValue(
newDaysOfWeek.filter((selectedDay) => selectedDay !== day)
);
}
};
return (
<>
<GroupWrapper
name="wkst"
label={<b>{t`Select the first day of the week`}</b>}
>
<Select
onSelect={(e, value) => {
weekStartDayHelpers.setValue(value);
setIsOpen(false);
}}
onBlur={() => setIsOpen(false)}
selections={weekStartDay.value}
onToggle={(isopen) => setIsOpen(isopen)}
isOpen={isOpen}
id={`schedule-run-on-the-day-${id}`}
onChange={(e, v) => {
weekStartDayHelpers.setValue(v);
}}
{...weekStartDay}
>
{weekdayOptions.map(({ key, value, label }) => (
<SelectOption key={key} value={value}>
{label}
</SelectOption>
))}
</Select>
</GroupWrapper>
<GroupWrapper
name="byweekday"
fieldId={`schedule-days-of-week-${id}`}
helperTextInvalid={daysOfWeekMeta.error}
validated={
!daysOfWeekMeta.touched || !daysOfWeekMeta.error ? 'default' : 'error'
}
label={<b>{t`On selected day(s) of the week`}</b>}
>
<Checkbox
label={t`Sun`}
isChecked={daysOfWeek.value?.includes(RRule.SU)}
onChange={(checked) => {
updateDaysOfWeek(RRule.SU, checked);
}}
aria-label={t`Sunday`}
id={`schedule-days-of-week-sun-${id}`}
ouiaId={`schedule-days-of-week-sun-${id}`}
name="daysOfWeek"
/>
<Checkbox
label={t`Mon`}
isChecked={daysOfWeek.value?.includes(RRule.MO)}
onChange={(checked) => {
updateDaysOfWeek(RRule.MO, checked);
}}
aria-label={t`Monday`}
id={`schedule-days-of-week-mon-${id}`}
ouiaId={`schedule-days-of-week-mon-${id}`}
name="daysOfWeek"
/>
<Checkbox
label={t`Tue`}
isChecked={daysOfWeek.value?.includes(RRule.TU)}
onChange={(checked) => {
updateDaysOfWeek(RRule.TU, checked);
}}
aria-label={t`Tuesday`}
id={`schedule-days-of-week-tue-${id}`}
ouiaId={`schedule-days-of-week-tue-${id}`}
name="daysOfWeek"
/>
<Checkbox
label={t`Wed`}
isChecked={daysOfWeek.value?.includes(RRule.WE)}
onChange={(checked) => {
updateDaysOfWeek(RRule.WE, checked);
}}
aria-label={t`Wednesday`}
id={`schedule-days-of-week-wed-${id}`}
ouiaId={`schedule-days-of-week-wed-${id}`}
name="daysOfWeek"
/>
<Checkbox
label={t`Thu`}
isChecked={daysOfWeek.value?.includes(RRule.TH)}
onChange={(checked) => {
updateDaysOfWeek(RRule.TH, checked);
}}
aria-label={t`Thursday`}
id={`schedule-days-of-week-thu-${id}`}
ouiaId={`schedule-days-of-week-thu-${id}`}
name="daysOfWeek"
/>
<Checkbox
label={t`Fri`}
isChecked={daysOfWeek.value?.includes(RRule.FR)}
onChange={(checked) => {
updateDaysOfWeek(RRule.FR, checked);
}}
aria-label={t`Friday`}
id={`schedule-days-of-week-fri-${id}`}
ouiaId={`schedule-days-of-week-fri-${id}`}
name="daysOfWeek"
/>
<Checkbox
label={t`Sat`}
isChecked={daysOfWeek.value?.includes(RRule.SA)}
onChange={(checked) => {
updateDaysOfWeek(RRule.SA, checked);
}}
aria-label={t`Saturday`}
id={`schedule-days-of-week-sat-${id}`}
ouiaId={`schedule-days-of-week-sat-${id}`}
name="daysOfWeek"
/>
</GroupWrapper>
</>
);
}
export default WeekdayForm;

View File

@@ -1,7 +1,5 @@
import { t } from '@lingui/macro';
import { RRule } from 'rrule'; import { RRule } from 'rrule';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { getRRuleDayConstants } from 'util/dates';
window.RRule = RRule; window.RRule = RRule;
window.DateTime = DateTime; window.DateTime = DateTime;
@@ -22,7 +20,7 @@ export function buildDtStartObj(values) {
startHour startHour
)}${pad(startMinute)}00`; )}${pad(startMinute)}00`;
const rruleString = values.timezone const rruleString = values.timezone
? `DTSTART;TZID=${values.timezone}:${dateString}` ? `DTSTART;TZID=${values.timezone}${dateString}`
: `DTSTART:${dateString}Z`; : `DTSTART:${dateString}Z`;
const rule = RRule.fromString(rruleString); const rule = RRule.fromString(rruleString);
@@ -38,7 +36,8 @@ function pad(num) {
export default function buildRuleObj(values, includeStart) { export default function buildRuleObj(values, includeStart) {
const ruleObj = { const ruleObj = {
interval: values.interval, interval: values.interval || 1,
freq: values.freq,
}; };
if (includeStart) { if (includeStart) {
@@ -49,68 +48,6 @@ export default function buildRuleObj(values, includeStart) {
); );
} }
switch (values.frequency) {
case 'none':
ruleObj.count = 1;
ruleObj.freq = RRule.MINUTELY;
break;
case 'minute':
ruleObj.freq = RRule.MINUTELY;
break;
case 'hour':
ruleObj.freq = RRule.HOURLY;
break;
case 'day':
ruleObj.freq = RRule.DAILY;
break;
case 'week':
ruleObj.freq = RRule.WEEKLY;
ruleObj.byweekday = values.daysOfWeek;
break;
case 'month':
ruleObj.freq = RRule.MONTHLY;
if (values.runOn === 'day') {
ruleObj.bymonthday = values.runOnDayNumber;
} else if (values.runOn === 'the') {
ruleObj.bysetpos = parseInt(values.runOnTheOccurrence, 10);
ruleObj.byweekday = getRRuleDayConstants(values.runOnTheDay);
}
break;
case 'year':
ruleObj.freq = RRule.YEARLY;
if (values.runOn === 'day') {
ruleObj.bymonth = parseInt(values.runOnDayMonth, 10);
ruleObj.bymonthday = values.runOnDayNumber;
} else if (values.runOn === 'the') {
ruleObj.bysetpos = parseInt(values.runOnTheOccurrence, 10);
ruleObj.byweekday = getRRuleDayConstants(values.runOnTheDay);
ruleObj.bymonth = parseInt(values.runOnTheMonth, 10);
}
break;
default:
throw new Error(t`Frequency did not match an expected value`);
}
if (values.frequency !== 'none') {
switch (values.end) {
case 'never':
break;
case 'after':
ruleObj.count = values.occurrences;
break;
case 'onDate': {
ruleObj.until = buildDateTime(
values.endDate,
values.endTime,
values.timezone
);
break;
}
default:
throw new Error(t`End did not match an expected value (${values.end})`);
}
}
return ruleObj; return ruleObj;
} }

View File

@@ -1,5 +1,6 @@
import { RRule, RRuleSet } from 'rrule'; import { RRule, RRuleSet } from 'rrule';
import buildRuleObj, { buildDtStartObj } from './buildRuleObj'; import buildRuleObj, { buildDtStartObj } from './buildRuleObj';
import { FREQUENCIESCONSTANTS } from './scheduleFormHelpers';
window.RRuleSet = RRuleSet; window.RRuleSet = RRuleSet;
@@ -12,42 +13,31 @@ export default function buildRuleSet(values, useUTCStart) {
startDate: values.startDate, startDate: values.startDate,
startTime: values.startTime, startTime: values.startTime,
timezone: values.timezone, timezone: values.timezone,
frequency: values.freq,
}); });
set.rrule(startRule); set.rrule(startRule);
} }
if (values.frequency.length === 0) { values.frequencies.forEach(({ frequency, rrule }) => {
const rule = buildRuleObj( if (!frequencies.includes(frequency)) {
{
startDate: values.startDate,
startTime: values.startTime,
timezone: values.timezone,
frequency: 'none',
interval: 1,
},
useUTCStart
);
set.rrule(new RRule(rule));
}
frequencies.forEach((frequency) => {
if (!values.frequency.includes(frequency)) {
return; return;
} }
const rule = buildRuleObj( const rule = buildRuleObj(
{ {
startDate: values.startDate, startDate: values.startDate,
startTime: values.startTime, startTime: values.startTime,
timezone: values.timezone, timezone: values.timezone,
frequency, freq: FREQUENCIESCONSTANTS[frequency],
...values.frequencyOptions[frequency], rrule,
}, },
useUTCStart true
); );
set.rrule(new RRule(rule)); set.rrule(new RRule(rule));
}); });
frequencies.forEach((frequency) => { values.exceptions?.forEach(({ frequency, rrule }) => {
if (!values.exceptionFrequency?.includes(frequency)) { if (!values.exceptionFrequency?.includes(frequency)) {
return; return;
} }
@@ -56,8 +46,8 @@ export default function buildRuleSet(values, useUTCStart) {
startDate: values.startDate, startDate: values.startDate,
startTime: values.startTime, startTime: values.startTime,
timezone: values.timezone, timezone: values.timezone,
frequency, freq: FREQUENCIESCONSTANTS[frequency],
...values.exceptionOptions[frequency], rrule,
}, },
useUTCStart useUTCStart
); );

View File

@@ -12,12 +12,14 @@ export class UnsupportedRRuleError extends Error {
export default function parseRuleObj(schedule) { export default function parseRuleObj(schedule) {
let values = { let values = {
frequency: [], frequency: '',
frequencyOptions: {}, rrules: '',
exceptionFrequency: [],
exceptionOptions: {},
timezone: schedule.timezone, timezone: schedule.timezone,
}; };
if (Object.values(schedule).length === 0) {
return values;
}
const ruleset = rrulestr(schedule.rrule.replace(' ', '\n'), { const ruleset = rrulestr(schedule.rrule.replace(' ', '\n'), {
forceset: true, forceset: true,
}); });
@@ -40,25 +42,9 @@ export default function parseRuleObj(schedule) {
} }
}); });
if (isSingleOccurrence(values)) {
values.frequency = [];
values.frequencyOptions = {};
}
return values; return values;
} }
function isSingleOccurrence(values) {
if (values.frequency.length > 1) {
return false;
}
if (values.frequency[0] !== 'minute') {
return false;
}
const options = values.frequencyOptions.minute;
return options.end === 'after' && options.occurrences === 1;
}
function parseDtstart(schedule, values) { function parseDtstart(schedule, values) {
// TODO: should this rely on DTSTART in rruleset rather than schedule.dtstart? // TODO: should this rely on DTSTART in rruleset rather than schedule.dtstart?
const [startDate, startTime] = dateToInputDateTime( const [startDate, startTime] = dateToInputDateTime(
@@ -81,27 +67,12 @@ const frequencyTypes = {
[RRule.YEARLY]: 'year', [RRule.YEARLY]: 'year',
}; };
function parseRrule(rruleString, schedule, values) { function parseRrule(rruleString, schedule) {
const { frequency, options } = parseRule( const { frequency } = parseRule(rruleString, schedule);
rruleString,
schedule,
values.exceptionFrequency
);
if (values.frequencyOptions[frequency]) { const freq = { frequency, rrule: rruleString };
throw new UnsupportedRRuleError(
'Duplicate exception frequency types not supported'
);
}
return { return freq;
...values,
frequency: [...values.frequency, frequency].sort(sortFrequencies),
frequencyOptions: {
...values.frequencyOptions,
[frequency]: options,
},
};
} }
function parseExRule(exruleString, schedule, values) { function parseExRule(exruleString, schedule, values) {
@@ -129,20 +100,10 @@ function parseExRule(exruleString, schedule, values) {
}; };
} }
function parseRule(ruleString, schedule, frequencies) { function parseRule(ruleString, schedule) {
const { const {
origOptions: { origOptions: { count, freq, interval, until, ...rest },
bymonth,
bymonthday,
bysetpos,
byweekday,
count,
freq,
interval,
until,
},
} = RRule.fromString(ruleString); } = RRule.fromString(ruleString);
const now = DateTime.now(); const now = DateTime.now();
const closestQuarterHour = DateTime.fromMillis( const closestQuarterHour = DateTime.fromMillis(
Math.ceil(now.ts / 900000) * 900000 Math.ceil(now.ts / 900000) * 900000
@@ -156,17 +117,17 @@ function parseRule(ruleString, schedule, frequencies) {
endTime: time, endTime: time,
occurrences: 1, occurrences: 1,
interval: 1, interval: 1,
end: 'never', endingType: 'never',
}; };
if (until) { if (until?.length) {
options.end = 'onDate'; options.endingType = 'onDate';
const end = DateTime.fromISO(until.toISOString()); const end = DateTime.fromISO(until.toISOString());
const [endDate, endTime] = dateToInputDateTime(end, schedule.timezone); const [endDate, endTime] = dateToInputDateTime(end, schedule.timezone);
options.endDate = endDate; options.endDate = endDate;
options.endTime = endTime; options.endTime = endTime;
} else if (count) { } else if (count) {
options.end = 'after'; options.endingType = 'after';
options.occurrences = count; options.occurrences = count;
} }
@@ -178,101 +139,10 @@ function parseRule(ruleString, schedule, frequencies) {
throw new Error(`Unexpected rrule frequency: ${freq}`); throw new Error(`Unexpected rrule frequency: ${freq}`);
} }
const frequency = frequencyTypes[freq]; const frequency = frequencyTypes[freq];
if (frequencies.includes(frequency)) {
throw new Error(`Duplicate frequency types not supported (${frequency})`);
}
if (freq === RRule.WEEKLY && byweekday) {
options.daysOfWeek = byweekday;
}
if (freq === RRule.MONTHLY) {
options.runOn = 'day';
options.runOnTheOccurrence = 1;
options.runOnTheDay = 'sunday';
options.runOnDayNumber = 1;
if (bymonthday) {
options.runOnDayNumber = bymonthday;
}
if (bysetpos) {
options.runOn = 'the';
options.runOnTheOccurrence = bysetpos;
options.runOnTheDay = generateRunOnTheDay(byweekday);
}
}
if (freq === RRule.YEARLY) {
options.runOn = 'day';
options.runOnTheOccurrence = 1;
options.runOnTheDay = 'sunday';
options.runOnTheMonth = 1;
options.runOnDayMonth = 1;
options.runOnDayNumber = 1;
if (bymonthday) {
options.runOnDayNumber = bymonthday;
options.runOnDayMonth = bymonth;
}
if (bysetpos) {
options.runOn = 'the';
options.runOnTheOccurrence = bysetpos;
options.runOnTheDay = generateRunOnTheDay(byweekday);
options.runOnTheMonth = bymonth;
}
}
return { return {
frequency, frequency,
options, ...options,
...rest,
}; };
} }
function generateRunOnTheDay(days = []) {
if (
[
RRule.MO,
RRule.TU,
RRule.WE,
RRule.TH,
RRule.FR,
RRule.SA,
RRule.SU,
].every((element) => days.indexOf(element) > -1)
) {
return 'day';
}
if (
[RRule.MO, RRule.TU, RRule.WE, RRule.TH, RRule.FR].every(
(element) => days.indexOf(element) > -1
)
) {
return 'weekday';
}
if ([RRule.SA, RRule.SU].every((element) => days.indexOf(element) > -1)) {
return 'weekendDay';
}
if (days.indexOf(RRule.MO) > -1) {
return 'monday';
}
if (days.indexOf(RRule.TU) > -1) {
return 'tuesday';
}
if (days.indexOf(RRule.WE) > -1) {
return 'wednesday';
}
if (days.indexOf(RRule.TH) > -1) {
return 'thursday';
}
if (days.indexOf(RRule.FR) > -1) {
return 'friday';
}
if (days.indexOf(RRule.SA) > -1) {
return 'saturday';
}
if (days.indexOf(RRule.SU) > -1) {
return 'sunday';
}
return null;
}

View File

@@ -0,0 +1,232 @@
import { t } from '@lingui/macro';
import { DateTime } from 'luxon';
import { RRule } from 'rrule';
import buildRuleObj from './buildRuleObj';
import buildRuleSet from './buildRuleSet';
// const NUM_DAYS_PER_FREQUENCY = {
// week: 7,
// month: 31,
// year: 365,
// };
// const validateSchedule = () =>
// const errors = {};
// values.frequencies.forEach((freq) => {
// const options = values.frequencyOptions[freq];
// const freqErrors = {};
// if (
// (freq === 'month' || freq === 'year') &&
// options.runOn === 'day' &&
// (options.runOnDayNumber < 1 || options.runOnDayNumber > 31)
// ) {
// freqErrors.runOn = t`Please select a day number between 1 and 31.`;
// }
// if (options.end === 'after' && !options.occurrences) {
// freqErrors.occurrences = t`Please enter a number of occurrences.`;
// }
// if (options.end === 'onDate') {
// if (
// DateTime.fromFormat(
// `${values.startDate} ${values.startTime}`,
// 'yyyy-LL-dd h:mm a'
// ).toMillis() >=
// DateTime.fromFormat(
// `${options.endDate} ${options.endTime}`,
// 'yyyy-LL-dd h:mm a'
// ).toMillis()
// ) {
// freqErrors.endDate = t`Please select an end date/time that comes after the start date/time.`;
// }
// if (
// DateTime.fromISO(options.endDate)
// .diff(DateTime.fromISO(values.startDate), 'days')
// .toObject().days < NUM_DAYS_PER_FREQUENCY[freq]
// ) {
// const rule = new RRule(
// buildRuleObj({
// startDate: values.startDate,
// startTime: values.startTime,
// frequencies: freq,
// ...options,
// })
// );
// if (rule.all().length === 0) {
// errors.startDate = t`Selected date range must have at least 1 schedule occurrence.`;
// freqErrors.endDate = t`Selected date range must have at least 1 schedule occurrence.`;
// }
// }
// }
// if (Object.keys(freqErrors).length > 0) {
// if (!errors.frequencyOptions) {
// errors.frequencyOptions = {};
// }
// errors.frequencyOptions[freq] = freqErrors;
// }
// });
// if (values.exceptionFrequency.length > 0 && !scheduleHasInstances(values)) {
// errors.exceptionFrequency = t`This schedule has no occurrences due to the
// selected exceptions.`;
// }
// ({});
// function scheduleHasInstances(values) {
// let rangeToCheck = 1;
// values.frequencies.forEach((freq) => {
// if (NUM_DAYS_PER_FREQUENCY[freq] > rangeToCheck) {
// rangeToCheck = NUM_DAYS_PER_FREQUENCY[freq];
// }
// });
// const ruleSet = buildRuleSet(values, true);
// const startDate = DateTime.fromISO(values.startDate);
// const endDate = startDate.plus({ days: rangeToCheck });
// const instances = ruleSet.between(
// startDate.toJSDate(),
// endDate.toJSDate(),
// true,
// (date, i) => i === 0
// );
// return instances.length > 0;
// }
const bysetposOptions = [
{ value: '', key: 'none', label: 'None' },
{ value: 1, key: 'first', label: t`First` },
{
value: 2,
key: 'second',
label: t`Second`,
},
{ value: 3, key: 'third', label: t`Third` },
{
value: 4,
key: 'fourth',
label: t`Fourth`,
},
{ value: 5, key: 'fifth', label: t`Fifth` },
{ value: -1, key: 'last', label: t`Last` },
];
const monthOptions = [
{
key: 'january',
value: 1,
label: t`January`,
},
{
key: 'february',
value: 2,
label: t`February`,
},
{
key: 'march',
value: 3,
label: t`March`,
},
{
key: 'april',
value: 4,
label: t`April`,
},
{
key: 'may',
value: 5,
label: t`May`,
},
{
key: 'june',
value: 6,
label: t`June`,
},
{
key: 'july',
value: 7,
label: t`July`,
},
{
key: 'august',
value: 8,
label: t`August`,
},
{
key: 'september',
value: 9,
label: t`September`,
},
{
key: 'october',
value: 10,
label: t`October`,
},
{
key: 'november',
value: 11,
label: t`November`,
},
{
key: 'december',
value: 12,
label: t`December`,
},
];
const weekdayOptions = [
{
value: RRule.SU,
key: 'sunday',
label: t`Sunday`,
},
{
value: RRule.MO,
key: 'monday',
label: t`Monday`,
},
{
value: RRule.TU,
key: 'tuesday',
label: t`Tuesday`,
},
{
value: RRule.WE,
key: 'wednesday',
label: t`Wednesday`,
},
{
value: RRule.TH,
key: 'thursday',
label: t`Thursday`,
},
{
value: RRule.FR,
key: 'friday',
label: t`Friday`,
},
{
value: RRule.SA,
key: 'saturday',
label: t`Saturday`,
},
];
const FREQUENCIESCONSTANTS = {
minute: RRule.MINUTELY,
hour: RRule.HOURLY,
day: RRule.DAILY,
week: RRule.WEEKLY,
month: RRule.MONTHLY,
year: RRule.YEARLY,
};
export {
monthOptions,
weekdayOptions,
bysetposOptions,
// validateSchedule,
FREQUENCIESCONSTANTS,
};

View File

@@ -41,7 +41,7 @@ function JobEvent({
if (lineNumber < 0) { if (lineNumber < 0) {
return null; return null;
} }
const canToggle = index === toggleLineIndex; const canToggle = index === toggleLineIndex && !event.isTracebackOnly;
return ( return (
<JobEventLine <JobEventLine
onClick={isClickable ? onJobEventClick : undefined} onClick={isClickable ? onJobEventClick : undefined}
@@ -55,7 +55,7 @@ function JobEvent({
onToggle={onToggleCollapsed} onToggle={onToggleCollapsed}
/> />
<JobEventLineNumber> <JobEventLineNumber>
{lineNumber} {!event.isTracebackOnly ? lineNumber : ''}
<JobEventEllipsis isCollapsed={isCollapsed && canToggle} /> <JobEventEllipsis isCollapsed={isCollapsed && canToggle} />
</JobEventLineNumber> </JobEventLineNumber>
<JobEventLineText <JobEventLineText

View File

@@ -29,8 +29,11 @@ export function prependTraceback(job, events) {
start_line: 0, start_line: 0,
}; };
const firstIndex = events.findIndex((jobEvent) => jobEvent.counter === 1); const firstIndex = events.findIndex((jobEvent) => jobEvent.counter === 1);
if (firstIndex && events[firstIndex]?.stdout) { if (firstIndex > -1) {
const stdoutLines = events[firstIndex].stdout.split('\r\n'); if (!events[firstIndex].stdout) {
events[firstIndex].isTracebackOnly = true;
}
const stdoutLines = events[firstIndex].stdout?.split('\r\n') || [];
stdoutLines[0] = tracebackEvent.stdout; stdoutLines[0] = tracebackEvent.stdout;
events[firstIndex].stdout = stdoutLines.join('\r\n'); events[firstIndex].stdout = stdoutLines.join('\r\n');
} else { } else {

View File

@@ -0,0 +1,3 @@
---
modules:
python_requires: '>3'

View File

@@ -1,88 +0,0 @@
plugins/module_utils/awxkit.py import-3.9
plugins/module_utils/controller_api.py import-3.9
plugins/modules/ad_hoc_command.py import-3.9
plugins/modules/ad_hoc_command_cancel.py import-3.9
plugins/modules/ad_hoc_command_wait.py import-3.9
plugins/modules/application.py import-3.9
plugins/modules/controller_meta.py import-3.9
plugins/modules/credential.py import-3.92
plugins/modules/credential_input_source.py import-3.9
plugins/modules/credential_type.py import-3.9
plugins/modules/execution_environment.py import-3.9
plugins/modules/export.py import-3.9
plugins/modules/group.py import-3.9
plugins/modules/host.py import-3.9
plugins/modules/import.py import-3.9
plugins/modules/instance.py import-3.9
plugins/modules/instance_group.py import-3.9
plugins/modules/inventory.py import-3.9
plugins/modules/inventory_source.py import-3.9
plugins/modules/inventory_source_update.py import-3.9
plugins/modules/job_cancel.py import-3.9
plugins/modules/job_launch.py import-3.9
plugins/modules/job_list.py import-3.9
plugins/modules/job_template.py import-3.93
plugins/modules/job_wait.py import-3.9
plugins/modules/label.py import-3.9
plugins/modules/license.py import-3.9
plugins/modules/notification_template.py import-3.9
plugins/modules/organization.py import-3.9
plugins/modules/project.py import-3.92
plugins/modules/project_update.py import-3.9
plugins/modules/role.py import-3.9
plugins/modules/schedule.py import-3.9
plugins/modules/settings.py import-3.9
plugins/modules/subscriptions.py import-3.9
plugins/modules/team.py import-3.9
plugins/modules/token.py import-3.9
plugins/modules/user.py import-3.9
plugins/modules/workflow_approval.py import-3.9
plugins/modules/workflow_job_template.py import-3.9
plugins/modules/workflow_job_template_node.py import-3.9
plugins/modules/workflow_launch.py import-3.9
plugins/modules/workflow_node_wait.py import-3.9
plugins/inventory/controller.py import-3.10
plugins/lookup/controller_api.py import-3.10
plugins/module_utils/awxkit.py import-3.10
plugins/module_utils/controller_api.py import-3.10
plugins/modules/ad_hoc_command.py import-3.10
plugins/modules/ad_hoc_command_cancel.py import-3.10
plugins/modules/ad_hoc_command_wait.py import-3.10
plugins/modules/application.py import-3.10
plugins/modules/controller_meta.py import-3.10
plugins/modules/credential.py import-3.10
plugins/modules/credential_input_source.py import-3.10
plugins/modules/credential_type.py import-3.10
plugins/modules/execution_environment.py import-3.10
plugins/modules/export.py import-3.10
plugins/modules/group.py import-3.10
plugins/modules/host.py import-3.10
plugins/modules/import.py import-3.10
plugins/modules/instance.py import-3.10
plugins/modules/instance_group.py import-3.10
plugins/modules/inventory.py import-3.10
plugins/modules/inventory_source.py import-3.10
plugins/modules/inventory_source_update.py import-3.10
plugins/modules/job_cancel.py import-3.10
plugins/modules/job_launch.py import-3.10
plugins/modules/job_list.py import-3.10
plugins/modules/job_template.py import-3.10
plugins/modules/job_wait.py import-3.10
plugins/modules/label.py import-3.10
plugins/modules/license.py import-3.10
plugins/modules/notification_template.py import-3.10
plugins/modules/organization.py import-3.10
plugins/modules/project.py import-3.10
plugins/modules/project_update.py import-3.10
plugins/modules/role.py import-3.10
plugins/modules/schedule.py import-3.10
plugins/modules/settings.py import-3.10
plugins/modules/subscriptions.py import-3.10
plugins/modules/team.py import-3.10
plugins/modules/token.py import-3.10
plugins/modules/user.py import-3.10
plugins/modules/workflow_approval.py import-3.10
plugins/modules/workflow_job_template.py import-3.10
plugins/modules/workflow_job_template_node.py import-3.10
plugins/modules/workflow_launch.py import-3.10
plugins/modules/workflow_node_wait.py import-3.10

View File

@@ -0,0 +1 @@
plugins/modules/export.py validate-modules:nonexistent-parameter-documented # needs awxkit to construct argspec

View File

@@ -0,0 +1 @@
plugins/modules/export.py validate-modules:nonexistent-parameter-documented # needs awxkit to construct argspec

View File

@@ -157,6 +157,15 @@ For now, we pin to the old version, 3.4.1
* https://github.com/django/channels_redis/issues/332 * https://github.com/django/channels_redis/issues/332
* https://github.com/ansible/awx/issues/13313 * https://github.com/ansible/awx/issues/13313
### hiredis
The hiredis 2.1.0 release doesn't provide source distribution on PyPI which prevents users to build that python package from the
sources.
Downgrading to 2.0.0 (which provides source distribution) until the channels-redis issue is fixed or a newer hiredis version is
available on PyPi with source distribution.
* https://github.com/redis/hiredis-py/issues/138
## Library Notes ## Library Notes
### pexpect ### pexpect

View File

@@ -26,6 +26,7 @@ djangorestframework==3.13.1
djangorestframework-yaml djangorestframework-yaml
filelock filelock
GitPython GitPython
hiredis==2.0.0 # see UPGRADE BLOCKERs
irc irc
jinja2 jinja2
JSON-log-formatter JSON-log-formatter
@@ -42,7 +43,7 @@ python-dsv-sdk
python-tss-sdk==1.0.0 python-tss-sdk==1.0.0
python-ldap python-ldap
pyyaml pyyaml
receptorctl==1.2.3 receptorctl==1.3.0
schedule==0.6.0 schedule==0.6.0
social-auth-core[openidconnect]==4.3.0 # see UPGRADE BLOCKERs social-auth-core[openidconnect]==4.3.0 # see UPGRADE BLOCKERs
social-auth-app-django==5.0.0 # see UPGRADE BLOCKERs social-auth-app-django==5.0.0 # see UPGRADE BLOCKERs

View File

@@ -158,8 +158,10 @@ gitpython==3.1.29
# via -r /awx_devel/requirements/requirements.in # via -r /awx_devel/requirements/requirements.in
google-auth==2.14.1 google-auth==2.14.1
# via kubernetes # via kubernetes
hiredis==2.1.0 hiredis==2.0.0
# via aioredis # via
# -r /awx_devel/requirements/requirements.in
# aioredis
hyperlink==21.0.0 hyperlink==21.0.0
# via # via
# autobahn # autobahn
@@ -336,7 +338,7 @@ pyyaml==6.0
# djangorestframework-yaml # djangorestframework-yaml
# kubernetes # kubernetes
# receptorctl # receptorctl
receptorctl==1.2.3 receptorctl==1.3.0
# via -r /awx_devel/requirements/requirements.in # via -r /awx_devel/requirements/requirements.in
redis==4.3.5 redis==4.3.5
# via # via

View File

@@ -116,7 +116,7 @@ RUN dnf -y update && dnf install -y 'dnf-command(config-manager)' && \
python3-psycopg2 \ python3-psycopg2 \
python3-setuptools \ python3-setuptools \
rsync \ rsync \
"rsyslog >= 8.1911.0" \ rsyslog-8.2102.0-106.el9 \
subversion \ subversion \
sudo \ sudo \
vim-minimal \ vim-minimal \