mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 01:57:35 -03:30
Merge pull request #9537 from AlexSCorey/lingUIUpdate
Updates Ling UI SUMMARY This PR updates Ling ui. One of the reasons for updating this dependency was that they were deprecating withI18n(). They changed their minds on that so we didn't need to urgently remove all those HOCs. Thus, we can now make that conversion a bit slower a couple of files at a time. One other thing: When we are changing the string based on a count of an item (ie. Cancel Job vs. Cancel Jobs) we should use ling ui's <Plural> component. However, in order to show the update strings passed to that component the developer will have to run npm run extract-strings each time they are changed to render the updated strings properly. More info here. ISSUE TYPE -dependency upgrade COMPONENT NAME UI AWX VERSION ADDITIONAL INFORMATION Reviewed-by: Kersom <None> Reviewed-by: Alex Corey <Alex.swansboro@gmail.com> Reviewed-by: Sergio Moreno <sergiomorenoalbert@gmail.com>
This commit is contained in:
commit
c72cc6486c
@ -93,7 +93,8 @@
|
||||
"RunOnRadio",
|
||||
"NodeTypeLetter",
|
||||
"SelectableItem",
|
||||
"Dash"
|
||||
"Dash",
|
||||
"Plural"
|
||||
],
|
||||
"ignoreCallee": ["describe"]
|
||||
}
|
||||
|
||||
@ -1,6 +1,17 @@
|
||||
{
|
||||
"localeDir": "src/locales/",
|
||||
"srcPathDirs": ["src/"],
|
||||
"format": "po",
|
||||
"sourceLocale": "en"
|
||||
{"catalogs":[{
|
||||
"path": "<rootDir>/locales/{locale}/messages",
|
||||
"include": ["<rootDir>"],
|
||||
"exclude": ["**/node_modules/**"]
|
||||
}],
|
||||
"compileNamespace": "cjs",
|
||||
"extractBabelOptions": {},
|
||||
"compilerBabelOptions": {},
|
||||
"fallbackLocales": { default: "en"},
|
||||
"format": "po",
|
||||
"locales": ["en","es","fr","nl","zh","ja", "zu"],
|
||||
"orderBy": "messageId",
|
||||
"pseudoLocale": "zu",
|
||||
"rootDir": "./src",
|
||||
"runtimeConfigModule": ["@lingui/core", "i18n"],
|
||||
"sourceLocale": "en",
|
||||
}
|
||||
|
||||
@ -35,7 +35,6 @@ Have questions about this document or anything not covered here? Feel free to re
|
||||
- [Setting up .po files to give to translation team](#setting-up-po-files-to-give-to-translation-team)
|
||||
- [Marking an issue to be translated](#marking-an-issue-to-be-translated)
|
||||
|
||||
|
||||
## Things to know prior to submitting code
|
||||
|
||||
- All code submissions are done through pull requests against the `devel` branch.
|
||||
@ -61,6 +60,7 @@ The AWX UI requires the following:
|
||||
- NPM 6.x LTS
|
||||
|
||||
Run the following to install all the dependencies:
|
||||
|
||||
```bash
|
||||
(host) $ npm install
|
||||
```
|
||||
@ -85,10 +85,10 @@ 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.
|
||||
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.
|
||||
**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:
|
||||
|
||||
@ -103,7 +103,7 @@ 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.
|
||||
**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:
|
||||
|
||||
@ -132,6 +132,7 @@ afterEach(() => {
|
||||
It should be noted that the `dataCy` prop, as well as its equivalent attribute `data-cy`, are used as flags for any UI test that wants to avoid relying on brittle CSS selectors such as `nth-of-type()`.
|
||||
|
||||
## 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.
|
||||
@ -141,6 +142,7 @@ API requests can and will fail occasionally so they should include explicit erro
|
||||
- **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.
|
||||
|
||||
## Forms
|
||||
|
||||
Our forms should have a known, consistent, and fully-resolved starting state before it is possible for a user, keyboard-mouse, screen reader, or automated test to interact with them. If multiple network calls are needed to populate a form, resolve them all before displaying the form or showing a content error. When multiple requests are needed to create or update the resources represented by a form, resolve them all before transitioning the ui to a success or failure state.
|
||||
|
||||
## Working with React
|
||||
@ -150,8 +152,9 @@ Our forms should have a known, consistent, and fully-resolved starting state bef
|
||||
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.
|
||||
|
||||
- **/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.
|
||||
- **/locales** - [Internationalization](#internationalization) config and source files.
|
||||
- **/screens** - Based on the various routes of awx.
|
||||
@ -159,11 +162,12 @@ Inside these folders, the internal structure is:
|
||||
- **/util** - Stateless helper functions that aren't tied to react.
|
||||
|
||||
### Patterns
|
||||
|
||||
- A **screen** shouldn't import from another screen. If a component _needs_ to be shared between two or more screens, it is a generic and should be moved to `src/components`.
|
||||
|
||||
#### 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
|
||||
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.
|
||||
@ -178,57 +182,66 @@ In the root of `/src`, there are a few files which are used to initialize the re
|
||||
|
||||
### 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`.
|
||||
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`.
|
||||
**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](https://lingui.js.org/), 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 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.
|
||||
- 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
|
||||
}
|
||||
}
|
||||
```
|
||||
```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
|
||||
}
|
||||
}
|
||||
```
|
||||
```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).
|
||||
|
||||
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,
|
||||
@ -254,6 +267,7 @@ There are currently a few custom hooks:
|
||||
4. [useSelected](https://github.com/ansible/awx/blob/devel/awx/ui_next/src/util/useSelected.jsx#L14) provides a way to read and update a selected list.
|
||||
|
||||
### Naming Functions
|
||||
|
||||
Here are the guidelines for how to name functions.
|
||||
|
||||
| Naming Convention | Description |
|
||||
@ -273,6 +287,7 @@ Here are the guidelines for how to name functions.
|
||||
| `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
|
||||
@ -282,7 +297,7 @@ this.state = {
|
||||
somethingC: 0,
|
||||
somethingD: {},
|
||||
somethingE: '',
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Testing components that use contexts
|
||||
@ -315,33 +330,35 @@ mountWithContexts(<Organization />< {
|
||||
|
||||
## Internationalization
|
||||
|
||||
Internationalization leans on the [lingui](https://github.com/lingui/js-lingui) project. [Official documentation here](https://lingui.js.org/). We use this library to mark our strings for translation. If you want to see this in action you'll need to take the following steps:
|
||||
Internationalization leans on the [lingui](https://github.com/lingui/js-lingui) project. [Official documentation here](https://lingui.js.org/). We use this library 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 translated. For consistency and ease of use, we have consolidated on one pattern for the codebase. To set strings to be translated in the UI:
|
||||
The lingui library provides various React helpers for dealing with both marking strings for translation, and replacing strings that have been translated. 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;`)
|
||||
- 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`)```
|
||||
- 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:** 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 try to avoid 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.
|
||||
**Note:** We try to avoid 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.
|
||||
|
||||
**Note:** Pluralization can be complicated so it is best to allow lingui handle cases where we have a string that may need to be pluralized based on number of items, or count. In that case lingui provides a `<Plural>` component, and a `plural()` function. See documentation [here](https://lingui.js.org/guides/plurals.html?highlight=pluralization).
|
||||
|
||||
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.
|
||||
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.
|
||||
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. When updating strings that are used by `<Plural>` or `plural()` you will need to run this command to get the strings to render properly. This commmand will create `.po` files for each of the supported languages that will need to be commited with your PR.
|
||||
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.
|
||||
|
||||
### Marking an issue to be translated
|
||||
|
||||
1) Issues marked with `component:I10n` should not be closed after the issue was fixed.
|
||||
2) Remove the label `state:needs_devel`.
|
||||
3) Add the label `state:pending_translations`. At this point, the translations will be batch translated by a maintainer, creating relevant entries in the PO files. Then after those translations have been merged, the issue can be closed.
|
||||
1. Issues marked with `component:I10n` should not be closed after the issue was fixed.
|
||||
2. Remove the label `state:needs_devel`.
|
||||
3. Add the label `state:pending_translations`. At this point, the translations will be batch translated by a maintainer, creating relevant entries in the PO files. Then after those translations have been merged, the issue can be closed.
|
||||
|
||||
1616
awx/ui_next/package-lock.json
generated
1616
awx/ui_next/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -6,7 +6,7 @@
|
||||
"node": "14.x"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lingui/react": "^2.9.1",
|
||||
"@lingui/react": "^3.7.1",
|
||||
"@patternfly/patternfly": "^4.80.3",
|
||||
"@patternfly/react-core": "^4.90.2",
|
||||
"@patternfly/react-icons": "4.7.22",
|
||||
@ -14,6 +14,8 @@
|
||||
"ace-builds": "^1.4.12",
|
||||
"ansi-to-html": "^0.6.11",
|
||||
"axios": "^0.21.1",
|
||||
"babel-plugin-macros": "^3.0.1",
|
||||
"codemirror": "^5.47.0",
|
||||
"d3": "^5.12.0",
|
||||
"dagre": "^0.8.4",
|
||||
"formik": "^2.1.2",
|
||||
@ -33,8 +35,9 @@
|
||||
"devDependencies": {
|
||||
"@babel/polyfill": "^7.8.7",
|
||||
"@cypress/instrument-cra": "^1.4.0",
|
||||
"@lingui/cli": "^2.9.2",
|
||||
"@lingui/macro": "^2.9.1",
|
||||
"@lingui/cli": "^3.7.1",
|
||||
"@lingui/loader": "^3.8.3",
|
||||
"@lingui/macro": "^3.7.1",
|
||||
"@nteract/mockument": "^1.0.4",
|
||||
"babel-core": "^7.0.0-bridge.0",
|
||||
"enzyme": "^3.10.0",
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
useRouteMatch,
|
||||
useLocation,
|
||||
@ -7,7 +7,8 @@ import {
|
||||
Switch,
|
||||
Redirect,
|
||||
} from 'react-router-dom';
|
||||
import { I18n, I18nProvider } from '@lingui/react';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { Card, PageSection } from '@patternfly/react-core';
|
||||
|
||||
import { ConfigProvider, useAuthorizedPath } from './contexts/Config';
|
||||
@ -16,10 +17,9 @@ import Background from './components/Background';
|
||||
import NotFound from './screens/NotFound';
|
||||
import Login from './screens/Login';
|
||||
|
||||
import ja from './locales/ja/messages';
|
||||
import en from './locales/en/messages';
|
||||
import { isAuthenticated } from './util/auth';
|
||||
import { getLanguageWithoutRegionCode } from './util/language';
|
||||
import { dynamicActivate, locales } from './i18nLoader';
|
||||
|
||||
import getRouteConfig from './routeConfig';
|
||||
import SubscriptionEdit from './screens/Setting/Subscription/SubscriptionEdit';
|
||||
@ -74,41 +74,40 @@ const ProtectedRoute = ({ children, ...rest }) =>
|
||||
);
|
||||
|
||||
function App() {
|
||||
const catalogs = { en, ja };
|
||||
let language = getLanguageWithoutRegionCode(navigator);
|
||||
if (!Object.keys(catalogs).includes(language)) {
|
||||
if (!Object.keys(locales).includes(language)) {
|
||||
// If there isn't a string catalog available for the browser's
|
||||
// preferred language, default to one that has strings.
|
||||
language = 'en';
|
||||
}
|
||||
useEffect(() => {
|
||||
dynamicActivate(language);
|
||||
}, [language]);
|
||||
|
||||
const { hash, search, pathname } = useLocation();
|
||||
|
||||
return (
|
||||
<I18nProvider language={language} catalogs={catalogs}>
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<Background>
|
||||
<Switch>
|
||||
<Route exact strict path="/*/">
|
||||
<Redirect to={`${pathname.slice(0, -1)}${search}${hash}`} />
|
||||
</Route>
|
||||
<Route path="/login">
|
||||
<Login isAuthenticated={isAuthenticated} />
|
||||
</Route>
|
||||
<Route exact path="/">
|
||||
<Redirect to="/home" />
|
||||
</Route>
|
||||
<ProtectedRoute>
|
||||
<ConfigProvider>
|
||||
<AppContainer navRouteConfig={getRouteConfig(i18n)}>
|
||||
<AuthorizedRoutes routeConfig={getRouteConfig(i18n)} />
|
||||
</AppContainer>
|
||||
</ConfigProvider>
|
||||
</ProtectedRoute>
|
||||
</Switch>
|
||||
</Background>
|
||||
)}
|
||||
</I18n>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<Background>
|
||||
<Switch>
|
||||
<Route exact strict path="/*/">
|
||||
<Redirect to={`${pathname.slice(0, -1)}${search}${hash}`} />
|
||||
</Route>
|
||||
<Route path="/login">
|
||||
<Login isAuthenticated={isAuthenticated} />
|
||||
</Route>
|
||||
<Route exact path="/">
|
||||
<Redirect to="/home" />
|
||||
</Route>
|
||||
<ProtectedRoute>
|
||||
<ConfigProvider>
|
||||
<AppContainer navRouteConfig={getRouteConfig(i18n)}>
|
||||
<AuthorizedRoutes routeConfig={getRouteConfig(i18n)} />
|
||||
</AppContainer>
|
||||
</ConfigProvider>
|
||||
</ProtectedRoute>
|
||||
</Switch>
|
||||
</Background>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { shallow } from 'enzyme';
|
||||
import {
|
||||
mountWithContexts,
|
||||
shallowWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../testUtils/enzymeHelpers';
|
||||
import { sleep } from '../../../testUtils/testUtils';
|
||||
@ -27,17 +27,19 @@ describe('<SelectResourceStep />', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
test('initially renders without crashing', () => {
|
||||
shallow(
|
||||
<SelectResourceStep
|
||||
searchColumns={searchColumns}
|
||||
sortColumns={sortColumns}
|
||||
displayKey="username"
|
||||
onRowClick={() => {}}
|
||||
fetchItems={() => {}}
|
||||
fetchOptions={() => {}}
|
||||
/>
|
||||
);
|
||||
test('initially renders without crashing', async () => {
|
||||
act(() => {
|
||||
shallowWithContexts(
|
||||
<SelectResourceStep
|
||||
searchColumns={searchColumns}
|
||||
sortColumns={sortColumns}
|
||||
displayKey="username"
|
||||
onRowClick={() => {}}
|
||||
fetchItems={() => {}}
|
||||
fetchOptions={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('fetches resources on mount and adds items to list', async () => {
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
|
||||
import {
|
||||
mountWithContexts,
|
||||
shallowWithContexts,
|
||||
} from '../../../testUtils/enzymeHelpers';
|
||||
|
||||
import SelectRoleStep from './SelectRoleStep';
|
||||
|
||||
describe('<SelectRoleStep />', () => {
|
||||
@ -31,7 +35,7 @@ describe('<SelectRoleStep />', () => {
|
||||
},
|
||||
];
|
||||
test('initially renders without crashing', () => {
|
||||
wrapper = shallow(
|
||||
wrapper = shallowWithContexts(
|
||||
<SelectRoleStep
|
||||
roles={roles}
|
||||
selectedResourceRows={selectedResourceRows}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { t, plural } from '@lingui/macro';
|
||||
|
||||
import { Card } from '@patternfly/react-core';
|
||||
import AlertModal from '../AlertModal';
|
||||
@ -244,15 +244,12 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
isJobRunning(item.status) ||
|
||||
!item.summary_fields.user_capabilities.delete
|
||||
}
|
||||
errorMessage={
|
||||
cannotDeleteItems.length === 1
|
||||
? i18n._(
|
||||
t`The selected job cannot be deleted due to insufficient permission or a running job status`
|
||||
)
|
||||
: i18n._(
|
||||
t`The selected jobs cannot be deleted due to insufficient permissions or a running job status`
|
||||
)
|
||||
}
|
||||
errorMessage={plural(cannotDeleteItems.length, {
|
||||
one:
|
||||
'The selected job cannot be deleted due to insufficient permission or a running job status',
|
||||
other:
|
||||
'The selected jobs cannot be deleted due to insufficient permissions or a running job status',
|
||||
})}
|
||||
/>,
|
||||
<JobListCancelButton
|
||||
key="cancel"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { t, Plural } from '@lingui/macro';
|
||||
import { arrayOf, func } from 'prop-types';
|
||||
import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
|
||||
import { KebabifiedContext } from '../../contexts/Kebabified';
|
||||
@ -22,7 +22,6 @@ function JobListCancelButton({ i18n, jobsToCancel, onCancel }) {
|
||||
const { isKebabified, onKebabModalChange } = useContext(KebabifiedContext);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const numJobsToCancel = jobsToCancel.length;
|
||||
const zeroOrOneJobSelected = numJobsToCancel < 2;
|
||||
|
||||
const handleCancelJob = () => {
|
||||
onCancel();
|
||||
@ -54,35 +53,32 @@ function JobListCancelButton({ i18n, jobsToCancel, onCancel }) {
|
||||
<div>
|
||||
{cannotCancelPermissions.length > 0 && (
|
||||
<div>
|
||||
{i18n._(
|
||||
'{numJobsUnableToCancel, plural, one {You do not have permission to cancel the following job:} other {You do not have permission to cancel the following jobs:}}',
|
||||
{
|
||||
numJobsUnableToCancel: cannotCancelPermissions.length,
|
||||
}
|
||||
)}
|
||||
{' '.concat(cannotCancelPermissions.join(', '))}
|
||||
<Plural
|
||||
value={cannotCancelPermissions.length}
|
||||
one="You do not have permission to cancel the following job:"
|
||||
other="You do not have permission to cancel the following jobs:"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{cannotCancelNotRunning.length > 0 && (
|
||||
<div>
|
||||
{i18n._(
|
||||
'{numJobsUnableToCancel, plural, one {You cannot cancel the following job because it is not running:} other {You cannot cancel the following jobs because they are not running:}}',
|
||||
{
|
||||
numJobsUnableToCancel: cannotCancelNotRunning.length,
|
||||
}
|
||||
)}
|
||||
{' '.concat(cannotCancelNotRunning.join(', '))}
|
||||
<Plural
|
||||
value={cannotCancelNotRunning.length}
|
||||
one="You cannot cancel the following job because it is not running"
|
||||
other="You cannot cancel the following jobs because they are not running"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (numJobsToCancel > 0) {
|
||||
return i18n._(
|
||||
'{numJobsToCancel, plural, one {Cancel selected job} other {Cancel selected jobs}}',
|
||||
{
|
||||
numJobsToCancel,
|
||||
}
|
||||
return (
|
||||
<Plural
|
||||
value={numJobsToCancel}
|
||||
one={i18n._(t`Cancel selected job`)}
|
||||
other={i18n._(t`Cancel selected jobs`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return i18n._(t`Select a job to cancel`);
|
||||
@ -92,12 +88,8 @@ function JobListCancelButton({ i18n, jobsToCancel, onCancel }) {
|
||||
jobsToCancel.length === 0 ||
|
||||
jobsToCancel.some(cannotCancelBecausePermissions) ||
|
||||
jobsToCancel.some(cannotCancelBecauseNotRunning);
|
||||
|
||||
const cancelJobText = i18n._(
|
||||
'{zeroOrOneJobSelected, plural, one {Cancel job} other {Cancel jobs}}',
|
||||
{
|
||||
zeroOrOneJobSelected,
|
||||
}
|
||||
const cancelJobText = (
|
||||
<Plural value={numJobsToCancel} one="Cancel job" other="Cancel jobs" />
|
||||
);
|
||||
|
||||
return (
|
||||
@ -156,12 +148,11 @@ function JobListCancelButton({ i18n, jobsToCancel, onCancel }) {
|
||||
]}
|
||||
>
|
||||
<div>
|
||||
{i18n._(
|
||||
'{numJobsToCancel, plural, one {This action will cancel the following job:} other {This action will cancel the following jobs:}}',
|
||||
{
|
||||
numJobsToCancel,
|
||||
}
|
||||
)}
|
||||
<Plural
|
||||
value={numJobsToCancel}
|
||||
one="This action will cancel the following job:"
|
||||
other="This action will cancel the following jobs:"
|
||||
/>
|
||||
</div>
|
||||
{jobsToCancel.map(job => (
|
||||
<span key={job.id}>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -145,7 +145,7 @@ function ToolbarDeleteButton({
|
||||
if (itemsToDelete.some(cannotDelete)) {
|
||||
return (
|
||||
<div>
|
||||
{errorMessage.length > 0
|
||||
{errorMessage
|
||||
? `${errorMessage}: ${itemsUnableToDelete}`
|
||||
: i18n._(
|
||||
t`You do not have permission to delete ${pluralizedItemName}: ${itemsUnableToDelete}`
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -4,7 +4,7 @@ import {
|
||||
Pagination as PFPagination,
|
||||
DropdownDirection,
|
||||
} from '@patternfly/react-core';
|
||||
import { I18n } from '@lingui/react';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
const AWXPagination = styled(PFPagination)`
|
||||
@ -19,26 +19,22 @@ const AWXPagination = styled(PFPagination)`
|
||||
`;
|
||||
|
||||
export default props => (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<AWXPagination
|
||||
titles={{
|
||||
items: i18n._(t`items`),
|
||||
page: i18n._(t`page`),
|
||||
pages: i18n._(t`pages`),
|
||||
itemsPerPage: i18n._(t`Items per page`),
|
||||
perPageSuffix: i18n._(t`per page`),
|
||||
toFirstPage: i18n._(t`Go to first page`),
|
||||
toPreviousPage: i18n._(t`Go to previous page`),
|
||||
toLastPage: i18n._(t`Go to last page`),
|
||||
toNextPage: i18n._(t`Go to next page`),
|
||||
optionsToggle: i18n._(t`Select`),
|
||||
currPage: i18n._(t`Current page`),
|
||||
paginationTitle: i18n._(t`Pagination`),
|
||||
}}
|
||||
dropDirection={DropdownDirection.up}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</I18n>
|
||||
<AWXPagination
|
||||
titles={{
|
||||
items: i18n._(t`items`),
|
||||
page: i18n._(t`page`),
|
||||
pages: i18n._(t`pages`),
|
||||
itemsPerPage: i18n._(t`Items per page`),
|
||||
perPageSuffix: i18n._(t`per page`),
|
||||
toFirstPage: i18n._(t`Go to first page`),
|
||||
toPreviousPage: i18n._(t`Go to previous page`),
|
||||
toLastPage: i18n._(t`Go to last page`),
|
||||
toNextPage: i18n._(t`Go to next page`),
|
||||
optionsToggle: i18n._(t`Select`),
|
||||
currPage: i18n._(t`Current page`),
|
||||
paginationTitle: i18n._(t`Pagination`),
|
||||
}}
|
||||
dropDirection={DropdownDirection.up}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -15,12 +15,12 @@ function DeleteRoleConfirmationModal({
|
||||
i18n,
|
||||
}) {
|
||||
const isTeamRole = () => {
|
||||
return typeof role.team_id !== 'undefined';
|
||||
return typeof role.team_id !== 'undefined'
|
||||
? i18n._(t`Team`)
|
||||
: i18n._(t`User`);
|
||||
};
|
||||
|
||||
const title = i18n._(
|
||||
t`Remove ${isTeamRole() ? i18n._(t`Team`) : i18n._(t`User`)} Access`
|
||||
);
|
||||
const title = i18n._(t`Remove ${isTeamRole()} Access`);
|
||||
return (
|
||||
<AlertModal
|
||||
variant="danger"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -3,7 +3,7 @@ import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { useField } from 'formik';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t, Trans } from '@lingui/macro';
|
||||
import { t, Trans, Plural } from '@lingui/macro';
|
||||
import { RRule } from 'rrule';
|
||||
import {
|
||||
Checkbox as _Checkbox,
|
||||
@ -185,29 +185,17 @@ const FrequencyDetailSubform = ({ i18n }) => {
|
||||
|
||||
switch (frequency.value) {
|
||||
case 'minute':
|
||||
return i18n._('{intervalValue, plural, one {minute} other {minutes}}', {
|
||||
intervalValue,
|
||||
});
|
||||
return <Plural value={intervalValue} one="minute" other="minutes" />;
|
||||
case 'hour':
|
||||
return i18n._('{intervalValue, plural, one {hour} other {hours}}', {
|
||||
intervalValue,
|
||||
});
|
||||
return <Plural value={intervalValue} one="hour" other="hours" />;
|
||||
case 'day':
|
||||
return i18n._('{intervalValue, plural, one {day} other {days}}', {
|
||||
intervalValue,
|
||||
});
|
||||
return <Plural value={intervalValue} one="day" other="days" />;
|
||||
case 'week':
|
||||
return i18n._('{intervalValue, plural, one {week} other {weeks}}', {
|
||||
intervalValue,
|
||||
});
|
||||
return <Plural value={intervalValue} one="week" other="weeks" />;
|
||||
case 'month':
|
||||
return i18n._('{intervalValue, plural, one {month} other {months}}', {
|
||||
intervalValue,
|
||||
});
|
||||
return <Plural value={intervalValue} one="month" other="months" />;
|
||||
case 'year':
|
||||
return i18n._('{intervalValue, plural, one {year} other {years}}', {
|
||||
intervalValue,
|
||||
});
|
||||
return <Plural value={intervalValue} one="year" other="years" />;
|
||||
default:
|
||||
throw new Error(i18n._(t`Frequency did not match an expected value`));
|
||||
}
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
|
||||
import {
|
||||
mountWithContexts,
|
||||
shallowWithContexts,
|
||||
} from '../../../testUtils/enzymeHelpers';
|
||||
|
||||
import Sparkline from './Sparkline';
|
||||
|
||||
describe('Sparkline', () => {
|
||||
test('renders the expected content', () => {
|
||||
const wrapper = mount(<Sparkline />);
|
||||
const wrapper = shallowWithContexts(<Sparkline />);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
});
|
||||
test('renders an icon with tooltips and links for each job', () => {
|
||||
|
||||
@ -170,8 +170,7 @@ function TemplateList({ defaultParams, i18n }) {
|
||||
);
|
||||
|
||||
const deleteDetailsRequests = relatedResourceDeleteRequests.template(
|
||||
selected[0],
|
||||
i18n
|
||||
selected[0]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import React from 'react';
|
||||
|
||||
import { en } from 'make-plural/plurals';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
import english from '../../locales/en/messages';
|
||||
import { WorkflowStateContext } from '../../contexts/Workflow';
|
||||
import WorkflowStartNode from './WorkflowStartNode';
|
||||
|
||||
@ -10,16 +15,22 @@ const nodePositions = {
|
||||
},
|
||||
};
|
||||
|
||||
i18n.loadLocaleData({ en: { plurals: en } });
|
||||
i18n.load({ en: english });
|
||||
i18n.activate('en');
|
||||
|
||||
describe('WorkflowStartNode', () => {
|
||||
test('mounts successfully', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<svg>
|
||||
<WorkflowStateContext.Provider value={{ nodePositions }}>
|
||||
<WorkflowStartNode
|
||||
nodePositions={nodePositions}
|
||||
showActionTooltip={false}
|
||||
/>
|
||||
</WorkflowStateContext.Provider>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<WorkflowStateContext.Provider value={{ nodePositions }}>
|
||||
<WorkflowStartNode
|
||||
nodePositions={nodePositions}
|
||||
showActionTooltip={false}
|
||||
/>
|
||||
</WorkflowStateContext.Provider>
|
||||
</I18nProvider>
|
||||
</svg>
|
||||
);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
@ -27,9 +38,14 @@ describe('WorkflowStartNode', () => {
|
||||
test('tooltip shown on hover', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<svg>
|
||||
<WorkflowStateContext.Provider value={{ nodePositions }}>
|
||||
<WorkflowStartNode nodePositions={nodePositions} showActionTooltip />
|
||||
</WorkflowStateContext.Provider>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<WorkflowStateContext.Provider value={{ nodePositions }}>
|
||||
<WorkflowStartNode
|
||||
nodePositions={nodePositions}
|
||||
showActionTooltip
|
||||
/>
|
||||
</WorkflowStateContext.Provider>
|
||||
</I18nProvider>
|
||||
</svg>
|
||||
);
|
||||
expect(wrapper.find('WorkflowActionTooltip')).toHaveLength(0);
|
||||
|
||||
32
awx/ui_next/src/i18nLoader.js
Normal file
32
awx/ui_next/src/i18nLoader.js
Normal file
@ -0,0 +1,32 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
import { en, fr, es, nl, ja, zh, zu } from 'make-plural/plurals';
|
||||
|
||||
export const locales = {
|
||||
en: 'English',
|
||||
ja: 'Japanese',
|
||||
zu: 'Zulu',
|
||||
fr: 'French',
|
||||
es: 'Spanish',
|
||||
zh: 'Chinese',
|
||||
nl: 'Dutch',
|
||||
};
|
||||
|
||||
i18n.loadLocaleData({
|
||||
en: { plurals: en },
|
||||
fr: { plurals: fr },
|
||||
es: { plurals: es },
|
||||
nl: { plurals: nl },
|
||||
ja: { plurals: ja },
|
||||
zh: { plurals: zh },
|
||||
zu: { plurals: zu },
|
||||
});
|
||||
|
||||
/**
|
||||
* We do a dynamic import of just the catalog that we need
|
||||
* @param locale any locale string
|
||||
*/
|
||||
export async function dynamicActivate(locale) {
|
||||
const { messages } = await import(`./locales/${locale}/messages`);
|
||||
i18n.load(locale, messages);
|
||||
i18n.activate(locale);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
9207
awx/ui_next/src/locales/zu/messages.po
Normal file
9207
awx/ui_next/src/locales/zu/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
@ -185,8 +185,7 @@ function CredentialDetail({ i18n, credential }) {
|
||||
}, [fetchDetails]);
|
||||
|
||||
const deleteDetailsRequests = relatedResourceDeleteRequests.credential(
|
||||
credential,
|
||||
i18n
|
||||
credential
|
||||
);
|
||||
|
||||
if (hasContentLoading) {
|
||||
|
||||
@ -105,8 +105,7 @@ function CredentialList({ i18n }) {
|
||||
const canAdd =
|
||||
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
|
||||
const deleteDetailsRequests = relatedResourceDeleteRequests.credential(
|
||||
selected[0],
|
||||
i18n
|
||||
selected[0]
|
||||
);
|
||||
return (
|
||||
<PageSection>
|
||||
|
||||
@ -107,8 +107,7 @@ function CredentialTypeList({ i18n }) {
|
||||
const canAdd = actions && actions.POST;
|
||||
|
||||
const deleteDetailsRequests = relatedResourceDeleteRequests.credentialType(
|
||||
selected[0],
|
||||
i18n
|
||||
selected[0]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@ -43,8 +43,7 @@ function ExecutionEnvironmentDetails({ executionEnvironment, i18n }) {
|
||||
|
||||
const { error, dismissError } = useDismissableError(deleteError);
|
||||
const deleteDetailsRequests = relatedResourceDeleteRequests.executionEnvironment(
|
||||
executionEnvironment,
|
||||
i18n
|
||||
executionEnvironment
|
||||
);
|
||||
return (
|
||||
<CardBody>
|
||||
|
||||
@ -106,8 +106,7 @@ function ExecutionEnvironmentList({ i18n }) {
|
||||
|
||||
const canAdd = actions && actions.POST;
|
||||
const deleteDetailsRequests = relatedResourceDeleteRequests.executionEnvironment(
|
||||
selected[0],
|
||||
i18n
|
||||
selected[0]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
|
||||
@ -36,8 +36,7 @@ function ContainerGroupDetails({ instanceGroup, i18n }) {
|
||||
|
||||
const { error, dismissError } = useDismissableError(deleteError);
|
||||
const deleteDetailsRequests = relatedResourceDeleteRequests.instanceGroup(
|
||||
instanceGroup,
|
||||
i18n
|
||||
instanceGroup
|
||||
);
|
||||
return (
|
||||
<CardBody>
|
||||
|
||||
@ -40,8 +40,7 @@ function InstanceGroupDetails({ instanceGroup, i18n }) {
|
||||
|
||||
const { error, dismissError } = useDismissableError(deleteError);
|
||||
const deleteDetailsRequests = relatedResourceDeleteRequests.instanceGroup(
|
||||
instanceGroup,
|
||||
i18n
|
||||
instanceGroup
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@ -187,8 +187,7 @@ function InstanceGroupList({ i18n }) {
|
||||
: `${match.url}/${item.id}/details`;
|
||||
};
|
||||
const deleteDetailsRequests = relatedResourceDeleteRequests.instanceGroup(
|
||||
selected[0],
|
||||
i18n
|
||||
selected[0]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
|
||||
@ -56,8 +56,7 @@ function InventoryDetail({ inventory, i18n }) {
|
||||
} = inventory.summary_fields;
|
||||
|
||||
const deleteDetailsRequests = relatedResourceDeleteRequests.inventory(
|
||||
inventory,
|
||||
i18n
|
||||
inventory
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
|
||||
@ -219,10 +219,12 @@ describe('<InventoryGroupsList/> error handling', () => {
|
||||
await act(async () => {
|
||||
wrapper.find('Toolbar Button[aria-label="Delete"]').invoke('onClick')();
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
await waitForElement(
|
||||
wrapper,
|
||||
'AlertModal[title="Delete Group?"]',
|
||||
el => el.props().isOpen === true
|
||||
'AlertModal__Header',
|
||||
el => el.text() === 'Delete Group?'
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper.find('Radio[id="radio-delete"]').invoke('onChange')();
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useLocation, useRouteMatch, Link } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { t, plural } from '@lingui/macro';
|
||||
import { Card, PageSection, DropdownItem } from '@patternfly/react-core';
|
||||
import { InventoriesAPI } from '../../../api';
|
||||
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
||||
@ -129,8 +129,7 @@ function InventoryList({ i18n }) {
|
||||
};
|
||||
|
||||
const deleteDetailsRequests = relatedResourceDeleteRequests.inventory(
|
||||
selected[0],
|
||||
i18n
|
||||
selected[0]
|
||||
);
|
||||
|
||||
const addInventory = i18n._(t`Add inventory`);
|
||||
@ -219,15 +218,13 @@ function InventoryList({ i18n }) {
|
||||
onDelete={handleInventoryDelete}
|
||||
itemsToDelete={selected}
|
||||
pluralizedItemName={i18n._(t`Inventories`)}
|
||||
warningMessage={i18n._(
|
||||
'{numItemsToDelete, plural, one {The inventory will be in a pending status until the final delete is processed.} other {The inventories will be in a pending status until the final delete is processed.}}',
|
||||
{ numItemsToDelete: selected.length }
|
||||
)}
|
||||
deleteMessage={i18n._(
|
||||
'{numItemsToDelete, plural, one {This inventory is currently being used by other resources. Are you sure you want to delete it?} other {Deleting these inventories could impact other resources that rely on them. Are you sure you want to delete anyway?}}',
|
||||
{ numItemsToDelete: selected.length }
|
||||
)}
|
||||
deleteDetailsRequests={deleteDetailsRequests}
|
||||
warningMessage={plural(selected.length, {
|
||||
one:
|
||||
'The inventory will be in a pending status until the final delete is processed.',
|
||||
other:
|
||||
'The inventories will be in a pending status until the final delete is processed.',
|
||||
})}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
|
||||
@ -99,7 +99,6 @@ function InventorySourceDetail({ inventorySource, i18n }) {
|
||||
|
||||
const deleteDetailsRequests = relatedResourceDeleteRequests.inventorySource(
|
||||
inventorySource.inventory,
|
||||
i18n,
|
||||
inventorySource
|
||||
);
|
||||
|
||||
|
||||
@ -146,7 +146,6 @@ function InventorySourceList({ i18n }) {
|
||||
|
||||
const deleteDetailsRequests = relatedResourceDeleteRequests.inventorySource(
|
||||
id,
|
||||
i18n,
|
||||
selected[0]
|
||||
);
|
||||
return (
|
||||
|
||||
@ -2,8 +2,8 @@ import 'styled-components/macro';
|
||||
import React, { useState, useContext, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { func, bool, arrayOf } from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { t, Plural } from '@lingui/macro';
|
||||
import { Button, Radio, DropdownItem } from '@patternfly/react-core';
|
||||
import styled from 'styled-components';
|
||||
import { KebabifiedContext } from '../../../contexts/Kebabified';
|
||||
@ -18,12 +18,8 @@ const ListItem = styled.li`
|
||||
color: var(--pf-global--danger-color--100);
|
||||
`;
|
||||
|
||||
const InventoryGroupsDeleteModal = ({
|
||||
onAfterDelete,
|
||||
isDisabled,
|
||||
groups,
|
||||
i18n,
|
||||
}) => {
|
||||
const InventoryGroupsDeleteModal = ({ onAfterDelete, isDisabled, groups }) => {
|
||||
const { i18n } = useLingui();
|
||||
const [radioOption, setRadioOption] = useState(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
@ -87,9 +83,11 @@ const InventoryGroupsDeleteModal = ({
|
||||
isOpen={isModalOpen}
|
||||
variant="danger"
|
||||
title={
|
||||
groups.length > 1
|
||||
? i18n._(t`Delete Groups?`)
|
||||
: i18n._(t`Delete Group?`)
|
||||
<Plural
|
||||
value={groups.length}
|
||||
one="Delete Group?"
|
||||
other="Delete Groups?"
|
||||
/>
|
||||
}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
actions={[
|
||||
@ -112,11 +110,12 @@ const InventoryGroupsDeleteModal = ({
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
{i18n._(
|
||||
t`Are you sure you want to delete the ${
|
||||
groups.length > 1 ? i18n._(t`groups`) : i18n._(t`group`)
|
||||
} below?`
|
||||
)}
|
||||
<Plural
|
||||
value={groups.length}
|
||||
one="Are you sure you want delete the group below?"
|
||||
other="Are you sure you want delete the groups below?"
|
||||
/>
|
||||
|
||||
<div css="padding: 24px 0;">
|
||||
{groups.map(group => {
|
||||
return <ListItem key={group.id}>{group.name}</ListItem>;
|
||||
@ -167,4 +166,4 @@ InventoryGroupsDeleteModal.defaultProps = {
|
||||
groups: [],
|
||||
};
|
||||
|
||||
export default withI18n()(InventoryGroupsDeleteModal);
|
||||
export default InventoryGroupsDeleteModal;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useHistory, useLocation, withRouter } from 'react-router-dom';
|
||||
import { I18n } from '@lingui/react';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { t } from '@lingui/macro';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
@ -21,7 +21,7 @@ import {
|
||||
ToolbarToggleGroup,
|
||||
Tooltip,
|
||||
} from '@patternfly/react-core';
|
||||
import { SearchIcon, QuestionCircleIcon } from '@patternfly/react-icons';
|
||||
import { SearchIcon } from '@patternfly/react-icons';
|
||||
|
||||
import AlertModal from '../../../components/AlertModal';
|
||||
import { CardBody as _CardBody } from '../../../components/Card';
|
||||
@ -47,8 +47,6 @@ import {
|
||||
removeParams,
|
||||
getQSConfig,
|
||||
} from '../../../util/qs';
|
||||
import getDocsBaseUrl from '../../../util/getDocsBaseUrl';
|
||||
import { useConfig } from '../../../contexts/Config';
|
||||
|
||||
const QS_CONFIG = getQSConfig('job_output', {
|
||||
order_by: 'start_line',
|
||||
@ -282,7 +280,6 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
|
||||
const jobSocketCounter = useRef(0);
|
||||
const interval = useRef(null);
|
||||
const history = useHistory();
|
||||
const config = useConfig();
|
||||
const [contentError, setContentError] = useState(null);
|
||||
const [cssMap, setCssMap] = useState({});
|
||||
const [currentlyLoading, setCurrentlyLoading] = useState([]);
|
||||
@ -625,7 +622,7 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
|
||||
history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname);
|
||||
};
|
||||
|
||||
const renderSearchComponent = i18n => (
|
||||
const renderSearchComponent = () => (
|
||||
<Search
|
||||
qsConfig={QS_CONFIG}
|
||||
columns={[
|
||||
@ -688,176 +685,157 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<>
|
||||
<CardBody>
|
||||
{isHostModalOpen && (
|
||||
<HostEventModal
|
||||
onClose={handleHostModalClose}
|
||||
isOpen={isHostModalOpen}
|
||||
hostEvent={hostEvent}
|
||||
/>
|
||||
)}
|
||||
<OutputHeader>
|
||||
<HeaderTitle>
|
||||
<StatusIcon status={job.status} />
|
||||
<h1>{job.name}</h1>
|
||||
</HeaderTitle>
|
||||
<OutputToolbar
|
||||
job={job}
|
||||
jobStatus={jobStatus}
|
||||
onCancel={() => setShowCancelModal(true)}
|
||||
onDelete={deleteJob}
|
||||
isDeleteDisabled={isDeleting}
|
||||
/>
|
||||
</OutputHeader>
|
||||
<HostStatusBar counts={job.host_status_counts} />
|
||||
<SearchToolbar
|
||||
id="job_output-toolbar"
|
||||
clearAllFilters={handleRemoveAllSearchTerms}
|
||||
collapseListedFiltersBreakpoint="lg"
|
||||
clearFiltersButtonText={i18n._(t`Clear all filters`)}
|
||||
>
|
||||
<SearchToolbarContent>
|
||||
<ToolbarToggleGroup toggleIcon={<SearchIcon />} breakpoint="lg">
|
||||
<ToolbarItem variant="search-filter">
|
||||
{isJobRunning(job.status) ? (
|
||||
<Tooltip
|
||||
content={i18n._(
|
||||
t`Search is disabled while the job is running`
|
||||
)}
|
||||
>
|
||||
{renderSearchComponent(i18n)}
|
||||
</Tooltip>
|
||||
) : (
|
||||
renderSearchComponent(i18n)
|
||||
<>
|
||||
<CardBody>
|
||||
{isHostModalOpen && (
|
||||
<HostEventModal
|
||||
onClose={handleHostModalClose}
|
||||
isOpen={isHostModalOpen}
|
||||
hostEvent={hostEvent}
|
||||
/>
|
||||
)}
|
||||
<OutputHeader>
|
||||
<HeaderTitle>
|
||||
<StatusIcon status={job.status} />
|
||||
<h1>{job.name}</h1>
|
||||
</HeaderTitle>
|
||||
<OutputToolbar
|
||||
job={job}
|
||||
jobStatus={jobStatus}
|
||||
onCancel={() => setShowCancelModal(true)}
|
||||
onDelete={deleteJob}
|
||||
isDeleteDisabled={isDeleting}
|
||||
/>
|
||||
</OutputHeader>
|
||||
<HostStatusBar counts={job.host_status_counts} />
|
||||
<SearchToolbar
|
||||
id="job_output-toolbar"
|
||||
clearAllFilters={handleRemoveAllSearchTerms}
|
||||
collapseListedFiltersBreakpoint="lg"
|
||||
clearFiltersButtonText={i18n._(t`Clear all filters`)}
|
||||
>
|
||||
<SearchToolbarContent>
|
||||
<ToolbarToggleGroup toggleIcon={<SearchIcon />} breakpoint="lg">
|
||||
<ToolbarItem variant="search-filter">
|
||||
{isJobRunning(job.status) ? (
|
||||
<Tooltip
|
||||
content={i18n._(
|
||||
t`Search is disabled while the job is running`
|
||||
)}
|
||||
<Tooltip
|
||||
content={i18n._(t`Job output documentation`)}
|
||||
position="bottom"
|
||||
>
|
||||
<Button
|
||||
component="a"
|
||||
variant="plain"
|
||||
target="_blank"
|
||||
href={`${getDocsBaseUrl(
|
||||
config
|
||||
)}/html/userguide/jobs.html#standard-out-pane`}
|
||||
>
|
||||
<QuestionCircleIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</ToolbarItem>
|
||||
</ToolbarToggleGroup>
|
||||
</SearchToolbarContent>
|
||||
</SearchToolbar>
|
||||
<PageControls
|
||||
onScrollFirst={handleScrollFirst}
|
||||
onScrollLast={handleScrollLast}
|
||||
onScrollNext={handleScrollNext}
|
||||
onScrollPrevious={handleScrollPrevious}
|
||||
/>
|
||||
<OutputWrapper cssMap={cssMap}>
|
||||
<InfiniteLoader
|
||||
isRowLoaded={isRowLoaded}
|
||||
loadMoreRows={loadMoreRows}
|
||||
rowCount={remoteRowCount}
|
||||
>
|
||||
{({ onRowsRendered, registerChild }) => (
|
||||
<AutoSizer nonce={window.NONCE_ID} onResize={handleResize}>
|
||||
{({ width, height }) => {
|
||||
return (
|
||||
<>
|
||||
{hasContentLoading ? (
|
||||
<div style={{ width }}>
|
||||
<ContentLoading />
|
||||
</div>
|
||||
) : (
|
||||
<List
|
||||
ref={ref => {
|
||||
registerChild(ref);
|
||||
listRef.current = ref;
|
||||
}}
|
||||
deferredMeasurementCache={cache}
|
||||
height={height || 1}
|
||||
onRowsRendered={onRowsRendered}
|
||||
rowCount={remoteRowCount}
|
||||
rowHeight={cache.rowHeight}
|
||||
rowRenderer={rowRenderer}
|
||||
scrollToAlignment="start"
|
||||
width={width || 1}
|
||||
overscanRowCount={20}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
>
|
||||
{renderSearchComponent(i18n)}
|
||||
</Tooltip>
|
||||
) : (
|
||||
renderSearchComponent(i18n)
|
||||
)}
|
||||
</InfiniteLoader>
|
||||
<OutputFooter />
|
||||
</OutputWrapper>
|
||||
</CardBody>
|
||||
{showCancelModal && isJobRunning(job.status) && (
|
||||
<AlertModal
|
||||
isOpen={showCancelModal}
|
||||
</ToolbarItem>
|
||||
</ToolbarToggleGroup>
|
||||
</SearchToolbarContent>
|
||||
</SearchToolbar>
|
||||
<PageControls
|
||||
onScrollFirst={handleScrollFirst}
|
||||
onScrollLast={handleScrollLast}
|
||||
onScrollNext={handleScrollNext}
|
||||
onScrollPrevious={handleScrollPrevious}
|
||||
/>
|
||||
<OutputWrapper cssMap={cssMap}>
|
||||
<InfiniteLoader
|
||||
isRowLoaded={isRowLoaded}
|
||||
loadMoreRows={loadMoreRows}
|
||||
rowCount={remoteRowCount}
|
||||
>
|
||||
{({ onRowsRendered, registerChild }) => (
|
||||
<AutoSizer nonce={window.NONCE_ID} onResize={handleResize}>
|
||||
{({ width, height }) => {
|
||||
return (
|
||||
<>
|
||||
{hasContentLoading ? (
|
||||
<div style={{ width }}>
|
||||
<ContentLoading />
|
||||
</div>
|
||||
) : (
|
||||
<List
|
||||
ref={ref => {
|
||||
registerChild(ref);
|
||||
listRef.current = ref;
|
||||
}}
|
||||
deferredMeasurementCache={cache}
|
||||
height={height || 1}
|
||||
onRowsRendered={onRowsRendered}
|
||||
rowCount={remoteRowCount}
|
||||
rowHeight={cache.rowHeight}
|
||||
rowRenderer={rowRenderer}
|
||||
scrollToAlignment="start"
|
||||
width={width || 1}
|
||||
overscanRowCount={20}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
)}
|
||||
</InfiniteLoader>
|
||||
<OutputFooter />
|
||||
</OutputWrapper>
|
||||
</CardBody>
|
||||
{showCancelModal && isJobRunning(job.status) && (
|
||||
<AlertModal
|
||||
isOpen={showCancelModal}
|
||||
variant="danger"
|
||||
onClose={() => setShowCancelModal(false)}
|
||||
title={i18n._(t`Cancel Job`)}
|
||||
label={i18n._(t`Cancel Job`)}
|
||||
actions={[
|
||||
<Button
|
||||
id="cancel-job-confirm-button"
|
||||
key="delete"
|
||||
variant="danger"
|
||||
onClose={() => setShowCancelModal(false)}
|
||||
title={i18n._(t`Cancel Job`)}
|
||||
label={i18n._(t`Cancel Job`)}
|
||||
actions={[
|
||||
<Button
|
||||
id="cancel-job-confirm-button"
|
||||
key="delete"
|
||||
variant="danger"
|
||||
isDisabled={isCancelling}
|
||||
aria-label={i18n._(t`Cancel job`)}
|
||||
onClick={cancelJob}
|
||||
>
|
||||
{i18n._(t`Cancel job`)}
|
||||
</Button>,
|
||||
<Button
|
||||
id="cancel-job-return-button"
|
||||
key="cancel"
|
||||
variant="secondary"
|
||||
aria-label={i18n._(t`Return`)}
|
||||
onClick={() => setShowCancelModal(false)}
|
||||
>
|
||||
{i18n._(t`Return`)}
|
||||
</Button>,
|
||||
]}
|
||||
isDisabled={isCancelling}
|
||||
aria-label={i18n._(t`Cancel job`)}
|
||||
onClick={cancelJob}
|
||||
>
|
||||
{i18n._(
|
||||
t`Are you sure you want to submit the request to cancel this job?`
|
||||
)}
|
||||
</AlertModal>
|
||||
)}
|
||||
{dismissableDeleteError && (
|
||||
<AlertModal
|
||||
isOpen={dismissableDeleteError}
|
||||
variant="danger"
|
||||
onClose={dismissDeleteError}
|
||||
title={i18n._(t`Job Delete Error`)}
|
||||
label={i18n._(t`Job Delete Error`)}
|
||||
{i18n._(t`Cancel job`)}
|
||||
</Button>,
|
||||
<Button
|
||||
id="cancel-job-return-button"
|
||||
key="cancel"
|
||||
variant="secondary"
|
||||
aria-label={i18n._(t`Return`)}
|
||||
onClick={() => setShowCancelModal(false)}
|
||||
>
|
||||
<ErrorDetail error={dismissableDeleteError} />
|
||||
</AlertModal>
|
||||
{i18n._(t`Return`)}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
{i18n._(
|
||||
t`Are you sure you want to submit the request to cancel this job?`
|
||||
)}
|
||||
{dismissableCancelError && (
|
||||
<AlertModal
|
||||
isOpen={dismissableCancelError}
|
||||
variant="danger"
|
||||
onClose={dismissCancelError}
|
||||
title={i18n._(t`Job Cancel Error`)}
|
||||
label={i18n._(t`Job Cancel Error`)}
|
||||
>
|
||||
<ErrorDetail error={dismissableCancelError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</>
|
||||
</AlertModal>
|
||||
)}
|
||||
</I18n>
|
||||
{dismissableDeleteError && (
|
||||
<AlertModal
|
||||
isOpen={dismissableDeleteError}
|
||||
variant="danger"
|
||||
onClose={dismissDeleteError}
|
||||
title={i18n._(t`Job Delete Error`)}
|
||||
label={i18n._(t`Job Delete Error`)}
|
||||
>
|
||||
<ErrorDetail error={dismissableDeleteError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
{dismissableCancelError && (
|
||||
<AlertModal
|
||||
isOpen={dismissableCancelError}
|
||||
variant="danger"
|
||||
onClose={dismissCancelError}
|
||||
title={i18n._(t`Job Cancel Error`)}
|
||||
label={i18n._(t`Job Cancel Error`)}
|
||||
>
|
||||
<ErrorDetail error={dismissableCancelError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -82,7 +82,13 @@ describe('<JobOutput />', () => {
|
||||
let wrapper;
|
||||
const mockJob = mockJobData;
|
||||
const mockJobEvents = mockJobEventsData;
|
||||
beforeAll(() => {
|
||||
jest.setTimeout(5000 * 4);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.setTimeout(5000);
|
||||
});
|
||||
beforeEach(() => {
|
||||
JobsAPI.readEvents.mockResolvedValue({
|
||||
data: {
|
||||
@ -259,6 +265,7 @@ describe('<JobOutput />', () => {
|
||||
});
|
||||
|
||||
test('filter should trigger api call and display correct rows', async () => {
|
||||
jest.setTimeout(5000 * 4);
|
||||
const searchBtn = 'button[aria-label="Search submit button"]';
|
||||
const searchTextInput = 'input[aria-label="Search text input"]';
|
||||
await act(async () => {
|
||||
|
||||
@ -73,8 +73,7 @@ function OrganizationDetail({ i18n, organization }) {
|
||||
const { error, dismissError } = useDismissableError(deleteError);
|
||||
|
||||
const deleteDetailsRequests = relatedResourceDeleteRequests.organization(
|
||||
organization,
|
||||
i18n
|
||||
organization
|
||||
);
|
||||
|
||||
if (hasContentLoading) {
|
||||
|
||||
@ -118,8 +118,7 @@ function OrganizationsList({ i18n }) {
|
||||
}
|
||||
};
|
||||
const deleteDetailsRequests = relatedResourceDeleteRequests.organization(
|
||||
selected[0],
|
||||
i18n
|
||||
selected[0]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@ -2,14 +2,21 @@ import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
|
||||
import { en } from 'make-plural/plurals';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
|
||||
import english from '../../../locales/en/messages';
|
||||
import OrganizationListItem from './OrganizationListItem';
|
||||
|
||||
i18n.loadLocaleData({ en: { plurals: en } });
|
||||
i18n.load({ en: english });
|
||||
i18n.activate('en');
|
||||
|
||||
describe('<OrganizationListItem />', () => {
|
||||
test('initially renders successfully', () => {
|
||||
mountWithContexts(
|
||||
<I18nProvider>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<MemoryRouter initialEntries={['/organizations']} initialIndex={0}>
|
||||
<table>
|
||||
<tbody>
|
||||
@ -40,7 +47,7 @@ describe('<OrganizationListItem />', () => {
|
||||
|
||||
test('edit button shown to users with edit capabilities', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<I18nProvider>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<MemoryRouter initialEntries={['/organizations']} initialIndex={0}>
|
||||
<table>
|
||||
<tbody>
|
||||
@ -72,7 +79,7 @@ describe('<OrganizationListItem />', () => {
|
||||
|
||||
test('edit button hidden from users without edit capabilities', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<I18nProvider>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<MemoryRouter initialEntries={['/organizations']} initialIndex={0}>
|
||||
<table>
|
||||
<tbody>
|
||||
|
||||
@ -53,10 +53,7 @@ function ProjectDetail({ project, i18n }) {
|
||||
);
|
||||
|
||||
const { error, dismissError } = useDismissableError(deleteError);
|
||||
const deleteDetailsRequests = relatedResourceDeleteRequests.project(
|
||||
project,
|
||||
i18n
|
||||
);
|
||||
const deleteDetailsRequests = relatedResourceDeleteRequests.project(project);
|
||||
let optionsList = '';
|
||||
if (
|
||||
scm_clean ||
|
||||
|
||||
@ -118,8 +118,7 @@ function ProjectList({ i18n }) {
|
||||
};
|
||||
|
||||
const deleteDetailsRequests = relatedResourceDeleteRequests.project(
|
||||
selected[0],
|
||||
i18n
|
||||
selected[0]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { bool, oneOf, shape, string } from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { shape, string } from 'prop-types';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { useField } from 'formik';
|
||||
import {
|
||||
@ -35,20 +35,20 @@ const FormGroup = styled(PFFormGroup)`
|
||||
}
|
||||
`;
|
||||
|
||||
const SettingGroup = withI18n()(
|
||||
({
|
||||
i18n,
|
||||
children,
|
||||
defaultValue,
|
||||
fieldId,
|
||||
helperTextInvalid,
|
||||
isDisabled,
|
||||
isRequired,
|
||||
label,
|
||||
onRevertCallback,
|
||||
popoverContent,
|
||||
validated,
|
||||
}) => (
|
||||
const SettingGroup = ({
|
||||
children,
|
||||
defaultValue,
|
||||
fieldId,
|
||||
helperTextInvalid,
|
||||
isDisabled,
|
||||
isRequired,
|
||||
label,
|
||||
onRevertCallback,
|
||||
popoverContent,
|
||||
validated,
|
||||
}) => {
|
||||
const { i18n } = useLingui();
|
||||
return (
|
||||
<FormGroup
|
||||
fieldId={fieldId}
|
||||
helperTextInvalid={helperTextInvalid}
|
||||
@ -73,43 +73,42 @@ const SettingGroup = withI18n()(
|
||||
>
|
||||
{children}
|
||||
</FormGroup>
|
||||
)
|
||||
);
|
||||
);
|
||||
};
|
||||
const BooleanField = ({ ariaLabel = '', name, config, disabled = false }) => {
|
||||
const [field, meta, helpers] = useField(name);
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const BooleanField = withI18n()(
|
||||
({ i18n, ariaLabel = '', name, config, disabled = false }) => {
|
||||
const [field, meta, helpers] = useField(name);
|
||||
return config ? (
|
||||
<SettingGroup
|
||||
defaultValue={config.default ?? false}
|
||||
fieldId={name}
|
||||
helperTextInvalid={meta.error}
|
||||
return config ? (
|
||||
<SettingGroup
|
||||
defaultValue={config.default ?? false}
|
||||
fieldId={name}
|
||||
helperTextInvalid={meta.error}
|
||||
isDisabled={disabled}
|
||||
label={config.label}
|
||||
popoverContent={config.help_text}
|
||||
>
|
||||
<Switch
|
||||
id={name}
|
||||
ouiaId={name}
|
||||
isChecked={field.value}
|
||||
isDisabled={disabled}
|
||||
label={config.label}
|
||||
popoverContent={config.help_text}
|
||||
>
|
||||
<Switch
|
||||
id={name}
|
||||
ouiaId={name}
|
||||
isChecked={field.value}
|
||||
isDisabled={disabled}
|
||||
label={i18n._(t`On`)}
|
||||
labelOff={i18n._(t`Off`)}
|
||||
onChange={checked => helpers.setValue(checked)}
|
||||
aria-label={ariaLabel || config.label}
|
||||
/>
|
||||
</SettingGroup>
|
||||
) : null;
|
||||
}
|
||||
);
|
||||
label={i18n._(t`On`)}
|
||||
labelOff={i18n._(t`Off`)}
|
||||
onChange={checked => helpers.setValue(checked)}
|
||||
aria-label={ariaLabel || config.label}
|
||||
/>
|
||||
</SettingGroup>
|
||||
) : null;
|
||||
};
|
||||
BooleanField.propTypes = {
|
||||
name: string.isRequired,
|
||||
config: shape({}).isRequired,
|
||||
ariaLabel: string,
|
||||
disabled: bool,
|
||||
};
|
||||
|
||||
const ChoiceField = withI18n()(({ i18n, name, config, isRequired = false }) => {
|
||||
const ChoiceField = ({ name, config, isRequired = false }) => {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const validate = isRequired ? required(null, i18n) : null;
|
||||
const [field, meta] = useField({ name, validate });
|
||||
const isValid = !meta.error || !meta.touched;
|
||||
@ -137,133 +136,130 @@ const ChoiceField = withI18n()(({ i18n, name, config, isRequired = false }) => {
|
||||
/>
|
||||
</SettingGroup>
|
||||
) : null;
|
||||
});
|
||||
};
|
||||
ChoiceField.propTypes = {
|
||||
name: string.isRequired,
|
||||
config: shape({}).isRequired,
|
||||
isRequired: bool,
|
||||
};
|
||||
|
||||
const EncryptedField = withI18n()(
|
||||
({ i18n, name, config, isRequired = false }) => {
|
||||
const validate = isRequired ? required(null, i18n) : null;
|
||||
const [, meta] = useField({ name, validate });
|
||||
const isValid = !(meta.touched && meta.error);
|
||||
const EncryptedField = ({ name, config, isRequired = false }) => {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
return config ? (
|
||||
<SettingGroup
|
||||
defaultValue={config.default ?? ''}
|
||||
fieldId={name}
|
||||
helperTextInvalid={meta.error}
|
||||
isRequired={isRequired}
|
||||
label={config.label}
|
||||
popoverContent={config.help_text}
|
||||
validated={isValid ? 'default' : 'error'}
|
||||
>
|
||||
<InputGroup>
|
||||
<PasswordInput
|
||||
id={name}
|
||||
name={name}
|
||||
label={config.label}
|
||||
validate={validate}
|
||||
isRequired={isRequired}
|
||||
/>
|
||||
</InputGroup>
|
||||
</SettingGroup>
|
||||
) : null;
|
||||
}
|
||||
);
|
||||
const validate = isRequired ? required(null, i18n) : null;
|
||||
const [, meta] = useField({ name, validate });
|
||||
const isValid = !(meta.touched && meta.error);
|
||||
|
||||
return config ? (
|
||||
<SettingGroup
|
||||
defaultValue={config.default ?? ''}
|
||||
fieldId={name}
|
||||
helperTextInvalid={meta.error}
|
||||
isRequired={isRequired}
|
||||
label={config.label}
|
||||
popoverContent={config.help_text}
|
||||
validated={isValid ? 'default' : 'error'}
|
||||
>
|
||||
<InputGroup>
|
||||
<PasswordInput
|
||||
id={name}
|
||||
name={name}
|
||||
label={config.label}
|
||||
validate={validate}
|
||||
isRequired={isRequired}
|
||||
/>
|
||||
</InputGroup>
|
||||
</SettingGroup>
|
||||
) : null;
|
||||
};
|
||||
EncryptedField.propTypes = {
|
||||
name: string.isRequired,
|
||||
config: shape({}).isRequired,
|
||||
isRequired: bool,
|
||||
};
|
||||
|
||||
const InputField = withI18n()(
|
||||
({ i18n, name, config, type = 'text', isRequired = false }) => {
|
||||
const min_value = config?.min_value ?? Number.MIN_SAFE_INTEGER;
|
||||
const max_value = config?.max_value ?? Number.MAX_SAFE_INTEGER;
|
||||
const validators = [
|
||||
...(isRequired ? [required(null, i18n)] : []),
|
||||
...(type === 'url' ? [url(i18n)] : []),
|
||||
...(type === 'number'
|
||||
? [integer(i18n), minMaxValue(min_value, max_value, i18n)]
|
||||
: []),
|
||||
];
|
||||
const [field, meta] = useField({ name, validate: combine(validators) });
|
||||
const isValid = !(meta.touched && meta.error);
|
||||
const InputField = ({ name, config, type = 'text', isRequired = false }) => {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
return config ? (
|
||||
<SettingGroup
|
||||
defaultValue={config.default ?? ''}
|
||||
fieldId={name}
|
||||
helperTextInvalid={meta.error}
|
||||
const min_value = config?.min_value ?? Number.MIN_SAFE_INTEGER;
|
||||
const max_value = config?.max_value ?? Number.MAX_SAFE_INTEGER;
|
||||
const validators = [
|
||||
...(isRequired ? [required(null, i18n)] : []),
|
||||
...(type === 'url' ? [url(i18n)] : []),
|
||||
...(type === 'number'
|
||||
? [integer(i18n), minMaxValue(min_value, max_value, i18n)]
|
||||
: []),
|
||||
];
|
||||
const [field, meta] = useField({ name, validate: combine(validators) });
|
||||
const isValid = !(meta.touched && meta.error);
|
||||
|
||||
return config ? (
|
||||
<SettingGroup
|
||||
defaultValue={config.default ?? ''}
|
||||
fieldId={name}
|
||||
helperTextInvalid={meta.error}
|
||||
isRequired={isRequired}
|
||||
label={config.label}
|
||||
popoverContent={config.help_text}
|
||||
validated={isValid ? 'default' : 'error'}
|
||||
>
|
||||
<TextInput
|
||||
id={name}
|
||||
isRequired={isRequired}
|
||||
label={config.label}
|
||||
popoverContent={config.help_text}
|
||||
placeholder={config.placeholder}
|
||||
validated={isValid ? 'default' : 'error'}
|
||||
>
|
||||
<TextInput
|
||||
id={name}
|
||||
isRequired={isRequired}
|
||||
placeholder={config.placeholder}
|
||||
validated={isValid ? 'default' : 'error'}
|
||||
value={field.value}
|
||||
onBlur={field.onBlur}
|
||||
onChange={(value, event) => {
|
||||
field.onChange(event);
|
||||
}}
|
||||
/>
|
||||
</SettingGroup>
|
||||
) : null;
|
||||
}
|
||||
);
|
||||
value={field.value}
|
||||
onBlur={field.onBlur}
|
||||
onChange={(value, event) => {
|
||||
field.onChange(event);
|
||||
}}
|
||||
/>
|
||||
</SettingGroup>
|
||||
) : null;
|
||||
};
|
||||
InputField.propTypes = {
|
||||
name: string.isRequired,
|
||||
config: shape({}).isRequired,
|
||||
type: oneOf(['text', 'number', 'url']),
|
||||
isRequired: bool,
|
||||
};
|
||||
|
||||
const TextAreaField = withI18n()(
|
||||
({ i18n, name, config, isRequired = false }) => {
|
||||
const validate = isRequired ? required(null, i18n) : null;
|
||||
const [field, meta] = useField({ name, validate });
|
||||
const isValid = !(meta.touched && meta.error);
|
||||
const TextAreaField = ({ name, config, isRequired = false }) => {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
return config ? (
|
||||
<SettingGroup
|
||||
defaultValue={config.default || ''}
|
||||
fieldId={name}
|
||||
helperTextInvalid={meta.error}
|
||||
const validate = isRequired ? required(null, i18n) : null;
|
||||
const [field, meta] = useField({ name, validate });
|
||||
const isValid = !(meta.touched && meta.error);
|
||||
|
||||
return config ? (
|
||||
<SettingGroup
|
||||
defaultValue={config.default || ''}
|
||||
fieldId={name}
|
||||
helperTextInvalid={meta.error}
|
||||
isRequired={isRequired}
|
||||
label={config.label}
|
||||
popoverContent={config.help_text}
|
||||
validated={isValid ? 'default' : 'error'}
|
||||
>
|
||||
<TextArea
|
||||
id={name}
|
||||
isRequired={isRequired}
|
||||
label={config.label}
|
||||
popoverContent={config.help_text}
|
||||
placeholder={config.placeholder}
|
||||
validated={isValid ? 'default' : 'error'}
|
||||
>
|
||||
<TextArea
|
||||
id={name}
|
||||
isRequired={isRequired}
|
||||
placeholder={config.placeholder}
|
||||
validated={isValid ? 'default' : 'error'}
|
||||
value={field.value}
|
||||
onBlur={field.onBlur}
|
||||
onChange={(value, event) => {
|
||||
field.onChange(event);
|
||||
}}
|
||||
resizeOrientation="vertical"
|
||||
/>
|
||||
</SettingGroup>
|
||||
) : null;
|
||||
}
|
||||
);
|
||||
value={field.value}
|
||||
onBlur={field.onBlur}
|
||||
onChange={(value, event) => {
|
||||
field.onChange(event);
|
||||
}}
|
||||
resizeOrientation="vertical"
|
||||
/>
|
||||
</SettingGroup>
|
||||
) : null;
|
||||
};
|
||||
TextAreaField.propTypes = {
|
||||
name: string.isRequired,
|
||||
config: shape({}).isRequired,
|
||||
isRequired: bool,
|
||||
};
|
||||
|
||||
const ObjectField = withI18n()(({ i18n, name, config, isRequired = false }) => {
|
||||
const ObjectField = ({ name, config, isRequired = false }) => {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const validate = isRequired ? required(null, i18n) : null;
|
||||
const [field, meta, helpers] = useField({ name, validate });
|
||||
const isValid = !(meta.touched && meta.error);
|
||||
@ -297,76 +293,79 @@ const ObjectField = withI18n()(({ i18n, name, config, isRequired = false }) => {
|
||||
</SettingGroup>
|
||||
</FormFullWidthLayout>
|
||||
) : null;
|
||||
});
|
||||
};
|
||||
ObjectField.propTypes = {
|
||||
name: string.isRequired,
|
||||
config: shape({}).isRequired,
|
||||
isRequired: bool,
|
||||
};
|
||||
|
||||
const FileUploadIconWrapper = styled.div`
|
||||
margin: var(--pf-global--spacer--md);
|
||||
`;
|
||||
const FileUploadField = withI18n()(
|
||||
({ i18n, name, config, type = 'text', isRequired = false }) => {
|
||||
const validate = isRequired ? required(null, i18n) : null;
|
||||
const [filename, setFilename] = useState('');
|
||||
const [fileIsUploading, setFileIsUploading] = useState(false);
|
||||
const [field, meta, helpers] = useField({ name, validate });
|
||||
const isValid = !(meta.touched && meta.error);
|
||||
const FileUploadField = ({
|
||||
name,
|
||||
config,
|
||||
type = 'text',
|
||||
isRequired = false,
|
||||
}) => {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
return config ? (
|
||||
<FormFullWidthLayout>
|
||||
<SettingGroup
|
||||
defaultValue={config.default ?? ''}
|
||||
fieldId={name}
|
||||
helperTextInvalid={meta.error}
|
||||
isRequired={isRequired}
|
||||
label={config.label}
|
||||
popoverContent={config.help_text}
|
||||
const validate = isRequired ? required(null, i18n) : null;
|
||||
const [filename, setFilename] = useState('');
|
||||
const [fileIsUploading, setFileIsUploading] = useState(false);
|
||||
const [field, meta, helpers] = useField({ name, validate });
|
||||
const isValid = !(meta.touched && meta.error);
|
||||
|
||||
return config ? (
|
||||
<FormFullWidthLayout>
|
||||
<SettingGroup
|
||||
defaultValue={config.default ?? ''}
|
||||
fieldId={name}
|
||||
helperTextInvalid={meta.error}
|
||||
isRequired={isRequired}
|
||||
label={config.label}
|
||||
popoverContent={config.help_text}
|
||||
validated={isValid ? 'default' : 'error'}
|
||||
onRevertCallback={() => setFilename('')}
|
||||
>
|
||||
<FileUpload
|
||||
{...field}
|
||||
id={name}
|
||||
type={type}
|
||||
filename={filename}
|
||||
onChange={(value, title) => {
|
||||
helpers.setValue(value);
|
||||
setFilename(title);
|
||||
}}
|
||||
onReadStarted={() => setFileIsUploading(true)}
|
||||
onReadFinished={() => setFileIsUploading(false)}
|
||||
isLoading={fileIsUploading}
|
||||
allowEditingUploadedText
|
||||
validated={isValid ? 'default' : 'error'}
|
||||
onRevertCallback={() => setFilename('')}
|
||||
hideDefaultPreview={type === 'dataURL'}
|
||||
>
|
||||
<FileUpload
|
||||
{...field}
|
||||
id={name}
|
||||
type={type}
|
||||
filename={filename}
|
||||
onChange={(value, title) => {
|
||||
helpers.setValue(value);
|
||||
setFilename(title);
|
||||
}}
|
||||
onReadStarted={() => setFileIsUploading(true)}
|
||||
onReadFinished={() => setFileIsUploading(false)}
|
||||
isLoading={fileIsUploading}
|
||||
allowEditingUploadedText
|
||||
validated={isValid ? 'default' : 'error'}
|
||||
hideDefaultPreview={type === 'dataURL'}
|
||||
>
|
||||
{type === 'dataURL' && (
|
||||
<FileUploadIconWrapper>
|
||||
{field.value ? (
|
||||
<img
|
||||
src={field.value}
|
||||
alt={filename}
|
||||
height="200px"
|
||||
width="200px"
|
||||
/>
|
||||
) : (
|
||||
<FileUploadIcon size="lg" />
|
||||
)}
|
||||
</FileUploadIconWrapper>
|
||||
)}
|
||||
</FileUpload>
|
||||
</SettingGroup>
|
||||
</FormFullWidthLayout>
|
||||
) : null;
|
||||
}
|
||||
);
|
||||
{type === 'dataURL' && (
|
||||
<FileUploadIconWrapper>
|
||||
{field.value ? (
|
||||
<img
|
||||
src={field.value}
|
||||
alt={filename}
|
||||
height="200px"
|
||||
width="200px"
|
||||
/>
|
||||
) : (
|
||||
<FileUploadIcon size="lg" />
|
||||
)}
|
||||
</FileUploadIconWrapper>
|
||||
)}
|
||||
</FileUpload>
|
||||
</SettingGroup>
|
||||
</FormFullWidthLayout>
|
||||
) : null;
|
||||
};
|
||||
FileUploadField.propTypes = {
|
||||
name: string.isRequired,
|
||||
config: shape({}).isRequired,
|
||||
isRequired: bool,
|
||||
};
|
||||
|
||||
export {
|
||||
|
||||
@ -3,6 +3,7 @@ import { mount } from 'enzyme';
|
||||
import { Formik } from 'formik';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import {
|
||||
BooleanField,
|
||||
@ -13,13 +14,17 @@ import {
|
||||
ObjectField,
|
||||
TextAreaField,
|
||||
} from './SharedFields';
|
||||
import en from '../../../locales/en/messages';
|
||||
|
||||
describe('Setting form fields', () => {
|
||||
test('BooleanField renders the expected content', async () => {
|
||||
const outerNode = document.createElement('div');
|
||||
document.body.appendChild(outerNode);
|
||||
i18n.loadLocaleData({ en: { plurals: en } });
|
||||
i18n.load({ en });
|
||||
i18n.activate('en');
|
||||
const wrapper = mount(
|
||||
<I18nProvider>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<Formik
|
||||
initialValues={{
|
||||
boolean: true,
|
||||
@ -244,7 +249,10 @@ describe('Setting form fields', () => {
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
expect(wrapper.find('FileUploadField')).toHaveLength(1);
|
||||
|
||||
expect(
|
||||
wrapper.find('FileUploadField[value="mock file value"]')
|
||||
).toHaveLength(1);
|
||||
expect(wrapper.find('label').text()).toEqual('mock file label');
|
||||
expect(wrapper.find('input#mock_file-filename').prop('value')).toEqual('');
|
||||
await act(async () => {
|
||||
|
||||
@ -2,14 +2,21 @@ import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
|
||||
import { i18n } from '@lingui/core';
|
||||
import { en } from 'make-plural/plurals';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
|
||||
import TeamListItem from './TeamListItem';
|
||||
import english from '../../../locales/en/messages';
|
||||
|
||||
i18n.loadLocaleData({ en: { plurals: en } });
|
||||
i18n.load({ en: english });
|
||||
i18n.activate('en');
|
||||
|
||||
describe('<TeamListItem />', () => {
|
||||
test('initially renders succesfully', () => {
|
||||
mountWithContexts(
|
||||
<I18nProvider>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<MemoryRouter initialEntries={['/teams']} initialIndex={0}>
|
||||
<table>
|
||||
<tbody>
|
||||
@ -35,7 +42,7 @@ describe('<TeamListItem />', () => {
|
||||
});
|
||||
test('edit button shown to users with edit capabilities', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<I18nProvider>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<MemoryRouter initialEntries={['/teams']} initialIndex={0}>
|
||||
<table>
|
||||
<tbody>
|
||||
@ -62,7 +69,7 @@ describe('<TeamListItem />', () => {
|
||||
});
|
||||
test('edit button hidden from users without edit capabilities', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<I18nProvider>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<MemoryRouter initialEntries={['/teams']} initialIndex={0}>
|
||||
<table>
|
||||
<tbody>
|
||||
|
||||
@ -98,8 +98,7 @@ function JobTemplateDetail({ i18n, template }) {
|
||||
const { error, dismissError } = useDismissableError(deleteError);
|
||||
|
||||
const deleteDetailsRequests = relatedResourceDeleteRequests.template(
|
||||
template,
|
||||
i18n
|
||||
template
|
||||
);
|
||||
const canLaunch =
|
||||
summary_fields.user_capabilities && summary_fields.user_capabilities.start;
|
||||
|
||||
@ -104,8 +104,7 @@ function WorkflowJobTemplateDetail({ template, i18n }) {
|
||||
}));
|
||||
|
||||
const deleteDetailsRequests = relatedResourceDeleteRequests.template(
|
||||
template,
|
||||
i18n
|
||||
template
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@ -2,10 +2,17 @@ import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
|
||||
import { i18n } from '@lingui/core';
|
||||
import { en } from 'make-plural/plurals';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
|
||||
import mockDetails from '../data.user.json';
|
||||
import UserListItem from './UserListItem';
|
||||
import english from '../../../locales/en/messages';
|
||||
|
||||
i18n.loadLocaleData({ en: { plurals: en } });
|
||||
i18n.load({ en: english });
|
||||
i18n.activate('en');
|
||||
|
||||
let wrapper;
|
||||
|
||||
@ -16,7 +23,7 @@ afterEach(() => {
|
||||
describe('UserListItem with full permissions', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<I18nProvider>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<MemoryRouter initialEntries={['/users']} initialIndex={0}>
|
||||
<table>
|
||||
<tbody>
|
||||
@ -52,7 +59,7 @@ describe('UserListItem with full permissions', () => {
|
||||
describe('UserListItem without full permissions', () => {
|
||||
test('edit button hidden from users without edit capabilities', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<I18nProvider>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<MemoryRouter initialEntries={['/users']} initialIndex={0}>
|
||||
<table>
|
||||
<tbody>
|
||||
|
||||
@ -1,13 +1,20 @@
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { en } from 'make-plural/plurals';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import UserTeamListItem from './UserTeamListItem';
|
||||
import english from '../../../locales/en/messages';
|
||||
|
||||
i18n.loadLocaleData({ en: { plurals: en } });
|
||||
i18n.load({ en: english });
|
||||
i18n.activate('en');
|
||||
|
||||
describe('<UserTeamListItem />', () => {
|
||||
test('should render item', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<I18nProvider>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<MemoryRouter initialEntries={['/teams']} initialIndex={0}>
|
||||
<UserTeamListItem
|
||||
team={{
|
||||
|
||||
@ -10,6 +10,8 @@ import {
|
||||
WorkflowJobTemplatesAPI,
|
||||
WorkflowJobTemplateNodesAPI,
|
||||
CredentialsAPI,
|
||||
ExecutionEnvironmentsAPI,
|
||||
CredentialInputSourcesAPI,
|
||||
} from '../api';
|
||||
|
||||
jest.mock('../api/models/Credentials');
|
||||
@ -19,17 +21,8 @@ jest.mock('../api/models/JobTemplates');
|
||||
jest.mock('../api/models/Projects');
|
||||
jest.mock('../api/models/WorkflowJobTemplates');
|
||||
jest.mock('../api/models/WorkflowJobTemplateNodes');
|
||||
|
||||
const i18n = {
|
||||
_: key => {
|
||||
if (key.values) {
|
||||
Object.entries(key.values).forEach(([k, v]) => {
|
||||
key.id = key.id.replace(new RegExp(`\\{${k}\\}`), v);
|
||||
});
|
||||
}
|
||||
return key.id;
|
||||
},
|
||||
};
|
||||
jest.mock('../api/models/CredentialInputSources');
|
||||
jest.mock('../api/models/ExecutionEnvironments');
|
||||
|
||||
describe('delete details', () => {
|
||||
afterEach(() => {
|
||||
@ -38,7 +31,7 @@ describe('delete details', () => {
|
||||
|
||||
test('should call api for credentials list', () => {
|
||||
getRelatedResourceDeleteCounts(
|
||||
relatedResourceDeleteRequests.credential({ id: 1 }, i18n)
|
||||
relatedResourceDeleteRequests.credential({ id: 1 })
|
||||
);
|
||||
expect(InventoriesAPI.read).toBeCalledWith({
|
||||
insights_credential: 1,
|
||||
@ -52,7 +45,7 @@ describe('delete details', () => {
|
||||
|
||||
test('should call api for projects list', () => {
|
||||
getRelatedResourceDeleteCounts(
|
||||
relatedResourceDeleteRequests.project({ id: 1 }, i18n)
|
||||
relatedResourceDeleteRequests.project({ id: 1 })
|
||||
);
|
||||
expect(WorkflowJobTemplateNodesAPI.read).toBeCalledWith({
|
||||
unified_job_template: 1,
|
||||
@ -65,7 +58,7 @@ describe('delete details', () => {
|
||||
|
||||
test('should call api for templates list', () => {
|
||||
getRelatedResourceDeleteCounts(
|
||||
relatedResourceDeleteRequests.template({ id: 1 }, i18n)
|
||||
relatedResourceDeleteRequests.template({ id: 1 })
|
||||
);
|
||||
expect(WorkflowJobTemplateNodesAPI.read).toBeCalledWith({
|
||||
unified_job_template: 1,
|
||||
@ -74,7 +67,7 @@ describe('delete details', () => {
|
||||
|
||||
test('should call api for credential type list', () => {
|
||||
getRelatedResourceDeleteCounts(
|
||||
relatedResourceDeleteRequests.credentialType({ id: 1 }, i18n)
|
||||
relatedResourceDeleteRequests.credentialType({ id: 1 })
|
||||
);
|
||||
expect(CredentialsAPI.read).toBeCalledWith({
|
||||
credential_type__id: 1,
|
||||
@ -83,7 +76,7 @@ describe('delete details', () => {
|
||||
|
||||
test('should call api for inventory list', () => {
|
||||
getRelatedResourceDeleteCounts(
|
||||
relatedResourceDeleteRequests.inventory({ id: 1 }, i18n)
|
||||
relatedResourceDeleteRequests.inventory({ id: 1 })
|
||||
);
|
||||
expect(JobTemplatesAPI.read).toBeCalledWith({ inventory: 1 });
|
||||
expect(WorkflowJobTemplatesAPI.read).toBeCalledWith({
|
||||
@ -96,7 +89,7 @@ describe('delete details', () => {
|
||||
data: [{ inventory_source: 2 }],
|
||||
});
|
||||
await getRelatedResourceDeleteCounts(
|
||||
relatedResourceDeleteRequests.inventorySource(1, i18n)
|
||||
relatedResourceDeleteRequests.inventorySource(1)
|
||||
);
|
||||
expect(InventoriesAPI.updateSources).toBeCalledWith(1);
|
||||
expect(WorkflowJobTemplateNodesAPI.read).toBeCalledWith({
|
||||
@ -106,7 +99,7 @@ describe('delete details', () => {
|
||||
|
||||
test('should call api for organization list', async () => {
|
||||
getRelatedResourceDeleteCounts(
|
||||
relatedResourceDeleteRequests.organization({ id: 1 }, i18n)
|
||||
relatedResourceDeleteRequests.organization({ id: 1 })
|
||||
);
|
||||
expect(CredentialsAPI.read).toBeCalledWith({ organization: 1 });
|
||||
});
|
||||
@ -123,7 +116,7 @@ describe('delete details', () => {
|
||||
},
|
||||
});
|
||||
const { error } = await getRelatedResourceDeleteCounts(
|
||||
relatedResourceDeleteRequests.inventorySource(1, i18n)
|
||||
relatedResourceDeleteRequests.inventorySource(1)
|
||||
);
|
||||
|
||||
expect(InventoriesAPI.updateSources).toBeCalledWith(1);
|
||||
@ -131,14 +124,24 @@ describe('delete details', () => {
|
||||
});
|
||||
|
||||
test('should return proper results', async () => {
|
||||
JobTemplatesAPI.read.mockResolvedValue({ data: { count: 0 } });
|
||||
JobTemplatesAPI.read.mockResolvedValue({ data: { count: 1 } });
|
||||
|
||||
InventorySourcesAPI.read.mockResolvedValue({ data: { count: 10 } });
|
||||
CredentialInputSourcesAPI.read.mockResolvedValue({ data: { count: 20 } });
|
||||
ExecutionEnvironmentsAPI.read.mockResolvedValue({ data: { count: 30 } });
|
||||
ProjectsAPI.read.mockResolvedValue({ data: { count: 2 } });
|
||||
InventoriesAPI.read.mockResolvedValue({ data: { count: 3 } });
|
||||
InventorySourcesAPI.read.mockResolvedValue({ data: { count: 0 } });
|
||||
|
||||
const { results } = await getRelatedResourceDeleteCounts(
|
||||
relatedResourceDeleteRequests.credential({ id: 1 }, i18n)
|
||||
relatedResourceDeleteRequests.credential({ id: 1 })
|
||||
);
|
||||
expect(results).toEqual({ Projects: 2, Inventories: 3 });
|
||||
expect(results).toEqual({
|
||||
'Job Templates': 1,
|
||||
Projects: 2,
|
||||
Inventories: 3,
|
||||
'Inventory Sources': 10,
|
||||
'Credential Input Sources': 20,
|
||||
'Execution Environments': 30,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { i18n } from '@lingui/core';
|
||||
|
||||
import {
|
||||
UnifiedJobTemplatesAPI,
|
||||
@ -46,7 +47,7 @@ export async function getRelatedResourceDeleteCounts(requests) {
|
||||
}
|
||||
|
||||
export const relatedResourceDeleteRequests = {
|
||||
credential: (selected, i18n) => [
|
||||
credential: selected => [
|
||||
{
|
||||
request: () =>
|
||||
JobTemplatesAPI.read({
|
||||
@ -77,7 +78,7 @@ export const relatedResourceDeleteRequests = {
|
||||
CredentialInputSourcesAPI.read({
|
||||
source_credential: selected.id,
|
||||
}),
|
||||
label: i18n._(t`Credential`),
|
||||
label: i18n._(t`Credential Input Sources`),
|
||||
},
|
||||
{
|
||||
request: () =>
|
||||
@ -88,7 +89,7 @@ export const relatedResourceDeleteRequests = {
|
||||
},
|
||||
],
|
||||
|
||||
credentialType: (selected, i18n) => [
|
||||
credentialType: selected => [
|
||||
{
|
||||
request: async () =>
|
||||
CredentialsAPI.read({
|
||||
@ -98,7 +99,7 @@ export const relatedResourceDeleteRequests = {
|
||||
},
|
||||
],
|
||||
|
||||
inventory: (selected, i18n) => [
|
||||
inventory: selected => [
|
||||
{
|
||||
request: async () =>
|
||||
JobTemplatesAPI.read({
|
||||
@ -112,7 +113,7 @@ export const relatedResourceDeleteRequests = {
|
||||
},
|
||||
],
|
||||
|
||||
inventorySource: (inventoryId, i18n, inventorySource) => [
|
||||
inventorySource: (inventoryId, inventorySource) => [
|
||||
{
|
||||
request: async () => {
|
||||
try {
|
||||
@ -147,7 +148,7 @@ export const relatedResourceDeleteRequests = {
|
||||
},
|
||||
],
|
||||
|
||||
project: (selected, i18n) => [
|
||||
project: selected => [
|
||||
{
|
||||
request: () =>
|
||||
JobTemplatesAPI.read({
|
||||
@ -171,7 +172,7 @@ export const relatedResourceDeleteRequests = {
|
||||
},
|
||||
],
|
||||
|
||||
template: (selected, i18n) => [
|
||||
template: selected => [
|
||||
{
|
||||
request: async () =>
|
||||
WorkflowJobTemplateNodesAPI.read({
|
||||
@ -181,7 +182,7 @@ export const relatedResourceDeleteRequests = {
|
||||
},
|
||||
],
|
||||
|
||||
organization: (selected, i18n) => [
|
||||
organization: selected => [
|
||||
{
|
||||
request: async () =>
|
||||
CredentialsAPI.read({
|
||||
@ -232,7 +233,7 @@ export const relatedResourceDeleteRequests = {
|
||||
label: i18n._(t`Applications`),
|
||||
},
|
||||
],
|
||||
executionEnvironment: (selected, i18n) => [
|
||||
executionEnvironment: selected => [
|
||||
{
|
||||
request: async () =>
|
||||
UnifiedJobTemplatesAPI.read({
|
||||
@ -283,7 +284,7 @@ export const relatedResourceDeleteRequests = {
|
||||
label: [i18n._(t`Workflow Job Template Nodes`)],
|
||||
},
|
||||
],
|
||||
instanceGroup: (selected, i18n) => [
|
||||
instanceGroup: selected => [
|
||||
{
|
||||
request: () => OrganizationsAPI.read({ instance_groups: selected.id }),
|
||||
label: i18n._(t`Organizations`),
|
||||
|
||||
@ -20,9 +20,7 @@ describe('validators', () => {
|
||||
});
|
||||
|
||||
test('required returns default message if value missing', () => {
|
||||
expect(required(null, i18n)('')).toEqual({
|
||||
id: 'This field must not be blank',
|
||||
});
|
||||
expect(required(null, i18n)('')).toEqual('This field must not be blank');
|
||||
});
|
||||
|
||||
test('required returns custom message if value missing', () => {
|
||||
@ -30,18 +28,14 @@ describe('validators', () => {
|
||||
});
|
||||
|
||||
test('required interprets white space as empty value', () => {
|
||||
expect(required(null, i18n)(' ')).toEqual({
|
||||
id: 'This field must not be blank',
|
||||
});
|
||||
expect(required(null, i18n)('\t')).toEqual({
|
||||
id: 'This field must not be blank',
|
||||
});
|
||||
expect(required(null, i18n)(' ')).toEqual('This field must not be blank');
|
||||
expect(required(null, i18n)('\t')).toEqual('This field must not be blank');
|
||||
});
|
||||
|
||||
test('required interprets undefined as empty value', () => {
|
||||
expect(required(null, i18n)(undefined)).toEqual({
|
||||
id: 'This field must not be blank',
|
||||
});
|
||||
expect(required(null, i18n)(undefined)).toEqual(
|
||||
'This field must not be blank'
|
||||
);
|
||||
});
|
||||
|
||||
test('required interprets 0 as non-empty value', () => {
|
||||
@ -57,10 +51,9 @@ describe('validators', () => {
|
||||
});
|
||||
|
||||
test('maxLength rejects value above max', () => {
|
||||
expect(maxLength(8, i18n)('abracadbra')).toEqual({
|
||||
id: 'This field must not exceed {max} characters',
|
||||
values: { max: 8 },
|
||||
});
|
||||
expect(maxLength(8, i18n)('abracadbra')).toEqual(
|
||||
'This field must not exceed {max} characters'
|
||||
);
|
||||
});
|
||||
|
||||
test('minLength accepts value above min', () => {
|
||||
@ -72,22 +65,21 @@ describe('validators', () => {
|
||||
});
|
||||
|
||||
test('minLength rejects value below min', () => {
|
||||
expect(minLength(12, i18n)('abracadbra')).toEqual({
|
||||
id: 'This field must be at least {min} characters',
|
||||
values: { min: 12 },
|
||||
});
|
||||
expect(minLength(12, i18n)('abracadbra')).toEqual(
|
||||
'This field must be at least {min} characters'
|
||||
);
|
||||
});
|
||||
|
||||
test('noWhiteSpace returns error', () => {
|
||||
expect(noWhiteSpace(i18n)('this has spaces')).toEqual({
|
||||
id: 'This field must not contain spaces',
|
||||
});
|
||||
expect(noWhiteSpace(i18n)('this has\twhitespace')).toEqual({
|
||||
id: 'This field must not contain spaces',
|
||||
});
|
||||
expect(noWhiteSpace(i18n)('this\nhas\nnewlines')).toEqual({
|
||||
id: 'This field must not contain spaces',
|
||||
});
|
||||
expect(noWhiteSpace(i18n)('this has spaces')).toEqual(
|
||||
'This field must not contain spaces'
|
||||
);
|
||||
expect(noWhiteSpace(i18n)('this has\twhitespace')).toEqual(
|
||||
'This field must not contain spaces'
|
||||
);
|
||||
expect(noWhiteSpace(i18n)('this\nhas\nnewlines')).toEqual(
|
||||
'This field must not contain spaces'
|
||||
);
|
||||
});
|
||||
|
||||
test('noWhiteSpace should accept valid string', () => {
|
||||
@ -103,15 +95,11 @@ describe('validators', () => {
|
||||
});
|
||||
|
||||
test('integer should reject decimal/float', () => {
|
||||
expect(integer(i18n)(13.1)).toEqual({
|
||||
id: 'This field must be an integer',
|
||||
});
|
||||
expect(integer(i18n)(13.1)).toEqual('This field must be an integer');
|
||||
});
|
||||
|
||||
test('integer should reject string containing alphanum', () => {
|
||||
expect(integer(i18n)('15a')).toEqual({
|
||||
id: 'This field must be an integer',
|
||||
});
|
||||
expect(integer(i18n)('15a')).toEqual('This field must be an integer');
|
||||
});
|
||||
|
||||
test('number should accept number (number)', () => {
|
||||
@ -136,15 +124,11 @@ describe('validators', () => {
|
||||
});
|
||||
|
||||
test('number should reject string containing alphanum', () => {
|
||||
expect(number(i18n)('15a')).toEqual({
|
||||
id: 'This field must be a number',
|
||||
});
|
||||
expect(number(i18n)('15a')).toEqual('This field must be a number');
|
||||
});
|
||||
|
||||
test('url should reject incomplete url', () => {
|
||||
expect(url(i18n)('abcd')).toEqual({
|
||||
id: 'Please enter a valid URL',
|
||||
});
|
||||
expect(url(i18n)('abcd')).toEqual('Please enter a valid URL');
|
||||
});
|
||||
|
||||
test('url should accept fully qualified url', () => {
|
||||
@ -156,43 +140,37 @@ describe('validators', () => {
|
||||
});
|
||||
|
||||
test('url should reject short protocol', () => {
|
||||
expect(url(i18n)('h://example.com/foo')).toEqual({
|
||||
id: 'Please enter a valid URL',
|
||||
});
|
||||
expect(url(i18n)('h://example.com/foo')).toEqual(
|
||||
'Please enter a valid URL'
|
||||
);
|
||||
});
|
||||
|
||||
test('combine should run all validators', () => {
|
||||
const validators = [required(null, i18n), noWhiteSpace(i18n)];
|
||||
expect(combine(validators)('')).toEqual({
|
||||
id: 'This field must not be blank',
|
||||
});
|
||||
expect(combine(validators)('one two')).toEqual({
|
||||
id: 'This field must not contain spaces',
|
||||
});
|
||||
expect(combine(validators)('')).toEqual('This field must not be blank');
|
||||
expect(combine(validators)('one two')).toEqual(
|
||||
'This field must not contain spaces'
|
||||
);
|
||||
expect(combine(validators)('ok')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('combine should skip null validators', () => {
|
||||
const validators = [required(null, i18n), null];
|
||||
expect(combine(validators)('')).toEqual({
|
||||
id: 'This field must not be blank',
|
||||
});
|
||||
expect(combine(validators)('')).toEqual('This field must not be blank');
|
||||
expect(combine(validators)('ok')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('regExp rejects invalid regular expression', () => {
|
||||
expect(regExp(i18n)('[')).toEqual({
|
||||
id: 'This field must be a regular expression',
|
||||
});
|
||||
expect(regExp(i18n)('[')).toEqual(
|
||||
'This field must be a regular expression'
|
||||
);
|
||||
expect(regExp(i18n)('')).toBeUndefined();
|
||||
expect(regExp(i18n)('ok')).toBeUndefined();
|
||||
expect(regExp(i18n)('[^a-zA-Z]')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('email validator rejects obviously invalid email ', () => {
|
||||
expect(requiredEmail(i18n)('foobar321')).toEqual({
|
||||
id: 'Invalid email address',
|
||||
});
|
||||
expect(requiredEmail(i18n)('foobar321')).toEqual('Invalid email address');
|
||||
});
|
||||
|
||||
test('bob has email', () => {
|
||||
|
||||
@ -3,41 +3,20 @@
|
||||
* derived from https://lingui.js.org/guides/testing.html
|
||||
*/
|
||||
import React from 'react';
|
||||
import { shape, object, string, arrayOf } from 'prop-types';
|
||||
import { shape, string, arrayOf } from 'prop-types';
|
||||
import { mount, shallow } from 'enzyme';
|
||||
import { MemoryRouter, Router } from 'react-router-dom';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
import { ConfigProvider } from '../src/contexts/Config'
|
||||
import { i18n } from '@lingui/core';
|
||||
import { en } from 'make-plural/plurals';
|
||||
import english from '../src/locales/en/messages';
|
||||
import { ConfigProvider } from '../src/contexts/Config';
|
||||
|
||||
const language = 'en-US';
|
||||
const intlProvider = new I18nProvider(
|
||||
{
|
||||
language,
|
||||
catalogs: {
|
||||
[language]: {},
|
||||
},
|
||||
},
|
||||
{}
|
||||
);
|
||||
const {
|
||||
linguiPublisher: { i18n: originalI18n },
|
||||
} = intlProvider.getChildContext();
|
||||
i18n.loadLocaleData({ en: { plurals: en } });
|
||||
i18n.load({ en: english });
|
||||
i18n.activate('en');
|
||||
|
||||
const defaultContexts = {
|
||||
linguiPublisher: {
|
||||
i18n: {
|
||||
...originalI18n,
|
||||
_: key => {
|
||||
if (key.values) {
|
||||
Object.entries(key.values).forEach(([k, v]) => {
|
||||
key.id = key.id.replace(new RegExp(`\\{${k}\\}`), v);
|
||||
});
|
||||
}
|
||||
return key.id;
|
||||
}, // provide _ macro, for just passing down the key
|
||||
toJSON: () => '/i18n/',
|
||||
},
|
||||
},
|
||||
config: {
|
||||
ansible_version: null,
|
||||
custom_virtualenvs: [],
|
||||
@ -45,8 +24,8 @@ const defaultContexts = {
|
||||
me: { is_superuser: true },
|
||||
toJSON: () => '/config/',
|
||||
license_info: {
|
||||
valid_key: true
|
||||
}
|
||||
valid_key: true,
|
||||
},
|
||||
},
|
||||
router: {
|
||||
history_: {
|
||||
@ -89,15 +68,19 @@ function wrapContexts(node, context) {
|
||||
const component = React.cloneElement(children, props);
|
||||
if (router.history) {
|
||||
return (
|
||||
<ConfigProvider value={config}>
|
||||
<Router history={router.history}>{component}</Router>
|
||||
</ConfigProvider>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<ConfigProvider value={config}>
|
||||
<Router history={router.history}>{component}</Router>
|
||||
</ConfigProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ConfigProvider value={config}>
|
||||
<MemoryRouter>{component}</MemoryRouter>
|
||||
</ConfigProvider>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<ConfigProvider value={config}>
|
||||
<MemoryRouter>{component}</MemoryRouter>
|
||||
</ConfigProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -127,9 +110,6 @@ export function shallowWithContexts(node, options = {}) {
|
||||
export function mountWithContexts(node, options = {}) {
|
||||
const context = applyDefaultContexts(options.context);
|
||||
const childContextTypes = {
|
||||
linguiPublisher: shape({
|
||||
i18n: object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
}).isRequired,
|
||||
config: shape({
|
||||
ansible_version: string,
|
||||
custom_virtualenvs: arrayOf(string),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user