1
0
mirror of https://github.com/ansible/awx.git synced 2024-10-31 23:51:09 +03:00

Merge pull request #4188 from mabashian/awx-pf-migration

Pull beginning of new ui application using React and Patternfly into AWX

Reviewed-by: https://github.com/softwarefactory-project-zuul[bot]
This commit is contained in:
softwarefactory-project-zuul[bot] 2019-07-01 17:31:32 +00:00 committed by GitHub
commit 3371a6f386
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
328 changed files with 41876 additions and 0 deletions

3
.gitignore vendored
View File

@ -28,6 +28,9 @@ awx/ui/build_test
awx/ui/client/languages
awx/ui/templates/ui/index.html
awx/ui/templates/ui/installing.html
awx/ui_next/node_modules/
awx/ui_next/coverage/
awx/ui_next/build/locales/_build
/tower-license
/tower-license/**
tools/prometheus/data

View File

@ -73,6 +73,9 @@ clean-ui:
rm -rf awx/ui/test/spec/reports/
rm -rf awx/ui/test/e2e/reports/
rm -rf awx/ui/client/languages/
rm -rf awx/ui_next/node_modules/
rm -rf awx/ui_next/coverage/
rm -rf awx/ui_next/build/locales/_build/
rm -f $(UI_DEPS_FLAG_FILE)
rm -f $(UI_RELEASE_DEPS_FLAG_FILE)
rm -f $(UI_RELEASE_FLAG_FILE)
@ -515,6 +518,20 @@ jshint: $(UI_DEPS_FLAG_FILE)
# END UI TASKS
# --------------------------------------
# UI NEXT TASKS
# --------------------------------------
ui-next-lint:
$(NPM_BIN) --prefix awx/ui_next install
$(NPM_BIN) run --prefix awx/ui_next lint
ui-next-test:
$(NPM_BIN) --prefix awx/ui_next install
$(NPM_BIN) run --prefix awx/ui_next test
# END UI NEXT TASKS
# --------------------------------------
# Build a pip-installable package into dist/ with a timestamped version number.
dev_build:
$(PYTHON) setup.py dev_build

View File

@ -0,0 +1,9 @@
jest.*.js
webpack.*.js
etc
coverage
build
node_modules
dist
images

61
awx/ui_next/.eslintrc Normal file
View File

@ -0,0 +1,61 @@
{
"parser": "babel-eslint",
"parserOptions": {
"ecmaVersion": 6,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true,
"modules": true
}
},
"extends": ["airbnb", "prettier", "prettier/react"],
"settings": {
"import/resolver": {
"webpack": {
"config": "webpack.config.js"
}
},
"react": {
"version": "16.5.2"
}
},
"env": {
"browser": true,
"node": true,
"jest": true
},
"globals": {
"window": true
},
"rules": {
"camelcase": "off",
"arrow-parens": "off",
"comma-dangle": "off",
"//": "https://github.com/benmosher/eslint-plugin-import/issues/479#issuecomment-252500896",
"import/no-extraneous-dependencies": "off",
"max-len": [
"error",
{
"code": 100,
"ignoreStrings": true,
"ignoreTemplateLiterals": true
}
],
"no-continue": "off",
"no-debugger": "off",
"no-mixed-operators": "off",
"no-param-reassign": "off",
"no-plusplus": "off",
"no-underscore-dangle": "off",
"no-use-before-define": "off",
"no-multiple-empty-lines": ["error", { "max": 1 }],
"object-curly-newline": "off",
"no-trailing-spaces": ["error"],
"no-unused-expressions": ["error", { "allowShortCircuit": true }],
"react/prefer-stateless-function": "off",
"react/prop-types": "off",
"react/sort-comp": ["error", {}],
"jsx-a11y/label-has-for": "off",
"jsx-a11y/label-has-associated-control": "off"
}
}

5
awx/ui_next/.linguirc Normal file
View File

@ -0,0 +1,5 @@
{
"localeDir": "build/locales/",
"srcPathDirs": ["src/"],
"format": "po"
}

8
awx/ui_next/.prettierrc Normal file
View File

@ -0,0 +1,8 @@
{
"printWidth": 80,
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"bracketSpacing": true
}

305
awx/ui_next/CONTRIBUTING.md Normal file
View File

@ -0,0 +1,305 @@
# Ansible AWX UI With PatternFly
Hi there! We're excited to have you as a contributor.
Have questions about this document or anything not covered here? Feel free to reach out to any of the contributors of this repository.
## Table of contents
* [Things to know prior to submitting code](#things-to-know-prior-to-submitting-code)
* [Setting up your development environment](#setting-up-your-development-environment)
* [Prerequisites](#prerequisites)
* [Node and npm](#node-and-npm)
* [Build the user interface](#build-the-user-interface)
* [Accessing the AWX web interface](#accessing-the-awx-web-interface)
* [AWX REST API Interaction](#awx-rest-api-interaction)
* [Handling API Errors](#handling-api-errors)
* [Working with React](#working-with-react)
* [App structure](#app-structure)
* [Naming files](#naming-files)
* [Class constructors vs Class properties](#class-constructors-vs-class-properties)
* [Binding](#binding)
* [Typechecking with PropTypes](#typechecking-with-proptypes)
* [Naming Functions](#naming-functions)
* [Default State Initialization](#default-state-initialization)
* [Internationalization](#internationalization)
## Things to know prior to submitting code
- All code submissions are done through pull requests against the `devel` branch.
- If collaborating with someone else on the same branch, please use `--force-with-lease` instead of `--force` when pushing up code. This will prevent you from accidentally overwriting commits pushed by someone else. For more information, see https://git-scm.com/docs/git-push#git-push---force-with-leaseltrefnamegt
## Setting up your development environment
The UI is built using [ReactJS](https://reactjs.org/docs/getting-started.html) and [Patternfly](https://www.patternfly.org/).
### Prerequisites
#### Node and npm
The AWX UI requires the following:
- Node 10.x LTS
- NPM 6.x LTS
Run the following to install all the dependencies:
```bash
(host) $ npm run install
```
#### Build the User Interface
Run the following to build the AWX UI:
```bash
(host) $ npm run start
```
## Accessing the AWX web interface
You can now log into the AWX web interface at [https://127.0.0.1:3001](https://127.0.0.1:3001).
## AWX REST API Interaction
This interface is built on top of the AWX REST API. If a component needs to interact with the API then the model that corresponds to that base endpoint will need to be imported from the api module.
Example:
`import { OrganizationsAPI, UsersAPI } from '../../../api';`
All models extend a `Base` class which provides an interface to the standard HTTP methods (GET, POST, PUT etc). Methods that are specific to that endpoint should be added directly to model's class.
**Mixins** - For related endpoints that apply to several different models a mixin should be used. Mixins are classes with a number of methods and can be used to avoid adding the same methods to a number of different models. A good example of this is the Notifications mixin. This mixin provides generic methods for reading notification templates and toggling them on and off.
Note that mixins can be chained. See the example below.
Example of a model using multiple mixins:
```
import NotificationsMixin from '../mixins/Notifications.mixin';
import InstanceGroupsMixin from '../mixins/InstanceGroups.mixin';
class Organizations extends InstanceGroupsMixin(NotificationsMixin(Base)) {
...
}
export default Organizations;
```
**Testing** - The easiest way to mock the api module in tests is to use jest's [automatic mock](https://jestjs.io/docs/en/es6-class-mocks#automatic-mock). This syntax will replace the class with a mock constructor and mock out all methods to return undefined by default. If necessary, you can still override these mocks for specific tests. See the example below.
Example of mocking a specific method for every test in a suite:
```
import { OrganizationsAPI } from '../../../../src/api';
// Mocks out all available methods. Comparable to:
// OrganizationsAPI.readAccessList = jest.fn();
// but for every available method
jest.mock('../../../../src/api');
// Return a specific mock value for the readAccessList method
beforeEach(() => {
OrganizationsAPI.readAccessList.mockReturnValue({ foo: 'bar' });
});
// Reset mocks
afterEach(() => {
jest.clearAllMocks();
});
...
```
## Handling API Errors
API requests can and will fail occasionally so they should include explicit error handling. The three _main_ categories of errors from our perspective are: content loading errors, form submission errors, and other errors. The patterns currently in place for these are described below:
- **content loading errors** - These are any errors that occur when fetching data to initialize a page or populate a list. For these, we conditionally render a _content error component_ in place of the unresolved content.
- **form submission errors** - If an error is encountered when submitting a form, we display the error message on the form. For field-specific validation errors, we display the error message beneath the specific field(s). For general errors, we display the error message at the bottom of the form near the action buttons. An error that happens when requesting data to populate a form is not a form submission error, it is still a content error and is handled as such (see above).
- **other errors** - Most errors will fall into the first two categories, but for miscellaneous actions like toggling notifications, deleting a list item, etc. we display an alert modal to notify the user that their requested action couldn't be performed.
## Working with React
### App structure
All source code lives in the `/src` directory and all tests are colocated with the components that they test.
Inside these folders, the internal structure is:
- **/api** - All classes used to interact with API's are found here. See [AWX REST API Interaction](#awx-rest-api-interaction) for more information.
- **/components** - All generic components that are meant to be used in multiple contexts throughout awx. Things like buttons, tabs go here.
- **/contexts** - Components which utilize react's context api.
- **/screens** - Based on the various routes of awx.
- **/shared** - Components that are meant to be used specifically by a particular route, but might be sharable across pages of that route. For example, a form component which is used on both add and edit screens.
- **/util** - Stateless helper functions that aren't tied to react.
#### Bootstrapping the application (root src/ files)
In the root of `/src`, there are a few files which are used to initialize the react app. These are
- **index.jsx**
- Connects react app to root dom node.
- Sets up root route structure, navigation grouping and login modal
- Calls base context providers
- Imports .scss styles.
- **app.jsx**
- Sets standard page layout, about modal, and root dialog modal.
- **RootProvider.jsx**
- Sets up all context providers.
- Initializes i18n and router
### Naming files
Ideally, files should be named the same as the component they export, and tests with `.test` appended. In other words, `<FooBar>` would be defined in `FooBar.jsx`, and its tests would be defined in `FooBar.test.jsx`.
#### Naming components that use the context api
**File naming** - Since contexts export both consumer and provider (and potentially in withContext function form), the file can be simplified to be named after the consumer export. In other words, the file containing the `Network` context components would be named `Network.jsx`.
**Component naming and conventions** - In order to provide a consistent interface with react-router and lingui, as well as make their usage easier and less verbose, context components follow these conventions:
- Providers are wrapped in a component in the `FooProvider` format.
- The value prop of the provider should be pulled from state. This is recommended by the react docs, [here](https://reactjs.org/docs/context.html#caveats).
- The provider should also be able to accept its value by prop for testing.
- Any sort of code related to grabbing data to put on the context should be done in this component.
- Consumers are wrapped in a component in the `Foo` format.
- If it makes sense, consumers can be exported as a function in the `withFoo()` format. If a component is wrapped in this function, its context values are available on the component as props.
### Class constructors vs Class properties
It is good practice to use constructor-bound instance methods rather than methods as class properties. Methods as arrow functions provide lexical scope and are bound to the Component class instance instead of the class itself. This makes it so we cannot easily test a Component's methods without invoking an instance of the Component and calling the method directly within our tests.
BAD:
```javascript
class MyComponent extends React.Component {
constructor(props) {
super(props);
}
myEventHandler = () => {
// do a thing
}
}
```
GOOD:
```javascript
class MyComponent extends React.Component {
constructor(props) {
super(props);
this.myEventHandler = this.myEventHandler.bind(this);
}
myEventHandler() {
// do a thing
}
}
```
### Binding
It is good practice to bind our class methods within our class constructor method for the following reasons:
1. Avoid defining the method every time `render()` is called.
2. [Performance advantages](https://stackoverflow.com/a/44844916).
3. Ease of [testing](https://github.com/airbnb/enzyme/issues/365).
### Typechecking with PropTypes
Shared components should have their prop values typechecked. This will help catch bugs when components get refactored/renamed.
```javascript
About.propTypes = {
ansible_version: PropTypes.string,
isOpen: PropTypes.bool,
onClose: PropTypes.func.isRequired,
version: PropTypes.string,
};
About.defaultProps = {
ansible_version: null,
isOpen: false,
version: null,
};
```
### Naming Functions
Here are the guidelines for how to name functions.
| Naming Convention | Description |
|----------|-------------|
|`handle<x>`| Use for methods that process events |
|`on<x>`| Use for component prop names |
|`toggle<x>`| Use for methods that flip one value to the opposite value |
|`show<x>`| Use for methods that always set a value to show or add an element |
|`hide<x>`| Use for methods that always set a value to hide or remove an element |
|`create<x>`| Use for methods that make API `POST` requests |
|`read<x>`| Use for methods that make API `GET` requests |
|`update<x>`| Use for methods that make API `PATCH` requests |
|`destroy<x>`| Use for methods that make API `DESTROY` requests |
|`replace<x>`| Use for methods that make API `PUT` requests |
|`disassociate<x>`| Use for methods that pass `{ disassociate: true }` as a data param to an endpoint |
|`associate<x>`| Use for methods that pass a resource id as a data param to an endpoint |
|`can<x>`| Use for props dealing with RBAC to denote whether a user has access to something |
### Default State Initialization
When declaring empty initial states, prefer the following instead of leaving them undefined:
```javascript
this.state = {
somethingA: null,
somethingB: [],
somethingC: 0,
somethingD: {},
somethingE: '',
}
```
### Testing components that use contexts
We have several React contexts that wrap much of the app, including those from react-router, lingui, and some of our own. When testing a component that depends on one or more of these, you can use the `mountWithContexts()` helper function found in `testUtils/enzymeHelpers.jsx`. This can be used just like Enzyme's `mount()` function, except it will wrap the component tree with the necessary context providers and basic stub data.
If you want to stub the value of a context, or assert actions taken on it, you can customize a contexts value by passing a second parameter to `mountWithContexts`. For example, this provides a custom value for the `Config` context:
```
const config = {
custom_virtualenvs: ['foo', 'bar'],
};
mountWithContexts(<OrganizationForm />, {
context: { config },
});
```
Now that these custom virtual environments are available in this `OrganizationForm` test we can assert that the component that displays
them is rendering properly.
The object containing context values looks for five known contexts, identified by the keys `linguiPublisher`, `router`, `config`, `network`, and `dialog` — the latter three each referring to the contexts defined in `src/contexts`. You can pass `false` for any of these values, and the corresponding context will be omitted from your test. For example, this will mount your component without the dialog context:
```
mountWithContexts(<Organization />< {
context: {
dialog: false,
}
});
```
## Internationalization
Internationalization leans on the [lingui](https://github.com/lingui/js-lingui) project. [Official documentation here](https://lingui.js.org/). We use this libary to mark our strings for translation. If you want to see this in action you'll need to take the following steps:
### Marking strings for translation and replacement in the UI
The lingui library provides various React helpers for dealing with both marking strings for translation, and replacing strings that have been traslated. For consistency and ease of use, we have consolidated on one pattern for the codebase. To set strings to be translated in the UI:
- import the withI18n function and wrap the export of your component in it (i.e. `export default withI18n()(Foo)`)
- doing the above gives you access to the i18n object on props. Make sure to put it in the scope of the function that contains strings needed to be translated (i.e. `const { i18n } = this.props;`)
- import the t template tag function from the @lingui/macro package.
- wrap your string using the following format: ```i18n._(t`String to be translated`)```
**Note:** Variables that are put inside the t-marked template tag will not be translated. If you have a variable string with text that needs translating, you must wrap it in ```i18n._(t``)``` where it is defined.
**Note:** We do not use the `I18n` consumer, `i18nMark` function, or `<Trans>` component lingui gives us access to in this repo. i18nMark does not actually replace the string in the UI (leading to the potential for untranslated bugs), and the other helpers are redundant. Settling on a consistent, single pattern helps us ease the mental overhead of the need to understand the ins and outs of the lingui API.
You can learn more about the ways lingui and its React helpers at [this link](https://lingui.js.org/tutorials/react-patterns.html).
### Setting up .po files to give to translation team
1) `npm run add-locale` to add the language that you want to translate to (we should only have to do this once and the commit to repo afaik). Example: `npm run add-locale en es fr` # Add English, Spanish and French locale
2) `npm run extract-strings` to create .po files for each language specified. The .po files will be placed in src/locales but this is configurable.
3) Open up the .po file for the language you want to test and add some translations. In production we would pass this .po file off to the translation team.
4) Once you've edited your .po file (or we've gotten a .po file back from the translation team) run `npm run compile-strings`. This command takes the .po files and turns them into a minified JSON object and can be seen in the `messages.js` file in each locale directory. These files get loaded at the App root level (see: App.jsx).
5) Change the language in your browser and reload the page. You should see your specified translations in place of English strings.

29
awx/ui_next/README.md Normal file
View File

@ -0,0 +1,29 @@
# AWX-PF
## Requirements
- node 10.x LTS, npm 6.x LTS, make, git
## Usage
* `git clone git@github.com:ansible/awx.git`
* cd awx/ui_next
* npm install
* npm start
* visit `https://127.0.0.1:3001/`
**note:** These instructions assume you have the [awx](https://github.com/ansible/awx/blob/devel/CONTRIBUTING.md#running-the-environment) development api server up and running at `localhost:8043`. You can use a different backend server with the `TAGET_HOST` and `TARGET_PORT` environment variables when starting the development server:
```shell
# use a non-default host and port when starting the development server
TARGET_HOST='ec2-awx.amazonaws.com' TARGET_PORT='443' npm run start
```
## Unit Tests
To run the unit tests on files that you've changed:
* `npm test`
To run a single test (in this case the login page test):
* `npm test -- testUtils/pages/Login.test.jsx`
**note:** Once the test watcher is up and running you can hit `a` to run all the tests

View File

@ -0,0 +1,7 @@
const path = require('path');
module.exports = {
process (src, filename) {
return `module.exports=${JSON.stringify(path.basename(filename))};`;
},
};

View File

@ -0,0 +1 @@
module.exports = {};

View File

@ -0,0 +1,18 @@
module.exports = api => {
api.cache(false);
return {
plugins: [
'babel-plugin-styled-components',
'@babel/plugin-proposal-class-properties',
'macros'
],
presets: [
['@babel/preset-env', {
targets: {
node: '8.11'
}
}],
'@babel/preset-react'
]
};
};

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,700 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2018-12-10 10:08-0500\n"
"Mime-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: en\n"
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Plural-Forms: \n"
#: src/contexts/Network.jsx:52
msgid "404"
msgstr ""
#: src/pages/Organizations/components/OrganizationBreadcrumb.jsx:60
#~ msgid "> add"
#~ msgstr ""
#: src/pages/Organizations/components/OrganizationBreadcrumb.jsx:53
#~ msgid "> edit"
#~ msgstr ""
#: src/components/BrandLogo/BrandLogo.jsx:71
msgid "AWX Logo"
msgstr ""
#: src/components/PageHeaderToolbar.jsx:88
msgid "About"
msgstr ""
#: src/components/About.jsx:59
#~ msgid "AboutModal Logo"
#~ msgstr ""
#: src/index.jsx:148
#: src/pages/Organizations/Organizations.jsx:43
#: src/pages/Organizations/screens/Organization/Organization.jsx:124
msgid "Access"
msgstr ""
#: src/components/PaginatedDataList/ToolbarAddButton.jsx:25
#: src/components/PaginatedDataList/ToolbarAddButton.jsx:32
#: src/components/PaginatedDataList/ToolbarAddButton.jsx:42
msgid "Add"
msgstr ""
#: src/components/AddRole/AddResourceRole.jsx:147
msgid "Add Roles"
msgstr ""
#: src/components/AddRole/AddResourceRole.jsx:144
msgid "Add Team Roles"
msgstr ""
#: src/components/AddRole/AddResourceRole.jsx:141
msgid "Add User Roles"
msgstr ""
#: src/index.jsx:169
msgid "Administration"
msgstr ""
#: src/pages/Organizations/components/OrganizationListItem.jsx:66
#~ msgid "Admins"
#~ msgstr ""
#: src/pages/Organizations/components/OrganizationForm.jsx:123
#: src/pages/Organizations/components/OrganizationForm.jsx:128
#: src/pages/Organizations/screens/Organization/OrganizationDetail.jsx:79
msgid "Ansible Environment"
msgstr ""
#: src/components/About.jsx:73
msgid "Ansible Version"
msgstr ""
#: src/pages/Applications.jsx:19
msgid "Applications"
msgstr ""
#: src/components/AddRole/AddResourceRole.jsx:203
msgid "Apply roles"
msgstr ""
#: src/components/PaginatedDataList/ToolbarDeleteButton.jsx:155
msgid "Are you sure you want to delete:"
msgstr ""
#: src/pages/Organizations/components/DeleteRoleConfirmationModal.jsx:51
msgid "Are you sure you want to remove {0} access from {1}? Doing so affects all members of the team."
msgstr ""
#: src/pages/Organizations/components/DeleteRoleConfirmationModal.jsx:58
msgid "Are you sure you want to remove {0} access from {username}?"
msgstr ""
#: src/index.jsx:204
msgid "Authentication"
msgstr ""
#: src/pages/AuthSettings.jsx:19
msgid "Authentication Settings"
msgstr ""
#: src/components/About.jsx:57
msgid "Brand Image"
msgstr ""
#: src/components/FormActionGroup/FormActionGroup.jsx:27
#: src/components/FormActionGroup/FormActionGroup.jsx:27
#: src/components/Lookup/Lookup.jsx:162
#: src/components/PaginatedDataList/ToolbarDeleteButton.jsx:151
#: src/pages/Organizations/components/DeleteRoleConfirmationModal.jsx:45
msgid "Cancel"
msgstr ""
#: src/pages/Organizations/Organizations.jsx:77
msgid "Cannot find organization with ID"
msgstr ""
#: src/contexts/Network.jsx:53
msgid "Cannot find resource."
msgstr ""
#: src/components/NotifyAndRedirect.jsx:23
msgid "Cannot find route {0}."
msgstr ""
#: src/App.jsx:109
#: src/components/CardCloseButton.jsx:22
#: src/components/CardCloseButton.jsx:23
#: src/components/CardCloseButton.jsx:34
#: src/components/Lookup/Lookup.jsx:162
#: src/pages/Organizations/screens/OrganizationAdd.jsx:67
msgid "Close"
msgstr ""
#: src/components/ExpandCollapse/ExpandCollapse.jsx:34
msgid "Collapse"
msgstr ""
#: src/components/About.jsx:55
#~ msgid "Copyright 2018 Red Hat, Inc."
#~ msgstr ""
#: src/components/About.jsx:55
msgid "Copyright 2019 Red Hat, Inc."
msgstr ""
#: src/pages/Organizations/Organizations.jsx:39
#: src/pages/Organizations/Organizations.jsx:25
msgid "Create New Organization"
msgstr ""
#: src/pages/Organizations/components/InstanceGroupsLookup.jsx:50
#: src/pages/Organizations/screens/Organization/OrganizationDetail.jsx:83
#: src/pages/Organizations/screens/OrganizationsList.jsx:164
msgid "Created"
msgstr ""
#: src/index.jsx:173
#: src/pages/CredentialTypes.jsx:19
msgid "Credential Types"
msgstr ""
#: src/index.jsx:126
#: src/pages/Credentials.jsx:19
msgid "Credentials"
msgstr ""
#: src/components/Pagination/Pagination.jsx:29
msgid "Current page"
msgstr ""
#: src/index.jsx:95
#: src/pages/Dashboard.jsx:19
msgid "Dashboard"
msgstr ""
#: src/components/PaginatedDataList/ToolbarDeleteButton.jsx:95
#: src/components/PaginatedDataList/ToolbarDeleteButton.jsx:119
#: src/components/PaginatedDataList/ToolbarDeleteButton.jsx:143
#: src/pages/Organizations/components/DeleteRoleConfirmationModal.jsx:42
msgid "Delete"
msgstr ""
#: src/components/PaginatedDataList/ToolbarDeleteButton.jsx:132
msgid "Delete {0}"
msgstr ""
#: src/components/PaginatedDataList/ToolbarDeleteButton.jsx:131
msgid "Delete {itemName}"
msgstr ""
#: src/pages/Organizations/components/OrganizationForm.jsx:113
#: src/pages/Organizations/screens/Organization/OrganizationDetail.jsx:75
msgid "Description"
msgstr ""
#: src/pages/Organizations/Organizations.jsx:42
#: src/pages/Organizations/screens/Organization/Organization.jsx:123
msgid "Details"
msgstr ""
#: src/pages/Organizations/screens/Organization/OrganizationDetail.jsx:110
msgid "Edit"
msgstr ""
#: src/pages/Organizations/Organizations.jsx:41
msgid "Edit Details"
msgstr ""
#: src/components/ExpandCollapse/ExpandCollapse.jsx:44
msgid "Expand"
msgstr ""
#: src/components/NotificationsList/NotificationListItem.jsx:82
msgid "Failure"
msgstr ""
#: src/components/Pagination/Pagination.jsx:170
#~ msgid "First"
#~ msgstr ""
#: src/components/Pagination/Pagination.jsx:24
msgid "Go to first page"
msgstr ""
#: src/components/Pagination/Pagination.jsx:26
msgid "Go to last page"
msgstr ""
#: src/components/Pagination/Pagination.jsx:27
msgid "Go to next page"
msgstr ""
#: src/components/Pagination/Pagination.jsx:25
msgid "Go to previous page"
msgstr ""
#: src/components/PageHeaderToolbar.jsx:80
msgid "Help"
msgstr ""
#: src/pages/Organizations/components/DeleteRoleConfirmationModal.jsx:54
msgid "If you {0} want to remove access for this particular user, please remove them from the team."
msgstr ""
#: src/components/PageHeaderToolbar.jsx:66
msgid "Info"
msgstr ""
#: src/index.jsx:188
#: src/pages/InstanceGroups.jsx:19
#: src/pages/Organizations/components/InstanceGroupsLookup.jsx:24
#: src/pages/Organizations/components/InstanceGroupsLookup.jsx:42
#: src/pages/Organizations/screens/Organization/OrganizationDetail.jsx:93
msgid "Instance Groups"
msgstr ""
#: src/index.jsx:193
msgid "Integrations"
msgstr ""
#: src/pages/Login.jsx:94
msgid "Invalid username or password. Please try again."
msgstr ""
#: src/index.jsx:136
#: src/pages/Inventories.jsx:19
msgid "Inventories"
msgstr ""
#: src/index.jsx:141
#: src/pages/InventoryScripts.jsx:19
msgid "Inventory Scripts"
msgstr ""
#: src/components/Pagination/Pagination.jsx:142
#~ msgid "Items Per Page"
#~ msgstr ""
#: src/components/Pagination/Pagination.jsx:22
msgid "Items per page"
msgstr ""
#: src/components/Pagination/Pagination.jsx:162
#~ msgid "Items {itemMin} {itemMax} of {count}"
#~ msgstr ""
#: src/index.jsx:100
#: src/index.jsx:209
#: src/pages/Jobs.jsx:19
msgid "Jobs"
msgstr ""
#: src/pages/JobsSettings.jsx:19
msgid "Jobs Settings"
msgstr ""
#: src/components/Pagination/Pagination.jsx:213
#~ msgid "Last"
#~ msgstr ""
#: src/pages/Organizations/screens/Organization/OrganizationDetail.jsx:87
msgid "Last Modified"
msgstr ""
#: src/pages/Organizations/screens/Organization/OrganizationAccess.jsx:175
msgid "Last Name"
msgstr ""
#: src/index.jsx:224
#: src/pages/License.jsx:19
msgid "License"
msgstr ""
#: src/components/AddRole/SelectResourceStep.jsx:89
msgid "Loading..."
msgstr ""
#: src/components/PageHeaderToolbar.jsx:120
msgid "Logout"
msgstr ""
#: src/index.jsx:183
#: src/pages/ManagementJobs.jsx:19
msgid "Management Jobs"
msgstr ""
#: src/pages/Organizations/components/OrganizationListItem.jsx:91
msgid "Members"
msgstr ""
#: src/pages/Organizations/components/InstanceGroupsLookup.jsx:49
#: src/pages/Organizations/screens/OrganizationsList.jsx:163
msgid "Modified"
msgstr ""
#: src/index.jsx:110
#: src/pages/Portal.jsx:19
msgid "My View"
msgstr ""
#: src/components/AddRole/AddResourceRole.jsx:134
#: src/components/PaginatedDataList/PaginatedDataList.jsx:99
#: src/pages/Organizations/components/InstanceGroupsLookup.jsx:48
#: src/pages/Organizations/components/OrganizationAccessItem.jsx:100
#: src/pages/Organizations/components/OrganizationForm.jsx:105
#: src/pages/Organizations/screens/Organization/OrganizationAccess.jsx:173
#: src/pages/Organizations/screens/Organization/OrganizationDetail.jsx:71
#: src/pages/Organizations/screens/OrganizationsList.jsx:162
msgid "Name"
msgstr ""
#: src/components/Pagination/Pagination.jsx:204
#~ msgid "Next"
#~ msgstr ""
#: src/components/PaginatedDataList/PaginatedDataList.jsx:119
msgid "No {0} Found"
msgstr ""
#: src/pages/NotificationTemplates.jsx:19
msgid "Notification Templates"
msgstr ""
#: src/index.jsx:178
#: src/pages/Organizations/Organizations.jsx:45
#: src/pages/Organizations/screens/Organization/Organization.jsx:130
msgid "Notifications"
msgstr ""
#: src/pages/Organizations/views/Organization.add.jsx:79
#~ msgid "Organization Add"
#~ msgstr ""
#: src/pages/Organizations/screens/Organization/Organization.jsx:144
msgid "Organization detail tabs"
msgstr ""
#: src/index.jsx:152
#: src/pages/Organizations/Organizations.jsx:38
#: src/pages/Organizations/Organizations.jsx:24
msgid "Organizations"
msgstr ""
#: src/pages/Organizations/views/Organizations.list.jsx:218
#~ msgid "Organizations List"
#~ msgstr ""
#: src/components/Pagination/Pagination.jsx:190
#~ msgid "Page"
#~ msgstr ""
#: src/components/Pagination/Pagination.jsx:189
#~ msgid "Page <0/> of {pageCount}"
#~ msgstr ""
#: src/components/Pagination/Pagination.jsx:193
#~ msgid "Page Number"
#~ msgstr ""
#: src/components/Pagination/Pagination.jsx:30
msgid "Pagination"
msgstr ""
#: src/pages/Login.jsx:92
msgid "Password"
msgstr ""
#: src/components/Pagination/Pagination.jsx:158
#~ msgid "Per Page"
#~ msgstr ""
#: src/components/PaginatedDataList/PaginatedDataList.jsx:122
msgid "Please add {0} to populate this list"
msgstr ""
#: src/components/PaginatedDataList/PaginatedDataList.jsx:136
#: src/components/PaginatedDataList/PaginatedDataList.jsx:199
#~ msgid "Please add {0} {itemName} to populate this list"
#~ msgstr ""
#: src/App.jsx:203
#~ msgid "Portal Mode"
#~ msgstr ""
#: src/components/Pagination/Pagination.jsx:179
#~ msgid "Previous"
#~ msgstr ""
#: src/index.jsx:88
msgid "Primary Navigation"
msgstr ""
#: src/index.jsx:131
#: src/pages/Projects.jsx:19
msgid "Projects"
msgstr ""
#: src/pages/Organizations/components/DeleteRoleConfirmationModal.jsx:28
msgid "Remove {0} Access"
msgstr ""
#: src/index.jsx:117
msgid "Resources"
msgstr ""
#: src/components/AddRole/AddResourceRole.jsx:214
#: src/components/FormActionGroup/FormActionGroup.jsx:24
#: src/components/FormActionGroup/FormActionGroup.jsx:24
#: src/components/Lookup/Lookup.jsx:161
msgid "Save"
msgstr ""
#: src/index.jsx:105
#: src/pages/Schedules.jsx:19
msgid "Schedules"
msgstr ""
#: src/components/Search/Search.jsx:138
msgid "Search"
msgstr ""
#: src/components/Search/Search.jsx:131
msgid "Search text input"
msgstr ""
#: src/components/Pagination/Pagination.jsx:28
msgid "Select"
msgstr ""
#: src/components/AnsibleSelect/AnsibleSelect.jsx:28
msgid "Select Input"
msgstr ""
#: src/components/AddRole/AddResourceRole.jsx:153
msgid "Select Users Or Teams"
msgstr ""
#: src/components/PaginatedDataList/ToolbarDeleteButton.jsx:97
msgid "Select a row to delete"
msgstr ""
#: src/components/DataListToolbar/DataListToolbar.jsx:108
msgid "Select all"
msgstr ""
#: src/components/AddRole/AddResourceRole.jsx:172
msgid "Select items from list"
msgstr ""
#: src/pages/Organizations/components/OrganizationForm.jsx:141
msgid "Select the Instance Groups for this Organization to run on."
msgstr ""
#: src/components/Lookup/Lookup.jsx:157
msgid "Select {header}"
msgstr ""
#: src/components/AddRole/AddResourceRole.jsx:181
#: src/components/AddRole/AddResourceRole.jsx:192
#: src/components/AddRole/AddResourceRole.jsx:209
#: src/components/AddRole/SelectRoleStep.jsx:29
#: src/components/Lookup/Lookup.jsx:187
msgid "Selected"
msgstr ""
#: src/index.jsx:200
msgid "Settings"
msgstr ""
#: src/components/Sort/Sort.jsx:135
msgid "Sort"
msgstr ""
#: src/components/NotificationsList/NotificationListItem.jsx:70
msgid "Successful"
msgstr ""
#: src/index.jsx:214
msgid "System"
msgstr ""
#: src/pages/SystemSettings.jsx:19
msgid "System Settings"
msgstr ""
#: src/pages/Organizations/components/DeleteRoleConfirmationModal.jsx:28
msgid "Team"
msgstr ""
#: src/pages/Organizations/components/OrganizationAccessItem.jsx:122
msgid "Team Roles"
msgstr ""
#: src/components/AddRole/AddResourceRole.jsx:163
#: src/index.jsx:162
#: src/pages/Organizations/Organizations.jsx:44
#: src/pages/Organizations/components/OrganizationListItem.jsx:99
#: src/pages/Organizations/screens/Organization/Organization.jsx:125
#: src/pages/Teams.jsx:19
msgid "Teams"
msgstr ""
#: src/index.jsx:121
#: src/pages/Templates.jsx:19
msgid "Templates"
msgstr ""
#: src/util/validators.jsx:6
msgid "This field must not be blank"
msgstr ""
#: src/util/validators.jsx:16
msgid "This field must not exceed {max} characters"
msgstr ""
#: src/components/NotificationsList/NotificationListItem.jsx:90
msgid "Toggle notification failure"
msgstr ""
#: src/components/NotificationsList/NotificationListItem.jsx:78
msgid "Toggle notification success"
msgstr ""
#: src/components/AnsibleSelect/AnsibleSelect.jsx:35
msgid "Use Default {label}"
msgstr ""
#: src/pages/Organizations/components/DeleteRoleConfirmationModal.jsx:28
msgid "User"
msgstr ""
#: src/components/PageHeaderToolbar.jsx:113
msgid "User Details"
msgstr ""
#: src/index.jsx:219
msgid "User Interface"
msgstr ""
#: src/pages/UISettings.jsx:19
msgid "User Interface Settings"
msgstr ""
#: src/pages/Organizations/components/OrganizationAccessItem.jsx:112
msgid "User Roles"
msgstr ""
#: src/components/AddRole/AddResourceRole.jsx:130
#: src/pages/Login.jsx:91
#: src/pages/Organizations/screens/Organization/OrganizationAccess.jsx:174
msgid "Username"
msgstr ""
#: src/components/AddRole/AddResourceRole.jsx:158
#: src/index.jsx:157
#: src/pages/Users.jsx:19
msgid "Users"
msgstr ""
#: src/index.jsx:91
msgid "Views"
msgstr ""
#: src/pages/Login.jsx:86
msgid "Welcome to Ansible {brandName}! Please Sign In."
msgstr ""
#: src/components/PaginatedDataList/ToolbarDeleteButton.jsx:90
msgid "You do not have permission to delete the following {0}: {itemsUnableToDelete}"
msgstr ""
#: src/contexts/Network.jsx:40
msgid "You have been logged out."
msgstr ""
#: src/pages/Organizations/components/OrganizationDetail.jsx:56
#~ msgid "add {currentTab}"
#~ msgstr ""
#: src/pages/Organizations/components/OrganizationDetail.jsx:45
#~ msgid "adding {currentTab}"
#~ msgstr ""
#: src/components/PaginatedDataList/ToolbarDeleteButton.jsx:148
msgid "cancel delete"
msgstr ""
#: src/components/PaginatedDataList/ToolbarDeleteButton.jsx:140
msgid "confirm delete"
msgstr ""
#: src/pages/Organizations/components/OrganizationDetail.jsx:38
#~ msgid "confirm removal of {currentTab}/cancel and go back to {currentTab} view."
#~ msgstr ""
#: src/pages/Organizations/components/OrganizationDetail.jsx:60
#~ msgid "delete {currentTab}"
#~ msgstr ""
#: src/pages/Organizations/components/OrganizationDetail.jsx:36
#~ msgid "deleting {currentTab} association with orgs"
#~ msgstr ""
#: src/pages/Organizations/components/OrganizationEdit.jsx:20
#~ msgid "edit view"
#~ msgstr ""
#: src/components/Lookup/Lookup.jsx:128
#: src/components/Pagination/Pagination.jsx:20
msgid "items"
msgstr ""
#: src/components/Pagination/Pagination.jsx:198
#~ msgid "of {pageCount}"
#~ msgstr ""
#: src/components/Pagination/Pagination.jsx:21
msgid "pages"
msgstr ""
#: src/components/Pagination/Pagination.jsx:23
msgid "per page"
msgstr ""
#: src/pages/Organizations/components/OrganizationEdit.jsx:22
#~ msgid "save/cancel and go back to view"
#~ msgstr ""
#: src/pages/Organizations/components/OrganizationDetail.jsx:47
#~ msgid "save/cancel and go back to {currentTab} view"
#~ msgstr ""
#: src/pages/Organizations/components/OrganizationListItem.jsx:29
#~ msgid "select organization {itemId}"
#~ msgstr ""
#: src/pages/Organizations/components/OrganizationDetail.jsx:82
#~ msgid "{0}"
#~ msgstr ""
#: src/components/PaginatedDataList/PaginatedDataList.jsx:135
msgid "{0} List"
msgstr ""
#: src/pages/Organizations/components/OrganizationDetail.jsx:54
#~ msgid "{currentTab} detail view"
#~ msgstr ""
#: src/components/Pagination/Pagination.jsx:163
#~ msgid "{itemMin} - {itemMax} of {count}"
#~ msgstr ""

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,700 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2018-12-10 10:08-0500\n"
"Mime-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: ja\n"
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Plural-Forms: \n"
#: src/contexts/Network.jsx:52
msgid "404"
msgstr ""
#: src/pages/Organizations/components/OrganizationBreadcrumb.jsx:60
#~ msgid "> add"
#~ msgstr ""
#: src/pages/Organizations/components/OrganizationBreadcrumb.jsx:53
#~ msgid "> edit"
#~ msgstr ""
#: src/components/BrandLogo/BrandLogo.jsx:71
msgid "AWX Logo"
msgstr ""
#: src/components/PageHeaderToolbar.jsx:88
msgid "About"
msgstr ""
#: src/components/About.jsx:59
#~ msgid "AboutModal Logo"
#~ msgstr ""
#: src/index.jsx:148
#: src/pages/Organizations/Organizations.jsx:43
#: src/pages/Organizations/screens/Organization/Organization.jsx:124
msgid "Access"
msgstr ""
#: src/components/PaginatedDataList/ToolbarAddButton.jsx:25
#: src/components/PaginatedDataList/ToolbarAddButton.jsx:32
#: src/components/PaginatedDataList/ToolbarAddButton.jsx:42
msgid "Add"
msgstr ""
#: src/components/AddRole/AddResourceRole.jsx:147
msgid "Add Roles"
msgstr ""
#: src/components/AddRole/AddResourceRole.jsx:144
msgid "Add Team Roles"
msgstr ""
#: src/components/AddRole/AddResourceRole.jsx:141
msgid "Add User Roles"
msgstr ""
#: src/index.jsx:169
msgid "Administration"
msgstr ""
#: src/pages/Organizations/components/OrganizationListItem.jsx:66
#~ msgid "Admins"
#~ msgstr ""
#: src/pages/Organizations/components/OrganizationForm.jsx:123
#: src/pages/Organizations/components/OrganizationForm.jsx:128
#: src/pages/Organizations/screens/Organization/OrganizationDetail.jsx:79
msgid "Ansible Environment"
msgstr ""
#: src/components/About.jsx:73
msgid "Ansible Version"
msgstr ""
#: src/pages/Applications.jsx:19
msgid "Applications"
msgstr ""
#: src/components/AddRole/AddResourceRole.jsx:203
msgid "Apply roles"
msgstr ""
#: src/components/PaginatedDataList/ToolbarDeleteButton.jsx:155
msgid "Are you sure you want to delete:"
msgstr ""
#: src/pages/Organizations/components/DeleteRoleConfirmationModal.jsx:51
msgid "Are you sure you want to remove {0} access from {1}? Doing so affects all members of the team."
msgstr ""
#: src/pages/Organizations/components/DeleteRoleConfirmationModal.jsx:58
msgid "Are you sure you want to remove {0} access from {username}?"
msgstr ""
#: src/index.jsx:204
msgid "Authentication"
msgstr ""
#: src/pages/AuthSettings.jsx:19
msgid "Authentication Settings"
msgstr ""
#: src/components/About.jsx:57
msgid "Brand Image"
msgstr ""
#: src/components/FormActionGroup/FormActionGroup.jsx:27
#: src/components/FormActionGroup/FormActionGroup.jsx:27
#: src/components/Lookup/Lookup.jsx:162
#: src/components/PaginatedDataList/ToolbarDeleteButton.jsx:151
#: src/pages/Organizations/components/DeleteRoleConfirmationModal.jsx:45
msgid "Cancel"
msgstr ""
#: src/pages/Organizations/Organizations.jsx:77
msgid "Cannot find organization with ID"
msgstr ""
#: src/contexts/Network.jsx:53
msgid "Cannot find resource."
msgstr ""
#: src/components/NotifyAndRedirect.jsx:23
msgid "Cannot find route {0}."
msgstr ""
#: src/App.jsx:109
#: src/components/CardCloseButton.jsx:22
#: src/components/CardCloseButton.jsx:23
#: src/components/CardCloseButton.jsx:34
#: src/components/Lookup/Lookup.jsx:162
#: src/pages/Organizations/screens/OrganizationAdd.jsx:67
msgid "Close"
msgstr ""
#: src/components/ExpandCollapse/ExpandCollapse.jsx:34
msgid "Collapse"
msgstr ""
#: src/components/About.jsx:55
#~ msgid "Copyright 2018 Red Hat, Inc."
#~ msgstr ""
#: src/components/About.jsx:55
msgid "Copyright 2019 Red Hat, Inc."
msgstr ""
#: src/pages/Organizations/Organizations.jsx:39
#: src/pages/Organizations/Organizations.jsx:25
msgid "Create New Organization"
msgstr ""
#: src/pages/Organizations/components/InstanceGroupsLookup.jsx:50
#: src/pages/Organizations/screens/Organization/OrganizationDetail.jsx:83
#: src/pages/Organizations/screens/OrganizationsList.jsx:164
msgid "Created"
msgstr ""
#: src/index.jsx:173
#: src/pages/CredentialTypes.jsx:19
msgid "Credential Types"
msgstr ""
#: src/index.jsx:126
#: src/pages/Credentials.jsx:19
msgid "Credentials"
msgstr ""
#: src/components/Pagination/Pagination.jsx:29
msgid "Current page"
msgstr ""
#: src/index.jsx:95
#: src/pages/Dashboard.jsx:19
msgid "Dashboard"
msgstr ""
#: src/components/PaginatedDataList/ToolbarDeleteButton.jsx:95
#: src/components/PaginatedDataList/ToolbarDeleteButton.jsx:119
#: src/components/PaginatedDataList/ToolbarDeleteButton.jsx:143
#: src/pages/Organizations/components/DeleteRoleConfirmationModal.jsx:42
msgid "Delete"
msgstr ""
#: src/components/PaginatedDataList/ToolbarDeleteButton.jsx:132
msgid "Delete {0}"
msgstr ""
#: src/components/PaginatedDataList/ToolbarDeleteButton.jsx:131
msgid "Delete {itemName}"
msgstr ""
#: src/pages/Organizations/components/OrganizationForm.jsx:113
#: src/pages/Organizations/screens/Organization/OrganizationDetail.jsx:75
msgid "Description"
msgstr ""
#: src/pages/Organizations/Organizations.jsx:42
#: src/pages/Organizations/screens/Organization/Organization.jsx:123
msgid "Details"
msgstr ""
#: src/pages/Organizations/screens/Organization/OrganizationDetail.jsx:110
msgid "Edit"
msgstr ""
#: src/pages/Organizations/Organizations.jsx:41
msgid "Edit Details"
msgstr ""
#: src/components/ExpandCollapse/ExpandCollapse.jsx:44
msgid "Expand"
msgstr ""
#: src/components/NotificationsList/NotificationListItem.jsx:82
msgid "Failure"
msgstr ""
#: src/components/Pagination/Pagination.jsx:170
#~ msgid "First"
#~ msgstr ""
#: src/components/Pagination/Pagination.jsx:24
msgid "Go to first page"
msgstr ""
#: src/components/Pagination/Pagination.jsx:26
msgid "Go to last page"
msgstr ""
#: src/components/Pagination/Pagination.jsx:27
msgid "Go to next page"
msgstr ""
#: src/components/Pagination/Pagination.jsx:25
msgid "Go to previous page"
msgstr ""
#: src/components/PageHeaderToolbar.jsx:80
msgid "Help"
msgstr ""
#: src/pages/Organizations/components/DeleteRoleConfirmationModal.jsx:54
msgid "If you {0} want to remove access for this particular user, please remove them from the team."
msgstr ""
#: src/components/PageHeaderToolbar.jsx:66
msgid "Info"
msgstr ""
#: src/index.jsx:188
#: src/pages/InstanceGroups.jsx:19
#: src/pages/Organizations/components/InstanceGroupsLookup.jsx:24
#: src/pages/Organizations/components/InstanceGroupsLookup.jsx:42
#: src/pages/Organizations/screens/Organization/OrganizationDetail.jsx:93
msgid "Instance Groups"
msgstr ""
#: src/index.jsx:193
msgid "Integrations"
msgstr ""
#: src/pages/Login.jsx:94
msgid "Invalid username or password. Please try again."
msgstr ""
#: src/index.jsx:136
#: src/pages/Inventories.jsx:19
msgid "Inventories"
msgstr ""
#: src/index.jsx:141
#: src/pages/InventoryScripts.jsx:19
msgid "Inventory Scripts"
msgstr ""
#: src/components/Pagination/Pagination.jsx:142
#~ msgid "Items Per Page"
#~ msgstr ""
#: src/components/Pagination/Pagination.jsx:22
msgid "Items per page"
msgstr ""
#: src/components/Pagination/Pagination.jsx:162
#~ msgid "Items {itemMin} {itemMax} of {count}"
#~ msgstr ""
#: src/index.jsx:100
#: src/index.jsx:209
#: src/pages/Jobs.jsx:19
msgid "Jobs"
msgstr ""
#: src/pages/JobsSettings.jsx:19
msgid "Jobs Settings"
msgstr ""
#: src/components/Pagination/Pagination.jsx:213
#~ msgid "Last"
#~ msgstr ""
#: src/pages/Organizations/screens/Organization/OrganizationDetail.jsx:87
msgid "Last Modified"
msgstr ""
#: src/pages/Organizations/screens/Organization/OrganizationAccess.jsx:175
msgid "Last Name"
msgstr ""
#: src/index.jsx:224
#: src/pages/License.jsx:19
msgid "License"
msgstr ""
#: src/components/AddRole/SelectResourceStep.jsx:89
msgid "Loading..."
msgstr ""
#: src/components/PageHeaderToolbar.jsx:120
msgid "Logout"
msgstr ""
#: src/index.jsx:183
#: src/pages/ManagementJobs.jsx:19
msgid "Management Jobs"
msgstr ""
#: src/pages/Organizations/components/OrganizationListItem.jsx:91
msgid "Members"
msgstr ""
#: src/pages/Organizations/components/InstanceGroupsLookup.jsx:49
#: src/pages/Organizations/screens/OrganizationsList.jsx:163
msgid "Modified"
msgstr ""
#: src/index.jsx:110
#: src/pages/Portal.jsx:19
msgid "My View"
msgstr ""
#: src/components/AddRole/AddResourceRole.jsx:134
#: src/components/PaginatedDataList/PaginatedDataList.jsx:99
#: src/pages/Organizations/components/InstanceGroupsLookup.jsx:48
#: src/pages/Organizations/components/OrganizationAccessItem.jsx:100
#: src/pages/Organizations/components/OrganizationForm.jsx:105
#: src/pages/Organizations/screens/Organization/OrganizationAccess.jsx:173
#: src/pages/Organizations/screens/Organization/OrganizationDetail.jsx:71
#: src/pages/Organizations/screens/OrganizationsList.jsx:162
msgid "Name"
msgstr ""
#: src/components/Pagination/Pagination.jsx:204
#~ msgid "Next"
#~ msgstr ""
#: src/components/PaginatedDataList/PaginatedDataList.jsx:119
msgid "No {0} Found"
msgstr ""
#: src/pages/NotificationTemplates.jsx:19
msgid "Notification Templates"
msgstr ""
#: src/index.jsx:178
#: src/pages/Organizations/Organizations.jsx:45
#: src/pages/Organizations/screens/Organization/Organization.jsx:130
msgid "Notifications"
msgstr ""
#: src/pages/Organizations/views/Organization.add.jsx:79
#~ msgid "Organization Add"
#~ msgstr ""
#: src/pages/Organizations/screens/Organization/Organization.jsx:144
msgid "Organization detail tabs"
msgstr ""
#: src/index.jsx:152
#: src/pages/Organizations/Organizations.jsx:38
#: src/pages/Organizations/Organizations.jsx:24
msgid "Organizations"
msgstr ""
#: src/pages/Organizations/views/Organizations.list.jsx:218
#~ msgid "Organizations List"
#~ msgstr ""
#: src/components/Pagination/Pagination.jsx:190
#~ msgid "Page"
#~ msgstr ""
#: src/components/Pagination/Pagination.jsx:189
#~ msgid "Page <0/> of {pageCount}"
#~ msgstr ""
#: src/components/Pagination/Pagination.jsx:193
#~ msgid "Page Number"
#~ msgstr ""
#: src/components/Pagination/Pagination.jsx:30
msgid "Pagination"
msgstr ""
#: src/pages/Login.jsx:92
msgid "Password"
msgstr ""
#: src/components/Pagination/Pagination.jsx:158
#~ msgid "Per Page"
#~ msgstr ""
#: src/components/PaginatedDataList/PaginatedDataList.jsx:122
msgid "Please add {0} to populate this list"
msgstr ""
#: src/components/PaginatedDataList/PaginatedDataList.jsx:136
#: src/components/PaginatedDataList/PaginatedDataList.jsx:199
#~ msgid "Please add {0} {itemName} to populate this list"
#~ msgstr ""
#: src/App.jsx:203
#~ msgid "Portal Mode"
#~ msgstr ""
#: src/components/Pagination/Pagination.jsx:179
#~ msgid "Previous"
#~ msgstr ""
#: src/index.jsx:88
msgid "Primary Navigation"
msgstr ""
#: src/index.jsx:131
#: src/pages/Projects.jsx:19
msgid "Projects"
msgstr ""
#: src/pages/Organizations/components/DeleteRoleConfirmationModal.jsx:28
msgid "Remove {0} Access"
msgstr ""
#: src/index.jsx:117
msgid "Resources"
msgstr ""
#: src/components/AddRole/AddResourceRole.jsx:214
#: src/components/FormActionGroup/FormActionGroup.jsx:24
#: src/components/FormActionGroup/FormActionGroup.jsx:24
#: src/components/Lookup/Lookup.jsx:161
msgid "Save"
msgstr ""
#: src/index.jsx:105
#: src/pages/Schedules.jsx:19
msgid "Schedules"
msgstr ""
#: src/components/Search/Search.jsx:138
msgid "Search"
msgstr ""
#: src/components/Search/Search.jsx:131
msgid "Search text input"
msgstr ""
#: src/components/Pagination/Pagination.jsx:28
msgid "Select"
msgstr ""
#: src/components/AnsibleSelect/AnsibleSelect.jsx:28
msgid "Select Input"
msgstr ""
#: src/components/AddRole/AddResourceRole.jsx:153
msgid "Select Users Or Teams"
msgstr ""
#: src/components/PaginatedDataList/ToolbarDeleteButton.jsx:97
msgid "Select a row to delete"
msgstr ""
#: src/components/DataListToolbar/DataListToolbar.jsx:108
msgid "Select all"
msgstr ""
#: src/components/AddRole/AddResourceRole.jsx:172
msgid "Select items from list"
msgstr ""
#: src/pages/Organizations/components/OrganizationForm.jsx:141
msgid "Select the Instance Groups for this Organization to run on."
msgstr ""
#: src/components/Lookup/Lookup.jsx:157
msgid "Select {header}"
msgstr ""
#: src/components/AddRole/AddResourceRole.jsx:181
#: src/components/AddRole/AddResourceRole.jsx:192
#: src/components/AddRole/AddResourceRole.jsx:209
#: src/components/AddRole/SelectRoleStep.jsx:29
#: src/components/Lookup/Lookup.jsx:187
msgid "Selected"
msgstr ""
#: src/index.jsx:200
msgid "Settings"
msgstr ""
#: src/components/Sort/Sort.jsx:135
msgid "Sort"
msgstr ""
#: src/components/NotificationsList/NotificationListItem.jsx:70
msgid "Successful"
msgstr ""
#: src/index.jsx:214
msgid "System"
msgstr ""
#: src/pages/SystemSettings.jsx:19
msgid "System Settings"
msgstr ""
#: src/pages/Organizations/components/DeleteRoleConfirmationModal.jsx:28
msgid "Team"
msgstr ""
#: src/pages/Organizations/components/OrganizationAccessItem.jsx:122
msgid "Team Roles"
msgstr ""
#: src/components/AddRole/AddResourceRole.jsx:163
#: src/index.jsx:162
#: src/pages/Organizations/Organizations.jsx:44
#: src/pages/Organizations/components/OrganizationListItem.jsx:99
#: src/pages/Organizations/screens/Organization/Organization.jsx:125
#: src/pages/Teams.jsx:19
msgid "Teams"
msgstr ""
#: src/index.jsx:121
#: src/pages/Templates.jsx:19
msgid "Templates"
msgstr ""
#: src/util/validators.jsx:6
msgid "This field must not be blank"
msgstr ""
#: src/util/validators.jsx:16
msgid "This field must not exceed {max} characters"
msgstr ""
#: src/components/NotificationsList/NotificationListItem.jsx:90
msgid "Toggle notification failure"
msgstr ""
#: src/components/NotificationsList/NotificationListItem.jsx:78
msgid "Toggle notification success"
msgstr ""
#: src/components/AnsibleSelect/AnsibleSelect.jsx:35
msgid "Use Default {label}"
msgstr ""
#: src/pages/Organizations/components/DeleteRoleConfirmationModal.jsx:28
msgid "User"
msgstr ""
#: src/components/PageHeaderToolbar.jsx:113
msgid "User Details"
msgstr ""
#: src/index.jsx:219
msgid "User Interface"
msgstr ""
#: src/pages/UISettings.jsx:19
msgid "User Interface Settings"
msgstr ""
#: src/pages/Organizations/components/OrganizationAccessItem.jsx:112
msgid "User Roles"
msgstr ""
#: src/components/AddRole/AddResourceRole.jsx:130
#: src/pages/Login.jsx:91
#: src/pages/Organizations/screens/Organization/OrganizationAccess.jsx:174
msgid "Username"
msgstr ""
#: src/components/AddRole/AddResourceRole.jsx:158
#: src/index.jsx:157
#: src/pages/Users.jsx:19
msgid "Users"
msgstr ""
#: src/index.jsx:91
msgid "Views"
msgstr ""
#: src/pages/Login.jsx:86
msgid "Welcome to Ansible {brandName}! Please Sign In."
msgstr ""
#: src/components/PaginatedDataList/ToolbarDeleteButton.jsx:90
msgid "You do not have permission to delete the following {0}: {itemsUnableToDelete}"
msgstr ""
#: src/contexts/Network.jsx:40
msgid "You have been logged out."
msgstr ""
#: src/pages/Organizations/components/OrganizationDetail.jsx:56
#~ msgid "add {currentTab}"
#~ msgstr ""
#: src/pages/Organizations/components/OrganizationDetail.jsx:45
#~ msgid "adding {currentTab}"
#~ msgstr ""
#: src/components/PaginatedDataList/ToolbarDeleteButton.jsx:148
msgid "cancel delete"
msgstr ""
#: src/components/PaginatedDataList/ToolbarDeleteButton.jsx:140
msgid "confirm delete"
msgstr ""
#: src/pages/Organizations/components/OrganizationDetail.jsx:38
#~ msgid "confirm removal of {currentTab}/cancel and go back to {currentTab} view."
#~ msgstr ""
#: src/pages/Organizations/components/OrganizationDetail.jsx:60
#~ msgid "delete {currentTab}"
#~ msgstr ""
#: src/pages/Organizations/components/OrganizationDetail.jsx:36
#~ msgid "deleting {currentTab} association with orgs"
#~ msgstr ""
#: src/pages/Organizations/components/OrganizationEdit.jsx:20
#~ msgid "edit view"
#~ msgstr ""
#: src/components/Lookup/Lookup.jsx:128
#: src/components/Pagination/Pagination.jsx:20
msgid "items"
msgstr ""
#: src/components/Pagination/Pagination.jsx:198
#~ msgid "of {pageCount}"
#~ msgstr ""
#: src/components/Pagination/Pagination.jsx:21
msgid "pages"
msgstr ""
#: src/components/Pagination/Pagination.jsx:23
msgid "per page"
msgstr ""
#: src/pages/Organizations/components/OrganizationEdit.jsx:22
#~ msgid "save/cancel and go back to view"
#~ msgstr ""
#: src/pages/Organizations/components/OrganizationDetail.jsx:47
#~ msgid "save/cancel and go back to {currentTab} view"
#~ msgstr ""
#: src/pages/Organizations/components/OrganizationListItem.jsx:29
#~ msgid "select organization {itemId}"
#~ msgstr ""
#: src/pages/Organizations/components/OrganizationDetail.jsx:82
#~ msgid "{0}"
#~ msgstr ""
#: src/components/PaginatedDataList/PaginatedDataList.jsx:135
msgid "{0} List"
msgstr ""
#: src/pages/Organizations/components/OrganizationDetail.jsx:54
#~ msgid "{currentTab} detail view"
#~ msgstr ""
#: src/components/Pagination/Pagination.jsx:163
#~ msgid "{itemMin} - {itemMax} of {count}"
#~ msgstr ""

7
awx/ui_next/dist/index.html vendored Normal file
View File

@ -0,0 +1,7 @@
<!DOCTYPE html>
<html>
<body>
<div id="app"></div>
<script src="/bundle.js"></script>
</body>
</html>

View File

@ -0,0 +1,232 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 21.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 500 500" style="enable-background:new 0 0 500 500;" xml:space="preserve">
<style type="text/css">
.st0{display:none;}
.st1{display:inline;fill:#ED1C24;}
.st2{fill:#42210B;}
.st3{fill:#FFFFFF;}
.st4{fill:#C69C6D;stroke:#8C6239;stroke-width:5;stroke-miterlimit:10;}
.st5{fill:#FFFFFF;stroke:#42210B;stroke-width:3;stroke-miterlimit:10;}
.st6{fill:#ED1C24;stroke:#8C6239;stroke-width:5;stroke-miterlimit:10;}
.st7{fill:#A67C52;}
.st8{fill:#ED1C24;}
</style>
<g class="st0">
<path class="st1" d="M319.8,169.3c1.5-14.2,13.7-27.2,29.9-31.9c-13.1,1.5-27.3-1.7-36-10c-8.7-8.3-10-21.9-1.4-30.1
c-12,6.7-28.1,8.1-41.4,3.4c-13.3-4.6-23.5-15.1-26.2-26.9c-2-8.8,0-17.9,2-26.7c-6.2,9.4-17.6,17.3-30.5,17.3
c-12.9,0.1-25.7-10.2-22.9-20.7c-5.5,7.8-11.4,15.9-21,20.2c-9.5,4.3-23.7,2.7-28.2-5.5c-1.6,10.8-7.5,22-19.1,27
c-9,3.9-21.5,2.2-28-3.8c5.7,11.4,4.3,25.3-4.1,35.6c-9.9,12.2-29.1,18.6-46.4,15.6c14.7,7.2,28.5,17.7,32.1,31.5
c3.7,13.8-7.1,30.7-24.1,31.7c13.6,3.1,28,7.4,35.6,17.2c7.6,9.8,2.9,26.4-11.1,28c12.8-2.6,27.4,3.9,31.9,14.2
c4.1,9.5-0.9,20.9-10.9,26.5c18.6-8.9,41-17.1,59.6-8.8c13.9,6.2,20.8,21.6,15.1,33.8c10.4-10.6,23-21.3,39.2-23.5
c12.8-1.8,27.5,4.6,31.9,14.1c-0.3-12.7,6.1-25.5,17.5-34c13.8-10.3,34.4-14,52-9.2c-11.1-7.8-14.9-22-8.9-33
c6-11,21.3-18,35.7-16.2C327.5,198.1,318.3,183.5,319.8,169.3z"/>
</g>
<g>
<g>
<g>
<path class="st2" d="M179.7,297.3c-10.1,3.2-20.3,6-30.6,8.4c-10.7,2.5-21.7,5-32.8,5.1C96,311.1,79.9,297.2,60,296.1
c-5.8-0.3-5.8,8.7,0,9c9.9,0.5,18.9,5.1,27.9,8.8c9.8,4,19.6,6.3,30.2,5.9c21.5-0.8,43.5-7.4,64-13.8
C187.6,304.3,185.2,295.6,179.7,297.3L179.7,297.3z"/>
</g>
</g>
<g>
<g>
<path class="st2" d="M322.2,194.8c17.9-8,36-18.5,44.3-37.2c4.2-9.3,6-19.2,7.2-29.3c1.5-11.7,2.5-23.4,3.7-35.2
c0.6-5.8-8.4-5.7-9,0c-1.1,10.3-2.1,20.6-3.3,30.9c-1.1,9.7-2.5,19.7-6.4,28.7c-7.5,17.5-24.6,26.8-41.2,34.2
C312.4,189.4,316.9,197.2,322.2,194.8L322.2,194.8z"/>
</g>
</g>
<g>
<ellipse transform="matrix(0.5541 -0.8324 0.8324 0.5541 -219.4917 376.0051)" class="st2" cx="241.2" cy="392.9" rx="65.5" ry="33.7"/>
</g>
<g>
<g>
<path class="st3" d="M224.1,442.5c22-11.5,38.7-31,47.1-54.3c2-5.5-6.7-7.8-8.7-2.4c-7.6,21.1-23.1,38.5-43,48.9
C214.4,437.4,218.9,445.1,224.1,442.5L224.1,442.5z"/>
</g>
</g>
<g>
<ellipse transform="matrix(0.9684 -0.2494 0.2494 0.9684 -66.4734 109.0276)" class="st2" cx="397" cy="316.8" rx="63.9" ry="32.9"/>
</g>
<g>
<g>
<path class="st3" d="M363.8,341.5c28.3,7,58.7-0.8,80.2-20.5c4.3-3.9-2.1-10.3-6.4-6.4c-19.1,17.5-46.4,24.4-71.5,18.2
C360.5,331.5,358.1,340.1,363.8,341.5L363.8,341.5z"/>
</g>
</g>
<path class="st4" d="M156.9,96c-25.4,4.5-32.9,20.2-45,46.9c-20.2,44.4,2,90.3,5.6,97.5c18.4,36.5,42.3,36.8,60,80.6
c8.6,21.2,4.6,25.2,13.1,37.5c20.4,29.2,63.7,36.1,91.9,33.8c40.3-3.3,91.5-28.8,108.8-82.5c17.1-53.2-6-112.1-41.2-131.2
c-25.3-13.7-44.9-0.5-71.2-20.6c-21.6-16.5-18.4-33.1-37.5-48.8C227.9,98.1,203.7,87.7,156.9,96z"/>
<ellipse transform="matrix(0.6622 -0.7494 0.7494 0.6622 65.2068 309.6339)" class="st2" cx="376" cy="82.5" rx="21" ry="15.5"/>
<g>
<g>
<path class="st3" d="M379.8,75.3c0.8,0.2-0.6-0.4-0.1-0.1c0.2,0.1,0.3,0.2,0.5,0.3c0.4,0.2-0.6-0.7-0.1-0.1
c0.1,0.1,0.7,0.8,0.2,0.2c-0.4-0.5,0,0,0.1,0.1c0.4,0.7,0,0.2,0-0.2c0,0.1,0.1,0.4,0.2,0.5c0.3,0.9-0.1-1,0-0.1
c0.1,2.3,2,4.6,4.5,4.5c2.3-0.1,4.6-2,4.5-4.5c-0.3-4.4-3-8.1-7.3-9.4c-2.2-0.7-5,0.8-5.5,3.1C376.1,72.2,377.4,74.5,379.8,75.3
L379.8,75.3z"/>
</g>
</g>
<ellipse transform="matrix(0.9999 -1.433736e-02 1.433736e-02 0.9999 -4.303 0.8051)" class="st2" cx="54" cy="300.5" rx="21" ry="15.5"/>
<g>
<g>
<path class="st3" d="M52.2,297.5c1.1-0.3,1.4-0.4,2.5,0c0.8,0.3,1.3,0.7,2,1.7c1.5,1.9,4.8,1.6,6.4,0c1.9-1.9,1.5-4.4,0-6.4
c-3.1-3.9-8.6-5.4-13.3-4C44.3,290.5,46.7,299.2,52.2,297.5L52.2,297.5z"/>
</g>
</g>
<g>
<g>
<path class="st2" d="M149.3,108.8c4.9-10.8-1.3-24.2-12.9-26.9c-1.9-0.4-2.7,2.4-0.8,2.9c9.6,2.3,15.3,13.5,11.2,22.5
C145.9,109,148.5,110.5,149.3,108.8L149.3,108.8z"/>
</g>
</g>
<g>
<g>
<path class="st2" d="M141.2,112.3c2.4-9.4-5.4-19.3-15.2-19c-1.9,0.1-1.9,3.1,0,3c7.8-0.2,14.2,7.6,12.3,15.2
C137.8,113.4,140.7,114.2,141.2,112.3L141.2,112.3z"/>
</g>
</g>
<g>
<g>
<path class="st2" d="M132.6,118c-1.1-8.3-10.9-13.4-18.2-9.1c-1.7,1-0.2,3.6,1.5,2.6c5.2-3,12.9,0.4,13.7,6.5
C129.8,119.9,132.8,119.9,132.6,118L132.6,118z"/>
</g>
</g>
<path class="st5" d="M215.5,166.5l34-73c0,0,35,0,46,21c7.5,14.3,8,39,8,39L215.5,166.5z"/>
<path class="st5" d="M208.2,170.5l-79.5-12.7c0,0-19.6,29-8.4,49.9c7.6,14.2,27.8,28.5,27.8,28.5L208.2,170.5z"/>
<path class="st2" d="M210.5,164.5l33-74c0,0-2.5-5.5-8-7s-12,0-12,0L210.5,164.5z"/>
<path class="st2" d="M207.4,165.3l-73.1-35c0,0-5.6,2.4-7.2,7.8c-1.6,5.5-0.3,12-0.3,12L207.4,165.3z"/>
<path d="M215.5,166.5L234,127c0,0,17-6,25.5,7.5c8.6,13.6-3.5,25.5-3.5,25.5L215.5,166.5z"/>
<path d="M206.7,170.9l-29.6,32c0,0-18,0.5-22-14.9c-4-15.6,11.1-23.2,11.1-23.2L206.7,170.9z"/>
<g>
<g>
<path class="st3" d="M243.4,139.1c-0.6,0.2-0.7,0.3-0.4,0.2c0.3-0.1,0.2-0.1-0.5,0.1c0.7,0-0.3,0-0.4-0.1c0.1,0,0.3,0.1,0.4,0.1
c0.3,0.1,0.2,0-0.4-0.2c0,0,0.6,0.3,0.6,0.3c0.5,0.2-0.9-0.6-0.1-0.1c0.6,0.4-0.3-0.5-0.1-0.1c0.3,0.5-0.3-1-0.1-0.2
c0.2,0.8,0-1,0-0.1c0,2.4,2.1,4.6,4.5,4.5c2.5-0.1,4.5-2,4.5-4.5c0-3-1.6-5.7-4.1-7.3c-2.6-1.7-5.6-1.6-8.4-0.4
c-2.2,0.9-2.8,4.3-1.6,6.2C238.7,139.7,241,140.1,243.4,139.1L243.4,139.1z"/>
</g>
</g>
<g>
<g>
<path class="st3" d="M173.5,176.4c-0.5-0.3-0.1,0,0.2,0.1c-0.7-0.6,0.3,0.5,0.1,0c-0.3-0.5,0.4,0.8,0.1,0.2
c-0.4-0.8,0.2,0.2,0,0.1c0,0,0-0.6,0-0.6c-0.1,0.1-0.1,1,0,0.3c-0.1,0.2-0.1,0.3-0.2,0.5c0.2-0.3,0.2-0.4,0-0.1
c-0.2,0.2-0.2,0.3-0.1,0.1c0.2-0.2,0.1-0.2-0.3,0.2c1.9-1.4,3-4,1.6-6.2c-1.2-1.9-4.1-3.1-6.2-1.6c-2.4,1.7-4,4.3-3.9,7.4
c0.1,3,1.6,5.7,4.1,7.3c2,1.2,5,0.5,6.2-1.6C176.3,180.4,175.7,177.7,173.5,176.4L173.5,176.4z"/>
</g>
</g>
<ellipse transform="matrix(0.862 -0.5069 0.5069 0.862 -88.3186 186.5516)" class="st6" cx="298.5" cy="255.5" rx="79.5" ry="68.5"/>
<g>
<g>
<path class="st7" d="M173.6,109.8c-2.1,2-3.9,4.6-3.6,7.6c0.3,3.5,2.8,6.6,6.6,6.7c6,0.2,11.5-7.7,8.2-13c-1-1.7-3.1-3.1-5.2-3
c-1.7,0.1-3.1,0.8-4.4,1.9c-2,1.8-2.8,5.2-1.9,7.7c2.4,6.6,11.8,5.9,13.8-0.7c0.7-2.5-0.9-5.6-3.5-6.2c-2.7-0.6-5.4,0.8-6.2,3.5
c0.6-2.1,3.1-2.6,4.6-1c0.8,0.9,1,1.8,0.8,2.8c0.2-0.5,0.1-0.4-0.1,0.3c-0.4,0.7-1,1.2-1.8,1.4c-0.9,0-1.8,0-2.7,0
c-1.8-0.6-2.5-1.6-2.3-3.1c-0.1-0.4-0.1-0.7,0.1-1c0.2-0.3,0.1-0.3-0.1,0.1c0.1-0.1,0.2-0.2,0.3-0.4c-0.2,0.3-0.5,0.5-0.7,0.8
c-0.1,0.1-0.2,0.2-0.3,0.3c-0.3,0.2-0.2,0.2,0.1-0.1c1.3,0.2,2.6,0.4,3.9,0.6c0.2,0.4,0.5,0.9,0.7,1.3c0.2,0.6-0.2,0.9-0.2,1.4
c0,0.4,0.4-0.5-0.1,0.1c0.3-0.4,0.6-0.7,1-1c1.9-1.8,2-5.3,0-7.1C178.7,107.9,175.6,107.8,173.6,109.8L173.6,109.8z"/>
</g>
</g>
<g>
<g>
<path class="st7" d="M151.2,248.6c-5.7,7,1.7,16.9,10,13.3c3.4-1.5,6.3-5,6.3-8.9c0-4.2-2.7-7.6-7-7.8c-3.1-0.1-5.8,3.3-4.8,6.3
c1.2,3.4,3.7,6.1,7.3,7c2.6,0.6,5.4-0.8,6.2-3.5c0.7-2.5-0.9-5.5-3.5-6.2c-1.7-0.4,0,0.1-0.2,0.1c-0.4,0-0.4-0.8-0.1-0.1
c-1.6,2.1-3.2,4.2-4.8,6.3c-2.4-0.1-2.8-1.1-3-2.6c0.1,0.7-0.1,0.2,0.1-0.1c0.7-0.9-0.5,0.5,0,0c-0.5,0.5-0.3,0.1-0.2,0.2
c0.1,0,0.6,0,0.7,0c0.4,0.1,0.5,0.4,0.8,0.6c0.2,0.3,0.2,0.2-0.1-0.2c0.1,0.1,0.1,0.3,0.2,0.4c0,1,0.1,1.1-0.7,2.1
c1.7-2.1,2-5,0-7.1C156.5,246.8,152.9,246.5,151.2,248.6L151.2,248.6z"/>
</g>
</g>
<g>
<g>
<path class="st7" d="M204.1,205.7c0.8,4.8,5.3,8.6,10.1,8.6c5.1,0,9.5-3.9,10.3-8.9c0.7-4.4-0.2-12.1-5.3-13.6
c-2.7-0.8-5.2,0.5-7,2.4c-1.1,1.2-1.5,1.7-3.1,1.2c0.7,2.8,1.5,5.6,2.2,8.4c0.2-0.2-0.5,0.2-0.5,0.2c6.3,1.4,8.9-8.2,2.7-9.6
c-3.5-0.8-6.6,0-9.3,2.4c-3,2.6-1.1,7.2,2.2,8.4c2.6,0.9,5.5,0.8,8-0.2c1.3-0.5,2.4-1.2,3.4-2.1c0.4-0.3,0.7-0.6,1-1
c0.2-0.3,0.4-0.5,0.6-0.7c0.4-0.4,0.3-0.4-0.5,0.3c-0.9,0-1.8,0-2.7,0c0.2,0.1,0.3,0.1,0.5,0.2c-0.7-0.4-1.5-0.9-2.2-1.3
c0.1,0.2,0.3,0.3,0.4,0.5c-0.4-0.7-0.9-1.5-1.3-2.2c0.4,1.2,0.8,2.5,1,3.7c0,0.4,0,0.8,0,1.2c0,0.5-0.5,0.9,0,0.4
c-0.8,0.6-0.9,0.2-1.1-0.9c-0.4-2.7-3.8-4.1-6.2-3.5C204.7,200.3,203.7,203,204.1,205.7L204.1,205.7z"/>
</g>
</g>
<g>
<g>
<path class="st7" d="M265.9,179.6c0.2,0.4,0.5,0.9,0.7,1.3c0.6,1.1,1.8,2,3,2.3c1.2,0.3,2.8,0.2,3.9-0.5c1.1-0.7,2-1.7,2.3-3
c0.3-1.4,0.1-2.6-0.5-3.9c-0.2-0.4-0.5-0.9-0.7-1.3c-0.6-1.1-1.8-2-3-2.3c-1.2-0.3-2.8-0.2-3.9,0.5c-1.1,0.7-2,1.7-2.3,3
C265.1,177.1,265.3,178.3,265.9,179.6L265.9,179.6z"/>
</g>
</g>
<g>
<g>
<path class="st7" d="M200.4,295.8c-6.1,1.6-8.1,8.6-5,13.7c2.8,4.7,9.1,7.2,14.3,5.4c4.9-1.7,7.8-7.1,6.3-12.2
c-0.8-2.7-2.7-4.8-5.3-5.8c-1.4-0.5-2.8-0.7-4.2-0.8c-0.1,0-0.9-0.1-0.9-0.1c0.2-0.4,1.2,2.5,0.9,0.7c0,0.9,0,1.8,0,2.7
c-0.1,0.1-0.1,0.1-0.2,0.2c3.1-5.6-5.5-10.7-8.6-5c-1.7,3-1.1,6.6,1.4,9c1.3,1.2,2.8,2,4.5,2.3c0.8,0.1,1.6,0.2,2.4,0.3
c0.4,0,0.7,0,1.1,0.1c0.2,0.1,0.1,0.1-0.2-0.1c0,0.1-0.6-0.5-0.6-0.5c-0.1-0.1-0.1-0.2,0-0.3c0.1-0.3,0.1-0.1-0.1,0.5
c-0.3-0.1,0.7-0.2-0.3-0.3c-0.9-0.1-1.1-0.6-1.8-0.9c0,0-0.2-0.3-0.3-0.3c0.3,0-0.8,1.2-0.8,1.2
C209.3,303.8,206.6,294.2,200.4,295.8L200.4,295.8z"/>
</g>
</g>
<g>
<g>
<path class="st7" d="M244.8,355.3c-4-6.2-11.2-2.3-12,3.9c-0.8,5.9,1.8,12,6.5,15.6c4.5,3.5,11.5,4.9,16.7,2.1
c6.4-3.3,5.4-9.8,4.9-15.9c-0.5-6.3-1.9-12-9.5-12.1c-5.1-0.1-13.1,0.2-14.5,6.4c-1.2,5.4,2.5,12.8,8.2,13.8
c6.2,1.1,11.2-5.5,7.8-11c-2.2-3.5-8.1-3.1-9.1,1.2c-1.1,4.4,0.5,8,4.1,10.6c5.2,3.8,10.2-4.8,5-8.6c0.2,0.2,0.4,0.5,0.5,0.7
c-3,0.4-6.1,0.8-9.1,1.2c-0.4-0.7,3.4-3.1,2.9-4.8c-0.8-2.6-1.7,1.4-1.9,1.1c0,0.1,5.2-0.1,5.6-0.4c0.7,0.1,0.8-0.1,0.2-0.6
c-0.4-0.7-0.5-0.8-0.4-0.3c-0.2,0.3,0.2,1.9,0.2,2.3c0.2,2,0.3,4,0.5,5.9c0.1,1.6,0.4,1.7-1.1,2c-1.3,0.2-2.9-0.3-4-0.9
c-1.4-0.8-2.5-2-3.1-3.5c-0.3-0.7-0.4-1.3-0.5-2c0-0.3-0.1-0.7,0-1c0.2-1.9-1.1-1.5-3.8,1.2c-1-0.8-2-1.5-3-2.3
c0.1,0.2,0.2,0.4,0.4,0.6C239.6,365.7,248.3,360.7,244.8,355.3L244.8,355.3z"/>
</g>
</g>
<g>
<g>
<path class="st7" d="M336.5,337.4c-2.4-1.5-5.1-2.5-7.9-1.8c-2.7,0.7-4.9,3.2-5.3,6c-0.9,6.4,6.3,8.3,11.2,8.4
c4.8,0.1,10.6-2.4,10.9-7.9c0.2-5.6-5.5-9.6-10.6-6.9c-5.7,3-0.7,11.6,5,8.6c-0.1,0.1-0.2,0.1-0.3,0.2c-0.9,0-1.8,0-2.7,0
c-2.1-0.4-1.4-4.8-0.3-4.3c0,0-1.3,0.3-1.3,0.3c-0.6,0-1.2,0-1.8-0.1c-0.5-0.1-1-0.2-1.5-0.4c-1.2-0.5-1-0.2,0.6,0.7
c0.2,0.8,0.5,1.7,0.7,2.5c-3.4,1.1-4.4,1.9-2.8,2.7c0.4,0.2,0.7,0.4,1.1,0.7C336.9,349.6,341.9,340.9,336.5,337.4L336.5,337.4z"
/>
</g>
</g>
<path class="st3" d="M224.3,256.5L252,273v-40l32,20v-38l28,17l4-28l23,12l-3-24c0,0-14-8-35.5-6.4c-11.6,0.9-24.3,6.8-33.5,11.4
c-14,7-23.7,18.9-31.2,29.1C227,238,224.3,256.5,224.3,256.5z"/>
<path class="st3" d="M372.9,248.9l-28.8-14.5l2.9,39.9l-33.3-17.7l2.7,37.9l-29.1-15l-2,28.2l-23.8-10.3l4.7,23.7
c0,0,14.5,7,35.9,3.8c11.5-1.7,23.7-8.5,32.6-13.8c13.5-8,22.3-20.5,29-31.2C371.5,267.5,372.9,248.9,372.9,248.9z"/>
</g>
<g>
<g>
<path class="st8" d="M235.2,121.6c8.5-3.1,23.2-0.1,27.8,8.4c2.3,4.4,4.5,9.9,4.5,14.9c0.1,5.5-2.7,10.5-5.3,15.3
c-1.5,2.8,2.8,5.4,4.3,2.5c3.1-5.8,6.3-11.9,6-18.7c-0.3-6-2.8-12.8-5.9-17.9c-6-9.5-22.6-13.1-32.7-9.4
C230.9,117.8,232.2,122.7,235.2,121.6L235.2,121.6z"/>
</g>
</g>
<g>
<g>
<path class="st8" d="M241.1,110.5c11.6-2.3,25.6,2.3,32.2,12.4c6.6,10.2,6.1,22.8,3.1,34.2c-1.3,5,6.4,7.1,7.7,2.1
c3.8-14.3,3.8-30.3-5.5-42.6c-8.9-11.7-25.5-16.6-39.6-13.8C233.9,103.8,236.1,111.5,241.1,110.5L241.1,110.5z"/>
</g>
</g>
<g>
<g>
<path class="st8" d="M245.4,97.5c7.8-1.8,15.5,0,22.9,2.8c7.2,2.7,15,6.1,20.3,11.8c10.7,11.7,9.5,29.3,8.7,44
c-0.3,6.4,9.7,6.4,10,0c1-17.9,1.2-38.5-12.7-52.1c-6.4-6.3-15.3-10.2-23.6-13.3c-9.1-3.4-18.6-4.9-28.2-2.8
C236.5,89.2,239.1,98.9,245.4,97.5L245.4,97.5z"/>
</g>
</g>
<g>
<g>
<path class="st8" d="M155.8,158.5c-13.1,4.8-14.2,21.6-10.1,33.1c4.3,12,15.2,20.6,28.2,20.5c3.2,0,3.2-5,0-5
c-9.9,0.1-18.6-5.9-22.6-14.9c-3.9-8.6-5.2-24.8,5.8-28.9C160.2,162.3,158.9,157.4,155.8,158.5L155.8,158.5z"/>
</g>
</g>
<g>
<g>
<path class="st8" d="M164.1,216.5c-11.4-2.2-18.8-11.4-22.7-21.9c-3.6-9.6-7.7-25.3,1.2-33.1c3.9-3.4-1.8-9-5.7-5.7
c-11.3,9.9-7.9,28.5-3.3,40.9c4.8,13,14.1,24.7,28.3,27.5C167,225.2,169.1,217.5,164.1,216.5L164.1,216.5z"/>
</g>
</g>
<g>
<g>
<path class="st8" d="M152,231.7c-27.3-13.3-38.1-46.5-23.3-73.2c3.1-5.6-5.5-10.7-8.6-5c-17.3,31.2-5.3,71.1,26.9,86.9
C152.7,243.1,157.8,234.5,152,231.7L152,231.7z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,41 @@
module.exports = {
collectCoverageFrom: [
'src/**/*.{js,jsx}'
],
coveragePathIgnorePatterns: [
'<rootDir>/src/locales',
'index.js'
],
moduleNameMapper: {
'\\.(css|scss|less)$': '<rootDir>/__mocks__/styleMock.js',
'^@api(.*)$': '<rootDir>/src/api$1',
'^@components(.*)$': '<rootDir>/src/components$1',
'^@contexts(.*)$': '<rootDir>/src/contexts$1',
'^@screens(.*)$': '<rootDir>/src/screens$1',
'^@util(.*)$': '<rootDir>/src/util$1',
'^@types(.*)$': '<rootDir>/src/types$1',
'^@testUtils(.*)$': '<rootDir>/testUtils$1',
},
setupFiles: [
'@nteract/mockument'
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
snapshotSerializers: [
"enzyme-to-json/serializer"
],
testMatch: [
'<rootDir>/**/*.test.{js,jsx}'
],
testEnvironment: 'jsdom',
testURL: 'http://127.0.0.1:3001',
transform: {
'^.+\\.(js|jsx)$': 'babel-jest',
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '<rootDir>/__mocks__/fileMock.js',
},
transformIgnorePatterns: [
'[/\\\\]node_modules[/\\\\].+\\.(?!(axios)/)(js|jsx)$'
],
watchPathIgnorePatterns: [
'<rootDir>/node_modules'
]
};

View File

@ -0,0 +1,9 @@
require('@babel/polyfill');
// eslint-disable-next-line import/prefer-default-export
export const asyncFlush = () => new Promise((resolve) => setImmediate(resolve));
const enzyme = require('enzyme');
const Adapter = require('enzyme-adapter-react-16');
enzyme.configure({ adapter: new Adapter() });

17193
awx/ui_next/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

74
awx/ui_next/package.json Normal file
View File

@ -0,0 +1,74 @@
{
"name": "awx-react",
"version": "1.0.0",
"description": "",
"main": "index.jsx",
"scripts": {
"start": "webpack-dev-server --config ./webpack.config.js --mode development",
"test": "jest --coverage",
"test-watch": "jest --watch",
"lint": "eslint --ext .js --ext .jsx .",
"add-locale": "lingui add-locale",
"extract-strings": "lingui extract",
"compile-strings": "lingui compile",
"prettier": "prettier --write \"src/**/*.{js,jsx,scss}\""
},
"keywords": [],
"author": "",
"license": "Apache",
"devDependencies": {
"@babel/core": "^7.2.0",
"@babel/plugin-proposal-class-properties": "^7.1.0",
"@babel/polyfill": "^7.0.0",
"@babel/preset-env": "^7.1.0",
"@babel/preset-react": "^7.0.0",
"@lingui/cli": "^2.7.4",
"@lingui/macro": "^2.7.2",
"@nteract/mockument": "^1.0.4",
"babel-core": "^7.0.0-bridge.0",
"babel-eslint": "^10.0.1",
"babel-jest": "^24.7.1",
"babel-loader": "^8.0.4",
"babel-plugin-macros": "^2.4.2",
"babel-plugin-styled-components": "^1.10.0",
"css-loader": "^1.0.0",
"enzyme": "^3.9.0",
"enzyme-adapter-react-16": "^1.12.1",
"enzyme-to-json": "^3.3.5",
"eslint": "^5.6.0",
"eslint-config-airbnb": "^17.1.0",
"eslint-config-prettier": "^5.0.0",
"eslint-import-resolver-webpack": "0.11.1",
"eslint-plugin-import": "^2.14.0",
"eslint-plugin-jsx-a11y": "^6.1.1",
"eslint-plugin-react": "^7.11.1",
"file-loader": "^2.0.0",
"history": "^4.9.0",
"jest": "^24.7.1",
"node-sass": "^4.12.0",
"prettier": "^1.18.2",
"react-hot-loader": "^4.3.3",
"sass-loader": "^7.1.0",
"style-loader": "^0.23.0",
"webpack": "^4.23.1",
"webpack-cli": "^3.0.8",
"webpack-dev-server": "^3.1.14"
},
"dependencies": {
"@lingui/react": "^2.7.2",
"@patternfly/patternfly": "^2.7.0",
"@patternfly/react-core": "^3.16.14",
"@patternfly/react-icons": "^3.7.5",
"@patternfly/react-tokens": "^2.3.3",
"axios": "^0.18.0",
"codemirror": "^5.47.0",
"formik": "^1.5.1",
"js-yaml": "^3.13.1",
"prop-types": "^15.6.2",
"react": "^16.4.1",
"react-codemirror2": "^6.0.0",
"react-dom": "^16.4.1",
"react-router-dom": "^4.3.1",
"styled-components": "^4.2.0"
}
}

196
awx/ui_next/src/App.jsx Normal file
View File

@ -0,0 +1,196 @@
import React, { Component, Fragment } from 'react';
import { global_breakpoint_md } from '@patternfly/react-tokens';
import {
Nav,
NavList,
Page,
PageHeader as PFPageHeader,
PageSidebar,
} from '@patternfly/react-core';
import styled from 'styled-components';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
import { ConfigAPI, MeAPI, RootAPI } from '@api';
import About from '@components/About';
import AlertModal from '@components/AlertModal';
import NavExpandableGroup from '@components/NavExpandableGroup';
import BrandLogo from '@components/BrandLogo';
import PageHeaderToolbar from '@components/PageHeaderToolbar';
import ErrorDetail from '@components/ErrorDetail';
import { ConfigProvider } from '@contexts/Config';
const PageHeader = styled(PFPageHeader)`
& .pf-c-page__header-brand-link {
color: inherit;
&:hover {
color: inherit;
}
& svg {
height: 76px;
}
}
`;
class App extends Component {
constructor(props) {
super(props);
// initialize with a closed navbar if window size is small
const isNavOpen =
typeof window !== 'undefined' &&
window.innerWidth >= parseInt(global_breakpoint_md.value, 10);
this.state = {
ansible_version: null,
custom_virtualenvs: null,
me: null,
version: null,
isAboutModalOpen: false,
isNavOpen,
configError: null,
};
this.handleLogout = this.handleLogout.bind(this);
this.handleAboutClose = this.handleAboutClose.bind(this);
this.handleAboutOpen = this.handleAboutOpen.bind(this);
this.handleNavToggle = this.handleNavToggle.bind(this);
this.handleConfigErrorClose = this.handleConfigErrorClose.bind(this);
}
async componentDidMount() {
await this.loadConfig();
}
// eslint-disable-next-line class-methods-use-this
async handleLogout() {
await RootAPI.logout();
window.location.replace('/#/login');
}
handleAboutOpen() {
this.setState({ isAboutModalOpen: true });
}
handleAboutClose() {
this.setState({ isAboutModalOpen: false });
}
handleNavToggle() {
this.setState(({ isNavOpen }) => ({ isNavOpen: !isNavOpen }));
}
handleConfigErrorClose() {
this.setState({
configError: null,
});
}
async loadConfig() {
try {
const [configRes, meRes] = await Promise.all([
ConfigAPI.read(),
MeAPI.read(),
]);
const {
data: { ansible_version, custom_virtualenvs, version },
} = configRes;
const {
data: {
results: [me],
},
} = meRes;
this.setState({ ansible_version, custom_virtualenvs, version, me });
} catch (err) {
this.setState({ configError: err });
}
}
render() {
const {
ansible_version,
custom_virtualenvs,
isAboutModalOpen,
isNavOpen,
me,
version,
configError,
} = this.state;
const {
i18n,
render = () => {},
routeGroups = [],
navLabel = '',
} = this.props;
const header = (
<PageHeader
showNavToggle
onNavToggle={this.handleNavToggle}
logo={<BrandLogo />}
logoProps={{ href: '/' }}
toolbar={
<PageHeaderToolbar
loggedInUser={me}
isAboutDisabled={!version}
onAboutClick={this.handleAboutOpen}
onLogoutClick={this.handleLogout}
/>
}
/>
);
const sidebar = (
<PageSidebar
isNavOpen={isNavOpen}
nav={
<Nav aria-label={navLabel}>
<NavList>
{routeGroups.map(({ groupId, groupTitle, routes }) => (
<NavExpandableGroup
key={groupId}
groupId={groupId}
groupTitle={groupTitle}
routes={routes}
/>
))}
</NavList>
</Nav>
}
/>
);
return (
<Fragment>
<Page usecondensed="True" header={header} sidebar={sidebar}>
<ConfigProvider
value={{ ansible_version, custom_virtualenvs, me, version }}
>
{render({ routeGroups })}
</ConfigProvider>
</Page>
<About
ansible_version={ansible_version}
version={version}
isOpen={isAboutModalOpen}
onClose={this.handleAboutClose}
/>
<AlertModal
isOpen={configError}
variant="danger"
title={i18n._(t`Error!`)}
onClose={this.handleConfigErrorClose}
>
{i18n._(t`Failed to retrieve configuration.`)}
<ErrorDetail error={configError} />
</AlertModal>
</Fragment>
);
}
}
export { App as _App };
export default withI18n()(App);

View File

@ -0,0 +1,121 @@
import React from 'react';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import { ConfigAPI, MeAPI, RootAPI } from '@api';
import { asyncFlush } from '../jest.setup';
import App from './App';
jest.mock('./api');
describe('<App />', () => {
const ansible_version = '111';
const custom_virtualenvs = [];
const version = '222';
beforeEach(() => {
ConfigAPI.read = () =>
Promise.resolve({
data: {
ansible_version,
custom_virtualenvs,
version,
},
});
MeAPI.read = () => Promise.resolve({ data: { results: [{}] } });
});
afterEach(() => {
jest.clearAllMocks();
});
test('expected content is rendered', () => {
const appWrapper = mountWithContexts(
<App
routeGroups={[
{
groupTitle: 'Group One',
groupId: 'group_one',
routes: [
{ title: 'Foo', path: '/foo' },
{ title: 'Bar', path: '/bar' },
],
},
{
groupTitle: 'Group Two',
groupId: 'group_two',
routes: [{ title: 'Fiz', path: '/fiz' }],
},
]}
render={({ routeGroups }) =>
routeGroups.map(({ groupId }) => <div key={groupId} id={groupId} />)
}
/>
);
// page components
expect(appWrapper.length).toBe(1);
expect(appWrapper.find('PageHeader').length).toBe(1);
expect(appWrapper.find('PageSidebar').length).toBe(1);
// sidebar groups and route links
expect(appWrapper.find('NavExpandableGroup').length).toBe(2);
expect(appWrapper.find('a[href="/#/foo"]').length).toBe(1);
expect(appWrapper.find('a[href="/#/bar"]').length).toBe(1);
expect(appWrapper.find('a[href="/#/fiz"]').length).toBe(1);
// inline render
expect(appWrapper.find('#group_one').length).toBe(1);
expect(appWrapper.find('#group_two').length).toBe(1);
});
test('opening the about modal renders prefetched config data', async done => {
const wrapper = mountWithContexts(<App />);
wrapper.update();
// open about modal
const aboutDropdown = 'Dropdown QuestionCircleIcon';
const aboutButton = 'DropdownItem li button';
const aboutModalContent = 'AboutModalBoxContent';
const aboutModalClose = 'button[aria-label="Close Dialog"]';
await waitForElement(wrapper, aboutDropdown);
wrapper.find(aboutDropdown).simulate('click');
const button = await waitForElement(
wrapper,
aboutButton,
el => !el.props().disabled
);
button.simulate('click');
// check about modal content
const content = await waitForElement(wrapper, aboutModalContent);
expect(content.find('dd').text()).toContain(ansible_version);
expect(content.find('pre').text()).toContain(`< AWX ${version} >`);
// close about modal
wrapper.find(aboutModalClose).simulate('click');
expect(wrapper.find(aboutModalContent)).toHaveLength(0);
done();
});
test('handleNavToggle sets state.isNavOpen to opposite', () => {
const appWrapper = mountWithContexts(<App />).find('App');
const { handleNavToggle } = appWrapper.instance();
[true, false, true, false, true].forEach(expected => {
expect(appWrapper.state().isNavOpen).toBe(expected);
handleNavToggle();
});
});
test('onLogout makes expected call to api client', async done => {
const appWrapper = mountWithContexts(<App />).find('App');
appWrapper.instance().handleLogout();
await asyncFlush();
expect(RootAPI.logout).toHaveBeenCalledTimes(1);
done();
});
});

View File

@ -0,0 +1,34 @@
import React, { Component } from 'react';
import { I18nProvider } from '@lingui/react';
import { HashRouter } from 'react-router-dom';
import ja from '../build/locales/ja/messages';
import en from '../build/locales/en/messages';
export function getLanguage(nav) {
const language =
(nav.languages && nav.languages[0]) || nav.language || nav.userLanguage;
const languageWithoutRegionCode = language.toLowerCase().split(/[_-]+/)[0];
return languageWithoutRegionCode;
}
class RootProvider extends Component {
render() {
const { children } = this.props;
const catalogs = { en, ja };
const language = getLanguage(navigator);
return (
<HashRouter>
<I18nProvider language={language} catalogs={catalogs}>
{children}
</I18nProvider>
</HashRouter>
);
}
}
export default RootProvider;

View File

@ -0,0 +1,18 @@
import { getLanguage } from './RootProvider';
describe('RootProvider.jsx', () => {
test('getLanguage returns the expected language code', () => {
expect(getLanguage({ languages: ['es-US'] })).toEqual('es');
expect(
getLanguage({
languages: ['es-US'],
language: 'fr-FR',
userLanguage: 'en-US',
})
).toEqual('es');
expect(getLanguage({ language: 'fr-FR', userLanguage: 'en-US' })).toEqual(
'fr'
);
expect(getLanguage({ userLanguage: 'en-US' })).toEqual('en');
});
});

View File

@ -0,0 +1,43 @@
import axios from 'axios';
const defaultHttp = axios.create({
xsrfCookieName: 'csrftoken',
xsrfHeaderName: 'X-CSRFToken',
});
class Base {
constructor(http = defaultHttp, baseURL) {
this.http = http;
this.baseUrl = baseURL;
}
create(data) {
return this.http.post(this.baseUrl, data);
}
destroy(id) {
return this.http.delete(`${this.baseUrl}${id}/`);
}
read(params = {}) {
return this.http.get(this.baseUrl, { params });
}
readDetail(id) {
return this.http.get(`${this.baseUrl}${id}/`);
}
readOptions() {
return this.http.options(this.baseUrl);
}
replace(id, data) {
return this.http.put(`${this.baseUrl}${id}/`, data);
}
update(id, data) {
return this.http.patch(`${this.baseUrl}${id}/`, data);
}
}
export default Base;

View File

@ -0,0 +1,105 @@
import Base from './Base';
describe('Base', () => {
const createPromise = () => Promise.resolve();
const mockBaseURL = '/api/v2/organizations/';
const mockHttp = {
delete: jest.fn(createPromise),
get: jest.fn(createPromise),
options: jest.fn(createPromise),
patch: jest.fn(createPromise),
post: jest.fn(createPromise),
put: jest.fn(createPromise),
};
const BaseAPI = new Base(mockHttp, mockBaseURL);
afterEach(() => {
jest.clearAllMocks();
});
test('create calls http method with expected data', async done => {
const data = { name: 'test ' };
await BaseAPI.create(data);
expect(mockHttp.post).toHaveBeenCalledTimes(1);
expect(mockHttp.post.mock.calls[0][1]).toEqual(data);
done();
});
test('destroy calls http method with expected data', async done => {
const resourceId = 1;
await BaseAPI.destroy(resourceId);
expect(mockHttp.delete).toHaveBeenCalledTimes(1);
expect(mockHttp.delete.mock.calls[0][0]).toEqual(
`${mockBaseURL}${resourceId}/`
);
done();
});
test('read calls http method with expected data', async done => {
const defaultParams = {};
const testParams = { foo: 'bar' };
await BaseAPI.read(testParams);
await BaseAPI.read();
expect(mockHttp.get).toHaveBeenCalledTimes(2);
expect(mockHttp.get.mock.calls[0][1]).toEqual({ params: testParams });
expect(mockHttp.get.mock.calls[1][1]).toEqual({ params: defaultParams });
done();
});
test('readDetail calls http method with expected data', async done => {
const resourceId = 1;
await BaseAPI.readDetail(resourceId);
expect(mockHttp.get).toHaveBeenCalledTimes(1);
expect(mockHttp.get.mock.calls[0][0]).toEqual(
`${mockBaseURL}${resourceId}/`
);
done();
});
test('readOptions calls http method with expected data', async done => {
await BaseAPI.readOptions();
expect(mockHttp.options).toHaveBeenCalledTimes(1);
expect(mockHttp.options.mock.calls[0][0]).toEqual(`${mockBaseURL}`);
done();
});
test('replace calls http method with expected data', async done => {
const resourceId = 1;
const data = { name: 'test ' };
await BaseAPI.replace(resourceId, data);
expect(mockHttp.put).toHaveBeenCalledTimes(1);
expect(mockHttp.put.mock.calls[0][0]).toEqual(
`${mockBaseURL}${resourceId}/`
);
expect(mockHttp.put.mock.calls[0][1]).toEqual(data);
done();
});
test('update calls http method with expected data', async done => {
const resourceId = 1;
const data = { name: 'test ' };
await BaseAPI.update(resourceId, data);
expect(mockHttp.patch).toHaveBeenCalledTimes(1);
expect(mockHttp.patch.mock.calls[0][0]).toEqual(
`${mockBaseURL}${resourceId}/`
);
expect(mockHttp.patch.mock.calls[0][1]).toEqual(data);
done();
});
});

View File

@ -0,0 +1,40 @@
import Config from './models/Config';
import InstanceGroups from './models/InstanceGroups';
import JobTemplates from './models/JobTemplates';
import Jobs from './models/Jobs';
import Me from './models/Me';
import Organizations from './models/Organizations';
import Root from './models/Root';
import Teams from './models/Teams';
import UnifiedJobTemplates from './models/UnifiedJobTemplates';
import UnifiedJobs from './models/UnifiedJobs';
import Users from './models/Users';
import WorkflowJobTemplates from './models/WorkflowJobTemplates';
const ConfigAPI = new Config();
const InstanceGroupsAPI = new InstanceGroups();
const JobTemplatesAPI = new JobTemplates();
const JobsAPI = new Jobs();
const MeAPI = new Me();
const OrganizationsAPI = new Organizations();
const RootAPI = new Root();
const TeamsAPI = new Teams();
const UnifiedJobTemplatesAPI = new UnifiedJobTemplates();
const UnifiedJobsAPI = new UnifiedJobs();
const UsersAPI = new Users();
const WorkflowJobTemplatesAPI = new WorkflowJobTemplates();
export {
ConfigAPI,
InstanceGroupsAPI,
JobTemplatesAPI,
JobsAPI,
MeAPI,
OrganizationsAPI,
RootAPI,
TeamsAPI,
UnifiedJobTemplatesAPI,
UnifiedJobsAPI,
UsersAPI,
WorkflowJobTemplatesAPI,
};

View File

@ -0,0 +1,23 @@
const InstanceGroupsMixin = parent =>
class extends parent {
readInstanceGroups(resourceId, params = {}) {
return this.http.get(`${this.baseUrl}${resourceId}/instance_groups/`, {
params,
});
}
associateInstanceGroup(resourceId, instanceGroupId) {
return this.http.post(`${this.baseUrl}${resourceId}/instance_groups/`, {
id: instanceGroupId,
});
}
disassociateInstanceGroup(resourceId, instanceGroupId) {
return this.http.post(`${this.baseUrl}${resourceId}/instance_groups/`, {
id: instanceGroupId,
disassociate: true,
});
}
};
export default InstanceGroupsMixin;

View File

@ -0,0 +1,102 @@
const NotificationsMixin = parent =>
class extends parent {
readNotificationTemplates(id, params = {}) {
return this.http.get(`${this.baseUrl}${id}/notification_templates/`, {
params,
});
}
readNotificationTemplatesSuccess(id, params = {}) {
return this.http.get(
`${this.baseUrl}${id}/notification_templates_success/`,
{ params }
);
}
readNotificationTemplatesError(id, params = {}) {
return this.http.get(
`${this.baseUrl}${id}/notification_templates_error/`,
{ params }
);
}
associateNotificationTemplatesSuccess(resourceId, notificationId) {
return this.http.post(
`${this.baseUrl}${resourceId}/notification_templates_success/`,
{ id: notificationId }
);
}
disassociateNotificationTemplatesSuccess(resourceId, notificationId) {
return this.http.post(
`${this.baseUrl}${resourceId}/notification_templates_success/`,
{ id: notificationId, disassociate: true }
);
}
associateNotificationTemplatesError(resourceId, notificationId) {
return this.http.post(
`${this.baseUrl}${resourceId}/notification_templates_error/`,
{ id: notificationId }
);
}
disassociateNotificationTemplatesError(resourceId, notificationId) {
return this.http.post(
`${this.baseUrl}${resourceId}/notification_templates_error/`,
{ id: notificationId, disassociate: true }
);
}
/**
* This is a helper method meant to simplify setting the "on" or "off" status of
* a related notification.
*
* @param[resourceId] - id of the base resource
* @param[notificationId] - id of the notification
* @param[notificationType] - the type of notification, options are "success" and "error"
* @param[associationState] - Boolean for associating or disassociating,
* options are true or false
*/
// eslint-disable-next-line max-len
updateNotificationTemplateAssociation(
resourceId,
notificationId,
notificationType,
associationState
) {
if (notificationType === 'success' && associationState === true) {
return this.associateNotificationTemplatesSuccess(
resourceId,
notificationId
);
}
if (notificationType === 'success' && associationState === false) {
return this.disassociateNotificationTemplatesSuccess(
resourceId,
notificationId
);
}
if (notificationType === 'error' && associationState === true) {
return this.associateNotificationTemplatesError(
resourceId,
notificationId
);
}
if (notificationType === 'error' && associationState === false) {
return this.disassociateNotificationTemplatesError(
resourceId,
notificationId
);
}
throw new Error(
`Unsupported notificationType, associationState combination: ${notificationType}, ${associationState}`
);
}
};
export default NotificationsMixin;

View File

@ -0,0 +1,10 @@
import Base from '../Base';
class Config extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/config/';
}
}
export default Config;

View File

@ -0,0 +1,10 @@
import Base from '../Base';
class InstanceGroups extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/instance_groups/';
}
}
export default InstanceGroups;

View File

@ -0,0 +1,22 @@
import Base from '../Base';
import InstanceGroupsMixin from '../mixins/InstanceGroups.mixin';
class JobTemplates extends InstanceGroupsMixin(Base) {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/job_templates/';
this.launch = this.launch.bind(this);
this.readLaunch = this.readLaunch.bind(this);
}
launch(id, data) {
return this.http.post(`${this.baseUrl}${id}/launch/`, data);
}
readLaunch(id) {
return this.http.get(`${this.baseUrl}${id}/launch/`);
}
}
export default JobTemplates;

View File

@ -0,0 +1,10 @@
import Base from '../Base';
class Jobs extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/jobs/';
}
}
export default Jobs;

View File

@ -0,0 +1,10 @@
import Base from '../Base';
class Me extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/me/';
}
}
export default Me;

View File

@ -0,0 +1,20 @@
import Base from '../Base';
import NotificationsMixin from '../mixins/Notifications.mixin';
import InstanceGroupsMixin from '../mixins/InstanceGroups.mixin';
class Organizations extends InstanceGroupsMixin(NotificationsMixin(Base)) {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/organizations/';
}
readAccessList(id, params = {}) {
return this.http.get(`${this.baseUrl}${id}/access_list/`, { params });
}
readTeams(id, params = {}) {
return this.http.get(`${this.baseUrl}${id}/teams/`, { params });
}
}
export default Organizations;

View File

@ -0,0 +1,51 @@
import Organizations from './Organizations';
import { describeNotificationMixin } from '../../../testUtils/apiReusable';
describe('OrganizationsAPI', () => {
const orgId = 1;
const searchParams = { foo: 'bar' };
const createPromise = () => Promise.resolve();
const mockHttp = { get: jest.fn(createPromise) };
const OrganizationsAPI = new Organizations(mockHttp);
afterEach(() => {
jest.clearAllMocks();
});
test('read access list calls get with expected params', async done => {
await OrganizationsAPI.readAccessList(orgId);
await OrganizationsAPI.readAccessList(orgId, searchParams);
expect(mockHttp.get).toHaveBeenCalledTimes(2);
expect(mockHttp.get.mock.calls[0]).toContainEqual(
`/api/v2/organizations/${orgId}/access_list/`,
{ params: {} }
);
expect(mockHttp.get.mock.calls[1]).toContainEqual(
`/api/v2/organizations/${orgId}/access_list/`,
{ params: searchParams }
);
done();
});
test('read teams calls get with expected params', async done => {
await OrganizationsAPI.readTeams(orgId);
await OrganizationsAPI.readTeams(orgId, searchParams);
expect(mockHttp.get).toHaveBeenCalledTimes(2);
expect(mockHttp.get.mock.calls[0]).toContainEqual(
`/api/v2/organizations/${orgId}/teams/`,
{ params: {} }
);
expect(mockHttp.get.mock.calls[1]).toContainEqual(
`/api/v2/organizations/${orgId}/teams/`,
{ params: searchParams }
);
done();
});
});
describeNotificationMixin(Organizations, 'Organizations[NotificationsMixin]');

View File

@ -0,0 +1,30 @@
import Base from '../Base';
class Root extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/';
this.redirectURL = '/api/v2/config/';
}
async login(username, password, redirect = this.redirectURL) {
const loginUrl = `${this.baseUrl}login/`;
const un = encodeURIComponent(username);
const pw = encodeURIComponent(password);
const next = encodeURIComponent(redirect);
const data = `username=${un}&password=${pw}&next=${next}`;
const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
await this.http.get(loginUrl, { headers });
const response = await this.http.post(loginUrl, data, { headers });
return response;
}
logout() {
return this.http.get(`${this.baseUrl}logout/`);
}
}
export default Root;

View File

@ -0,0 +1,52 @@
import Root from './Root';
describe('RootAPI', () => {
const createPromise = () => Promise.resolve();
const mockHttp = {
get: jest.fn(createPromise),
post: jest.fn(createPromise),
};
const RootAPI = new Root(mockHttp);
afterEach(() => {
jest.clearAllMocks();
});
test('login calls get and post with expected content headers', async done => {
const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
await RootAPI.login('username', 'password');
expect(mockHttp.get).toHaveBeenCalledTimes(1);
expect(mockHttp.get.mock.calls[0]).toContainEqual({ headers });
expect(mockHttp.post).toHaveBeenCalledTimes(1);
expect(mockHttp.post.mock.calls[0]).toContainEqual({ headers });
done();
});
test('login sends expected data', async done => {
await RootAPI.login('foo', 'bar');
await RootAPI.login('foo', 'bar', 'baz');
expect(mockHttp.post).toHaveBeenCalledTimes(2);
expect(mockHttp.post.mock.calls[0]).toContainEqual(
'username=foo&password=bar&next=%2Fapi%2Fv2%2Fconfig%2F'
);
expect(mockHttp.post.mock.calls[1]).toContainEqual(
'username=foo&password=bar&next=baz'
);
done();
});
test('logout calls expected http method', async done => {
await RootAPI.logout();
expect(mockHttp.get).toHaveBeenCalledTimes(1);
done();
});
});

View File

@ -0,0 +1,21 @@
import Base from '../Base';
class Teams extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/teams/';
}
associateRole(teamId, roleId) {
return this.http.post(`${this.baseUrl}${teamId}/roles/`, { id: roleId });
}
disassociateRole(teamId, roleId) {
return this.http.post(`${this.baseUrl}${teamId}/roles/`, {
id: roleId,
disassociate: true,
});
}
}
export default Teams;

View File

@ -0,0 +1,41 @@
import Teams from './Teams';
describe('TeamsAPI', () => {
const teamId = 1;
const roleId = 7;
const createPromise = () => Promise.resolve();
const mockHttp = { post: jest.fn(createPromise) };
const TeamsAPI = new Teams(mockHttp);
afterEach(() => {
jest.clearAllMocks();
});
test('associate role calls post with expected params', async done => {
await TeamsAPI.associateRole(teamId, roleId);
expect(mockHttp.post).toHaveBeenCalledTimes(1);
expect(mockHttp.post.mock.calls[0]).toContainEqual(
`/api/v2/teams/${teamId}/roles/`,
{ id: roleId }
);
done();
});
test('read teams calls post with expected params', async done => {
await TeamsAPI.disassociateRole(teamId, roleId);
expect(mockHttp.post).toHaveBeenCalledTimes(1);
expect(mockHttp.post.mock.calls[0]).toContainEqual(
`/api/v2/teams/${teamId}/roles/`,
{
id: roleId,
disassociate: true,
}
);
done();
});
});

View File

@ -0,0 +1,10 @@
import Base from '../Base';
class UnifiedJobTemplates extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/unified_job_templates/';
}
}
export default UnifiedJobTemplates;

View File

@ -0,0 +1,10 @@
import Base from '../Base';
class UnifiedJobs extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/unified_jobs/';
}
}
export default UnifiedJobs;

View File

@ -0,0 +1,21 @@
import Base from '../Base';
class Users extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/users/';
}
associateRole(userId, roleId) {
return this.http.post(`${this.baseUrl}${userId}/roles/`, { id: roleId });
}
disassociateRole(userId, roleId) {
return this.http.post(`${this.baseUrl}${userId}/roles/`, {
id: roleId,
disassociate: true,
});
}
}
export default Users;

View File

@ -0,0 +1,41 @@
import Users from './Users';
describe('UsersAPI', () => {
const userId = 1;
const roleId = 7;
const createPromise = () => Promise.resolve();
const mockHttp = { post: jest.fn(createPromise) };
const UsersAPI = new Users(mockHttp);
afterEach(() => {
jest.clearAllMocks();
});
test('associate role calls post with expected params', async done => {
await UsersAPI.associateRole(userId, roleId);
expect(mockHttp.post).toHaveBeenCalledTimes(1);
expect(mockHttp.post.mock.calls[0]).toContainEqual(
`/api/v2/users/${userId}/roles/`,
{ id: roleId }
);
done();
});
test('read users calls post with expected params', async done => {
await UsersAPI.disassociateRole(userId, roleId);
expect(mockHttp.post).toHaveBeenCalledTimes(1);
expect(mockHttp.post.mock.calls[0]).toContainEqual(
`/api/v2/users/${userId}/roles/`,
{
id: roleId,
disassociate: true,
}
);
done();
});
});

View File

@ -0,0 +1,10 @@
import Base from '../Base';
class WorkflowJobTemplates extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/workflow_job_templates/';
}
}
export default WorkflowJobTemplates;

275
awx/ui_next/src/app.scss Normal file
View File

@ -0,0 +1,275 @@
// https://github.com/patternfly/patternfly-react/issues/1294
#app {
height: 100%;
}
//
// sidebar overrides
//
.pf-c-page__sidebar {
--pf-c-page__sidebar--md--Width: 255px;
.pf-c-nav {
overflow-y: auto;
.pf-c-nav__section {
--pf-c-nav__section--MarginTop: 8px;
}
.pf-c-nav__section + .pf-c-nav__section {
--pf-c-nav__section--MarginTop: 8px;
}
.pf-c-nav__simple-list .pf-c-nav__link {
--pf-c-nav__simple-list-link--PaddingBottom: 6px;
--pf-c-nav__simple-list-link--PaddingTop: 6px;
}
.pf-c-nav__section-title {
--pf-c-nav__section-title--PaddingLeft: 24px;
}
.pf-c-nav__link {
display: flex;
align-items: center;
padding-right: 64px;
}
}
}
//
// data list overrides
//
.pf-c-data-list {
--pf-global--target-size--MinHeight: 32px;
--pf-global--target-size--MinWidth: 32px;
--pf-global--FontSize--md: 14px;
.pf-c-badge:not(:last-child),
.pf-c-switch:not(:last-child) {
margin-right: 18px;
}
}
.pf-c-data-list__item-row {
--pf-c-data-list__item-row--PaddingRight: 20px;
--pf-c-data-list__item-row--PaddingLeft: 20px;
}
.pf-c-data-list__item-content {
--pf-c-data-list__item-content--PaddingBottom: 16px;
min-height: 59px;
align-items: center;
}
.pf-c-data-list__item-control {
--pf-c-data-list__item-control--PaddingTop: 16px;
--pf-c-data-list__item-control--MarginRight: 8px;
--pf-c-data-list__item-control--PaddingBottom: 16px;
}
.pf-c-data-list__item {
--pf-c-data-list__item--PaddingLeft: 20px;
--pf-c-data-list__item--PaddingRight: 20px;
}
.pf-c-data-list__cell {
--pf-c-data-list__cell--PaddingTop: 16px;
--pf-c-data-list__cell-cell--PaddingTop: 16px;
&.pf-c-data-list__cell--divider {
--pf-c-data-list__cell-cell--MarginRight: 0;
--pf-c-data-list__cell--PaddingTop: 12px;
flex-grow: 0;
}
}
//
// pf modal overrides
//
.awx-c-modal.pf-c-modal-box {
margin: 0;
padding: 24px;
width: 600px;
.pf-c-modal-box__body {
overflow: visible;
}
.pf-c-modal-box__footer > .pf-c-button:not(:last-child) {
margin-right: 20px;
}
}
.pf-c-modal-box__footer {
--pf-c-modal-box__footer--PaddingTop: 20px;
--pf-c-modal-box__footer--PaddingRight: 20px;
--pf-c-modal-box__footer--PaddingBottom: 20px;
--pf-c-modal-box__footer--PaddingLeft: 20px;
justify-content: flex-end;
}
.pf-c-modal-box__header {
--pf-c-modal-box__header--PaddingTop: 10px;
--pf-c-modal-box__header--PaddingRight: 0;
--pf-c-modal-box__header--PaddingBottom: 0;
--pf-c-modal-box__header--PaddingLeft: 20px;
}
.pf-c-modal-box__body {
--pf-c-modal-box__body--PaddingLeft: 20px;
--pf-c-modal-box__body--PaddingRight: 20px;
--pf-c-modal-box__body--PaddingBottom: 5px;
}
//
// pf tooltip overrides
//
.pf-c-tooltip__content {
--pf-c-tooltip__content--PaddingTop: 0.71rem;
--pf-c-tooltip__content--PaddingRight: 0.71rem;
--pf-c-tooltip__content--PaddingBottom: 0.71rem;
--pf-c-tooltip__content--PaddingLeft: 0.71rem;
}
// higher specificity needed to override PF styles added dynamically to page
.pf-c-tooltip .pf-c-tooltip__content {
text-align: left;
}
//
// pf empty state overrides
//
.pf-c-empty-state {
align-self: center;
}
//
// assorted custom component styles
// note that these should be given a consistent prefix
// and bem style, as well as moved into component-based scss files
//
.awx-lookup .pf-c-form-control {
--pf-c-form-control--Height: 90px;
overflow-y: auto;
}
.at-c-listCardBody {
--pf-c-card__footer--PaddingX: 0;
--pf-c-card__footer--PaddingY: 0;
--pf-c-card__body--PaddingX: 0;
--pf-c-card__body--PaddingY: 0;
}
.awx-c-card {
position: relative;
}
//
// PF Alert notification component overrides
//
.pf-c-alert__title {
--pf-c-alert__title--PaddingTop: 20px;
--pf-c-alert__title--PaddingRight: 20px;
--pf-c-alert__title--PaddingBottom: 20px;
--pf-c-alert__title--PaddingLeft: 20px;
}
.pf-c-alert__description {
--pf-c-alert__description--PaddingRight: 20px;
--pf-c-alert__description--PaddingBottom: 20px;
--pf-c-alert__description--PaddingLeft: 20px;
}
.pf-c-alert {
position: absolute;
width: 100%;
z-index: 20;
}
.pf-c-alert__icon {
--pf-c-alert__icon--Color: white;
}
.at-u-textRight {
text-align: right;
}
//
// AlertModal styles
//
.at-c-alertModal.pf-c-modal-box {
border: 0;
border-left: 56px solid black;
.at-c-alertModal__icon {
position: absolute;
font-size: 23px;
top: 28px;
left: -39px;
}
}
.at-c-alertModal--warning.pf-c-modal-box {
border-color: var(--pf-global--warning-color--100);
.pf-c-title {
color: var(--pf-global--warning-color--200);
}
.at-c-alertModal__icon {
color: var(--pf-global--warning-color--200);
}
}
.at-c-alertModal--danger.pf-c-modal-box {
border-color: var(--pf-global--danger-color--100);
.pf-c-title {
color: var(--pf-global--danger-color--200);
}
.at-c-alertModal__icon {
color: white;
}
}
.at-c-alertModal--info.pf-c-modal-box {
border-color: var(--pf-global--info-color--100);
.pf-c-title {
color: var(--pf-global--info-color--200);
}
.at-c-alertModal__icon {
color: var(--pf-global--info-color--200);
}
}
.at-c-alertModal--success.pf-c-modal-box {
border-color: var(--pf-global--success-color--100);
.pf-c-title {
color: var(--pf-global--success-color--200);
}
.at-c-alertModal__icon {
color: var(--pf-global--success-color--200);
}
}
//
// LoginModal overrides
//
.pf-m-error p.pf-c-form__helper-text {
color: var(--pf-global--danger-color--100);
}

View File

@ -0,0 +1,90 @@
import React from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
AboutModal,
TextContent,
TextList,
TextListItem,
} from '@patternfly/react-core';
import { BrandName } from '../../variables';
import brandLogoImg from '../../../images/brand-logo.svg';
class About extends React.Component {
static createSpeechBubble(version) {
let text = `${BrandName} ${version}`;
let top = '';
let bottom = '';
for (let i = 0; i < text.length; i++) {
top += '_';
bottom += '-';
}
top = ` __${top}__ \n`;
text = `< ${text} >\n`;
bottom = ` --${bottom}-- `;
return top + text + bottom;
}
constructor(props) {
super(props);
this.createSpeechBubble = this.constructor.createSpeechBubble.bind(this);
}
render() {
const { ansible_version, version, isOpen, onClose, i18n } = this.props;
const speechBubble = this.createSpeechBubble(version);
return (
<AboutModal
isOpen={isOpen}
onClose={onClose}
productName={`Ansible ${BrandName}`}
trademark={i18n._(t`Copyright 2019 Red Hat, Inc.`)}
brandImageSrc={brandLogoImg}
brandImageAlt={i18n._(t`Brand Image`)}
>
<pre>
{speechBubble}
{`
\\
\\ ^__^
(oo)\\_______
(__) A )\\
||----w |
|| ||
`}
</pre>
<TextContent>
<TextList component="dl">
<TextListItem component="dt">
{i18n._(t`Ansible Version`)}
</TextListItem>
<TextListItem component="dd">{ansible_version}</TextListItem>
</TextList>
</TextContent>
</AboutModal>
);
}
}
About.propTypes = {
ansible_version: PropTypes.string,
isOpen: PropTypes.bool,
onClose: PropTypes.func.isRequired,
version: PropTypes.string,
};
About.defaultProps = {
ansible_version: null,
isOpen: false,
version: null,
};
export default withI18n()(About);

View File

@ -0,0 +1,22 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import About from './About';
describe('<About />', () => {
let aboutWrapper;
let closeButton;
const onClose = jest.fn();
test('initially renders without crashing', () => {
aboutWrapper = mountWithContexts(<About isOpen onClose={onClose} />);
expect(aboutWrapper.length).toBe(1);
aboutWrapper.unmount();
});
test('close button calls onClose handler', () => {
aboutWrapper = mountWithContexts(<About isOpen onClose={onClose} />);
closeButton = aboutWrapper.find('AboutModalBoxCloseButton Button');
closeButton.simulate('click');
expect(onClose).toBeCalled();
aboutWrapper.unmount();
});
});

View File

@ -0,0 +1 @@
export { default } from './About';

View File

@ -0,0 +1,250 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Wizard } from '@patternfly/react-core';
import SelectResourceStep from './SelectResourceStep';
import SelectRoleStep from './SelectRoleStep';
import SelectableCard from './SelectableCard';
import { TeamsAPI, UsersAPI } from '../../api';
const readUsers = async queryParams =>
UsersAPI.read(Object.assign(queryParams, { is_superuser: false }));
const readTeams = async queryParams => TeamsAPI.read(queryParams);
class AddResourceRole extends React.Component {
constructor(props) {
super(props);
this.state = {
selectedResource: null,
selectedResourceRows: [],
selectedRoleRows: [],
currentStepId: 1,
};
this.handleResourceCheckboxClick = this.handleResourceCheckboxClick.bind(
this
);
this.handleResourceSelect = this.handleResourceSelect.bind(this);
this.handleRoleCheckboxClick = this.handleRoleCheckboxClick.bind(this);
this.handleWizardNext = this.handleWizardNext.bind(this);
this.handleWizardSave = this.handleWizardSave.bind(this);
}
handleResourceCheckboxClick(user) {
const { selectedResourceRows } = this.state;
const selectedIndex = selectedResourceRows.findIndex(
selectedRow => selectedRow.id === user.id
);
if (selectedIndex > -1) {
selectedResourceRows.splice(selectedIndex, 1);
this.setState({ selectedResourceRows });
} else {
this.setState(prevState => ({
selectedResourceRows: [...prevState.selectedResourceRows, user],
}));
}
}
handleRoleCheckboxClick(role) {
const { selectedRoleRows } = this.state;
const selectedIndex = selectedRoleRows.findIndex(
selectedRow => selectedRow.id === role.id
);
if (selectedIndex > -1) {
selectedRoleRows.splice(selectedIndex, 1);
this.setState({ selectedRoleRows });
} else {
this.setState(prevState => ({
selectedRoleRows: [...prevState.selectedRoleRows, role],
}));
}
}
handleResourceSelect(resourceType) {
this.setState({
selectedResource: resourceType,
selectedResourceRows: [],
selectedRoleRows: [],
});
}
handleWizardNext(step) {
this.setState({
currentStepId: step.id,
});
}
async handleWizardSave() {
const { onSave } = this.props;
const {
selectedResourceRows,
selectedRoleRows,
selectedResource,
} = this.state;
try {
const roleRequests = [];
for (let i = 0; i < selectedResourceRows.length; i++) {
for (let j = 0; j < selectedRoleRows.length; j++) {
if (selectedResource === 'users') {
roleRequests.push(
UsersAPI.associateRole(
selectedResourceRows[i].id,
selectedRoleRows[j].id
)
);
} else if (selectedResource === 'teams') {
roleRequests.push(
TeamsAPI.associateRole(
selectedResourceRows[i].id,
selectedRoleRows[j].id
)
);
}
}
}
await Promise.all(roleRequests);
onSave();
} catch (err) {
// TODO: handle this error
}
}
render() {
const {
selectedResource,
selectedResourceRows,
selectedRoleRows,
currentStepId,
} = this.state;
const { onClose, roles, i18n } = this.props;
const userColumns = [
{ name: i18n._(t`Username`), key: 'username', isSortable: true },
];
const teamColumns = [
{ name: i18n._(t`Name`), key: 'name', isSortable: true },
];
let wizardTitle = '';
switch (selectedResource) {
case 'users':
wizardTitle = i18n._(t`Add User Roles`);
break;
case 'teams':
wizardTitle = i18n._(t`Add Team Roles`);
break;
default:
wizardTitle = i18n._(t`Add Roles`);
}
const steps = [
{
id: 1,
name: i18n._(t`Select Users Or Teams`),
component: (
<div style={{ display: 'flex' }}>
<SelectableCard
isSelected={selectedResource === 'users'}
label={i18n._(t`Users`)}
onClick={() => this.handleResourceSelect('users')}
/>
<SelectableCard
isSelected={selectedResource === 'teams'}
label={i18n._(t`Teams`)}
onClick={() => this.handleResourceSelect('teams')}
/>
</div>
),
enableNext: selectedResource !== null,
},
{
id: 2,
name: i18n._(t`Select items from list`),
component: (
<Fragment>
{selectedResource === 'users' && (
<SelectResourceStep
columns={userColumns}
displayKey="username"
onRowClick={this.handleResourceCheckboxClick}
onSearch={readUsers}
selectedLabel={i18n._(t`Selected`)}
selectedResourceRows={selectedResourceRows}
sortedColumnKey="username"
itemName="user"
/>
)}
{selectedResource === 'teams' && (
<SelectResourceStep
columns={teamColumns}
onRowClick={this.handleResourceCheckboxClick}
onSearch={readTeams}
selectedLabel={i18n._(t`Selected`)}
selectedResourceRows={selectedResourceRows}
itemName="team"
/>
)}
</Fragment>
),
enableNext: selectedResourceRows.length > 0,
},
{
id: 3,
name: i18n._(t`Apply roles`),
component: (
<SelectRoleStep
onRolesClick={this.handleRoleCheckboxClick}
roles={roles}
selectedListKey={selectedResource === 'users' ? 'username' : 'name'}
selectedListLabel={i18n._(t`Selected`)}
selectedResourceRows={selectedResourceRows}
selectedRoleRows={selectedRoleRows}
/>
),
nextButtonText: i18n._(t`Save`),
enableNext: selectedRoleRows.length > 0,
},
];
const currentStep = steps.find(step => step.id === currentStepId);
// TODO: somehow internationalize steps and currentStep.nextButtonText
return (
<Wizard
style={{ overflow: 'scroll' }}
isOpen
onNext={this.handleWizardNext}
onClose={onClose}
onSave={this.handleWizardSave}
steps={steps}
title={wizardTitle}
nextButtonText={currentStep.nextButtonText || undefined}
/>
);
}
}
AddResourceRole.propTypes = {
onClose: PropTypes.func.isRequired,
onSave: PropTypes.func.isRequired,
roles: PropTypes.shape(),
};
AddResourceRole.defaultProps = {
roles: {},
};
export { AddResourceRole as _AddResourceRole };
export default withI18n()(AddResourceRole);

View File

@ -0,0 +1,219 @@
/* eslint-disable react/jsx-pascal-case */
import React from 'react';
import { shallow } from 'enzyme';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import AddResourceRole, { _AddResourceRole } from './AddResourceRole';
import { TeamsAPI, UsersAPI } from '../../api';
jest.mock('../../api');
describe('<_AddResourceRole />', () => {
UsersAPI.read.mockResolvedValue({
data: {
count: 2,
results: [{ id: 1, username: 'foo' }, { id: 2, username: 'bar' }],
},
});
const roles = {
admin_role: {
description: 'Can manage all aspects of the organization',
id: 1,
name: 'Admin',
},
execute_role: {
description: 'May run any executable resources in the organization',
id: 2,
name: 'Execute',
},
};
test('initially renders without crashing', () => {
shallow(
<_AddResourceRole
onClose={() => {}}
onSave={() => {}}
roles={roles}
i18n={{ _: val => val.toString() }}
/>
);
});
test('handleRoleCheckboxClick properly updates state', () => {
const wrapper = shallow(
<_AddResourceRole
onClose={() => {}}
onSave={() => {}}
roles={roles}
i18n={{ _: val => val.toString() }}
/>
);
wrapper.setState({
selectedRoleRows: [
{
description: 'Can manage all aspects of the organization',
name: 'Admin',
id: 1,
},
],
});
wrapper.instance().handleRoleCheckboxClick({
description: 'Can manage all aspects of the organization',
name: 'Admin',
id: 1,
});
expect(wrapper.state('selectedRoleRows')).toEqual([]);
wrapper.instance().handleRoleCheckboxClick({
description: 'Can manage all aspects of the organization',
name: 'Admin',
id: 1,
});
expect(wrapper.state('selectedRoleRows')).toEqual([
{
description: 'Can manage all aspects of the organization',
name: 'Admin',
id: 1,
},
]);
});
test('handleResourceCheckboxClick properly updates state', () => {
const wrapper = shallow(
<_AddResourceRole
onClose={() => {}}
onSave={() => {}}
roles={roles}
i18n={{ _: val => val.toString() }}
/>
);
wrapper.setState({
selectedResourceRows: [
{
id: 1,
username: 'foobar',
},
],
});
wrapper.instance().handleResourceCheckboxClick({
id: 1,
username: 'foobar',
});
expect(wrapper.state('selectedResourceRows')).toEqual([]);
wrapper.instance().handleResourceCheckboxClick({
id: 1,
username: 'foobar',
});
expect(wrapper.state('selectedResourceRows')).toEqual([
{
id: 1,
username: 'foobar',
},
]);
});
test('clicking user/team cards updates state', () => {
const spy = jest.spyOn(_AddResourceRole.prototype, 'handleResourceSelect');
const wrapper = mountWithContexts(
<AddResourceRole onClose={() => {}} onSave={() => {}} roles={roles} />,
{ context: { network: { handleHttpError: () => {} } } }
).find('AddResourceRole');
const selectableCardWrapper = wrapper.find('SelectableCard');
expect(selectableCardWrapper.length).toBe(2);
selectableCardWrapper.first().simulate('click');
expect(spy).toHaveBeenCalledWith('users');
expect(wrapper.state('selectedResource')).toBe('users');
selectableCardWrapper.at(1).simulate('click');
expect(spy).toHaveBeenCalledWith('teams');
expect(wrapper.state('selectedResource')).toBe('teams');
});
test('handleResourceSelect clears out selected lists and sets selectedResource', () => {
const wrapper = shallow(
<_AddResourceRole
onClose={() => {}}
onSave={() => {}}
roles={roles}
i18n={{ _: val => val.toString() }}
/>
);
wrapper.setState({
selectedResource: 'teams',
selectedResourceRows: [
{
id: 1,
username: 'foobar',
},
],
selectedRoleRows: [
{
description: 'Can manage all aspects of the organization',
id: 1,
name: 'Admin',
},
],
});
wrapper.instance().handleResourceSelect('users');
expect(wrapper.state()).toEqual({
selectedResource: 'users',
selectedResourceRows: [],
selectedRoleRows: [],
currentStepId: 1,
});
wrapper.instance().handleResourceSelect('teams');
expect(wrapper.state()).toEqual({
selectedResource: 'teams',
selectedResourceRows: [],
selectedRoleRows: [],
currentStepId: 1,
});
});
test('handleWizardSave makes correct api calls, calls onSave when done', async () => {
const handleSave = jest.fn();
const wrapper = mountWithContexts(
<AddResourceRole onClose={() => {}} onSave={handleSave} roles={roles} />,
{ context: { network: { handleHttpError: () => {} } } }
).find('AddResourceRole');
wrapper.setState({
selectedResource: 'users',
selectedResourceRows: [
{
id: 1,
username: 'foobar',
},
],
selectedRoleRows: [
{
description: 'Can manage all aspects of the organization',
id: 1,
name: 'Admin',
},
{
description: 'May run any executable resources in the organization',
id: 2,
name: 'Execute',
},
],
});
await wrapper.instance().handleWizardSave();
expect(UsersAPI.associateRole).toHaveBeenCalledTimes(2);
expect(handleSave).toHaveBeenCalled();
wrapper.setState({
selectedResource: 'teams',
selectedResourceRows: [
{
id: 1,
name: 'foobar',
},
],
selectedRoleRows: [
{
description: 'Can manage all aspects of the organization',
id: 1,
name: 'Admin',
},
{
description: 'May run any executable resources in the organization',
id: 2,
name: 'Execute',
},
],
});
await wrapper.instance().handleWizardSave();
expect(TeamsAPI.associateRole).toHaveBeenCalledTimes(2);
expect(handleSave).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,49 @@
import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import { Checkbox } from '@patternfly/react-core';
class CheckboxCard extends Component {
render() {
const { name, description, isSelected, onSelect, itemId } = this.props;
return (
<div
style={{
display: 'flex',
border: '1px solid var(--pf-global--BorderColor--200)',
borderRadius: 'var(--pf-global--BorderRadius--sm)',
padding: '10px',
}}
>
<Checkbox
checked={isSelected}
onChange={onSelect}
aria-label={name}
id={`checkbox-card-${itemId}`}
label={
<Fragment>
<div style={{ fontWeight: 'bold' }}>{name}</div>
<div>{description}</div>
</Fragment>
}
value={itemId}
/>
</div>
);
}
}
CheckboxCard.propTypes = {
name: PropTypes.string.isRequired,
description: PropTypes.string,
isSelected: PropTypes.bool,
onSelect: PropTypes.func,
itemId: PropTypes.number.isRequired,
};
CheckboxCard.defaultProps = {
description: '',
isSelected: false,
onSelect: null,
};
export default CheckboxCard;

View File

@ -0,0 +1,11 @@
import React from 'react';
import { shallow } from 'enzyme';
import CheckboxCard from './CheckboxCard';
describe('<CheckboxCard />', () => {
let wrapper;
test('initially renders without crashing', () => {
wrapper = shallow(<CheckboxCard name="Foobar" itemId={5} />);
expect(wrapper.length).toBe(1);
});
});

View File

@ -0,0 +1,147 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import PaginatedDataList from '../PaginatedDataList';
import DataListToolbar from '../DataListToolbar';
import CheckboxListItem from '../CheckboxListItem';
import SelectedList from '../SelectedList';
import { getQSConfig, parseNamespacedQueryString } from '../../util/qs';
class SelectResourceStep extends React.Component {
constructor(props) {
super(props);
this.state = {
isInitialized: false,
count: null,
error: false,
resources: [],
};
this.qsConfig = getQSConfig('resource', {
page: 1,
page_size: 5,
order_by: props.sortedColumnKey,
});
}
componentDidMount() {
this.readResourceList();
}
componentDidUpdate(prevProps) {
const { location } = this.props;
if (location !== prevProps.location) {
this.readResourceList();
}
}
async readResourceList() {
const { onSearch, location } = this.props;
const queryParams = parseNamespacedQueryString(
this.qsConfig,
location.search
);
this.setState({
isLoading: true,
error: false,
});
try {
const { data } = await onSearch(queryParams);
const { count, results } = data;
this.setState({
resources: results,
count,
isInitialized: true,
isLoading: false,
error: false,
});
} catch (err) {
this.setState({
isLoading: false,
error: true,
});
}
}
render() {
const { isInitialized, isLoading, count, error, resources } = this.state;
const {
columns,
displayKey,
onRowClick,
selectedLabel,
selectedResourceRows,
itemName,
i18n,
} = this.props;
return (
<Fragment>
{isLoading && <div>{i18n._(t`Loading...`)}</div>}
{isInitialized && (
<Fragment>
{selectedResourceRows.length > 0 && (
<SelectedList
displayKey={displayKey}
label={selectedLabel}
onRemove={onRowClick}
selected={selectedResourceRows}
showOverflowAfter={5}
/>
)}
<PaginatedDataList
items={resources}
itemCount={count}
itemName={itemName}
qsConfig={this.qsConfig}
toolbarColumns={columns}
renderItem={item => (
<CheckboxListItem
isSelected={selectedResourceRows.some(i => i.id === item.id)}
itemId={item.id}
key={item.id}
name={item[displayKey]}
onSelect={() => onRowClick(item)}
/>
)}
renderToolbar={props => (
<DataListToolbar {...props} alignToolbarLeft />
)}
showPageSizeOptions={false}
/>
</Fragment>
)}
{error ? <div>error</div> : ''}
</Fragment>
);
}
}
SelectResourceStep.propTypes = {
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
displayKey: PropTypes.string,
onRowClick: PropTypes.func,
onSearch: PropTypes.func.isRequired,
selectedLabel: PropTypes.string,
selectedResourceRows: PropTypes.arrayOf(PropTypes.object),
sortedColumnKey: PropTypes.string,
itemName: PropTypes.string,
};
SelectResourceStep.defaultProps = {
displayKey: 'name',
onRowClick: () => {},
selectedLabel: null,
selectedResourceRows: [],
sortedColumnKey: 'name',
itemName: 'item',
};
export { SelectResourceStep as _SelectResourceStep };
export default withI18n()(withRouter(SelectResourceStep));

View File

@ -0,0 +1,121 @@
import React from 'react';
import { createMemoryHistory } from 'history';
import { shallow } from 'enzyme';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import { sleep } from '../../../testUtils/testUtils';
import SelectResourceStep from './SelectResourceStep';
describe('<SelectResourceStep />', () => {
const columns = [{ name: 'Username', key: 'username', isSortable: true }];
afterEach(() => {
jest.restoreAllMocks();
});
test('initially renders without crashing', () => {
shallow(
<SelectResourceStep
columns={columns}
displayKey="username"
onRowClick={() => {}}
onSearch={() => {}}
sortedColumnKey="username"
/>
);
});
test('fetches resources on mount', async () => {
const handleSearch = jest.fn().mockResolvedValue({
data: {
count: 2,
results: [
{ id: 1, username: 'foo', url: 'item/1' },
{ id: 2, username: 'bar', url: 'item/2' },
],
},
});
mountWithContexts(
<SelectResourceStep
columns={columns}
displayKey="username"
onRowClick={() => {}}
onSearch={handleSearch}
sortedColumnKey="username"
/>
);
expect(handleSearch).toHaveBeenCalledWith({
order_by: 'username',
page: 1,
page_size: 5,
});
});
test('readResourceList properly adds rows to state', async () => {
const selectedResourceRows = [{ id: 1, username: 'foo', url: 'item/1' }];
const handleSearch = jest.fn().mockResolvedValue({
data: {
count: 2,
results: [
{ id: 1, username: 'foo', url: 'item/1' },
{ id: 2, username: 'bar', url: 'item/2' },
],
},
});
const history = createMemoryHistory({
initialEntries: [
'/organizations/1/access?resource.page=1&resource.order_by=-username',
],
});
const wrapper = await mountWithContexts(
<SelectResourceStep
columns={columns}
displayKey="username"
onRowClick={() => {}}
onSearch={handleSearch}
selectedResourceRows={selectedResourceRows}
sortedColumnKey="username"
/>,
{
context: { router: { history, route: { location: history.location } } },
}
).find('SelectResourceStep');
await wrapper.instance().readResourceList();
expect(handleSearch).toHaveBeenCalledWith({
order_by: '-username',
page: 1,
page_size: 5,
});
expect(wrapper.state('resources')).toEqual([
{ id: 1, username: 'foo', url: 'item/1' },
{ id: 2, username: 'bar', url: 'item/2' },
]);
});
test('clicking on row fires callback with correct params', async () => {
const handleRowClick = jest.fn();
const data = {
count: 2,
results: [
{ id: 1, username: 'foo', url: 'item/1' },
{ id: 2, username: 'bar', url: 'item/2' },
],
};
const wrapper = mountWithContexts(
<SelectResourceStep
columns={columns}
displayKey="username"
onRowClick={handleRowClick}
onSearch={() => ({ data })}
selectedResourceRows={[]}
sortedColumnKey="username"
/>
);
await sleep(0);
wrapper.update();
const checkboxListItemWrapper = wrapper.find('CheckboxListItem');
expect(checkboxListItemWrapper.length).toBe(2);
checkboxListItemWrapper
.first()
.find('input[type="checkbox"]')
.simulate('change', { target: { checked: true } });
expect(handleRowClick).toHaveBeenCalledWith(data.results[0]);
});
});

View File

@ -0,0 +1,78 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import CheckboxCard from './CheckboxCard';
import SelectedList from '../SelectedList';
class RolesStep extends React.Component {
render() {
const {
onRolesClick,
roles,
selectedListKey,
selectedListLabel,
selectedResourceRows,
selectedRoleRows,
i18n,
} = this.props;
return (
<Fragment>
<div>
{selectedResourceRows.length > 0 && (
<SelectedList
displayKey={selectedListKey}
isReadOnly
label={selectedListLabel || i18n._(t`Selected`)}
selected={selectedResourceRows}
showOverflowAfter={5}
/>
)}
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '20px 20px',
marginTop: '20px',
}}
>
{Object.keys(roles).map(role => (
<CheckboxCard
description={roles[role].description}
itemId={roles[role].id}
isSelected={selectedRoleRows.some(
item => item.id === roles[role].id
)}
key={roles[role].id}
name={roles[role].name}
onSelect={() => onRolesClick(roles[role])}
/>
))}
</div>
</Fragment>
);
}
}
RolesStep.propTypes = {
onRolesClick: PropTypes.func,
roles: PropTypes.objectOf(PropTypes.object).isRequired,
selectedListKey: PropTypes.string,
selectedListLabel: PropTypes.string,
selectedResourceRows: PropTypes.arrayOf(PropTypes.object),
selectedRoleRows: PropTypes.arrayOf(PropTypes.object),
};
RolesStep.defaultProps = {
onRolesClick: () => {},
selectedListKey: 'name',
selectedListLabel: null,
selectedResourceRows: [],
selectedRoleRows: [],
};
export default withI18n()(RolesStep);

View File

@ -0,0 +1,64 @@
import React from 'react';
import { shallow } from 'enzyme';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import SelectRoleStep from './SelectRoleStep';
describe('<SelectRoleStep />', () => {
let wrapper;
const roles = {
project_admin_role: {
id: 1,
name: 'Project Admin',
description: 'Can manage all projects of the organization',
},
execute_role: {
id: 2,
name: 'Execute',
description: 'May run any executable resources in the organization',
},
};
const selectedRoles = [
{
id: 1,
name: 'Project Admin',
description: 'Can manage all projects of the organization',
},
];
const selectedResourceRows = [
{
id: 1,
name: 'foo',
},
];
test('initially renders without crashing', () => {
wrapper = shallow(
<SelectRoleStep
roles={roles}
selectedResourceRows={selectedResourceRows}
selectedRoleRows={selectedRoles}
/>
);
expect(wrapper.length).toBe(1);
wrapper.unmount();
});
test('clicking role fires onRolesClick callback', () => {
const onRolesClick = jest.fn();
wrapper = mountWithContexts(
<SelectRoleStep
onRolesClick={onRolesClick}
roles={roles}
selectedResourceRows={selectedResourceRows}
selectedRoleRows={selectedRoles}
/>
);
const CheckboxCards = wrapper.find('CheckboxCard');
expect(CheckboxCards.length).toBe(2);
CheckboxCards.first().prop('onSelect')();
expect(onRolesClick).toBeCalledWith({
id: 1,
name: 'Project Admin',
description: 'Can manage all projects of the organization',
});
wrapper.unmount();
});
});

View File

@ -0,0 +1,64 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
const SelectableItem = styled.div`
min-width: 200px;
border: 1px solid var(--pf-global--BorderColor--200);
border-radius: var(--pf-global--BorderRadius--sm);
border: 1px solid;
border-color: ${props =>
props.isSelected
? 'var(--pf-global--active-color--100)'
: 'var(--pf-global--BorderColor--200)'};
margin-right: 20px;
font-weight: bold;
display: flex;
cursor: pointer;
`;
const Indicator = styled.div`
display: flex;
flex: 0 0 5px;
background-color: ${props =>
props.isSelected ? 'var(--pf-global--active-color--100)' : null};
`;
const Label = styled.div`
display: flex;
flex: 1;
align-items: center;
padding: 20px;
`;
class SelectableCard extends Component {
render() {
const { label, onClick, isSelected } = this.props;
return (
<SelectableItem
onClick={onClick}
onKeyPress={onClick}
role="button"
tabIndex="0"
isSelected={isSelected}
>
<Indicator isSelected={isSelected} />
<Label>{label}</Label>
</SelectableItem>
);
}
}
SelectableCard.propTypes = {
label: PropTypes.string,
onClick: PropTypes.func.isRequired,
isSelected: PropTypes.bool,
};
SelectableCard.defaultProps = {
label: '',
isSelected: false,
};
export default SelectableCard;

View File

@ -0,0 +1,18 @@
import React from 'react';
import { shallow } from 'enzyme';
import SelectableCard from './SelectableCard';
describe('<SelectableCard />', () => {
let wrapper;
const onClick = jest.fn();
test('initially renders without crashing when not selected', () => {
wrapper = shallow(<SelectableCard onClick={onClick} />);
expect(wrapper.length).toBe(1);
wrapper.unmount();
});
test('initially renders without crashing when selected', () => {
wrapper = shallow(<SelectableCard isSelected onClick={onClick} />);
expect(wrapper.length).toBe(1);
wrapper.unmount();
});
});

View File

@ -0,0 +1,5 @@
export { default as AddResourceRole } from './AddResourceRole';
export { default as CheckboxCard } from './CheckboxCard';
export { default as SelectableCard } from './SelectableCard';
export { default as SelectResourceStep } from './SelectResourceStep';
export { default as SelectRoleStep } from './SelectRoleStep';

View File

@ -0,0 +1,41 @@
import React from 'react';
import { Modal } from '@patternfly/react-core';
import {
ExclamationTriangleIcon,
ExclamationCircleIcon,
InfoCircleIcon,
CheckCircleIcon,
} from '@patternfly/react-icons';
const getIcon = variant => {
let icon;
if (variant === 'warning') {
icon = <ExclamationTriangleIcon className="at-c-alertModal__icon" />;
} else if (variant === 'danger') {
icon = <ExclamationCircleIcon className="at-c-alertModal__icon" />;
}
if (variant === 'info') {
icon = <InfoCircleIcon className="at-c-alertModal__icon" />;
}
if (variant === 'success') {
icon = <CheckCircleIcon className="at-c-alertModal__icon" />;
}
return icon;
};
export default ({ variant, children, ...props }) => {
const { isOpen = null } = props;
props.isOpen = Boolean(isOpen);
return (
<Modal
className={`awx-c-modal${variant &&
` at-c-alertModal at-c-alertModal--${variant}`}`}
{...props}
>
{children}
{getIcon(variant)}
</Modal>
);
};

View File

@ -0,0 +1,11 @@
import React from 'react';
import { mount } from 'enzyme';
import AlertModal from './AlertModal';
describe('AlertModal', () => {
test('renders the expected content', () => {
const wrapper = mount(<AlertModal title="Danger!" />);
expect(wrapper).toHaveLength(1);
});
});

View File

@ -0,0 +1 @@
export { default } from './AlertModal';

View File

@ -0,0 +1,52 @@
import React from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { FormSelect, FormSelectOption } from '@patternfly/react-core';
class AnsibleSelect extends React.Component {
constructor(props) {
super(props);
this.onSelectChange = this.onSelectChange.bind(this);
}
onSelectChange(val, event) {
const { onChange, name } = this.props;
event.target.name = name;
onChange(event, val);
}
render() {
const { value, data, i18n } = this.props;
return (
<FormSelect
value={value}
onChange={this.onSelectChange}
aria-label={i18n._(t`Select Input`)}
>
{data.map(datum => (
<FormSelectOption
key={datum.key}
value={datum.value}
label={datum.label}
isDisabled={datum.isDisabled}
/>
))}
</FormSelect>
);
}
}
AnsibleSelect.defaultProps = {
data: [],
};
AnsibleSelect.propTypes = {
data: PropTypes.arrayOf(PropTypes.object),
onChange: PropTypes.func.isRequired,
value: PropTypes.string.isRequired,
};
export { AnsibleSelect as _AnsibleSelect };
export default withI18n()(AnsibleSelect);

View File

@ -0,0 +1,58 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import AnsibleSelect, { _AnsibleSelect } from './AnsibleSelect';
const mockData = [
{
key: 'baz',
label: 'Baz',
value: '/venv/baz/',
},
{
key: 'default',
label: 'Default',
value: '/venv/ansible/',
},
];
describe('<AnsibleSelect />', () => {
test('initially renders succesfully', async () => {
mountWithContexts(
<AnsibleSelect
value="foo"
name="bar"
onChange={() => {}}
data={mockData}
/>
);
});
test('calls "onSelectChange" on dropdown select change', () => {
const spy = jest.spyOn(_AnsibleSelect.prototype, 'onSelectChange');
const wrapper = mountWithContexts(
<AnsibleSelect
value="foo"
name="bar"
onChange={() => {}}
data={mockData}
/>
);
expect(spy).not.toHaveBeenCalled();
wrapper.find('select').simulate('change');
expect(spy).toHaveBeenCalled();
});
test('Returns correct select options', () => {
const wrapper = mountWithContexts(
<AnsibleSelect
value="foo"
name="bar"
onChange={() => {}}
data={mockData}
/>
);
expect(wrapper.find('FormSelect')).toHaveLength(1);
expect(wrapper.find('FormSelectOption')).toHaveLength(2);
});
});

View File

@ -0,0 +1 @@
export { default } from './AnsibleSelect';

View File

@ -0,0 +1,20 @@
import React, { Fragment } from 'react';
import { BackgroundImage, BackgroundImageSrc } from '@patternfly/react-core';
import bgFilter from '@patternfly/patternfly/assets/images/background-filter.svg';
const backgroundImageConfig = {
[BackgroundImageSrc.xs]: '/assets/images/pfbg_576.jpg',
[BackgroundImageSrc.xs2x]: '/assets/images/pfbg_576@2x.jpg',
[BackgroundImageSrc.sm]: '/assets/images/pfbg_768.jpg',
[BackgroundImageSrc.sm2x]: '/assets/images/pfbg_768@2x.jpg',
[BackgroundImageSrc.lg]: '/assets/images/pfbg_2000.jpg',
[BackgroundImageSrc.filter]: `${bgFilter}#image_overlay`,
};
export default ({ children }) => (
<Fragment>
<BackgroundImage src={backgroundImageConfig} />
{children}
</Fragment>
);

View File

@ -0,0 +1,17 @@
import React from 'react';
import { mount } from 'enzyme';
import Background from './Background';
describe('Background', () => {
test('renders the expected content', () => {
const wrapper = mount(
<Background>
<div id="test" />
</Background>
);
expect(wrapper).toHaveLength(1);
expect(wrapper.find('BackgroundImage')).toHaveLength(1);
expect(wrapper.find('#test')).toHaveLength(1);
});
});

View File

@ -0,0 +1 @@
export { default } from './Background';

View File

@ -0,0 +1,374 @@
import React, { Component } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
const ST0 = styled.g`
display: none;
`;
const ST1 = styled.path`
display: inline;
fill: #ed1c24;
`;
const ST2 = styled.path`
fill: #42210b;
`;
const ST3 = styled.path`
fill: #ffffff;
`;
const ST4 = styled.path`
fill: #c69c6d;
stroke: #8c6239;
stroke-width: 5;
stroke-miterlimit: 10;
`;
const ST5 = styled.path`
fill: #ffffff;
stroke: #42210b;
stroke-width: 3;
stroke-miterlimit: 10;
`;
const ST6 = styled.ellipse`
fill: #ed1c24;
stroke: #8c6239;
stroke-width: 5;
stroke-miterlimit: 10;
`;
const ST7 = styled.path`
fill: #a67c52;
`;
const ST8 = styled.path`
fill: #ed1c24;
`;
const ST9 = styled.ellipse`
fill: #42210b;
`;
class BrandLogo extends Component {
render() {
const { i18n } = this.props;
return (
<svg
version="1.1"
id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 500 500"
xmlSpace="preserve"
>
<title>{i18n._(t`AWX Logo`)}</title>
<ST0>
<ST1
d="M319.8,169.3c1.5-14.2,13.7-27.2,29.9-31.9c-13.1,1.5-27.3-1.7-36-10c-8.7-8.3-10-21.9-1.4-30.1
c-12,6.7-28.1,8.1-41.4,3.4c-13.3-4.6-23.5-15.1-26.2-26.9c-2-8.8,0-17.9,2-26.7c-6.2,9.4-17.6,17.3-30.5,17.3
c-12.9,0.1-25.7-10.2-22.9-20.7c-5.5,7.8-11.4,15.9-21,20.2c-9.5,4.3-23.7,2.7-28.2-5.5c-1.6,10.8-7.5,22-19.1,27
c-9,3.9-21.5,2.2-28-3.8c5.7,11.4,4.3,25.3-4.1,35.6c-9.9,12.2-29.1,18.6-46.4,15.6c14.7,7.2,28.5,17.7,32.1,31.5
c3.7,13.8-7.1,30.7-24.1,31.7c13.6,3.1,28,7.4,35.6,17.2c7.6,9.8,2.9,26.4-11.1,28c12.8-2.6,27.4,3.9,31.9,14.2
c4.1,9.5-0.9,20.9-10.9,26.5c18.6-8.9,41-17.1,59.6-8.8c13.9,6.2,20.8,21.6,15.1,33.8c10.4-10.6,23-21.3,39.2-23.5
c12.8-1.8,27.5,4.6,31.9,14.1c-0.3-12.7,6.1-25.5,17.5-34c13.8-10.3,34.4-14,52-9.2c-11.1-7.8-14.9-22-8.9-33
c6-11,21.3-18,35.7-16.2C327.5,198.1,318.3,183.5,319.8,169.3z"
/>
</ST0>
<g>
<g>
<g>
<ST2
d="M179.7,297.3c-10.1,3.2-20.3,6-30.6,8.4c-10.7,2.5-21.7,5-32.8,5.1C96,311.1,79.9,297.2,60,296.1
c-5.8-0.3-5.8,8.7,0,9c9.9,0.5,18.9,5.1,27.9,8.8c9.8,4,19.6,6.3,30.2,5.9c21.5-0.8,43.5-7.4,64-13.8
C187.6,304.3,185.2,295.6,179.7,297.3L179.7,297.3z"
/>
</g>
</g>
<g>
<g>
<ST2
d="M322.2,194.8c17.9-8,36-18.5,44.3-37.2c4.2-9.3,6-19.2,7.2-29.3c1.5-11.7,2.5-23.4,3.7-35.2
c0.6-5.8-8.4-5.7-9,0c-1.1,10.3-2.1,20.6-3.3,30.9c-1.1,9.7-2.5,19.7-6.4,28.7c-7.5,17.5-24.6,26.8-41.2,34.2
C312.4,189.4,316.9,197.2,322.2,194.8L322.2,194.8z"
/>
</g>
</g>
<g>
<ST9
transform="matrix(0.5541 -0.8324 0.8324 0.5541 -219.4917 376.0051)"
cx="241.2"
cy="392.9"
rx="65.5"
ry="33.7"
/>
</g>
<g>
<g>
<ST3
d="M224.1,442.5c22-11.5,38.7-31,47.1-54.3c2-5.5-6.7-7.8-8.7-2.4c-7.6,21.1-23.1,38.5-43,48.9
C214.4,437.4,218.9,445.1,224.1,442.5L224.1,442.5z"
/>
</g>
</g>
<g>
<ST9
transform="matrix(0.9684 -0.2494 0.2494 0.9684 -66.4734 109.0276)"
cx="397"
cy="316.8"
rx="63.9"
ry="32.9"
/>
</g>
<g>
<g>
<ST3
d="M363.8,341.5c28.3,7,58.7-0.8,80.2-20.5c4.3-3.9-2.1-10.3-6.4-6.4c-19.1,17.5-46.4,24.4-71.5,18.2
C360.5,331.5,358.1,340.1,363.8,341.5L363.8,341.5z"
/>
</g>
</g>
<ST4
d="M156.9,96c-25.4,4.5-32.9,20.2-45,46.9c-20.2,44.4,2,90.3,5.6,97.5c18.4,36.5,42.3,36.8,60,80.6
c8.6,21.2,4.6,25.2,13.1,37.5c20.4,29.2,63.7,36.1,91.9,33.8c40.3-3.3,91.5-28.8,108.8-82.5c17.1-53.2-6-112.1-41.2-131.2
c-25.3-13.7-44.9-0.5-71.2-20.6c-21.6-16.5-18.4-33.1-37.5-48.8C227.9,98.1,203.7,87.7,156.9,96z"
/>
<ST9
transform="matrix(0.6622 -0.7494 0.7494 0.6622 65.2068 309.6339)"
cx="376"
cy="82.5"
rx="21"
ry="15.5"
/>
<g>
<g>
<ST3
d="M379.8,75.3c0.8,0.2-0.6-0.4-0.1-0.1c0.2,0.1,0.3,0.2,0.5,0.3c0.4,0.2-0.6-0.7-0.1-0.1
c0.1,0.1,0.7,0.8,0.2,0.2c-0.4-0.5,0,0,0.1,0.1c0.4,0.7,0,0.2,0-0.2c0,0.1,0.1,0.4,0.2,0.5c0.3,0.9-0.1-1,0-0.1
c0.1,2.3,2,4.6,4.5,4.5c2.3-0.1,4.6-2,4.5-4.5c-0.3-4.4-3-8.1-7.3-9.4c-2.2-0.7-5,0.8-5.5,3.1C376.1,72.2,377.4,74.5,379.8,75.3
L379.8,75.3z"
/>
</g>
</g>
<ST9
transform="matrix(0.9999 -1.433736e-02 1.433736e-02 0.9999 -4.303 0.8051)"
cx="54"
cy="300.5"
rx="21"
ry="15.5"
/>
<g>
<g>
<ST3
d="M52.2,297.5c1.1-0.3,1.4-0.4,2.5,0c0.8,0.3,1.3,0.7,2,1.7c1.5,1.9,4.8,1.6,6.4,0c1.9-1.9,1.5-4.4,0-6.4
c-3.1-3.9-8.6-5.4-13.3-4C44.3,290.5,46.7,299.2,52.2,297.5L52.2,297.5z"
/>
</g>
</g>
<g>
<g>
<ST2
d="M149.3,108.8c4.9-10.8-1.3-24.2-12.9-26.9c-1.9-0.4-2.7,2.4-0.8,2.9c9.6,2.3,15.3,13.5,11.2,22.5
C145.9,109,148.5,110.5,149.3,108.8L149.3,108.8z"
/>
</g>
</g>
<g>
<g>
<ST2
d="M141.2,112.3c2.4-9.4-5.4-19.3-15.2-19c-1.9,0.1-1.9,3.1,0,3c7.8-0.2,14.2,7.6,12.3,15.2
C137.8,113.4,140.7,114.2,141.2,112.3L141.2,112.3z"
/>
</g>
</g>
<g>
<g>
<ST2
d="M132.6,118c-1.1-8.3-10.9-13.4-18.2-9.1c-1.7,1-0.2,3.6,1.5,2.6c5.2-3,12.9,0.4,13.7,6.5
C129.8,119.9,132.8,119.9,132.6,118L132.6,118z"
/>
</g>
</g>
<ST5 d="M215.5,166.5l34-73c0,0,35,0,46,21c7.5,14.3,8,39,8,39L215.5,166.5z" />
<ST5 d="M208.2,170.5l-79.5-12.7c0,0-19.6,29-8.4,49.9c7.6,14.2,27.8,28.5,27.8,28.5L208.2,170.5z" />
<ST2 d="M210.5,164.5l33-74c0,0-2.5-5.5-8-7s-12,0-12,0L210.5,164.5z" />
<ST2 d="M207.4,165.3l-73.1-35c0,0-5.6,2.4-7.2,7.8c-1.6,5.5-0.3,12-0.3,12L207.4,165.3z" />
<path d="M215.5,166.5L234,127c0,0,17-6,25.5,7.5c8.6,13.6-3.5,25.5-3.5,25.5L215.5,166.5z" />
<path d="M206.7,170.9l-29.6,32c0,0-18,0.5-22-14.9c-4-15.6,11.1-23.2,11.1-23.2L206.7,170.9z" />
<g>
<g>
<ST3
d="M243.4,139.1c-0.6,0.2-0.7,0.3-0.4,0.2c0.3-0.1,0.2-0.1-0.5,0.1c0.7,0-0.3,0-0.4-0.1c0.1,0,0.3,0.1,0.4,0.1
c0.3,0.1,0.2,0-0.4-0.2c0,0,0.6,0.3,0.6,0.3c0.5,0.2-0.9-0.6-0.1-0.1c0.6,0.4-0.3-0.5-0.1-0.1c0.3,0.5-0.3-1-0.1-0.2
c0.2,0.8,0-1,0-0.1c0,2.4,2.1,4.6,4.5,4.5c2.5-0.1,4.5-2,4.5-4.5c0-3-1.6-5.7-4.1-7.3c-2.6-1.7-5.6-1.6-8.4-0.4
c-2.2,0.9-2.8,4.3-1.6,6.2C238.7,139.7,241,140.1,243.4,139.1L243.4,139.1z"
/>
</g>
</g>
<g>
<g>
<ST3
d="M173.5,176.4c-0.5-0.3-0.1,0,0.2,0.1c-0.7-0.6,0.3,0.5,0.1,0c-0.3-0.5,0.4,0.8,0.1,0.2
c-0.4-0.8,0.2,0.2,0,0.1c0,0,0-0.6,0-0.6c-0.1,0.1-0.1,1,0,0.3c-0.1,0.2-0.1,0.3-0.2,0.5c0.2-0.3,0.2-0.4,0-0.1
c-0.2,0.2-0.2,0.3-0.1,0.1c0.2-0.2,0.1-0.2-0.3,0.2c1.9-1.4,3-4,1.6-6.2c-1.2-1.9-4.1-3.1-6.2-1.6c-2.4,1.7-4,4.3-3.9,7.4
c0.1,3,1.6,5.7,4.1,7.3c2,1.2,5,0.5,6.2-1.6C176.3,180.4,175.7,177.7,173.5,176.4L173.5,176.4z"
/>
</g>
</g>
<ST6
transform="matrix(0.862 -0.5069 0.5069 0.862 -88.3186 186.5516)"
cx="298.5"
cy="255.5"
rx="79.5"
ry="68.5"
/>
<g>
<g>
<ST7
d="M173.6,109.8c-2.1,2-3.9,4.6-3.6,7.6c0.3,3.5,2.8,6.6,6.6,6.7c6,0.2,11.5-7.7,8.2-13c-1-1.7-3.1-3.1-5.2-3
c-1.7,0.1-3.1,0.8-4.4,1.9c-2,1.8-2.8,5.2-1.9,7.7c2.4,6.6,11.8,5.9,13.8-0.7c0.7-2.5-0.9-5.6-3.5-6.2c-2.7-0.6-5.4,0.8-6.2,3.5
c0.6-2.1,3.1-2.6,4.6-1c0.8,0.9,1,1.8,0.8,2.8c0.2-0.5,0.1-0.4-0.1,0.3c-0.4,0.7-1,1.2-1.8,1.4c-0.9,0-1.8,0-2.7,0
c-1.8-0.6-2.5-1.6-2.3-3.1c-0.1-0.4-0.1-0.7,0.1-1c0.2-0.3,0.1-0.3-0.1,0.1c0.1-0.1,0.2-0.2,0.3-0.4c-0.2,0.3-0.5,0.5-0.7,0.8
c-0.1,0.1-0.2,0.2-0.3,0.3c-0.3,0.2-0.2,0.2,0.1-0.1c1.3,0.2,2.6,0.4,3.9,0.6c0.2,0.4,0.5,0.9,0.7,1.3c0.2,0.6-0.2,0.9-0.2,1.4
c0,0.4,0.4-0.5-0.1,0.1c0.3-0.4,0.6-0.7,1-1c1.9-1.8,2-5.3,0-7.1C178.7,107.9,175.6,107.8,173.6,109.8L173.6,109.8z"
/>
</g>
</g>
<g>
<g>
<ST7
d="M151.2,248.6c-5.7,7,1.7,16.9,10,13.3c3.4-1.5,6.3-5,6.3-8.9c0-4.2-2.7-7.6-7-7.8c-3.1-0.1-5.8,3.3-4.8,6.3
c1.2,3.4,3.7,6.1,7.3,7c2.6,0.6,5.4-0.8,6.2-3.5c0.7-2.5-0.9-5.5-3.5-6.2c-1.7-0.4,0,0.1-0.2,0.1c-0.4,0-0.4-0.8-0.1-0.1
c-1.6,2.1-3.2,4.2-4.8,6.3c-2.4-0.1-2.8-1.1-3-2.6c0.1,0.7-0.1,0.2,0.1-0.1c0.7-0.9-0.5,0.5,0,0c-0.5,0.5-0.3,0.1-0.2,0.2
c0.1,0,0.6,0,0.7,0c0.4,0.1,0.5,0.4,0.8,0.6c0.2,0.3,0.2,0.2-0.1-0.2c0.1,0.1,0.1,0.3,0.2,0.4c0,1,0.1,1.1-0.7,2.1
c1.7-2.1,2-5,0-7.1C156.5,246.8,152.9,246.5,151.2,248.6L151.2,248.6z"
/>
</g>
</g>
<g>
<g>
<ST7
d="M204.1,205.7c0.8,4.8,5.3,8.6,10.1,8.6c5.1,0,9.5-3.9,10.3-8.9c0.7-4.4-0.2-12.1-5.3-13.6
c-2.7-0.8-5.2,0.5-7,2.4c-1.1,1.2-1.5,1.7-3.1,1.2c0.7,2.8,1.5,5.6,2.2,8.4c0.2-0.2-0.5,0.2-0.5,0.2c6.3,1.4,8.9-8.2,2.7-9.6
c-3.5-0.8-6.6,0-9.3,2.4c-3,2.6-1.1,7.2,2.2,8.4c2.6,0.9,5.5,0.8,8-0.2c1.3-0.5,2.4-1.2,3.4-2.1c0.4-0.3,0.7-0.6,1-1
c0.2-0.3,0.4-0.5,0.6-0.7c0.4-0.4,0.3-0.4-0.5,0.3c-0.9,0-1.8,0-2.7,0c0.2,0.1,0.3,0.1,0.5,0.2c-0.7-0.4-1.5-0.9-2.2-1.3
c0.1,0.2,0.3,0.3,0.4,0.5c-0.4-0.7-0.9-1.5-1.3-2.2c0.4,1.2,0.8,2.5,1,3.7c0,0.4,0,0.8,0,1.2c0,0.5-0.5,0.9,0,0.4
c-0.8,0.6-0.9,0.2-1.1-0.9c-0.4-2.7-3.8-4.1-6.2-3.5C204.7,200.3,203.7,203,204.1,205.7L204.1,205.7z"
/>
</g>
</g>
<g>
<g>
<ST7
d="M265.9,179.6c0.2,0.4,0.5,0.9,0.7,1.3c0.6,1.1,1.8,2,3,2.3c1.2,0.3,2.8,0.2,3.9-0.5c1.1-0.7,2-1.7,2.3-3
c0.3-1.4,0.1-2.6-0.5-3.9c-0.2-0.4-0.5-0.9-0.7-1.3c-0.6-1.1-1.8-2-3-2.3c-1.2-0.3-2.8-0.2-3.9,0.5c-1.1,0.7-2,1.7-2.3,3
C265.1,177.1,265.3,178.3,265.9,179.6L265.9,179.6z"
/>
</g>
</g>
<g>
<g>
<ST7
d="M200.4,295.8c-6.1,1.6-8.1,8.6-5,13.7c2.8,4.7,9.1,7.2,14.3,5.4c4.9-1.7,7.8-7.1,6.3-12.2
c-0.8-2.7-2.7-4.8-5.3-5.8c-1.4-0.5-2.8-0.7-4.2-0.8c-0.1,0-0.9-0.1-0.9-0.1c0.2-0.4,1.2,2.5,0.9,0.7c0,0.9,0,1.8,0,2.7
c-0.1,0.1-0.1,0.1-0.2,0.2c3.1-5.6-5.5-10.7-8.6-5c-1.7,3-1.1,6.6,1.4,9c1.3,1.2,2.8,2,4.5,2.3c0.8,0.1,1.6,0.2,2.4,0.3
c0.4,0,0.7,0,1.1,0.1c0.2,0.1,0.1,0.1-0.2-0.1c0,0.1-0.6-0.5-0.6-0.5c-0.1-0.1-0.1-0.2,0-0.3c0.1-0.3,0.1-0.1-0.1,0.5
c-0.3-0.1,0.7-0.2-0.3-0.3c-0.9-0.1-1.1-0.6-1.8-0.9c0,0-0.2-0.3-0.3-0.3c0.3,0-0.8,1.2-0.8,1.2
C209.3,303.8,206.6,294.2,200.4,295.8L200.4,295.8z"
/>
</g>
</g>
<g>
<g>
<ST7
d="M244.8,355.3c-4-6.2-11.2-2.3-12,3.9c-0.8,5.9,1.8,12,6.5,15.6c4.5,3.5,11.5,4.9,16.7,2.1
c6.4-3.3,5.4-9.8,4.9-15.9c-0.5-6.3-1.9-12-9.5-12.1c-5.1-0.1-13.1,0.2-14.5,6.4c-1.2,5.4,2.5,12.8,8.2,13.8
c6.2,1.1,11.2-5.5,7.8-11c-2.2-3.5-8.1-3.1-9.1,1.2c-1.1,4.4,0.5,8,4.1,10.6c5.2,3.8,10.2-4.8,5-8.6c0.2,0.2,0.4,0.5,0.5,0.7
c-3,0.4-6.1,0.8-9.1,1.2c-0.4-0.7,3.4-3.1,2.9-4.8c-0.8-2.6-1.7,1.4-1.9,1.1c0,0.1,5.2-0.1,5.6-0.4c0.7,0.1,0.8-0.1,0.2-0.6
c-0.4-0.7-0.5-0.8-0.4-0.3c-0.2,0.3,0.2,1.9,0.2,2.3c0.2,2,0.3,4,0.5,5.9c0.1,1.6,0.4,1.7-1.1,2c-1.3,0.2-2.9-0.3-4-0.9
c-1.4-0.8-2.5-2-3.1-3.5c-0.3-0.7-0.4-1.3-0.5-2c0-0.3-0.1-0.7,0-1c0.2-1.9-1.1-1.5-3.8,1.2c-1-0.8-2-1.5-3-2.3
c0.1,0.2,0.2,0.4,0.4,0.6C239.6,365.7,248.3,360.7,244.8,355.3L244.8,355.3z"
/>
</g>
</g>
<g>
<g>
<ST7
d="M336.5,337.4c-2.4-1.5-5.1-2.5-7.9-1.8c-2.7,0.7-4.9,3.2-5.3,6c-0.9,6.4,6.3,8.3,11.2,8.4
c4.8,0.1,10.6-2.4,10.9-7.9c0.2-5.6-5.5-9.6-10.6-6.9c-5.7,3-0.7,11.6,5,8.6c-0.1,0.1-0.2,0.1-0.3,0.2c-0.9,0-1.8,0-2.7,0
c-2.1-0.4-1.4-4.8-0.3-4.3c0,0-1.3,0.3-1.3,0.3c-0.6,0-1.2,0-1.8-0.1c-0.5-0.1-1-0.2-1.5-0.4c-1.2-0.5-1-0.2,0.6,0.7
c0.2,0.8,0.5,1.7,0.7,2.5c-3.4,1.1-4.4,1.9-2.8,2.7c0.4,0.2,0.7,0.4,1.1,0.7C336.9,349.6,341.9,340.9,336.5,337.4L336.5,337.4z"
/>
</g>
</g>
<ST3
d="M224.3,256.5L252,273v-40l32,20v-38l28,17l4-28l23,12l-3-24c0,0-14-8-35.5-6.4c-11.6,0.9-24.3,6.8-33.5,11.4
c-14,7-23.7,18.9-31.2,29.1C227,238,224.3,256.5,224.3,256.5z"
/>
<ST3
d="M372.9,248.9l-28.8-14.5l2.9,39.9l-33.3-17.7l2.7,37.9l-29.1-15l-2,28.2l-23.8-10.3l4.7,23.7
c0,0,14.5,7,35.9,3.8c11.5-1.7,23.7-8.5,32.6-13.8c13.5-8,22.3-20.5,29-31.2C371.5,267.5,372.9,248.9,372.9,248.9z"
/>
</g>
<g>
<g>
<ST8
d="M235.2,121.6c8.5-3.1,23.2-0.1,27.8,8.4c2.3,4.4,4.5,9.9,4.5,14.9c0.1,5.5-2.7,10.5-5.3,15.3
c-1.5,2.8,2.8,5.4,4.3,2.5c3.1-5.8,6.3-11.9,6-18.7c-0.3-6-2.8-12.8-5.9-17.9c-6-9.5-22.6-13.1-32.7-9.4
C230.9,117.8,232.2,122.7,235.2,121.6L235.2,121.6z"
/>
</g>
</g>
<g>
<g>
<ST8
d="M241.1,110.5c11.6-2.3,25.6,2.3,32.2,12.4c6.6,10.2,6.1,22.8,3.1,34.2c-1.3,5,6.4,7.1,7.7,2.1
c3.8-14.3,3.8-30.3-5.5-42.6c-8.9-11.7-25.5-16.6-39.6-13.8C233.9,103.8,236.1,111.5,241.1,110.5L241.1,110.5z"
/>
</g>
</g>
<g>
<g>
<ST8
d="M245.4,97.5c7.8-1.8,15.5,0,22.9,2.8c7.2,2.7,15,6.1,20.3,11.8c10.7,11.7,9.5,29.3,8.7,44
c-0.3,6.4,9.7,6.4,10,0c1-17.9,1.2-38.5-12.7-52.1c-6.4-6.3-15.3-10.2-23.6-13.3c-9.1-3.4-18.6-4.9-28.2-2.8
C236.5,89.2,239.1,98.9,245.4,97.5L245.4,97.5z"
/>
</g>
</g>
<g>
<g>
<ST8
d="M155.8,158.5c-13.1,4.8-14.2,21.6-10.1,33.1c4.3,12,15.2,20.6,28.2,20.5c3.2,0,3.2-5,0-5
c-9.9,0.1-18.6-5.9-22.6-14.9c-3.9-8.6-5.2-24.8,5.8-28.9C160.2,162.3,158.9,157.4,155.8,158.5L155.8,158.5z"
/>
</g>
</g>
<g>
<g>
<ST8
d="M164.1,216.5c-11.4-2.2-18.8-11.4-22.7-21.9c-3.6-9.6-7.7-25.3,1.2-33.1c3.9-3.4-1.8-9-5.7-5.7
c-11.3,9.9-7.9,28.5-3.3,40.9c4.8,13,14.1,24.7,28.3,27.5C167,225.2,169.1,217.5,164.1,216.5L164.1,216.5z"
/>
</g>
</g>
<g>
<g>
<ST8
d="M152,231.7c-27.3-13.3-38.1-46.5-23.3-73.2c3.1-5.6-5.5-10.7-8.6-5c-17.3,31.2-5.3,71.1,26.9,86.9
C152.7,243.1,157.8,234.5,152,231.7L152,231.7z"
/>
</g>
</g>
</svg>
);
}
}
export default withI18n()(BrandLogo);

View File

@ -0,0 +1,22 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import BrandLogo from './BrandLogo';
let logoWrapper;
let brandLogoElem;
let svgElem;
const findChildren = () => {
brandLogoElem = logoWrapper.find('BrandLogo');
svgElem = logoWrapper.find('svg');
};
describe('<BrandLogo />', () => {
test('initially renders without crashing', () => {
logoWrapper = mountWithContexts(<BrandLogo />);
findChildren();
expect(logoWrapper.length).toBe(1);
expect(brandLogoElem.length).toBe(1);
expect(svgElem.length).toBe(1);
});
});

View File

@ -0,0 +1 @@
export { default } from './BrandLogo';

View File

@ -0,0 +1,81 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import {
PageSection as PFPageSection,
PageSectionVariants,
Breadcrumb,
BreadcrumbItem,
BreadcrumbHeading as PFBreadcrumbHeading,
} from '@patternfly/react-core';
import { Link, Route, withRouter } from 'react-router-dom';
import styled from 'styled-components';
const PageSection = styled(PFPageSection)`
padding-top: 10px;
padding-bottom: 10px;
`;
const BreadcrumbHeading = styled(PFBreadcrumbHeading)`
--pf-c-breadcrumb__heading--FontSize: 20px;
line-height: 24px;
flex: 100%;
`;
const Breadcrumbs = ({ breadcrumbConfig }) => {
const { light } = PageSectionVariants;
return (
<PageSection variant={light}>
<Breadcrumb>
<Route
path="/:path"
render={props => (
<Crumb breadcrumbConfig={breadcrumbConfig} {...props} />
)}
/>
</Breadcrumb>
</PageSection>
);
};
const Crumb = ({ breadcrumbConfig, match }) => {
const crumb = breadcrumbConfig[match.url];
let crumbElement = (
<BreadcrumbItem key={match.url}>
<Link to={match.url}>{crumb}</Link>
</BreadcrumbItem>
);
if (match.isExact) {
crumbElement = (
<BreadcrumbHeading key="breadcrumb-heading">{crumb}</BreadcrumbHeading>
);
}
if (!crumb) {
crumbElement = null;
}
return (
<Fragment>
{crumbElement}
<Route
path={`${match.url}/:path`}
render={props => (
<Crumb breadcrumbConfig={breadcrumbConfig} {...props} />
)}
/>
</Fragment>
);
};
Breadcrumbs.propTypes = {
breadcrumbConfig: PropTypes.objectOf(PropTypes.string).isRequired,
};
Crumb.propTypes = {
breadcrumbConfig: PropTypes.objectOf(PropTypes.string).isRequired,
};
export default withRouter(Breadcrumbs);

View File

@ -0,0 +1,66 @@
import React from 'react';
import { mount } from 'enzyme';
import { MemoryRouter } from 'react-router-dom';
import Breadcrumbs from './Breadcrumbs';
describe('<Breadcrumb />', () => {
let breadcrumbWrapper;
let breadcrumb;
let breadcrumbItem;
let breadcrumbHeading;
const config = {
'/foo': 'Foo',
'/foo/1': 'One',
'/foo/1/bar': 'Bar',
'/foo/1/bar/fiz': 'Fiz',
};
const findChildren = () => {
breadcrumb = breadcrumbWrapper.find('Breadcrumb');
breadcrumbItem = breadcrumbWrapper.find('BreadcrumbItem');
breadcrumbHeading = breadcrumbWrapper.find('BreadcrumbHeading');
};
test('initially renders succesfully', () => {
breadcrumbWrapper = mount(
<MemoryRouter initialEntries={['/foo/1/bar']} initialIndex={0}>
<Breadcrumbs breadcrumbConfig={config} />
</MemoryRouter>
);
findChildren();
expect(breadcrumb).toHaveLength(1);
expect(breadcrumbItem).toHaveLength(2);
expect(breadcrumbHeading).toHaveLength(1);
expect(breadcrumbItem.first().text()).toBe('Foo');
expect(breadcrumbItem.last().text()).toBe('One');
expect(breadcrumbHeading.text()).toBe('Bar');
breadcrumbWrapper.unmount();
});
test('renders breadcrumb items defined in breadcrumbConfig', () => {
const routes = [
['/fo', 0],
['/foo', 0],
['/foo/1', 1],
['/foo/baz', 1],
['/foo/1/bar', 2],
['/foo/1/bar/fiz', 3],
];
routes.forEach(([location, crumbLength]) => {
breadcrumbWrapper = mount(
<MemoryRouter initialEntries={[location]}>
<Breadcrumbs breadcrumbConfig={config} />
</MemoryRouter>
);
expect(breadcrumbWrapper.find('BreadcrumbItem')).toHaveLength(
crumbLength
);
breadcrumbWrapper.unmount();
});
});
});

View File

@ -0,0 +1 @@
export { default } from './Breadcrumbs';

View File

@ -0,0 +1,28 @@
import React from 'react';
import styled from 'styled-components';
const Group = styled.div`
display: inline-flex;
& > .pf-c-button:not(:last-child) {
&,
&::after {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
& > .pf-c-button:not(:first-child) {
&,
&::after {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
`;
function ButtonGroup({ children }) {
return <Group>{children}</Group>;
}
export default ButtonGroup;

View File

@ -0,0 +1,44 @@
import React from 'react';
import { string } from 'prop-types';
import { Link as RRLink } from 'react-router-dom';
import { Button } from '@patternfly/react-core';
import { TimesIcon } from '@patternfly/react-icons';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
const Link = styled(RRLink)`
position: absolute;
top: 5px;
right: 4px;
color: var(--pf-c-button--m-plain--Color);
`;
function CardCloseButton({ linkTo, i18n, i18nHash, ...props }) {
if (linkTo) {
return (
<Link
className="pf-c-button"
aria-label={i18n._(t`Close`)}
title={i18n._(t`Close`)}
to={linkTo}
{...props}
>
<TimesIcon />
</Link>
);
}
return (
<Button variant="plain" aria-label={i18n._(t`Close`)} {...props}>
<TimesIcon />
</Button>
);
}
CardCloseButton.propTypes = {
linkTo: string,
};
CardCloseButton.defaultProps = {
linkTo: null,
};
export default withI18n()(CardCloseButton);

View File

@ -0,0 +1,23 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import CardCloseButton from './CardCloseButton';
describe('<CardCloseButton>', () => {
test('should render close button', () => {
const wrapper = mountWithContexts(<CardCloseButton />);
const button = wrapper.find('Button');
expect(button).toHaveLength(1);
expect(button.prop('variant')).toBe('plain');
expect(button.prop('aria-label')).toBe('Close');
expect(wrapper.find('Link')).toHaveLength(0);
});
test('should render close link when `linkTo` prop provided', () => {
const wrapper = mountWithContexts(<CardCloseButton linkTo="/foo" />);
expect(wrapper.find('Button')).toHaveLength(0);
const link = wrapper.find('Link');
expect(link).toHaveLength(1);
expect(link.prop('to')).toEqual('/foo');
expect(link.prop('aria-label')).toEqual('Close');
});
});

View File

@ -0,0 +1 @@
export { default } from './CardCloseButton';

View File

@ -0,0 +1,50 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
DataListItem,
DataListItemRow,
DataListItemCells,
DataListCheck,
DataListCell,
} from '@patternfly/react-core';
import VerticalSeparator from '../VerticalSeparator';
const CheckboxListItem = ({ itemId, name, isSelected, onSelect }) => (
<DataListItem key={itemId} aria-labelledby={`check-action-item-${itemId}`}>
<DataListItemRow>
<DataListCheck
id={`selected-${itemId}`}
checked={isSelected}
onChange={onSelect}
aria-labelledby={`check-action-item-${itemId}`}
value={itemId}
/>
<DataListItemCells
dataListCells={[
<DataListCell key="divider" className="pf-c-data-list__cell--divider">
<VerticalSeparator />
</DataListCell>,
<DataListCell key="name">
<label
id={`check-action-item-${itemId}`}
htmlFor={`selected-${itemId}`}
className="check-action-item"
>
<b>{name}</b>
</label>
</DataListCell>,
]}
/>
</DataListItemRow>
</DataListItem>
);
CheckboxListItem.propTypes = {
itemId: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
isSelected: PropTypes.bool.isRequired,
onSelect: PropTypes.func.isRequired,
};
export default CheckboxListItem;

View File

@ -0,0 +1,18 @@
import React from 'react';
import { mount } from 'enzyme';
import CheckboxListItem from './CheckboxListItem';
describe('CheckboxListItem', () => {
test('renders the expected content', () => {
const wrapper = mount(
<CheckboxListItem
itemId={1}
name="Buzz"
isSelected={false}
onSelect={() => {}}
/>
);
expect(wrapper).toHaveLength(1);
});
});

View File

@ -0,0 +1 @@
export { default } from './CheckboxListItem';

View File

@ -0,0 +1,19 @@
import { Chip } from '@patternfly/react-core';
import styled from 'styled-components';
export default styled(Chip)`
--pf-c-chip--m-read-only--PaddingTop: 3px;
--pf-c-chip--m-read-only--PaddingRight: 8px;
--pf-c-chip--m-read-only--PaddingBottom: 3px;
--pf-c-chip--m-read-only--PaddingLeft: 8px;
& > .pf-c-button {
padding: 3px 8px;
}
${props =>
props.isOverflowChip &&
`
padding: 0;
`}
`;

View File

@ -0,0 +1,11 @@
import React from 'react';
import { mount } from 'enzyme';
import Chip from './Chip';
describe('Chip', () => {
test('renders the expected content', () => {
const wrapper = mount(<Chip />);
expect(wrapper).toHaveLength(1);
});
});

View File

@ -0,0 +1,42 @@
import React, { useState } from 'react';
import { number } from 'prop-types';
import styled from 'styled-components';
import Chip from './Chip';
const ChipGroup = ({ children, className, showOverflowAfter, ...props }) => {
const [isExpanded, setIsExpanded] = useState(!showOverflowAfter);
const toggleIsOpen = () => setIsExpanded(!isExpanded);
const mappedChildren = React.Children.map(children, c =>
React.cloneElement(c, { component: 'li' })
);
const showOverflowToggle =
showOverflowAfter && children.length > showOverflowAfter;
const numToShow = isExpanded
? children.length
: Math.min(showOverflowAfter, children.length);
const expandedText = 'Show Less';
const collapsedText = `${children.length - showOverflowAfter} more`;
return (
<ul className={`pf-c-chip-group ${className}`} {...props}>
{mappedChildren.slice(0, numToShow)}
{showOverflowToggle && (
<Chip isOverflowChip onClick={toggleIsOpen} component="li">
{isExpanded ? expandedText : collapsedText}
</Chip>
)}
</ul>
);
};
ChipGroup.propTypes = {
showOverflowAfter: number,
};
ChipGroup.defaultProps = {
showOverflowAfter: null,
};
export default styled(ChipGroup)`
--pf-c-chip-group--c-chip--MarginRight: 10px;
--pf-c-chip-group--c-chip--MarginBottom: 10px;
`;

View File

@ -0,0 +1,74 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import { ChipGroup, Chip } from '.';
describe('<ChipGroup />', () => {
test('should render all chips', () => {
const wrapper = mountWithContexts(
<ChipGroup>
<Chip>One</Chip>
<Chip>Two</Chip>
<Chip>Three</Chip>
<Chip>Four</Chip>
<Chip>Five</Chip>
<Chip>Six</Chip>
</ChipGroup>
);
expect(wrapper.find(Chip)).toHaveLength(6);
expect(wrapper.find('li')).toHaveLength(6);
});
test('should render show more toggle', () => {
const wrapper = mountWithContexts(
<ChipGroup showOverflowAfter={5}>
<Chip>One</Chip>
<Chip>Two</Chip>
<Chip>Three</Chip>
<Chip>Four</Chip>
<Chip>Five</Chip>
<Chip>Six</Chip>
<Chip>Seven</Chip>
</ChipGroup>
);
expect(wrapper.find(Chip)).toHaveLength(6);
const toggle = wrapper.find(Chip).at(5);
expect(toggle.prop('isOverflowChip')).toBe(true);
expect(toggle.text()).toEqual('2 more');
});
test('should render show less toggle', () => {
const wrapper = mountWithContexts(
<ChipGroup showOverflowAfter={5}>
<Chip>One</Chip>
<Chip>Two</Chip>
<Chip>Three</Chip>
<Chip>Four</Chip>
<Chip>Five</Chip>
<Chip>Six</Chip>
<Chip>Seven</Chip>
</ChipGroup>
);
expect(wrapper.find(Chip)).toHaveLength(6);
const toggle = wrapper.find(Chip).at(5);
expect(toggle.prop('isOverflowChip')).toBe(true);
act(() => {
toggle.prop('onClick')();
});
wrapper.update();
expect(wrapper.find(Chip)).toHaveLength(8);
expect(
wrapper
.find(Chip)
.at(7)
.text()
).toEqual('Show Less');
act(() => {
const toggle2 = wrapper.find(Chip).at(7);
expect(toggle2.prop('isOverflowChip')).toBe(true);
toggle2.prop('onClick')();
});
wrapper.update();
expect(wrapper.find(Chip)).toHaveLength(6);
});
});

View File

@ -0,0 +1,2 @@
export { default as ChipGroup } from './ChipGroup';
export { default as Chip } from './Chip';

View File

@ -0,0 +1,68 @@
import React from 'react';
import { oneOf, bool, number, string, func } from 'prop-types';
import { Controlled as ReactCodeMirror } from 'react-codemirror2';
import styled from 'styled-components';
import 'codemirror/mode/javascript/javascript';
import 'codemirror/mode/yaml/yaml';
import 'codemirror/mode/jinja2/jinja2';
import 'codemirror/lib/codemirror.css';
const LINE_HEIGHT = 24;
const PADDING = 12;
const CodeMirror = styled(ReactCodeMirror)`
&& {
height: initial;
padding: 0;
}
& > .CodeMirror {
height: ${props => props.rows * LINE_HEIGHT + PADDING}px;
font-family: var(--pf-global--FontFamily--monospace);
}
${props =>
props.hasErrors &&
`
&& {
--pf-c-form-control--PaddingRight: var(--pf-c-form-control--invalid--PaddingRight);
--pf-c-form-control--BorderBottomColor: var(--pf-c-form-control--invalid--BorderBottomColor);
padding-right: 24px;
padding-bottom: var(--pf-c-form-control--invalid--PaddingBottom);
background: var(--pf-c-form-control--invalid--Background);
border-bottom-width: var(--pf-c-form-control--invalid--BorderBottomWidth);
}`}
`;
function CodeMirrorInput({ value, onChange, mode, readOnly, hasErrors, rows }) {
return (
<CodeMirror
className="pf-c-form-control"
value={value}
onBeforeChange={(editor, data, val) => onChange(val)}
mode={mode}
hasErrors={hasErrors}
options={{
smartIndent: false,
lineNumbers: true,
readOnly,
}}
rows={rows}
/>
);
}
CodeMirrorInput.propTypes = {
value: string.isRequired,
onChange: func.isRequired,
mode: oneOf(['javascript', 'yaml', 'jinja2']).isRequired,
readOnly: bool,
hasErrors: bool,
rows: number,
};
CodeMirrorInput.defaultProps = {
readOnly: false,
rows: 6,
hasErrors: false,
};
export default CodeMirrorInput;

View File

@ -0,0 +1,30 @@
import React from 'react';
import { mount } from 'enzyme';
import CodeMirrorInput from './CodeMirrorInput';
describe('CodeMirrorInput', () => {
beforeEach(() => {
document.body.createTextRange = jest.fn();
});
it('should trigger onChange prop', () => {
const onChange = jest.fn();
const wrapper = mount(
<CodeMirrorInput value="---\n" onChange={onChange} mode="yaml" />
);
const codemirror = wrapper.find('Controlled');
expect(codemirror.prop('mode')).toEqual('yaml');
expect(codemirror.prop('options').readOnly).toEqual(false);
codemirror.prop('onBeforeChange')(null, null, 'newvalue');
expect(onChange).toHaveBeenCalledWith('newvalue');
});
it('should render in read only mode', () => {
const onChange = jest.fn();
const wrapper = mount(
<CodeMirrorInput value="---\n" onChange={onChange} mode="yaml" readOnly />
);
const codemirror = wrapper.find('Controlled');
expect(codemirror.prop('options').readOnly).toEqual(true);
});
});

View File

@ -0,0 +1,101 @@
import React, { useState } from 'react';
import { string, bool } from 'prop-types';
import { Field } from 'formik';
import { Button, Split, SplitItem } from '@patternfly/react-core';
import styled from 'styled-components';
import ButtonGroup from '../ButtonGroup';
import CodeMirrorInput from './CodeMirrorInput';
import { yamlToJson, jsonToYaml } from '../../util/yaml';
const YAML_MODE = 'yaml';
const JSON_MODE = 'javascript';
const SmallButton = styled(Button)`
padding: 3px 8px;
font-size: var(--pf-global--FontSize--xs);
`;
function VariablesField({ id, name, label, readOnly }) {
const [mode, setMode] = useState(YAML_MODE);
return (
<Field
name={name}
render={({ field, form }) => (
<div className="pf-c-form__group">
<Split gutter="sm">
<SplitItem>
<label htmlFor={id} className="pf-c-form__label">
{label}
</label>
</SplitItem>
<SplitItem>
<ButtonGroup>
<SmallButton
onClick={() => {
if (mode === YAML_MODE) {
return;
}
try {
form.setFieldValue(name, jsonToYaml(field.value));
setMode(YAML_MODE);
} catch (err) {
form.setFieldError(name, err.message);
}
}}
variant={mode === YAML_MODE ? 'primary' : 'secondary'}
>
YAML
</SmallButton>
<SmallButton
onClick={() => {
if (mode === JSON_MODE) {
return;
}
try {
form.setFieldValue(name, yamlToJson(field.value));
setMode(JSON_MODE);
} catch (err) {
form.setFieldError(name, err.message);
}
}}
variant={mode === JSON_MODE ? 'primary' : 'secondary'}
>
JSON
</SmallButton>
</ButtonGroup>
</SplitItem>
</Split>
<CodeMirrorInput
mode={mode}
readOnly={readOnly}
{...field}
onChange={value => {
form.setFieldValue(name, value);
}}
hasErrors={!!form.errors[field.name]}
/>
{form.errors[field.name] ? (
<div
className="pf-c-form__helper-text pf-m-error"
aria-live="polite"
>
{form.errors[field.name]}
</div>
) : null}
</div>
)}
/>
);
}
VariablesField.propTypes = {
id: string.isRequired,
name: string.isRequired,
label: string.isRequired,
readOnly: bool,
};
VariablesField.defaultProps = {
readOnly: false,
};
export default VariablesField;

View File

@ -0,0 +1,100 @@
import React from 'react';
import { mount } from 'enzyme';
import { Formik } from 'formik';
import { sleep } from '../../../testUtils/testUtils';
import VariablesField from './VariablesField';
describe('VariablesField', () => {
beforeEach(() => {
document.body.createTextRange = jest.fn();
});
it('should render code mirror input', () => {
const value = '---\n';
const wrapper = mount(
<Formik
initialValues={{ variables: value }}
render={() => (
<VariablesField id="the-field" name="variables" label="Variables" />
)}
/>
);
const codemirror = wrapper.find('Controlled');
expect(codemirror.prop('value')).toEqual(value);
});
it('should render yaml/json toggles', () => {
const value = '---\n';
const wrapper = mount(
<Formik
initialValues={{ variables: value }}
render={() => (
<VariablesField id="the-field" name="variables" label="Variables" />
)}
/>
);
const buttons = wrapper.find('Button');
expect(buttons).toHaveLength(2);
expect(buttons.at(0).prop('variant')).toEqual('primary');
expect(buttons.at(1).prop('variant')).toEqual('secondary');
buttons.at(1).simulate('click');
wrapper.update(0);
expect(wrapper.find('CodeMirrorInput').prop('mode')).toEqual('javascript');
const buttons2 = wrapper.find('Button');
expect(buttons2.at(0).prop('variant')).toEqual('secondary');
expect(buttons2.at(1).prop('variant')).toEqual('primary');
buttons2.at(0).simulate('click');
wrapper.update(0);
expect(wrapper.find('CodeMirrorInput').prop('mode')).toEqual('yaml');
});
it('should set Formik error if yaml is invalid', () => {
const value = '---\nfoo bar\n';
const wrapper = mount(
<Formik
initialValues={{ variables: value }}
render={() => (
<VariablesField id="the-field" name="variables" label="Variables" />
)}
/>
);
wrapper
.find('Button')
.at(1)
.simulate('click');
wrapper.update();
const field = wrapper.find('CodeMirrorInput');
expect(field.prop('hasErrors')).toEqual(true);
expect(wrapper.find('.pf-m-error')).toHaveLength(1);
});
it('should submit value through Formik', async () => {
const value = '---\nfoo: bar\n';
const handleSubmit = jest.fn();
const wrapper = mount(
<Formik
initialValues={{ variables: value }}
onSubmit={handleSubmit}
render={formik => (
<form onSubmit={formik.handleSubmit}>
<VariablesField id="the-field" name="variables" label="Variables" />
<button type="submit" id="submit">
Submit
</button>
</form>
)}
/>
);
wrapper.find('CodeMirrorInput').prop('onChange')('---\nnewval: changed');
wrapper.find('form').simulate('submit');
await sleep(1);
await sleep(1);
expect(handleSubmit).toHaveBeenCalled();
expect(handleSubmit.mock.calls[0][0]).toEqual({
variables: '---\nnewval: changed',
});
});
});

View File

@ -0,0 +1,4 @@
import CodeMirrorInput from './CodeMirrorInput';
export default CodeMirrorInput;
export { default as VariablesField } from './VariablesField';

View File

@ -0,0 +1,21 @@
import React from 'react';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
import {
Title,
EmptyState,
EmptyStateIcon,
EmptyStateBody,
} from '@patternfly/react-core';
import { CubesIcon } from '@patternfly/react-icons';
const ContentEmpty = ({ i18n, title = '', message = '' }) => (
<EmptyState>
<EmptyStateIcon icon={CubesIcon} />
<Title size="lg">{title || i18n._(t`No items found.`)}</Title>
<EmptyStateBody>{message}</EmptyStateBody>
</EmptyState>
);
export { ContentEmpty as _ContentEmpty };
export default withI18n()(ContentEmpty);

View File

@ -0,0 +1,11 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import ContentEmpty from './ContentEmpty';
describe('ContentEmpty', () => {
test('renders the expected content', () => {
const wrapper = mountWithContexts(<ContentEmpty />);
expect(wrapper).toHaveLength(1);
});
});

View File

@ -0,0 +1 @@
export { default } from './ContentEmpty';

View File

@ -0,0 +1,38 @@
import React from 'react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import { withI18n } from '@lingui/react';
import {
Title,
EmptyState as PFEmptyState,
EmptyStateIcon,
EmptyStateBody,
} from '@patternfly/react-core';
import { ExclamationTriangleIcon } from '@patternfly/react-icons';
import ErrorDetail from '@components/ErrorDetail';
const EmptyState = styled(PFEmptyState)`
width: var(--pf-c-empty-state--m-lg--MaxWidth);
`;
class ContentError extends React.Component {
render() {
const { error, i18n } = this.props;
return (
<EmptyState>
<EmptyStateIcon icon={ExclamationTriangleIcon} />
<Title size="lg">{i18n._(t`Something went wrong...`)}</Title>
<EmptyStateBody>
{i18n._(
t`There was an error loading this content. Please reload the page.`
)}
</EmptyStateBody>
{error && <ErrorDetail error={error} />}
</EmptyState>
);
}
}
export { ContentError as _ContentError };
export default withI18n()(ContentError);

View File

@ -0,0 +1,24 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import ContentError from './ContentError';
describe('ContentError', () => {
test('renders the expected content', () => {
const wrapper = mountWithContexts(
<ContentError
error={
new Error({
response: {
config: {
method: 'post',
},
data: 'An error occurred',
},
})
}
/>
);
expect(wrapper).toHaveLength(1);
});
});

View File

@ -0,0 +1 @@
export { default } from './ContentError';

View File

@ -0,0 +1,14 @@
import React from 'react';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
import { EmptyState, EmptyStateBody } from '@patternfly/react-core';
// TODO: Better loading state - skeleton lines / spinner, etc.
const ContentLoading = ({ i18n }) => (
<EmptyState>
<EmptyStateBody>{i18n._(t`Loading...`)}</EmptyStateBody>
</EmptyState>
);
export { ContentLoading as _ContentLoading };
export default withI18n()(ContentLoading);

Some files were not shown because too many files have changed in this diff Show More