mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 01:57:35 -03:30
Merge branch 'copy_awx_collection' of github.com:sean-m-sullivan/awx into copy_awx_collection
This commit is contained in:
commit
c4d8d5ee9e
2
.env
2
.env
@ -1,3 +1,3 @@
|
||||
PYTHONUNBUFFERED=true
|
||||
SELENIUM_DOCKER_TAG=latest
|
||||
|
||||
COMPOSE_PROJECT_NAME=tools
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -29,14 +29,18 @@ awx/ui/client/languages
|
||||
awx/ui/templates/ui/index.html
|
||||
awx/ui/templates/ui/installing.html
|
||||
awx/ui_next/node_modules/
|
||||
awx/ui_next/src/locales/
|
||||
awx/ui_next/src/locales/*/messages.js
|
||||
awx/ui_next/coverage/
|
||||
awx/ui_next/build
|
||||
awx/ui_next/.env.local
|
||||
awx/ui_next/instrumented
|
||||
rsyslog.pid
|
||||
tools/prometheus/data
|
||||
tools/docker-compose/ansible/awx_dump.sql
|
||||
tools/docker-compose/Dockerfile
|
||||
tools/docker-compose/_build
|
||||
tools/docker-compose/_sources
|
||||
tools/docker-compose/overrides/
|
||||
|
||||
# Tower setup playbook testing
|
||||
setup/test/roles/postgresql
|
||||
@ -146,7 +150,6 @@ use_dev_supervisor.txt
|
||||
.idea/*
|
||||
*.unison.tmp
|
||||
*.#
|
||||
/tools/docker-compose/overrides/
|
||||
/awx/ui_next/.ui-built
|
||||
/Dockerfile
|
||||
/_build/
|
||||
|
||||
@ -14,6 +14,8 @@ This is a list of high-level changes for each release of AWX. A full list of com
|
||||
- Fixed a bug where launch prompt inputs were unexpectedly deposited in the url: https://github.com/ansible/awx/pull/9231
|
||||
- Playbook, credential type, and inventory file inputs now support type-ahead and manual type-in! https://github.com/ansible/awx/pull/9120
|
||||
- Added ability to relaunch against failed hosts: https://github.com/ansible/awx/pull/9225
|
||||
- Added pending workflow approval count to the application header https://github.com/ansible/awx/pull/9334
|
||||
- Added user interface for management jobs: https://github.com/ansible/awx/pull/9224
|
||||
|
||||
# 17.0.1 (January 26, 2021)
|
||||
- Fixed pgdocker directory permissions issue with Local Docker installer: https://github.com/ansible/awx/pull/9152
|
||||
|
||||
224
CONTRIBUTING.md
224
CONTRIBUTING.md
@ -11,24 +11,15 @@ Have questions about this document or anything not covered here? Come chat with
|
||||
* [Prerequisites](#prerequisites)
|
||||
* [Docker](#docker)
|
||||
* [Docker compose](#docker-compose)
|
||||
* [Node and npm](#node-and-npm)
|
||||
* [Build the environment](#build-the-environment)
|
||||
* [Frontend Development](#frontend-development)
|
||||
* [Build and Run the Development Environment](#build-and-run-the-development-environment)
|
||||
* [Fork and clone the AWX repo](#fork-and-clone-the-awx-repo)
|
||||
* [Create local settings](#create-local-settings)
|
||||
* [Build the base image](#build-the-base-image)
|
||||
* [Build the user interface](#build-the-user-interface)
|
||||
* [Running the environment](#running-the-environment)
|
||||
* [Start the containers](#start-the-containers)
|
||||
* [Start from the container shell](#start-from-the-container-shell)
|
||||
* [Post Build Steps](#post-build-steps)
|
||||
* [Start a shell](#start-a-shell)
|
||||
* [Create a superuser](#create-a-superuser)
|
||||
* [Load the data](#load-the-data)
|
||||
* [Building API Documentation](#build-api-documentation)
|
||||
* [Building API Documentation](#building-api-documentation)
|
||||
* [Accessing the AWX web interface](#accessing-the-awx-web-interface)
|
||||
* [Purging containers and images](#purging-containers-and-images)
|
||||
* [What should I work on?](#what-should-i-work-on)
|
||||
* [Submitting Pull Requests](#submitting-pull-requests)
|
||||
* [PR Checks run by Zuul](#pr-checks-run-by-zuul)
|
||||
* [Reporting Issues](#reporting-issues)
|
||||
|
||||
## Things to know prior to submitting code
|
||||
@ -42,7 +33,7 @@ Have questions about this document or anything not covered here? Come chat with
|
||||
|
||||
## Setting up your development environment
|
||||
|
||||
The AWX development environment workflow and toolchain is based on Docker, and the docker-compose tool, to provide dependencies, services, and databases necessary to run all of the components. It also binds the local source tree into the development container, making it possible to observe and test changes in real time.
|
||||
The AWX development environment workflow and toolchain uses Docker and the docker-compose tool, to provide dependencies, services, and databases necessary to run all of the components. It also bind-mounts the local source tree into the development container, making it possible to observe and test changes in real time.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
@ -55,29 +46,19 @@ respectively.
|
||||
|
||||
For Linux platforms, refer to the following from Docker:
|
||||
|
||||
**Fedora**
|
||||
* **Fedora** - https://docs.docker.com/engine/installation/linux/docker-ce/fedora/
|
||||
|
||||
> https://docs.docker.com/engine/installation/linux/docker-ce/fedora/
|
||||
* **CentOS** - https://docs.docker.com/engine/installation/linux/docker-ce/centos/
|
||||
|
||||
**CentOS**
|
||||
* **Ubuntu** - https://docs.docker.com/engine/installation/linux/docker-ce/ubuntu/
|
||||
|
||||
> https://docs.docker.com/engine/installation/linux/docker-ce/centos/
|
||||
* **Debian** - https://docs.docker.com/engine/installation/linux/docker-ce/debian/
|
||||
|
||||
**Ubuntu**
|
||||
* **Arch** - https://wiki.archlinux.org/index.php/Docker
|
||||
|
||||
> https://docs.docker.com/engine/installation/linux/docker-ce/ubuntu/
|
||||
#### Docker Compose
|
||||
|
||||
**Debian**
|
||||
|
||||
> https://docs.docker.com/engine/installation/linux/docker-ce/debian/
|
||||
|
||||
**Arch**
|
||||
|
||||
> https://wiki.archlinux.org/index.php/Docker
|
||||
|
||||
#### Docker compose
|
||||
|
||||
If you're not using Docker for Mac, or Docker for Windows, you may need, or choose to, install the Docker compose Python module separately, in which case you'll need to run the following:
|
||||
If you're not using Docker for Mac, or Docker for Windows, you may need, or choose to, install the `docker-compose` Python module separately.
|
||||
|
||||
```bash
|
||||
(host)$ pip3 install docker-compose
|
||||
@ -87,186 +68,15 @@ If you're not using Docker for Mac, or Docker for Windows, you may need, or choo
|
||||
|
||||
See [the ui development documentation](awx/ui_next/CONTRIBUTING.md).
|
||||
|
||||
|
||||
### Build the environment
|
||||
|
||||
#### Fork and clone the AWX repo
|
||||
|
||||
If you have not done so already, you'll need to fork the AWX repo on GitHub. For more on how to do this, see [Fork a Repo](https://help.github.com/articles/fork-a-repo/).
|
||||
|
||||
#### Create local settings
|
||||
### Build and Run the Development Environment
|
||||
|
||||
AWX will import the file `awx/settings/local_settings.py` and combine it with defaults in `awx/settings/defaults.py`. This file is required for starting the development environment and startup will fail if it's not provided.
|
||||
See the [README.md](./tools/docker-compose/README.md) for docs on how to build the awx_devel image and run the development environment.
|
||||
|
||||
An example is provided. Make a copy of it, and edit as needed (the defaults are usually fine):
|
||||
|
||||
```bash
|
||||
(host)$ cp awx/settings/local_settings.py.docker_compose awx/settings/local_settings.py
|
||||
```
|
||||
|
||||
#### Build the base image
|
||||
|
||||
The AWX base container image (defined in `tools/docker-compose/Dockerfile`) contains basic OS dependencies and symbolic links into the development environment that make running the services easy.
|
||||
|
||||
Run the following to build the image:
|
||||
|
||||
```bash
|
||||
(host)$ make docker-compose-build
|
||||
```
|
||||
|
||||
**NOTE**
|
||||
|
||||
> The image will need to be rebuilt, if the Python requirements or OS dependencies change.
|
||||
|
||||
Once the build completes, you will have a `ansible/awx_devel` image in your local image cache. Use the `docker images` command to view it, as follows:
|
||||
|
||||
```bash
|
||||
(host)$ docker images
|
||||
|
||||
REPOSITORY TAG IMAGE ID CREATED SIZE
|
||||
ansible/awx_devel latest ba9ec3e8df74 26 minutes ago 1.42GB
|
||||
```
|
||||
|
||||
#### Build the user interface
|
||||
|
||||
Run the following to build the AWX UI:
|
||||
|
||||
```bash
|
||||
(host) $ make ui-devel
|
||||
```
|
||||
See [the ui development documentation](awx/ui/README.md) for more information on using the frontend development, build, and test tooling.
|
||||
|
||||
### Running the environment
|
||||
|
||||
#### Start the containers
|
||||
|
||||
Start the development containers by running the following:
|
||||
|
||||
```bash
|
||||
(host)$ make docker-compose
|
||||
```
|
||||
|
||||
The above utilizes the image built in the previous step, and will automatically start all required services and dependent containers. Once the containers launch, your session will be attached to the *awx* container, and you'll be able to watch log messages and events in real time. You will see messages from Django and the front end build process.
|
||||
|
||||
If you start a second terminal session, you can take a look at the running containers using the `docker ps` command. For example:
|
||||
|
||||
```bash
|
||||
# List running containers
|
||||
(host)$ docker ps
|
||||
|
||||
$ docker ps
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
44251b476f98 gcr.io/ansible-tower-engineering/awx_devel:devel "/entrypoint.sh /bin…" 27 seconds ago Up 23 seconds 0.0.0.0:6899->6899/tcp, 0.0.0.0:7899-7999->7899-7999/tcp, 0.0.0.0:8013->8013/tcp, 0.0.0.0:8043->8043/tcp, 0.0.0.0:8080->8080/tcp, 22/tcp, 0.0.0.0:8888->8888/tcp tools_awx_run_9e820694d57e
|
||||
40de380e3c2e redis:latest "docker-entrypoint.s…" 28 seconds ago Up 26 seconds
|
||||
b66a506d3007 postgres:12 "docker-entrypoint.s…" 28 seconds ago Up 26 seconds 0.0.0.0:5432->5432/tcp tools_postgres_1
|
||||
```
|
||||
**NOTE**
|
||||
|
||||
> The Makefile assumes that the image you built is tagged with your current branch. This allows you to build images for different contexts or branches. When starting the containers, you can choose a specific branch by setting `COMPOSE_TAG=<branch name>` in your environment.
|
||||
|
||||
> For example, you might be working in a feature branch, but you want to run the containers using the `devel` image you built previously. To do that, start the containers using the following command: `$ COMPOSE_TAG=devel make docker-compose`
|
||||
|
||||
##### Wait for migrations to complete
|
||||
|
||||
The first time you start the environment, database migrations need to run in order to build the PostgreSQL database. It will take few moments, but eventually you will see output in your terminal session that looks like the following:
|
||||
|
||||
```bash
|
||||
awx_1 | Operations to perform:
|
||||
awx_1 | Synchronize unmigrated apps: solo, api, staticfiles, debug_toolbar, messages, channels, django_extensions, ui, rest_framework, polymorphic
|
||||
awx_1 | Apply all migrations: sso, taggit, sessions, sites, kombu_transport_django, social_auth, contenttypes, auth, conf, main
|
||||
awx_1 | Synchronizing apps without migrations:
|
||||
awx_1 | Creating tables...
|
||||
awx_1 | Running deferred SQL...
|
||||
awx_1 | Installing custom SQL...
|
||||
awx_1 | Running migrations:
|
||||
awx_1 | Rendering model states... DONE
|
||||
awx_1 | Applying contenttypes.0001_initial... OK
|
||||
awx_1 | Applying contenttypes.0002_remove_content_type_name... OK
|
||||
awx_1 | Applying auth.0001_initial... OK
|
||||
awx_1 | Applying auth.0002_alter_permission_name_max_length... OK
|
||||
awx_1 | Applying auth.0003_alter_user_email_max_length... OK
|
||||
awx_1 | Applying auth.0004_alter_user_username_opts... OK
|
||||
awx_1 | Applying auth.0005_alter_user_last_login_null... OK
|
||||
awx_1 | Applying auth.0006_require_contenttypes_0002... OK
|
||||
awx_1 | Applying taggit.0001_initial... OK
|
||||
awx_1 | Applying taggit.0002_auto_20150616_2121... OK
|
||||
awx_1 | Applying main.0001_initial... OK
|
||||
awx_1 | Applying main.0002_squashed_v300_release... OK
|
||||
awx_1 | Applying main.0003_squashed_v300_v303_updates... OK
|
||||
awx_1 | Applying main.0004_squashed_v310_release... OK
|
||||
awx_1 | Applying conf.0001_initial... OK
|
||||
awx_1 | Applying conf.0002_v310_copy_tower_settings... OK
|
||||
...
|
||||
```
|
||||
|
||||
Once migrations are completed, you can begin using AWX.
|
||||
|
||||
#### Start from the container shell
|
||||
|
||||
Often times you'll want to start the development environment without immediately starting all of the services in the *awx* container, and instead be taken directly to a shell. You can do this with the following:
|
||||
|
||||
```bash
|
||||
(host)$ make docker-compose-test
|
||||
```
|
||||
|
||||
Using `docker exec`, this will create a session in the running *awx* container, and place you at a command prompt, where you can run shell commands inside the container.
|
||||
|
||||
If you want to start and use the development environment, you'll first need to bootstrap it by running the following command:
|
||||
|
||||
```bash
|
||||
(container)# /usr/bin/bootstrap_development.sh
|
||||
```
|
||||
|
||||
The above will do all the setup tasks, including running database migrations, so it may take a couple minutes. Once it's done it
|
||||
will drop you back to the shell.
|
||||
|
||||
In order to launch all developer services:
|
||||
|
||||
```bash
|
||||
(container)# /usr/bin/launch_awx.sh
|
||||
```
|
||||
|
||||
`launch_awx.sh` also calls `bootstrap_development.sh` so if all you are doing is launching the supervisor to start all services, you don't
|
||||
need to call `bootstrap_development.sh` first.
|
||||
|
||||
|
||||
|
||||
### Post Build Steps
|
||||
|
||||
Before you can log in and use the system, you will need to create an admin user. Optionally, you may also want to load some demo data.
|
||||
|
||||
##### Start a shell
|
||||
|
||||
To create the admin user, and load demo data, you first need to start a shell session on the *awx* container. In a new terminal session, use the `docker exec` command as follows to start the shell session:
|
||||
|
||||
```bash
|
||||
(host)$ docker exec -it tools_awx_1 bash
|
||||
```
|
||||
This creates a session in the *awx* containers, just as if you were using `ssh`, and allows you execute commands within the running container.
|
||||
|
||||
##### Create an admin user
|
||||
|
||||
Before you can log into AWX, you need to create an admin user. With this user you will be able to create more users, and begin configuring the server. From within the container shell, run the following command:
|
||||
|
||||
```bash
|
||||
(container)# awx-manage createsuperuser
|
||||
```
|
||||
You will be prompted for a username, an email address, and a password, and you will be asked to confirm the password. The email address is not important, so just enter something that looks like an email address. Remember the username and password, as you will use them to log into the web interface for the first time.
|
||||
|
||||
##### Load demo data
|
||||
|
||||
You can optionally load some demo data. This will create a demo project, inventory, and job template. From within the container shell, run the following to load the data:
|
||||
|
||||
```bash
|
||||
(container)# awx-manage create_preload_data
|
||||
```
|
||||
|
||||
**NOTE**
|
||||
|
||||
> This information will persist in the database running in the `tools_postgres_1` container, until the container is removed. You may periodically need to recreate
|
||||
this container, and thus the database, if the database schema changes in an upstream commit.
|
||||
|
||||
##### Building API Documentation
|
||||
### Building API Documentation
|
||||
|
||||
AWX includes support for building [Swagger/OpenAPI
|
||||
documentation](https://swagger.io). To build the documentation locally, run:
|
||||
@ -284,7 +94,7 @@ is an example of one such service.
|
||||
|
||||
You can now log into the AWX web interface at [https://localhost:8043](https://localhost:8043), and access the API directly at [https://localhost:8043/api/](https://localhost:8043/api/).
|
||||
|
||||
To log in use the admin user and password you created above in [Create an admin user](#create-an-admin-user).
|
||||
[Create an admin user](./tools/docker-compose/README.md#create-an-admin-user) if needed.
|
||||
|
||||
### Purging containers and images
|
||||
|
||||
@ -335,7 +145,7 @@ Sometimes it might take us a while to fully review your PR. We try to keep the `
|
||||
|
||||
All submitted PRs will have the linter and unit tests run against them via Zuul, and the status reported in the PR.
|
||||
|
||||
## PR Checks ran by Zuul
|
||||
## PR Checks run by Zuul
|
||||
Zuul jobs for awx are defined in the [zuul-jobs](https://github.com/ansible/zuul-jobs) repo.
|
||||
|
||||
Zuul runs the following checks that must pass:
|
||||
|
||||
243
INSTALL.md
243
INSTALL.md
@ -12,7 +12,6 @@ This document provides a guide for installing AWX.
|
||||
+ [System Requirements](#system-requirements)
|
||||
+ [Choose a deployment platform](#choose-a-deployment-platform)
|
||||
+ [Official vs Building Images](#official-vs-building-images)
|
||||
* [Upgrading from previous versions](#upgrading-from-previous-versions)
|
||||
* [OpenShift](#openshift)
|
||||
+ [Prerequisites](#prerequisites-1)
|
||||
+ [Pre-install steps](#pre-install-steps)
|
||||
@ -29,17 +28,6 @@ This document provides a guide for installing AWX.
|
||||
+ [Post-install](#post-install-1)
|
||||
+ [Accessing AWX](#accessing-awx-1)
|
||||
+ [SSL Termination](#ssl-termination)
|
||||
* [Docker-Compose](#docker-compose)
|
||||
+ [Prerequisites](#prerequisites-3)
|
||||
+ [Pre-install steps](#pre-install-steps-2)
|
||||
- [Deploying to a remote host](#deploying-to-a-remote-host)
|
||||
- [Inventory variables](#inventory-variables)
|
||||
- [Docker registry](#docker-registry)
|
||||
- [Proxy settings](#proxy-settings)
|
||||
- [PostgreSQL](#postgresql-1)
|
||||
+ [Run the installer](#run-the-installer-2)
|
||||
+ [Post-install](#post-install-2)
|
||||
+ [Accessing AWX](#accessing-awx-2)
|
||||
- [Installing the AWX CLI](#installing-the-awx-cli)
|
||||
* [Building the CLI Documentation](#building-the-cli-documentation)
|
||||
|
||||
@ -83,10 +71,6 @@ Before you can run a deployment, you'll need the following installed in your loc
|
||||
- [GNU Make](https://www.gnu.org/software/make/)
|
||||
- [Git](https://git-scm.com/) Requires Version 1.8.4+
|
||||
- Python 3.6+
|
||||
- [Node 14.x LTS version](https://nodejs.org/en/download/)
|
||||
+ This is only required if you're [building your own container images](#official-vs-building-images) with `use_container_for_build=false`
|
||||
- [NPM 6.x LTS](https://docs.npmjs.com/)
|
||||
+ This is only required if you're [building your own container images](#official-vs-building-images) with `use_container_for_build=false`
|
||||
|
||||
### System Requirements
|
||||
|
||||
@ -100,14 +84,13 @@ The system that runs the AWX service will need to satisfy the following requirem
|
||||
|
||||
### Choose a deployment platform
|
||||
|
||||
We currently support running AWX as a containerized application using Docker images deployed to either an OpenShift cluster, a Kubernetes cluster, or docker-compose. The remainder of this document will walk you through the process of building the images, and deploying them to either platform.
|
||||
We currently support running AWX as a containerized application using Docker images deployed to either an OpenShift cluster or a Kubernetes cluster. The remainder of this document will walk you through the process of building the images, and deploying them to either platform.
|
||||
|
||||
The [installer](./installer) directory contains an [inventory](./installer/inventory) file, and a playbook, [install.yml](./installer/install.yml). You'll begin by setting variables in the inventory file according to the platform you wish to use, and then you'll start the image build and deployment process by running the playbook.
|
||||
|
||||
In the sections below, you'll find deployment details and instructions for each platform:
|
||||
- [OpenShift](#openshift)
|
||||
- [Kubernetes](#kubernetes)
|
||||
- [Docker Compose](#docker-compose).
|
||||
|
||||
### Official vs Building Images
|
||||
|
||||
@ -130,28 +113,15 @@ If these variables are present then all deployments will use these hosted images
|
||||
|
||||
> Multiple versions are provided. `latest` always pulls the most recent. You may also select version numbers at different granularities: 1, 1.0, 1.0.1, 1.0.0.123
|
||||
|
||||
*use_container_for_build*
|
||||
|
||||
> Use a local distribution build container image for building the AWX package. This is helpful if you don't want to bother installing the build-time dependencies as it is taken care of already.
|
||||
|
||||
|
||||
## Upgrading from previous versions
|
||||
|
||||
Upgrading AWX involves rerunning the install playbook. Download a newer release from [https://github.com/ansible/awx/releases](https://github.com/ansible/awx/releases) and re-populate the inventory file with your customized variables.
|
||||
|
||||
For convenience, you can create a file called `vars.yml`:
|
||||
To build your own container use the `build.yml` playbook:
|
||||
|
||||
```
|
||||
admin_password: 'adminpass'
|
||||
pg_password: 'pgpass'
|
||||
secret_key: 'mysupersecret'
|
||||
ansible-playbook tools/ansible/build.yml -e awx_version=test-build
|
||||
```
|
||||
|
||||
And pass it to the installer:
|
||||
The resulting image will automatically be pushed to a registry if `docker_registry` is defined.
|
||||
|
||||
|
||||
```
|
||||
$ ansible-playbook -i inventory install.yml -e @vars.yml
|
||||
```
|
||||
|
||||
## OpenShift
|
||||
|
||||
@ -437,209 +407,6 @@ If your provider is able to allocate an IP Address from the Ingress controller t
|
||||
Unlike Openshift's `Route` the Kubernetes `Ingress` doesn't yet handle SSL termination. As such the default configuration will only expose AWX through HTTP on port 80. You are responsible for configuring SSL support until support is added (either to Kubernetes or AWX itself).
|
||||
|
||||
|
||||
## Docker-Compose
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Docker](https://docs.docker.com/engine/installation/) on the host where AWX will be deployed. After installing Docker, the Docker service must be started (depending on your OS, you may have to add the local user that uses Docker to the ``docker`` group, refer to the documentation for details)
|
||||
- [docker-compose](https://pypi.org/project/docker-compose/) Python module.
|
||||
+ This also installs the `docker` Python module, which is incompatible with `docker-py`. If you have previously installed `docker-py`, please uninstall it.
|
||||
- [Docker Compose](https://docs.docker.com/compose/install/).
|
||||
|
||||
### Pre-install steps
|
||||
|
||||
#### Deploying to a remote host
|
||||
|
||||
By default, the delivered [installer/inventory](./installer/inventory) file will deploy AWX to the local host. It is possible, however, to deploy to a remote host. The [installer/install.yml](./installer/install.yml) playbook can be used to build images on the local host, and ship the built images to, and run deployment tasks on, a remote host. To do this, modify the [installer/inventory](./installer/inventory) file, by commenting out `localhost`, and adding the remote host.
|
||||
|
||||
For example, suppose you wish to build images locally on your CI/CD host, and deploy them to a remote host named *awx-server*. To do this, add *awx-server* to the [installer/inventory](./installer/inventory) file, and comment out or remove `localhost`, as demonstrated by the following:
|
||||
|
||||
```yaml
|
||||
# localhost ansible_connection=local
|
||||
awx-server
|
||||
|
||||
[all:vars]
|
||||
...
|
||||
```
|
||||
|
||||
In the above example, image build tasks will be delegated to `localhost`, which is typically where the clone of the AWX project exists. Built images will be archived, copied to remote host, and imported into the remote Docker image cache. Tasks to start the AWX containers will then execute on the remote host.
|
||||
|
||||
If you choose to use the official images then the remote host will be the one to pull those images.
|
||||
|
||||
**Note**
|
||||
|
||||
> You may also want to set additional variables to control how Ansible connects to the host. For more information about this, view [Behavioral Inventory Parameters](http://docs.ansible.com/ansible/latest/intro_inventory.html#id12).
|
||||
|
||||
> As mentioned above, in [Prerequisites](#prerequisites-1), the prerequisites are required on the remote host.
|
||||
|
||||
> When deploying to a remote host, the playbook does not execute tasks with the `become` option. For this reason, make sure the user that connects to the remote host has privileges to run the `docker` command. This typically means that non-privileged users need to be part of the `docker` group.
|
||||
|
||||
|
||||
#### Inventory variables
|
||||
|
||||
Before starting the install process, review the [inventory](./installer/inventory) file, and uncomment and provide values for the following variables found in the `[all:vars]` section:
|
||||
|
||||
*postgres_data_dir*
|
||||
|
||||
> If you're using the default PostgreSQL container (see [PostgreSQL](#postgresql-1) below), provide a path that can be mounted to the container, and where the database can be persisted.
|
||||
|
||||
*host_port*
|
||||
|
||||
> Provide a port number that can be mapped from the Docker daemon host to the web server running inside the AWX container. If undefined no port will be exposed. Defaults to *80*.
|
||||
|
||||
*host_port_ssl*
|
||||
|
||||
> Provide a port number that can be mapped from the Docker daemon host to the web server running inside the AWX container for SSL support. If undefined no port will be exposed. Defaults to *443*, only works if you also set `ssl_certificate` (see below).
|
||||
|
||||
*ssl_certificate*
|
||||
|
||||
> Optionally, provide the path to a file that contains a certificate and its private key. This needs to be a .pem-file
|
||||
|
||||
*docker_compose_dir*
|
||||
|
||||
> When using docker-compose, the `docker-compose.yml` file will be created there (default `~/.awx/awxcompose`).
|
||||
|
||||
*custom_venv_dir*
|
||||
|
||||
> Adds the custom venv environments from the local host to be passed into the containers at install.
|
||||
|
||||
*ca_trust_dir*
|
||||
|
||||
> If you're using a non trusted CA, provide a path where the untrusted Certs are stored on your Host.
|
||||
|
||||
#### Docker registry
|
||||
|
||||
If you wish to tag and push built images to a Docker registry, set the following variables in the inventory file:
|
||||
|
||||
*docker_registry*
|
||||
|
||||
> IP address and port, or URL, for accessing a registry.
|
||||
|
||||
*docker_registry_repository*
|
||||
|
||||
> Namespace to use when pushing and pulling images to and from the registry. Defaults to *awx*.
|
||||
|
||||
*docker_registry_username*
|
||||
|
||||
> Username of the user that will push images to the registry. Defaults to *developer*.
|
||||
|
||||
**Note**
|
||||
|
||||
> These settings are ignored if using official images
|
||||
|
||||
|
||||
#### Proxy settings
|
||||
|
||||
*http_proxy*
|
||||
|
||||
> IP address and port, or URL, for using an http_proxy.
|
||||
|
||||
*https_proxy*
|
||||
|
||||
> IP address and port, or URL, for using an https_proxy.
|
||||
|
||||
*no_proxy*
|
||||
|
||||
> Exclude IP address or URL from the proxy.
|
||||
|
||||
#### PostgreSQL
|
||||
|
||||
AWX requires access to a PostgreSQL database, and by default, one will be created and deployed in a container, and data will be persisted to a host volume. In this scenario, you must set the value of `postgres_data_dir` to a path that can be mounted to the container. When the container is stopped, the database files will still exist in the specified path.
|
||||
|
||||
If you wish to use an external database, in the inventory file, set the value of `pg_hostname`, and update `pg_username`, `pg_password`, `pg_admin_password`, `pg_database`, and `pg_port` with the connection information.
|
||||
|
||||
### Run the installer
|
||||
|
||||
If you are not pushing images to a Docker registry, start the install by running the following:
|
||||
|
||||
```bash
|
||||
# Set the working directory to installer
|
||||
$ cd installer
|
||||
|
||||
# Run the Ansible playbook
|
||||
$ ansible-playbook -i inventory install.yml
|
||||
```
|
||||
|
||||
If you're pushing built images to a repository, then use the `-e` option to pass the registry password as follows, replacing *password* with the password of the username assigned to `docker_registry_username` (note that you will also need to remove `dockerhub_base` and `dockerhub_version` from the inventory file):
|
||||
|
||||
```bash
|
||||
# Set the working directory to installer
|
||||
$ cd installer
|
||||
|
||||
# Run the Ansible playbook
|
||||
$ ansible-playbook -i inventory -e docker_registry_password=password install.yml
|
||||
```
|
||||
|
||||
### Post-install
|
||||
|
||||
After the playbook run completes, Docker starts a series of containers that provide the services that make up AWX. You can view the running containers using the `docker ps` command.
|
||||
|
||||
If you're deploying using Docker Compose, container names will be prefixed by the name of the folder where the docker-compose.yml file is created (by default, `awx`).
|
||||
|
||||
Immediately after the containers start, the *awx_task* container will perform required setup tasks, including database migrations. These tasks need to complete before the web interface can be accessed. To monitor the progress, you can follow the container's STDOUT by running the following:
|
||||
|
||||
```bash
|
||||
# Tail the awx_task log
|
||||
$ docker logs -f awx_task
|
||||
```
|
||||
|
||||
You will see output similar to the following:
|
||||
|
||||
```bash
|
||||
Using /etc/ansible/ansible.cfg as config file
|
||||
127.0.0.1 | SUCCESS => {
|
||||
"changed": false,
|
||||
"db": "awx"
|
||||
}
|
||||
Operations to perform:
|
||||
Synchronize unmigrated apps: solo, api, staticfiles, messages, channels, django_extensions, ui, rest_framework, polymorphic
|
||||
Apply all migrations: sso, taggit, sessions, sites, kombu_transport_django, social_auth, contenttypes, auth, conf, main
|
||||
Synchronizing apps without migrations:
|
||||
Creating tables...
|
||||
Running deferred SQL...
|
||||
Installing custom SQL...
|
||||
Running migrations:
|
||||
Rendering model states... DONE
|
||||
Applying contenttypes.0001_initial... OK
|
||||
Applying contenttypes.0002_remove_content_type_name... OK
|
||||
Applying auth.0001_initial... OK
|
||||
Applying auth.0002_alter_permission_name_max_length... OK
|
||||
Applying auth.0003_alter_user_email_max_length... OK
|
||||
Applying auth.0004_alter_user_username_opts... OK
|
||||
Applying auth.0005_alter_user_last_login_null... OK
|
||||
Applying auth.0006_require_contenttypes_0002... OK
|
||||
Applying taggit.0001_initial... OK
|
||||
Applying taggit.0002_auto_20150616_2121... OK
|
||||
Applying main.0001_initial... OK
|
||||
...
|
||||
```
|
||||
|
||||
Once migrations complete, you will see the following log output, indicating that migrations have completed:
|
||||
|
||||
```bash
|
||||
Python 2.7.5 (default, Nov 6 2016, 00:28:07)
|
||||
[GCC 4.8.5 20150623 (Red Hat 4.8.5-11)] on linux2
|
||||
Type "help", "copyright", "credits" or "license" for more information.
|
||||
(InteractiveConsole)
|
||||
|
||||
>>> <User: admin>
|
||||
>>> Default organization added.
|
||||
Demo Credential, Inventory, and Job Template added.
|
||||
Successfully registered instance awx
|
||||
(changed: True)
|
||||
Creating instance group tower
|
||||
Added instance awx to tower
|
||||
(changed: True)
|
||||
...
|
||||
```
|
||||
|
||||
### Accessing AWX
|
||||
|
||||
The AWX web server is accessible on the deployment host, using the *host_port* value set in the *inventory* file. The default URL is [http://localhost](http://localhost).
|
||||
|
||||
You will prompted with a login dialog. The default administrator username is `admin`, and the password is `password`.
|
||||
|
||||
|
||||
# Installing the AWX CLI
|
||||
|
||||
`awx` is the official command-line client for AWX. It:
|
||||
|
||||
126
Makefile
126
Makefile
@ -25,6 +25,8 @@ SCL_PREFIX ?=
|
||||
CELERY_SCHEDULE_FILE ?= /var/lib/awx/beat.db
|
||||
|
||||
DEV_DOCKER_TAG_BASE ?= gcr.io/ansible-tower-engineering
|
||||
DEVEL_IMAGE_NAME ?= $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG)
|
||||
|
||||
# Python packages to install only from source (not from binary wheels)
|
||||
# Comma separated list
|
||||
SRC_ONLY_PKGS ?= cffi,pycparser,psycopg2,twilio,pycurl
|
||||
@ -64,7 +66,7 @@ I18N_FLAG_FILE = .i18n_built
|
||||
receiver test test_unit test_coverage coverage_html \
|
||||
dev_build release_build release_clean sdist \
|
||||
ui-docker-machine ui-docker ui-release ui-devel \
|
||||
ui-test ui-deps ui-test-ci VERSION
|
||||
ui-test ui-deps ui-test-ci VERSION docker-compose-sources
|
||||
|
||||
clean-tmp:
|
||||
rm -rf tmp/
|
||||
@ -443,39 +445,6 @@ bulk_data:
|
||||
fi; \
|
||||
$(PYTHON) tools/data_generators/rbac_dummy_data_generator.py --preset=$(DATA_GEN_PRESET)
|
||||
|
||||
# l10n TASKS
|
||||
# --------------------------------------
|
||||
|
||||
# check for UI po files
|
||||
HAVE_PO := $(shell ls awx/ui/po/*.po 2>/dev/null)
|
||||
check-po:
|
||||
ifdef HAVE_PO
|
||||
# Should be 'Language: zh-CN' but not 'Language: zh_CN' in zh_CN.po
|
||||
for po in awx/ui/po/*.po ; do \
|
||||
echo $$po; \
|
||||
mo="awx/ui/po/`basename $$po .po`.mo"; \
|
||||
msgfmt --check --verbose $$po -o $$mo; \
|
||||
if test "$$?" -ne 0 ; then \
|
||||
exit -1; \
|
||||
fi; \
|
||||
rm $$mo; \
|
||||
name=`echo "$$po" | grep '-'`; \
|
||||
if test "x$$name" != x ; then \
|
||||
right_name=`echo $$language | sed -e 's/-/_/'`; \
|
||||
echo "ERROR: WRONG $$name CORRECTION: $$right_name"; \
|
||||
exit -1; \
|
||||
fi; \
|
||||
language=`grep '^"Language:' "$$po" | grep '_'`; \
|
||||
if test "x$$language" != x ; then \
|
||||
right_language=`echo $$language | sed -e 's/_/-/'`; \
|
||||
echo "ERROR: WRONG $$language CORRECTION: $$right_language in $$po"; \
|
||||
exit -1; \
|
||||
fi; \
|
||||
done;
|
||||
else
|
||||
@echo No PO files
|
||||
endif
|
||||
|
||||
|
||||
# UI TASKS
|
||||
# --------------------------------------
|
||||
@ -494,10 +463,8 @@ awx/ui_next/node_modules:
|
||||
$(NPM_BIN) --prefix awx/ui_next --loglevel warn --ignore-scripts install
|
||||
|
||||
$(UI_BUILD_FLAG_FILE):
|
||||
$(NPM_BIN) --prefix awx/ui_next --loglevel warn run extract-strings
|
||||
$(NPM_BIN) --prefix awx/ui_next --loglevel warn run compile-strings
|
||||
$(NPM_BIN) --prefix awx/ui_next --loglevel warn run build
|
||||
git checkout awx/ui_next/src/locales
|
||||
mkdir -p awx/public/static/css
|
||||
mkdir -p awx/public/static/js
|
||||
mkdir -p awx/public/static/media
|
||||
@ -511,6 +478,12 @@ ui-release: awx/ui_next/node_modules $(UI_BUILD_FLAG_FILE)
|
||||
ui-devel: awx/ui_next/node_modules
|
||||
@$(MAKE) -B $(UI_BUILD_FLAG_FILE)
|
||||
|
||||
ui-devel-instrumented: awx/ui_next/node_modules
|
||||
$(NPM_BIN) --prefix awx/ui_next --loglevel warn run start-instrumented
|
||||
|
||||
ui-devel-test: awx/ui_next/node_modules
|
||||
$(NPM_BIN) --prefix awx/ui_next --loglevel warn run start
|
||||
|
||||
ui-zuul-lint-and-test:
|
||||
$(NPM_BIN) --prefix awx/ui_next install
|
||||
$(NPM_BIN) run --prefix awx/ui_next lint
|
||||
@ -559,31 +532,31 @@ docker-auth:
|
||||
awx/projects:
|
||||
@mkdir -p $@
|
||||
|
||||
# Docker isolated rampart
|
||||
docker-compose-isolated: awx/projects
|
||||
CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose.yml -f tools/docker-isolated-override.yml up
|
||||
|
||||
COMPOSE_UP_OPTS ?=
|
||||
|
||||
# Docker Compose Development environment
|
||||
docker-compose: docker-auth awx/projects
|
||||
CURRENT_UID=$(shell id -u) OS="$(shell docker info | grep 'Operating System')" TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose.yml $(COMPOSE_UP_OPTS) up --no-recreate awx
|
||||
docker-compose-sources:
|
||||
ansible-playbook -i tools/docker-compose/inventory tools/docker-compose/ansible/sources.yml \
|
||||
-e awx_image=$(DEV_DOCKER_TAG_BASE)/awx_devel \
|
||||
-e awx_image_tag=$(COMPOSE_TAG)
|
||||
|
||||
docker-compose: docker-auth awx/projects docker-compose-sources
|
||||
docker-compose -f tools/docker-compose/_sources/docker-compose.yml $(COMPOSE_UP_OPTS) up --no-recreate awx
|
||||
|
||||
docker-compose-cluster: docker-auth awx/projects
|
||||
CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose-cluster.yml up
|
||||
docker-compose -f tools/docker-compose-cluster.yml up
|
||||
|
||||
docker-compose-credential-plugins: docker-auth awx/projects
|
||||
docker-compose-credential-plugins: docker-auth awx/projects docker-compose-sources
|
||||
echo -e "\033[0;31mTo generate a CyberArk Conjur API key: docker exec -it tools_conjur_1 conjurctl account create quick-start\033[0m"
|
||||
CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose.yml -f tools/docker-credential-plugins-override.yml up --no-recreate awx
|
||||
docker-compose -f tools/docker-compose/_sources/docker-compose.yml -f tools/docker-credential-plugins-override.yml up --no-recreate awx
|
||||
|
||||
docker-compose-test: docker-auth awx/projects
|
||||
cd tools && CURRENT_UID=$(shell id -u) OS="$(shell docker info | grep 'Operating System')" TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose run --rm --service-ports awx /bin/bash
|
||||
docker-compose-test: docker-auth awx/projects docker-compose-sources
|
||||
docker-compose -f tools/docker-compose/_sources/docker-compose.yml run --rm --service-ports awx /bin/bash
|
||||
|
||||
docker-compose-runtest: awx/projects
|
||||
cd tools && CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose run --rm --service-ports awx /start_tests.sh
|
||||
docker-compose-runtest: awx/projects docker-compose-sources
|
||||
docker-compose -f tools/docker-compose/_sources/docker-compose.yml run --rm --service-ports awx /start_tests.sh
|
||||
|
||||
docker-compose-build-swagger: awx/projects
|
||||
cd tools && CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose run --rm --service-ports --no-deps awx /start_tests.sh swagger
|
||||
docker-compose-build-swagger: awx/projects docker-compose-sources
|
||||
docker-compose -f tools/docker-compose/_sources/docker-compose.yml run --rm --service-ports --no-deps awx /start_tests.sh swagger
|
||||
|
||||
detect-schema-change: genschema
|
||||
curl https://s3.amazonaws.com/awx-public-ci-files/schema.json -o reference-schema.json
|
||||
@ -591,24 +564,14 @@ detect-schema-change: genschema
|
||||
diff -u -b reference-schema.json schema.json
|
||||
|
||||
docker-compose-clean: awx/projects
|
||||
cd tools && TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose rm -sf
|
||||
docker-compose -f tools/docker-compose/_sources/docker-compose.yml rm -sf
|
||||
|
||||
# Base development image build
|
||||
docker-compose-build:
|
||||
ansible-playbook installer/dockerfile.yml -e build_dev=True
|
||||
docker build -t ansible/awx_devel \
|
||||
ansible-playbook tools/ansible/dockerfile.yml -e build_dev=True
|
||||
DOCKER_BUILDKIT=1 docker build -t $(DEVEL_IMAGE_NAME) \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
--cache-from=$(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) .
|
||||
docker tag ansible/awx_devel $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG)
|
||||
#docker push $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG)
|
||||
|
||||
# For use when developing on "isolated" AWX deployments
|
||||
docker-compose-isolated-build: docker-compose-build
|
||||
docker build -t ansible/awx_isolated \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
-f tools/docker-isolated/Dockerfile .
|
||||
docker tag ansible/awx_isolated $(DEV_DOCKER_TAG_BASE)/awx_isolated:$(COMPOSE_TAG)
|
||||
#docker push $(DEV_DOCKER_TAG_BASE)/awx_isolated:$(COMPOSE_TAG)
|
||||
|
||||
docker-clean:
|
||||
$(foreach container_id,$(shell docker ps -f name=tools_awx -aq),docker stop $(container_id); docker rm -f $(container_id);)
|
||||
@ -620,11 +583,11 @@ docker-clean-volumes: docker-compose-clean
|
||||
docker-refresh: docker-clean docker-compose
|
||||
|
||||
# Docker Development Environment with Elastic Stack Connected
|
||||
docker-compose-elk: docker-auth awx/projects
|
||||
CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose.yml -f tools/elastic/docker-compose.logstash-link.yml -f tools/elastic/docker-compose.elastic-override.yml up --no-recreate
|
||||
docker-compose-elk: docker-auth awx/projects docker-compose-sources
|
||||
docker-compose -f tools/docker-compose/_sources/docker-compose.yml -f tools/elastic/docker-compose.logstash-link.yml -f tools/elastic/docker-compose.elastic-override.yml up --no-recreate
|
||||
|
||||
docker-compose-cluster-elk: docker-auth awx/projects
|
||||
TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose-cluster.yml -f tools/elastic/docker-compose.logstash-link-cluster.yml -f tools/elastic/docker-compose.elastic-override.yml up --no-recreate
|
||||
docker-compose-cluster-elk: docker-auth awx/projects docker-compose-sources
|
||||
docker-compose -f tools/docker-compose/_sources/docker-compose.yml -f tools/elastic/docker-compose.logstash-link-cluster.yml -f tools/elastic/docker-compose.elastic-override.yml up --no-recreate
|
||||
|
||||
prometheus:
|
||||
docker run -u0 --net=tools_default --link=`docker ps | egrep -o "tools_awx(_run)?_([^ ]+)?"`:awxweb --volume `pwd`/tools/prometheus:/prometheus --name prometheus -d -p 0.0.0.0:9090:9090 prom/prometheus --web.enable-lifecycle --config.file=/prometheus/prometheus.yml
|
||||
@ -643,11 +606,11 @@ psql-container:
|
||||
VERSION:
|
||||
@echo "awx: $(VERSION)"
|
||||
|
||||
Dockerfile: installer/roles/dockerfile/templates/Dockerfile.j2
|
||||
ansible-playbook installer/dockerfile.yml
|
||||
Dockerfile: tools/ansible/roles/dockerfile/templates/Dockerfile.j2
|
||||
ansible-playbook tools/ansible/dockerfile.yml
|
||||
|
||||
Dockerfile.kube-dev: installer/roles/dockerfile/templates/Dockerfile.j2
|
||||
ansible-playbook installer/dockerfile.yml \
|
||||
Dockerfile.kube-dev: tools/ansible/roles/dockerfile/templates/Dockerfile.j2
|
||||
ansible-playbook tools/ansible/dockerfile.yml \
|
||||
-e dockerfile_name=Dockerfile.kube-dev \
|
||||
-e kube_dev=True \
|
||||
-e template_dest=_build_kube_dev
|
||||
@ -656,3 +619,20 @@ awx-kube-dev-build: Dockerfile.kube-dev
|
||||
docker build -f Dockerfile.kube-dev \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
-t $(DEV_DOCKER_TAG_BASE)/awx_kube_devel:$(COMPOSE_TAG) .
|
||||
|
||||
|
||||
# Translation TASKS
|
||||
# --------------------------------------
|
||||
|
||||
# generate UI .pot
|
||||
pot: $(UI_BUILD_FLAG_FILE)
|
||||
$(NPM_BIN) --prefix awx/ui_next --loglevel warn run extract-strings
|
||||
$(NPM_BIN) --prefix awx/ui_next --loglevel warn run extract-template
|
||||
|
||||
# generate API django .pot .po
|
||||
LANG = "en-us"
|
||||
messages:
|
||||
@if [ "$(VENV_BASE)" ]; then \
|
||||
. $(VENV_BASE)/awx/bin/activate; \
|
||||
fi; \
|
||||
$(PYTHON) manage.py makemessages -l $(LANG) --keep-pot
|
||||
|
||||
@ -124,7 +124,7 @@ SUMMARIZABLE_FK_FIELDS = {
|
||||
'last_update': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'license_error'),
|
||||
'current_update': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'license_error'),
|
||||
'current_job': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'license_error'),
|
||||
'inventory_source': ('source', 'last_updated', 'status'),
|
||||
'inventory_source': ('id', 'name', 'source', 'last_updated', 'status'),
|
||||
'custom_inventory_script': DEFAULT_SUMMARY_FIELDS,
|
||||
'source_script': DEFAULT_SUMMARY_FIELDS,
|
||||
'role': ('id', 'role_field'),
|
||||
|
||||
@ -1453,6 +1453,7 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
|
||||
for name in ('awx', 'tower'):
|
||||
r['{}_workflow_job_id'.format(name)] = wj.pk
|
||||
r['{}_workflow_job_name'.format(name)] = wj.name
|
||||
r['{}_workflow_job_launch_type'.format(name)] = wj.launch_type
|
||||
if schedule:
|
||||
r['{}_parent_job_schedule_id'.format(name)] = schedule.pk
|
||||
r['{}_parent_job_schedule_name'.format(name)] = schedule.name
|
||||
|
||||
@ -154,6 +154,7 @@ class TestMetaVars:
|
||||
assert data['awx_user_id'] == admin_user.id
|
||||
assert data['awx_user_name'] == admin_user.username
|
||||
assert data['awx_workflow_job_id'] == workflow_job.pk
|
||||
assert data['awx_workflow_job_launch_type'] == workflow_job.launch_type
|
||||
|
||||
def test_scheduled_job_metavars(self, job_template, admin_user):
|
||||
schedule = Schedule.objects.create(
|
||||
@ -197,6 +198,8 @@ class TestMetaVars:
|
||||
'tower_workflow_job_name': 'workflow-job',
|
||||
'awx_workflow_job_id': workflow_job.id,
|
||||
'tower_workflow_job_id': workflow_job.id,
|
||||
'awx_workflow_job_launch_type': workflow_job.launch_type,
|
||||
'tower_workflow_job_launch_type': workflow_job.launch_type,
|
||||
'awx_parent_job_schedule_id': schedule.id,
|
||||
'tower_parent_job_schedule_id': schedule.id,
|
||||
'awx_parent_job_schedule_name': 'job-schedule',
|
||||
|
||||
@ -116,7 +116,7 @@ def construct_rsyslog_conf_template(settings=settings):
|
||||
def reconfigure_rsyslog():
|
||||
tmpl = construct_rsyslog_conf_template()
|
||||
# Write config to a temp file then move it to preserve atomicity
|
||||
with tempfile.TemporaryDirectory(prefix='rsyslog-conf-') as temp_dir:
|
||||
with tempfile.TemporaryDirectory(dir='/var/lib/awx/rsyslog/', prefix='rsyslog-conf-') as temp_dir:
|
||||
path = temp_dir + '/rsyslog.conf.temp'
|
||||
with open(path, 'w') as f:
|
||||
os.chmod(path, 0o640)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
# Copyright (c) 2015 Ansible, Inc.
|
||||
# All Rights Reserved.
|
||||
|
||||
import base64
|
||||
import os
|
||||
import re # noqa
|
||||
import sys
|
||||
@ -148,7 +149,10 @@ SCHEDULE_MAX_JOBS = 10
|
||||
SITE_ID = 1
|
||||
|
||||
# Make this unique, and don't share it with anybody.
|
||||
SECRET_KEY = 'p7z7g1ql4%6+(6nlebb6hdk7sd^&fnjpal308%n%+p^_e6vo1y'
|
||||
if os.path.exists('/etc/tower/SECRET_KEY'):
|
||||
SECRET_KEY = open('/etc/tower/SECRET_KEY', 'rb').read().strip()
|
||||
else:
|
||||
SECRET_KEY = base64.encodebytes(os.urandom(32)).decode().rstrip()
|
||||
|
||||
# Hosts/domain names that are valid for this site; required if DEBUG is False
|
||||
# See https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
|
||||
@ -738,10 +742,10 @@ TOWER_INSTANCE_ID_VAR = 'remote_tower_id'
|
||||
# ---------------------
|
||||
# ----- Foreman -----
|
||||
# ---------------------
|
||||
SATELLITE6_ENABLED_VAR = 'foreman.enabled'
|
||||
SATELLITE6_ENABLED_VAR = 'foreman_enabled'
|
||||
SATELLITE6_ENABLED_VALUE = 'True'
|
||||
SATELLITE6_EXCLUDE_EMPTY_GROUPS = True
|
||||
SATELLITE6_INSTANCE_ID_VAR = 'foreman.id'
|
||||
SATELLITE6_INSTANCE_ID_VAR = 'foreman_id'
|
||||
# SATELLITE6_GROUP_PREFIX and SATELLITE6_GROUP_PATTERNS defined in source vars
|
||||
|
||||
# ---------------------
|
||||
|
||||
2
awx/ui_next/.prettierignore
Normal file
2
awx/ui_next/.prettierignore
Normal file
@ -0,0 +1,2 @@
|
||||
build
|
||||
src/locales
|
||||
@ -29,6 +29,7 @@ import Root from './models/Root';
|
||||
import Schedules from './models/Schedules';
|
||||
import Settings from './models/Settings';
|
||||
import SystemJobs from './models/SystemJobs';
|
||||
import SystemJobTemplates from './models/SystemJobTemplates';
|
||||
import Teams from './models/Teams';
|
||||
import Tokens from './models/Tokens';
|
||||
import UnifiedJobTemplates from './models/UnifiedJobTemplates';
|
||||
@ -71,6 +72,7 @@ const RootAPI = new Root();
|
||||
const SchedulesAPI = new Schedules();
|
||||
const SettingsAPI = new Settings();
|
||||
const SystemJobsAPI = new SystemJobs();
|
||||
const SystemJobTemplatesAPI = new SystemJobTemplates();
|
||||
const TeamsAPI = new Teams();
|
||||
const TokensAPI = new Tokens();
|
||||
const UnifiedJobTemplatesAPI = new UnifiedJobTemplates();
|
||||
@ -114,6 +116,7 @@ export {
|
||||
SchedulesAPI,
|
||||
SettingsAPI,
|
||||
SystemJobsAPI,
|
||||
SystemJobTemplatesAPI,
|
||||
TeamsAPI,
|
||||
TokensAPI,
|
||||
UnifiedJobTemplatesAPI,
|
||||
|
||||
@ -10,6 +10,7 @@ class InventorySources extends LaunchUpdateMixin(
|
||||
super(http);
|
||||
this.baseUrl = '/api/v2/inventory_sources/';
|
||||
|
||||
this.createSchedule = this.createSchedule.bind(this);
|
||||
this.createSyncStart = this.createSyncStart.bind(this);
|
||||
this.destroyGroups = this.destroyGroups.bind(this);
|
||||
this.destroyHosts = this.destroyHosts.bind(this);
|
||||
|
||||
@ -10,6 +10,7 @@ class JobTemplates extends SchedulesMixin(
|
||||
super(http);
|
||||
this.baseUrl = '/api/v2/job_templates/';
|
||||
|
||||
this.createSchedule = this.createSchedule.bind(this);
|
||||
this.launch = this.launch.bind(this);
|
||||
this.readLaunch = this.readLaunch.bind(this);
|
||||
this.associateLabel = this.associateLabel.bind(this);
|
||||
|
||||
@ -9,8 +9,8 @@ const getBaseURL = type => {
|
||||
case 'project':
|
||||
case 'project_update':
|
||||
return '/project_updates/';
|
||||
case 'system':
|
||||
case 'system_job':
|
||||
case 'management':
|
||||
case 'management_job':
|
||||
return '/system_jobs/';
|
||||
case 'inventory':
|
||||
case 'inventory_update':
|
||||
|
||||
@ -16,6 +16,7 @@ class Projects extends SchedulesMixin(
|
||||
this.readPlaybooks = this.readPlaybooks.bind(this);
|
||||
this.readSync = this.readSync.bind(this);
|
||||
this.sync = this.sync.bind(this);
|
||||
this.createSchedule = this.createSchedule.bind(this);
|
||||
}
|
||||
|
||||
readAccessList(id, params) {
|
||||
|
||||
@ -14,6 +14,19 @@ class Schedules extends Base {
|
||||
return this.http.get(`${this.baseUrl}${resourceId}/credentials/`, params);
|
||||
}
|
||||
|
||||
associateCredential(resourceId, credentialId) {
|
||||
return this.http.post(`${this.baseUrl}${resourceId}/credentials/`, {
|
||||
id: credentialId,
|
||||
});
|
||||
}
|
||||
|
||||
disassociateCredential(resourceId, credentialId) {
|
||||
return this.http.post(`${this.baseUrl}${resourceId}/credentials/`, {
|
||||
id: credentialId,
|
||||
disassociate: true,
|
||||
});
|
||||
}
|
||||
|
||||
readZoneInfo() {
|
||||
return this.http.get(`${this.baseUrl}zoneinfo/`);
|
||||
}
|
||||
|
||||
24
awx/ui_next/src/api/models/SystemJobTemplates.js
Normal file
24
awx/ui_next/src/api/models/SystemJobTemplates.js
Normal file
@ -0,0 +1,24 @@
|
||||
import Base from '../Base';
|
||||
import NotificationsMixin from '../mixins/Notifications.mixin';
|
||||
import SchedulesMixin from '../mixins/Schedules.mixin';
|
||||
|
||||
const Mixins = SchedulesMixin(NotificationsMixin(Base));
|
||||
|
||||
class SystemJobTemplates extends Mixins {
|
||||
constructor(http) {
|
||||
super(http);
|
||||
this.baseUrl = '/api/v2/system_job_templates/';
|
||||
}
|
||||
|
||||
readDetail(id) {
|
||||
const path = `${this.baseUrl}${id}/`;
|
||||
|
||||
return this.http.get(path).then(({ data }) => data);
|
||||
}
|
||||
|
||||
launch(id, data) {
|
||||
return this.http.post(`${this.baseUrl}${id}/launch/`, data);
|
||||
}
|
||||
}
|
||||
|
||||
export default SystemJobTemplates;
|
||||
@ -6,6 +6,7 @@ class WorkflowJobTemplates extends SchedulesMixin(NotificationsMixin(Base)) {
|
||||
constructor(http) {
|
||||
super(http);
|
||||
this.baseUrl = '/api/v2/workflow_job_templates/';
|
||||
this.createSchedule = this.createSchedule.bind(this);
|
||||
}
|
||||
|
||||
readWebhookKey(id) {
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
Badge,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownToggle,
|
||||
@ -12,7 +15,25 @@ import {
|
||||
PageHeaderToolsItem,
|
||||
Tooltip,
|
||||
} from '@patternfly/react-core';
|
||||
import { QuestionCircleIcon, UserIcon } from '@patternfly/react-icons';
|
||||
import {
|
||||
BellIcon,
|
||||
QuestionCircleIcon,
|
||||
UserIcon,
|
||||
} from '@patternfly/react-icons';
|
||||
import { WorkflowApprovalsAPI } from '../../api';
|
||||
import useRequest from '../../util/useRequest';
|
||||
import useWsPendingApprovalCount from './useWsPendingApprovalCount';
|
||||
|
||||
const PendingWorkflowApprovals = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
margin-right: 10px;
|
||||
`;
|
||||
|
||||
const PendingWorkflowApprovalBadge = styled(Badge)`
|
||||
margin-left: 10px;
|
||||
`;
|
||||
|
||||
const DOCLINK =
|
||||
'https://docs.ansible.com/ansible-tower/latest/html/userguide/index.html';
|
||||
@ -27,6 +48,31 @@ function PageHeaderToolbar({
|
||||
const [isHelpOpen, setIsHelpOpen] = useState(false);
|
||||
const [isUserOpen, setIsUserOpen] = useState(false);
|
||||
|
||||
const {
|
||||
request: fetchPendingApprovalCount,
|
||||
result: pendingApprovals,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const {
|
||||
data: { count },
|
||||
} = await WorkflowApprovalsAPI.read({
|
||||
status: 'pending',
|
||||
page_size: 1,
|
||||
});
|
||||
return count;
|
||||
}, []),
|
||||
0
|
||||
);
|
||||
|
||||
const pendingApprovalsCount = useWsPendingApprovalCount(
|
||||
pendingApprovals,
|
||||
fetchPendingApprovalCount
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPendingApprovalCount();
|
||||
}, [fetchPendingApprovalCount]);
|
||||
|
||||
const handleHelpSelect = () => {
|
||||
setIsHelpOpen(!isHelpOpen);
|
||||
};
|
||||
@ -37,7 +83,25 @@ function PageHeaderToolbar({
|
||||
return (
|
||||
<PageHeaderTools>
|
||||
<PageHeaderToolsGroup>
|
||||
<Tooltip position="left" content={<div>{i18n._(t`Info`)}</div>}>
|
||||
<Tooltip
|
||||
position="bottom"
|
||||
content={i18n._(t`Pending Workflow Approvals`)}
|
||||
>
|
||||
<PageHeaderToolsItem>
|
||||
<Link to="/workflow_approvals?workflow_approvals.status=pending">
|
||||
<PendingWorkflowApprovals>
|
||||
<BellIcon color="white" />
|
||||
<PendingWorkflowApprovalBadge
|
||||
id="toolbar-workflow-approval-badge"
|
||||
isRead
|
||||
>
|
||||
{pendingApprovalsCount}
|
||||
</PendingWorkflowApprovalBadge>
|
||||
</PendingWorkflowApprovals>
|
||||
</Link>
|
||||
</PageHeaderToolsItem>
|
||||
</Tooltip>
|
||||
<Tooltip position="bottom" content={<div>{i18n._(t`Info`)}</div>}>
|
||||
<PageHeaderToolsItem>
|
||||
<Dropdown
|
||||
isPlain
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
import PageHeaderToolbar from './PageHeaderToolbar';
|
||||
import { WorkflowApprovalsAPI } from '../../api';
|
||||
|
||||
jest.mock('../../api');
|
||||
|
||||
let wrapper;
|
||||
|
||||
describe('PageHeaderToolbar', () => {
|
||||
const pageHelpDropdownSelector = 'Dropdown QuestionCircleIcon';
|
||||
@ -8,26 +14,39 @@ describe('PageHeaderToolbar', () => {
|
||||
const onAboutClick = jest.fn();
|
||||
const onLogoutClick = jest.fn();
|
||||
|
||||
test('expected content is rendered on initialization', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<PageHeaderToolbar
|
||||
onAboutClick={onAboutClick}
|
||||
onLogoutClick={onLogoutClick}
|
||||
/>
|
||||
);
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('expected content is rendered on initialization', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<PageHeaderToolbar
|
||||
onAboutClick={onAboutClick}
|
||||
onLogoutClick={onLogoutClick}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
wrapper.find(
|
||||
'Link[to="/workflow_approvals?workflow_approvals.status=pending"]'
|
||||
)
|
||||
).toHaveLength(1);
|
||||
expect(wrapper.find(pageHelpDropdownSelector)).toHaveLength(1);
|
||||
expect(wrapper.find(pageUserDropdownSelector)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('dropdowns have expected items and callbacks', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<PageHeaderToolbar
|
||||
onAboutClick={onAboutClick}
|
||||
onLogoutClick={onLogoutClick}
|
||||
loggedInUser={{ id: 1 }}
|
||||
/>
|
||||
);
|
||||
test('dropdowns have expected items and callbacks', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<PageHeaderToolbar
|
||||
onAboutClick={onAboutClick}
|
||||
onLogoutClick={onLogoutClick}
|
||||
loggedInUser={{ id: 1 }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('DropdownItem')).toHaveLength(0);
|
||||
wrapper.find(pageHelpDropdownSelector).simulate('click');
|
||||
expect(wrapper.find('DropdownItem')).toHaveLength(2);
|
||||
@ -48,4 +67,24 @@ describe('PageHeaderToolbar', () => {
|
||||
logout.simulate('click');
|
||||
expect(onLogoutClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('pending workflow approvals count set correctly', async () => {
|
||||
WorkflowApprovalsAPI.read.mockResolvedValueOnce({
|
||||
data: {
|
||||
count: 20,
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<PageHeaderToolbar
|
||||
onAboutClick={onAboutClick}
|
||||
onLogoutClick={onLogoutClick}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
wrapper.find('Badge#toolbar-workflow-approval-badge').text()
|
||||
).toEqual('20');
|
||||
});
|
||||
});
|
||||
|
||||
@ -0,0 +1,46 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import useWebsocket from '../../util/useWebsocket';
|
||||
import useThrottle from '../../util/useThrottle';
|
||||
|
||||
export default function useWsPendingApprovalCount(
|
||||
initialCount,
|
||||
fetchApprovalsCount
|
||||
) {
|
||||
const [pendingApprovalCount, setPendingApprovalCount] = useState(
|
||||
initialCount
|
||||
);
|
||||
const [reloadCount, setReloadCount] = useState(false);
|
||||
const throttledFetch = useThrottle(reloadCount, 1000);
|
||||
const lastMessage = useWebsocket({
|
||||
jobs: ['status_changed'],
|
||||
control: ['limit_reached_1'],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setPendingApprovalCount(initialCount);
|
||||
}, [initialCount]);
|
||||
|
||||
useEffect(
|
||||
function reloadTheCount() {
|
||||
(async () => {
|
||||
if (!throttledFetch) {
|
||||
return;
|
||||
}
|
||||
setReloadCount(false);
|
||||
fetchApprovalsCount();
|
||||
})();
|
||||
},
|
||||
[throttledFetch, fetchApprovalsCount]
|
||||
);
|
||||
|
||||
useEffect(
|
||||
function processWsMessage() {
|
||||
if (lastMessage?.type === 'workflow_approval') {
|
||||
setReloadCount(true);
|
||||
}
|
||||
},
|
||||
[lastMessage]
|
||||
);
|
||||
|
||||
return pendingApprovalCount;
|
||||
}
|
||||
@ -0,0 +1,117 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import WS from 'jest-websocket-mock';
|
||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
import useWsPendingApprovalCount from './useWsPendingApprovalCount';
|
||||
|
||||
/*
|
||||
Jest mock timers don’t play well with jest-websocket-mock,
|
||||
so we'll stub out throttling to resolve immediately
|
||||
*/
|
||||
jest.mock('../../util/useThrottle', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(val => val),
|
||||
}));
|
||||
|
||||
function TestInner() {
|
||||
return <div />;
|
||||
}
|
||||
function Test({ initialCount, fetchApprovalsCount }) {
|
||||
const updatedWorkflowApprovals = useWsPendingApprovalCount(
|
||||
initialCount,
|
||||
fetchApprovalsCount
|
||||
);
|
||||
return <TestInner initialCount={updatedWorkflowApprovals} />;
|
||||
}
|
||||
|
||||
describe('useWsPendingApprovalCount hook', () => {
|
||||
let debug;
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
debug = global.console.debug; // eslint-disable-line prefer-destructuring
|
||||
global.console.debug = () => {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.console.debug = debug;
|
||||
WS.clean();
|
||||
});
|
||||
|
||||
test('should return workflow approval pending count', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Test initialCount={2} fetchApprovalsCount={() => {}} />
|
||||
);
|
||||
|
||||
expect(wrapper.find('TestInner').prop('initialCount')).toEqual(2);
|
||||
});
|
||||
|
||||
test('should establish websocket connection', async () => {
|
||||
global.document.cookie = 'csrftoken=abc123';
|
||||
const mockServer = new WS('ws://localhost/websocket/');
|
||||
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Test initialCount={2} fetchApprovalsCount={() => {}} />
|
||||
);
|
||||
});
|
||||
|
||||
await mockServer.connected;
|
||||
await expect(mockServer).toReceiveMessage(
|
||||
JSON.stringify({
|
||||
xrftoken: 'abc123',
|
||||
groups: {
|
||||
jobs: ['status_changed'],
|
||||
control: ['limit_reached_1'],
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should refetch count after approval status changes', async () => {
|
||||
global.document.cookie = 'csrftoken=abc123';
|
||||
const mockServer = new WS('ws://localhost/websocket/');
|
||||
const fetchApprovalsCount = jest.fn(() => []);
|
||||
await act(async () => {
|
||||
wrapper = await mountWithContexts(
|
||||
<Test initialCount={2} fetchApprovalsCount={fetchApprovalsCount} />
|
||||
);
|
||||
});
|
||||
|
||||
await mockServer.connected;
|
||||
await act(async () => {
|
||||
mockServer.send(
|
||||
JSON.stringify({
|
||||
unified_job_id: 2,
|
||||
type: 'workflow_approval',
|
||||
status: 'pending',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(fetchApprovalsCount).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should not refetch when message is not workflow approval', async () => {
|
||||
global.document.cookie = 'csrftoken=abc123';
|
||||
const mockServer = new WS('ws://localhost/websocket/');
|
||||
const fetchApprovalsCount = jest.fn(() => []);
|
||||
await act(async () => {
|
||||
wrapper = await mountWithContexts(
|
||||
<Test initialCount={2} fetchApprovalsCount={fetchApprovalsCount} />
|
||||
);
|
||||
});
|
||||
|
||||
await mockServer.connected;
|
||||
await act(async () => {
|
||||
mockServer.send(
|
||||
JSON.stringify({
|
||||
unified_job_id: 1,
|
||||
type: 'job',
|
||||
status: 'successful',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(fetchApprovalsCount).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
@ -32,7 +32,7 @@ function JobListItem({
|
||||
inventory_update: i18n._(t`Inventory Sync`),
|
||||
job: i18n._(t`Playbook Run`),
|
||||
ad_hoc_command: i18n._(t`Command`),
|
||||
management_job: i18n._(t`Management Job`),
|
||||
system_job: i18n._(t`Management Job`),
|
||||
workflow_job: i18n._(t`Workflow Job`),
|
||||
};
|
||||
|
||||
@ -160,6 +160,14 @@ function JobListItem({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{job.job_explanation && (
|
||||
<Detail
|
||||
fullWidth
|
||||
label={i18n._(t`Explanation`)}
|
||||
value={job.job_explanation}
|
||||
/>
|
||||
)}
|
||||
</DetailList>
|
||||
</ExpandableRowContent>
|
||||
</Td>
|
||||
|
||||
@ -62,7 +62,7 @@ function ReLaunchDropDown({ isPrimary = false, handleRelaunch, i18n }) {
|
||||
<DropdownToggle
|
||||
toggleIndicator={null}
|
||||
onToggle={onToggle}
|
||||
aria-label={i18n._(`relaunch jobs`)}
|
||||
aria-label={i18n._(t`relaunch jobs`)}
|
||||
id="relaunch_jobs"
|
||||
isPrimary
|
||||
>
|
||||
@ -83,7 +83,7 @@ function ReLaunchDropDown({ isPrimary = false, handleRelaunch, i18n }) {
|
||||
<DropdownToggle
|
||||
toggleIndicator={null}
|
||||
onToggle={onToggle}
|
||||
aria-label={i18n._(`relaunch jobs`)}
|
||||
aria-label={i18n._(t`relaunch jobs`)}
|
||||
id="relaunch_jobs"
|
||||
>
|
||||
<RocketIcon />
|
||||
|
||||
@ -11,7 +11,8 @@ export default function usePreviewStep(
|
||||
resource,
|
||||
surveyConfig,
|
||||
hasErrors,
|
||||
showStep
|
||||
showStep,
|
||||
nextButtonText
|
||||
) {
|
||||
return {
|
||||
step: showStep
|
||||
@ -31,7 +32,7 @@ export default function usePreviewStep(
|
||||
/>
|
||||
),
|
||||
enableNext: !hasErrors,
|
||||
nextButtonText: i18n._(t`Launch`),
|
||||
nextButtonText: nextButtonText || i18n._(t`Launch`),
|
||||
}
|
||||
: null,
|
||||
initialValues: {},
|
||||
|
||||
@ -6,7 +6,7 @@ import { t } from '@lingui/macro';
|
||||
import AlertModal from '../AlertModal';
|
||||
import ErrorDetail from '../ErrorDetail';
|
||||
import NotificationListItem from './NotificationListItem';
|
||||
import PaginatedDataList from '../PaginatedDataList';
|
||||
import PaginatedTable, { HeaderRow, HeaderCell } from '../PaginatedTable';
|
||||
import { getQSConfig, parseQueryString } from '../../util/qs';
|
||||
import useRequest from '../../util/useRequest';
|
||||
import { NotificationTemplatesAPI } from '../../api';
|
||||
@ -169,7 +169,7 @@ function NotificationList({
|
||||
|
||||
return (
|
||||
<>
|
||||
<PaginatedDataList
|
||||
<PaginatedTable
|
||||
contentError={contentError}
|
||||
hasContentLoading={isLoading}
|
||||
items={notifications}
|
||||
@ -211,19 +211,18 @@ function NotificationList({
|
||||
key: 'modified_by__username__icontains',
|
||||
},
|
||||
]}
|
||||
toolbarSortColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Type`),
|
||||
key: 'notification_type',
|
||||
},
|
||||
]}
|
||||
toolbarSearchableKeys={searchableKeys}
|
||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||
renderItem={notification => (
|
||||
headerRow={
|
||||
<HeaderRow qsConfig={QS_CONFIG} isSelectable={false}>
|
||||
<HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell>
|
||||
<HeaderCell sortKey="notification_type">
|
||||
{i18n._(t`Type`)}
|
||||
</HeaderCell>
|
||||
<HeaderCell>{i18n._(t`Options`)}</HeaderCell>
|
||||
</HeaderRow>
|
||||
}
|
||||
renderRow={(notification, index) => (
|
||||
<NotificationListItem
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
@ -239,6 +238,7 @@ function NotificationList({
|
||||
successTurnedOn={successTemplateIds.includes(notification.id)}
|
||||
typeLabels={typeLabels}
|
||||
showApprovalsToggle={showApprovalsToggle}
|
||||
rowIndex={index}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@ -87,10 +87,6 @@ describe('<NotificationList />', () => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('initially renders succesfully', () => {
|
||||
expect(wrapper.find('PaginatedDataList')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('should render list fetched of items', () => {
|
||||
expect(NotificationTemplatesAPI.read).toHaveBeenCalled();
|
||||
expect(NotificationTemplatesAPI.readOptions).toHaveBeenCalled();
|
||||
|
||||
@ -3,25 +3,9 @@ import { shape, number, string, bool, func } from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
DataListAction as _DataListAction,
|
||||
DataListItem,
|
||||
DataListItemCells,
|
||||
DataListItemRow,
|
||||
Switch,
|
||||
} from '@patternfly/react-core';
|
||||
import styled from 'styled-components';
|
||||
import DataListCell from '../DataListCell';
|
||||
|
||||
const DataListAction = styled(_DataListAction)`
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-gap: 16px;
|
||||
grid-template-columns: ${props => `repeat(${props.columns}, max-content)`};
|
||||
`;
|
||||
const Label = styled.b`
|
||||
margin-right: 20px;
|
||||
`;
|
||||
import { Switch } from '@patternfly/react-core';
|
||||
import { Tr, Td } from '@patternfly/react-table';
|
||||
import { ActionsTd, ActionItem } from '../PaginatedTable';
|
||||
|
||||
function NotificationListItem({
|
||||
canToggleNotifications,
|
||||
@ -37,54 +21,37 @@ function NotificationListItem({
|
||||
showApprovalsToggle,
|
||||
}) {
|
||||
return (
|
||||
<DataListItem
|
||||
aria-labelledby={`items-list-item-${notification.id}`}
|
||||
key={notification.id}
|
||||
id={`${notification.id}`}
|
||||
>
|
||||
<DataListItemRow>
|
||||
<DataListItemCells
|
||||
dataListCells={[
|
||||
<DataListCell key="name">
|
||||
<Link
|
||||
to={{
|
||||
pathname: detailUrl,
|
||||
}}
|
||||
>
|
||||
<b id={`items-list-item-${notification.id}`}>
|
||||
{notification.name}
|
||||
</b>
|
||||
</Link>
|
||||
</DataListCell>,
|
||||
<DataListCell key="type">
|
||||
<Label>{i18n._(t`Type `)}</Label>
|
||||
{typeLabels[notification.notification_type]}
|
||||
</DataListCell>,
|
||||
]}
|
||||
/>
|
||||
<DataListAction
|
||||
aria-label={i18n._(t`actions`)}
|
||||
aria-labelledby={`items-list-item-${notification.id}`}
|
||||
id={`items-list-item-${notification.id}`}
|
||||
columns={showApprovalsToggle ? 4 : 3}
|
||||
>
|
||||
{showApprovalsToggle && (
|
||||
<Switch
|
||||
id={`notification-${notification.id}-approvals-toggle`}
|
||||
label={i18n._(t`Approval`)}
|
||||
labelOff={i18n._(t`Approval`)}
|
||||
isChecked={approvalsTurnedOn}
|
||||
isDisabled={!canToggleNotifications}
|
||||
onChange={() =>
|
||||
toggleNotification(
|
||||
notification.id,
|
||||
approvalsTurnedOn,
|
||||
'approvals'
|
||||
)
|
||||
}
|
||||
aria-label={i18n._(t`Toggle notification approvals`)}
|
||||
/>
|
||||
)}
|
||||
<Tr id={`notification-row-${notification.id}`}>
|
||||
<Td id={`notification-${notification.id}`} dataLabel={i18n._(t`Name`)}>
|
||||
<Link to={`${detailUrl}`}>
|
||||
<b>{notification.name}</b>
|
||||
</Link>
|
||||
</Td>
|
||||
<Td dataLabel={i18n._(t`Type`)}>
|
||||
{typeLabels[notification.notification_type]}
|
||||
</Td>
|
||||
<ActionsTd
|
||||
dataLabel={i18n._(t`Options`)}
|
||||
gridColumns="120px 120px 120px 120px"
|
||||
>
|
||||
<ActionItem visible={showApprovalsToggle}>
|
||||
<Switch
|
||||
id={`notification-${notification.id}-approvals-toggle`}
|
||||
label={i18n._(t`Approval`)}
|
||||
labelOff={i18n._(t`Approval`)}
|
||||
isChecked={approvalsTurnedOn}
|
||||
isDisabled={!canToggleNotifications}
|
||||
onChange={() =>
|
||||
toggleNotification(
|
||||
notification.id,
|
||||
approvalsTurnedOn,
|
||||
'approvals'
|
||||
)
|
||||
}
|
||||
aria-label={i18n._(t`Toggle notification approvals`)}
|
||||
/>
|
||||
</ActionItem>
|
||||
<ActionItem visible>
|
||||
<Switch
|
||||
id={`notification-${notification.id}-started-toggle`}
|
||||
label={i18n._(t`Start`)}
|
||||
@ -96,6 +63,8 @@ function NotificationListItem({
|
||||
}
|
||||
aria-label={i18n._(t`Toggle notification start`)}
|
||||
/>
|
||||
</ActionItem>
|
||||
<ActionItem visible>
|
||||
<Switch
|
||||
id={`notification-${notification.id}-success-toggle`}
|
||||
label={i18n._(t`Success`)}
|
||||
@ -107,6 +76,8 @@ function NotificationListItem({
|
||||
}
|
||||
aria-label={i18n._(t`Toggle notification success`)}
|
||||
/>
|
||||
</ActionItem>
|
||||
<ActionItem visible>
|
||||
<Switch
|
||||
id={`notification-${notification.id}-error-toggle`}
|
||||
label={i18n._(t`Failure`)}
|
||||
@ -118,9 +89,9 @@ function NotificationListItem({
|
||||
}
|
||||
aria-label={i18n._(t`Toggle notification failure`)}
|
||||
/>
|
||||
</DataListAction>
|
||||
</DataListItemRow>
|
||||
</DataListItem>
|
||||
</ActionItem>
|
||||
</ActionsTd>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -30,13 +30,17 @@ describe('<NotificationListItem canToggleNotifications />', () => {
|
||||
|
||||
test('initially renders succesfully and displays correct label', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
toggleNotification={toggleNotification}
|
||||
detailUrl="/foo"
|
||||
canToggleNotifications
|
||||
typeLabels={typeLabels}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
toggleNotification={toggleNotification}
|
||||
detailUrl="/foo"
|
||||
canToggleNotifications
|
||||
typeLabels={typeLabels}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('NotificationListItem')).toMatchSnapshot();
|
||||
expect(wrapper.find('Switch').length).toBe(3);
|
||||
@ -44,46 +48,55 @@ describe('<NotificationListItem canToggleNotifications />', () => {
|
||||
|
||||
test('shows approvals toggle when configured', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
toggleNotification={toggleNotification}
|
||||
detailUrl="/foo"
|
||||
canToggleNotifications
|
||||
typeLabels={typeLabels}
|
||||
showApprovalsToggle
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
toggleNotification={toggleNotification}
|
||||
detailUrl="/foo"
|
||||
canToggleNotifications
|
||||
typeLabels={typeLabels}
|
||||
showApprovalsToggle
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('Switch').length).toBe(4);
|
||||
});
|
||||
|
||||
test('displays correct label in correct column', () => {
|
||||
test('displays correct type', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
toggleNotification={toggleNotification}
|
||||
detailUrl="/foo"
|
||||
canToggleNotifications
|
||||
typeLabels={typeLabels}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
toggleNotification={toggleNotification}
|
||||
detailUrl="/foo"
|
||||
canToggleNotifications
|
||||
typeLabels={typeLabels}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
const typeCell = wrapper
|
||||
.find('DataListCell')
|
||||
.at(1)
|
||||
.find('div');
|
||||
const typeCell = wrapper.find('Td').at(1);
|
||||
expect(typeCell.text()).toContain('Slack');
|
||||
});
|
||||
|
||||
test('handles approvals click when toggle is on', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
approvalsTurnedOn
|
||||
toggleNotification={toggleNotification}
|
||||
detailUrl="/foo"
|
||||
canToggleNotifications
|
||||
typeLabels={typeLabels}
|
||||
showApprovalsToggle
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
approvalsTurnedOn
|
||||
toggleNotification={toggleNotification}
|
||||
detailUrl="/foo"
|
||||
canToggleNotifications
|
||||
typeLabels={typeLabels}
|
||||
showApprovalsToggle
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
wrapper
|
||||
.find('Switch[aria-label="Toggle notification approvals"]')
|
||||
@ -95,15 +108,19 @@ describe('<NotificationListItem canToggleNotifications />', () => {
|
||||
|
||||
test('handles approvals click when toggle is off', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
approvalsTurnedOn={false}
|
||||
toggleNotification={toggleNotification}
|
||||
detailUrl="/foo"
|
||||
canToggleNotifications
|
||||
typeLabels={typeLabels}
|
||||
showApprovalsToggle
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
approvalsTurnedOn={false}
|
||||
toggleNotification={toggleNotification}
|
||||
detailUrl="/foo"
|
||||
canToggleNotifications
|
||||
typeLabels={typeLabels}
|
||||
showApprovalsToggle
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
wrapper
|
||||
.find('Switch[aria-label="Toggle notification approvals"]')
|
||||
@ -114,14 +131,18 @@ describe('<NotificationListItem canToggleNotifications />', () => {
|
||||
|
||||
test('handles started click when toggle is on', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
startedTurnedOn
|
||||
toggleNotification={toggleNotification}
|
||||
detailUrl="/foo"
|
||||
canToggleNotifications
|
||||
typeLabels={typeLabels}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
startedTurnedOn
|
||||
toggleNotification={toggleNotification}
|
||||
detailUrl="/foo"
|
||||
canToggleNotifications
|
||||
typeLabels={typeLabels}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
wrapper
|
||||
.find('Switch[aria-label="Toggle notification start"]')
|
||||
@ -132,14 +153,18 @@ describe('<NotificationListItem canToggleNotifications />', () => {
|
||||
|
||||
test('handles started click when toggle is off', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
startedTurnedOn={false}
|
||||
toggleNotification={toggleNotification}
|
||||
detailUrl="/foo"
|
||||
canToggleNotifications
|
||||
typeLabels={typeLabels}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
startedTurnedOn={false}
|
||||
toggleNotification={toggleNotification}
|
||||
detailUrl="/foo"
|
||||
canToggleNotifications
|
||||
typeLabels={typeLabels}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
wrapper
|
||||
.find('Switch[aria-label="Toggle notification start"]')
|
||||
@ -150,14 +175,18 @@ describe('<NotificationListItem canToggleNotifications />', () => {
|
||||
|
||||
test('handles success click when toggle is on', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
successTurnedOn
|
||||
toggleNotification={toggleNotification}
|
||||
detailUrl="/foo"
|
||||
canToggleNotifications
|
||||
typeLabels={typeLabels}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
successTurnedOn
|
||||
toggleNotification={toggleNotification}
|
||||
detailUrl="/foo"
|
||||
canToggleNotifications
|
||||
typeLabels={typeLabels}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
wrapper
|
||||
.find('Switch[aria-label="Toggle notification success"]')
|
||||
@ -168,14 +197,18 @@ describe('<NotificationListItem canToggleNotifications />', () => {
|
||||
|
||||
test('handles success click when toggle is off', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
successTurnedOn={false}
|
||||
toggleNotification={toggleNotification}
|
||||
detailUrl="/foo"
|
||||
canToggleNotifications
|
||||
typeLabels={typeLabels}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
successTurnedOn={false}
|
||||
toggleNotification={toggleNotification}
|
||||
detailUrl="/foo"
|
||||
canToggleNotifications
|
||||
typeLabels={typeLabels}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
wrapper
|
||||
.find('Switch[aria-label="Toggle notification success"]')
|
||||
@ -186,14 +219,18 @@ describe('<NotificationListItem canToggleNotifications />', () => {
|
||||
|
||||
test('handles error click when toggle is on', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
errorTurnedOn
|
||||
toggleNotification={toggleNotification}
|
||||
detailUrl="/foo"
|
||||
canToggleNotifications
|
||||
typeLabels={typeLabels}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
errorTurnedOn
|
||||
toggleNotification={toggleNotification}
|
||||
detailUrl="/foo"
|
||||
canToggleNotifications
|
||||
typeLabels={typeLabels}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
wrapper
|
||||
.find('Switch[aria-label="Toggle notification failure"]')
|
||||
@ -204,14 +241,18 @@ describe('<NotificationListItem canToggleNotifications />', () => {
|
||||
|
||||
test('handles error click when toggle is off', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
errorTurnedOn={false}
|
||||
toggleNotification={toggleNotification}
|
||||
detailUrl="/foo"
|
||||
canToggleNotifications
|
||||
typeLabels={typeLabels}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<NotificationListItem
|
||||
notification={mockNotif}
|
||||
errorTurnedOn={false}
|
||||
toggleNotification={toggleNotification}
|
||||
detailUrl="/foo"
|
||||
canToggleNotifications
|
||||
typeLabels={typeLabels}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
wrapper
|
||||
.find('Switch[aria-label="Toggle notification failure"]')
|
||||
|
||||
@ -24,398 +24,442 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
|
||||
}
|
||||
}
|
||||
>
|
||||
<DataListItem
|
||||
aria-labelledby="items-list-item-9000"
|
||||
className=""
|
||||
id="9000"
|
||||
isExpanded={false}
|
||||
key="9000"
|
||||
<Tr
|
||||
id="notification-row-9000"
|
||||
>
|
||||
<li
|
||||
aria-labelledby="items-list-item-9000"
|
||||
className="pf-c-data-list__item"
|
||||
id="9000"
|
||||
<TrBase
|
||||
id="notification-row-9000"
|
||||
innerRef={null}
|
||||
>
|
||||
<DataListItemRow
|
||||
key=".0"
|
||||
rowid="items-list-item-9000"
|
||||
<tr
|
||||
className=""
|
||||
data-ouia-component-id="OUIA-Generated-TableRow-1"
|
||||
data-ouia-component-type="PF4/TableRow"
|
||||
data-ouia-safe={true}
|
||||
hidden={false}
|
||||
id="notification-row-9000"
|
||||
>
|
||||
<div
|
||||
className="pf-c-data-list__item-row"
|
||||
<Td
|
||||
dataLabel="Name"
|
||||
id="notification-9000"
|
||||
>
|
||||
<DataListItemCells
|
||||
dataListCells={
|
||||
Array [
|
||||
<ForwardRef(Styled(PFDataListCell))>
|
||||
<ForwardRef
|
||||
to={
|
||||
Object {
|
||||
"pathname": "/foo",
|
||||
}
|
||||
}
|
||||
<TdBase
|
||||
dataLabel="Name"
|
||||
id="notification-9000"
|
||||
innerRef={null}
|
||||
>
|
||||
<td
|
||||
className=""
|
||||
data-label="Name"
|
||||
id="notification-9000"
|
||||
>
|
||||
<Link
|
||||
to="/foo"
|
||||
>
|
||||
<LinkAnchor
|
||||
href="/foo"
|
||||
navigate={[Function]}
|
||||
>
|
||||
<a
|
||||
href="/foo"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<b
|
||||
id="items-list-item-9000"
|
||||
>
|
||||
<b>
|
||||
Foo
|
||||
</b>
|
||||
</ForwardRef>
|
||||
</ForwardRef(Styled(PFDataListCell))>,
|
||||
<ForwardRef(Styled(PFDataListCell))>
|
||||
<ForwardRef(styled.b)>
|
||||
Type
|
||||
</ForwardRef(styled.b)>
|
||||
Slack
|
||||
</ForwardRef(Styled(PFDataListCell))>,
|
||||
]
|
||||
}
|
||||
key=".0"
|
||||
rowid="items-list-item-9000"
|
||||
</a>
|
||||
</LinkAnchor>
|
||||
</Link>
|
||||
</td>
|
||||
</TdBase>
|
||||
</Td>
|
||||
<Td
|
||||
dataLabel="Type"
|
||||
>
|
||||
<TdBase
|
||||
dataLabel="Type"
|
||||
innerRef={null}
|
||||
>
|
||||
<div
|
||||
className="pf-c-data-list__item-content"
|
||||
<td
|
||||
className=""
|
||||
data-label="Type"
|
||||
>
|
||||
<DataListCell
|
||||
key="name"
|
||||
>
|
||||
<StyledComponent
|
||||
forwardedComponent={
|
||||
Object {
|
||||
"$$typeof": Symbol(react.forward_ref),
|
||||
"attrs": Array [],
|
||||
"componentStyle": ComponentStyle {
|
||||
"componentId": "sc-bdVaJa",
|
||||
"isStatic": false,
|
||||
"lastClassName": "kruorc",
|
||||
"rules": Array [
|
||||
"
|
||||
word-break: break-word;
|
||||
",
|
||||
],
|
||||
},
|
||||
"displayName": "DataListCell",
|
||||
"foldedComponentIds": Array [],
|
||||
"render": [Function],
|
||||
"styledComponentId": "sc-bdVaJa",
|
||||
"target": [Function],
|
||||
"toString": [Function],
|
||||
"warnTooManyClasses": [Function],
|
||||
"withComponent": [Function],
|
||||
}
|
||||
}
|
||||
forwardedRef={null}
|
||||
>
|
||||
<PFDataListCell
|
||||
className="sc-bdVaJa kruorc"
|
||||
>
|
||||
<div
|
||||
className="pf-c-data-list__cell sc-bdVaJa kruorc"
|
||||
>
|
||||
<Link
|
||||
to={
|
||||
Object {
|
||||
"pathname": "/foo",
|
||||
}
|
||||
}
|
||||
>
|
||||
<LinkAnchor
|
||||
href="/foo"
|
||||
navigate={[Function]}
|
||||
>
|
||||
<a
|
||||
href="/foo"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<b
|
||||
id="items-list-item-9000"
|
||||
>
|
||||
Foo
|
||||
</b>
|
||||
</a>
|
||||
</LinkAnchor>
|
||||
</Link>
|
||||
</div>
|
||||
</PFDataListCell>
|
||||
</StyledComponent>
|
||||
</DataListCell>
|
||||
<DataListCell
|
||||
key="type"
|
||||
>
|
||||
<StyledComponent
|
||||
forwardedComponent={
|
||||
Object {
|
||||
"$$typeof": Symbol(react.forward_ref),
|
||||
"attrs": Array [],
|
||||
"componentStyle": ComponentStyle {
|
||||
"componentId": "sc-bdVaJa",
|
||||
"isStatic": false,
|
||||
"lastClassName": "kruorc",
|
||||
"rules": Array [
|
||||
"
|
||||
word-break: break-word;
|
||||
",
|
||||
],
|
||||
},
|
||||
"displayName": "DataListCell",
|
||||
"foldedComponentIds": Array [],
|
||||
"render": [Function],
|
||||
"styledComponentId": "sc-bdVaJa",
|
||||
"target": [Function],
|
||||
"toString": [Function],
|
||||
"warnTooManyClasses": [Function],
|
||||
"withComponent": [Function],
|
||||
}
|
||||
}
|
||||
forwardedRef={null}
|
||||
>
|
||||
<PFDataListCell
|
||||
className="sc-bdVaJa kruorc"
|
||||
>
|
||||
<div
|
||||
className="pf-c-data-list__cell sc-bdVaJa kruorc"
|
||||
>
|
||||
<styled.b>
|
||||
<StyledComponent
|
||||
forwardedComponent={
|
||||
Object {
|
||||
"$$typeof": Symbol(react.forward_ref),
|
||||
"attrs": Array [],
|
||||
"componentStyle": ComponentStyle {
|
||||
"componentId": "sc-htpNat",
|
||||
"isStatic": false,
|
||||
"lastClassName": "jyYvCB",
|
||||
"rules": Array [
|
||||
"
|
||||
margin-right: 20px;
|
||||
",
|
||||
],
|
||||
},
|
||||
"displayName": "styled.b",
|
||||
"foldedComponentIds": Array [],
|
||||
"render": [Function],
|
||||
"styledComponentId": "sc-htpNat",
|
||||
"target": "b",
|
||||
"toString": [Function],
|
||||
"warnTooManyClasses": [Function],
|
||||
"withComponent": [Function],
|
||||
}
|
||||
}
|
||||
forwardedRef={null}
|
||||
>
|
||||
<b
|
||||
className="sc-htpNat jyYvCB"
|
||||
>
|
||||
Type
|
||||
</b>
|
||||
</StyledComponent>
|
||||
</styled.b>
|
||||
Slack
|
||||
</div>
|
||||
</PFDataListCell>
|
||||
</StyledComponent>
|
||||
</DataListCell>
|
||||
</div>
|
||||
</DataListItemCells>
|
||||
<Styled(DataListAction)
|
||||
aria-label="actions"
|
||||
aria-labelledby="items-list-item-9000"
|
||||
columns={3}
|
||||
id="items-list-item-9000"
|
||||
key=".1"
|
||||
rowid="items-list-item-9000"
|
||||
Slack
|
||||
</td>
|
||||
</TdBase>
|
||||
</Td>
|
||||
<ActionsTd
|
||||
dataLabel="Options"
|
||||
gridColumns="120px 120px 120px 120px"
|
||||
>
|
||||
<ActionsTd___StyledTd
|
||||
_css={160}
|
||||
dataLabel="Options"
|
||||
>
|
||||
<StyledComponent
|
||||
aria-label="actions"
|
||||
aria-labelledby="items-list-item-9000"
|
||||
columns={3}
|
||||
_css={160}
|
||||
dataLabel="Options"
|
||||
forwardedComponent={
|
||||
Object {
|
||||
"$$typeof": Symbol(react.forward_ref),
|
||||
"attrs": Array [],
|
||||
"componentStyle": ComponentStyle {
|
||||
"componentId": "sc-bwzfXH",
|
||||
"componentId": "ActionsTd___StyledTd-sc-1ys3lw-1",
|
||||
"isStatic": false,
|
||||
"lastClassName": "llKtln",
|
||||
"lastClassName": "dIDjZI",
|
||||
"rules": Array [
|
||||
"
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-gap: 16px;
|
||||
grid-template-columns: ",
|
||||
"text-align:right;--pf-c-table--cell--Width:",
|
||||
[Function],
|
||||
";
|
||||
",
|
||||
"px;",
|
||||
],
|
||||
},
|
||||
"displayName": "Styled(DataListAction)",
|
||||
"displayName": "ActionsTd___StyledTd",
|
||||
"foldedComponentIds": Array [],
|
||||
"render": [Function],
|
||||
"styledComponentId": "sc-bwzfXH",
|
||||
"target": [Function],
|
||||
"styledComponentId": "ActionsTd___StyledTd-sc-1ys3lw-1",
|
||||
"target": Object {
|
||||
"$$typeof": Symbol(react.forward_ref),
|
||||
"displayName": "Td",
|
||||
"render": [Function],
|
||||
},
|
||||
"toString": [Function],
|
||||
"warnTooManyClasses": [Function],
|
||||
"withComponent": [Function],
|
||||
}
|
||||
}
|
||||
forwardedRef={null}
|
||||
id="items-list-item-9000"
|
||||
rowid="items-list-item-9000"
|
||||
>
|
||||
<DataListAction
|
||||
aria-label="actions"
|
||||
aria-labelledby="items-list-item-9000"
|
||||
className="sc-bwzfXH llKtln"
|
||||
columns={3}
|
||||
id="items-list-item-9000"
|
||||
rowid="items-list-item-9000"
|
||||
<Td
|
||||
_css={160}
|
||||
className="ActionsTd___StyledTd-sc-1ys3lw-1 dIDjZI"
|
||||
dataLabel="Options"
|
||||
>
|
||||
<div
|
||||
className="pf-c-data-list__item-action sc-bwzfXH llKtln"
|
||||
columns={3}
|
||||
rowid="items-list-item-9000"
|
||||
<TdBase
|
||||
_css={160}
|
||||
className="ActionsTd___StyledTd-sc-1ys3lw-1 dIDjZI"
|
||||
dataLabel="Options"
|
||||
innerRef={null}
|
||||
>
|
||||
<Switch
|
||||
aria-label="Toggle notification start"
|
||||
id="notification-9000-started-toggle"
|
||||
isChecked={false}
|
||||
isDisabled={false}
|
||||
label="Start"
|
||||
labelOff="Start"
|
||||
onChange={[Function]}
|
||||
<td
|
||||
_css={160}
|
||||
className="ActionsTd___StyledTd-sc-1ys3lw-1 dIDjZI"
|
||||
data-label="Options"
|
||||
>
|
||||
<label
|
||||
className="pf-c-switch"
|
||||
data-ouia-component-id="OUIA-Generated-Switch-1"
|
||||
data-ouia-component-type="PF4/Switch"
|
||||
data-ouia-safe={true}
|
||||
htmlFor="notification-9000-started-toggle"
|
||||
<ActionsGrid
|
||||
gridColumns="120px 120px 120px 120px"
|
||||
numActions={4}
|
||||
>
|
||||
<input
|
||||
aria-label="Toggle notification start"
|
||||
aria-labelledby={null}
|
||||
checked={false}
|
||||
className="pf-c-switch__input"
|
||||
disabled={false}
|
||||
id="notification-9000-started-toggle"
|
||||
onChange={[Function]}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span
|
||||
className="pf-c-switch__toggle"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pf-c-switch__label pf-m-on"
|
||||
id={null}
|
||||
<StyledComponent
|
||||
forwardedComponent={
|
||||
Object {
|
||||
"$$typeof": Symbol(react.forward_ref),
|
||||
"attrs": Array [],
|
||||
"componentStyle": ComponentStyle {
|
||||
"componentId": "ActionsTd__ActionsGrid-sc-1ys3lw-0",
|
||||
"isStatic": false,
|
||||
"lastClassName": "dkSSIN",
|
||||
"rules": Array [
|
||||
"display:grid;grid-gap:16px;align-items:center;",
|
||||
[Function],
|
||||
],
|
||||
},
|
||||
"displayName": "ActionsGrid",
|
||||
"foldedComponentIds": Array [],
|
||||
"render": [Function],
|
||||
"styledComponentId": "ActionsTd__ActionsGrid-sc-1ys3lw-0",
|
||||
"target": "div",
|
||||
"toString": [Function],
|
||||
"warnTooManyClasses": [Function],
|
||||
"withComponent": [Function],
|
||||
}
|
||||
}
|
||||
forwardedRef={null}
|
||||
gridColumns="120px 120px 120px 120px"
|
||||
numActions={4}
|
||||
>
|
||||
Start
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pf-c-switch__label pf-m-off"
|
||||
id={null}
|
||||
>
|
||||
Start
|
||||
</span>
|
||||
</label>
|
||||
</Switch>
|
||||
<Switch
|
||||
aria-label="Toggle notification success"
|
||||
id="notification-9000-success-toggle"
|
||||
isChecked={false}
|
||||
isDisabled={false}
|
||||
label="Success"
|
||||
labelOff="Success"
|
||||
onChange={[Function]}
|
||||
>
|
||||
<label
|
||||
className="pf-c-switch"
|
||||
data-ouia-component-id="OUIA-Generated-Switch-2"
|
||||
data-ouia-component-type="PF4/Switch"
|
||||
data-ouia-safe={true}
|
||||
htmlFor="notification-9000-success-toggle"
|
||||
>
|
||||
<input
|
||||
aria-label="Toggle notification success"
|
||||
aria-labelledby={null}
|
||||
checked={false}
|
||||
className="pf-c-switch__input"
|
||||
disabled={false}
|
||||
id="notification-9000-success-toggle"
|
||||
onChange={[Function]}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span
|
||||
className="pf-c-switch__toggle"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pf-c-switch__label pf-m-on"
|
||||
id={null}
|
||||
>
|
||||
Success
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pf-c-switch__label pf-m-off"
|
||||
id={null}
|
||||
>
|
||||
Success
|
||||
</span>
|
||||
</label>
|
||||
</Switch>
|
||||
<Switch
|
||||
aria-label="Toggle notification failure"
|
||||
id="notification-9000-error-toggle"
|
||||
isChecked={false}
|
||||
isDisabled={false}
|
||||
label="Failure"
|
||||
labelOff="Failure"
|
||||
onChange={[Function]}
|
||||
>
|
||||
<label
|
||||
className="pf-c-switch"
|
||||
data-ouia-component-id="OUIA-Generated-Switch-3"
|
||||
data-ouia-component-type="PF4/Switch"
|
||||
data-ouia-safe={true}
|
||||
htmlFor="notification-9000-error-toggle"
|
||||
>
|
||||
<input
|
||||
aria-label="Toggle notification failure"
|
||||
aria-labelledby={null}
|
||||
checked={false}
|
||||
className="pf-c-switch__input"
|
||||
disabled={false}
|
||||
id="notification-9000-error-toggle"
|
||||
onChange={[Function]}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span
|
||||
className="pf-c-switch__toggle"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pf-c-switch__label pf-m-on"
|
||||
id={null}
|
||||
>
|
||||
Failure
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pf-c-switch__label pf-m-off"
|
||||
id={null}
|
||||
>
|
||||
Failure
|
||||
</span>
|
||||
</label>
|
||||
</Switch>
|
||||
</div>
|
||||
</DataListAction>
|
||||
<div
|
||||
className="ActionsTd__ActionsGrid-sc-1ys3lw-0 dkSSIN"
|
||||
>
|
||||
<ActionItem
|
||||
column={1}
|
||||
key=".0"
|
||||
visible={false}
|
||||
/>
|
||||
<ActionItem
|
||||
column={2}
|
||||
key=".1"
|
||||
visible={true}
|
||||
>
|
||||
<ActionItem___StyledDiv
|
||||
_css={2}
|
||||
>
|
||||
<StyledComponent
|
||||
_css={2}
|
||||
forwardedComponent={
|
||||
Object {
|
||||
"$$typeof": Symbol(react.forward_ref),
|
||||
"attrs": Array [],
|
||||
"componentStyle": ComponentStyle {
|
||||
"componentId": "ActionItem___StyledDiv-sc-1x1z9nz-0",
|
||||
"isStatic": false,
|
||||
"lastClassName": "gydHvj",
|
||||
"rules": Array [
|
||||
"grid-column:",
|
||||
[Function],
|
||||
";",
|
||||
],
|
||||
},
|
||||
"displayName": "ActionItem___StyledDiv",
|
||||
"foldedComponentIds": Array [],
|
||||
"render": [Function],
|
||||
"styledComponentId": "ActionItem___StyledDiv-sc-1x1z9nz-0",
|
||||
"target": "div",
|
||||
"toString": [Function],
|
||||
"warnTooManyClasses": [Function],
|
||||
"withComponent": [Function],
|
||||
}
|
||||
}
|
||||
forwardedRef={null}
|
||||
>
|
||||
<div
|
||||
className="ActionItem___StyledDiv-sc-1x1z9nz-0 cSmxUZ"
|
||||
>
|
||||
<Switch
|
||||
aria-label="Toggle notification start"
|
||||
id="notification-9000-started-toggle"
|
||||
isChecked={false}
|
||||
isDisabled={false}
|
||||
label="Start"
|
||||
labelOff="Start"
|
||||
onChange={[Function]}
|
||||
>
|
||||
<label
|
||||
className="pf-c-switch"
|
||||
data-ouia-component-id="OUIA-Generated-Switch-1"
|
||||
data-ouia-component-type="PF4/Switch"
|
||||
data-ouia-safe={true}
|
||||
htmlFor="notification-9000-started-toggle"
|
||||
>
|
||||
<input
|
||||
aria-label="Toggle notification start"
|
||||
aria-labelledby={null}
|
||||
checked={false}
|
||||
className="pf-c-switch__input"
|
||||
disabled={false}
|
||||
id="notification-9000-started-toggle"
|
||||
onChange={[Function]}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span
|
||||
className="pf-c-switch__toggle"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pf-c-switch__label pf-m-on"
|
||||
id={null}
|
||||
>
|
||||
Start
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pf-c-switch__label pf-m-off"
|
||||
id={null}
|
||||
>
|
||||
Start
|
||||
</span>
|
||||
</label>
|
||||
</Switch>
|
||||
</div>
|
||||
</StyledComponent>
|
||||
</ActionItem___StyledDiv>
|
||||
</ActionItem>
|
||||
<ActionItem
|
||||
column={3}
|
||||
key=".2"
|
||||
visible={true}
|
||||
>
|
||||
<ActionItem___StyledDiv
|
||||
_css={3}
|
||||
>
|
||||
<StyledComponent
|
||||
_css={3}
|
||||
forwardedComponent={
|
||||
Object {
|
||||
"$$typeof": Symbol(react.forward_ref),
|
||||
"attrs": Array [],
|
||||
"componentStyle": ComponentStyle {
|
||||
"componentId": "ActionItem___StyledDiv-sc-1x1z9nz-0",
|
||||
"isStatic": false,
|
||||
"lastClassName": "gydHvj",
|
||||
"rules": Array [
|
||||
"grid-column:",
|
||||
[Function],
|
||||
";",
|
||||
],
|
||||
},
|
||||
"displayName": "ActionItem___StyledDiv",
|
||||
"foldedComponentIds": Array [],
|
||||
"render": [Function],
|
||||
"styledComponentId": "ActionItem___StyledDiv-sc-1x1z9nz-0",
|
||||
"target": "div",
|
||||
"toString": [Function],
|
||||
"warnTooManyClasses": [Function],
|
||||
"withComponent": [Function],
|
||||
}
|
||||
}
|
||||
forwardedRef={null}
|
||||
>
|
||||
<div
|
||||
className="ActionItem___StyledDiv-sc-1x1z9nz-0 fZyHIW"
|
||||
>
|
||||
<Switch
|
||||
aria-label="Toggle notification success"
|
||||
id="notification-9000-success-toggle"
|
||||
isChecked={false}
|
||||
isDisabled={false}
|
||||
label="Success"
|
||||
labelOff="Success"
|
||||
onChange={[Function]}
|
||||
>
|
||||
<label
|
||||
className="pf-c-switch"
|
||||
data-ouia-component-id="OUIA-Generated-Switch-2"
|
||||
data-ouia-component-type="PF4/Switch"
|
||||
data-ouia-safe={true}
|
||||
htmlFor="notification-9000-success-toggle"
|
||||
>
|
||||
<input
|
||||
aria-label="Toggle notification success"
|
||||
aria-labelledby={null}
|
||||
checked={false}
|
||||
className="pf-c-switch__input"
|
||||
disabled={false}
|
||||
id="notification-9000-success-toggle"
|
||||
onChange={[Function]}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span
|
||||
className="pf-c-switch__toggle"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pf-c-switch__label pf-m-on"
|
||||
id={null}
|
||||
>
|
||||
Success
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pf-c-switch__label pf-m-off"
|
||||
id={null}
|
||||
>
|
||||
Success
|
||||
</span>
|
||||
</label>
|
||||
</Switch>
|
||||
</div>
|
||||
</StyledComponent>
|
||||
</ActionItem___StyledDiv>
|
||||
</ActionItem>
|
||||
<ActionItem
|
||||
column={4}
|
||||
key=".3"
|
||||
visible={true}
|
||||
>
|
||||
<ActionItem___StyledDiv
|
||||
_css={4}
|
||||
>
|
||||
<StyledComponent
|
||||
_css={4}
|
||||
forwardedComponent={
|
||||
Object {
|
||||
"$$typeof": Symbol(react.forward_ref),
|
||||
"attrs": Array [],
|
||||
"componentStyle": ComponentStyle {
|
||||
"componentId": "ActionItem___StyledDiv-sc-1x1z9nz-0",
|
||||
"isStatic": false,
|
||||
"lastClassName": "gydHvj",
|
||||
"rules": Array [
|
||||
"grid-column:",
|
||||
[Function],
|
||||
";",
|
||||
],
|
||||
},
|
||||
"displayName": "ActionItem___StyledDiv",
|
||||
"foldedComponentIds": Array [],
|
||||
"render": [Function],
|
||||
"styledComponentId": "ActionItem___StyledDiv-sc-1x1z9nz-0",
|
||||
"target": "div",
|
||||
"toString": [Function],
|
||||
"warnTooManyClasses": [Function],
|
||||
"withComponent": [Function],
|
||||
}
|
||||
}
|
||||
forwardedRef={null}
|
||||
>
|
||||
<div
|
||||
className="ActionItem___StyledDiv-sc-1x1z9nz-0 gydHvj"
|
||||
>
|
||||
<Switch
|
||||
aria-label="Toggle notification failure"
|
||||
id="notification-9000-error-toggle"
|
||||
isChecked={false}
|
||||
isDisabled={false}
|
||||
label="Failure"
|
||||
labelOff="Failure"
|
||||
onChange={[Function]}
|
||||
>
|
||||
<label
|
||||
className="pf-c-switch"
|
||||
data-ouia-component-id="OUIA-Generated-Switch-3"
|
||||
data-ouia-component-type="PF4/Switch"
|
||||
data-ouia-safe={true}
|
||||
htmlFor="notification-9000-error-toggle"
|
||||
>
|
||||
<input
|
||||
aria-label="Toggle notification failure"
|
||||
aria-labelledby={null}
|
||||
checked={false}
|
||||
className="pf-c-switch__input"
|
||||
disabled={false}
|
||||
id="notification-9000-error-toggle"
|
||||
onChange={[Function]}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span
|
||||
className="pf-c-switch__toggle"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pf-c-switch__label pf-m-on"
|
||||
id={null}
|
||||
>
|
||||
Failure
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pf-c-switch__label pf-m-off"
|
||||
id={null}
|
||||
>
|
||||
Failure
|
||||
</span>
|
||||
</label>
|
||||
</Switch>
|
||||
</div>
|
||||
</StyledComponent>
|
||||
</ActionItem___StyledDiv>
|
||||
</ActionItem>
|
||||
</div>
|
||||
</StyledComponent>
|
||||
</ActionsGrid>
|
||||
</td>
|
||||
</TdBase>
|
||||
</Td>
|
||||
</StyledComponent>
|
||||
</Styled(DataListAction)>
|
||||
</div>
|
||||
</DataListItemRow>
|
||||
</li>
|
||||
</DataListItem>
|
||||
</ActionsTd___StyledTd>
|
||||
</ActionsTd>
|
||||
</tr>
|
||||
</TrBase>
|
||||
</Tr>
|
||||
</NotificationListItem>
|
||||
`;
|
||||
|
||||
@ -13,9 +13,13 @@ export default function ActionItem({ column, tooltip, visible, children }) {
|
||||
grid-column: ${column};
|
||||
`}
|
||||
>
|
||||
<Tooltip content={tooltip} position="top">
|
||||
<div>{children}</div>
|
||||
</Tooltip>
|
||||
{tooltip ? (
|
||||
<Tooltip content={tooltip} position="top">
|
||||
<div>{children}</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -13,7 +13,12 @@ const Th = styled(PFTh)`
|
||||
--pf-c-table--cell--Overflow: initial;
|
||||
`;
|
||||
|
||||
export default function HeaderRow({ qsConfig, isExpandable, children }) {
|
||||
export default function HeaderRow({
|
||||
qsConfig,
|
||||
isExpandable,
|
||||
isSelectable,
|
||||
children,
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
|
||||
@ -49,7 +54,7 @@ export default function HeaderRow({ qsConfig, isExpandable, children }) {
|
||||
<Thead>
|
||||
<Tr>
|
||||
{isExpandable && <Th />}
|
||||
<Th />
|
||||
{isSelectable && <Th />}
|
||||
{React.Children.map(
|
||||
children,
|
||||
child =>
|
||||
@ -66,6 +71,10 @@ export default function HeaderRow({ qsConfig, isExpandable, children }) {
|
||||
);
|
||||
}
|
||||
|
||||
HeaderRow.defaultProps = {
|
||||
isSelectable: true,
|
||||
};
|
||||
|
||||
export function HeaderCell({
|
||||
sortKey,
|
||||
onSort,
|
||||
|
||||
@ -37,6 +37,7 @@ function PaginatedTable({
|
||||
showPageSizeOptions,
|
||||
i18n,
|
||||
renderToolbar,
|
||||
emptyContentMessage,
|
||||
}) {
|
||||
const history = useHistory();
|
||||
|
||||
@ -73,9 +74,6 @@ function PaginatedTable({
|
||||
const queryParams = parseQueryString(qsConfig, history.location.search);
|
||||
|
||||
const dataListLabel = i18n._(t`${pluralizedItemName} List`);
|
||||
const emptyContentMessage = i18n._(
|
||||
t`Please add ${pluralizedItemName} to populate this list `
|
||||
);
|
||||
const emptyContentTitle = i18n._(t`No ${pluralizedItemName} Found `);
|
||||
|
||||
let Content;
|
||||
@ -85,7 +83,13 @@ function PaginatedTable({
|
||||
Content = <ContentError error={contentError} />;
|
||||
} else if (items.length <= 0) {
|
||||
Content = (
|
||||
<ContentEmpty title={emptyContentTitle} message={emptyContentMessage} />
|
||||
<ContentEmpty
|
||||
title={emptyContentTitle}
|
||||
message={
|
||||
emptyContentMessage ||
|
||||
i18n._(t`Please add ${pluralizedItemName} to populate this list `)
|
||||
}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
Content = (
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { withI18n } from '@lingui/react';
|
||||
|
||||
@ -17,37 +17,41 @@ import ContentLoading from '../ContentLoading';
|
||||
import ScheduleDetail from './ScheduleDetail';
|
||||
import ScheduleEdit from './ScheduleEdit';
|
||||
import { SchedulesAPI } from '../../api';
|
||||
import useRequest from '../../util/useRequest';
|
||||
|
||||
function Schedule({ i18n, setBreadcrumb, unifiedJobTemplate }) {
|
||||
const [schedule, setSchedule] = useState(null);
|
||||
const [contentLoading, setContentLoading] = useState(true);
|
||||
const [contentError, setContentError] = useState(null);
|
||||
function Schedule({
|
||||
i18n,
|
||||
setBreadcrumb,
|
||||
resource,
|
||||
launchConfig,
|
||||
surveyConfig,
|
||||
hasDaysToKeepField,
|
||||
}) {
|
||||
const { scheduleId } = useParams();
|
||||
const location = useLocation();
|
||||
const { pathname } = location;
|
||||
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const pathRoot = pathname.substr(0, pathname.indexOf('schedules'));
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const { data } = await SchedulesAPI.readDetail(scheduleId);
|
||||
setSchedule(data);
|
||||
} catch (err) {
|
||||
setContentError(err);
|
||||
} finally {
|
||||
setContentLoading(false);
|
||||
}
|
||||
};
|
||||
const { isLoading, error, request: loadData, result: schedule } = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data } = await SchedulesAPI.readDetail(scheduleId);
|
||||
|
||||
return data;
|
||||
}, [scheduleId]),
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [location.pathname, scheduleId]);
|
||||
}, [loadData, pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (schedule) {
|
||||
setBreadcrumb(unifiedJobTemplate, schedule);
|
||||
setBreadcrumb(resource, schedule);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [schedule, unifiedJobTemplate]);
|
||||
}, [schedule, resource]);
|
||||
const tabsArray = [
|
||||
{
|
||||
name: (
|
||||
@ -66,13 +70,13 @@ function Schedule({ i18n, setBreadcrumb, unifiedJobTemplate }) {
|
||||
},
|
||||
];
|
||||
|
||||
if (contentLoading) {
|
||||
if (isLoading || !schedule?.summary_fields?.unified_job_template?.id) {
|
||||
return <ContentLoading />;
|
||||
}
|
||||
|
||||
if (
|
||||
schedule.summary_fields.unified_job_template.id !==
|
||||
parseInt(unifiedJobTemplate.id, 10)
|
||||
schedule?.summary_fields.unified_job_template.id !==
|
||||
parseInt(resource.id, 10)
|
||||
) {
|
||||
return (
|
||||
<ContentError>
|
||||
@ -83,18 +87,16 @@ function Schedule({ i18n, setBreadcrumb, unifiedJobTemplate }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (contentError) {
|
||||
return <ContentError error={contentError} />;
|
||||
if (error) {
|
||||
return <ContentError error={error} />;
|
||||
}
|
||||
|
||||
let showCardHeader = true;
|
||||
|
||||
if (
|
||||
!location.pathname.includes('schedules/') ||
|
||||
location.pathname.endsWith('edit')
|
||||
) {
|
||||
if (!pathname.includes('schedules/') || pathname.endsWith('edit')) {
|
||||
showCardHeader = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showCardHeader && <RoutedTabs tabsArray={tabsArray} />}
|
||||
@ -106,18 +108,28 @@ function Schedule({ i18n, setBreadcrumb, unifiedJobTemplate }) {
|
||||
/>
|
||||
{schedule && [
|
||||
<Route key="edit" path={`${pathRoot}schedules/:id/edit`}>
|
||||
<ScheduleEdit schedule={schedule} />
|
||||
<ScheduleEdit
|
||||
hasDaysToKeepField={hasDaysToKeepField}
|
||||
schedule={schedule}
|
||||
resource={resource}
|
||||
launchConfig={launchConfig}
|
||||
surveyConfig={surveyConfig}
|
||||
/>
|
||||
</Route>,
|
||||
<Route
|
||||
key="details"
|
||||
path={`${pathRoot}schedules/:scheduleId/details`}
|
||||
>
|
||||
<ScheduleDetail schedule={schedule} />
|
||||
<ScheduleDetail
|
||||
hasDaysToKeepField={hasDaysToKeepField}
|
||||
schedule={schedule}
|
||||
surveyConfig={surveyConfig}
|
||||
/>
|
||||
</Route>,
|
||||
]}
|
||||
<Route key="not-found" path="*">
|
||||
<ContentError>
|
||||
{unifiedJobTemplate && (
|
||||
{resource && (
|
||||
<Link to={`${pathRoot}details`}>{i18n._(t`View Details`)}</Link>
|
||||
)}
|
||||
</ContentError>
|
||||
|
||||
@ -93,10 +93,7 @@ describe('<Schedule />', () => {
|
||||
<Route
|
||||
path="/templates/job_template/:id/schedules"
|
||||
component={() => (
|
||||
<Schedule
|
||||
setBreadcrumb={() => {}}
|
||||
unifiedJobTemplate={unifiedJobTemplate}
|
||||
/>
|
||||
<Schedule setBreadcrumb={() => {}} resource={unifiedJobTemplate} />
|
||||
)}
|
||||
/>,
|
||||
{
|
||||
|
||||
@ -1,31 +1,105 @@
|
||||
import React, { useState } from 'react';
|
||||
import { func } from 'prop-types';
|
||||
import { func, shape } from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { RRule } from 'rrule';
|
||||
import { Card } from '@patternfly/react-core';
|
||||
import yaml from 'js-yaml';
|
||||
import { CardBody } from '../../Card';
|
||||
import { parseVariableField } from '../../../util/yaml';
|
||||
|
||||
import buildRuleObj from '../shared/buildRuleObj';
|
||||
import ScheduleForm from '../shared/ScheduleForm';
|
||||
import { SchedulesAPI } from '../../../api';
|
||||
import mergeExtraVars from '../../../util/prompt/mergeExtraVars';
|
||||
import getSurveyValues from '../../../util/prompt/getSurveyValues';
|
||||
import { getAddedAndRemoved } from '../../../util/lists';
|
||||
|
||||
function ScheduleAdd({ i18n, createSchedule }) {
|
||||
function ScheduleAdd({
|
||||
i18n,
|
||||
resource,
|
||||
apiModel,
|
||||
launchConfig,
|
||||
surveyConfig,
|
||||
hasDaysToKeepField,
|
||||
}) {
|
||||
const [formSubmitError, setFormSubmitError] = useState(null);
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const { pathname } = location;
|
||||
const pathRoot = pathname.substr(0, pathname.indexOf('schedules'));
|
||||
|
||||
const handleSubmit = async values => {
|
||||
const handleSubmit = async (
|
||||
values,
|
||||
launchConfiguration,
|
||||
surveyConfiguration
|
||||
) => {
|
||||
const {
|
||||
inventory,
|
||||
extra_vars,
|
||||
originalCredentials,
|
||||
end,
|
||||
frequency,
|
||||
interval,
|
||||
startDateTime,
|
||||
timezone,
|
||||
occurrences,
|
||||
runOn,
|
||||
runOnTheDay,
|
||||
runOnTheMonth,
|
||||
runOnDayMonth,
|
||||
runOnDayNumber,
|
||||
endDateTime,
|
||||
runOnTheOccurrence,
|
||||
credentials,
|
||||
daysOfWeek,
|
||||
...submitValues
|
||||
} = values;
|
||||
const { added } = getAddedAndRemoved(
|
||||
resource?.summary_fields.credentials,
|
||||
credentials
|
||||
);
|
||||
let extraVars;
|
||||
const surveyValues = getSurveyValues(values);
|
||||
const initialExtraVars =
|
||||
launchConfiguration?.ask_variables_on_launch &&
|
||||
(values.extra_vars || '---');
|
||||
if (surveyConfiguration?.spec) {
|
||||
extraVars = yaml.safeDump(mergeExtraVars(initialExtraVars, surveyValues));
|
||||
} else {
|
||||
extraVars = yaml.safeDump(mergeExtraVars(initialExtraVars, {}));
|
||||
}
|
||||
submitValues.extra_data = extraVars && parseVariableField(extraVars);
|
||||
delete values.extra_vars;
|
||||
if (inventory) {
|
||||
submitValues.inventory = inventory.id;
|
||||
}
|
||||
|
||||
try {
|
||||
const rule = new RRule(buildRuleObj(values, i18n));
|
||||
const requestData = {
|
||||
...submitValues,
|
||||
rrule: rule.toString().replace(/\n/g, ' '),
|
||||
};
|
||||
|
||||
if (Object.keys(values).includes('daysToKeep')) {
|
||||
if (requestData.extra_data) {
|
||||
requestData.extra_data.days = values.daysToKeep;
|
||||
} else {
|
||||
requestData.extra_data = JSON.stringify({ days: values.daysToKeep });
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
data: { id: scheduleId },
|
||||
} = await createSchedule({
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
rrule: rule.toString().replace(/\n/g, ' '),
|
||||
});
|
||||
|
||||
} = await apiModel.createSchedule(resource.id, requestData);
|
||||
if (credentials?.length > 0) {
|
||||
await Promise.all(
|
||||
added.map(({ id: credentialId }) =>
|
||||
SchedulesAPI.associateCredential(scheduleId, credentialId)
|
||||
)
|
||||
);
|
||||
}
|
||||
history.push(`${pathRoot}schedules/${scheduleId}`);
|
||||
} catch (err) {
|
||||
setFormSubmitError(err);
|
||||
@ -36,9 +110,13 @@ function ScheduleAdd({ i18n, createSchedule }) {
|
||||
<Card>
|
||||
<CardBody>
|
||||
<ScheduleForm
|
||||
hasDaysToKeepField={hasDaysToKeepField}
|
||||
handleCancel={() => history.push(`${pathRoot}schedules`)}
|
||||
handleSubmit={handleSubmit}
|
||||
submitError={formSubmitError}
|
||||
launchConfig={launchConfig}
|
||||
surveyConfig={surveyConfig}
|
||||
resource={resource}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
@ -46,7 +124,7 @@ function ScheduleAdd({ i18n, createSchedule }) {
|
||||
}
|
||||
|
||||
ScheduleAdd.propTypes = {
|
||||
createSchedule: func.isRequired,
|
||||
apiModel: shape({ createSchedule: func.isRequired }).isRequired,
|
||||
};
|
||||
|
||||
ScheduleAdd.defaultProps = {};
|
||||
|
||||
@ -5,10 +5,12 @@ import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
import { SchedulesAPI } from '../../../api';
|
||||
import { SchedulesAPI, JobTemplatesAPI, InventoriesAPI } from '../../../api';
|
||||
import ScheduleAdd from './ScheduleAdd';
|
||||
|
||||
jest.mock('../../../api/models/Schedules');
|
||||
jest.mock('../../../api/models/JobTemplates');
|
||||
jest.mock('../../../api/models/Inventories');
|
||||
|
||||
SchedulesAPI.readZoneInfo.mockResolvedValue({
|
||||
data: [
|
||||
@ -18,21 +20,62 @@ SchedulesAPI.readZoneInfo.mockResolvedValue({
|
||||
],
|
||||
});
|
||||
|
||||
let wrapper;
|
||||
|
||||
const createSchedule = jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
data: {
|
||||
id: 1,
|
||||
const launchConfig = {
|
||||
can_start_without_user_input: false,
|
||||
passwords_needed_to_start: [],
|
||||
ask_scm_branch_on_launch: false,
|
||||
ask_variables_on_launch: false,
|
||||
ask_tags_on_launch: false,
|
||||
ask_diff_mode_on_launch: false,
|
||||
ask_skip_tags_on_launch: false,
|
||||
ask_job_type_on_launch: false,
|
||||
ask_limit_on_launch: false,
|
||||
ask_verbosity_on_launch: false,
|
||||
ask_inventory_on_launch: true,
|
||||
ask_credential_on_launch: false,
|
||||
survey_enabled: false,
|
||||
variables_needed_to_start: [],
|
||||
credential_needed_to_start: false,
|
||||
inventory_needed_to_start: true,
|
||||
job_template_data: {
|
||||
name: 'Demo Job Template',
|
||||
id: 7,
|
||||
description: '',
|
||||
},
|
||||
defaults: {
|
||||
extra_vars: '---',
|
||||
diff_mode: false,
|
||||
limit: '',
|
||||
job_tags: '',
|
||||
skip_tags: '',
|
||||
job_type: 'run',
|
||||
verbosity: 0,
|
||||
inventory: {
|
||||
name: null,
|
||||
id: null,
|
||||
},
|
||||
};
|
||||
});
|
||||
scm_branch: '',
|
||||
},
|
||||
};
|
||||
|
||||
JobTemplatesAPI.createSchedule.mockResolvedValue({ data: { id: 3 } });
|
||||
|
||||
let wrapper;
|
||||
|
||||
describe('<ScheduleAdd />', () => {
|
||||
beforeAll(async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ScheduleAdd createSchedule={createSchedule} />
|
||||
<ScheduleAdd
|
||||
apiModel={JobTemplatesAPI}
|
||||
resource={{
|
||||
id: 700,
|
||||
type: 'job_template',
|
||||
inventory: 2,
|
||||
summary_fields: { credentials: [] },
|
||||
}}
|
||||
launchConfig={launchConfig}
|
||||
/>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
@ -42,7 +85,7 @@ describe('<ScheduleAdd />', () => {
|
||||
});
|
||||
test('Successfully creates a schedule with repeat frequency: None (run once)', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('ScheduleForm').invoke('handleSubmit')({
|
||||
wrapper.find('Formik').invoke('onSubmit')({
|
||||
description: 'test description',
|
||||
end: 'never',
|
||||
frequency: 'none',
|
||||
@ -52,16 +95,17 @@ describe('<ScheduleAdd />', () => {
|
||||
timezone: 'America/New_York',
|
||||
});
|
||||
});
|
||||
expect(createSchedule).toHaveBeenCalledWith({
|
||||
expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, {
|
||||
description: 'test description',
|
||||
name: 'Run once schedule',
|
||||
extra_data: {},
|
||||
rrule:
|
||||
'DTSTART;TZID=America/New_York:20200325T100000 RRULE:INTERVAL=1;COUNT=1;FREQ=MINUTELY',
|
||||
});
|
||||
});
|
||||
test('Successfully creates a schedule with 10 minute repeat frequency after 10 occurrences', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('ScheduleForm').invoke('handleSubmit')({
|
||||
wrapper.find('Formik').invoke('onSubmit')({
|
||||
description: 'test description',
|
||||
end: 'after',
|
||||
frequency: 'minute',
|
||||
@ -72,16 +116,17 @@ describe('<ScheduleAdd />', () => {
|
||||
timezone: 'America/New_York',
|
||||
});
|
||||
});
|
||||
expect(createSchedule).toHaveBeenCalledWith({
|
||||
expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, {
|
||||
description: 'test description',
|
||||
name: 'Run every 10 minutes 10 times',
|
||||
extra_data: {},
|
||||
rrule:
|
||||
'DTSTART;TZID=America/New_York:20200325T103000 RRULE:INTERVAL=10;FREQ=MINUTELY;COUNT=10',
|
||||
});
|
||||
});
|
||||
test('Successfully creates a schedule with hourly repeat frequency ending on a specific date/time', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('ScheduleForm').invoke('handleSubmit')({
|
||||
wrapper.find('Formik').invoke('onSubmit')({
|
||||
description: 'test description',
|
||||
end: 'onDate',
|
||||
endDateTime: '2020-03-26T10:45:00',
|
||||
@ -92,16 +137,17 @@ describe('<ScheduleAdd />', () => {
|
||||
timezone: 'America/New_York',
|
||||
});
|
||||
});
|
||||
expect(createSchedule).toHaveBeenCalledWith({
|
||||
expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, {
|
||||
description: 'test description',
|
||||
name: 'Run every hour until date',
|
||||
extra_data: {},
|
||||
rrule:
|
||||
'DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=HOURLY;UNTIL=20200326T104500',
|
||||
});
|
||||
});
|
||||
test('Successfully creates a schedule with daily repeat frequency', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('ScheduleForm').invoke('handleSubmit')({
|
||||
wrapper.find('Formik').invoke('onSubmit')({
|
||||
description: 'test description',
|
||||
end: 'never',
|
||||
frequency: 'day',
|
||||
@ -111,16 +157,17 @@ describe('<ScheduleAdd />', () => {
|
||||
timezone: 'America/New_York',
|
||||
});
|
||||
});
|
||||
expect(createSchedule).toHaveBeenCalledWith({
|
||||
expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, {
|
||||
description: 'test description',
|
||||
name: 'Run daily',
|
||||
extra_data: {},
|
||||
rrule:
|
||||
'DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=DAILY',
|
||||
});
|
||||
});
|
||||
test('Successfully creates a schedule with weekly repeat frequency on mon/wed/fri', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('ScheduleForm').invoke('handleSubmit')({
|
||||
wrapper.find('Formik').invoke('onSubmit')({
|
||||
daysOfWeek: [RRule.MO, RRule.WE, RRule.FR],
|
||||
description: 'test description',
|
||||
end: 'never',
|
||||
@ -132,15 +179,16 @@ describe('<ScheduleAdd />', () => {
|
||||
timezone: 'America/New_York',
|
||||
});
|
||||
});
|
||||
expect(createSchedule).toHaveBeenCalledWith({
|
||||
expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, {
|
||||
description: 'test description',
|
||||
name: 'Run weekly on mon/wed/fri',
|
||||
extra_data: {},
|
||||
rrule: `DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=WEEKLY;BYDAY=${RRule.MO},${RRule.WE},${RRule.FR}`,
|
||||
});
|
||||
});
|
||||
test('Successfully creates a schedule with monthly repeat frequency on the first day of the month', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('ScheduleForm').invoke('handleSubmit')({
|
||||
wrapper.find('Formik').invoke('onSubmit')({
|
||||
description: 'test description',
|
||||
end: 'never',
|
||||
frequency: 'month',
|
||||
@ -153,16 +201,17 @@ describe('<ScheduleAdd />', () => {
|
||||
timezone: 'America/New_York',
|
||||
});
|
||||
});
|
||||
expect(createSchedule).toHaveBeenCalledWith({
|
||||
expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, {
|
||||
description: 'test description',
|
||||
name: 'Run on the first day of the month',
|
||||
extra_data: {},
|
||||
rrule:
|
||||
'DTSTART;TZID=America/New_York:20200401T104500 RRULE:INTERVAL=1;FREQ=MONTHLY;BYMONTHDAY=1',
|
||||
});
|
||||
});
|
||||
test('Successfully creates a schedule with monthly repeat frequency on the last tuesday of the month', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('ScheduleForm').invoke('handleSubmit')({
|
||||
wrapper.find('Formik').invoke('onSubmit')({
|
||||
description: 'test description',
|
||||
end: 'never',
|
||||
endDateTime: '2020-03-26T11:00:00',
|
||||
@ -177,16 +226,17 @@ describe('<ScheduleAdd />', () => {
|
||||
timezone: 'America/New_York',
|
||||
});
|
||||
});
|
||||
expect(createSchedule).toHaveBeenCalledWith({
|
||||
expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, {
|
||||
description: 'test description',
|
||||
name: 'Run monthly on the last Tuesday',
|
||||
extra_data: {},
|
||||
rrule:
|
||||
'DTSTART;TZID=America/New_York:20200331T110000 RRULE:INTERVAL=1;FREQ=MONTHLY;BYSETPOS=-1;BYDAY=TU',
|
||||
});
|
||||
});
|
||||
test('Successfully creates a schedule with yearly repeat frequency on the first day of March', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('ScheduleForm').invoke('handleSubmit')({
|
||||
wrapper.find('Formik').invoke('onSubmit')({
|
||||
description: 'test description',
|
||||
end: 'never',
|
||||
frequency: 'year',
|
||||
@ -200,16 +250,17 @@ describe('<ScheduleAdd />', () => {
|
||||
timezone: 'America/New_York',
|
||||
});
|
||||
});
|
||||
expect(createSchedule).toHaveBeenCalledWith({
|
||||
expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, {
|
||||
description: 'test description',
|
||||
name: 'Yearly on the first day of March',
|
||||
extra_data: {},
|
||||
rrule:
|
||||
'DTSTART;TZID=America/New_York:20200301T000000 RRULE:INTERVAL=1;FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=1',
|
||||
});
|
||||
});
|
||||
test('Successfully creates a schedule with yearly repeat frequency on the second Friday in April', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('ScheduleForm').invoke('handleSubmit')({
|
||||
wrapper.find('Formik').invoke('onSubmit')({
|
||||
description: 'test description',
|
||||
end: 'never',
|
||||
frequency: 'year',
|
||||
@ -224,16 +275,17 @@ describe('<ScheduleAdd />', () => {
|
||||
timezone: 'America/New_York',
|
||||
});
|
||||
});
|
||||
expect(createSchedule).toHaveBeenCalledWith({
|
||||
expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, {
|
||||
description: 'test description',
|
||||
name: 'Yearly on the second Friday in April',
|
||||
extra_data: {},
|
||||
rrule:
|
||||
'DTSTART;TZID=America/New_York:20200410T111500 RRULE:INTERVAL=1;FREQ=YEARLY;BYSETPOS=2;BYDAY=FR;BYMONTH=4',
|
||||
});
|
||||
});
|
||||
test('Successfully creates a schedule with yearly repeat frequency on the first weekday in October', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('ScheduleForm').invoke('handleSubmit')({
|
||||
wrapper.find('Formik').invoke('onSubmit')({
|
||||
description: 'test description',
|
||||
end: 'never',
|
||||
frequency: 'year',
|
||||
@ -248,11 +300,118 @@ describe('<ScheduleAdd />', () => {
|
||||
timezone: 'America/New_York',
|
||||
});
|
||||
});
|
||||
expect(createSchedule).toHaveBeenCalledWith({
|
||||
expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, {
|
||||
description: 'test description',
|
||||
name: 'Yearly on the first weekday in October',
|
||||
extra_data: {},
|
||||
rrule:
|
||||
'DTSTART;TZID=America/New_York:20200410T111500 RRULE:INTERVAL=1;FREQ=YEARLY;BYSETPOS=1;BYDAY=MO,TU,WE,TH,FR;BYMONTH=10',
|
||||
});
|
||||
});
|
||||
|
||||
test('should submit prompted data properly', async () => {
|
||||
InventoriesAPI.read.mockResolvedValue({
|
||||
data: {
|
||||
count: 2,
|
||||
results: [
|
||||
{
|
||||
name: 'Foo',
|
||||
id: 1,
|
||||
url: '',
|
||||
},
|
||||
{
|
||||
name: 'Bar',
|
||||
id: 2,
|
||||
url: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
InventoriesAPI.readOptions.mockResolvedValue({
|
||||
data: {
|
||||
related_search_fields: [],
|
||||
actions: {
|
||||
GET: {
|
||||
filterable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () =>
|
||||
wrapper.find('Button[aria-label="Prompt"]').prop('onClick')()
|
||||
);
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper
|
||||
.find('WizardNavItem')
|
||||
.at(0)
|
||||
.prop('isCurrent')
|
||||
).toBe(true);
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find('input[aria-labelledby="check-action-item-1"]')
|
||||
.simulate('change', {
|
||||
target: {
|
||||
checked: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper
|
||||
.find('input[aria-labelledby="check-action-item-1"]')
|
||||
.prop('checked')
|
||||
).toBe(true);
|
||||
await act(async () =>
|
||||
wrapper.find('WizardFooterInternal').prop('onNext')()
|
||||
);
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper
|
||||
.find('WizardNavItem')
|
||||
.at(1)
|
||||
.prop('isCurrent')
|
||||
).toBe(true);
|
||||
await act(async () =>
|
||||
wrapper.find('WizardFooterInternal').prop('onNext')()
|
||||
);
|
||||
wrapper.update();
|
||||
expect(wrapper.find('Wizard').length).toBe(0);
|
||||
await act(async () => {
|
||||
wrapper.find('Formik').invoke('onSubmit')({
|
||||
name: 'Schedule',
|
||||
end: 'never',
|
||||
endDateTime: '2021-01-29T14:15:00',
|
||||
frequency: 'none',
|
||||
occurrences: 1,
|
||||
runOn: 'day',
|
||||
runOnDayMonth: 1,
|
||||
runOnDayNumber: 1,
|
||||
runOnTheDay: 'sunday',
|
||||
runOnTheMonth: 1,
|
||||
runOnTheOccurrence: 1,
|
||||
skip_tags: '',
|
||||
inventory: { name: 'inventory', id: 45 },
|
||||
credentials: [
|
||||
{ name: 'cred 1', id: 10 },
|
||||
{ name: 'cred 2', id: 20 },
|
||||
],
|
||||
startDateTime: '2021-01-28T14:15:00',
|
||||
timezone: 'America/New_York',
|
||||
});
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
expect(JobTemplatesAPI.createSchedule).toBeCalledWith(700, {
|
||||
extra_data: {},
|
||||
inventory: 45,
|
||||
name: 'Schedule',
|
||||
rrule:
|
||||
'DTSTART;TZID=America/New_York:20210128T141500 RRULE:COUNT=1;FREQ=MINUTELY',
|
||||
skip_tags: '',
|
||||
});
|
||||
expect(SchedulesAPI.associateCredential).toBeCalledWith(3, 10);
|
||||
expect(SchedulesAPI.associateCredential).toBeCalledWith(3, 20);
|
||||
});
|
||||
});
|
||||
|
||||
@ -26,6 +26,7 @@ import DeleteButton from '../../DeleteButton';
|
||||
import ErrorDetail from '../../ErrorDetail';
|
||||
import ChipGroup from '../../ChipGroup';
|
||||
import { VariablesDetail } from '../../CodeMirrorInput';
|
||||
import { parseVariableField } from '../../../util/yaml';
|
||||
|
||||
const PromptDivider = styled(Divider)`
|
||||
margin-top: var(--pf-global--spacer--lg);
|
||||
@ -42,7 +43,7 @@ const PromptDetailList = styled(DetailList)`
|
||||
padding: 0px 20px;
|
||||
`;
|
||||
|
||||
function ScheduleDetail({ schedule, i18n }) {
|
||||
function ScheduleDetail({ hasDaysToKeepField, schedule, i18n, surveyConfig }) {
|
||||
const {
|
||||
id,
|
||||
created,
|
||||
@ -148,6 +149,7 @@ function ScheduleDetail({ schedule, i18n }) {
|
||||
|
||||
const {
|
||||
ask_credential_on_launch,
|
||||
inventory_needed_to_start,
|
||||
ask_diff_mode_on_launch,
|
||||
ask_inventory_on_launch,
|
||||
ask_job_type_on_launch,
|
||||
@ -160,6 +162,41 @@ function ScheduleDetail({ schedule, i18n }) {
|
||||
survey_enabled,
|
||||
} = launchData || {};
|
||||
|
||||
const missingRequiredInventory = () => {
|
||||
if (!inventory_needed_to_start || schedule?.summary_fields?.inventory?.id) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const hasMissingSurveyValue = () => {
|
||||
let missingValues = false;
|
||||
if (survey_enabled) {
|
||||
surveyConfig.spec.forEach(question => {
|
||||
const hasDefaultValue = Boolean(question.default);
|
||||
if (question.required && !hasDefaultValue) {
|
||||
const extraDataKeys = Object.keys(schedule?.extra_data);
|
||||
|
||||
const hasMatchingKey = extraDataKeys.includes(question.variable);
|
||||
Object.values(schedule?.extra_data).forEach(value => {
|
||||
if (!value || !hasMatchingKey) {
|
||||
missingValues = true;
|
||||
} else {
|
||||
missingValues = false;
|
||||
}
|
||||
});
|
||||
if (!Object.values(schedule.extra_data).length) {
|
||||
missingValues = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return missingValues;
|
||||
};
|
||||
const isDisabled = Boolean(
|
||||
missingRequiredInventory() || hasMissingSurveyValue()
|
||||
);
|
||||
|
||||
const showCredentialsDetail =
|
||||
ask_credential_on_launch && credentials.length > 0;
|
||||
const showInventoryDetail = ask_inventory_on_launch && inventory;
|
||||
@ -197,9 +234,23 @@ function ScheduleDetail({ schedule, i18n }) {
|
||||
return <ContentError error={readContentError} />;
|
||||
}
|
||||
|
||||
let daysToKeep = null;
|
||||
if (hasDaysToKeepField && extra_data) {
|
||||
if (typeof extra_data === 'string' && extra_data !== '') {
|
||||
daysToKeep = parseVariableField(extra_data).days;
|
||||
}
|
||||
if (typeof extra_data === 'object') {
|
||||
daysToKeep = extra_data?.days;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<CardBody>
|
||||
<ScheduleToggle schedule={schedule} css="padding-bottom: 40px" />
|
||||
<ScheduleToggle
|
||||
schedule={schedule}
|
||||
css="padding-bottom: 40px"
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
<DetailList gutter="sm">
|
||||
<Detail label={i18n._(t`Name`)} value={name} />
|
||||
<Detail label={i18n._(t`Description`)} value={description} />
|
||||
@ -214,6 +265,9 @@ function ScheduleDetail({ schedule, i18n }) {
|
||||
<Detail label={i18n._(t`Last Run`)} value={formatDateString(dtend)} />
|
||||
<Detail label={i18n._(t`Local Time Zone`)} value={timezone} />
|
||||
<Detail label={i18n._(t`Repeat Frequency`)} value={repeatFrequency} />
|
||||
{hasDaysToKeepField ? (
|
||||
<Detail label={i18n._(t`Days of Data to Keep`)} value={daysToKeep} />
|
||||
) : null}
|
||||
<ScheduleOccurrences preview={preview} />
|
||||
<UserDateDetail
|
||||
label={i18n._(t`Created`)}
|
||||
@ -256,6 +310,12 @@ function ScheduleDetail({ schedule, i18n }) {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{ask_verbosity_on_launch && (
|
||||
<Detail
|
||||
label={i18n._(t`Verbosity`)}
|
||||
value={VERBOSITY[verbosity]}
|
||||
/>
|
||||
)}
|
||||
{ask_scm_branch_on_launch && (
|
||||
<Detail
|
||||
label={i18n._(t`Source Control Branch`)}
|
||||
@ -265,12 +325,6 @@ function ScheduleDetail({ schedule, i18n }) {
|
||||
{ask_limit_on_launch && (
|
||||
<Detail label={i18n._(t`Limit`)} value={limit} />
|
||||
)}
|
||||
{ask_verbosity_on_launch && (
|
||||
<Detail
|
||||
label={i18n._(t`Verbosity`)}
|
||||
value={VERBOSITY[verbosity]}
|
||||
/>
|
||||
)}
|
||||
{showDiffModeDetail && (
|
||||
<Detail
|
||||
label={i18n._(t`Show Changes`)}
|
||||
|
||||
@ -26,6 +26,7 @@ const allPrompts = {
|
||||
ask_variables_on_launch: true,
|
||||
ask_verbosity_on_launch: true,
|
||||
survey_enabled: true,
|
||||
inventory_needed_to_start: true,
|
||||
},
|
||||
};
|
||||
|
||||
@ -489,4 +490,39 @@ describe('<ScheduleDetail />', () => {
|
||||
);
|
||||
expect(SchedulesAPI.destroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
test('should have disabled toggle', async () => {
|
||||
SchedulesAPI.readCredentials.mockResolvedValueOnce({
|
||||
data: {
|
||||
count: 0,
|
||||
results: [],
|
||||
},
|
||||
});
|
||||
JobTemplatesAPI.readLaunch.mockResolvedValueOnce(allPrompts);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Route
|
||||
path="/templates/job_template/:id/schedules/:scheduleId"
|
||||
component={() => (
|
||||
<ScheduleDetail schedule={schedule} surveyConfig={{ spec: [] }} />
|
||||
)}
|
||||
/>,
|
||||
{
|
||||
context: {
|
||||
router: {
|
||||
history,
|
||||
route: {
|
||||
location: history.location,
|
||||
match: { params: { id: 1 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
await waitForElement(
|
||||
wrapper,
|
||||
'ScheduleToggle',
|
||||
el => el.prop('isDisabled') === true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,28 +4,112 @@ import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { RRule } from 'rrule';
|
||||
import { shape } from 'prop-types';
|
||||
import { Card } from '@patternfly/react-core';
|
||||
import yaml from 'js-yaml';
|
||||
import { CardBody } from '../../Card';
|
||||
import { SchedulesAPI } from '../../../api';
|
||||
import buildRuleObj from '../shared/buildRuleObj';
|
||||
import ScheduleForm from '../shared/ScheduleForm';
|
||||
import { getAddedAndRemoved } from '../../../util/lists';
|
||||
|
||||
function ScheduleEdit({ i18n, schedule }) {
|
||||
import { parseVariableField } from '../../../util/yaml';
|
||||
import mergeExtraVars from '../../../util/prompt/mergeExtraVars';
|
||||
import getSurveyValues from '../../../util/prompt/getSurveyValues';
|
||||
|
||||
function ScheduleEdit({
|
||||
i18n,
|
||||
hasDaysToKeepField,
|
||||
schedule,
|
||||
resource,
|
||||
launchConfig,
|
||||
surveyConfig,
|
||||
}) {
|
||||
const [formSubmitError, setFormSubmitError] = useState(null);
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const { pathname } = location;
|
||||
const pathRoot = pathname.substr(0, pathname.indexOf('schedules'));
|
||||
|
||||
const handleSubmit = async values => {
|
||||
const handleSubmit = async (
|
||||
values,
|
||||
launchConfiguration,
|
||||
surveyConfiguration,
|
||||
scheduleCredentials = []
|
||||
) => {
|
||||
const {
|
||||
inventory,
|
||||
credentials = [],
|
||||
end,
|
||||
frequency,
|
||||
interval,
|
||||
startDateTime,
|
||||
timezone,
|
||||
occurences,
|
||||
runOn,
|
||||
runOnTheDay,
|
||||
runOnTheMonth,
|
||||
runOnDayMonth,
|
||||
runOnDayNumber,
|
||||
endDateTime,
|
||||
runOnTheOccurence,
|
||||
daysOfWeek,
|
||||
...submitValues
|
||||
} = values;
|
||||
const { added, removed } = getAddedAndRemoved(
|
||||
[...(resource?.summary_fields.credentials || []), ...scheduleCredentials],
|
||||
credentials
|
||||
);
|
||||
|
||||
let extraVars;
|
||||
const surveyValues = getSurveyValues(values);
|
||||
const initialExtraVars =
|
||||
launchConfiguration?.ask_variables_on_launch &&
|
||||
(values.extra_vars || '---');
|
||||
if (surveyConfiguration?.spec) {
|
||||
extraVars = yaml.safeDump(mergeExtraVars(initialExtraVars, surveyValues));
|
||||
} else {
|
||||
extraVars = yaml.safeDump(mergeExtraVars(initialExtraVars, {}));
|
||||
}
|
||||
submitValues.extra_data = extraVars && parseVariableField(extraVars);
|
||||
|
||||
if (
|
||||
Object.keys(submitValues.extra_data).length === 0 &&
|
||||
Object.keys(schedule.extra_data).length > 0
|
||||
) {
|
||||
submitValues.extra_data = schedule.extra_data;
|
||||
}
|
||||
delete values.extra_vars;
|
||||
if (inventory) {
|
||||
submitValues.inventory = inventory.id;
|
||||
}
|
||||
|
||||
try {
|
||||
const rule = new RRule(buildRuleObj(values, i18n));
|
||||
const requestData = {
|
||||
...submitValues,
|
||||
rrule: rule.toString().replace(/\n/g, ' '),
|
||||
};
|
||||
|
||||
if (Object.keys(values).includes('daysToKeep')) {
|
||||
if (!requestData.extra_data) {
|
||||
requestData.extra_data = JSON.stringify({ days: values.daysToKeep });
|
||||
} else {
|
||||
requestData.extra_data.days = values.daysToKeep;
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
data: { id: scheduleId },
|
||||
} = await SchedulesAPI.update(schedule.id, {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
rrule: rule.toString().replace(/\n/g, ' '),
|
||||
});
|
||||
} = await SchedulesAPI.update(schedule.id, requestData);
|
||||
if (values.credentials?.length > 0) {
|
||||
await Promise.all([
|
||||
...removed.map(({ id }) =>
|
||||
SchedulesAPI.disassociateCredential(scheduleId, id)
|
||||
),
|
||||
...added.map(({ id }) =>
|
||||
SchedulesAPI.associateCredential(scheduleId, id)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
history.push(`${pathRoot}schedules/${scheduleId}/details`);
|
||||
} catch (err) {
|
||||
@ -38,11 +122,15 @@ function ScheduleEdit({ i18n, schedule }) {
|
||||
<CardBody>
|
||||
<ScheduleForm
|
||||
schedule={schedule}
|
||||
hasDaysToKeepField={hasDaysToKeepField}
|
||||
handleCancel={() =>
|
||||
history.push(`${pathRoot}schedules/${schedule.id}/details`)
|
||||
}
|
||||
handleSubmit={handleSubmit}
|
||||
submitError={formSubmitError}
|
||||
resource={resource}
|
||||
launchConfig={launchConfig}
|
||||
surveyConfig={surveyConfig}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
@ -5,10 +5,19 @@ import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
import { SchedulesAPI } from '../../../api';
|
||||
import {
|
||||
SchedulesAPI,
|
||||
InventoriesAPI,
|
||||
CredentialsAPI,
|
||||
CredentialTypesAPI,
|
||||
} from '../../../api';
|
||||
import ScheduleEdit from './ScheduleEdit';
|
||||
|
||||
jest.mock('../../../api/models/Schedules');
|
||||
jest.mock('../../../api/models/JobTemplates');
|
||||
jest.mock('../../../api/models/Inventories');
|
||||
jest.mock('../../../api/models/Credentials');
|
||||
jest.mock('../../../api/models/CredentialTypes');
|
||||
|
||||
SchedulesAPI.readZoneInfo.mockResolvedValue({
|
||||
data: [
|
||||
@ -18,6 +27,35 @@ SchedulesAPI.readZoneInfo.mockResolvedValue({
|
||||
],
|
||||
});
|
||||
|
||||
SchedulesAPI.readCredentials.mockResolvedValue({
|
||||
data: {
|
||||
results: [
|
||||
{ name: 'schedule credential 1', id: 1, kind: 'vault' },
|
||||
{ name: 'schedule credential 2', id: 2, kind: 'aws' },
|
||||
],
|
||||
count: 2,
|
||||
},
|
||||
});
|
||||
|
||||
CredentialTypesAPI.loadAllTypes.mockResolvedValue([
|
||||
{ id: 1, name: 'ssh', kind: 'ssh' },
|
||||
]);
|
||||
|
||||
CredentialsAPI.read.mockResolvedValue({
|
||||
data: {
|
||||
count: 3,
|
||||
results: [
|
||||
{ id: 1, name: 'Credential 1', kind: 'ssh', url: '' },
|
||||
{ id: 2, name: 'Credential 2', kind: 'ssh', url: '' },
|
||||
{ id: 3, name: 'Credential 3', kind: 'ssh', url: '' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
CredentialsAPI.readOptions.mockResolvedValue({
|
||||
data: { related_search_fields: [], actions: { GET: { filterabled: true } } },
|
||||
});
|
||||
|
||||
SchedulesAPI.update.mockResolvedValue({
|
||||
data: {
|
||||
id: 27,
|
||||
@ -37,13 +75,14 @@ const mockSchedule = {
|
||||
edit: true,
|
||||
delete: true,
|
||||
},
|
||||
inventory: { id: 702, name: 'Inventory' },
|
||||
},
|
||||
created: '2020-04-02T18:43:12.664142Z',
|
||||
modified: '2020-04-02T18:43:12.664185Z',
|
||||
name: 'mock schedule',
|
||||
description: '',
|
||||
extra_data: {},
|
||||
inventory: null,
|
||||
inventory: 1,
|
||||
scm_branch: null,
|
||||
job_type: null,
|
||||
job_tags: null,
|
||||
@ -61,18 +100,71 @@ const mockSchedule = {
|
||||
};
|
||||
|
||||
describe('<ScheduleEdit />', () => {
|
||||
beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<ScheduleEdit schedule={mockSchedule} />);
|
||||
wrapper = mountWithContexts(
|
||||
<ScheduleEdit
|
||||
schedule={mockSchedule}
|
||||
resource={{
|
||||
id: 700,
|
||||
type: 'job_template',
|
||||
iventory: 1,
|
||||
summary_fields: {
|
||||
credentials: [
|
||||
{ name: 'job template credential', id: 75, kind: 'ssh' },
|
||||
],
|
||||
},
|
||||
}}
|
||||
launchConfig={{
|
||||
can_start_without_user_input: false,
|
||||
passwords_needed_to_start: [],
|
||||
ask_scm_branch_on_launch: false,
|
||||
ask_variables_on_launch: false,
|
||||
ask_tags_on_launch: false,
|
||||
ask_diff_mode_on_launch: false,
|
||||
ask_skip_tags_on_launch: false,
|
||||
ask_job_type_on_launch: false,
|
||||
ask_limit_on_launch: false,
|
||||
ask_verbosity_on_launch: false,
|
||||
ask_inventory_on_launch: true,
|
||||
ask_credential_on_launch: true,
|
||||
survey_enabled: false,
|
||||
variables_needed_to_start: [],
|
||||
credential_needed_to_start: true,
|
||||
inventory_needed_to_start: true,
|
||||
job_template_data: {
|
||||
name: 'Demo Job Template',
|
||||
id: 7,
|
||||
description: '',
|
||||
},
|
||||
defaults: {
|
||||
extra_vars: '---',
|
||||
diff_mode: false,
|
||||
limit: '',
|
||||
job_tags: '',
|
||||
skip_tags: '',
|
||||
job_type: 'run',
|
||||
verbosity: 0,
|
||||
inventory: {
|
||||
name: null,
|
||||
id: null,
|
||||
},
|
||||
scm_branch: '',
|
||||
},
|
||||
}}
|
||||
surveyConfig={{}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
test('Successfully creates a schedule with repeat frequency: None (run once)', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('ScheduleForm').invoke('handleSubmit')({
|
||||
wrapper.find('Formik').invoke('onSubmit')({
|
||||
description: 'test description',
|
||||
end: 'never',
|
||||
frequency: 'none',
|
||||
@ -85,13 +177,14 @@ describe('<ScheduleEdit />', () => {
|
||||
expect(SchedulesAPI.update).toHaveBeenCalledWith(27, {
|
||||
description: 'test description',
|
||||
name: 'Run once schedule',
|
||||
extra_data: {},
|
||||
rrule:
|
||||
'DTSTART;TZID=America/New_York:20200325T100000 RRULE:INTERVAL=1;COUNT=1;FREQ=MINUTELY',
|
||||
});
|
||||
});
|
||||
test('Successfully creates a schedule with 10 minute repeat frequency after 10 occurrences', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('ScheduleForm').invoke('handleSubmit')({
|
||||
wrapper.find('Formik').invoke('onSubmit')({
|
||||
description: 'test description',
|
||||
end: 'after',
|
||||
frequency: 'minute',
|
||||
@ -105,13 +198,15 @@ describe('<ScheduleEdit />', () => {
|
||||
expect(SchedulesAPI.update).toHaveBeenCalledWith(27, {
|
||||
description: 'test description',
|
||||
name: 'Run every 10 minutes 10 times',
|
||||
extra_data: {},
|
||||
occurrences: 10,
|
||||
rrule:
|
||||
'DTSTART;TZID=America/New_York:20200325T103000 RRULE:INTERVAL=10;FREQ=MINUTELY;COUNT=10',
|
||||
});
|
||||
});
|
||||
test('Successfully creates a schedule with hourly repeat frequency ending on a specific date/time', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('ScheduleForm').invoke('handleSubmit')({
|
||||
wrapper.find('Formik').invoke('onSubmit')({
|
||||
description: 'test description',
|
||||
end: 'onDate',
|
||||
endDateTime: '2020-03-26T10:45:00',
|
||||
@ -125,13 +220,14 @@ describe('<ScheduleEdit />', () => {
|
||||
expect(SchedulesAPI.update).toHaveBeenCalledWith(27, {
|
||||
description: 'test description',
|
||||
name: 'Run every hour until date',
|
||||
extra_data: {},
|
||||
rrule:
|
||||
'DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=HOURLY;UNTIL=20200326T104500',
|
||||
});
|
||||
});
|
||||
test('Successfully creates a schedule with daily repeat frequency', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('ScheduleForm').invoke('handleSubmit')({
|
||||
wrapper.find('Formik').invoke('onSubmit')({
|
||||
description: 'test description',
|
||||
end: 'never',
|
||||
frequency: 'day',
|
||||
@ -144,13 +240,14 @@ describe('<ScheduleEdit />', () => {
|
||||
expect(SchedulesAPI.update).toHaveBeenCalledWith(27, {
|
||||
description: 'test description',
|
||||
name: 'Run daily',
|
||||
extra_data: {},
|
||||
rrule:
|
||||
'DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=DAILY',
|
||||
});
|
||||
});
|
||||
test('Successfully creates a schedule with weekly repeat frequency on mon/wed/fri', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('ScheduleForm').invoke('handleSubmit')({
|
||||
wrapper.find('Formik').invoke('onSubmit')({
|
||||
daysOfWeek: [RRule.MO, RRule.WE, RRule.FR],
|
||||
description: 'test description',
|
||||
end: 'never',
|
||||
@ -165,12 +262,14 @@ describe('<ScheduleEdit />', () => {
|
||||
expect(SchedulesAPI.update).toHaveBeenCalledWith(27, {
|
||||
description: 'test description',
|
||||
name: 'Run weekly on mon/wed/fri',
|
||||
extra_data: {},
|
||||
occurrences: 1,
|
||||
rrule: `DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=WEEKLY;BYDAY=${RRule.MO},${RRule.WE},${RRule.FR}`,
|
||||
});
|
||||
});
|
||||
test('Successfully creates a schedule with monthly repeat frequency on the first day of the month', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('ScheduleForm').invoke('handleSubmit')({
|
||||
wrapper.find('Formik').invoke('onSubmit')({
|
||||
description: 'test description',
|
||||
end: 'never',
|
||||
frequency: 'month',
|
||||
@ -186,13 +285,15 @@ describe('<ScheduleEdit />', () => {
|
||||
expect(SchedulesAPI.update).toHaveBeenCalledWith(27, {
|
||||
description: 'test description',
|
||||
name: 'Run on the first day of the month',
|
||||
extra_data: {},
|
||||
occurrences: 1,
|
||||
rrule:
|
||||
'DTSTART;TZID=America/New_York:20200401T104500 RRULE:INTERVAL=1;FREQ=MONTHLY;BYMONTHDAY=1',
|
||||
});
|
||||
});
|
||||
test('Successfully creates a schedule with monthly repeat frequency on the last tuesday of the month', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('ScheduleForm').invoke('handleSubmit')({
|
||||
wrapper.find('Formik').invoke('onSubmit')({
|
||||
description: 'test description',
|
||||
end: 'never',
|
||||
endDateTime: '2020-03-26T11:00:00',
|
||||
@ -210,13 +311,16 @@ describe('<ScheduleEdit />', () => {
|
||||
expect(SchedulesAPI.update).toHaveBeenCalledWith(27, {
|
||||
description: 'test description',
|
||||
name: 'Run monthly on the last Tuesday',
|
||||
extra_data: {},
|
||||
occurrences: 1,
|
||||
runOnTheOccurrence: -1,
|
||||
rrule:
|
||||
'DTSTART;TZID=America/New_York:20200331T110000 RRULE:INTERVAL=1;FREQ=MONTHLY;BYSETPOS=-1;BYDAY=TU',
|
||||
});
|
||||
});
|
||||
test('Successfully creates a schedule with yearly repeat frequency on the first day of March', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('ScheduleForm').invoke('handleSubmit')({
|
||||
wrapper.find('Formik').invoke('onSubmit')({
|
||||
description: 'test description',
|
||||
end: 'never',
|
||||
frequency: 'year',
|
||||
@ -233,13 +337,15 @@ describe('<ScheduleEdit />', () => {
|
||||
expect(SchedulesAPI.update).toHaveBeenCalledWith(27, {
|
||||
description: 'test description',
|
||||
name: 'Yearly on the first day of March',
|
||||
extra_data: {},
|
||||
occurrences: 1,
|
||||
rrule:
|
||||
'DTSTART;TZID=America/New_York:20200301T000000 RRULE:INTERVAL=1;FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=1',
|
||||
});
|
||||
});
|
||||
test('Successfully creates a schedule with yearly repeat frequency on the second Friday in April', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('ScheduleForm').invoke('handleSubmit')({
|
||||
wrapper.find('Formik').invoke('onSubmit')({
|
||||
description: 'test description',
|
||||
end: 'never',
|
||||
frequency: 'year',
|
||||
@ -257,13 +363,16 @@ describe('<ScheduleEdit />', () => {
|
||||
expect(SchedulesAPI.update).toHaveBeenCalledWith(27, {
|
||||
description: 'test description',
|
||||
name: 'Yearly on the second Friday in April',
|
||||
extra_data: {},
|
||||
occurrences: 1,
|
||||
runOnTheOccurrence: 2,
|
||||
rrule:
|
||||
'DTSTART;TZID=America/New_York:20200410T111500 RRULE:INTERVAL=1;FREQ=YEARLY;BYSETPOS=2;BYDAY=FR;BYMONTH=4',
|
||||
});
|
||||
});
|
||||
test('Successfully creates a schedule with yearly repeat frequency on the first weekday in October', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('ScheduleForm').invoke('handleSubmit')({
|
||||
wrapper.find('Formik').invoke('onSubmit')({
|
||||
description: 'test description',
|
||||
end: 'never',
|
||||
frequency: 'year',
|
||||
@ -281,8 +390,221 @@ describe('<ScheduleEdit />', () => {
|
||||
expect(SchedulesAPI.update).toHaveBeenCalledWith(27, {
|
||||
description: 'test description',
|
||||
name: 'Yearly on the first weekday in October',
|
||||
extra_data: {},
|
||||
occurrences: 1,
|
||||
runOnTheOccurrence: 1,
|
||||
rrule:
|
||||
'DTSTART;TZID=America/New_York:20200410T111500 RRULE:INTERVAL=1;FREQ=YEARLY;BYSETPOS=1;BYDAY=MO,TU,WE,TH,FR;BYMONTH=10',
|
||||
});
|
||||
});
|
||||
|
||||
test('should open with correct values and navigate through the Promptable fields properly', async () => {
|
||||
InventoriesAPI.read.mockResolvedValue({
|
||||
data: {
|
||||
count: 2,
|
||||
results: [
|
||||
{
|
||||
name: 'Foo',
|
||||
id: 1,
|
||||
url: '',
|
||||
},
|
||||
{
|
||||
name: 'Bar',
|
||||
id: 2,
|
||||
url: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
InventoriesAPI.readOptions.mockResolvedValue({
|
||||
data: {
|
||||
related_search_fields: [],
|
||||
actions: {
|
||||
GET: {
|
||||
filterable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () =>
|
||||
wrapper.find('Button[aria-label="Prompt"]').prop('onClick')()
|
||||
);
|
||||
wrapper.update();
|
||||
expect(wrapper.find('WizardNavItem').length).toBe(3);
|
||||
expect(
|
||||
wrapper
|
||||
.find('WizardNavItem')
|
||||
.at(0)
|
||||
.prop('isCurrent')
|
||||
).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find('input[aria-labelledby="check-action-item-1"]')
|
||||
.simulate('change', {
|
||||
target: {
|
||||
checked: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find('input[aria-labelledby="check-action-item-1"]')
|
||||
.prop('checked')
|
||||
).toBe(true);
|
||||
await act(async () =>
|
||||
wrapper.find('WizardFooterInternal').prop('onNext')()
|
||||
);
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper
|
||||
.find('WizardNavItem')
|
||||
.at(1)
|
||||
.prop('isCurrent')
|
||||
).toBe(true);
|
||||
|
||||
expect(wrapper.find('CredentialChip').length).toBe(3);
|
||||
|
||||
wrapper.update();
|
||||
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find('input[aria-labelledby="check-action-item-3"]')
|
||||
.simulate('change', {
|
||||
target: {
|
||||
checked: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper
|
||||
.find('input[aria-labelledby="check-action-item-3"]')
|
||||
.prop('checked')
|
||||
).toBe(true);
|
||||
await act(async () =>
|
||||
wrapper.find('WizardFooterInternal').prop('onNext')()
|
||||
);
|
||||
wrapper.update();
|
||||
await act(async () =>
|
||||
wrapper.find('WizardFooterInternal').prop('onNext')()
|
||||
);
|
||||
wrapper.update();
|
||||
expect(wrapper.find('Wizard').length).toBe(0);
|
||||
await act(async () => {
|
||||
wrapper.find('Formik').invoke('onSubmit')({
|
||||
name: mockSchedule.name,
|
||||
end: 'never',
|
||||
endDateTime: '2021-01-29T14:15:00',
|
||||
frequency: 'none',
|
||||
occurrences: 1,
|
||||
runOn: 'day',
|
||||
runOnDayMonth: 1,
|
||||
runOnDayNumber: 1,
|
||||
runOnTheDay: 'sunday',
|
||||
runOnTheMonth: 1,
|
||||
runOnTheOccurrence: 1,
|
||||
skip_tags: '',
|
||||
startDateTime: '2021-01-28T14:15:00',
|
||||
timezone: 'America/New_York',
|
||||
credentials: [
|
||||
{ id: 3, name: 'Credential 3', kind: 'ssh', url: '' },
|
||||
{ name: 'schedule credential 1', id: 1, kind: 'vault' },
|
||||
{ name: 'schedule credential 2', id: 2, kind: 'aws' },
|
||||
],
|
||||
});
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
expect(SchedulesAPI.update).toBeCalledWith(27, {
|
||||
extra_data: {},
|
||||
name: 'mock schedule',
|
||||
occurrences: 1,
|
||||
runOnTheOccurrence: 1,
|
||||
rrule:
|
||||
'DTSTART;TZID=America/New_York:20210128T141500 RRULE:COUNT=1;FREQ=MINUTELY',
|
||||
skip_tags: '',
|
||||
});
|
||||
expect(SchedulesAPI.disassociateCredential).toBeCalledWith(27, 75);
|
||||
|
||||
expect(SchedulesAPI.associateCredential).toBeCalledWith(27, 3);
|
||||
});
|
||||
|
||||
test('should submit updated static form values, but original prompt form values', async () => {
|
||||
InventoriesAPI.read.mockResolvedValue({
|
||||
data: {
|
||||
count: 2,
|
||||
results: [
|
||||
{
|
||||
name: 'Foo',
|
||||
id: 1,
|
||||
url: '',
|
||||
},
|
||||
{
|
||||
name: 'Bar',
|
||||
id: 2,
|
||||
url: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
InventoriesAPI.readOptions.mockResolvedValue({
|
||||
data: {
|
||||
related_search_fields: [],
|
||||
actions: {
|
||||
GET: {
|
||||
filterable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await act(async () =>
|
||||
wrapper.find('input#schedule-name').simulate('change', {
|
||||
target: { value: 'foo', name: 'name' },
|
||||
})
|
||||
);
|
||||
wrapper.update();
|
||||
await act(async () =>
|
||||
wrapper.find('Button[aria-label="Prompt"]').prop('onClick')()
|
||||
);
|
||||
wrapper.update();
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find('input[aria-labelledby="check-action-item-2"]')
|
||||
.simulate('change', {
|
||||
target: {
|
||||
checked: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find('input[aria-labelledby="check-action-item-2"]')
|
||||
.prop('checked')
|
||||
).toBe(true);
|
||||
await act(async () =>
|
||||
wrapper.find('WizardFooterInternal').prop('onClose')()
|
||||
);
|
||||
wrapper.update();
|
||||
expect(wrapper.find('Wizard').length).toBe(0);
|
||||
|
||||
await act(async () =>
|
||||
wrapper.find('Button[aria-label="Save"]').prop('onClick')()
|
||||
);
|
||||
expect(SchedulesAPI.update).toBeCalledWith(27, {
|
||||
description: '',
|
||||
extra_data: {},
|
||||
occurrences: 1,
|
||||
runOnTheOccurrence: 1,
|
||||
name: 'foo',
|
||||
inventory: 702,
|
||||
rrule:
|
||||
'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;COUNT=1;FREQ=MINUTELY',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -24,6 +24,9 @@ function ScheduleList({
|
||||
loadSchedules,
|
||||
loadScheduleOptions,
|
||||
hideAddButton,
|
||||
resource,
|
||||
launchConfig,
|
||||
surveyConfig,
|
||||
}) {
|
||||
const [selected, setSelected] = useState([]);
|
||||
|
||||
@ -114,6 +117,47 @@ function ScheduleList({
|
||||
actions &&
|
||||
Object.prototype.hasOwnProperty.call(actions, 'POST') &&
|
||||
!hideAddButton;
|
||||
const isTemplate =
|
||||
resource?.type === 'workflow_job_template' ||
|
||||
resource?.type === 'job_template';
|
||||
|
||||
const missingRequiredInventory = schedule => {
|
||||
if (
|
||||
!launchConfig.inventory_needed_to_start ||
|
||||
schedule?.summary_fields?.inventory?.id
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return i18n._(t`This schedule is missing an Inventory`);
|
||||
};
|
||||
|
||||
const hasMissingSurveyValue = schedule => {
|
||||
let missingValues;
|
||||
if (launchConfig.survey_enabled) {
|
||||
surveyConfig.spec.forEach(question => {
|
||||
const hasDefaultValue = Boolean(question.default);
|
||||
if (question.required && !hasDefaultValue) {
|
||||
const extraDataKeys = Object.keys(schedule?.extra_data);
|
||||
|
||||
const hasMatchingKey = extraDataKeys.includes(question.variable);
|
||||
Object.values(schedule?.extra_data).forEach(value => {
|
||||
if (!value || !hasMatchingKey) {
|
||||
missingValues = true;
|
||||
} else {
|
||||
missingValues = false;
|
||||
}
|
||||
});
|
||||
if (!Object.values(schedule.extra_data).length) {
|
||||
missingValues = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return (
|
||||
missingValues &&
|
||||
i18n._(t`This schedule is missing required survey values`)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -139,6 +183,8 @@ function ScheduleList({
|
||||
onSelect={() => handleSelect(item)}
|
||||
schedule={item}
|
||||
rowIndex={index}
|
||||
isMissingInventory={isTemplate && missingRequiredInventory(item)}
|
||||
isMissingSurvey={isTemplate && hasMissingSurveyValue(item)}
|
||||
/>
|
||||
)}
|
||||
toolbarSearchColumns={[
|
||||
|
||||
@ -32,19 +32,22 @@ describe('ScheduleList', () => {
|
||||
});
|
||||
|
||||
describe('read call successful', () => {
|
||||
beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ScheduleList
|
||||
loadSchedules={loadSchedules}
|
||||
loadScheduleOptions={loadScheduleOptions}
|
||||
resource={{ type: 'job_template', inventory: 1 }}
|
||||
launchConfig={{ survey_enabled: false }}
|
||||
surveyConfig={{}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
@ -203,6 +206,60 @@ describe('ScheduleList', () => {
|
||||
wrapper.update();
|
||||
expect(wrapper.find('ToolbarAddButton').length).toBe(0);
|
||||
});
|
||||
test('should show missing resource icon and disabled toggle', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ScheduleList
|
||||
loadSchedules={loadSchedules}
|
||||
loadScheduleOptions={loadScheduleOptions}
|
||||
hideAddButton
|
||||
resource={{ type: 'job_template', inventory: 1 }}
|
||||
launchConfig={{ survey_enabled: true }}
|
||||
surveyConfig={{ spec: [{ required: true, default: null }] }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper
|
||||
.find('ScheduleListItem')
|
||||
.at(4)
|
||||
.prop('isMissingSurvey')
|
||||
).toBe('This schedule is missing required survey values');
|
||||
expect(wrapper.find('ExclamationTriangleIcon').length).toBe(5);
|
||||
expect(wrapper.find('Switch#schedule-5-toggle').prop('isDisabled')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
test('should show missing resource icon and disabled toggle', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ScheduleList
|
||||
loadSchedules={loadSchedules}
|
||||
loadScheduleOptions={loadScheduleOptions}
|
||||
hideAddButton
|
||||
resource={{ type: 'job_template' }}
|
||||
launchConfig={{
|
||||
survey_enabled: true,
|
||||
inventory_needed_to_start: true,
|
||||
}}
|
||||
surveyConfig={{ spec: [] }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find('ScheduleListItem')
|
||||
.at(3)
|
||||
.prop('isMissingInventory')
|
||||
).toBe('This schedule is missing an Inventory');
|
||||
expect(wrapper.find('ExclamationTriangleIcon').length).toBe(4);
|
||||
expect(wrapper.find('Switch#schedule-3-toggle').prop('isDisabled')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('read call unsuccessful', () => {
|
||||
|
||||
@ -4,16 +4,33 @@ import { bool, func } from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { Button, Tooltip } from '@patternfly/react-core';
|
||||
import { Tr, Td } from '@patternfly/react-table';
|
||||
import { PencilAltIcon } from '@patternfly/react-icons';
|
||||
import {
|
||||
PencilAltIcon,
|
||||
ExclamationTriangleIcon as PFExclamationTriangleIcon,
|
||||
} from '@patternfly/react-icons';
|
||||
import styled from 'styled-components';
|
||||
import { DetailList, Detail } from '../../DetailList';
|
||||
import { ActionsTd, ActionItem } from '../../PaginatedTable';
|
||||
import { ScheduleToggle } from '..';
|
||||
import { Schedule } from '../../../types';
|
||||
import { formatDateString } from '../../../util/dates';
|
||||
|
||||
function ScheduleListItem({ i18n, isSelected, onSelect, schedule, rowIndex }) {
|
||||
const ExclamationTriangleIcon = styled(PFExclamationTriangleIcon)`
|
||||
color: #c9190b;
|
||||
margin-left: 20px;
|
||||
`;
|
||||
|
||||
function ScheduleListItem({
|
||||
i18n,
|
||||
rowIndex,
|
||||
isSelected,
|
||||
onSelect,
|
||||
schedule,
|
||||
isMissingInventory,
|
||||
isMissingSurvey,
|
||||
}) {
|
||||
const labelId = `check-action-${schedule.id}`;
|
||||
|
||||
const jobTypeLabels = {
|
||||
@ -45,6 +62,7 @@ function ScheduleListItem({ i18n, isSelected, onSelect, schedule, rowIndex }) {
|
||||
default:
|
||||
break;
|
||||
}
|
||||
const isDisabled = Boolean(isMissingInventory || isMissingSurvey);
|
||||
|
||||
return (
|
||||
<Tr id={`schedule-row-${schedule.id}`}>
|
||||
@ -61,6 +79,18 @@ function ScheduleListItem({ i18n, isSelected, onSelect, schedule, rowIndex }) {
|
||||
<Link to={`${scheduleBaseUrl}/details`}>
|
||||
<b>{schedule.name}</b>
|
||||
</Link>
|
||||
{Boolean(isMissingInventory || isMissingSurvey) && (
|
||||
<span>
|
||||
<Tooltip
|
||||
content={[isMissingInventory, isMissingSurvey].map(message => (
|
||||
<div key={message}>{message}</div>
|
||||
))}
|
||||
position="right"
|
||||
>
|
||||
<ExclamationTriangleIcon />
|
||||
</Tooltip>
|
||||
</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td dataLabel={i18n._(t`Type`)}>
|
||||
{
|
||||
@ -80,7 +110,7 @@ function ScheduleListItem({ i18n, isSelected, onSelect, schedule, rowIndex }) {
|
||||
)}
|
||||
</Td>
|
||||
<ActionsTd dataLabel={i18n._(t`Actions`)} gridColumns="auto 40px">
|
||||
<ScheduleToggle schedule={schedule} />
|
||||
<ScheduleToggle schedule={schedule} isDisabled={isDisabled} />
|
||||
<ActionItem
|
||||
visible={schedule.summary_fields.user_capabilities.edit}
|
||||
tooltip={i18n._(t`Edit Schedule`)}
|
||||
|
||||
@ -50,15 +50,13 @@ describe('ScheduleListItem', () => {
|
||||
describe('User has edit permissions', () => {
|
||||
beforeAll(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<ScheduleListItem
|
||||
isSelected={false}
|
||||
onSelect={onSelect}
|
||||
schedule={mockSchedule}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
<ScheduleListItem
|
||||
isSelected={false}
|
||||
onSelect={onSelect}
|
||||
schedule={mockSchedule}
|
||||
isMissingSurvey={false}
|
||||
isMissingInventory={false}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@ -118,6 +116,9 @@ describe('ScheduleListItem', () => {
|
||||
.simulate('change');
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
test('Toggle button is enabled', () => {
|
||||
expect(wrapper.find('ScheduleToggle').prop('isDisabled')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('User has read-only permissions', () => {
|
||||
@ -186,4 +187,35 @@ describe('ScheduleListItem', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
describe('schedule has missing prompt data', () => {
|
||||
beforeAll(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<ScheduleListItem
|
||||
isSelected={false}
|
||||
onSelect={onSelect}
|
||||
schedule={{
|
||||
...mockSchedule,
|
||||
summary_fields: {
|
||||
...mockSchedule.summary_fields,
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
delete: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
isMissingInventory="Inventory Error"
|
||||
isMissingSurvey="Survey Error"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('should show missing resource icon', () => {
|
||||
expect(wrapper.find('ExclamationTriangleIcon').length).toBe(1);
|
||||
expect(wrapper.find('ScheduleToggle').prop('isDisabled')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -8,7 +8,7 @@ import ErrorDetail from '../../ErrorDetail';
|
||||
import useRequest from '../../../util/useRequest';
|
||||
import { SchedulesAPI } from '../../../api';
|
||||
|
||||
function ScheduleToggle({ schedule, onToggle, className, i18n }) {
|
||||
function ScheduleToggle({ schedule, onToggle, className, i18n, isDisabled }) {
|
||||
const [isEnabled, setIsEnabled] = useState(schedule.enabled);
|
||||
const [showError, setShowError] = useState(false);
|
||||
|
||||
@ -55,7 +55,9 @@ function ScheduleToggle({ schedule, onToggle, className, i18n }) {
|
||||
labelOff={i18n._(t`Off`)}
|
||||
isChecked={isEnabled}
|
||||
isDisabled={
|
||||
isLoading || !schedule.summary_fields.user_capabilities.edit
|
||||
isLoading ||
|
||||
!schedule.summary_fields.user_capabilities.edit ||
|
||||
isDisabled
|
||||
}
|
||||
onChange={toggleSchedule}
|
||||
aria-label={i18n._(t`Toggle schedule`)}
|
||||
|
||||
@ -6,28 +6,49 @@ import ScheduleAdd from './ScheduleAdd';
|
||||
import ScheduleList from './ScheduleList';
|
||||
|
||||
function Schedules({
|
||||
createSchedule,
|
||||
apiModel,
|
||||
loadScheduleOptions,
|
||||
loadSchedules,
|
||||
setBreadcrumb,
|
||||
unifiedJobTemplate,
|
||||
launchConfig,
|
||||
surveyConfig,
|
||||
resource,
|
||||
}) {
|
||||
const match = useRouteMatch();
|
||||
|
||||
// For some management jobs that delete data, we want to provide an additional
|
||||
// field on the scheduler for configuring the number of days to retain.
|
||||
const hasDaysToKeepField = [
|
||||
'cleanup_activitystream',
|
||||
'cleanup_jobs',
|
||||
].includes(resource?.job_type);
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Route path={`${match.path}/add`}>
|
||||
<ScheduleAdd createSchedule={createSchedule} />
|
||||
<ScheduleAdd
|
||||
hasDaysToKeepField={hasDaysToKeepField}
|
||||
apiModel={apiModel}
|
||||
resource={resource}
|
||||
launchConfig={launchConfig}
|
||||
surveyConfig={surveyConfig}
|
||||
/>
|
||||
</Route>
|
||||
<Route key="details" path={`${match.path}/:scheduleId`}>
|
||||
<Schedule
|
||||
unifiedJobTemplate={unifiedJobTemplate}
|
||||
hasDaysToKeepField={hasDaysToKeepField}
|
||||
setBreadcrumb={setBreadcrumb}
|
||||
resource={resource}
|
||||
launchConfig={launchConfig}
|
||||
surveyConfig={surveyConfig}
|
||||
/>
|
||||
</Route>
|
||||
<Route key="list" path={`${match.path}`}>
|
||||
<ScheduleList
|
||||
resource={resource}
|
||||
loadSchedules={loadSchedules}
|
||||
launchConfig={launchConfig}
|
||||
surveyConfig={surveyConfig}
|
||||
loadScheduleOptions={loadScheduleOptions}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
"rrule":
|
||||
"DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1",
|
||||
"id": 1,
|
||||
"extra_data":{},
|
||||
"summary_fields": {
|
||||
"unified_job_template": {
|
||||
"id": 6,
|
||||
@ -27,6 +28,7 @@
|
||||
"rrule":
|
||||
"DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1",
|
||||
"id": 2,
|
||||
"extra_data":{},
|
||||
"summary_fields": {
|
||||
"unified_job_template": {
|
||||
"id": 7,
|
||||
@ -46,6 +48,7 @@
|
||||
"rrule":
|
||||
"DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1",
|
||||
"id": 3,
|
||||
"extra_data":{},
|
||||
"summary_fields": {
|
||||
"unified_job_template": {
|
||||
"id": 8,
|
||||
@ -65,6 +68,7 @@
|
||||
"rrule":
|
||||
"DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1",
|
||||
"id": 4,
|
||||
"extra_data":{},
|
||||
"summary_fields": {
|
||||
"unified_job_template": {
|
||||
"id": 9,
|
||||
@ -84,6 +88,7 @@
|
||||
"rrule":
|
||||
"DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1",
|
||||
"id": 5,
|
||||
"extra_data":{"novalue":null},
|
||||
"summary_fields": {
|
||||
"unified_job_template": {
|
||||
"id": 10,
|
||||
@ -103,4 +108,4 @@
|
||||
"next_run": "2020-02-20T05:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,22 +1,33 @@
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import React, { useEffect, useCallback, useState } from 'react';
|
||||
import { shape, func } from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Formik, useField } from 'formik';
|
||||
import { RRule } from 'rrule';
|
||||
import { Form, FormGroup, Title } from '@patternfly/react-core';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
FormGroup,
|
||||
Title,
|
||||
ActionGroup,
|
||||
} from '@patternfly/react-core';
|
||||
import { Config } from '../../../contexts/Config';
|
||||
import { SchedulesAPI } from '../../../api';
|
||||
import AnsibleSelect from '../../AnsibleSelect';
|
||||
import ContentError from '../../ContentError';
|
||||
import ContentLoading from '../../ContentLoading';
|
||||
import FormActionGroup from '../../FormActionGroup/FormActionGroup';
|
||||
import FormField, { FormSubmitError } from '../../FormField';
|
||||
import { FormColumnLayout, SubFormLayout } from '../../FormLayout';
|
||||
import {
|
||||
FormColumnLayout,
|
||||
SubFormLayout,
|
||||
FormFullWidthLayout,
|
||||
} from '../../FormLayout';
|
||||
import { dateToInputDateTime, formatDateStringUTC } from '../../../util/dates';
|
||||
import useRequest from '../../../util/useRequest';
|
||||
import { required } from '../../../util/validators';
|
||||
import { parseVariableField } from '../../../util/yaml';
|
||||
import FrequencyDetailSubform from './FrequencyDetailSubform';
|
||||
import SchedulePromptableFields from './SchedulePromptableFields';
|
||||
|
||||
const generateRunOnTheDay = (days = []) => {
|
||||
if (
|
||||
@ -67,7 +78,7 @@ const generateRunOnTheDay = (days = []) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
function ScheduleFormFields({ i18n, zoneOptions }) {
|
||||
function ScheduleFormFields({ i18n, hasDaysToKeepField, zoneOptions }) {
|
||||
const [startDateTime, startDateTimeMeta] = useField({
|
||||
name: 'startDateTime',
|
||||
validate: required(
|
||||
@ -159,6 +170,16 @@ function ScheduleFormFields({ i18n, zoneOptions }) {
|
||||
{...frequency}
|
||||
/>
|
||||
</FormGroup>
|
||||
{hasDaysToKeepField ? (
|
||||
<FormField
|
||||
id="schedule-days-to-keep"
|
||||
label={i18n._(t`Days of Data to Keep`)}
|
||||
name="daysToKeep"
|
||||
type="number"
|
||||
validate={required(null, i18n)}
|
||||
isRequired
|
||||
/>
|
||||
) : null}
|
||||
{frequency.value !== 'none' && (
|
||||
<SubFormLayout>
|
||||
<Title size="md" headingLevel="h4">
|
||||
@ -174,13 +195,20 @@ function ScheduleFormFields({ i18n, zoneOptions }) {
|
||||
}
|
||||
|
||||
function ScheduleForm({
|
||||
hasDaysToKeepField,
|
||||
handleCancel,
|
||||
handleSubmit,
|
||||
i18n,
|
||||
schedule,
|
||||
submitError,
|
||||
resource,
|
||||
launchConfig,
|
||||
surveyConfig,
|
||||
...rest
|
||||
}) {
|
||||
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
||||
const [isSaveDisabled, setIsSaveDisabled] = useState(false);
|
||||
|
||||
let rruleError;
|
||||
const now = new Date();
|
||||
const closestQuarterHour = new Date(
|
||||
@ -189,6 +217,113 @@ function ScheduleForm({
|
||||
const tomorrow = new Date(closestQuarterHour);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
const isTemplate =
|
||||
resource.type === 'workflow_job_template' ||
|
||||
resource.type === 'job_template';
|
||||
const {
|
||||
request: loadScheduleData,
|
||||
error: contentError,
|
||||
contentLoading,
|
||||
result: { zoneOptions, credentials },
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data } = await SchedulesAPI.readZoneInfo();
|
||||
|
||||
let creds;
|
||||
if (schedule.id) {
|
||||
const {
|
||||
data: { results },
|
||||
} = await SchedulesAPI.readCredentials(schedule.id);
|
||||
creds = results;
|
||||
}
|
||||
|
||||
const zones = data.map(zone => {
|
||||
return {
|
||||
value: zone.name,
|
||||
key: zone.name,
|
||||
label: zone.name,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
zoneOptions: zones,
|
||||
credentials: creds || [],
|
||||
};
|
||||
}, [schedule]),
|
||||
{
|
||||
zonesOptions: [],
|
||||
credentials: [],
|
||||
}
|
||||
);
|
||||
const missingRequiredInventory = useCallback(() => {
|
||||
let missingInventory = false;
|
||||
if (
|
||||
launchConfig.inventory_needed_to_start &&
|
||||
!schedule?.summary_fields?.inventory?.id
|
||||
) {
|
||||
missingInventory = true;
|
||||
}
|
||||
return missingInventory;
|
||||
}, [launchConfig, schedule]);
|
||||
|
||||
const hasMissingSurveyValue = useCallback(() => {
|
||||
let missingValues = false;
|
||||
if (launchConfig?.survey_enabled) {
|
||||
surveyConfig.spec.forEach(question => {
|
||||
const hasDefaultValue = Boolean(question.default);
|
||||
const hasSchedule = Object.keys(schedule).length;
|
||||
const isRequired = question.required;
|
||||
if (isRequired && !hasDefaultValue) {
|
||||
if (!hasSchedule) {
|
||||
missingValues = true;
|
||||
} else {
|
||||
const hasMatchingKey = Object.keys(schedule?.extra_data).includes(
|
||||
question.variable
|
||||
);
|
||||
Object.values(schedule?.extra_data).forEach(value => {
|
||||
if (!value || !hasMatchingKey) {
|
||||
missingValues = true;
|
||||
} else {
|
||||
missingValues = false;
|
||||
}
|
||||
});
|
||||
if (!Object.values(schedule.extra_data).length) {
|
||||
missingValues = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return missingValues;
|
||||
}, [launchConfig, schedule, surveyConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTemplate && (missingRequiredInventory() || hasMissingSurveyValue())) {
|
||||
setIsSaveDisabled(true);
|
||||
}
|
||||
}, [isTemplate, hasMissingSurveyValue, missingRequiredInventory]);
|
||||
|
||||
useEffect(() => {
|
||||
loadScheduleData();
|
||||
}, [loadScheduleData]);
|
||||
|
||||
let showPromptButton = false;
|
||||
|
||||
if (
|
||||
launchConfig &&
|
||||
(launchConfig.ask_inventory_on_launch ||
|
||||
launchConfig.ask_variables_on_launch ||
|
||||
launchConfig.ask_job_type_on_launch ||
|
||||
launchConfig.ask_limit_on_launch ||
|
||||
launchConfig.ask_credential_on_launch ||
|
||||
launchConfig.ask_scm_branch_on_launch ||
|
||||
launchConfig.survey_enabled ||
|
||||
launchConfig.inventory_needed_to_start ||
|
||||
launchConfig.variables_needed_to_start?.length > 0)
|
||||
) {
|
||||
showPromptButton = true;
|
||||
}
|
||||
|
||||
const initialValues = {
|
||||
daysOfWeek: [],
|
||||
description: schedule.description || '',
|
||||
@ -207,6 +342,35 @@ function ScheduleForm({
|
||||
startDateTime: dateToInputDateTime(closestQuarterHour),
|
||||
timezone: schedule.timezone || 'America/New_York',
|
||||
};
|
||||
const submitSchedule = (
|
||||
values,
|
||||
launchConfiguration,
|
||||
surveyConfiguration,
|
||||
scheduleCredentials
|
||||
) => {
|
||||
handleSubmit(
|
||||
values,
|
||||
launchConfiguration,
|
||||
surveyConfiguration,
|
||||
scheduleCredentials
|
||||
);
|
||||
};
|
||||
|
||||
if (hasDaysToKeepField) {
|
||||
let initialDaysToKeep = 30;
|
||||
if (schedule?.extra_data) {
|
||||
if (
|
||||
typeof schedule?.extra_data === 'string' &&
|
||||
schedule?.extra_data !== ''
|
||||
) {
|
||||
initialDaysToKeep = parseVariableField(schedule?.extra_data).days;
|
||||
}
|
||||
if (typeof schedule?.extra_data === 'object') {
|
||||
initialDaysToKeep = schedule?.extra_data?.days;
|
||||
}
|
||||
}
|
||||
initialValues.daysToKeep = initialDaysToKeep;
|
||||
}
|
||||
|
||||
const overriddenValues = {};
|
||||
|
||||
@ -297,28 +461,6 @@ function ScheduleForm({
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
request: loadZoneInfo,
|
||||
error: contentError,
|
||||
contentLoading,
|
||||
result: zoneOptions,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data } = await SchedulesAPI.readZoneInfo();
|
||||
return data.map(zone => {
|
||||
return {
|
||||
value: zone.name,
|
||||
key: zone.name,
|
||||
label: zone.name,
|
||||
};
|
||||
});
|
||||
}, [])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadZoneInfo();
|
||||
}, [loadZoneInfo]);
|
||||
|
||||
if (contentError || rruleError) {
|
||||
return <ContentError error={contentError || rruleError} />;
|
||||
}
|
||||
@ -333,7 +475,9 @@ function ScheduleForm({
|
||||
return (
|
||||
<Formik
|
||||
initialValues={Object.assign(initialValues, overriddenValues)}
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={values => {
|
||||
submitSchedule(values, launchConfig, surveyConfig, credentials);
|
||||
}}
|
||||
validate={values => {
|
||||
const errors = {};
|
||||
const {
|
||||
@ -371,15 +515,61 @@ function ScheduleForm({
|
||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||
<FormColumnLayout>
|
||||
<ScheduleFormFields
|
||||
hasDaysToKeepField={hasDaysToKeepField}
|
||||
i18n={i18n}
|
||||
zoneOptions={zoneOptions}
|
||||
{...rest}
|
||||
/>
|
||||
{isWizardOpen && (
|
||||
<SchedulePromptableFields
|
||||
schedule={schedule}
|
||||
credentials={credentials}
|
||||
surveyConfig={surveyConfig}
|
||||
launchConfig={launchConfig}
|
||||
resource={resource}
|
||||
onCloseWizard={hasErrors => {
|
||||
setIsWizardOpen(false);
|
||||
setIsSaveDisabled(hasErrors);
|
||||
}}
|
||||
onSave={() => {
|
||||
setIsWizardOpen(false);
|
||||
setIsSaveDisabled(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<FormSubmitError error={submitError} />
|
||||
<FormActionGroup
|
||||
onCancel={handleCancel}
|
||||
onSubmit={formik.handleSubmit}
|
||||
/>
|
||||
<FormFullWidthLayout>
|
||||
<ActionGroup>
|
||||
<Button
|
||||
aria-label={i18n._(t`Save`)}
|
||||
variant="primary"
|
||||
type="button"
|
||||
onClick={formik.handleSubmit}
|
||||
isDisabled={isSaveDisabled}
|
||||
>
|
||||
{i18n._(t`Save`)}
|
||||
</Button>
|
||||
|
||||
{isTemplate && showPromptButton && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
aria-label={i18n._(t`Prompt`)}
|
||||
onClick={() => setIsWizardOpen(true)}
|
||||
>
|
||||
{i18n._(t`Prompt`)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
aria-label={i18n._(t`Cancel`)}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
{i18n._(t`Cancel`)}
|
||||
</Button>
|
||||
</ActionGroup>
|
||||
</FormFullWidthLayout>
|
||||
</FormColumnLayout>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
@ -1,11 +1,53 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import { SchedulesAPI } from '../../../api';
|
||||
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
import { SchedulesAPI, JobTemplatesAPI, InventoriesAPI } from '../../../api';
|
||||
import ScheduleForm from './ScheduleForm';
|
||||
|
||||
jest.mock('../../../api/models/Schedules');
|
||||
jest.mock('../../../api/models/JobTemplates');
|
||||
jest.mock('../../../api/models/Inventories');
|
||||
|
||||
const credentials = {
|
||||
data: {
|
||||
results: [
|
||||
{ id: 1, kind: 'cloud', name: 'Cred 1', url: 'www.google.com' },
|
||||
{ id: 2, kind: 'ssh', name: 'Cred 2', url: 'www.google.com' },
|
||||
{ id: 3, kind: 'Ansible', name: 'Cred 3', url: 'www.google.com' },
|
||||
{ id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' },
|
||||
{ id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const launchData = {
|
||||
data: {
|
||||
can_start_without_user_input: false,
|
||||
passwords_needed_to_start: [],
|
||||
ask_scm_branch_on_launch: false,
|
||||
ask_variables_on_launch: false,
|
||||
ask_tags_on_launch: false,
|
||||
ask_diff_mode_on_launch: false,
|
||||
ask_skip_tags_on_launch: false,
|
||||
ask_job_type_on_launch: false,
|
||||
ask_limit_on_launch: false,
|
||||
ask_verbosity_on_launch: false,
|
||||
ask_inventory_on_launch: true,
|
||||
ask_credential_on_launch: false,
|
||||
survey_enabled: false,
|
||||
variables_needed_to_start: [],
|
||||
credential_needed_to_start: false,
|
||||
inventory_needed_to_start: true,
|
||||
job_template_data: {
|
||||
name: 'Demo Job Template',
|
||||
id: 7,
|
||||
description: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
const mockSchedule = {
|
||||
rrule:
|
||||
'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;COUNT=1;FREQ=MINUTELY',
|
||||
@ -23,7 +65,7 @@ const mockSchedule = {
|
||||
name: 'mock schedule',
|
||||
description: 'test description',
|
||||
extra_data: {},
|
||||
inventory: null,
|
||||
inventory: 1,
|
||||
scm_branch: null,
|
||||
job_type: null,
|
||||
job_tags: null,
|
||||
@ -82,7 +124,34 @@ describe('<ScheduleForm />', () => {
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ScheduleForm handleSubmit={jest.fn()} handleCancel={jest.fn()} />
|
||||
<ScheduleForm
|
||||
handleSubmit={jest.fn()}
|
||||
handleCancel={jest.fn()}
|
||||
launchConfig={{
|
||||
can_start_without_user_input: false,
|
||||
passwords_needed_to_start: [],
|
||||
ask_scm_branch_on_launch: false,
|
||||
ask_variables_on_launch: false,
|
||||
ask_tags_on_launch: false,
|
||||
ask_diff_mode_on_launch: false,
|
||||
ask_skip_tags_on_launch: false,
|
||||
ask_job_type_on_launch: false,
|
||||
ask_limit_on_launch: false,
|
||||
ask_verbosity_on_launch: false,
|
||||
ask_inventory_on_launch: true,
|
||||
ask_credential_on_launch: false,
|
||||
survey_enabled: false,
|
||||
variables_needed_to_start: [],
|
||||
credential_needed_to_start: false,
|
||||
inventory_needed_to_start: true,
|
||||
job_template_data: {
|
||||
name: 'Demo Job Template',
|
||||
id: 7,
|
||||
description: '',
|
||||
},
|
||||
}}
|
||||
resource={{ id: 23, type: 'job_template' }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
@ -92,6 +161,9 @@ describe('<ScheduleForm />', () => {
|
||||
describe('Cancel', () => {
|
||||
test('should make the appropriate callback', async () => {
|
||||
const handleCancel = jest.fn();
|
||||
JobTemplatesAPI.readLaunch.mockResolvedValue(launchData);
|
||||
|
||||
SchedulesAPI.readCredentials.mockResolvedValue(credentials);
|
||||
SchedulesAPI.readZoneInfo.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
@ -101,7 +173,34 @@ describe('<ScheduleForm />', () => {
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ScheduleForm handleSubmit={jest.fn()} handleCancel={handleCancel} />
|
||||
<ScheduleForm
|
||||
handleSubmit={jest.fn()}
|
||||
handleCancel={handleCancel}
|
||||
launchConfig={{
|
||||
can_start_without_user_input: false,
|
||||
passwords_needed_to_start: [],
|
||||
ask_scm_branch_on_launch: false,
|
||||
ask_variables_on_launch: false,
|
||||
ask_tags_on_launch: false,
|
||||
ask_diff_mode_on_launch: false,
|
||||
ask_skip_tags_on_launch: false,
|
||||
ask_job_type_on_launch: false,
|
||||
ask_limit_on_launch: false,
|
||||
ask_verbosity_on_launch: false,
|
||||
ask_inventory_on_launch: true,
|
||||
ask_credential_on_launch: false,
|
||||
survey_enabled: false,
|
||||
variables_needed_to_start: [],
|
||||
credential_needed_to_start: false,
|
||||
inventory_needed_to_start: true,
|
||||
job_template_data: {
|
||||
name: 'Demo Job Template',
|
||||
id: 7,
|
||||
description: '',
|
||||
},
|
||||
}}
|
||||
resource={{ id: 23, type: 'job_template', inventory: 1 }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
@ -111,6 +210,201 @@ describe('<ScheduleForm />', () => {
|
||||
expect(handleCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
describe('Prompted Schedule', () => {
|
||||
let promptWrapper;
|
||||
beforeEach(async () => {
|
||||
SchedulesAPI.readZoneInfo.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
name: 'America/New_York',
|
||||
},
|
||||
],
|
||||
});
|
||||
await act(async () => {
|
||||
promptWrapper = mountWithContexts(
|
||||
<ScheduleForm
|
||||
handleSubmit={jest.fn()}
|
||||
handleCancel={jest.fn()}
|
||||
resource={{
|
||||
id: 23,
|
||||
type: 'job_template',
|
||||
inventory: 1,
|
||||
summary_fields: {
|
||||
credentials: [],
|
||||
},
|
||||
}}
|
||||
launchConfig={{
|
||||
can_start_without_user_input: false,
|
||||
passwords_needed_to_start: [],
|
||||
ask_scm_branch_on_launch: false,
|
||||
ask_variables_on_launch: false,
|
||||
ask_tags_on_launch: false,
|
||||
ask_diff_mode_on_launch: false,
|
||||
ask_skip_tags_on_launch: false,
|
||||
ask_job_type_on_launch: false,
|
||||
ask_limit_on_launch: false,
|
||||
ask_verbosity_on_launch: false,
|
||||
ask_inventory_on_launch: true,
|
||||
ask_credential_on_launch: false,
|
||||
survey_enabled: false,
|
||||
variables_needed_to_start: [],
|
||||
credential_needed_to_start: false,
|
||||
inventory_needed_to_start: true,
|
||||
job_template_data: {
|
||||
name: 'Demo Job Template',
|
||||
id: 7,
|
||||
description: '',
|
||||
},
|
||||
}}
|
||||
surveyConfig={{ spec: [{ required: true, default: '' }] }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
waitForElement(
|
||||
promptWrapper,
|
||||
'Button[aria-label="Prompt"]',
|
||||
el => el.length > 0
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
promptWrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should open prompt modal with proper steps and default values', async () => {
|
||||
await act(async () =>
|
||||
promptWrapper.find('Button[aria-label="Prompt"]').prop('onClick')()
|
||||
);
|
||||
promptWrapper.update();
|
||||
waitForElement(promptWrapper, 'Wizard', el => el.length > 0);
|
||||
expect(promptWrapper.find('Wizard').length).toBe(1);
|
||||
expect(promptWrapper.find('StepName#inventory-step').length).toBe(2);
|
||||
expect(promptWrapper.find('StepName#preview-step').length).toBe(1);
|
||||
expect(promptWrapper.find('WizardNavItem').length).toBe(2);
|
||||
});
|
||||
|
||||
test('should render disabled save button due to missing required surevy values', () => {
|
||||
expect(
|
||||
promptWrapper.find('Button[aria-label="Save"]').prop('isDisabled')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('should update prompt modal data', async () => {
|
||||
InventoriesAPI.read.mockResolvedValue({
|
||||
data: {
|
||||
count: 2,
|
||||
results: [
|
||||
{
|
||||
name: 'Foo',
|
||||
id: 1,
|
||||
url: '',
|
||||
},
|
||||
{
|
||||
name: 'Bar',
|
||||
id: 2,
|
||||
url: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
InventoriesAPI.readOptions.mockResolvedValue({
|
||||
data: {
|
||||
related_search_fields: [],
|
||||
actions: {
|
||||
GET: {
|
||||
filterable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () =>
|
||||
promptWrapper.find('Button[aria-label="Prompt"]').prop('onClick')()
|
||||
);
|
||||
promptWrapper.update();
|
||||
expect(
|
||||
promptWrapper
|
||||
.find('WizardNavItem')
|
||||
.at(0)
|
||||
.prop('isCurrent')
|
||||
).toBe(true);
|
||||
await act(async () => {
|
||||
promptWrapper
|
||||
.find('input[aria-labelledby="check-action-item-1"]')
|
||||
.simulate('change', {
|
||||
target: {
|
||||
checked: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
promptWrapper.update();
|
||||
expect(
|
||||
promptWrapper
|
||||
.find('input[aria-labelledby="check-action-item-1"]')
|
||||
.prop('checked')
|
||||
).toBe(true);
|
||||
await act(async () =>
|
||||
promptWrapper.find('WizardFooterInternal').prop('onNext')()
|
||||
);
|
||||
promptWrapper.update();
|
||||
expect(
|
||||
promptWrapper
|
||||
.find('WizardNavItem')
|
||||
.at(1)
|
||||
.prop('isCurrent')
|
||||
).toBe(true);
|
||||
await act(async () =>
|
||||
promptWrapper.find('WizardFooterInternal').prop('onNext')()
|
||||
);
|
||||
promptWrapper.update();
|
||||
expect(promptWrapper.find('Wizard').length).toBe(0);
|
||||
});
|
||||
test('should render prompt button with disabled save button', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ScheduleForm
|
||||
handleSubmit={jest.fn()}
|
||||
handleCancel={jest.fn()}
|
||||
resource={{
|
||||
id: 23,
|
||||
type: 'job_template',
|
||||
}}
|
||||
launchConfig={{
|
||||
can_start_without_user_input: false,
|
||||
passwords_needed_to_start: [],
|
||||
ask_scm_branch_on_launch: false,
|
||||
ask_variables_on_launch: false,
|
||||
ask_tags_on_launch: false,
|
||||
ask_diff_mode_on_launch: false,
|
||||
ask_skip_tags_on_launch: false,
|
||||
ask_job_type_on_launch: false,
|
||||
ask_limit_on_launch: false,
|
||||
ask_verbosity_on_launch: false,
|
||||
ask_inventory_on_launch: true,
|
||||
ask_credential_on_launch: false,
|
||||
survey_enabled: false,
|
||||
variables_needed_to_start: [],
|
||||
credential_needed_to_start: false,
|
||||
inventory_needed_to_start: true,
|
||||
job_template_data: {
|
||||
name: 'Demo Job Template',
|
||||
id: 7,
|
||||
description: '',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
waitForElement(
|
||||
wrapper,
|
||||
'Button[aria-label="Prompt"]',
|
||||
el => el.length > 0
|
||||
);
|
||||
expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('Add', () => {
|
||||
beforeAll(async () => {
|
||||
SchedulesAPI.readZoneInfo.mockResolvedValue({
|
||||
@ -120,9 +414,37 @@ describe('<ScheduleForm />', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ScheduleForm handleSubmit={jest.fn()} handleCancel={jest.fn()} />
|
||||
<ScheduleForm
|
||||
handleSubmit={jest.fn()}
|
||||
handleCancel={jest.fn()}
|
||||
resource={{ id: 23, type: 'job_template', inventory: 1 }}
|
||||
launchConfig={{
|
||||
can_start_without_user_input: true,
|
||||
passwords_needed_to_start: [],
|
||||
ask_scm_branch_on_launch: false,
|
||||
ask_variables_on_launch: false,
|
||||
ask_tags_on_launch: false,
|
||||
ask_diff_mode_on_launch: false,
|
||||
ask_skip_tags_on_launch: false,
|
||||
ask_job_type_on_launch: false,
|
||||
ask_limit_on_launch: false,
|
||||
ask_verbosity_on_launch: false,
|
||||
ask_inventory_on_launch: false,
|
||||
ask_credential_on_launch: false,
|
||||
survey_enabled: false,
|
||||
variables_needed_to_start: [],
|
||||
credential_needed_to_start: false,
|
||||
inventory_needed_to_start: false,
|
||||
job_template_data: {
|
||||
name: 'Demo Job Template',
|
||||
id: 7,
|
||||
description: '',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -312,6 +634,14 @@ describe('<ScheduleForm />', () => {
|
||||
expect(wrapper.find('select#schedule-run-on-the-month').length).toBe(1);
|
||||
});
|
||||
test('occurrences field properly shown when end after selection is made', async () => {
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find('FormGroup[label="Run frequency"] FormSelect')
|
||||
.invoke('onChange')('minute', {
|
||||
target: { value: 'minute', key: 'minute', label: 'Minute' },
|
||||
});
|
||||
});
|
||||
wrapper.update();
|
||||
await act(async () => {
|
||||
wrapper.find('Radio#end-after').invoke('onChange')('after', {
|
||||
target: { name: 'end' },
|
||||
@ -331,6 +661,14 @@ describe('<ScheduleForm />', () => {
|
||||
wrapper.update();
|
||||
});
|
||||
test('error shown when end date/time comes before start date/time', async () => {
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find('FormGroup[label="Run frequency"] FormSelect')
|
||||
.invoke('onChange')('minute', {
|
||||
target: { value: 'minute', key: 'minute', label: 'Minute' },
|
||||
});
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('input#end-never').prop('checked')).toBe(true);
|
||||
expect(wrapper.find('input#end-after').prop('checked')).toBe(false);
|
||||
expect(wrapper.find('input#end-on-date').prop('checked')).toBe(false);
|
||||
@ -361,13 +699,28 @@ describe('<ScheduleForm />', () => {
|
||||
);
|
||||
});
|
||||
test('error shown when on day number is not between 1 and 31', async () => {
|
||||
await act(async () => {
|
||||
act(() => {
|
||||
wrapper.find('select[id="schedule-frequency"]').invoke('onChange')(
|
||||
{
|
||||
currentTarget: { value: 'month', type: 'change' },
|
||||
target: { name: 'frequency', value: 'month' },
|
||||
},
|
||||
'month'
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
act(() => {
|
||||
wrapper.find('input#schedule-run-on-day-number').simulate('change', {
|
||||
target: { value: 32, name: 'runOnDayNumber' },
|
||||
});
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
expect(
|
||||
wrapper.find('input#schedule-run-on-day-number').prop('value')
|
||||
).toBe(32);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('button[aria-label="Save"]').simulate('click');
|
||||
});
|
||||
@ -379,7 +732,7 @@ describe('<ScheduleForm />', () => {
|
||||
});
|
||||
});
|
||||
describe('Edit', () => {
|
||||
beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
SchedulesAPI.readZoneInfo.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
@ -387,10 +740,113 @@ describe('<ScheduleForm />', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
SchedulesAPI.readCredentials.mockResolvedValue(credentials);
|
||||
});
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should make API calls to fetch credentials, launch configuration, and survey configuration', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ScheduleForm
|
||||
handleSubmit={jest.fn()}
|
||||
handleCancel={jest.fn()}
|
||||
schedule={{ inventory: null, ...mockSchedule }}
|
||||
resource={{ id: 23, type: 'job_template' }}
|
||||
launchConfig={{
|
||||
can_start_without_user_input: true,
|
||||
passwords_needed_to_start: [],
|
||||
ask_scm_branch_on_launch: false,
|
||||
ask_variables_on_launch: false,
|
||||
ask_tags_on_launch: false,
|
||||
ask_diff_mode_on_launch: false,
|
||||
ask_skip_tags_on_launch: false,
|
||||
ask_job_type_on_launch: false,
|
||||
ask_limit_on_launch: false,
|
||||
ask_verbosity_on_launch: false,
|
||||
ask_inventory_on_launch: false,
|
||||
ask_credential_on_launch: false,
|
||||
survey_enabled: false,
|
||||
variables_needed_to_start: [],
|
||||
credential_needed_to_start: false,
|
||||
inventory_needed_to_start: false,
|
||||
job_template_data: {
|
||||
name: 'Demo Job Template',
|
||||
id: 7,
|
||||
description: '',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
expect(SchedulesAPI.readCredentials).toBeCalledWith(27);
|
||||
});
|
||||
|
||||
test('should not call API to get credentials ', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ScheduleForm
|
||||
handleSubmit={jest.fn()}
|
||||
handleCancel={jest.fn()}
|
||||
resource={{ id: 23, type: 'job_template' }}
|
||||
launchConfig={{
|
||||
can_start_without_user_input: true,
|
||||
passwords_needed_to_start: [],
|
||||
ask_scm_branch_on_launch: false,
|
||||
ask_variables_on_launch: false,
|
||||
ask_tags_on_launch: false,
|
||||
ask_diff_mode_on_launch: false,
|
||||
ask_skip_tags_on_launch: false,
|
||||
ask_job_type_on_launch: false,
|
||||
ask_limit_on_launch: false,
|
||||
ask_verbosity_on_launch: false,
|
||||
ask_inventory_on_launch: false,
|
||||
ask_credential_on_launch: false,
|
||||
survey_enabled: false,
|
||||
variables_needed_to_start: [],
|
||||
credential_needed_to_start: false,
|
||||
inventory_needed_to_start: false,
|
||||
job_template_data: {
|
||||
name: 'Demo Job Template',
|
||||
id: 7,
|
||||
description: '',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
expect(SchedulesAPI.readCredentials).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('should render prompt button with enabled save button for project', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ScheduleForm
|
||||
handleSubmit={jest.fn()}
|
||||
handleCancel={jest.fn()}
|
||||
resource={{
|
||||
id: 23,
|
||||
type: 'project',
|
||||
inventory: 2,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
waitForElement(
|
||||
wrapper,
|
||||
'Button[aria-label="Prompt"]',
|
||||
el => el.length > 0
|
||||
);
|
||||
|
||||
expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('initially renders expected fields and values with existing schedule that runs once', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
@ -398,6 +854,8 @@ describe('<ScheduleForm />', () => {
|
||||
handleSubmit={jest.fn()}
|
||||
handleCancel={jest.fn()}
|
||||
schedule={mockSchedule}
|
||||
launchConfig={{ inventory_needed_to_start: false }}
|
||||
resource={{ id: 23, type: 'job_template' }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@ -421,11 +879,13 @@ describe('<ScheduleForm />', () => {
|
||||
<ScheduleForm
|
||||
handleSubmit={jest.fn()}
|
||||
handleCancel={jest.fn()}
|
||||
launchConfig={{ inventory_needed_to_start: false }}
|
||||
schedule={Object.assign(mockSchedule, {
|
||||
rrule:
|
||||
'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=10;FREQ=MINUTELY',
|
||||
dtend: null,
|
||||
})}
|
||||
resource={{ id: 23, type: 'job_template' }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@ -453,12 +913,14 @@ describe('<ScheduleForm />', () => {
|
||||
<ScheduleForm
|
||||
handleSubmit={jest.fn()}
|
||||
handleCancel={jest.fn()}
|
||||
launchConfig={{ inventory_needed_to_start: false }}
|
||||
schedule={Object.assign(mockSchedule, {
|
||||
rrule:
|
||||
'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;FREQ=HOURLY;COUNT=10',
|
||||
dtend: '2020-04-03T03:45:00Z',
|
||||
until: '',
|
||||
})}
|
||||
resource={{ id: 23, type: 'job_template' }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@ -487,12 +949,14 @@ describe('<ScheduleForm />', () => {
|
||||
<ScheduleForm
|
||||
handleSubmit={jest.fn()}
|
||||
handleCancel={jest.fn()}
|
||||
launchConfig={{ inventory_needed_to_start: false }}
|
||||
schedule={Object.assign(mockSchedule, {
|
||||
rrule:
|
||||
'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;FREQ=DAILY',
|
||||
dtend: null,
|
||||
until: '',
|
||||
})}
|
||||
resource={{ id: 23, type: 'job_template' }}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('ScheduleForm').length).toBe(1);
|
||||
@ -520,12 +984,14 @@ describe('<ScheduleForm />', () => {
|
||||
<ScheduleForm
|
||||
handleSubmit={jest.fn()}
|
||||
handleCancel={jest.fn()}
|
||||
launchConfig={{ inventory_needed_to_start: false }}
|
||||
schedule={Object.assign(mockSchedule, {
|
||||
rrule:
|
||||
'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20210101T050000Z',
|
||||
dtend: '2020-10-30T18:45:00Z',
|
||||
until: '2021-01-01T00:00:00',
|
||||
})}
|
||||
resource={{ id: 23, type: 'job_template' }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@ -577,12 +1043,14 @@ describe('<ScheduleForm />', () => {
|
||||
<ScheduleForm
|
||||
handleSubmit={jest.fn()}
|
||||
handleCancel={jest.fn()}
|
||||
launchConfig={{ inventory_needed_to_start: false }}
|
||||
schedule={Object.assign(mockSchedule, {
|
||||
rrule:
|
||||
'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;FREQ=MONTHLY;BYSETPOS=-1;BYDAY=MO,TU,WE,TH,FR',
|
||||
dtend: null,
|
||||
until: '',
|
||||
})}
|
||||
resource={{ id: 23, type: 'job_template' }}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('ScheduleForm').length).toBe(1);
|
||||
@ -622,12 +1090,14 @@ describe('<ScheduleForm />', () => {
|
||||
<ScheduleForm
|
||||
handleSubmit={jest.fn()}
|
||||
handleCancel={jest.fn()}
|
||||
launchConfig={{ inventory_needed_to_start: false }}
|
||||
schedule={Object.assign(mockSchedule, {
|
||||
rrule:
|
||||
'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;FREQ=YEARLY;BYMONTH=5;BYMONTHDAY=6',
|
||||
dtend: null,
|
||||
until: '',
|
||||
})}
|
||||
resource={{ id: 23, type: 'job_template' }}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('ScheduleForm').length).toBe(1);
|
||||
|
||||
@ -0,0 +1,127 @@
|
||||
import React from 'react';
|
||||
import { Wizard } from '@patternfly/react-core';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { useFormikContext } from 'formik';
|
||||
import AlertModal from '../../AlertModal';
|
||||
import { useDismissableError } from '../../../util/useRequest';
|
||||
import ContentError from '../../ContentError';
|
||||
import ContentLoading from '../../ContentLoading';
|
||||
import useSchedulePromptSteps from './useSchedulePromptSteps';
|
||||
|
||||
function SchedulePromptableFields({
|
||||
schedule,
|
||||
surveyConfig,
|
||||
launchConfig,
|
||||
onCloseWizard,
|
||||
onSave,
|
||||
credentials,
|
||||
resource,
|
||||
i18n,
|
||||
}) {
|
||||
const {
|
||||
validateForm,
|
||||
setFieldTouched,
|
||||
values,
|
||||
initialValues,
|
||||
resetForm,
|
||||
} = useFormikContext();
|
||||
const {
|
||||
steps,
|
||||
visitStep,
|
||||
visitAllSteps,
|
||||
validateStep,
|
||||
contentError,
|
||||
isReady,
|
||||
} = useSchedulePromptSteps(
|
||||
surveyConfig,
|
||||
launchConfig,
|
||||
schedule,
|
||||
resource,
|
||||
i18n,
|
||||
credentials
|
||||
);
|
||||
|
||||
const { error, dismissError } = useDismissableError(contentError);
|
||||
const cancelPromptableValues = async () => {
|
||||
const hasErrors = await validateForm();
|
||||
resetForm({
|
||||
values: {
|
||||
...initialValues,
|
||||
daysOfWeek: values.daysOfWeek,
|
||||
description: values.description,
|
||||
end: values.end,
|
||||
endDateTime: values.endDateTime,
|
||||
frequency: values.frequency,
|
||||
interval: values.interval,
|
||||
name: values.name,
|
||||
occurences: values.occurances,
|
||||
runOn: values.runOn,
|
||||
runOnDayMonth: values.runOnDayMonth,
|
||||
runOnDayNumber: values.runOnDayNumber,
|
||||
runOnTheDay: values.runOnTheDay,
|
||||
runOnTheMonth: values.runOnTheMonth,
|
||||
runOnTheOccurence: values.runOnTheOccurance,
|
||||
startDateTime: values.startDateTime,
|
||||
timezone: values.timezone,
|
||||
},
|
||||
});
|
||||
onCloseWizard(Object.keys(hasErrors).length > 0);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<AlertModal
|
||||
isOpen={error}
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={() => {
|
||||
dismissError();
|
||||
onCloseWizard();
|
||||
}}
|
||||
>
|
||||
<ContentError error={error} />
|
||||
</AlertModal>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Wizard
|
||||
isOpen
|
||||
onClose={cancelPromptableValues}
|
||||
onSave={onSave}
|
||||
onNext={async (nextStep, prevStep) => {
|
||||
if (nextStep.id === 'preview') {
|
||||
visitAllSteps(setFieldTouched);
|
||||
} else {
|
||||
visitStep(prevStep.prevId, setFieldTouched);
|
||||
}
|
||||
await validateForm();
|
||||
}}
|
||||
onGoToStep={async (nextStep, prevStep) => {
|
||||
if (nextStep.id === 'preview') {
|
||||
visitAllSteps(setFieldTouched);
|
||||
} else {
|
||||
visitStep(prevStep.prevId, setFieldTouched);
|
||||
validateStep(nextStep.id);
|
||||
}
|
||||
await validateForm();
|
||||
}}
|
||||
title={i18n._(t`Prompts`)}
|
||||
steps={
|
||||
isReady
|
||||
? steps
|
||||
: [
|
||||
{
|
||||
name: i18n._(t`Content Loading`),
|
||||
component: <ContentLoading />,
|
||||
},
|
||||
]
|
||||
}
|
||||
backButtonText={i18n._(t`Back`)}
|
||||
cancelButtonText={i18n._(t`Cancel`)}
|
||||
nextButtonText={i18n._(t`Next`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(SchedulePromptableFields);
|
||||
@ -0,0 +1,102 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useFormikContext } from 'formik';
|
||||
import { t } from '@lingui/macro';
|
||||
import useInventoryStep from '../../LaunchPrompt/steps/useInventoryStep';
|
||||
import useCredentialsStep from '../../LaunchPrompt/steps/useCredentialsStep';
|
||||
import useOtherPromptsStep from '../../LaunchPrompt/steps/useOtherPromptsStep';
|
||||
import useSurveyStep from '../../LaunchPrompt/steps/useSurveyStep';
|
||||
import usePreviewStep from '../../LaunchPrompt/steps/usePreviewStep';
|
||||
|
||||
export default function useSchedulePromptSteps(
|
||||
surveyConfig,
|
||||
launchConfig,
|
||||
schedule,
|
||||
resource,
|
||||
i18n,
|
||||
scheduleCredentials
|
||||
) {
|
||||
const {
|
||||
summary_fields: { credentials: resourceCredentials },
|
||||
} = resource;
|
||||
const sourceOfValues =
|
||||
(Object.keys(schedule).length > 0 && schedule) || resource;
|
||||
|
||||
sourceOfValues.summary_fields = {
|
||||
credentials: [...(resourceCredentials || []), ...scheduleCredentials],
|
||||
...sourceOfValues.summary_fields,
|
||||
};
|
||||
const { resetForm, values } = useFormikContext();
|
||||
const [visited, setVisited] = useState({});
|
||||
|
||||
const steps = [
|
||||
useInventoryStep(launchConfig, sourceOfValues, i18n, visited),
|
||||
useCredentialsStep(launchConfig, sourceOfValues, i18n),
|
||||
useOtherPromptsStep(launchConfig, sourceOfValues, i18n),
|
||||
useSurveyStep(launchConfig, surveyConfig, sourceOfValues, i18n, visited),
|
||||
];
|
||||
|
||||
const hasErrors = steps.some(step => step.hasError);
|
||||
steps.push(
|
||||
usePreviewStep(
|
||||
launchConfig,
|
||||
i18n,
|
||||
resource,
|
||||
surveyConfig,
|
||||
hasErrors,
|
||||
true,
|
||||
i18n._(t`Save`)
|
||||
)
|
||||
);
|
||||
|
||||
const pfSteps = steps.map(s => s.step).filter(s => s != null);
|
||||
const isReady = !steps.some(s => !s.isReady);
|
||||
|
||||
useEffect(() => {
|
||||
let initialValues = {};
|
||||
if (launchConfig && surveyConfig && isReady) {
|
||||
initialValues = steps.reduce((acc, cur) => {
|
||||
return {
|
||||
...acc,
|
||||
...cur.initialValues,
|
||||
};
|
||||
}, {});
|
||||
}
|
||||
resetForm({
|
||||
values: {
|
||||
...initialValues,
|
||||
...values,
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [launchConfig, surveyConfig, isReady]);
|
||||
|
||||
const stepWithError = steps.find(s => s.contentError);
|
||||
const contentError = stepWithError ? stepWithError.contentError : null;
|
||||
|
||||
return {
|
||||
isReady,
|
||||
validateStep: stepId => {
|
||||
steps.find(s => s?.step?.id === stepId).validate();
|
||||
},
|
||||
steps: pfSteps,
|
||||
visitStep: (prevStepId, setFieldTouched) => {
|
||||
setVisited({
|
||||
...visited,
|
||||
[prevStepId]: true,
|
||||
});
|
||||
steps.find(s => s?.step?.id === prevStepId).setTouched(setFieldTouched);
|
||||
},
|
||||
visitAllSteps: setFieldTouched => {
|
||||
setVisited({
|
||||
inventory: true,
|
||||
credentials: true,
|
||||
other: true,
|
||||
survey: true,
|
||||
preview: true,
|
||||
});
|
||||
steps.forEach(s => s.setTouched(setFieldTouched));
|
||||
},
|
||||
contentError,
|
||||
};
|
||||
}
|
||||
@ -6,7 +6,7 @@ import styled from 'styled-components';
|
||||
import ChipGroup from '../ChipGroup';
|
||||
|
||||
const Split = styled(PFSplit)`
|
||||
margin: 20px 0px;
|
||||
margin: 20px 0 5px 0;
|
||||
align-items: baseline;
|
||||
`;
|
||||
|
||||
|
||||
@ -53,13 +53,21 @@ export default function StatusLabel({ status, tooltipContent = '' }) {
|
||||
const color = colors[status] || 'grey';
|
||||
const Icon = icons[status];
|
||||
|
||||
const renderLabel = () => (
|
||||
<Label variant="outline" color={color} icon={Icon ? <Icon /> : null}>
|
||||
{label}
|
||||
</Label>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip content={tooltipContent} position="top">
|
||||
<Label variant="outline" color={color} icon={Icon ? <Icon /> : null}>
|
||||
{label}
|
||||
</Label>
|
||||
</Tooltip>
|
||||
{tooltipContent ? (
|
||||
<Tooltip content={tooltipContent} position="top">
|
||||
{renderLabel()}
|
||||
</Tooltip>
|
||||
) : (
|
||||
renderLabel()
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ describe('StatusLabel', () => {
|
||||
expect(wrapper.find('CheckCircleIcon')).toHaveLength(1);
|
||||
expect(wrapper.find('Label').prop('color')).toEqual('green');
|
||||
expect(wrapper.text()).toEqual('Success');
|
||||
expect(wrapper.find('Tooltip')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should render failed', () => {
|
||||
@ -58,4 +59,16 @@ describe('StatusLabel', () => {
|
||||
expect(wrapper.find('Label').prop('color')).toEqual('orange');
|
||||
expect(wrapper.text()).toEqual('Canceled');
|
||||
});
|
||||
|
||||
test('should render tooltip', () => {
|
||||
const wrapper = mount(
|
||||
<StatusLabel tooltipContent="Foo" status="success" />
|
||||
);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
expect(wrapper.find('CheckCircleIcon')).toHaveLength(1);
|
||||
expect(wrapper.find('Label').prop('color')).toEqual('green');
|
||||
expect(wrapper.text()).toEqual('Success');
|
||||
expect(wrapper.find('Tooltip')).toHaveLength(1);
|
||||
expect(wrapper.find('Tooltip').prop('content')).toEqual('Foo');
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
export const JOB_TYPE_URL_SEGMENTS = {
|
||||
job: 'playbook',
|
||||
project_update: 'project',
|
||||
system_job: 'system',
|
||||
system_job: 'management',
|
||||
inventory_update: 'inventory',
|
||||
ad_hoc_command: 'command',
|
||||
workflow_job: 'workflow',
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
8068
awx/ui_next/src/locales/es/messages.po
Normal file
8068
awx/ui_next/src/locales/es/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
8068
awx/ui_next/src/locales/fr/messages.po
Normal file
8068
awx/ui_next/src/locales/fr/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
8068
awx/ui_next/src/locales/nl/messages.po
Normal file
8068
awx/ui_next/src/locales/nl/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
8068
awx/ui_next/src/locales/zh/messages.po
Normal file
8068
awx/ui_next/src/locales/zh/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
@ -48,6 +48,7 @@ describe('<ApplicationsList/>', () => {
|
||||
});
|
||||
await waitForElement(wrapper, 'ApplicationsList', el => el.length > 0);
|
||||
});
|
||||
|
||||
test('should have data fetched and render 2 rows', async () => {
|
||||
ApplicationsAPI.read.mockResolvedValue(applications);
|
||||
ApplicationsAPI.readOptions.mockResolvedValue(options);
|
||||
@ -69,14 +70,20 @@ describe('<ApplicationsList/>', () => {
|
||||
waitForElement(wrapper, 'ApplicationsList', el => el.length > 0);
|
||||
|
||||
wrapper
|
||||
.find('input#select-application-1')
|
||||
.find('.pf-c-table__check')
|
||||
.first()
|
||||
.find('input')
|
||||
.simulate('change', applications.data.results[0]);
|
||||
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('input#select-application-1').prop('checked')).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
wrapper
|
||||
.find('.pf-c-table__check')
|
||||
.first()
|
||||
.find('input')
|
||||
.prop('checked')
|
||||
).toBe(true);
|
||||
await act(async () =>
|
||||
wrapper.find('Button[aria-label="Delete"]').prop('onClick')()
|
||||
);
|
||||
@ -131,13 +138,21 @@ describe('<ApplicationsList/>', () => {
|
||||
});
|
||||
waitForElement(wrapper, 'ApplicationsList', el => el.length > 0);
|
||||
|
||||
wrapper.find('input#select-application-1').simulate('change', 'a');
|
||||
wrapper
|
||||
.find('.pf-c-table__check')
|
||||
.first()
|
||||
.find('input')
|
||||
.simulate('change', 'a');
|
||||
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('input#select-application-1').prop('checked')).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
wrapper
|
||||
.find('.pf-c-table__check')
|
||||
.first()
|
||||
.find('input')
|
||||
.prop('checked')
|
||||
).toBe(true);
|
||||
await act(async () =>
|
||||
wrapper.find('Button[aria-label="Delete"]').prop('onClick')()
|
||||
);
|
||||
@ -163,6 +178,7 @@ describe('<ApplicationsList/>', () => {
|
||||
waitForElement(wrapper, 'ApplicationsList', el => el.length > 0);
|
||||
expect(wrapper.find('ToolbarAddButton').length).toBe(0);
|
||||
});
|
||||
|
||||
test('should not render edit button for first list item', async () => {
|
||||
applications.data.results[0].summary_fields.user_capabilities.edit = false;
|
||||
ApplicationsAPI.read.mockResolvedValue(applications);
|
||||
|
||||
@ -1,104 +1,65 @@
|
||||
import React from 'react';
|
||||
import { string, bool, func } from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import {
|
||||
Button,
|
||||
DataListAction as _DataListAction,
|
||||
DataListCheck,
|
||||
DataListItem,
|
||||
DataListItemCells,
|
||||
DataListItemRow,
|
||||
Tooltip,
|
||||
} from '@patternfly/react-core';
|
||||
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { Tr, Td } from '@patternfly/react-table';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { PencilAltIcon } from '@patternfly/react-icons';
|
||||
import { ActionsTd, ActionItem } from '../../../components/PaginatedTable';
|
||||
import { formatDateString } from '../../../util/dates';
|
||||
import { Application } from '../../../types';
|
||||
import DataListCell from '../../../components/DataListCell';
|
||||
|
||||
const DataListAction = styled(_DataListAction)`
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-gap: 16px;
|
||||
grid-template-columns: 40px;
|
||||
`;
|
||||
|
||||
const Label = styled.b`
|
||||
margin-right: 20px;
|
||||
`;
|
||||
|
||||
function ApplicationListItem({
|
||||
application,
|
||||
isSelected,
|
||||
onSelect,
|
||||
detailUrl,
|
||||
rowIndex,
|
||||
i18n,
|
||||
}) {
|
||||
const labelId = `check-action-${application.id}`;
|
||||
return (
|
||||
<DataListItem
|
||||
key={application.id}
|
||||
aria-labelledby={labelId}
|
||||
id={`${application.id}`}
|
||||
>
|
||||
<DataListItemRow>
|
||||
<DataListCheck
|
||||
id={`select-application-${application.id}`}
|
||||
checked={isSelected}
|
||||
onChange={onSelect}
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
<DataListItemCells
|
||||
dataListCells={[
|
||||
<DataListCell
|
||||
key="divider"
|
||||
aria-label={i18n._(t`application name`)}
|
||||
>
|
||||
<Link to={`${detailUrl}`}>
|
||||
<b>{application.name}</b>
|
||||
</Link>
|
||||
</DataListCell>,
|
||||
<DataListCell
|
||||
key="organization"
|
||||
aria-label={i18n._(t`organization name`)}
|
||||
>
|
||||
<Link
|
||||
to={`/organizations/${application.summary_fields.organization.id}`}
|
||||
>
|
||||
<b>{application.summary_fields.organization.name}</b>
|
||||
</Link>
|
||||
</DataListCell>,
|
||||
<DataListCell key="modified" aria-label={i18n._(t`last modified`)}>
|
||||
<Label>{i18n._(t`Last Modified`)}</Label>
|
||||
<span>{formatDateString(application.modified)}</span>
|
||||
</DataListCell>,
|
||||
]}
|
||||
/>
|
||||
<DataListAction
|
||||
aria-label={i18n._(t`actions`)}
|
||||
aria-labelledby={labelId}
|
||||
id={labelId}
|
||||
<Tr id={`application-row-${application.id}`}>
|
||||
<Td
|
||||
select={{
|
||||
rowIndex,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}}
|
||||
dataLabel={i18n._(t`Selected`)}
|
||||
/>
|
||||
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
|
||||
<Link to={`${detailUrl}`}>
|
||||
<b>{application.name}</b>
|
||||
</Link>
|
||||
</Td>
|
||||
<Td dataLabel={i18n._(t`Organization`)}>
|
||||
<Link
|
||||
to={`/organizations/${application.summary_fields.organization.id}`}
|
||||
>
|
||||
{application.summary_fields.user_capabilities.edit ? (
|
||||
<Tooltip content={i18n._(t`Edit application`)} position="top">
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit application`)}
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={`/applications/${application.id}/edit`}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</DataListAction>
|
||||
</DataListItemRow>
|
||||
</DataListItem>
|
||||
<b>{application.summary_fields.organization.name}</b>
|
||||
</Link>
|
||||
</Td>
|
||||
<Td dataLabel={i18n._(t`Last Modified`)}>
|
||||
{formatDateString(application.modified)}
|
||||
</Td>
|
||||
<ActionsTd dataLabel={i18n._(t`Actions`)}>
|
||||
<ActionItem
|
||||
visible={application.summary_fields.user_capabilities.edit}
|
||||
tooltip={i18n._(t`Edit application`)}
|
||||
>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit application`)}
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={`/applications/${application.id}/edit`}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</ActionItem>
|
||||
</ActionsTd>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -18,12 +18,16 @@ describe('<ApplicationListItem/>', () => {
|
||||
test('should mount successfully', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ApplicationListItem
|
||||
application={application}
|
||||
detailUrl="/organizations/2/details"
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<ApplicationListItem
|
||||
application={application}
|
||||
detailUrl="/organizations/2/details"
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('ApplicationListItem').length).toBe(1);
|
||||
@ -31,38 +35,30 @@ describe('<ApplicationListItem/>', () => {
|
||||
test('should render the proper data', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ApplicationListItem
|
||||
application={application}
|
||||
detailUrl="/organizations/2/details"
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<ApplicationListItem
|
||||
application={application}
|
||||
detailUrl="/organizations/2/details"
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
expect(
|
||||
wrapper.find('DataListCell[aria-label="application name"]').text()
|
||||
wrapper
|
||||
.find('Td')
|
||||
.at(1)
|
||||
.text()
|
||||
).toBe('Foo');
|
||||
expect(
|
||||
wrapper.find('DataListCell[aria-label="organization name"]').text()
|
||||
wrapper
|
||||
.find('Td')
|
||||
.at(2)
|
||||
.text()
|
||||
).toBe('Organization');
|
||||
expect(wrapper.find('input#select-application-1').prop('checked')).toBe(
|
||||
false
|
||||
);
|
||||
expect(wrapper.find('PencilAltIcon').length).toBe(1);
|
||||
});
|
||||
test('should be checked', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ApplicationListItem
|
||||
application={application}
|
||||
detailUrl="/organizations/2/details"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('input#select-application-1').prop('checked')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -11,7 +11,11 @@ import AlertModal from '../../../components/AlertModal';
|
||||
|
||||
import DatalistToolbar from '../../../components/DataListToolbar';
|
||||
import { ApplicationsAPI } from '../../../api';
|
||||
import PaginatedDataList, {
|
||||
import PaginatedTable, {
|
||||
HeaderRow,
|
||||
HeaderCell,
|
||||
} from '../../../components/PaginatedTable';
|
||||
import {
|
||||
ToolbarDeleteButton,
|
||||
ToolbarAddButton,
|
||||
} from '../../../components/PaginatedDataList';
|
||||
@ -104,7 +108,7 @@ function ApplicationsList({ i18n }) {
|
||||
<>
|
||||
<PageSection>
|
||||
<Card>
|
||||
<PaginatedDataList
|
||||
<PaginatedTable
|
||||
contentError={error}
|
||||
hasContentLoading={isLoading || deleteLoading}
|
||||
items={applications}
|
||||
@ -123,24 +127,6 @@ function ApplicationsList({ i18n }) {
|
||||
key: 'description__icontains',
|
||||
},
|
||||
]}
|
||||
toolbarSortColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Created`),
|
||||
key: 'created',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Organization`),
|
||||
key: 'organization',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Description`),
|
||||
key: 'description',
|
||||
},
|
||||
]}
|
||||
toolbarSearchableKeys={searchableKeys}
|
||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||
renderToolbar={props => (
|
||||
@ -170,7 +156,17 @@ function ApplicationsList({ i18n }) {
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
renderItem={application => (
|
||||
headerRow={
|
||||
<HeaderRow qsConfig={QS_CONFIG}>
|
||||
<HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell>
|
||||
<HeaderCell sortKey="organization">
|
||||
{i18n._(t`Organization`)}
|
||||
</HeaderCell>
|
||||
<HeaderCell>{i18n._(t`Last Modified`)}</HeaderCell>
|
||||
<HeaderCell>{i18n._(t`Actions`)}</HeaderCell>
|
||||
</HeaderRow>
|
||||
}
|
||||
renderRow={(application, index) => (
|
||||
<ApplicationListItem
|
||||
key={application.id}
|
||||
value={application.name}
|
||||
@ -178,6 +174,7 @@ function ApplicationsList({ i18n }) {
|
||||
detailUrl={`${match.url}/${application.id}/details`}
|
||||
onSelect={() => handleSelect(application)}
|
||||
isSelected={selected.some(row => row.id === application.id)}
|
||||
rowIndex={index}
|
||||
/>
|
||||
)}
|
||||
emptyStateControls={
|
||||
|
||||
@ -148,10 +148,10 @@ function CredentialFormFields({ i18n, credentialTypes }) {
|
||||
label={i18n._(t`Credential Type`)}
|
||||
>
|
||||
<Select
|
||||
ouiaId="CredentialForm-credential_type"
|
||||
aria-label={i18n._(t`Credential Type`)}
|
||||
isOpen={isSelectOpen}
|
||||
variant={SelectVariant.typeahead}
|
||||
ouiaId="credential-select"
|
||||
onToggle={setIsSelectOpen}
|
||||
onSelect={(event, value) => {
|
||||
credTypeHelpers.setValue(value);
|
||||
|
||||
@ -42,6 +42,7 @@ function BecomeMethodField({ fieldOptions, isRequired }) {
|
||||
validated={!(meta.touched && meta.error) ? 'default' : 'error'}
|
||||
>
|
||||
<Select
|
||||
ouiaId={`CredentialForm-${fieldOptions.id}`}
|
||||
maxHeight={200}
|
||||
variant={SelectVariant.typeahead}
|
||||
onToggle={setIsOpen}
|
||||
|
||||
@ -8,10 +8,14 @@ import { CredentialTypesAPI } from '../../../api';
|
||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
||||
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
||||
import useSelected from '../../../util/useSelected';
|
||||
import PaginatedDataList, {
|
||||
import {
|
||||
ToolbarDeleteButton,
|
||||
ToolbarAddButton,
|
||||
} from '../../../components/PaginatedDataList';
|
||||
import PaginatedTable, {
|
||||
HeaderRow,
|
||||
HeaderCell,
|
||||
} from '../../../components/PaginatedTable';
|
||||
import ErrorDetail from '../../../components/ErrorDetail';
|
||||
import AlertModal from '../../../components/AlertModal';
|
||||
import DatalistToolbar from '../../../components/DataListToolbar';
|
||||
@ -106,7 +110,7 @@ function CredentialTypeList({ i18n }) {
|
||||
<>
|
||||
<PageSection>
|
||||
<Card>
|
||||
<PaginatedDataList
|
||||
<PaginatedTable
|
||||
contentError={contentError}
|
||||
hasContentLoading={isLoading || deleteLoading}
|
||||
items={credentialTypes}
|
||||
@ -162,7 +166,13 @@ function CredentialTypeList({ i18n }) {
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
renderItem={credentialType => (
|
||||
headerRow={
|
||||
<HeaderRow qsConfig={QS_CONFIG}>
|
||||
<HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell>
|
||||
<HeaderCell>{i18n._(t`Actions`)}</HeaderCell>
|
||||
</HeaderRow>
|
||||
}
|
||||
renderRow={(credentialType, index) => (
|
||||
<CredentialTypeListItem
|
||||
key={credentialType.id}
|
||||
value={credentialType.name}
|
||||
@ -170,6 +180,7 @@ function CredentialTypeList({ i18n }) {
|
||||
detailUrl={`${match.url}/${credentialType.id}/details`}
|
||||
onSelect={() => handleSelect(credentialType)}
|
||||
isSelected={selected.some(row => row.id === credentialType.id)}
|
||||
rowIndex={index}
|
||||
/>
|
||||
)}
|
||||
emptyStateControls={
|
||||
|
||||
@ -72,12 +72,18 @@ describe('<CredentialTypeList', () => {
|
||||
await waitForElement(wrapper, 'CredentialTypeList', el => el.length > 0);
|
||||
|
||||
wrapper
|
||||
.find('input#select-credential-types-1')
|
||||
.find('.pf-c-table__check')
|
||||
.first()
|
||||
.find('input')
|
||||
.simulate('change', credentialTypes.data.results[0]);
|
||||
wrapper.update();
|
||||
|
||||
expect(
|
||||
wrapper.find('input#select-credential-types-1').prop('checked')
|
||||
wrapper
|
||||
.find('.pf-c-table__check')
|
||||
.first()
|
||||
.find('input')
|
||||
.prop('checked')
|
||||
).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
@ -133,10 +139,18 @@ describe('<CredentialTypeList', () => {
|
||||
});
|
||||
waitForElement(wrapper, 'CredentialTypeList', el => el.length > 0);
|
||||
|
||||
wrapper.find('input#select-credential-types-1').simulate('change', 'a');
|
||||
wrapper
|
||||
.find('.pf-c-table__check')
|
||||
.first()
|
||||
.find('input')
|
||||
.simulate('change', 'a');
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper.find('input#select-credential-types-1').prop('checked')
|
||||
wrapper
|
||||
.find('.pf-c-table__check')
|
||||
.first()
|
||||
.find('input')
|
||||
.prop('checked')
|
||||
).toBe(true);
|
||||
|
||||
await act(async () =>
|
||||
|
||||
@ -3,82 +3,53 @@ import { string, bool, func } from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
DataListAction as _DataListAction,
|
||||
DataListCheck,
|
||||
DataListItem,
|
||||
DataListItemRow,
|
||||
DataListItemCells,
|
||||
Tooltip,
|
||||
} from '@patternfly/react-core';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { Tr, Td } from '@patternfly/react-table';
|
||||
import { PencilAltIcon } from '@patternfly/react-icons';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import DataListCell from '../../../components/DataListCell';
|
||||
import { ActionsTd, ActionItem } from '../../../components/PaginatedTable';
|
||||
import { CredentialType } from '../../../types';
|
||||
|
||||
const DataListAction = styled(_DataListAction)`
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-gap: 16px;
|
||||
grid-template-columns: 40px;
|
||||
`;
|
||||
|
||||
function CredentialTypeListItem({
|
||||
credentialType,
|
||||
detailUrl,
|
||||
isSelected,
|
||||
onSelect,
|
||||
rowIndex,
|
||||
i18n,
|
||||
}) {
|
||||
const labelId = `check-action-${credentialType.id}`;
|
||||
|
||||
return (
|
||||
<DataListItem
|
||||
key={credentialType.id}
|
||||
aria-labelledby={labelId}
|
||||
id={`${credentialType.id} `}
|
||||
>
|
||||
<DataListItemRow>
|
||||
<DataListCheck
|
||||
id={`select-credential-types-${credentialType.id}`}
|
||||
checked={isSelected}
|
||||
onChange={onSelect}
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
<DataListItemCells
|
||||
dataListCells={[
|
||||
<DataListCell
|
||||
key="name"
|
||||
aria-label={i18n._(t`credential type name`)}
|
||||
>
|
||||
<Link to={`${detailUrl}`}>
|
||||
<b>{credentialType.name}</b>
|
||||
</Link>
|
||||
</DataListCell>,
|
||||
]}
|
||||
/>
|
||||
<DataListAction
|
||||
aria-label={i18n._(t`actions`)}
|
||||
aria-labelledby={labelId}
|
||||
id={labelId}
|
||||
<Tr id={`credential-type-row-${credentialType.id}`}>
|
||||
<Td
|
||||
select={{
|
||||
rowIndex,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}}
|
||||
dataLabel={i18n._(t`Selected`)}
|
||||
/>
|
||||
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
|
||||
<Link to={`${detailUrl}`}>
|
||||
<b>{credentialType.name}</b>
|
||||
</Link>
|
||||
</Td>
|
||||
<ActionsTd dataLabel={i18n._(t`Actions`)}>
|
||||
<ActionItem
|
||||
visible={credentialType.summary_fields.user_capabilities.edit}
|
||||
tooltip={i18n._(t`Edit credential type`)}
|
||||
>
|
||||
{credentialType.summary_fields.user_capabilities.edit && (
|
||||
<Tooltip content={i18n._(t`Edit credential type`)} position="top">
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit credential type`)}
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={`/credential_types/${credentialType.id}/edit`}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</DataListAction>
|
||||
</DataListItemRow>
|
||||
</DataListItem>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit credential type`)}
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={`/credential_types/${credentialType.id}/edit`}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</ActionItem>
|
||||
</ActionsTd>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -17,12 +17,16 @@ describe('<CredentialTypeListItem/>', () => {
|
||||
test('should mount successfully', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<CredentialTypeListItem
|
||||
credentialType={credential_type}
|
||||
detailUrl="credential_types/1/details"
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<CredentialTypeListItem
|
||||
credentialType={credential_type}
|
||||
detailUrl="credential_types/1/details"
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('CredentialTypeListItem').length).toBe(1);
|
||||
@ -31,48 +35,38 @@ describe('<CredentialTypeListItem/>', () => {
|
||||
test('should render the proper data', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<CredentialTypeListItem
|
||||
credentialType={credential_type}
|
||||
detailsUrl="credential_types/1/details"
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<CredentialTypeListItem
|
||||
credentialType={credential_type}
|
||||
detailsUrl="credential_types/1/details"
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
expect(
|
||||
wrapper.find('DataListCell[aria-label="credential type name"]').text()
|
||||
).toBe('Foo');
|
||||
expect(wrapper.find('Td[dataLabel="Name"]').text()).toBe('Foo');
|
||||
expect(wrapper.find('PencilAltIcon').length).toBe(1);
|
||||
expect(
|
||||
wrapper.find('input#select-credential-types-1').prop('checked')
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('should be checked', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<CredentialTypeListItem
|
||||
credentialType={credential_type}
|
||||
detailsUrl="credential_types/1/details"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
expect(
|
||||
wrapper.find('input#select-credential-types-1').prop('checked')
|
||||
).toBe(true);
|
||||
expect(wrapper.find('.pf-c-table__check input').prop('checked')).toBe(
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
test('edit button shown to users with edit capabilities', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<CredentialTypeListItem
|
||||
credentialType={credential_type}
|
||||
detailsUrl="credential_types/1/details"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<CredentialTypeListItem
|
||||
credentialType={credential_type}
|
||||
detailsUrl="credential_types/1/details"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
|
||||
@ -82,15 +76,19 @@ describe('<CredentialTypeListItem/>', () => {
|
||||
test('edit button hidden from users without edit capabilities', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<CredentialTypeListItem
|
||||
credentialType={{
|
||||
...credential_type,
|
||||
summary_fields: { user_capabilities: { edit: false } },
|
||||
}}
|
||||
detailsUrl="credential_types/1/details"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<CredentialTypeListItem
|
||||
credentialType={{
|
||||
...credential_type,
|
||||
summary_fields: { user_capabilities: { edit: false } },
|
||||
}}
|
||||
detailsUrl="credential_types/1/details"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -8,9 +8,11 @@ import { InstanceGroupsAPI } from '../../../api';
|
||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
||||
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
||||
import useSelected from '../../../util/useSelected';
|
||||
import PaginatedDataList, {
|
||||
ToolbarDeleteButton,
|
||||
} from '../../../components/PaginatedDataList';
|
||||
import PaginatedTable, {
|
||||
HeaderRow,
|
||||
HeaderCell,
|
||||
} from '../../../components/PaginatedTable';
|
||||
import { ToolbarDeleteButton } from '../../../components/PaginatedDataList';
|
||||
import ErrorDetail from '../../../components/ErrorDetail';
|
||||
import AlertModal from '../../../components/AlertModal';
|
||||
import DatalistToolbar from '../../../components/DataListToolbar';
|
||||
@ -189,7 +191,7 @@ function InstanceGroupList({ i18n }) {
|
||||
<>
|
||||
<PageSection>
|
||||
<Card>
|
||||
<PaginatedDataList
|
||||
<PaginatedTable
|
||||
contentError={contentError}
|
||||
hasContentLoading={isLoading || deleteLoading}
|
||||
items={instanceGroups}
|
||||
@ -220,7 +222,18 @@ function InstanceGroupList({ i18n }) {
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
renderItem={instanceGroup => (
|
||||
headerRow={
|
||||
<HeaderRow qsConfig={QS_CONFIG}>
|
||||
<HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell>
|
||||
<HeaderCell>{i18n._(t`Type`)}</HeaderCell>
|
||||
<HeaderCell>{i18n._(t`Running Jobs`)}</HeaderCell>
|
||||
<HeaderCell>{i18n._(t`Total Jobs`)}</HeaderCell>
|
||||
<HeaderCell>{i18n._(t`Instances`)}</HeaderCell>
|
||||
<HeaderCell>{i18n._(t`Capacity`)}</HeaderCell>
|
||||
<HeaderCell>{i18n._(t`Actions`)}</HeaderCell>
|
||||
</HeaderRow>
|
||||
}
|
||||
renderRow={(instanceGroup, index) => (
|
||||
<InstanceGroupListItem
|
||||
key={instanceGroup.id}
|
||||
value={instanceGroup.name}
|
||||
@ -228,6 +241,7 @@ function InstanceGroupList({ i18n }) {
|
||||
detailUrl={getDetailUrl(instanceGroup)}
|
||||
onSelect={() => handleSelect(instanceGroup)}
|
||||
isSelected={selected.some(row => row.id === instanceGroup.id)}
|
||||
rowIndex={index}
|
||||
/>
|
||||
)}
|
||||
emptyStateControls={canAdd && addButton}
|
||||
|
||||
@ -71,13 +71,19 @@ describe('<InstanceGroupList />', () => {
|
||||
await waitForElement(wrapper, 'InstanceGroupList', el => el.length > 0);
|
||||
|
||||
wrapper
|
||||
.find('input#select-instance-groups-1')
|
||||
.find('.pf-c-table__check')
|
||||
.first()
|
||||
.find('input')
|
||||
.simulate('change', instanceGroups);
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('input#select-instance-groups-1').prop('checked')).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
wrapper
|
||||
.find('.pf-c-table__check')
|
||||
.first()
|
||||
.find('input')
|
||||
.prop('checked')
|
||||
).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('Button[aria-label="Delete"]').prop('onClick')();
|
||||
@ -102,16 +108,22 @@ describe('<InstanceGroupList />', () => {
|
||||
});
|
||||
await waitForElement(wrapper, 'InstanceGroupList', el => el.length > 0);
|
||||
|
||||
const instanceGroupIndex = [1, 2, 3];
|
||||
const instanceGroupIndex = [0, 1, 2];
|
||||
|
||||
instanceGroupIndex.forEach(element => {
|
||||
wrapper
|
||||
.find(`input#select-instance-groups-${element}`)
|
||||
.find('.pf-c-table__check')
|
||||
.at(element)
|
||||
.find('input')
|
||||
.simulate('change', instanceGroups);
|
||||
wrapper.update();
|
||||
|
||||
expect(
|
||||
wrapper.find(`input#select-instance-groups-${element}`).prop('checked')
|
||||
wrapper
|
||||
.find('.pf-c-table__check')
|
||||
.at(element)
|
||||
.find('input')
|
||||
.prop('checked')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@ -159,11 +171,19 @@ describe('<InstanceGroupList />', () => {
|
||||
});
|
||||
waitForElement(wrapper, 'InstanceGroupList', el => el.length > 0);
|
||||
|
||||
wrapper.find('input#select-instance-groups-1').simulate('change', 'a');
|
||||
wrapper
|
||||
.find('.pf-c-table__check')
|
||||
.first()
|
||||
.find('input')
|
||||
.simulate('change', 'a');
|
||||
wrapper.update();
|
||||
expect(wrapper.find('input#select-instance-groups-1').prop('checked')).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
wrapper
|
||||
.find('.pf-c-table__check')
|
||||
.first()
|
||||
.find('input')
|
||||
.prop('checked')
|
||||
).toBe(true);
|
||||
|
||||
await act(async () =>
|
||||
wrapper.find('Button[aria-label="Delete"]').prop('onClick')()
|
||||
|
||||
@ -5,48 +5,18 @@ import { t } from '@lingui/macro';
|
||||
import { Link } from 'react-router-dom';
|
||||
import 'styled-components/macro';
|
||||
import {
|
||||
Badge as PFBadge,
|
||||
Button,
|
||||
DataListAction as _DataListAction,
|
||||
DataListCheck,
|
||||
DataListItem,
|
||||
DataListItemCells,
|
||||
DataListItemRow,
|
||||
Label,
|
||||
Progress,
|
||||
ProgressMeasureLocation,
|
||||
ProgressSize,
|
||||
Tooltip,
|
||||
} from '@patternfly/react-core';
|
||||
import { Tr, Td } from '@patternfly/react-table';
|
||||
import { PencilAltIcon } from '@patternfly/react-icons';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import _DataListCell from '../../../components/DataListCell';
|
||||
import { ActionsTd, ActionItem } from '../../../components/PaginatedTable';
|
||||
import { InstanceGroup } from '../../../types';
|
||||
|
||||
const DataListCell = styled(_DataListCell)`
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const Badge = styled(PFBadge)`
|
||||
margin-left: 8px;
|
||||
`;
|
||||
|
||||
const ListGroup = styled.span`
|
||||
margin-left: 12px;
|
||||
|
||||
&:first-of-type {
|
||||
margin-left: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const DataListAction = styled(_DataListAction)`
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-gap: 16px;
|
||||
grid-template-columns: 40px;
|
||||
`;
|
||||
|
||||
const Unavailable = styled.span`
|
||||
color: var(--pf-global--danger-color--200);
|
||||
`;
|
||||
@ -56,6 +26,7 @@ function InstanceGroupListItem({
|
||||
detailUrl,
|
||||
isSelected,
|
||||
onSelect,
|
||||
rowIndex,
|
||||
i18n,
|
||||
}) {
|
||||
const labelId = `check-action-${instanceGroup.id}`;
|
||||
@ -104,98 +75,50 @@ function InstanceGroupListItem({
|
||||
};
|
||||
|
||||
return (
|
||||
<DataListItem
|
||||
key={instanceGroup.id}
|
||||
aria-labelledby={labelId}
|
||||
id={`${instanceGroup.id} `}
|
||||
>
|
||||
<DataListItemRow>
|
||||
<DataListCheck
|
||||
id={`select-instance-groups-${instanceGroup.id}`}
|
||||
checked={isSelected}
|
||||
onChange={onSelect}
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
|
||||
<DataListItemCells
|
||||
dataListCells={[
|
||||
<DataListCell
|
||||
key="name"
|
||||
aria-label={i18n._(t`instance group name`)}
|
||||
>
|
||||
<span id={labelId}>
|
||||
<Link to={`${detailUrl}`}>
|
||||
<b>{instanceGroup.name}</b>
|
||||
</Link>
|
||||
</span>
|
||||
{verifyInstanceGroup(instanceGroup)}
|
||||
</DataListCell>,
|
||||
|
||||
<DataListCell
|
||||
key="type"
|
||||
aria-label={i18n._(t`instance group type`)}
|
||||
>
|
||||
<b css="margin-right: 24px">{i18n._(t`Type`)}</b>
|
||||
<span id={labelId}>
|
||||
{isContainerGroup(instanceGroup)
|
||||
? i18n._(t`Container group`)
|
||||
: i18n._(t`Instance group`)}
|
||||
</span>
|
||||
</DataListCell>,
|
||||
<DataListCell
|
||||
key="related-field-counts"
|
||||
aria-label={i18n._(t`instance counts`)}
|
||||
width={2}
|
||||
>
|
||||
<ListGroup>
|
||||
<b>{i18n._(t`Running jobs`)}</b>
|
||||
<Badge isRead>{instanceGroup.jobs_running}</Badge>
|
||||
</ListGroup>
|
||||
<ListGroup>
|
||||
<b>{i18n._(t`Total jobs`)}</b>
|
||||
<Badge isRead>{instanceGroup.jobs_total}</Badge>
|
||||
</ListGroup>
|
||||
|
||||
{!instanceGroup.is_containerized ? (
|
||||
<ListGroup>
|
||||
<b>{i18n._(t`Instances`)}</b>
|
||||
<Badge isRead>{instanceGroup.instances}</Badge>
|
||||
</ListGroup>
|
||||
) : null}
|
||||
</DataListCell>,
|
||||
|
||||
<DataListCell
|
||||
key="capacity"
|
||||
aria-label={i18n._(t`instance group used capacity`)}
|
||||
>
|
||||
{usedCapacity(instanceGroup)}
|
||||
</DataListCell>,
|
||||
]}
|
||||
/>
|
||||
<DataListAction
|
||||
aria-label={i18n._(t`actions`)}
|
||||
aria-labelledby={labelId}
|
||||
id={labelId}
|
||||
<Tr id={`ig-row-${instanceGroup.id}`}>
|
||||
<Td
|
||||
select={{
|
||||
rowIndex,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}}
|
||||
dataLabel={i18n._(t`Selected`)}
|
||||
/>
|
||||
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
|
||||
<Link to={`${detailUrl}`}>
|
||||
<b>{instanceGroup.name}</b>
|
||||
{verifyInstanceGroup(instanceGroup)}
|
||||
</Link>
|
||||
</Td>
|
||||
<Td dataLabel={i18n._(t`Type`)}>
|
||||
{isContainerGroup(instanceGroup)
|
||||
? i18n._(t`Container group`)
|
||||
: i18n._(t`Instance group`)}
|
||||
</Td>
|
||||
<Td dataLabel={i18n._(t`Running jobs`)}>{instanceGroup.jobs_running}</Td>
|
||||
<Td dataLabel={i18n._(t`Total jobs`)}>{instanceGroup.jobs_total}</Td>
|
||||
<Td dataLabel={i18n._(t`Instances`)}>{instanceGroup.instances}</Td>
|
||||
<Td dataLabel={i18n._(t`Capacity`)}>{usedCapacity(instanceGroup)}</Td>
|
||||
<ActionsTd dataLabel={i18n._(t`Actions`)}>
|
||||
<ActionItem
|
||||
visible={instanceGroup.summary_fields.user_capabilities.edit}
|
||||
tooltip={i18n._(t`Edit instance group`)}
|
||||
>
|
||||
{instanceGroup.summary_fields.user_capabilities.edit && (
|
||||
<Tooltip content={i18n._(t`Edit instance group`)} position="top">
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit instance group`)}
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={
|
||||
isContainerGroup(instanceGroup)
|
||||
? `/instance_groups/container_group/${instanceGroup.id}/edit`
|
||||
: `/instance_groups/${instanceGroup.id}/edit`
|
||||
}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</DataListAction>
|
||||
</DataListItemRow>
|
||||
</DataListItem>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit instance group`)}
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={
|
||||
isContainerGroup(instanceGroup)
|
||||
? `/instance_groups/container_group/${instanceGroup.id}/edit`
|
||||
: `/instance_groups/${instanceGroup.id}/edit`
|
||||
}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</ActionItem>
|
||||
</ActionsTd>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
InstanceGroupListItem.prototype = {
|
||||
|
||||
@ -47,12 +47,16 @@ describe('<InstanceGroupListItem/>', () => {
|
||||
test('should mount successfully', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<InstanceGroupListItem
|
||||
instanceGroup={instanceGroups[1]}
|
||||
detailUrl="instance_groups/1/details"
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<InstanceGroupListItem
|
||||
instanceGroup={instanceGroups[1]}
|
||||
detailUrl="instance_groups/1/details"
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('InstanceGroupListItem').length).toBe(1);
|
||||
@ -61,73 +65,81 @@ describe('<InstanceGroupListItem/>', () => {
|
||||
test('should render the proper data instance group', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<InstanceGroupListItem
|
||||
instanceGroup={instanceGroups[0]}
|
||||
detailUrl="instance_groups/1/details"
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<InstanceGroupListItem
|
||||
instanceGroup={instanceGroups[0]}
|
||||
detailUrl="instance_groups/1/details"
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
expect(
|
||||
wrapper.find('PFDataListCell[aria-label="instance group name"]').text()
|
||||
wrapper
|
||||
.find('Td')
|
||||
.at(1)
|
||||
.text()
|
||||
).toBe('Foo');
|
||||
expect(wrapper.find('Progress').prop('value')).toBe(40);
|
||||
expect(
|
||||
wrapper.find('PFDataListCell[aria-label="instance group type"]').text()
|
||||
).toBe('TypeInstance group');
|
||||
wrapper
|
||||
.find('Td')
|
||||
.at(2)
|
||||
.text()
|
||||
).toBe('Instance group');
|
||||
expect(wrapper.find('PencilAltIcon').length).toBe(1);
|
||||
expect(wrapper.find('input#select-instance-groups-1').prop('checked')).toBe(
|
||||
false
|
||||
expect(wrapper.find('.pf-c-table__check input').prop('checked')).toBe(
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
test('should render the proper data container group', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<InstanceGroupListItem
|
||||
instanceGroup={instanceGroups[1]}
|
||||
detailUrl="instance_groups/2/details"
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<InstanceGroupListItem
|
||||
instanceGroup={instanceGroups[1]}
|
||||
detailUrl="instance_groups/2/details"
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
expect(
|
||||
wrapper.find('PFDataListCell[aria-label="instance group name"]').text()
|
||||
wrapper
|
||||
.find('Td')
|
||||
.at(1)
|
||||
.text()
|
||||
).toBe('Bar');
|
||||
|
||||
expect(
|
||||
wrapper.find('PFDataListCell[aria-label="instance group type"]').text()
|
||||
).toBe('TypeContainer group');
|
||||
wrapper
|
||||
.find('Td')
|
||||
.at(2)
|
||||
.text()
|
||||
).toBe('Container group');
|
||||
expect(wrapper.find('PencilAltIcon').length).toBe(0);
|
||||
});
|
||||
|
||||
test('should be checked', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<InstanceGroupListItem
|
||||
instanceGroup={instanceGroups[0]}
|
||||
detailUrl="instance_groups/1/details"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('input#select-instance-groups-1').prop('checked')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('edit button shown to users with edit capabilities', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<InstanceGroupListItem
|
||||
instanceGroup={instanceGroups[0]}
|
||||
detailUrl="instance_groups/1/details"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<InstanceGroupListItem
|
||||
instanceGroup={instanceGroups[0]}
|
||||
detailUrl="instance_groups/1/details"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
|
||||
@ -137,12 +149,16 @@ describe('<InstanceGroupListItem/>', () => {
|
||||
test('edit button hidden from users without edit capabilities', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<InstanceGroupListItem
|
||||
instanceGroup={instanceGroups[1]}
|
||||
detailsUrl="instance_group/2/details"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<InstanceGroupListItem
|
||||
instanceGroup={instanceGroups[1]}
|
||||
detailsUrl="instance_group/2/details"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -117,7 +117,7 @@ function Inventories({ i18n }) {
|
||||
t`Schedule details`
|
||||
),
|
||||
[`${inventorySourcesPath}/${nestedObject?.id}/notifications`]: i18n._(
|
||||
t`Notifcations`
|
||||
t`Notifications`
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
@ -69,9 +69,6 @@ function InventorySource({ i18n, inventory, setBreadcrumb, me }) {
|
||||
[source]
|
||||
);
|
||||
|
||||
const createSchedule = data =>
|
||||
InventorySourcesAPI.createSchedule(source?.id, data);
|
||||
|
||||
const loadScheduleOptions = useCallback(() => {
|
||||
return InventorySourcesAPI.readScheduleOptions(source?.id);
|
||||
}, [source]);
|
||||
@ -160,11 +157,11 @@ function InventorySource({ i18n, inventory, setBreadcrumb, me }) {
|
||||
path="/inventories/inventory/:id/sources/:sourceId/schedules"
|
||||
>
|
||||
<Schedules
|
||||
createSchedule={createSchedule}
|
||||
setBreadcrumb={(unifiedJobTemplate, schedule) =>
|
||||
apiModel={InventorySourcesAPI}
|
||||
setBreadcrumb={schedule =>
|
||||
setBreadcrumb(inventory, source, schedule)
|
||||
}
|
||||
unifiedJobTemplate={source}
|
||||
resource={source}
|
||||
loadSchedules={loadSchedules}
|
||||
loadScheduleOptions={loadScheduleOptions}
|
||||
/>
|
||||
|
||||
@ -161,7 +161,7 @@ const InventorySourceFormFields = ({ source, sourceOptions, i18n }) => {
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
{sourceField.value !== '' && (
|
||||
{!['', 'custom'].includes(sourceField.value) && (
|
||||
<SubFormLayout>
|
||||
<Title size="md" headingLevel="h4">
|
||||
{i18n._(t`Source details`)}
|
||||
|
||||
@ -93,7 +93,8 @@ describe('<InventorySourceForm />', () => {
|
||||
id: 2,
|
||||
name: 'mock proj',
|
||||
});
|
||||
wrapper.find('AnsibleSelect#source_path').prop('onChange')(null, 'foo');
|
||||
wrapper.find('Select#source_path').prop('onToggle')();
|
||||
wrapper.find('Select#source_path').prop('onSelect')(null, 'foo');
|
||||
wrapper.find('AnsibleSelect#verbosity').prop('onChange')(null, '2');
|
||||
wrapper.find('button[aria-label="Save"]').simulate('click');
|
||||
});
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useField, useFormikContext } from 'formik';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { FormGroup } from '@patternfly/react-core';
|
||||
import {
|
||||
FormGroup,
|
||||
SelectVariant,
|
||||
Select,
|
||||
SelectOption,
|
||||
} from '@patternfly/react-core';
|
||||
import { ProjectsAPI } from '../../../../api';
|
||||
import useRequest from '../../../../util/useRequest';
|
||||
import { required } from '../../../../util/validators';
|
||||
|
||||
import AnsibleSelect from '../../../../components/AnsibleSelect';
|
||||
import CredentialLookup from '../../../../components/Lookup/CredentialLookup';
|
||||
import ProjectLookup from '../../../../components/Lookup/ProjectLookup';
|
||||
import Popover from '../../../../components/Popover';
|
||||
@ -21,6 +25,8 @@ import {
|
||||
} from './SharedFields';
|
||||
|
||||
const SCMSubForm = ({ autoPopulateProject, i18n }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [sourcePath, setSourcePath] = useState([]);
|
||||
const { setFieldValue, setFieldTouched } = useFormikContext();
|
||||
const [credentialField] = useField('credential');
|
||||
const [projectField, projectMeta, projectHelpers] = useField({
|
||||
@ -32,14 +38,10 @@ const SCMSubForm = ({ autoPopulateProject, i18n }) => {
|
||||
validate: required(i18n._(t`Select a value for this field`), i18n),
|
||||
});
|
||||
|
||||
const {
|
||||
error: sourcePathError,
|
||||
request: fetchSourcePath,
|
||||
result: sourcePath,
|
||||
} = useRequest(
|
||||
const { error: sourcePathError, request: fetchSourcePath } = useRequest(
|
||||
useCallback(async projectId => {
|
||||
const { data } = await ProjectsAPI.readInventories(projectId);
|
||||
return [...data, '/ (project root)'];
|
||||
setSourcePath([...data, '/ (project root)']);
|
||||
}, []),
|
||||
[]
|
||||
);
|
||||
@ -106,26 +108,41 @@ const SCMSubForm = ({ autoPopulateProject, i18n }) => {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AnsibleSelect
|
||||
{...sourcePathField}
|
||||
<Select
|
||||
ouiaId="InventorySourceForm-source_path"
|
||||
variant={SelectVariant.typeahead}
|
||||
onToggle={setIsOpen}
|
||||
isOpen={isOpen}
|
||||
selections={sourcePathField.value}
|
||||
id="source_path"
|
||||
isValid={
|
||||
(!sourcePathMeta.error || !sourcePathMeta.touched) &&
|
||||
!sourcePathError?.message
|
||||
}
|
||||
data={[
|
||||
{
|
||||
value: '',
|
||||
key: '',
|
||||
label: i18n._(t`Choose an inventory file`),
|
||||
isDisabled: true,
|
||||
},
|
||||
...sourcePath.map(value => ({ value, label: value, key: value })),
|
||||
]}
|
||||
onChange={(event, value) => {
|
||||
onSelect={(event, value) => {
|
||||
setIsOpen(false);
|
||||
value = value.trim();
|
||||
if (!value.endsWith('/')) {
|
||||
value += '/';
|
||||
}
|
||||
sourcePathHelpers.setValue(value);
|
||||
}}
|
||||
/>
|
||||
aria-label={i18n._(t`Select source path`)}
|
||||
placeholder={i18n._(t`Select source path`)}
|
||||
isCreatable
|
||||
onCreateOption={value => {
|
||||
value.trim();
|
||||
|
||||
if (!value.endsWith('/')) {
|
||||
value += '/';
|
||||
}
|
||||
setSourcePath([...sourcePath, value]);
|
||||
}}
|
||||
>
|
||||
{sourcePath.map(path => (
|
||||
<SelectOption key={path} id={path} value={path} />
|
||||
))}
|
||||
</Select>
|
||||
</FormGroup>
|
||||
<VerbosityField />
|
||||
<HostFilterField />
|
||||
|
||||
@ -89,17 +89,17 @@ describe('<SCMSubForm />', () => {
|
||||
});
|
||||
|
||||
test('changing source project should reset source path dropdown', async () => {
|
||||
expect(wrapper.find('AnsibleSelect#source_path').prop('value')).toEqual('');
|
||||
|
||||
expect(wrapper.find('Select#source_path').prop('selections')).toEqual('');
|
||||
await act(async () => {
|
||||
await wrapper.find('AnsibleSelect#source_path').prop('onChange')(
|
||||
null,
|
||||
'bar'
|
||||
);
|
||||
await wrapper.find('Select#source_path').prop('onToggle')();
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('AnsibleSelect#source_path').prop('value')).toEqual(
|
||||
'bar'
|
||||
await act(async () => {
|
||||
await wrapper.find('Select#source_path').prop('onSelect')(null, 'bar');
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('Select#source_path').prop('selections')).toEqual(
|
||||
'bar/'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
@ -109,6 +109,37 @@ describe('<SCMSubForm />', () => {
|
||||
});
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('AnsibleSelect#source_path').prop('value')).toEqual('');
|
||||
expect(wrapper.find('Select#source_path').prop('selections')).toEqual('');
|
||||
});
|
||||
|
||||
test('should be able to create custom source path', async () => {
|
||||
const customInitialValues = {
|
||||
credential: { id: 1, name: 'Credential' },
|
||||
custom_virtualenv: '',
|
||||
overwrite: false,
|
||||
overwrite_vars: false,
|
||||
source_path: '/path',
|
||||
source_project: { id: 1, name: 'Source project' },
|
||||
source_script: null,
|
||||
source_vars: '---\n',
|
||||
update_cache_timeout: 0,
|
||||
update_on_launch: true,
|
||||
update_on_project_update: false,
|
||||
verbosity: 1,
|
||||
};
|
||||
let customWrapper;
|
||||
await act(async () => {
|
||||
customWrapper = mountWithContexts(
|
||||
<Formik initialValues={customInitialValues}>
|
||||
<SCMSubForm />
|
||||
</Formik>
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
customWrapper.find('Select').invoke('onSelect')({}, 'newPath');
|
||||
});
|
||||
customWrapper.update();
|
||||
expect(customWrapper.find('Select').prop('selections')).toBe('newPath/');
|
||||
});
|
||||
});
|
||||
|
||||
@ -64,6 +64,8 @@ function JobDetail({ job, i18n }) {
|
||||
credentials,
|
||||
instance_group: instanceGroup,
|
||||
inventory,
|
||||
inventory_source,
|
||||
source_project,
|
||||
job_template: jobTemplate,
|
||||
workflow_job_template: workflowJobTemplate,
|
||||
labels,
|
||||
@ -206,6 +208,33 @@ function JobDetail({ job, i18n }) {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{inventory_source && (
|
||||
<Detail
|
||||
label={i18n._(t`Inventory Source`)}
|
||||
value={
|
||||
<Link
|
||||
to={`/inventories/inventory/${inventory.id}/sources/${inventory_source.id}`}
|
||||
>
|
||||
{inventory_source.name}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{inventory_source && inventory_source.source === 'scm' && (
|
||||
<Detail
|
||||
label={i18n._(t`Project`)}
|
||||
value={
|
||||
<StatusDetailValue>
|
||||
{source_project.status && (
|
||||
<StatusIcon status={source_project.status} />
|
||||
)}
|
||||
<Link to={`/projects/${source_project.id}`}>
|
||||
{source_project.name}
|
||||
</Link>
|
||||
</StatusDetailValue>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{project && (
|
||||
<Detail
|
||||
label={i18n._(t`Project`)}
|
||||
|
||||
193
awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx
Normal file
193
awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx
Normal file
@ -0,0 +1,193 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Link,
|
||||
Redirect,
|
||||
Route,
|
||||
Switch,
|
||||
useLocation,
|
||||
useParams,
|
||||
useRouteMatch,
|
||||
} from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { CaretLeftIcon } from '@patternfly/react-icons';
|
||||
import { Card, PageSection } from '@patternfly/react-core';
|
||||
|
||||
import { SystemJobTemplatesAPI, OrganizationsAPI } from '../../api';
|
||||
import ContentError from '../../components/ContentError';
|
||||
import ContentLoading from '../../components/ContentLoading';
|
||||
import NotificationList from '../../components/NotificationList';
|
||||
import RoutedTabs from '../../components/RoutedTabs';
|
||||
import { Schedules } from '../../components/Schedule';
|
||||
import { useConfig } from '../../contexts/Config';
|
||||
import useRequest from '../../util/useRequest';
|
||||
|
||||
function ManagementJob({ i18n, setBreadcrumb }) {
|
||||
const basePath = '/management_jobs';
|
||||
|
||||
const match = useRouteMatch();
|
||||
const { id } = useParams();
|
||||
const { pathname } = useLocation();
|
||||
const { me } = useConfig();
|
||||
|
||||
const [isNotificationAdmin, setIsNotificationAdmin] = useState(false);
|
||||
|
||||
const { isLoading, error, request, result } = useRequest(
|
||||
useCallback(
|
||||
() =>
|
||||
Promise.all([
|
||||
SystemJobTemplatesAPI.readDetail(id),
|
||||
OrganizationsAPI.read({
|
||||
page_size: 1,
|
||||
role_level: 'notification_admin_role',
|
||||
}),
|
||||
]).then(([systemJobTemplate, notificationRoles]) => ({
|
||||
systemJobTemplate,
|
||||
notificationRoles,
|
||||
})),
|
||||
[id]
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
request();
|
||||
}, [request, pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!result) return;
|
||||
setIsNotificationAdmin(
|
||||
Boolean(result?.notificationRoles?.data?.results?.length)
|
||||
);
|
||||
setBreadcrumb(result);
|
||||
}, [result, setBreadcrumb, setIsNotificationAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!result) return;
|
||||
|
||||
setBreadcrumb(result);
|
||||
}, [result, setBreadcrumb]);
|
||||
|
||||
const createSchedule = useCallback(
|
||||
data =>
|
||||
SystemJobTemplatesAPI.createSchedule(result?.systemJobTemplate.id, data),
|
||||
[result]
|
||||
);
|
||||
const loadSchedules = useCallback(
|
||||
params =>
|
||||
SystemJobTemplatesAPI.readSchedules(result?.systemJobTemplate.id, params),
|
||||
[result]
|
||||
);
|
||||
const loadScheduleOptions = useCallback(
|
||||
() =>
|
||||
SystemJobTemplatesAPI.readScheduleOptions(result?.systemJobTemplate.id),
|
||||
[result]
|
||||
);
|
||||
|
||||
const shouldShowNotifications =
|
||||
result?.systemJobTemplate?.id &&
|
||||
(isNotificationAdmin || me?.is_system_auditor);
|
||||
const shouldShowSchedules = !!result?.systemJobTemplate?.id;
|
||||
|
||||
const tabsArray = [
|
||||
{
|
||||
id: 99,
|
||||
link: basePath,
|
||||
name: (
|
||||
<>
|
||||
<CaretLeftIcon />
|
||||
{i18n._(t`Back to management jobs`)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (shouldShowSchedules) {
|
||||
tabsArray.push({
|
||||
id: 0,
|
||||
name: i18n._(t`Schedules`),
|
||||
link: `${match.url}/schedules`,
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldShowNotifications) {
|
||||
tabsArray.push({
|
||||
id: 1,
|
||||
name: i18n._(t`Notifications`),
|
||||
link: `${match.url}/notifications`,
|
||||
});
|
||||
}
|
||||
|
||||
let Tabs = <RoutedTabs tabsArray={tabsArray} />;
|
||||
if (pathname.includes('edit') || pathname.includes('schedules/')) {
|
||||
Tabs = null;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<ContentError error={error}>
|
||||
{error?.response?.status === 404 && (
|
||||
<span>
|
||||
{i18n._(t`Management job not found.`)}
|
||||
{''}
|
||||
<Link to={basePath}>{i18n._(t`View all management jobs`)}</Link>
|
||||
</span>
|
||||
)}
|
||||
</ContentError>
|
||||
</Card>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
{Tabs}
|
||||
<ContentLoading />
|
||||
</Card>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
{Tabs}
|
||||
<Switch>
|
||||
<Redirect
|
||||
exact
|
||||
from={`${basePath}/:id`}
|
||||
to={`${basePath}/:id/schedules`}
|
||||
/>
|
||||
{shouldShowNotifications ? (
|
||||
<Route path={`${basePath}/:id/notifications`}>
|
||||
<NotificationList
|
||||
id={Number(result?.systemJobTemplate?.id)}
|
||||
canToggleNotifications={isNotificationAdmin}
|
||||
apiModel={SystemJobTemplatesAPI}
|
||||
/>
|
||||
</Route>
|
||||
) : null}
|
||||
{shouldShowSchedules ? (
|
||||
<Route path={`${basePath}/:id/schedules`}>
|
||||
<Schedules
|
||||
apiModel={SystemJobTemplatesAPI}
|
||||
resource={result.systemJobTemplate}
|
||||
createSchedule={createSchedule}
|
||||
loadSchedules={loadSchedules}
|
||||
loadScheduleOptions={loadScheduleOptions}
|
||||
setBreadcrumb={setBreadcrumb}
|
||||
launchConfig={{}}
|
||||
surveyConfig={{}}
|
||||
/>
|
||||
</Route>
|
||||
) : null}
|
||||
</Switch>
|
||||
</Card>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(ManagementJob);
|
||||
@ -0,0 +1,83 @@
|
||||
import React, { useState } from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Button, TextInput, Tooltip } from '@patternfly/react-core';
|
||||
import { RocketIcon } from '@patternfly/react-icons';
|
||||
|
||||
import AlertModal from '../../../components/AlertModal';
|
||||
|
||||
const MAX_RETENTION = 99999;
|
||||
|
||||
const clamp = (val, min, max) => {
|
||||
if (val < min) {
|
||||
return min;
|
||||
}
|
||||
if (val > max) {
|
||||
return max;
|
||||
}
|
||||
return val;
|
||||
};
|
||||
|
||||
function LaunchManagementPrompt({
|
||||
i18n,
|
||||
isOpen,
|
||||
isLoading,
|
||||
onClick,
|
||||
onClose,
|
||||
onConfirm,
|
||||
defaultDays,
|
||||
}) {
|
||||
const [dataRetention, setDataRetention] = useState(defaultDays);
|
||||
return (
|
||||
<>
|
||||
<Tooltip content={i18n._(t`Launch management job`)} position="top">
|
||||
<Button
|
||||
aria-label={i18n._(t`Launch management job`)}
|
||||
variant="plain"
|
||||
onClick={onClick}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
<RocketIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<AlertModal
|
||||
isOpen={isOpen}
|
||||
variant="info"
|
||||
onClose={onClose}
|
||||
title={i18n._(t`Launch management job`)}
|
||||
label={i18n._(t`Launch management job`)}
|
||||
actions={[
|
||||
<Button
|
||||
id="launch-job-confirm-button"
|
||||
key="delete"
|
||||
variant="primary"
|
||||
isDisabled={isLoading}
|
||||
aria-label={i18n._(t`Launch`)}
|
||||
onClick={() => onConfirm(dataRetention)}
|
||||
>
|
||||
{i18n._(t`Launch`)}
|
||||
</Button>,
|
||||
<Button
|
||||
id="launch-job-cancel-button"
|
||||
key="cancel"
|
||||
variant="link"
|
||||
aria-label={i18n._(t`Cancel`)}
|
||||
onClick={onClose}
|
||||
>
|
||||
{i18n._(t`Cancel`)}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
{i18n._(t`Set how many days of data should be retained.`)}
|
||||
<TextInput
|
||||
value={dataRetention}
|
||||
type="number"
|
||||
onChange={value => setDataRetention(clamp(value, 0, MAX_RETENTION))}
|
||||
aria-label={i18n._(t`Data retention period`)}
|
||||
/>
|
||||
</AlertModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(LaunchManagementPrompt);
|
||||
@ -0,0 +1,137 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Card, PageSection } from '@patternfly/react-core';
|
||||
|
||||
import { SystemJobTemplatesAPI } from '../../../api';
|
||||
import AlertModal from '../../../components/AlertModal';
|
||||
import DatalistToolbar from '../../../components/DataListToolbar';
|
||||
import ErrorDetail from '../../../components/ErrorDetail';
|
||||
import PaginatedTable, {
|
||||
HeaderRow,
|
||||
HeaderCell,
|
||||
} from '../../../components/PaginatedTable';
|
||||
import { useConfig } from '../../../contexts/Config';
|
||||
import { parseQueryString, getQSConfig } from '../../../util/qs';
|
||||
import useRequest from '../../../util/useRequest';
|
||||
|
||||
import ManagementJobListItem from './ManagementJobListItem';
|
||||
|
||||
const QS_CONFIG = getQSConfig('system_job_templates', {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
});
|
||||
|
||||
const buildSearchKeys = options => {
|
||||
const actions = options?.data?.actions?.GET || {};
|
||||
const searchableKeys = Object.keys(actions).filter(
|
||||
key => actions[key].filterable
|
||||
);
|
||||
const relatedSearchableKeys = options?.data?.related_search_fields || [];
|
||||
|
||||
return { searchableKeys, relatedSearchableKeys };
|
||||
};
|
||||
|
||||
const loadManagementJobs = async search => {
|
||||
const params = parseQueryString(QS_CONFIG, search);
|
||||
const [
|
||||
{
|
||||
data: { results: items, count },
|
||||
},
|
||||
options,
|
||||
] = await Promise.all([
|
||||
SystemJobTemplatesAPI.read(params),
|
||||
SystemJobTemplatesAPI.readOptions(),
|
||||
]);
|
||||
|
||||
return { items, count, options };
|
||||
};
|
||||
|
||||
function ManagementJobList({ i18n }) {
|
||||
const { search } = useLocation();
|
||||
const { me } = useConfig();
|
||||
const [launchError, setLaunchError] = useState(null);
|
||||
|
||||
const {
|
||||
request,
|
||||
error = false,
|
||||
isLoading = true,
|
||||
result: { options = {}, items = [], count = 0 },
|
||||
} = useRequest(
|
||||
useCallback(async () => loadManagementJobs(search), [search]),
|
||||
{}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
request();
|
||||
}, [request]);
|
||||
|
||||
const { searchableKeys, relatedSearchableKeys } = buildSearchKeys(options);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageSection>
|
||||
<Card>
|
||||
<PaginatedTable
|
||||
qsConfig={QS_CONFIG}
|
||||
contentError={error}
|
||||
hasContentLoading={isLoading}
|
||||
items={items}
|
||||
itemCount={count}
|
||||
pluralizedItemName={i18n._(t`Management Jobs`)}
|
||||
emptyContentMessage={' '}
|
||||
toolbarSearchableKeys={searchableKeys}
|
||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||
toolbarSearchColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name__icontains',
|
||||
isDefault: true,
|
||||
},
|
||||
]}
|
||||
renderToolbar={props => (
|
||||
<DatalistToolbar
|
||||
{...props}
|
||||
showSelectAll={false}
|
||||
qsConfig={QS_CONFIG}
|
||||
/>
|
||||
)}
|
||||
headerRow={
|
||||
<HeaderRow qsConfig={QS_CONFIG}>
|
||||
<HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell>
|
||||
<HeaderCell>{i18n._(t`Description`)}</HeaderCell>
|
||||
<HeaderCell>{i18n._(t`Actions`)}</HeaderCell>
|
||||
</HeaderRow>
|
||||
}
|
||||
renderRow={({ id, name, description, job_type }) => (
|
||||
<ManagementJobListItem
|
||||
key={id}
|
||||
id={id}
|
||||
name={name}
|
||||
jobType={job_type}
|
||||
description={description}
|
||||
isSuperUser={me?.is_superuser}
|
||||
isPrompted={['cleanup_activitystream', 'cleanup_jobs'].includes(
|
||||
job_type
|
||||
)}
|
||||
onLaunchError={setLaunchError}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</PageSection>
|
||||
<AlertModal
|
||||
isOpen={Boolean(launchError)}
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={() => setLaunchError(null)}
|
||||
>
|
||||
{i18n._(t`Failed to launch job.`)}
|
||||
<ErrorDetail error={launchError} />
|
||||
</AlertModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(ManagementJobList);
|
||||
@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
|
||||
import { SystemJobTemplatesAPI } from '../../../api';
|
||||
import ManagementJobList from './ManagementJobList';
|
||||
|
||||
jest.mock('../../../api/models/SystemJobTemplates');
|
||||
|
||||
const managementJobs = {
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Cleanup Activity Stream',
|
||||
description: 'Remove activity stream history',
|
||||
job_type: 'cleanup_activitystream',
|
||||
url: '/api/v2/system_job_templates/1/',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Cleanup Expired OAuth 2 Tokens',
|
||||
description: 'Cleanup expired OAuth 2 access and refresh tokens',
|
||||
job_type: 'cleanup_tokens',
|
||||
url: '/api/v2/system_job_templates/2/',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Cleanup Expired Sessions',
|
||||
description: 'Cleans out expired browser sessions',
|
||||
job_type: 'cleanup_sessions',
|
||||
url: '/api/v2/system_job_templates/3/',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Cleanup Job Details',
|
||||
description: 'Remove job history older than X days',
|
||||
job_type: 'cleanup_tokens',
|
||||
url: '/api/v2/system_job_templates/4/',
|
||||
},
|
||||
],
|
||||
count: 4,
|
||||
},
|
||||
};
|
||||
|
||||
const options = { data: { actions: { POST: true } } };
|
||||
|
||||
describe('<ManagementJobList/>', () => {
|
||||
beforeEach(() => {
|
||||
SystemJobTemplatesAPI.read.mockResolvedValue(managementJobs);
|
||||
SystemJobTemplatesAPI.readOptions.mockResolvedValue(options);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
let wrapper;
|
||||
|
||||
test('should mount successfully', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<ManagementJobList />);
|
||||
});
|
||||
await waitForElement(wrapper, 'ManagementJobList', el => el.length > 0);
|
||||
});
|
||||
|
||||
test('should have data fetched and render 4 rows', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<ManagementJobList />);
|
||||
});
|
||||
await waitForElement(wrapper, 'ManagementJobList', el => el.length > 0);
|
||||
|
||||
expect(wrapper.find('ManagementJobListItem').length).toBe(4);
|
||||
expect(SystemJobTemplatesAPI.read).toBeCalled();
|
||||
expect(SystemJobTemplatesAPI.readOptions).toBeCalled();
|
||||
});
|
||||
|
||||
test('should throw content error', async () => {
|
||||
SystemJobTemplatesAPI.read.mockRejectedValue(
|
||||
new Error({
|
||||
response: {
|
||||
config: {
|
||||
method: 'GET',
|
||||
url: '/api/v2/system_job_templates',
|
||||
},
|
||||
data: 'An error occurred',
|
||||
},
|
||||
})
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<ManagementJobList />);
|
||||
});
|
||||
await waitForElement(wrapper, 'ManagementJobList', el => el.length > 0);
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should not render add button', async () => {
|
||||
SystemJobTemplatesAPI.read.mockResolvedValue(managementJobs);
|
||||
SystemJobTemplatesAPI.readOptions.mockResolvedValue({
|
||||
data: { actions: { POST: false } },
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<ManagementJobList />);
|
||||
});
|
||||
waitForElement(wrapper, 'ManagementJobList', el => el.length > 0);
|
||||
expect(wrapper.find('ToolbarAddButton').length).toBe(0);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,127 @@
|
||||
import React, { useState } from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
import { Button, Tooltip } from '@patternfly/react-core';
|
||||
import { Tr, Td } from '@patternfly/react-table';
|
||||
import { RocketIcon } from '@patternfly/react-icons';
|
||||
|
||||
import { SystemJobTemplatesAPI } from '../../../api';
|
||||
import AlertModal from '../../../components/AlertModal';
|
||||
import ErrorDetail from '../../../components/ErrorDetail';
|
||||
import { ActionsTd, ActionItem } from '../../../components/PaginatedTable';
|
||||
import LaunchManagementPrompt from './LaunchManagementPrompt';
|
||||
|
||||
function ManagementJobListItem({
|
||||
i18n,
|
||||
onLaunchError,
|
||||
isPrompted,
|
||||
isSuperUser,
|
||||
id,
|
||||
jobType,
|
||||
name,
|
||||
description,
|
||||
}) {
|
||||
const detailsUrl = `/management_jobs/${id}`;
|
||||
|
||||
const history = useHistory();
|
||||
const [isLaunchLoading, setIsLaunchLoading] = useState(false);
|
||||
|
||||
const [isManagementPromptOpen, setIsManagementPromptOpen] = useState(false);
|
||||
const [isManagementPromptLoading, setIsManagementPromptLoading] = useState(
|
||||
false
|
||||
);
|
||||
const [managementPromptError, setManagementPromptError] = useState(null);
|
||||
const handleManagementPromptClick = () => setIsManagementPromptOpen(true);
|
||||
const handleManagementPromptClose = () => setIsManagementPromptOpen(false);
|
||||
|
||||
const handleManagementPromptConfirm = async days => {
|
||||
setIsManagementPromptLoading(true);
|
||||
try {
|
||||
const { data } = await SystemJobTemplatesAPI.launch(id, {
|
||||
extra_vars: { days },
|
||||
});
|
||||
history.push(`/jobs/management/${data.id}/output`);
|
||||
} catch (error) {
|
||||
setManagementPromptError(error);
|
||||
} finally {
|
||||
setIsManagementPromptLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLaunch = async () => {
|
||||
setIsLaunchLoading(true);
|
||||
try {
|
||||
const { data } = await SystemJobTemplatesAPI.launch(id);
|
||||
history.push(`/jobs/management/${data.id}/output`);
|
||||
} catch (error) {
|
||||
onLaunchError(error);
|
||||
} finally {
|
||||
setIsLaunchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tr id={`mgmt-jobs-row-${jobType ? jobType.replace('_', '-') : ''}`}>
|
||||
<Td />
|
||||
<Td dataLabel={i18n._(t`Name`)}>
|
||||
<Link to={`${detailsUrl}`}>
|
||||
<b>{name}</b>
|
||||
</Link>
|
||||
</Td>
|
||||
<Td dataLabel={i18n._(t`Description`)}>{description}</Td>
|
||||
<ActionsTd dataLabel={i18n._(t`Actions`)}>
|
||||
<ActionItem
|
||||
visible={isSuperUser}
|
||||
tooltip={i18n._(t`Launch Management Job`)}
|
||||
>
|
||||
{isSuperUser ? (
|
||||
<>
|
||||
{isPrompted ? (
|
||||
<>
|
||||
<LaunchManagementPrompt
|
||||
isOpen={isManagementPromptOpen}
|
||||
isLoading={isManagementPromptLoading}
|
||||
onClick={handleManagementPromptClick}
|
||||
onClose={handleManagementPromptClose}
|
||||
onConfirm={handleManagementPromptConfirm}
|
||||
defaultDays={30}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Tooltip
|
||||
content={i18n._(t`Launch management job`)}
|
||||
position="top"
|
||||
>
|
||||
<Button
|
||||
aria-label={i18n._(t`Launch management job`)}
|
||||
variant="plain"
|
||||
onClick={handleLaunch}
|
||||
isDisabled={isLaunchLoading}
|
||||
>
|
||||
<RocketIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}{' '}
|
||||
</>
|
||||
) : null}
|
||||
</ActionItem>
|
||||
</ActionsTd>
|
||||
</Tr>
|
||||
{managementPromptError && (
|
||||
<AlertModal
|
||||
isOpen={managementPromptError}
|
||||
variant="danger"
|
||||
onClose={() => setManagementPromptError(null)}
|
||||
title={i18n._(t`Management job launch error`)}
|
||||
label={i18n._(t`Management job launch error`)}
|
||||
>
|
||||
<ErrorDetail error={managementPromptError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(ManagementJobListItem);
|
||||
@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
|
||||
import ManagementJobListItem from './ManagementJobListItem';
|
||||
|
||||
describe('<ManagementJobListItem/>', () => {
|
||||
let wrapper;
|
||||
|
||||
const managementJob = {
|
||||
id: 3,
|
||||
name: 'Cleanup Expired Sessions',
|
||||
description: 'Cleans out expired browser sessions',
|
||||
job_type: 'cleanup_sessions',
|
||||
url: '/api/v2/system_job_templates/3/',
|
||||
};
|
||||
|
||||
test('should mount successfully', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<ManagementJobListItem
|
||||
id={managementJob.id}
|
||||
name={managementJob.name}
|
||||
description={managementJob.description}
|
||||
isSuperUser
|
||||
onLaunchError={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('ManagementJobListItem').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should render the proper data', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<ManagementJobListItem
|
||||
id={managementJob.id}
|
||||
name={managementJob.name}
|
||||
description={managementJob.description}
|
||||
isSuperUser
|
||||
onLaunchError={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
expect(
|
||||
wrapper
|
||||
.find('Td')
|
||||
.at(1)
|
||||
.text()
|
||||
).toBe(managementJob.name);
|
||||
expect(
|
||||
wrapper
|
||||
.find('Td')
|
||||
.at(2)
|
||||
.text()
|
||||
).toBe(managementJob.description);
|
||||
|
||||
expect(wrapper.find('RocketIcon').exists()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './ManagementJobList';
|
||||
@ -1,17 +1,53 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
|
||||
import ScreenHeader from '../../components/ScreenHeader';
|
||||
import ManagementJob from './ManagementJob';
|
||||
import ManagementJobList from './ManagementJobList';
|
||||
|
||||
function ManagementJobs({ i18n }) {
|
||||
const basePath = '/management_jobs';
|
||||
|
||||
const [breadcrumbConfig, setBreadcrumbConfig] = useState({
|
||||
[basePath]: i18n._(t`Management jobs`),
|
||||
});
|
||||
|
||||
const buildBreadcrumbConfig = useCallback(
|
||||
({ id, name }, nested) => {
|
||||
if (!id) return;
|
||||
|
||||
setBreadcrumbConfig({
|
||||
[basePath]: i18n._(t`Management job`),
|
||||
[`${basePath}/${id}`]: name,
|
||||
[`${basePath}/${id}/notifications`]: i18n._(t`Notifications`),
|
||||
[`${basePath}/${id}/schedules`]: i18n._(t`Schedules`),
|
||||
[`${basePath}/${id}/schedules/add`]: i18n._(t`Create New Schedule`),
|
||||
[`${basePath}/${id}/schedules/${nested?.id}`]: `${nested?.name}`,
|
||||
[`${basePath}/${id}/schedules/${nested?.id}/details`]: i18n._(
|
||||
t`Details`
|
||||
),
|
||||
[`${basePath}/${id}/schedules/${nested?.id}/edit`]: i18n._(
|
||||
t`Edit Details`
|
||||
),
|
||||
});
|
||||
},
|
||||
[i18n]
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<ScreenHeader
|
||||
streamType="none"
|
||||
breadcrumbConfig={{ '/management_jobs': i18n._(t`Management Jobs`) }}
|
||||
/>
|
||||
</Fragment>
|
||||
<>
|
||||
<ScreenHeader streamType="none" breadcrumbConfig={breadcrumbConfig} />
|
||||
<Switch>
|
||||
<Route path={`${basePath}/:id`}>
|
||||
<ManagementJob setBreadcrumb={buildBreadcrumbConfig} />
|
||||
</Route>
|
||||
<Route path={basePath}>
|
||||
<ManagementJobList setBreadcrumb={buildBreadcrumbConfig} />
|
||||
</Route>
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -10,17 +10,20 @@ jest.mock('react-router-dom', () => ({
|
||||
|
||||
describe('<ManagementJobs />', () => {
|
||||
let pageWrapper;
|
||||
let pageSections;
|
||||
|
||||
beforeEach(() => {
|
||||
pageWrapper = mountWithContexts(<ManagementJobs />);
|
||||
pageSections = pageWrapper.find('PageSection');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
pageWrapper.unmount();
|
||||
});
|
||||
|
||||
test('initially renders without crashing', () => {
|
||||
test('renders ok', () => {
|
||||
expect(pageWrapper.length).toBe(1);
|
||||
expect(pageWrapper.find('ScreenHeader').length).toBe(1);
|
||||
expect(pageSections.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,7 +4,11 @@ import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Card, PageSection } from '@patternfly/react-core';
|
||||
import { NotificationTemplatesAPI } from '../../../api';
|
||||
import PaginatedDataList, {
|
||||
import PaginatedTable, {
|
||||
HeaderRow,
|
||||
HeaderCell,
|
||||
} from '../../../components/PaginatedTable';
|
||||
import {
|
||||
ToolbarAddButton,
|
||||
ToolbarDeleteButton,
|
||||
} from '../../../components/PaginatedDataList';
|
||||
@ -104,7 +108,7 @@ function NotificationTemplatesList({ i18n }) {
|
||||
<>
|
||||
<PageSection>
|
||||
<Card>
|
||||
<PaginatedDataList
|
||||
<PaginatedTable
|
||||
contentError={contentError}
|
||||
hasContentLoading={isTemplatesLoading || isDeleteLoading}
|
||||
items={templates}
|
||||
@ -149,16 +153,6 @@ function NotificationTemplatesList({ i18n }) {
|
||||
]}
|
||||
toolbarSearchableKeys={searchableKeys}
|
||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||
toolbarSortColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Type`),
|
||||
key: 'notification_type',
|
||||
},
|
||||
]}
|
||||
renderToolbar={props => (
|
||||
<DataListToolbar
|
||||
{...props}
|
||||
@ -179,7 +173,17 @@ function NotificationTemplatesList({ i18n }) {
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
renderItem={template => (
|
||||
headerRow={
|
||||
<HeaderRow qsConfig={QS_CONFIG}>
|
||||
<HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell>
|
||||
<HeaderCell>{i18n._(t`Status`)}</HeaderCell>
|
||||
<HeaderCell sortKey="notification_type">
|
||||
{i18n._(t`Type`)}
|
||||
</HeaderCell>
|
||||
<HeaderCell>{i18n._(t`Actions`)}</HeaderCell>
|
||||
</HeaderRow>
|
||||
}
|
||||
renderRow={(template, index) => (
|
||||
<NotificationTemplateListItem
|
||||
key={template.id}
|
||||
fetchTemplates={fetchTemplates}
|
||||
@ -187,6 +191,7 @@ function NotificationTemplatesList({ i18n }) {
|
||||
detailUrl={`${match.url}/${template.id}`}
|
||||
isSelected={selected.some(row => row.id === template.id)}
|
||||
onSelect={() => handleSelect(template)}
|
||||
rowIndex={index}
|
||||
/>
|
||||
)}
|
||||
emptyStateControls={
|
||||
|
||||
@ -89,21 +89,33 @@ describe('<NotificationTemplateList />', () => {
|
||||
});
|
||||
|
||||
test('should select item', async () => {
|
||||
const itemCheckboxInput = 'input#select-template-1';
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<NotificationTemplateList />);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find(itemCheckboxInput).prop('checked')).toEqual(false);
|
||||
expect(
|
||||
wrapper
|
||||
.find('.pf-c-table__check')
|
||||
.first()
|
||||
.find('input')
|
||||
.prop('checked')
|
||||
).toEqual(false);
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find(itemCheckboxInput)
|
||||
.closest('DataListCheck')
|
||||
.find('.pf-c-table__check')
|
||||
.first()
|
||||
.find('input')
|
||||
.props()
|
||||
.onChange();
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find(itemCheckboxInput).prop('checked')).toEqual(true);
|
||||
expect(
|
||||
wrapper
|
||||
.find('.pf-c-table__check')
|
||||
.first()
|
||||
.find('input')
|
||||
.prop('checked')
|
||||
).toEqual(true);
|
||||
});
|
||||
|
||||
test('should delete notifications', async () => {
|
||||
@ -135,7 +147,6 @@ describe('<NotificationTemplateList />', () => {
|
||||
});
|
||||
|
||||
test('should show error dialog shown for failed deletion', async () => {
|
||||
const itemCheckboxInput = 'input#select-template-1';
|
||||
OrganizationsAPI.destroy.mockRejectedValue(
|
||||
new Error({
|
||||
response: {
|
||||
@ -153,8 +164,9 @@ describe('<NotificationTemplateList />', () => {
|
||||
wrapper.update();
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find(itemCheckboxInput)
|
||||
.closest('DataListCheck')
|
||||
.find('.pf-c-table__check')
|
||||
.first()
|
||||
.find('input')
|
||||
.props()
|
||||
.onChange();
|
||||
});
|
||||
|
||||
@ -3,32 +3,17 @@ import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
Button,
|
||||
DataListAction as _DataListAction,
|
||||
DataListCheck,
|
||||
DataListItem,
|
||||
DataListItemCells,
|
||||
DataListItemRow,
|
||||
Tooltip,
|
||||
} from '@patternfly/react-core';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { Tr, Td } from '@patternfly/react-table';
|
||||
import { PencilAltIcon, BellIcon } from '@patternfly/react-icons';
|
||||
import { ActionsTd, ActionItem } from '../../../components/PaginatedTable';
|
||||
import { timeOfDay } from '../../../util/dates';
|
||||
import { NotificationTemplatesAPI, NotificationsAPI } from '../../../api';
|
||||
import DataListCell from '../../../components/DataListCell';
|
||||
import StatusLabel from '../../../components/StatusLabel';
|
||||
import CopyButton from '../../../components/CopyButton';
|
||||
import useRequest from '../../../util/useRequest';
|
||||
import { NOTIFICATION_TYPES } from '../constants';
|
||||
|
||||
const DataListAction = styled(_DataListAction)`
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-gap: 16px;
|
||||
grid-template-columns: repeat(3, 40px);
|
||||
`;
|
||||
|
||||
const NUM_RETRIES = 25;
|
||||
const RETRY_TIMEOUT = 5000;
|
||||
|
||||
@ -38,6 +23,7 @@ function NotificationTemplateListItem({
|
||||
fetchTemplates,
|
||||
isSelected,
|
||||
onSelect,
|
||||
rowIndex,
|
||||
i18n,
|
||||
}) {
|
||||
const recentNotifications = template.summary_fields?.recent_notifications;
|
||||
@ -102,76 +88,65 @@ function NotificationTemplateListItem({
|
||||
const labelId = `template-name-${template.id}`;
|
||||
|
||||
return (
|
||||
<DataListItem key={template.id} aria-labelledby={labelId} id={template.id}>
|
||||
<DataListItemRow>
|
||||
<DataListCheck
|
||||
id={`select-template-${template.id}`}
|
||||
checked={isSelected}
|
||||
onChange={onSelect}
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
<DataListItemCells
|
||||
dataListCells={[
|
||||
<DataListCell key="name" id={labelId}>
|
||||
<Link to={detailUrl}>
|
||||
<b>{template.name}</b>
|
||||
</Link>
|
||||
</DataListCell>,
|
||||
<DataListCell key="status">
|
||||
{status && <StatusLabel status={status} />}
|
||||
</DataListCell>,
|
||||
<DataListCell key="type">
|
||||
<strong>{i18n._(t`Type:`)}</strong>{' '}
|
||||
{NOTIFICATION_TYPES[template.notification_type] ||
|
||||
template.notification_type}
|
||||
</DataListCell>,
|
||||
]}
|
||||
/>
|
||||
<DataListAction
|
||||
aria-label={i18n._(t`actions`)}
|
||||
aria-labelledby={labelId}
|
||||
<Tr id={`notification-template-row-${template.id}`}>
|
||||
<Td
|
||||
select={{
|
||||
rowIndex,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}}
|
||||
dataLabel={i18n._(t`Selected`)}
|
||||
/>
|
||||
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
|
||||
<Link to={`${detailUrl}`}>
|
||||
<b>{template.name}</b>
|
||||
</Link>
|
||||
</Td>
|
||||
<Td dataLabel={i18n._(t`Status`)}>
|
||||
{status && <StatusLabel status={status} />}
|
||||
</Td>
|
||||
<Td dataLabel={i18n._(t`Type`)}>
|
||||
{NOTIFICATION_TYPES[template.notification_type] ||
|
||||
template.notification_type}
|
||||
</Td>
|
||||
<ActionsTd dataLabel={i18n._(t`Actions`)}>
|
||||
<ActionItem visible tooltip={i18n._(t`Test notification`)}>
|
||||
<Button
|
||||
aria-label={i18n._(t`Test Notification`)}
|
||||
variant="plain"
|
||||
onClick={sendTestNotification}
|
||||
isDisabled={isLoading || status === 'running'}
|
||||
>
|
||||
<BellIcon />
|
||||
</Button>
|
||||
</ActionItem>
|
||||
<ActionItem
|
||||
visible={template.summary_fields.user_capabilities.edit}
|
||||
tooltip={i18n._(t`Edit`)}
|
||||
>
|
||||
<Tooltip content={i18n._(t`Test Notification`)} position="top">
|
||||
<Button
|
||||
aria-label={i18n._(t`Test Notification`)}
|
||||
variant="plain"
|
||||
onClick={sendTestNotification}
|
||||
isDisabled={isLoading || status === 'running'}
|
||||
>
|
||||
<BellIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{template.summary_fields.user_capabilities.edit ? (
|
||||
<Tooltip
|
||||
content={i18n._(t`Edit Notification Template`)}
|
||||
position="top"
|
||||
>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit Notification Template`)}
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={`/notification_templates/${template.id}/edit`}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{template.summary_fields.user_capabilities.copy && (
|
||||
<Tooltip content={i18n._(t`Copy Notification Template`)}>
|
||||
<CopyButton
|
||||
copyItem={copyTemplate}
|
||||
isCopyDisabled={isCopyDisabled}
|
||||
onCopyStart={handleCopyStart}
|
||||
onCopyFinish={handleCopyFinish}
|
||||
errorMessage={i18n._(t`Failed to copy template.`)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</DataListAction>
|
||||
</DataListItemRow>
|
||||
</DataListItem>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit Notification Template`)}
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={`/notification_templates/${template.id}/edit`}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</ActionItem>
|
||||
<ActionItem
|
||||
visible={template.summary_fields.user_capabilities.copy}
|
||||
tooltip={i18n._(t`Copy Notification Template`)}
|
||||
>
|
||||
<CopyButton
|
||||
copyItem={copyTemplate}
|
||||
isCopyDisabled={isCopyDisabled}
|
||||
onCopyStart={handleCopyStart}
|
||||
onCopyFinish={handleCopyFinish}
|
||||
errorMessage={i18n._(t`Failed to copy template.`)}
|
||||
/>
|
||||
</ActionItem>
|
||||
</ActionsTd>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -26,17 +26,21 @@ const template = {
|
||||
describe('<NotificationTemplateListItem />', () => {
|
||||
test('should render template row', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<NotificationTemplateListItem
|
||||
template={template}
|
||||
detailUrl="/notification_templates/3/detail"
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<NotificationTemplateListItem
|
||||
template={template}
|
||||
detailUrl="/notification_templates/3/detail"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
const cells = wrapper.find('DataListCell');
|
||||
expect(cells).toHaveLength(3);
|
||||
expect(cells.at(0).text()).toEqual('Test Notification');
|
||||
expect(cells.at(1).text()).toEqual('Success');
|
||||
expect(cells.at(2).text()).toEqual('Type: Slack');
|
||||
const cells = wrapper.find('Td');
|
||||
expect(cells).toHaveLength(5);
|
||||
expect(cells.at(1).text()).toEqual('Test Notification');
|
||||
expect(cells.at(2).text()).toEqual('Success');
|
||||
expect(cells.at(3).text()).toEqual('Slack');
|
||||
});
|
||||
|
||||
test('should send test notification', async () => {
|
||||
@ -45,10 +49,14 @@ describe('<NotificationTemplateListItem />', () => {
|
||||
});
|
||||
|
||||
const wrapper = mountWithContexts(
|
||||
<NotificationTemplateListItem
|
||||
template={template}
|
||||
detailUrl="/notification_templates/3/detail"
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<NotificationTemplateListItem
|
||||
template={template}
|
||||
detailUrl="/notification_templates/3/detail"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper
|
||||
@ -59,8 +67,8 @@ describe('<NotificationTemplateListItem />', () => {
|
||||
expect(NotificationTemplatesAPI.test).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
wrapper
|
||||
.find('DataListCell')
|
||||
.at(1)
|
||||
.find('Td')
|
||||
.at(2)
|
||||
.text()
|
||||
).toEqual('Running');
|
||||
});
|
||||
@ -69,10 +77,14 @@ describe('<NotificationTemplateListItem />', () => {
|
||||
NotificationTemplatesAPI.copy.mockResolvedValue();
|
||||
|
||||
const wrapper = mountWithContexts(
|
||||
<NotificationTemplateListItem
|
||||
template={template}
|
||||
detailUrl="/notification_templates/3/detail"
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<NotificationTemplateListItem
|
||||
template={template}
|
||||
detailUrl="/notification_templates/3/detail"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
await act(async () =>
|
||||
@ -86,10 +98,14 @@ describe('<NotificationTemplateListItem />', () => {
|
||||
NotificationTemplatesAPI.copy.mockRejectedValue(new Error());
|
||||
|
||||
const wrapper = mountWithContexts(
|
||||
<NotificationTemplateListItem
|
||||
template={template}
|
||||
detailUrl="/notification_templates/3/detail"
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<NotificationTemplateListItem
|
||||
template={template}
|
||||
detailUrl="/notification_templates/3/detail"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
await act(async () =>
|
||||
wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
|
||||
@ -101,18 +117,22 @@ describe('<NotificationTemplateListItem />', () => {
|
||||
|
||||
test('should not render copy button', async () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<NotificationTemplateListItem
|
||||
template={{
|
||||
...template,
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
copy: false,
|
||||
edit: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
detailUrl="/notification_templates/3/detail"
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<NotificationTemplateListItem
|
||||
template={{
|
||||
...template,
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
copy: false,
|
||||
edit: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
detailUrl="/notification_templates/3/detail"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('CopyButton').length).toBe(0);
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user