Compare commits

..

1 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
91 changed files with 1212 additions and 1782 deletions

View File

@@ -106,13 +106,6 @@ The Ansible Community is looking at building an EE that corresponds to all of th
### Oracle AWX
We'd be happy to help if you can reproduce this with AWX since we do not have Oracle's Linux Automation Manager. If you need help with this specific version of Oracles Linux Automation Manager you will need to contact your Oracle for support.
### Community Resolved
Hi,
We are happy to see that it appears a fix has been provided for your issue, so we will go ahead and close this ticket. Please feel free to reopen if any other problems arise.
<name of community member who helped> thanks so much for taking the time to write a thoughtful and helpful response to this issue!
### AWX Release
Subject: Announcing AWX Xa.Ya.za and AWX-Operator Xb.Yb.zb

View File

@@ -17,9 +17,9 @@ jobs:
env:
PR_BODY: ${{ github.event.pull_request.body }}
run: |
echo "$PR_BODY" | grep "Bug, Docs Fix or other nominal change" > Z
echo "$PR_BODY" | grep "New or Enhanced Feature" > Y
echo "$PR_BODY" | grep "Breaking Change" > X
echo $PR_BODY | grep "Bug, Docs Fix or other nominal change" > Z
echo $PR_BODY | grep "New or Enhanced Feature" > Y
echo $PR_BODY | grep "Breaking Change" > X
exit 0
# We exit 0 and set the shell to prevent the returns from the greps from failing this step
# See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#exit-codes-and-error-action-preference

View File

@@ -38,13 +38,9 @@ jobs:
- name: Build collection and publish to galaxy
run: |
COLLECTION_TEMPLATE_VERSION=true COLLECTION_NAMESPACE=${{ env.collection_namespace }} make build_collection
if [ "$(curl --head -sw '%{http_code}' https://galaxy.ansible.com/download/${{ env.collection_namespace }}-awx-${{ github.event.release.tag_name }}.tar.gz | tail -1)" == "302" ] ; then \
echo "Galaxy release already done"; \
else \
ansible-galaxy collection publish \
--token=${{ secrets.GALAXY_TOKEN }} \
awx_collection_build/${{ env.collection_namespace }}-awx-${{ github.event.release.tag_name }}.tar.gz; \
fi
ansible-galaxy collection publish \
--token=${{ secrets.GALAXY_TOKEN }} \
awx_collection_build/${{ env.collection_namespace }}-awx-${{ github.event.release.tag_name }}.tar.gz
- name: Set official pypi info
run: echo pypi_repo=pypi >> $GITHUB_ENV
@@ -56,7 +52,6 @@ jobs:
- name: Build awxkit and upload to pypi
run: |
git reset --hard
cd awxkit && python3 setup.py bdist_wheel
twine upload \
-r ${{ env.pypi_repo }} \
@@ -79,6 +74,4 @@ jobs:
docker tag ghcr.io/${{ github.repository }}:${{ github.event.release.tag_name }} quay.io/${{ github.repository }}:latest
docker push quay.io/${{ github.repository }}:${{ github.event.release.tag_name }}
docker push quay.io/${{ github.repository }}:latest
docker pull ghcr.io/${{ github.repository_owner }}/awx-ee:${{ github.event.release.tag_name }}
docker tag ghcr.io/${{ github.repository_owner }}/awx-ee:${{ github.event.release.tag_name }} quay.io/${{ github.repository_owner }}/awx-ee:${{ github.event.release.tag_name }}
docker push quay.io/${{ github.repository_owner }}/awx-ee:${{ github.event.release.tag_name }}

View File

@@ -84,20 +84,6 @@ jobs:
-e push=yes \
-e awx_official=yes
- name: Log in to GHCR
run: |
echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Log in to Quay
run: |
echo ${{ secrets.QUAY_TOKEN }} | docker login quay.io -u ${{ secrets.QUAY_USER }} --password-stdin
- name: tag awx-ee:latest with version input
run: |
docker pull quay.io/ansible/awx-ee:latest
docker tag quay.io/ansible/awx-ee:latest ghcr.io/${{ github.repository_owner }}/awx-ee:${{ github.event.inputs.version }}
docker push ghcr.io/${{ github.repository_owner }}/awx-ee:${{ github.event.inputs.version }}
- name: Build and stage awx-operator
working-directory: awx-operator
run: |
@@ -117,7 +103,6 @@ jobs:
env:
AWX_TEST_IMAGE: ${{ github.repository }}
AWX_TEST_VERSION: ${{ github.event.inputs.version }}
AWX_EE_TEST_IMAGE: ghcr.io/${{ github.repository_owner }}/awx-ee:${{ github.event.inputs.version }}
- name: Create draft release for AWX
working-directory: awx

View File

@@ -96,15 +96,6 @@ register(
category=_('Authentication'),
category_slug='authentication',
)
register(
'ALLOW_METRICS_FOR_ANONYMOUS_USERS',
field_class=fields.BooleanField,
default=False,
label=_('Allow anonymous users to poll metrics'),
help_text=_('If true, anonymous users are allowed to poll metrics.'),
category=_('Authentication'),
category_slug='authentication',
)
def authentication_validate(serializer, attrs):

View File

@@ -5,11 +5,9 @@
import logging
# Django
from django.conf import settings
from django.utils.translation import gettext_lazy as _
# Django REST Framework
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.exceptions import PermissionDenied
@@ -33,14 +31,9 @@ class MetricsView(APIView):
renderer_classes = [renderers.PlainTextRenderer, renderers.PrometheusJSONRenderer, renderers.BrowsableAPIRenderer]
def initialize_request(self, request, *args, **kwargs):
if settings.ALLOW_METRICS_FOR_ANONYMOUS_USERS:
self.permission_classes = (AllowAny,)
return super(APIView, self).initialize_request(request, *args, **kwargs)
def get(self, request):
'''Show Metrics Details'''
if settings.ALLOW_METRICS_FOR_ANONYMOUS_USERS or request.user.is_superuser or request.user.is_system_auditor:
if request.user.is_superuser or request.user.is_system_auditor:
metrics_to_show = ''
if not request.query_params.get('subsystemonly', "0") == "1":
metrics_to_show += metrics().decode('UTF-8')

View File

@@ -1,7 +1,6 @@
import copy
import os
import pathlib
import time
from urllib.parse import urljoin
from .plugin import CredentialPlugin, CertFiles, raise_for_status
@@ -248,15 +247,7 @@ def kv_backend(**kwargs):
request_url = urljoin(url, '/'.join(['v1'] + path_segments)).rstrip('/')
with CertFiles(cacert) as cert:
request_kwargs['verify'] = cert
request_retries = 0
while request_retries < 5:
response = sess.get(request_url, **request_kwargs)
# https://developer.hashicorp.com/vault/docs/enterprise/consistency
if response.status_code == 412:
request_retries += 1
time.sleep(1)
else:
break
response = sess.get(request_url, **request_kwargs)
raise_for_status(response)
json = response.json()
@@ -298,15 +289,8 @@ def ssh_backend(**kwargs):
with CertFiles(cacert) as cert:
request_kwargs['verify'] = cert
request_retries = 0
while request_retries < 5:
resp = sess.post(request_url, **request_kwargs)
# https://developer.hashicorp.com/vault/docs/enterprise/consistency
if resp.status_code == 412:
request_retries += 1
time.sleep(1)
else:
break
resp = sess.post(request_url, **request_kwargs)
raise_for_status(resp)
return resp.json()['data']['signed_key']

View File

@@ -3,12 +3,14 @@ import logging
import os
import signal
import time
import traceback
import datetime
from django.conf import settings
from django.utils.functional import cached_property
from django.utils.timezone import now as tz_now
from django.db import transaction, connection as django_connection
from django.db import DatabaseError, OperationalError, transaction, connection as django_connection
from django.db.utils import InterfaceError, InternalError
from django_guid import set_guid
import psutil
@@ -62,7 +64,6 @@ class CallbackBrokerWorker(BaseWorker):
"""
MAX_RETRIES = 2
INDIVIDUAL_EVENT_RETRIES = 3
last_stats = time.time()
last_flush = time.time()
total = 0
@@ -163,48 +164,38 @@ class CallbackBrokerWorker(BaseWorker):
else: # only calculate the seconds if the created time already has been set
metrics_total_job_event_processing_seconds += e.modified - e.created
metrics_duration_to_save = time.perf_counter()
saved_events = []
try:
cls.objects.bulk_create(events)
metrics_bulk_events_saved += len(events)
saved_events = events
self.buff[cls] = []
except Exception as exc:
# If the database is flaking, let ensure_connection throw a general exception
# will be caught by the outer loop, which goes into a proper sleep and retry loop
django_connection.ensure_connection()
logger.warning(f'Error in events bulk_create, will try indiviually, error: {str(exc)}')
logger.warning(f'Error in events bulk_create, will try indiviually up to 5 errors, error {str(exc)}')
# if an exception occurs, we should re-attempt to save the
# events one-by-one, because something in the list is
# broken/stale
consecutive_errors = 0
events_saved = 0
metrics_events_batch_save_errors += 1
for e in events.copy():
for e in events:
try:
e.save()
metrics_singular_events_saved += 1
events.remove(e)
saved_events.append(e) # Importantly, remove successfully saved events from the buffer
events_saved += 1
consecutive_errors = 0
except Exception as exc_indv:
retry_count = getattr(e, '_retry_count', 0) + 1
e._retry_count = retry_count
# special sanitization logic for postgres treatment of NUL 0x00 char
if (retry_count == 1) and isinstance(exc_indv, ValueError) and ("\x00" in e.stdout):
e.stdout = e.stdout.replace("\x00", "")
if retry_count >= self.INDIVIDUAL_EVENT_RETRIES:
logger.error(f'Hit max retries ({retry_count}) saving individual Event error: {str(exc_indv)}\ndata:\n{e.__dict__}')
events.remove(e)
else:
logger.info(f'Database Error Saving individual Event uuid={e.uuid} try={retry_count}, error: {str(exc_indv)}')
consecutive_errors += 1
logger.info(f'Database Error Saving individual Job Event, error {str(exc_indv)}')
if consecutive_errors >= 5:
raise
metrics_singular_events_saved += events_saved
if events_saved == 0:
raise
metrics_duration_to_save = time.perf_counter() - metrics_duration_to_save
for e in saved_events:
for e in events:
if not getattr(e, '_skip_websocket_message', False):
metrics_events_broadcast += 1
emit_event_detail(e)
if getattr(e, '_notification_trigger_event', False):
job_stats_wrapup(getattr(e, e.JOB_REFERENCE), event=e)
self.buff = {}
self.last_flush = time.time()
# only update metrics if we saved events
if (metrics_bulk_events_saved + metrics_singular_events_saved) > 0:
@@ -276,16 +267,20 @@ class CallbackBrokerWorker(BaseWorker):
try:
self.flush(force=flush)
break
except Exception as exc:
# Aside form bugs, exceptions here are assumed to be due to database flake
except (OperationalError, InterfaceError, InternalError) as exc:
if retries >= self.MAX_RETRIES:
logger.exception('Worker could not re-establish database connectivity, giving up on one or more events.')
self.buff = {}
return
delay = 60 * retries
logger.warning(f'Database Error Flushing Job Events, retry #{retries + 1} in {delay} seconds: {str(exc)}')
django_connection.close()
time.sleep(delay)
retries += 1
except Exception:
logger.exception(f'Callback Task Processor Raised Unexpected Exception processing event data:\n{body}')
except DatabaseError:
logger.exception('Database Error Flushing Job Events')
django_connection.close()
break
except Exception as exc:
tb = traceback.format_exc()
logger.error('Callback Task Processor Raised Exception: %r', exc)
logger.error('Detail: {}'.format(tb))

View File

@@ -390,7 +390,6 @@ class BaseTask(object):
logger.error("I/O error({0}) while trying to open lock file [{1}]: {2}".format(e.errno, lock_path, e.strerror))
raise
emitted_lockfile_log = False
start_time = time.time()
while True:
try:
@@ -402,9 +401,6 @@ class BaseTask(object):
logger.error("I/O error({0}) while trying to aquire lock on file [{1}]: {2}".format(e.errno, lock_path, e.strerror))
raise
else:
if not emitted_lockfile_log:
logger.info(f"exception acquiring lock {lock_path}: {e}")
emitted_lockfile_log = True
time.sleep(1.0)
self.instance.refresh_from_db(fields=['cancel_flag'])
if self.instance.cancel_flag or signal_callback():

View File

@@ -1,15 +1,7 @@
import pytest
import time
from unittest import mock
from uuid import uuid4
from django.test import TransactionTestCase
from awx.main.dispatch.worker.callback import job_stats_wrapup, CallbackBrokerWorker
from awx.main.dispatch.worker.callback import job_stats_wrapup
from awx.main.models.jobs import Job
from awx.main.models.inventory import InventoryUpdate, InventorySource
from awx.main.models.events import InventoryUpdateEvent
@pytest.mark.django_db
@@ -32,108 +24,3 @@ def test_wrapup_does_send_notifications(mocker):
job.refresh_from_db()
assert job.host_status_counts == {}
mock.assert_called_once_with('succeeded')
class FakeRedis:
def keys(self, *args, **kwargs):
return []
def set(self):
pass
def get(self):
return None
@classmethod
def from_url(cls, *args, **kwargs):
return cls()
def pipeline(self):
return self
class TestCallbackBrokerWorker(TransactionTestCase):
@pytest.fixture(autouse=True)
def turn_off_websockets(self):
with mock.patch('awx.main.dispatch.worker.callback.emit_event_detail', lambda *a, **kw: None):
yield
def get_worker(self):
with mock.patch('redis.Redis', new=FakeRedis): # turn off redis stuff
return CallbackBrokerWorker()
def event_create_kwargs(self):
inventory_update = InventoryUpdate.objects.create(source='file', inventory_source=InventorySource.objects.create(source='file'))
return dict(inventory_update=inventory_update, created=inventory_update.created)
def test_flush_with_valid_event(self):
worker = self.get_worker()
events = [InventoryUpdateEvent(uuid=str(uuid4()), **self.event_create_kwargs())]
worker.buff = {InventoryUpdateEvent: events}
worker.flush()
assert worker.buff.get(InventoryUpdateEvent, []) == []
assert InventoryUpdateEvent.objects.filter(uuid=events[0].uuid).count() == 1
def test_flush_with_invalid_event(self):
worker = self.get_worker()
kwargs = self.event_create_kwargs()
events = [
InventoryUpdateEvent(uuid=str(uuid4()), stdout='good1', **kwargs),
InventoryUpdateEvent(uuid=str(uuid4()), stdout='bad', counter=-2, **kwargs),
InventoryUpdateEvent(uuid=str(uuid4()), stdout='good2', **kwargs),
]
worker.buff = {InventoryUpdateEvent: events.copy()}
worker.flush()
assert InventoryUpdateEvent.objects.filter(uuid=events[0].uuid).count() == 1
assert InventoryUpdateEvent.objects.filter(uuid=events[1].uuid).count() == 0
assert InventoryUpdateEvent.objects.filter(uuid=events[2].uuid).count() == 1
assert worker.buff == {InventoryUpdateEvent: [events[1]]}
def test_duplicate_key_not_saved_twice(self):
worker = self.get_worker()
events = [InventoryUpdateEvent(uuid=str(uuid4()), **self.event_create_kwargs())]
worker.buff = {InventoryUpdateEvent: events.copy()}
worker.flush()
# put current saved event in buffer (error case)
worker.buff = {InventoryUpdateEvent: [InventoryUpdateEvent.objects.get(uuid=events[0].uuid)]}
worker.last_flush = time.time() - 2.0
# here, the bulk_create will fail with UNIQUE constraint violation, but individual saves should resolve it
worker.flush()
assert InventoryUpdateEvent.objects.filter(uuid=events[0].uuid).count() == 1
assert worker.buff.get(InventoryUpdateEvent, []) == []
def test_give_up_on_bad_event(self):
worker = self.get_worker()
events = [InventoryUpdateEvent(uuid=str(uuid4()), counter=-2, **self.event_create_kwargs())]
worker.buff = {InventoryUpdateEvent: events.copy()}
for i in range(5):
worker.last_flush = time.time() - 2.0
worker.flush()
# Could not save, should be logged, and buffer should be cleared
assert worker.buff.get(InventoryUpdateEvent, []) == []
assert InventoryUpdateEvent.objects.filter(uuid=events[0].uuid).count() == 0 # sanity
def test_postgres_invalid_NUL_char(self):
# In postgres, text fields reject NUL character, 0x00
# tests use sqlite3 which will not raise an error
# but we can still test that it is sanitized before saving
worker = self.get_worker()
kwargs = self.event_create_kwargs()
events = [InventoryUpdateEvent(uuid=str(uuid4()), stdout="\x00", **kwargs)]
assert "\x00" in events[0].stdout # sanity
worker.buff = {InventoryUpdateEvent: events.copy()}
with mock.patch.object(InventoryUpdateEvent.objects, 'bulk_create', side_effect=ValueError):
with mock.patch.object(events[0], 'save', side_effect=ValueError):
worker.flush()
assert "\x00" not in events[0].stdout
worker.last_flush = time.time() - 2.0
worker.flush()
event = InventoryUpdateEvent.objects.get(uuid=events[0].uuid)
assert "\x00" not in event.stdout

View File

@@ -103,10 +103,6 @@ ColorHandler = logging.StreamHandler
if settings.COLOR_LOGS is True:
try:
from logutils.colorize import ColorizingStreamHandler
import colorama
colorama.deinit()
colorama.init(wrap=False, convert=False, strip=False)
class ColorHandler(ColorizingStreamHandler):
def colorize(self, line, record):

View File

@@ -290,7 +290,7 @@ TEMPLATES = [
],
'builtins': ['awx.main.templatetags.swagger'],
},
'DIRS': [os.path.join(BASE_DIR, 'templates'), '/var/lib/awx/public/static/controller/', os.path.join(BASE_DIR, 'ui', 'build'), os.path.join(BASE_DIR, 'ui', 'public')],
'DIRS': [os.path.join(BASE_DIR, 'templates'), os.path.join(BASE_DIR, 'ui', 'build'), os.path.join(BASE_DIR, 'ui', 'public')],
},
]
@@ -418,9 +418,6 @@ AUTH_BASIC_ENABLED = True
# when trying to access a UI page that requries authentication.
LOGIN_REDIRECT_OVERRIDE = ''
# Note: This setting may be overridden by database settings.
ALLOW_METRICS_FOR_ANONYMOUS_USERS = False
DEVSERVER_DEFAULT_ADDR = '0.0.0.0'
DEVSERVER_DEFAULT_PORT = '8013'

View File

@@ -452,10 +452,7 @@ def on_populate_user(sender, **kwargs):
remove = bool(team_opts.get('remove', True))
state = _update_m2m_from_groups(ldap_user, users_opts, remove)
if state is not None:
organization = team_opts['organization']
if organization not in desired_team_states:
desired_team_states[organization] = {}
desired_team_states[organization][team_name] = {'member_role': state}
desired_team_states[team_name] = {'member_role': state}
# Check if user.profile is available, otherwise force user.save()
try:
@@ -476,28 +473,16 @@ def on_populate_user(sender, **kwargs):
def reconcile_users_org_team_mappings(user, desired_org_states, desired_team_states, source):
#
# Arguments:
# user - a user object
# desired_org_states: { '<org_name>': { '<role>': <boolean> or None } }
# desired_team_states: { '<org_name>': { '<team name>': { '<role>': <boolean> or None } } }
# source - a text label indicating the "authentication adapter" for debug messages
#
# This function will load the users existing roles and then based on the deisred states modify the users roles
# True indicates the user needs to be a member of the role
# False indicates the user should not be a member of the role
# None means this function should not change the users membership of a role
#
from awx.main.models import Organization, Team
content_types = []
reconcile_items = []
if desired_org_states:
content_types.append(ContentType.objects.get_for_model(Organization))
reconcile_items.append(('organization', desired_org_states))
reconcile_items.append(('organization', desired_org_states, Organization))
if desired_team_states:
content_types.append(ContentType.objects.get_for_model(Team))
reconcile_items.append(('team', desired_team_states))
reconcile_items.append(('team', desired_team_states, Team))
if not content_types:
# If both desired states were empty we can simply return because there is nothing to reconcile
@@ -506,39 +491,24 @@ def reconcile_users_org_team_mappings(user, desired_org_states, desired_team_sta
# users_roles is a flat set of IDs
users_roles = set(user.roles.filter(content_type__in=content_types).values_list('pk', flat=True))
for object_type, desired_states in reconcile_items:
for object_type, desired_states, model in reconcile_items:
# Get all of the roles in the desired states for efficient DB extraction
roles = []
# Get a set of named tuples for the org/team name plus all of the roles we got above
if object_type == 'organization':
for sub_dict in desired_states.values():
for role_name in sub_dict:
if sub_dict[role_name] is None:
continue
if role_name not in roles:
roles.append(role_name)
model_roles = Organization.objects.filter(name__in=desired_states.keys()).values_list('name', *roles, named=True)
else:
team_names = []
for teams_dict in desired_states.values():
team_names.extend(teams_dict.keys())
for sub_dict in teams_dict.values():
for role_name in sub_dict:
if sub_dict[role_name] is None:
continue
if role_name not in roles:
roles.append(role_name)
model_roles = Team.objects.filter(name__in=team_names).values_list('name', 'organization__name', *roles, named=True)
for sub_dict in desired_states.values():
for role_name in sub_dict:
if sub_dict[role_name] is None:
continue
if role_name not in roles:
roles.append(role_name)
# Get a set of named tuples for the org/team name plus all of the roles we got above
model_roles = model.objects.filter(name__in=desired_states.keys()).values_list('name', *roles, named=True)
for row in model_roles:
for role_name in roles:
if object_type == 'organization':
desired_state = desired_states.get(row.name, {})
else:
desired_state = desired_states.get(row.organization__name, {}).get(row.name, {})
if desired_state.get(role_name, None) is None:
desired_state = desired_states.get(row.name, {})
if desired_state[role_name] is None:
# The mapping was not defined for this [org/team]/role so we can just pass
continue
pass
# If somehow the auth adapter knows about an items role but that role is not defined in the DB we are going to print a pretty error
# This is your classic safety net that we should never hit; but here you are reading this comment... good luck and Godspeed.

View File

@@ -34,14 +34,8 @@ const QS_CONFIG = getQSConfig('template', {
order_by: 'name',
});
const resources = {
projects: 'project',
inventories: 'inventory',
credentials: 'credentials',
};
function RelatedTemplateList({ searchParams, resourceName = null }) {
const { id } = useParams();
function RelatedTemplateList({ searchParams, projectName = null }) {
const { id: projectId } = useParams();
const location = useLocation();
const { addToast, Toast, toastProps } = useToast();
@@ -135,19 +129,12 @@ function RelatedTemplateList({ searchParams, resourceName = null }) {
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
let linkTo = '';
if (resourceName) {
const queryString = {
resource_id: id,
resource_name: resourceName,
resource_type: resources[location.pathname.split('/')[1]],
resource_kind: null,
};
if (Array.isArray(resourceName)) {
const [name, kind] = resourceName;
queryString.resource_name = name;
queryString.resource_kind = kind;
}
const qs = encodeQueryString(queryString);
if (projectName) {
const qs = encodeQueryString({
project_id: projectId,
project_name: projectName,
});
linkTo = `/templates/job_template/add/?${qs}`;
} else {
linkTo = '/templates/job_template/add';

View File

@@ -1 +0,0 @@
/* eslint-disable import/prefer-default-export */

View File

@@ -55,7 +55,6 @@ function DateTimePicker({ dateFieldName, timeFieldName, label }) {
onChange={onDateChange}
/>
<TimePicker
placeholder="hh:mm AM/PM"
stepMinutes={15}
aria-label={
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 { 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';
export default function FrequencySelect({
id,
value,
onChange,
onBlur,
placeholderText,
children,
}) {
export default function FrequencySelect({ id, onBlur, placeholderText }) {
const [isOpen, setIsOpen] = useState(false);
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 [frequency, , frequencyHelpers] = useField('freq');
const onToggle = (val) => {
if (!val) {
@@ -35,21 +17,26 @@ export default function FrequencySelect({
return (
<Select
variant={SelectVariant.checkbox}
onSelect={onSelect}
selections={value}
onSelect={(e, v) => {
frequencyHelpers.setValue(v);
setIsOpen(false);
}}
selections={frequency.value}
placeholderText={placeholderText}
onToggle={onToggle}
value={frequency.value}
isOpen={isOpen}
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>
);
}
FrequencySelect.propTypes = {
value: arrayOf(string).isRequired,
};
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 UnsupportedScheduleForm from './UnsupportedScheduleForm';
import parseRuleObj, { UnsupportedRRuleError } from './parseRuleObj';
import buildRuleObj from './buildRuleObj';
import buildRuleSet from './buildRuleSet';
const NUM_DAYS_PER_FREQUENCY = {
week: 7,
month: 31,
year: 365,
};
import ScheduleFormWizard from './ScheduleFormWizard';
import FrequenciesList from './FrequenciesList';
// import { validateSchedule } from './scheduleFormHelpers';
function ScheduleForm({
hasDaysToKeepField,
@@ -40,15 +35,16 @@ function ScheduleForm({
}) {
const [isWizardOpen, setIsWizardOpen] = useState(false);
const [isSaveDisabled, setIsSaveDisabled] = useState(false);
const [isScheduleWizardOpen, setIsScheduleWizardOpen] = useState(false);
const originalLabels = useRef([]);
const originalInstanceGroups = useRef([]);
let rruleError;
const now = DateTime.now();
const closestQuarterHour = DateTime.fromMillis(
Math.ceil(now.ts / 900000) * 900000
);
const tomorrow = closestQuarterHour.plus({ days: 1 });
const isTemplate =
resource.type === 'workflow_job_template' ||
resource.type === 'job_template';
@@ -283,69 +279,10 @@ function ScheduleForm({
}
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 = {
description: schedule.description || '',
frequency: [],
frequencies: [],
exceptionFrequency: [],
frequencyOptions: initialFrequencyOptions,
exceptionOptions: initialFrequencyOptions,
name: schedule.name || '',
startDate: currentDate,
startTime: time,
@@ -367,11 +304,9 @@ function ScheduleForm({
}
initialValues.daysToKeep = initialDaysToKeep;
}
let overriddenValues = {};
if (schedule.rrule) {
try {
overriddenValues = parseRuleObj(schedule);
parseRuleObj(schedule);
} catch (error) {
if (error instanceof UnsupportedRRuleError) {
return (
@@ -394,89 +329,33 @@ function ScheduleForm({
if (contentLoading) {
return <ContentLoading />;
}
const validate = (values) => {
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;
};
const frequencies = [];
frequencies.push(parseRuleObj(schedule));
return (
<Config>
{() => (
<Formik
initialValues={{
...initialValues,
...overriddenValues,
frequencyOptions: {
...initialValues.frequencyOptions,
...overriddenValues.frequencyOptions,
},
exceptionOptions: {
...initialValues.exceptionOptions,
...overriddenValues.exceptionOptions,
},
name: schedule.name || '',
description: schedule.description || '',
frequencies: frequencies || [],
freq: RRule.DAILY,
interval: 1,
wkst: RRule.SU,
byweekday: [],
byweekno: [],
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) => {
submitSchedule(
@@ -488,73 +367,90 @@ function ScheduleForm({
credentials
);
}}
validate={validate}
validate={() => {}}
>
{(formik) => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<ScheduleFormFields
hasDaysToKeepField={hasDaysToKeepField}
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}
<>
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<ScheduleFormFields
hasDaysToKeepField={hasDaysToKeepField}
zoneOptions={zoneOptions}
zoneLinks={zoneLinks}
/>
)}
<FormSubmitError error={submitError} />
<FormFullWidthLayout>
<ActionGroup>
<Button
ouiaId="schedule-form-save-button"
aria-label={t`Save`}
variant="primary"
type="button"
onClick={formik.handleSubmit}
isDisabled={isSaveDisabled}
>
{t`Save`}
</Button>
{isTemplate && showPromptButton && (
{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}
/>
)}
<FormFullWidthLayout>
<FrequenciesList openWizard={setIsScheduleWizardOpen} />
</FormFullWidthLayout>
<FormSubmitError error={submitError} />
<FormFullWidthLayout>
<ActionGroup>
<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"
type="button"
aria-label={t`Prompt`}
onClick={() => setIsWizardOpen(true)}
onClick={handleCancel}
>
{t`Prompt`}
{t`Cancel`}
</Button>
)}
<Button
ouiaId="schedule-form-cancel-button"
aria-label={t`Cancel`}
variant="secondary"
type="button"
onClick={handleCancel}
>
{t`Cancel`}
</Button>
</ActionGroup>
</FormFullWidthLayout>
</FormColumnLayout>
</Form>
</ActionGroup>
</FormFullWidthLayout>
</FormColumnLayout>
</Form>
{isScheduleWizardOpen && (
<ScheduleFormWizard
staticFormFormkik={formik}
isOpen={isScheduleWizardOpen}
handleSave={() => {}}
setIsOpen={setIsScheduleWizardOpen}
/>
)}
</>
)}
</Formik>
)}
@@ -575,24 +471,3 @@ ScheduleForm.defaultProps = {
};
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 { useField } from 'formik';
import { FormGroup, Title } from '@patternfly/react-core';
import { FormGroup } from '@patternfly/react-core';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import 'styled-components/macro';
import FormField from 'components/FormField';
import { required } from 'util/validators';
import { useConfig } from 'contexts/Config';
import Popover from '../../Popover';
import AnsibleSelect from '../../AnsibleSelect';
import FrequencySelect, { SelectOption } from './FrequencySelect';
import getHelpText from '../../../screens/Template/shared/JobTemplate.helptext';
import { SubFormLayout, FormColumnLayout } from '../../FormLayout';
import FrequencyDetailSubform from './FrequencyDetailSubform';
import DateTimePicker from './DateTimePicker';
import sortFrequencies from './sortFrequencies';
const SelectClearOption = styled(SelectOption)`
& > input[type='checkbox'] {
display: none;
}
`;
export default function ScheduleFormFields({
hasDaysToKeepField,
zoneOptions,
zoneLinks,
setTimeZone,
}) {
const helpText = getHelpText();
const [timezone, timezoneMeta] = useField({
name: 'timezone',
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 warnLinkedTZ = (event, selectedValue) => {
if (zoneLinks[selectedValue]) {
@@ -46,6 +32,7 @@ export default function ScheduleFormFields({
setTimezoneMessage('');
}
timezone.onChange(event, selectedValue);
setTimeZone(zoneLinks(selectedValue));
};
let timezoneValidatedStatus = 'default';
if (timezoneMeta.touched && timezoneMeta.error) {
@@ -55,16 +42,6 @@ export default function ScheduleFormFields({
}
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 (
<>
<FormField
@@ -103,33 +80,7 @@ export default function ScheduleFormFields({
onChange={warnLinkedTZ}
/>
</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 ? (
<FormField
id="schedule-days-to-keep"
@@ -140,68 +91,6 @@ export default function ScheduleFormFields({
isRequired
/>
) : 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 { DateTime } from 'luxon';
import { getRRuleDayConstants } from 'util/dates';
window.RRule = RRule;
window.DateTime = DateTime;
@@ -22,7 +20,7 @@ export function buildDtStartObj(values) {
startHour
)}${pad(startMinute)}00`;
const rruleString = values.timezone
? `DTSTART;TZID=${values.timezone}:${dateString}`
? `DTSTART;TZID=${values.timezone}${dateString}`
: `DTSTART:${dateString}Z`;
const rule = RRule.fromString(rruleString);
@@ -38,7 +36,8 @@ function pad(num) {
export default function buildRuleObj(values, includeStart) {
const ruleObj = {
interval: values.interval,
interval: values.interval || 1,
freq: values.freq,
};
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;
}

View File

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

View File

@@ -12,12 +12,14 @@ export class UnsupportedRRuleError extends Error {
export default function parseRuleObj(schedule) {
let values = {
frequency: [],
frequencyOptions: {},
exceptionFrequency: [],
exceptionOptions: {},
frequency: '',
rrules: '',
timezone: schedule.timezone,
};
if (Object.values(schedule).length === 0) {
return values;
}
const ruleset = rrulestr(schedule.rrule.replace(' ', '\n'), {
forceset: true,
});
@@ -40,25 +42,9 @@ export default function parseRuleObj(schedule) {
}
});
if (isSingleOccurrence(values)) {
values.frequency = [];
values.frequencyOptions = {};
}
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) {
// TODO: should this rely on DTSTART in rruleset rather than schedule.dtstart?
const [startDate, startTime] = dateToInputDateTime(
@@ -81,27 +67,12 @@ const frequencyTypes = {
[RRule.YEARLY]: 'year',
};
function parseRrule(rruleString, schedule, values) {
const { frequency, options } = parseRule(
rruleString,
schedule,
values.exceptionFrequency
);
function parseRrule(rruleString, schedule) {
const { frequency } = parseRule(rruleString, schedule);
if (values.frequencyOptions[frequency]) {
throw new UnsupportedRRuleError(
'Duplicate exception frequency types not supported'
);
}
const freq = { frequency, rrule: rruleString };
return {
...values,
frequency: [...values.frequency, frequency].sort(sortFrequencies),
frequencyOptions: {
...values.frequencyOptions,
[frequency]: options,
},
};
return freq;
}
function parseExRule(exruleString, schedule, values) {
@@ -129,20 +100,10 @@ function parseExRule(exruleString, schedule, values) {
};
}
function parseRule(ruleString, schedule, frequencies) {
function parseRule(ruleString, schedule) {
const {
origOptions: {
bymonth,
bymonthday,
bysetpos,
byweekday,
count,
freq,
interval,
until,
},
origOptions: { count, freq, interval, until, ...rest },
} = RRule.fromString(ruleString);
const now = DateTime.now();
const closestQuarterHour = DateTime.fromMillis(
Math.ceil(now.ts / 900000) * 900000
@@ -156,17 +117,17 @@ function parseRule(ruleString, schedule, frequencies) {
endTime: time,
occurrences: 1,
interval: 1,
end: 'never',
endingType: 'never',
};
if (until) {
options.end = 'onDate';
if (until?.length) {
options.endingType = 'onDate';
const end = DateTime.fromISO(until.toISOString());
const [endDate, endTime] = dateToInputDateTime(end, schedule.timezone);
options.endDate = endDate;
options.endTime = endTime;
} else if (count) {
options.end = 'after';
options.endingType = 'after';
options.occurrences = count;
}
@@ -178,101 +139,10 @@ function parseRule(ruleString, schedule, frequencies) {
throw new Error(`Unexpected rrule frequency: ${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 {
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

@@ -420,7 +420,7 @@ describe('<AdvancedSearch />', () => {
const selectOptions = wrapper.find(
'Select[aria-label="Related search type"] SelectOption'
);
expect(selectOptions).toHaveLength(3);
expect(selectOptions).toHaveLength(2);
expect(
selectOptions.find('SelectOption[id="name-option-select"]').prop('value')
).toBe('name__icontains');

View File

@@ -31,12 +31,6 @@ function RelatedLookupTypeInput({
value="name__icontains"
description={t`Fuzzy search on name field.`}
/>
<SelectOption
id="name-exact-option-select"
key="name"
value="name"
description={t`Exact search on name field.`}
/>
<SelectOption
id="id-option-select"
key="id"

View File

@@ -22,16 +22,6 @@ import { CredentialsAPI } from 'api';
import CredentialDetail from './CredentialDetail';
import CredentialEdit from './CredentialEdit';
const jobTemplateCredentialTypes = [
'machine',
'cloud',
'net',
'ssh',
'vault',
'kubernetes',
'cryptography',
];
function Credential({ setBreadcrumb }) {
const { pathname } = useLocation();
@@ -85,14 +75,13 @@ function Credential({ setBreadcrumb }) {
link: `/credentials/${id}/access`,
id: 1,
},
];
if (jobTemplateCredentialTypes.includes(credential?.kind)) {
tabsArray.push({
{
name: t`Job Templates`,
link: `/credentials/${id}/job_templates`,
id: 2,
});
}
},
];
let showCardHeader = true;
if (pathname.endsWith('edit') || pathname.endsWith('add')) {
@@ -144,7 +133,6 @@ function Credential({ setBreadcrumb }) {
<Route key="job_templates" path="/credentials/:id/job_templates">
<RelatedTemplateList
searchParams={{ credentials__id: credential.id }}
resourceName={[credential.name, credential.kind]}
/>
</Route>,
<Route key="not-found" path="*">

View File

@@ -6,8 +6,7 @@ import {
mountWithContexts,
waitForElement,
} from '../../../testUtils/enzymeHelpers';
import mockMachineCredential from './shared/data.machineCredential.json';
import mockSCMCredential from './shared/data.scmCredential.json';
import mockCredential from './shared/data.scmCredential.json';
import Credential from './Credential';
jest.mock('../../api');
@@ -22,10 +21,13 @@ jest.mock('react-router-dom', () => ({
describe('<Credential />', () => {
let wrapper;
test('initially renders user-based machine credential successfully', async () => {
beforeEach(() => {
CredentialsAPI.readDetail.mockResolvedValueOnce({
data: mockMachineCredential,
data: mockCredential,
});
});
test('initially renders user-based credential successfully', async () => {
await act(async () => {
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />);
});
@@ -34,18 +36,6 @@ describe('<Credential />', () => {
expect(wrapper.find('RoutedTabs li').length).toBe(4);
});
test('initially renders user-based SCM credential successfully', async () => {
CredentialsAPI.readDetail.mockResolvedValueOnce({
data: mockSCMCredential,
});
await act(async () => {
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />);
});
wrapper.update();
expect(wrapper.find('Credential').length).toBe(1);
expect(wrapper.find('RoutedTabs li').length).toBe(3);
});
test('should render expected tabs', async () => {
const expectedTabs = [
'Back to Credentials',

View File

@@ -1,16 +1,14 @@
import React, { useCallback, useEffect, useState } from 'react';
import styled from 'styled-components';
import { t, Trans } from '@lingui/macro';
import { t } from '@lingui/macro';
import {
Banner,
Card,
PageSection,
Tabs,
Tab,
TabTitleText,
} from '@patternfly/react-core';
import { InfoCircleIcon } from '@patternfly/react-icons';
import useRequest from 'hooks/useRequest';
import { DashboardAPI } from 'api';
@@ -71,15 +69,6 @@ function Dashboard() {
}
return (
<>
<Banner variant="info">
<Trans>
<p>
<InfoCircleIcon /> A tech preview of the new Ansible Automation
Platform user interface can be found{' '}
<a href="/controller/dashboard">here</a>.
</p>
</Trans>
</Banner>
<ScreenHeader
streamType="all"
breadcrumbConfig={{ '/home': t`Dashboard` }}

View File

@@ -81,30 +81,35 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
const {
data: { results },
} = await InstanceGroupsAPI.readInstances(instanceGroup.id);
let instanceDetails;
const isAssociated = results.some(
({ id: instId }) => instId === parseInt(instanceId, 10)
);
if (isAssociated) {
const { data: details } = await InstancesAPI.readDetail(instanceId);
if (details.node_type === 'execution') {
const { data: healthCheckData } =
await InstancesAPI.readHealthCheckDetail(instanceId);
setHealthCheck(healthCheckData);
}
setBreadcrumb(instanceGroup, details);
setForks(
computeForks(
details.mem_capacity,
details.cpu_capacity,
details.capacity_adjustment
)
const [{ data: details }, { data: healthCheckData }] =
await Promise.all([
InstancesAPI.readDetail(instanceId),
InstancesAPI.readHealthCheckDetail(instanceId),
]);
instanceDetails = details;
setHealthCheck(healthCheckData);
} else {
throw new Error(
`This instance is not associated with this instance group`
);
return { instance: details };
}
throw new Error(
`This instance is not associated with this instance group`
setBreadcrumb(instanceGroup, instanceDetails);
setForks(
computeForks(
instanceDetails.mem_capacity,
instanceDetails.cpu_capacity,
instanceDetails.capacity_adjustment
)
);
return { instance: instanceDetails };
}, [instanceId, setBreadcrumb, instanceGroup]),
{ instance: {}, isLoading: true }
);

View File

@@ -181,7 +181,6 @@ function Inventory({ setBreadcrumb }) {
>
<RelatedTemplateList
searchParams={{ inventory__id: inventory.id }}
resourceName={inventory.name}
/>
</Route>,
<Route path="*" key="not-found">

View File

@@ -187,9 +187,7 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
useEffect(() => {
const pendingRequests = Object.values(eventByUuidRequests.current || {});
setHasContentLoading(true); // prevents "no content found" screen from flashing
if (location.search) {
setIsFollowModeEnabled(false);
}
setIsFollowModeEnabled(false);
Promise.allSettled(pendingRequests).then(() => {
setRemoteRowCount(0);
clearLoadedEvents();
@@ -253,9 +251,6 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
});
const updated = oldWsEvents.concat(newEvents);
jobSocketCounter.current = updated.length;
if (!oldWsEvents.length && min > remoteRowCount + 1) {
loadJobEvents(min);
}
return updated.sort((a, b) => a.counter - b.counter);
});
setCssMap((prevCssMap) => ({
@@ -363,7 +358,7 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
}
};
const loadJobEvents = async (firstWsCounter = null) => {
const loadJobEvents = async () => {
const [params, loadRange] = getEventRequestParams(job, 50, [1, 50]);
if (isMounted.current) {
@@ -376,9 +371,6 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
if (isFlatMode) {
params.not__stdout = '';
}
if (firstWsCounter) {
params.counter__lt = firstWsCounter;
}
const qsParams = parseQueryString(QS_CONFIG, location.search);
const eventPromise = getJobModel(job.type).readEvents(job.id, {
...params,
@@ -443,7 +435,7 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
if (getEvent(counter)) {
return true;
}
if (index >= remoteRowCount && index < remoteRowCount + wsEvents.length) {
if (index > remoteRowCount && index < remoteRowCount + wsEvents.length) {
return true;
}
return currentlyLoading.includes(counter);
@@ -470,7 +462,7 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
}
if (
!event &&
index >= remoteRowCount &&
index > remoteRowCount &&
index < remoteRowCount + wsEvents.length
) {
event = wsEvents[index - remoteRowCount];
@@ -637,14 +629,10 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
setIsFollowModeEnabled(false);
};
const scrollToEnd = useCallback(() => {
const scrollToEnd = () => {
scrollToRow(-1);
let timeout;
if (isFollowModeEnabled) {
setTimeout(() => scrollToRow(-1), 100);
}
return () => clearTimeout(timeout);
}, [isFollowModeEnabled]);
setTimeout(() => scrollToRow(-1), 100);
};
const handleScrollLast = () => {
scrollToEnd();

View File

@@ -179,7 +179,7 @@ function Project({ setBreadcrumb }) {
searchParams={{
project__id: project.id,
}}
resourceName={project.name}
projectName={project.name}
/>
</Route>
{project?.scm_type && project.scm_type !== '' && (

View File

@@ -141,14 +141,14 @@ function JobsEdit() {
<FormColumnLayout>
<InputField
name="AWX_ISOLATION_BASE_PATH"
config={jobs.AWX_ISOLATION_BASE_PATH ?? null}
isRequired={Boolean(options?.AWX_ISOLATION_BASE_PATH)}
config={jobs.AWX_ISOLATION_BASE_PATH}
isRequired
/>
<InputField
name="SCHEDULE_MAX_JOBS"
config={jobs.SCHEDULE_MAX_JOBS ?? null}
type={options?.SCHEDULE_MAX_JOBS ? 'number' : undefined}
isRequired={Boolean(options?.SCHEDULE_MAX_JOBS)}
config={jobs.SCHEDULE_MAX_JOBS}
type="number"
isRequired
/>
<InputField
name="DEFAULT_JOB_TIMEOUT"

View File

@@ -122,22 +122,4 @@ describe('<JobsEdit />', () => {
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
test('Form input fields that are invisible (due to being set manually via a settings file) should not prevent submitting the form', async () => {
const mockOptions = Object.assign({}, mockAllOptions);
// If AWX_ISOLATION_BASE_PATH has been set in a settings file it will be absent in the PUT options
delete mockOptions['actions']['PUT']['AWX_ISOLATION_BASE_PATH'];
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockOptions.actions}>
<JobsEdit />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
});
});

View File

@@ -397,10 +397,7 @@ const InputField = ({ name, config, type = 'text', isRequired = false }) => {
};
InputField.propTypes = {
name: string.isRequired,
config: shape({}),
};
InputField.defaultProps = {
config: null,
config: shape({}).isRequired,
};
const TextAreaField = ({ name, config, isRequired = false }) => {

View File

@@ -9,31 +9,29 @@ function JobTemplateAdd() {
const [formSubmitError, setFormSubmitError] = useState(null);
const history = useHistory();
const resourceParams = {
resource_id: null,
resource_name: null,
resource_type: null,
resource_kind: null,
const projectParams = {
project_id: null,
project_name: null,
};
history.location.search
.replace(/^\?/, '')
.split('&')
.map((s) => s.split('='))
.forEach(([key, val]) => {
if (!(key in resourceParams)) {
if (!(key in projectParams)) {
return;
}
resourceParams[key] = decodeURIComponent(val);
projectParams[key] = decodeURIComponent(val);
});
let resourceValues = null;
let projectValues = null;
if (history.location.search.includes('resource_id' && 'resource_name')) {
resourceValues = {
id: resourceParams.resource_id,
name: resourceParams.resource_name,
type: resourceParams.resource_type,
kind: resourceParams.resource_kind, // refers to credential kind
if (
Object.values(projectParams).filter((item) => item !== null).length === 2
) {
projectValues = {
id: projectParams.project_id,
name: projectParams.project_name,
};
}
@@ -124,7 +122,7 @@ function JobTemplateAdd() {
handleCancel={handleCancel}
handleSubmit={handleSubmit}
submitError={formSubmitError}
resourceValues={resourceValues}
projectValues={projectValues}
isOverrideDisabledLookup
/>
</CardBody>

View File

@@ -274,14 +274,9 @@ describe('<JobTemplateAdd />', () => {
test('should parse and pre-fill project field from query params', async () => {
const history = createMemoryHistory({
initialEntries: [
'/templates/job_template/add?resource_id=6&resource_name=Demo%20Project&resource_type=project',
'/templates/job_template/add/add?project_id=6&project_name=Demo%20Project',
],
});
ProjectsAPI.read.mockResolvedValueOnce({
count: 1,
results: [{ name: 'foo', id: 1, allow_override: true, organization: 1 }],
});
ProjectsAPI.readOptions.mockResolvedValueOnce({});
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<JobTemplateAdd />, {
@@ -289,9 +284,8 @@ describe('<JobTemplateAdd />', () => {
});
});
await waitForElement(wrapper, 'EmptyStateBody', (el) => el.length === 0);
expect(wrapper.find('input#project').prop('value')).toEqual('Demo Project');
expect(ProjectsAPI.readPlaybooks).toBeCalledWith(6);
expect(ProjectsAPI.readPlaybooks).toBeCalledWith('6');
});
test('should not call ProjectsAPI.readPlaybooks if there is no project', async () => {

View File

@@ -690,7 +690,7 @@ JobTemplateForm.defaultProps = {
};
const FormikApp = withFormik({
mapPropsToValues({ resourceValues = null, template = {} }) {
mapPropsToValues({ projectValues = {}, template = {} }) {
const {
summary_fields = {
labels: { results: [] },
@@ -698,7 +698,7 @@ const FormikApp = withFormik({
},
} = template;
const initialValues = {
return {
allow_callbacks: template.allow_callbacks || false,
allow_simultaneous: template.allow_simultaneous || false,
ask_credential_on_launch: template.ask_credential_on_launch || false,
@@ -739,7 +739,7 @@ const FormikApp = withFormik({
playbook: template.playbook || '',
prevent_instance_group_fallback:
template.prevent_instance_group_fallback || false,
project: summary_fields?.project || null,
project: summary_fields?.project || projectValues || null,
scm_branch: template.scm_branch || '',
skip_tags: template.skip_tags || '',
timeout: template.timeout || 0,
@@ -756,24 +756,6 @@ const FormikApp = withFormik({
execution_environment:
template.summary_fields?.execution_environment || null,
};
if (resourceValues !== null) {
if (resourceValues.type === 'credentials') {
initialValues[resourceValues.type] = [
{
id: parseInt(resourceValues.id, 10),
name: resourceValues.name,
kind: resourceValues.kind,
},
];
} else {
initialValues[resourceValues.type] = {
id: parseInt(resourceValues.id, 10),
name: resourceValues.name,
};
}
}
return initialValues;
},
handleSubmit: async (values, { props, setErrors }) => {
try {

View File

@@ -9,10 +9,6 @@ class IndexView(TemplateView):
template_name = 'index.html'
class ControllerView(TemplateView):
template_name = 'index_controller.html'
class MigrationsNotran(TemplateView):
@@ -31,4 +27,4 @@ class MigrationsNotran(TemplateView):
app_name = 'ui'
urlpatterns = [re_path(r'^$', IndexView.as_view(), name='index'), re_path(r'^controller/.*$', ControllerView.as_view(), name='index'), re_path(r'^migrations_notran/$', MigrationsNotran.as_view(), name='migrations_notran')]
urlpatterns = [re_path(r'^$', IndexView.as_view(), name='index'), re_path(r'^migrations_notran/$', MigrationsNotran.as_view(), name='migrations_notran')]

View File

@@ -46,216 +46,90 @@ action_groups:
plugin_routing:
inventory:
tower:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* plugins have been deprecated, use awx.awx.controller instead.
redirect: awx.awx.controller
lookup:
tower_api:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* plugins have been deprecated, use awx.awx.controller_api instead.
redirect: awx.awx.controller_api
tower_schedule_rrule:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* plugins have been deprecated, use awx.awx.schedule_rrule instead.
redirect: awx.awx.schedule_rrule
modules:
tower_ad_hoc_command_cancel:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.ad_hoc_command_cancel instead.
redirect: awx.awx.ad_hoc_command_cancel
tower_ad_hoc_command_wait:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.ad_hoc_command_wait instead.
redirect: awx.awx.ad_hoc_command_wait
tower_ad_hoc_command:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.ad_hoc_command instead.
redirect: awx.awx.ad_hoc_command
tower_application:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.application instead.
redirect: awx.awx.application
tower_meta:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.controller_meta instead.
redirect: awx.awx.controller_meta
tower_credential_input_source:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.credential_input_source instead.
redirect: awx.awx.credential_input_source
tower_credential_type:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.credential_type instead.
redirect: awx.awx.credential_type
tower_credential:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.credential instead.
redirect: awx.awx.credential
tower_execution_environment:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.execution_environment instead.
redirect: awx.awx.execution_environment
tower_export:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.export instead.
redirect: awx.awx.export
tower_group:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.group instead.
redirect: awx.awx.group
tower_host:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.host instead.
redirect: awx.awx.host
tower_import:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.import instead.
redirect: awx.awx.import
tower_instance_group:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.instance_group instead.
redirect: awx.awx.instance_group
tower_inventory_source_update:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.inventory_source_update instead.
redirect: awx.awx.inventory_source_update
tower_inventory_source:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.inventory_source instead.
redirect: awx.awx.inventory_source
tower_inventory:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.inventory instead.
redirect: awx.awx.inventory
tower_job_cancel:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.job_cancel instead.
redirect: awx.awx.job_cancel
tower_job_launch:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.job_launch instead.
redirect: awx.awx.job_launch
tower_job_list:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.job_list instead.
redirect: awx.awx.job_list
tower_job_template:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.job_template instead.
redirect: awx.awx.job_template
tower_job_wait:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.job_wait instead.
redirect: awx.awx.job_wait
tower_label:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.label instead.
redirect: awx.awx.label
tower_license:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.license instead.
redirect: awx.awx.license
tower_notification_template:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.notification_template instead.
redirect: awx.awx.notification_template
tower_notification:
redirect: awx.awx.notification_template
tower_organization:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.organization instead.
redirect: awx.awx.organization
tower_project_update:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.project_update instead.
redirect: awx.awx.project_update
tower_project:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.project instead.
redirect: awx.awx.project
tower_role:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.role instead.
redirect: awx.awx.role
tower_schedule:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.schedule instead.
redirect: awx.awx.schedule
tower_settings:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.settings instead.
redirect: awx.awx.settings
tower_team:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.team instead.
redirect: awx.awx.team
tower_token:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.token instead.
redirect: awx.awx.token
tower_user:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.user instead.
redirect: awx.awx.user
tower_workflow_approval:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.workflow_approval instead.
redirect: awx.awx.workflow_approval
tower_workflow_job_template_node:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.workflow_job_template_node instead.
redirect: awx.awx.workflow_job_template_node
tower_workflow_job_template:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.workflow_job_template instead.
redirect: awx.awx.workflow_job_template
tower_workflow_launch:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.workflow_launch instead.
redirect: awx.awx.workflow_launch
tower_workflow_node_wait:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.workflow_node_wait instead.
redirect: awx.awx.workflow_node_wait

View File

@@ -128,7 +128,7 @@ def main():
description = module.params.get('description')
state = module.params.pop('state')
preserve_existing_hosts = module.params.get('preserve_existing_hosts')
preserve_existing_children = module.params.get('preserve_existing_children')
preserve_existing_children = module.params.get('preserve_existing_groups')
variables = module.params.get('variables')
# Attempt to look up the related items the user specified (these will fail the module if not found)

View File

@@ -0,0 +1 @@
ad_hoc_command.py

View File

@@ -0,0 +1 @@
ad_hoc_command_cancel.py

View File

@@ -0,0 +1 @@
ad_hoc_command_wait.py

View File

@@ -0,0 +1 @@
application.py

View File

@@ -0,0 +1 @@
controller_meta.py

View File

@@ -0,0 +1 @@
credential.py

View File

@@ -0,0 +1 @@
credential_input_source.py

View File

@@ -0,0 +1 @@
credential_type.py

View File

@@ -0,0 +1 @@
execution_environment.py

View File

@@ -0,0 +1 @@
export.py

View File

@@ -0,0 +1 @@
group.py

View File

@@ -0,0 +1 @@
host.py

View File

@@ -0,0 +1 @@
import.py

View File

@@ -0,0 +1 @@
instance_group.py

View File

@@ -0,0 +1 @@
inventory.py

View File

@@ -0,0 +1 @@
inventory_source.py

View File

@@ -0,0 +1 @@
inventory_source_update.py

View File

@@ -0,0 +1 @@
job_cancel.py

View File

@@ -0,0 +1 @@
job_launch.py

View File

@@ -0,0 +1 @@
job_list.py

View File

@@ -0,0 +1 @@
job_template.py

View File

@@ -0,0 +1 @@
job_wait.py

View File

@@ -0,0 +1 @@
label.py

View File

@@ -0,0 +1 @@
license.py

View File

@@ -0,0 +1 @@
notification_template.py

View File

@@ -0,0 +1 @@
organization.py

View File

@@ -0,0 +1 @@
project.py

View File

@@ -0,0 +1 @@
project_update.py

View File

@@ -0,0 +1 @@
role.py

View File

@@ -0,0 +1 @@
schedule.py

View File

@@ -0,0 +1 @@
settings.py

View File

@@ -0,0 +1 @@
team.py

View File

@@ -0,0 +1 @@
token.py

View File

@@ -0,0 +1 @@
user.py

View File

@@ -0,0 +1 @@
workflow_approval.py

View File

@@ -0,0 +1 @@
workflow_job_template.py

View File

@@ -0,0 +1 @@
workflow_job_template_node.py

View File

@@ -0,0 +1 @@
workflow_launch.py

View File

@@ -0,0 +1 @@
workflow_node_wait.py

View File

@@ -19,6 +19,7 @@ author: "John Westcott IV (@john-westcott-iv)"
short_description: create, update, or destroy Automation Platform Controller workflow job templates.
description:
- Create, update, or destroy Automation Platform Controller workflow job templates.
- Replaces the deprecated tower_workflow_template module.
- Use workflow_job_template_node after this, or use the workflow_nodes parameter to build the workflow's graph
options:
name:
@@ -613,10 +614,6 @@ def create_workflow_nodes(module, response, workflow_nodes, workflow_id):
if workflow_node['unified_job_template']['type'] != 'workflow_approval':
module.fail_json(msg="Unable to Find unified_job_template: {0}".format(search_fields))
inventory = workflow_node.get('inventory')
if inventory:
workflow_node_fields['inventory'] = module.resolve_name_to_id('inventories', inventory)
# Lookup Values for other fields
for field_name in (

View File

@@ -20,6 +20,7 @@ short_description: create, update, or destroy Automation Platform Controller wor
description:
- Create, update, or destroy Automation Platform Controller workflow job template nodes.
- Use this to build a graph for a workflow, which dictates what the workflow runs.
- Replaces the deprecated tower_workflow_template module schema command.
- You can create nodes first, and link them afterwards, and not worry about ordering.
For failsafe referencing of a node, specify identifier, WFJT, and organization.
With those specified, you can choose to modify or not modify any other parameter.

View File

@@ -74,7 +74,6 @@ Notable releases of the `{{ collection_namespace }}.{{ collection_package }}` co
- 7.0.0 is intended to be identical to the content prior to the migration, aside from changes necessary to function as a collection.
- 11.0.0 has no non-deprecated modules that depend on the deprecated `tower-cli` [PyPI](https://pypi.org/project/ansible-tower-cli/).
- 19.2.1 large renaming purged "tower" names (like options and module names), adding redirects for old names
- 21.11.0 "tower" modules deprecated and symlinks removed.
- 0.0.1-devel is the version you should see if installing from source, which is intended for development and expected to be unstable.
{% else %}
- 3.7.0 initial release

View File

@@ -1,10 +1,6 @@
awxkit
======
A Python library that backs the provided `awx` command line client.
Python library that backs the provided `awx` command line client.
It can be installed by running `pip install awxkit`.
The PyPI respository can be found [here](https://pypi.org/project/awxkit/).
For more information on installing the CLI and building the docs on how to use it, look [here](./awxkit/cli/docs).
For more information on installing the CLI and building the docs on how to use it, look [here](./awxkit/cli/docs).