diff --git a/awx/ui_next/CONTRIBUTING.md b/awx/ui_next/CONTRIBUTING.md index 749dc8ab20..105935734f 100644 --- a/awx/ui_next/CONTRIBUTING.md +++ b/awx/ui_next/CONTRIBUTING.md @@ -112,7 +112,7 @@ afterEach(() => { ... ``` -**Test Attributes** - +**Test Attributes** - 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 @@ -296,7 +296,7 @@ The lingui library provides various React helpers for dealing with both marking **Note:** Variables that are put inside the t-marked template tag will not be translated. If you have a variable string with text that needs translating, you must wrap it in ```i18n._(t``)``` where it is defined. -**Note:** We do not use the `I18n` consumer, `i18nMark` function, or `` 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 `` component lingui gives us access to in this repo. i18nMark does not actually replace the string in the UI (leading to the potential for untranslated bugs), and the other helpers are redundant. Settling on a consistent, single pattern helps us ease the mental overhead of the need to understand the ins and outs of the lingui API. You can learn more about the ways lingui and its React helpers at [this link](https://lingui.js.org/tutorials/react-patterns.html). diff --git a/awx/ui_next/src/app.scss b/awx/ui_next/src/app.scss index 16ad4a101a..edf29254b3 100644 --- a/awx/ui_next/src/app.scss +++ b/awx/ui_next/src/app.scss @@ -156,13 +156,6 @@ // and bem style, as well as moved into component-based scss files // -.at-c-listCardBody { - --pf-c-card__footer--PaddingX: 0; - --pf-c-card__footer--PaddingY: 0; - --pf-c-card__body--PaddingX: 0; - --pf-c-card__body--PaddingY: 0; -} - .awx-c-card { position: relative; } diff --git a/awx/ui_next/src/components/Card/CardActionsRow.jsx b/awx/ui_next/src/components/Card/CardActionsRow.jsx new file mode 100644 index 0000000000..8312207fa9 --- /dev/null +++ b/awx/ui_next/src/components/Card/CardActionsRow.jsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { CardActions } from '@patternfly/react-core'; +import styled from 'styled-components'; + +const CardActionsWrapper = styled.div` + display: flex; + justify-content: flex-end; + margin-top: 20px; + + & > .pf-c-card__actions > :not(:first-child) { + margin-left: 0.5rem; + } +`; + +function CardActionsRow({ children }) { + return ( + + {children} + + ); +} + +export default CardActionsRow; diff --git a/awx/ui_next/src/components/Card/CardBody.jsx b/awx/ui_next/src/components/Card/CardBody.jsx new file mode 100644 index 0000000000..095eece142 --- /dev/null +++ b/awx/ui_next/src/components/Card/CardBody.jsx @@ -0,0 +1,9 @@ +import styled from 'styled-components'; +import { CardBody } from '@patternfly/react-core'; + +const TabbedCardBody = styled(CardBody)` + padding-top: var(--pf-c-card--first-child--PaddingTop); +`; +CardBody.displayName = 'PFCardBody'; + +export default TabbedCardBody; diff --git a/awx/ui_next/src/components/Card/TabbedCardHeader.js b/awx/ui_next/src/components/Card/TabbedCardHeader.js new file mode 100644 index 0000000000..2fa040a9db --- /dev/null +++ b/awx/ui_next/src/components/Card/TabbedCardHeader.js @@ -0,0 +1,13 @@ +import styled from 'styled-components'; +import { CardHeader } from '@patternfly/react-core'; + +const TabbedCardHeader = styled(CardHeader)` + --pf-c-card--first-child--PaddingTop: 0; + --pf-c-card--child--PaddingLeft: 0; + --pf-c-card--child--PaddingRight: 0; + --pf-c-card__header--not-last-child--PaddingBottom: 24px; + --pf-c-card__header--not-last-child--PaddingBottom: 0; + position: relative; +`; + +export default TabbedCardHeader; diff --git a/awx/ui_next/src/components/Card/index.js b/awx/ui_next/src/components/Card/index.js new file mode 100644 index 0000000000..860e50a051 --- /dev/null +++ b/awx/ui_next/src/components/Card/index.js @@ -0,0 +1,3 @@ +export { default as TabbedCardHeader } from './TabbedCardHeader'; +export { default as CardBody } from './CardBody'; +export { default as CardActionsRow } from './CardActionsRow'; diff --git a/awx/ui_next/src/components/CodeMirrorInput/CodeMirrorInput.jsx b/awx/ui_next/src/components/CodeMirrorInput/CodeMirrorInput.jsx index 6513322f66..c84ff0ba84 100644 --- a/awx/ui_next/src/components/CodeMirrorInput/CodeMirrorInput.jsx +++ b/awx/ui_next/src/components/CodeMirrorInput/CodeMirrorInput.jsx @@ -83,7 +83,7 @@ function CodeMirrorInput({ } CodeMirrorInput.propTypes = { value: string.isRequired, - onChange: func.isRequired, + onChange: func, mode: oneOf(['javascript', 'yaml', 'jinja2']).isRequired, readOnly: bool, hasErrors: bool, @@ -91,6 +91,7 @@ CodeMirrorInput.propTypes = { }; CodeMirrorInput.defaultProps = { readOnly: false, + onChange: () => {}, rows: 6, hasErrors: false, }; diff --git a/awx/ui_next/src/components/CodeMirrorInput/VariablesDetail.jsx b/awx/ui_next/src/components/CodeMirrorInput/VariablesDetail.jsx new file mode 100644 index 0000000000..d89ea07b58 --- /dev/null +++ b/awx/ui_next/src/components/CodeMirrorInput/VariablesDetail.jsx @@ -0,0 +1,91 @@ +import React, { useState } from 'react'; +import { string, number } from 'prop-types'; +import { Split, SplitItem, TextListItemVariants } from '@patternfly/react-core'; +import { DetailName, DetailValue } from '@components/DetailList'; +import CodeMirrorInput from './CodeMirrorInput'; +import YamlJsonToggle from './YamlJsonToggle'; +import { yamlToJson, jsonToYaml, isJson } from '../../util/yaml'; + +const YAML_MODE = 'yaml'; +const JSON_MODE = 'javascript'; + +function VariablesDetail({ value, label, rows }) { + const [mode, setMode] = useState(isJson(value) ? JSON_MODE : YAML_MODE); + const [currentValue, setCurrentValue] = useState(value); + const [error, setError] = useState(null); + + if (!value) { + return null; + } + + return ( + <> + + + +
+ + {label} + +
+
+ + { + try { + const newVal = + newMode === YAML_MODE + ? jsonToYaml(currentValue) + : yamlToJson(currentValue); + setCurrentValue(newVal); + setMode(newMode); + } catch (err) { + setError(err); + } + }} + /> + +
+
+ + + {error && ( +
+ Error: {error.message} +
+ )} +
+ + ); +} +VariablesDetail.propTypes = { + value: string.isRequired, + label: string.isRequired, + rows: number, +}; +VariablesDetail.defaultProps = { + rows: null, +}; + +export default VariablesDetail; diff --git a/awx/ui_next/src/components/CodeMirrorInput/VariablesDetail.test.jsx b/awx/ui_next/src/components/CodeMirrorInput/VariablesDetail.test.jsx new file mode 100644 index 0000000000..71d082290c --- /dev/null +++ b/awx/ui_next/src/components/CodeMirrorInput/VariablesDetail.test.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import VariablesDetail from './VariablesDetail'; + +jest.mock('@api'); + +describe('', () => { + test('should render readonly CodeMirrorInput', () => { + const wrapper = shallow( + + ); + const input = wrapper.find('Styled(CodeMirrorInput)'); + expect(input).toHaveLength(1); + expect(input.prop('mode')).toEqual('yaml'); + expect(input.prop('value')).toEqual('---foo: bar'); + expect(input.prop('readOnly')).toEqual(true); + }); + + test('should detect JSON', () => { + const wrapper = shallow( + + ); + const input = wrapper.find('Styled(CodeMirrorInput)'); + expect(input).toHaveLength(1); + expect(input.prop('mode')).toEqual('javascript'); + expect(input.prop('value')).toEqual('{"foo": "bar"}'); + }); + + test('should convert between modes', () => { + const wrapper = shallow( + + ); + wrapper.find('YamlJsonToggle').invoke('onChange')('javascript'); + const input = wrapper.find('Styled(CodeMirrorInput)'); + expect(input.prop('mode')).toEqual('javascript'); + expect(input.prop('value')).toEqual('{\n "foo": "bar"\n}'); + + wrapper.find('YamlJsonToggle').invoke('onChange')('yaml'); + const input2 = wrapper.find('Styled(CodeMirrorInput)'); + expect(input2.prop('mode')).toEqual('yaml'); + expect(input2.prop('value')).toEqual('foo: bar\n'); + }); +}); diff --git a/awx/ui_next/src/components/CodeMirrorInput/VariablesField.jsx b/awx/ui_next/src/components/CodeMirrorInput/VariablesField.jsx index 3c828c6308..97c35c1871 100644 --- a/awx/ui_next/src/components/CodeMirrorInput/VariablesField.jsx +++ b/awx/ui_next/src/components/CodeMirrorInput/VariablesField.jsx @@ -1,21 +1,15 @@ import React, { useState } from 'react'; import { string, bool } from 'prop-types'; import { Field } from 'formik'; -import { Button, Split, SplitItem } from '@patternfly/react-core'; -import styled from 'styled-components'; -import ButtonGroup from '../ButtonGroup'; +import { Split, SplitItem } from '@patternfly/react-core'; import CodeMirrorInput from './CodeMirrorInput'; +import YamlJsonToggle from './YamlJsonToggle'; import { yamlToJson, jsonToYaml } from '../../util/yaml'; const YAML_MODE = 'yaml'; -const JSON_MODE = 'javascript'; - -const SmallButton = styled(Button)` - padding: 3px 8px; - font-size: var(--pf-global--FontSize--xs); -`; function VariablesField({ id, name, label, readOnly }) { + // TODO: detect initial mode const [mode, setMode] = useState(YAML_MODE); return ( @@ -30,40 +24,21 @@ function VariablesField({ id, name, label, readOnly }) { - - { - if (mode === YAML_MODE) { - return; - } - try { - form.setFieldValue(name, jsonToYaml(field.value)); - setMode(YAML_MODE); - } catch (err) { - form.setFieldError(name, err.message); - } - }} - variant={mode === YAML_MODE ? 'primary' : 'secondary'} - > - YAML - - { - if (mode === JSON_MODE) { - return; - } - try { - form.setFieldValue(name, yamlToJson(field.value)); - setMode(JSON_MODE); - } catch (err) { - form.setFieldError(name, err.message); - } - }} - variant={mode === JSON_MODE ? 'primary' : 'secondary'} - > - JSON - - + { + try { + const newVal = + newMode === YAML_MODE + ? jsonToYaml(field.value) + : yamlToJson(field.value); + form.setFieldValue(name, newVal); + setMode(newMode); + } catch (err) { + form.setFieldError(name, err.message); + } + }} + /> { + if (mode !== newMode) { + onChange(newMode); + } + }; + + return ( + + setMode(YAML_MODE)} + variant={mode === YAML_MODE ? 'primary' : 'secondary'} + > + YAML + + setMode(JSON_MODE)} + variant={mode === JSON_MODE ? 'primary' : 'secondary'} + > + JSON + + + ); +} +YamlJsonToggle.propTypes = { + mode: oneOf([YAML_MODE, JSON_MODE]).isRequired, + onChange: func.isRequired, +}; + +export default YamlJsonToggle; diff --git a/awx/ui_next/src/components/CodeMirrorInput/index.js b/awx/ui_next/src/components/CodeMirrorInput/index.js index 48940bbb37..9cad016228 100644 --- a/awx/ui_next/src/components/CodeMirrorInput/index.js +++ b/awx/ui_next/src/components/CodeMirrorInput/index.js @@ -1,5 +1,6 @@ import CodeMirrorInput from './CodeMirrorInput'; export default CodeMirrorInput; +export { default as VariablesDetail } from './VariablesDetail'; export { default as VariablesInput } from './VariablesInput'; export { default as VariablesField } from './VariablesField'; diff --git a/awx/ui_next/src/components/DeleteButton/DeleteButton.jsx b/awx/ui_next/src/components/DeleteButton/DeleteButton.jsx new file mode 100644 index 0000000000..69a84e7f79 --- /dev/null +++ b/awx/ui_next/src/components/DeleteButton/DeleteButton.jsx @@ -0,0 +1,50 @@ +import React, { useState } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import AlertModal from '@components/AlertModal'; +import { CardActionsRow } from '@components/Card'; + +function DeleteButton({ onConfirm, modalTitle, name, i18n }) { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + setIsOpen(false)} + > + {i18n._(t`Are you sure you want to delete:`)} +
+ {name} + + + + +
+ + ); +} + +export default withI18n()(DeleteButton); diff --git a/awx/ui_next/src/components/DeleteButton/index.js b/awx/ui_next/src/components/DeleteButton/index.js new file mode 100644 index 0000000000..991ad79b29 --- /dev/null +++ b/awx/ui_next/src/components/DeleteButton/index.js @@ -0,0 +1 @@ +export { default } from './DeleteButton'; diff --git a/awx/ui_next/src/components/DetailList/UserDateDetail.jsx b/awx/ui_next/src/components/DetailList/UserDateDetail.jsx new file mode 100644 index 0000000000..bb4471d703 --- /dev/null +++ b/awx/ui_next/src/components/DetailList/UserDateDetail.jsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { node, string } from 'prop-types'; +import { Trans } from '@lingui/macro'; +import { Link } from 'react-router-dom'; +import { formatDateString } from '@util/dates'; +import Detail from './Detail'; +import { SummaryFieldUser } from '../../types'; + +function UserDateDetail({ label, date, user }) { + const dateStr = formatDateString(date); + const username = user ? user.username : ''; + return ( + + {dateStr} by {username} +
+ ) : ( + dateStr + ) + } + /> + ); +} +UserDateDetail.propTypes = { + label: node.isRequired, + date: string.isRequired, + user: SummaryFieldUser, +}; +UserDateDetail.defaultProps = { + user: null, +}; + +export default UserDateDetail; diff --git a/awx/ui_next/src/components/DetailList/index.js b/awx/ui_next/src/components/DetailList/index.js index 665470d178..4a5b77dbc0 100644 --- a/awx/ui_next/src/components/DetailList/index.js +++ b/awx/ui_next/src/components/DetailList/index.js @@ -1,2 +1,3 @@ export { default as DetailList } from './DetailList'; export { default as Detail, DetailName, DetailValue } from './Detail'; +export { default as UserDateDetail } from './UserDateDetail'; diff --git a/awx/ui_next/src/screens/Host/Host.jsx b/awx/ui_next/src/screens/Host/Host.jsx index 20cd4aad0c..0601e661a8 100644 --- a/awx/ui_next/src/screens/Host/Host.jsx +++ b/awx/ui_next/src/screens/Host/Host.jsx @@ -2,12 +2,8 @@ import React, { Component } from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Switch, Route, withRouter, Redirect, Link } from 'react-router-dom'; -import { - Card, - CardHeader as PFCardHeader, - PageSection, -} from '@patternfly/react-core'; -import styled from 'styled-components'; +import { Card, PageSection } from '@patternfly/react-core'; +import { TabbedCardHeader } from '@components/Card'; import CardCloseButton from '@components/CardCloseButton'; import RoutedTabs from '@components/RoutedTabs'; import ContentError from '@components/ContentError'; @@ -18,13 +14,6 @@ import HostGroups from './HostGroups'; import HostCompletedJobs from './HostCompletedJobs'; import { HostsAPI } from '@api'; -const CardHeader = styled(PFCardHeader)` - --pf-c-card--first-child--PaddingTop: 0; - --pf-c-card--child--PaddingLeft: 0; - --pf-c-card--child--PaddingRight: 0; - position: relative; -`; - class Host extends Component { constructor(props) { super(props); @@ -89,7 +78,7 @@ class Host extends Component { ]; let cardHeader = ( - + - + ); if (!isInitialized) { diff --git a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx index 1919721d06..5ab7a5d40b 100644 --- a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx +++ b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx @@ -1,7 +1,8 @@ import React from 'react'; import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; -import { PageSection, Card, CardBody } from '@patternfly/react-core'; +import { PageSection, Card } from '@patternfly/react-core'; +import { CardBody } from '@components/Card'; import { HostsAPI } from '@api'; import { Config } from '@contexts/Config'; import HostForm from '../shared'; diff --git a/awx/ui_next/src/screens/Host/HostCompletedJobs/HostCompletedJobs.jsx b/awx/ui_next/src/screens/Host/HostCompletedJobs/HostCompletedJobs.jsx index 63859254a6..871ce97d50 100644 --- a/awx/ui_next/src/screens/Host/HostCompletedJobs/HostCompletedJobs.jsx +++ b/awx/ui_next/src/screens/Host/HostCompletedJobs/HostCompletedJobs.jsx @@ -1,5 +1,5 @@ import React, { Component } from 'react'; -import { CardBody } from '@patternfly/react-core'; +import { CardBody } from '@components/Card'; class HostCompletedJobs extends Component { render() { diff --git a/awx/ui_next/src/screens/Host/HostDetail/HostDetail.jsx b/awx/ui_next/src/screens/Host/HostDetail/HostDetail.jsx index 479d28211f..fc43bb27a9 100644 --- a/awx/ui_next/src/screens/Host/HostDetail/HostDetail.jsx +++ b/awx/ui_next/src/screens/Host/HostDetail/HostDetail.jsx @@ -4,10 +4,10 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import styled from 'styled-components'; import { Host } from '@types'; -import { formatDateString } from '@util/dates'; -import { Button, CardBody } from '@patternfly/react-core'; -import { DetailList, Detail } from '@components/DetailList'; -import CodeMirrorInput from '@components/CodeMirrorInput'; +import { Button } from '@patternfly/react-core'; +import { CardBody } from '@components/Card'; +import { DetailList, Detail, UserDateDetail } from '@components/DetailList'; +import { VariablesDetail } from '@components/CodeMirrorInput'; const ActionButtonWrapper = styled.div` display: flex; @@ -21,32 +21,8 @@ const ActionButtonWrapper = styled.div` function HostDetail({ host, i18n }) { const { created, description, id, modified, name, summary_fields } = host; - let createdBy = ''; - if (created) { - if (summary_fields.created_by && summary_fields.created_by.username) { - createdBy = i18n._( - t`${formatDateString(created)} by ${summary_fields.created_by.username}` - ); - } else { - createdBy = formatDateString(created); - } - } - - let modifiedBy = ''; - if (modified) { - if (summary_fields.modified_by && summary_fields.modified_by.username) { - modifiedBy = i18n._( - t`${formatDateString(modified)} by ${ - summary_fields.modified_by.username - }` - ); - } else { - modifiedBy = formatDateString(modified); - } - } - return ( - + @@ -66,23 +42,20 @@ function HostDetail({ host, i18n }) { } /> )} - {/* TODO: Link to user in users */} - - {/* TODO: Link to user in users */} - - + + {}} - rows={6} - hasErrors={false} - /> - } + value={host.variables} + rows={6} /> diff --git a/awx/ui_next/src/screens/Host/HostDetail/HostDetail.test.jsx b/awx/ui_next/src/screens/Host/HostDetail/HostDetail.test.jsx index a748f9596d..749c512171 100644 --- a/awx/ui_next/src/screens/Host/HostDetail/HostDetail.test.jsx +++ b/awx/ui_next/src/screens/Host/HostDetail/HostDetail.test.jsx @@ -30,7 +30,7 @@ describe('', () => { mountWithContexts(); }); - test('should render Details', async done => { + test('should render Details', async () => { const wrapper = mountWithContexts(); const testParams = [ { label: 'Name', value: 'Foo' }, @@ -46,23 +46,22 @@ describe('', () => { expect(detail.find('dt').text()).toBe(label); expect(detail.find('dd').text()).toBe(value); } - done(); }); - test('should show edit button for users with edit permission', async done => { + test('should show edit button for users with edit permission', async () => { const wrapper = mountWithContexts(); - const editButton = await waitForElement(wrapper, 'HostDetail Button'); + // VariablesDetail has two buttons + const editButton = wrapper.find('Button').at(2); expect(editButton.text()).toEqual('Edit'); expect(editButton.prop('to')).toBe('/hosts/1/edit'); - done(); }); - test('should hide edit button for users without edit permission', async done => { + test('should hide edit button for users without edit permission', async () => { const readOnlyHost = { ...mockHost }; readOnlyHost.summary_fields.user_capabilities.edit = false; const wrapper = mountWithContexts(); await waitForElement(wrapper, 'HostDetail'); - expect(wrapper.find('HostDetail Button').length).toBe(0); - done(); + // VariablesDetail has two buttons + expect(wrapper.find('Button').length).toBe(2); }); }); diff --git a/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx index 44665eb771..6f66483cc1 100644 --- a/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx +++ b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx @@ -1,7 +1,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { withRouter } from 'react-router-dom'; -import { CardBody } from '@patternfly/react-core'; +import { CardBody } from '@components/Card'; import { HostsAPI } from '@api'; import { Config } from '@contexts/Config'; diff --git a/awx/ui_next/src/screens/Host/HostFacts/HostFacts.jsx b/awx/ui_next/src/screens/Host/HostFacts/HostFacts.jsx index d86c2b7606..62499c4b40 100644 --- a/awx/ui_next/src/screens/Host/HostFacts/HostFacts.jsx +++ b/awx/ui_next/src/screens/Host/HostFacts/HostFacts.jsx @@ -1,5 +1,5 @@ import React, { Component } from 'react'; -import { CardBody } from '@patternfly/react-core'; +import { CardBody } from '@components/Card'; class HostFacts extends Component { render() { diff --git a/awx/ui_next/src/screens/Host/HostGroups/HostGroups.jsx b/awx/ui_next/src/screens/Host/HostGroups/HostGroups.jsx index 9757da9d4b..2fad6886cd 100644 --- a/awx/ui_next/src/screens/Host/HostGroups/HostGroups.jsx +++ b/awx/ui_next/src/screens/Host/HostGroups/HostGroups.jsx @@ -1,5 +1,5 @@ import React, { Component } from 'react'; -import { CardBody } from '@patternfly/react-core'; +import { CardBody } from '@components/Card'; class HostGroups extends Component { render() { diff --git a/awx/ui_next/src/screens/Inventory/Inventory.jsx b/awx/ui_next/src/screens/Inventory/Inventory.jsx index f3e78d584b..e632610660 100644 --- a/awx/ui_next/src/screens/Inventory/Inventory.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventory.jsx @@ -1,8 +1,9 @@ import React, { useEffect, useState } from 'react'; import { t } from '@lingui/macro'; import { withI18n } from '@lingui/react'; -import { Card, CardHeader, PageSection } from '@patternfly/react-core'; +import { Card, PageSection } from '@patternfly/react-core'; import { Switch, Route, Redirect, withRouter, Link } from 'react-router-dom'; +import { TabbedCardHeader } from '@components/Card'; import CardCloseButton from '@components/CardCloseButton'; import ContentError from '@components/ContentError'; import RoutedTabs from '@components/RoutedTabs'; @@ -51,10 +52,10 @@ function Inventory({ history, i18n, location, match, setBreadcrumb }) { ]; let cardHeader = hasContentLoading ? null : ( - + - + ); if ( diff --git a/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.jsx b/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.jsx index da666db200..4249bcaf92 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.jsx @@ -2,14 +2,8 @@ import React, { useState, useEffect } from 'react'; import { withI18n } from '@lingui/react'; import { withRouter } from 'react-router-dom'; import { t } from '@lingui/macro'; -import { - PageSection, - Card, - CardHeader, - CardBody, - Tooltip, -} from '@patternfly/react-core'; - +import { PageSection, Card, CardHeader, Tooltip } from '@patternfly/react-core'; +import { CardBody } from '@components/Card'; import ContentError from '@components/ContentError'; import ContentLoading from '@components/ContentLoading'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryCompletedJobs/InventoryCompletedJobs.jsx b/awx/ui_next/src/screens/Inventory/InventoryCompletedJobs/InventoryCompletedJobs.jsx index 376edb63e6..d128548d7c 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryCompletedJobs/InventoryCompletedJobs.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryCompletedJobs/InventoryCompletedJobs.jsx @@ -1,5 +1,5 @@ import React, { Component } from 'react'; -import { CardBody } from '@patternfly/react-core'; +import { CardBody } from '@components/Card'; class InventoryCompletedJobs extends Component { render() { diff --git a/awx/ui_next/src/screens/Inventory/InventoryDetail/InventoryDetail.jsx b/awx/ui_next/src/screens/Inventory/InventoryDetail/InventoryDetail.jsx index 5d9d9789b6..81a765982a 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryDetail/InventoryDetail.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryDetail/InventoryDetail.jsx @@ -1,10 +1,124 @@ -import React, { Component } from 'react'; -import { CardBody } from '@patternfly/react-core'; +import React, { useState, useEffect } from 'react'; +import { Link, useHistory } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { CardBody, CardActionsRow } from '@components/Card'; +import { DetailList, Detail, UserDateDetail } from '@components/DetailList'; +import { ChipGroup, Chip } from '@components/Chip'; +import { VariablesDetail } from '@components/CodeMirrorInput'; +import DeleteButton from '@components/DeleteButton'; +import ContentError from '@components/ContentError'; +import ContentLoading from '@components/ContentLoading'; +import { InventoriesAPI } from '@api'; +import { Inventory } from '../../../types'; -class InventoryDetail extends Component { - render() { - return Coming soon :); +function InventoryDetail({ inventory, i18n }) { + const [instanceGroups, setInstanceGroups] = useState([]); + const [hasContentLoading, setHasContentLoading] = useState(true); + const [contentError, setContentError] = useState(null); + const history = useHistory(); + + useEffect(() => { + (async () => { + setHasContentLoading(true); + try { + const { data } = await InventoriesAPI.readInstanceGroups(inventory.id); + setInstanceGroups(data.results); + } catch (err) { + setContentError(err); + } finally { + setHasContentLoading(false); + } + })(); + }, [inventory.id]); + + const deleteInventory = async () => { + await InventoriesAPI.destroy(inventory.id); + history.push(`/inventories`); + }; + + const { + organization, + user_capabilities: userCapabilities, + } = inventory.summary_fields; + + if (hasContentLoading) { + return ; } -} -export default InventoryDetail; + if (contentError) { + return ; + } + + return ( + + + + + + + + {organization.name} + + } + /> + + {instanceGroups.map(ig => ( + + {ig.name} + + ))} + + } + /> + + + + + + {userCapabilities.edit && ( + + )} + {userCapabilities.delete && ( + + {i18n._(t`Delete`)} + + )} + + + ); +} +InventoryDetail.propTypes = { + inventory: Inventory.isRequired, +}; + +export default withI18n()(InventoryDetail); diff --git a/awx/ui_next/src/screens/Inventory/InventoryDetail/InventoryDetail.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryDetail/InventoryDetail.test.jsx new file mode 100644 index 0000000000..9f3a0bba26 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryDetail/InventoryDetail.test.jsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { InventoriesAPI, CredentialTypesAPI } from '@api'; +import InventoryDetail from './InventoryDetail'; + +jest.mock('@api'); + +const mockInventory = { + id: 1, + type: 'inventory', + url: '/api/v2/inventories/1/', + summary_fields: { + organization: { + id: 1, + name: 'The Organization', + description: '', + }, + user_capabilities: { + edit: true, + delete: true, + copy: true, + adhoc: true, + }, + insights_credential: { + id: 1, + name: 'Foo', + }, + }, + created: '2019-10-04T16:56:48.025455Z', + modified: '2019-10-04T16:56:48.025468Z', + name: 'Inv no hosts', + description: '', + organization: 1, + kind: '', + host_filter: null, + variables: '---\nfoo: bar', + has_active_failures: false, + total_hosts: 0, + hosts_with_active_failures: 0, + total_groups: 0, + groups_with_active_failures: 0, + has_inventory_sources: false, + total_inventory_sources: 0, + inventory_sources_with_failures: 0, + insights_credential: null, + pending_deletion: false, +}; + +CredentialTypesAPI.read.mockResolvedValue({ + data: { + results: [ + { + id: 14, + name: 'insights', + }, + ], + }, +}); +const associatedInstanceGroups = [ + { + id: 1, + name: 'Foo', + }, +]; +InventoriesAPI.readInstanceGroups.mockResolvedValue({ + data: { + results: associatedInstanceGroups, + }, +}); + +function expectDetailToMatch(wrapper, label, value) { + const detail = wrapper.find(`Detail[label="${label}"]`); + expect(detail).toHaveLength(1); + expect(detail.prop('value')).toEqual(value); +} + +describe('', () => { + test('should render details', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.update(); + expectDetailToMatch(wrapper, 'Name', mockInventory.name); + expectDetailToMatch(wrapper, 'Activity', 'Coming soon'); + expectDetailToMatch(wrapper, 'Description', mockInventory.description); + expectDetailToMatch(wrapper, 'Type', 'Inventory'); + const org = wrapper.find('Detail[label="Organization"]'); + expect(org.prop('value')).toMatchInlineSnapshot(` + + The Organization + + `); + const vars = wrapper.find('VariablesDetail'); + expect(vars).toHaveLength(1); + expect(vars.prop('value')).toEqual(mockInventory.variables); + const dates = wrapper.find('UserDateDetail'); + expect(dates).toHaveLength(2); + expect(dates.at(0).prop('date')).toEqual(mockInventory.created); + expect(dates.at(1).prop('date')).toEqual(mockInventory.modified); + }); + + test('should load instance groups', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.update(); + expect(InventoriesAPI.readInstanceGroups).toHaveBeenCalledWith( + mockInventory.id + ); + const chip = wrapper.find('Chip').at(0); + expect(chip.prop('isReadOnly')).toEqual(true); + expect(chip.prop('children')).toEqual('Foo'); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.jsx b/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.jsx index 2ec78aef4a..584709dce3 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.jsx @@ -2,9 +2,10 @@ import React, { useState, useEffect } from 'react'; import { withI18n } from '@lingui/react'; import { withRouter } from 'react-router-dom'; import { t } from '@lingui/macro'; -import { CardHeader, CardBody, Tooltip } from '@patternfly/react-core'; +import { CardHeader, Tooltip } from '@patternfly/react-core'; import { object } from 'prop-types'; +import { CardBody } from '@components/Card'; import CardCloseButton from '@components/CardCloseButton'; import { InventoriesAPI, CredentialTypesAPI } from '@api'; import ContentLoading from '@components/ContentLoading'; @@ -58,7 +59,9 @@ function InventoryEdit({ history, i18n, inventory }) { } = values; try { await InventoriesAPI.update(inventory.id, { - insights_credential: insights_credential.id, + insights_credential: insights_credential + ? insights_credential.id + : null, organization: organization.id, ...remainingValues, }); @@ -76,13 +79,13 @@ function InventoryEdit({ history, i18n, inventory }) { ); await Promise.all([...associatePromises, ...disassociatePromises]); } + const url = + history.location.pathname.search('smart') > -1 + ? `/inventories/smart_inventory/${inventory.id}/details` + : `/inventories/inventory/${inventory.id}/details`; + history.push(`${url}`); } catch (err) { setError(err); - } finally { - const url = history.location.pathname.search('smart') - ? `/inventories/smart_inventory/${inventory.id}/details` - : `/inventories/inventory/${inventory.id}/details`; - history.push(`${url}`); } }; if (contentLoading) { diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx index 1c510f4fe6..40d6ac6de8 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx @@ -1,7 +1,6 @@ import React, { useEffect, useState } from 'react'; import { t } from '@lingui/macro'; import { withI18n } from '@lingui/react'; -import { CardHeader } from '@patternfly/react-core'; import { Switch, Route, withRouter, Link, Redirect } from 'react-router-dom'; import { GroupsAPI } from '@api'; @@ -9,6 +8,7 @@ import CardCloseButton from '@components/CardCloseButton'; import RoutedTabs from '@components/RoutedTabs'; import ContentError from '@components/ContentError'; import ContentLoading from '@components/ContentLoading'; +import { TabbedCardHeader } from '@components/Card'; import InventoryGroupEdit from '../InventoryGroupEdit/InventoryGroupEdit'; import InventoryGroupDetail from '../InventoryGroupDetail/InventoryGroupDetail'; @@ -97,12 +97,12 @@ function InventoryGroups({ i18n, match, setBreadcrumb, inventory, history }) { !history.location.pathname.endsWith('edit') ) { cardHeader = ( - + - + ); } return ( diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.jsx index 8cf97cffc0..bef4a8ff7b 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.jsx @@ -1,25 +1,19 @@ import React, { useState } from 'react'; import { t } from '@lingui/macro'; -import { CardBody, Button } from '@patternfly/react-core'; +import { Button } from '@patternfly/react-core'; import { withI18n } from '@lingui/react'; -import { withRouter, Link } from 'react-router-dom'; +import { withRouter } from 'react-router-dom'; import styled from 'styled-components'; -import { VariablesInput as CodeMirrorInput } from '@components/CodeMirrorInput'; +import { VariablesDetail } from '@components/CodeMirrorInput'; +import { CardBody } from '@components/Card'; import ErrorDetail from '@components/ErrorDetail'; import AlertModal from '@components/AlertModal'; -import { formatDateString } from '@util/dates'; import { GroupsAPI } from '@api'; -import { DetailList, Detail } from '@components/DetailList'; +import { DetailList, Detail, UserDateDetail } from '@components/DetailList'; -const VariablesInput = styled(CodeMirrorInput)` - .pf-c-form__label { - font-weight: 600; - font-size: 16px; - } - margin: 20px 0; -`; +// TODO: extract this into a component for use in all relevant Detail views const ActionButtonWrapper = styled.div` display: flex; justify-content: flex-end; @@ -28,6 +22,7 @@ const ActionButtonWrapper = styled.div` margin-left: 20px; } `; + function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) { const { summary_fields: { created_by, modified_by }, @@ -50,52 +45,26 @@ function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) { } }; - let createdBy = ''; - if (created) { - if (created_by && created_by.username) { - createdBy = ( - - {i18n._(t`${formatDateString(inventoryGroup.created)} by`)}{' '} - {created_by.username} - - ); - } else { - createdBy = formatDateString(inventoryGroup.created); - } - } - - let modifiedBy = ''; - if (modified) { - if (modified_by && modified_by.username) { - modifiedBy = ( - - {i18n._(t`${formatDateString(inventoryGroup.modified)} by`)}{' '} - {modified_by.username} - - ); - } else { - modifiedBy = formatDateString(inventoryGroup.modified); - } - } - return ( - + - - - - {createdBy && } - {modifiedBy && ( - - )} + + +