Merge pull request #5520 from keithjgrant/5261-inventory-detail-b

Inventory Detail

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2019-12-19 20:03:22 +00:00 committed by GitHub
commit df5aa8a47d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
72 changed files with 787 additions and 452 deletions

View File

@ -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 `<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.
You can learn more about the ways lingui and its React helpers at [this link](https://lingui.js.org/tutorials/react-patterns.html).

View File

@ -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;
}

View File

@ -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 (
<CardActionsWrapper>
<CardActions>{children}</CardActions>
</CardActionsWrapper>
);
}
export default CardActionsRow;

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,3 @@
export { default as TabbedCardHeader } from './TabbedCardHeader';
export { default as CardBody } from './CardBody';
export { default as CardActionsRow } from './CardActionsRow';

View File

@ -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,
};

View File

@ -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 (
<>
<DetailName
component={TextListItemVariants.dt}
fullWidth
css="grid-column: 1 / -1"
>
<Split gutter="sm">
<SplitItem>
<div className="pf-c-form__label">
<span
className="pf-c-form__label-text"
css="font-weight: var(--pf-global--FontWeight--bold)"
>
{label}
</span>
</div>
</SplitItem>
<SplitItem>
<YamlJsonToggle
mode={mode}
onChange={newMode => {
try {
const newVal =
newMode === YAML_MODE
? jsonToYaml(currentValue)
: yamlToJson(currentValue);
setCurrentValue(newVal);
setMode(newMode);
} catch (err) {
setError(err);
}
}}
/>
</SplitItem>
</Split>
</DetailName>
<DetailValue
component={TextListItemVariants.dd}
fullWidth
css="grid-column: 1 / -1; margin-top: -20px"
>
<CodeMirrorInput
mode={mode}
value={currentValue}
readOnly
rows={rows}
css="margin-top: 10px"
/>
{error && (
<div
css="color: var(--pf-global--danger-color--100);
font-size: var(--pf-global--FontSize--sm"
>
Error: {error.message}
</div>
)}
</DetailValue>
</>
);
}
VariablesDetail.propTypes = {
value: string.isRequired,
label: string.isRequired,
rows: number,
};
VariablesDetail.defaultProps = {
rows: null,
};
export default VariablesDetail;

View File

@ -0,0 +1,43 @@
import React from 'react';
import { shallow } from 'enzyme';
import VariablesDetail from './VariablesDetail';
jest.mock('@api');
describe('<VariablesDetail>', () => {
test('should render readonly CodeMirrorInput', () => {
const wrapper = shallow(
<VariablesDetail value="---foo: bar" label="Variables" />
);
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(
<VariablesDetail value='{"foo": "bar"}' label="Variables" />
);
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(
<VariablesDetail value="---foo: bar" label="Variables" />
);
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');
});
});

View File

@ -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 }) {
</label>
</SplitItem>
<SplitItem>
<ButtonGroup>
<SmallButton
onClick={() => {
if (mode === YAML_MODE) {
return;
}
try {
form.setFieldValue(name, jsonToYaml(field.value));
setMode(YAML_MODE);
} catch (err) {
form.setFieldError(name, err.message);
}
}}
variant={mode === YAML_MODE ? 'primary' : 'secondary'}
>
YAML
</SmallButton>
<SmallButton
onClick={() => {
if (mode === JSON_MODE) {
return;
}
try {
form.setFieldValue(name, yamlToJson(field.value));
setMode(JSON_MODE);
} catch (err) {
form.setFieldError(name, err.message);
}
}}
variant={mode === JSON_MODE ? 'primary' : 'secondary'}
>
JSON
</SmallButton>
</ButtonGroup>
<YamlJsonToggle
mode={mode}
onChange={newMode => {
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);
}
}}
/>
</SplitItem>
</Split>
<CodeMirrorInput

View File

@ -0,0 +1,44 @@
import React from 'react';
import { oneOf, func } from 'prop-types';
import styled from 'styled-components';
import { Button } from '@patternfly/react-core';
import ButtonGroup from '../ButtonGroup';
const SmallButton = styled(Button)`
padding: 3px 8px;
font-size: var(--pf-global--FontSize--xs);
`;
const YAML_MODE = 'yaml';
const JSON_MODE = 'javascript';
function YamlJsonToggle({ mode, onChange }) {
const setMode = newMode => {
if (mode !== newMode) {
onChange(newMode);
}
};
return (
<ButtonGroup>
<SmallButton
onClick={() => setMode(YAML_MODE)}
variant={mode === YAML_MODE ? 'primary' : 'secondary'}
>
YAML
</SmallButton>
<SmallButton
onClick={() => setMode(JSON_MODE)}
variant={mode === JSON_MODE ? 'primary' : 'secondary'}
>
JSON
</SmallButton>
</ButtonGroup>
);
}
YamlJsonToggle.propTypes = {
mode: oneOf([YAML_MODE, JSON_MODE]).isRequired,
onChange: func.isRequired,
};
export default YamlJsonToggle;

View File

@ -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';

View File

@ -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 (
<>
<Button
variant="danger"
aria-label={i18n._(t`Delete`)}
onClick={() => setIsOpen(true)}
>
{i18n._(t`Delete`)}
</Button>
<AlertModal
isOpen={isOpen}
title={modalTitle}
variant="danger"
onClose={() => setIsOpen(false)}
>
{i18n._(t`Are you sure you want to delete:`)}
<br />
<strong>{name}</strong>
<CardActionsRow>
<Button
variant="secondary"
aria-label={i18n._(t`Cancel`)}
onClick={() => setIsOpen(false)}
>
{i18n._(t`Cancel`)}
</Button>
<Button
variant="danger"
aria-label={i18n._(t`Delete`)}
onClick={onConfirm}
>
{i18n._(t`Delete`)}
</Button>
</CardActionsRow>
</AlertModal>
</>
);
}
export default withI18n()(DeleteButton);

View File

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

View File

@ -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 (
<Detail
label={label}
value={
user ? (
<Trans>
{dateStr} by <Link to={`/users/${user.id}`}>{username}</Link>
</Trans>
) : (
dateStr
)
}
/>
);
}
UserDateDetail.propTypes = {
label: node.isRequired,
date: string.isRequired,
user: SummaryFieldUser,
};
UserDateDetail.defaultProps = {
user: null,
};
export default UserDateDetail;

View File

@ -1,2 +1,3 @@
export { default as DetailList } from './DetailList';
export { default as Detail, DetailName, DetailValue } from './Detail';
export { default as UserDateDetail } from './UserDateDetail';

View File

@ -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 = (
<CardHeader style={{ padding: 0 }}>
<TabbedCardHeader>
<RoutedTabs
match={match}
history={history}
@ -97,7 +86,7 @@ class Host extends Component {
tabsArray={tabsArray}
/>
<CardCloseButton linkTo="/hosts" />
</CardHeader>
</TabbedCardHeader>
);
if (!isInitialized) {

View File

@ -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';

View File

@ -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() {

View File

@ -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 (
<CardBody css="padding-top: 20px">
<CardBody>
<DetailList gutter="sm">
<Detail label={i18n._(t`Name`)} value={name} />
<Detail label={i18n._(t`Description`)} value={description} />
@ -66,23 +42,20 @@ function HostDetail({ host, i18n }) {
}
/>
)}
{/* TODO: Link to user in users */}
<Detail label={i18n._(t`Created`)} value={createdBy} />
{/* TODO: Link to user in users */}
<Detail label={i18n._(t`Last Modified`)} value={modifiedBy} />
<Detail
fullWidth
<UserDateDetail
label={i18n._(t`Created`)}
date={created}
user={summary_fields.created_by}
/>
<UserDateDetail
label={i18n._(t`Last Modified`)}
date={modified}
user={summary_fields.modified_by}
/>
<VariablesDetail
label={i18n._(t`Variables`)}
value={
<CodeMirrorInput
mode="yaml"
readOnly
value={host.variables}
onChange={() => {}}
rows={6}
hasErrors={false}
/>
}
value={host.variables}
rows={6}
/>
</DetailList>
<ActionButtonWrapper>

View File

@ -30,7 +30,7 @@ describe('<HostDetail />', () => {
mountWithContexts(<HostDetail host={mockHost} />);
});
test('should render Details', async done => {
test('should render Details', async () => {
const wrapper = mountWithContexts(<HostDetail host={mockHost} />);
const testParams = [
{ label: 'Name', value: 'Foo' },
@ -46,23 +46,22 @@ describe('<HostDetail />', () => {
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(<HostDetail host={mockHost} />);
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(<HostDetail host={readOnlyHost} />);
await waitForElement(wrapper, 'HostDetail');
expect(wrapper.find('HostDetail Button').length).toBe(0);
done();
// VariablesDetail has two buttons
expect(wrapper.find('Button').length).toBe(2);
});
});

View File

@ -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';

View File

@ -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() {

View File

@ -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() {

View File

@ -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 : (
<CardHeader style={{ padding: 0 }}>
<TabbedCardHeader>
<RoutedTabs history={history} tabsArray={tabsArray} />
<CardCloseButton linkTo="/inventories" />
</CardHeader>
</TabbedCardHeader>
);
if (

View File

@ -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';

View File

@ -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() {

View File

@ -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 <CardBody>Coming soon :)</CardBody>;
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 <ContentLoading />;
}
}
export default InventoryDetail;
if (contentError) {
return <ContentError error={contentError} />;
}
return (
<CardBody>
<DetailList>
<Detail label={i18n._(t`Name`)} value={inventory.name} />
<Detail label={i18n._(t`Activity`)} value="Coming soon" />
<Detail label={i18n._(t`Description`)} value={inventory.description} />
<Detail label={i18n._(t`Type`)} value={i18n._(t`Inventory`)} />
<Detail
label={i18n._(t`Organization`)}
value={
<Link to={`/organizations/${organization.id}/details`}>
{organization.name}
</Link>
}
/>
<Detail
fullWidth
label={i18n._(t`Instance Groups`)}
value={
<ChipGroup numChips={5}>
{instanceGroups.map(ig => (
<Chip key={ig.id} isReadOnly>
{ig.name}
</Chip>
))}
</ChipGroup>
}
/>
<VariablesDetail
label={i18n._(t`Variables`)}
value={inventory.variables}
rows={4}
/>
<UserDateDetail
label={i18n._(t`Created`)}
date={inventory.created}
user={inventory.summary_fields.created_by}
/>
<UserDateDetail
label={i18n._(t`Last Modified`)}
date={inventory.modified}
user={inventory.summary_fields.modified_by}
/>
</DetailList>
<CardActionsRow>
{userCapabilities.edit && (
<Button
component={Link}
to={`/inventories/inventory/${inventory.id}/edit`}
>
{i18n._(t`Edit`)}
</Button>
)}
{userCapabilities.delete && (
<DeleteButton
name={inventory.name}
modalTitle={i18n._(t`Delete Inventory`)}
onConfirm={deleteInventory}
>
{i18n._(t`Delete`)}
</DeleteButton>
)}
</CardActionsRow>
</CardBody>
);
}
InventoryDetail.propTypes = {
inventory: Inventory.isRequired,
};
export default withI18n()(InventoryDetail);

View File

@ -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('<InventoryDetail />', () => {
test('should render details', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<InventoryDetail inventory={mockInventory} />
);
});
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(`
<ForwardRef
to="/organizations/1/details"
>
The Organization
</ForwardRef>
`);
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(
<InventoryDetail inventory={mockInventory} />
);
});
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');
});
});

View File

@ -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) {

View File

@ -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 = (
<CardHeader style={{ padding: 0 }}>
<TabbedCardHeader>
<RoutedTabs history={history} tabsArray={tabsArray} />
<CardCloseButton
linkTo={`/inventories/inventory/${inventory.id}/groups`}
/>
</CardHeader>
</TabbedCardHeader>
);
}
return (

View File

@ -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 = (
<span>
{i18n._(t`${formatDateString(inventoryGroup.created)} by`)}{' '}
<Link to={`/users/${created_by.id}`}>{created_by.username}</Link>
</span>
);
} else {
createdBy = formatDateString(inventoryGroup.created);
}
}
let modifiedBy = '';
if (modified) {
if (modified_by && modified_by.username) {
modifiedBy = (
<span>
{i18n._(t`${formatDateString(inventoryGroup.modified)} by`)}{' '}
<Link to={`/users/${modified_by.id}`}>{modified_by.username}</Link>
</span>
);
} else {
modifiedBy = formatDateString(inventoryGroup.modified);
}
}
return (
<CardBody css="padding-top: 20px">
<CardBody>
<DetailList gutter="sm">
<Detail label={i18n._(t`Name`)} value={name} />
<Detail label={i18n._(t`Description`)} value={description} />
</DetailList>
<VariablesInput
id="inventoryGroup-variables"
readOnly
value={variables}
rows={4}
label={i18n._(t`Variables`)}
/>
<DetailList>
{createdBy && <Detail label={i18n._(t`Created`)} value={createdBy} />}
{modifiedBy && (
<Detail label={i18n._(t`Modified`)} value={modifiedBy} />
)}
<VariablesDetail
label={i18n._(t`Variables`)}
value={variables}
rows={4}
/>
<UserDateDetail
label={i18n._(t`Created`)}
date={created}
user={created_by}
/>
<UserDateDetail
label={i18n._(t`Last Modified`)}
date={modified}
user={modified_by}
/>
</DetailList>
<ActionButtonWrapper>
<Button

View File

@ -80,7 +80,7 @@ describe('<InventoryGroupDetail />', () => {
'Bar'
);
expect(wrapper.find('Detail[label="Created"]').length).toBe(1);
expect(wrapper.find('Detail[label="Modified"]').length).toBe(1);
expect(wrapper.find('VariablesInput').prop('value')).toBe('bizz: buzz');
expect(wrapper.find('Detail[label="Last Modified"]').length).toBe(1);
expect(wrapper.find('VariablesDetail').prop('value')).toBe('bizz: buzz');
});
});

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { CardBody } from '@patternfly/react-core';
import { CardBody } from '@components/Card';
import InventoryHostForm from '../shared/InventoryHostForm';
import { InventoriesAPI } from '@api';

View File

@ -1,5 +1,5 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
import { CardBody } from '@components/Card';
class InventorySources extends Component {
render() {

View File

@ -1,8 +1,9 @@
import React, { Component } 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';
@ -74,10 +75,10 @@ class SmartInventory extends Component {
];
let cardHeader = hasContentLoading ? null : (
<CardHeader style={{ padding: 0 }}>
<TabbedCardHeader>
<RoutedTabs history={history} tabsArray={tabsArray} />
<CardCloseButton linkTo="/inventories" />
</CardHeader>
</TabbedCardHeader>
);
if (location.pathname.endsWith('edit')) {

View File

@ -1,5 +1,5 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
import { CardBody } from '@components/Card';
class SmartInventoryCompletedJobs extends Component {
render() {

View File

@ -1,5 +1,5 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
import { CardBody } from '@components/Card';
class SmartInventoryDetail extends Component {
render() {

View File

@ -1,5 +1,5 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
import { CardBody } from '@components/Card';
class SmartInventoryHosts extends Component {
render() {

View File

@ -2,9 +2,10 @@ import React from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { Formik } from 'formik';
import { Form, Card, CardBody } from '@patternfly/react-core';
import { Form, Card } from '@patternfly/react-core';
import { t } from '@lingui/macro';
import { CardBody } from '@components/Card';
import FormRow from '@components/FormRow';
import FormField from '@components/FormField';
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';

View File

@ -2,13 +2,9 @@ import React, { Component } from 'react';
import { Route, withRouter, Switch, Redirect, Link } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import {
Card,
CardHeader as PFCardHeader,
PageSection,
} from '@patternfly/react-core';
import { Card, PageSection } from '@patternfly/react-core';
import { JobsAPI } from '@api';
import { TabbedCardHeader } from '@components/Card';
import ContentError from '@components/ContentError';
import CardCloseButton from '@components/CardCloseButton';
import RoutedTabs from '@components/RoutedTabs';
@ -17,13 +13,6 @@ import JobDetail from './JobDetail';
import JobOutput from './JobOutput';
import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
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 Job extends Component {
constructor(props) {
super(props);
@ -81,10 +70,10 @@ class Job extends Component {
];
let cardHeader = (
<CardHeader>
<TabbedCardHeader>
<RoutedTabs match={match} history={history} tabsArray={tabsArray} />
<CardCloseButton linkTo="/jobs" />
</CardHeader>
</TabbedCardHeader>
);
if (!isInitialized) {

View File

@ -2,11 +2,12 @@ import React, { useState } from 'react';
import { Link, withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { CardBody, Button } from '@patternfly/react-core';
import { Button } from '@patternfly/react-core';
import styled from 'styled-components';
import AlertModal from '@components/AlertModal';
import { DetailList, Detail } from '@components/DetailList';
import { CardBody } from '@components/Card';
import { ChipGroup, Chip, CredentialChip } from '@components/Chip';
import { VariablesInput as _VariablesInput } from '@components/CodeMirrorInput';
import ErrorDetail from '@components/ErrorDetail';

View File

@ -8,7 +8,7 @@ import {
} from 'react-virtualized';
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
import { CardBody } from '@components/Card';
import { JobsAPI } from '@api';
import ContentError from '@components/ContentError';

View File

@ -2,13 +2,9 @@ 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 CardCloseButton from '@components/CardCloseButton';
import { TabbedCardHeader } from '@components/Card';
import RoutedTabs from '@components/RoutedTabs';
import ContentError from '@components/ContentError';
import NotificationList from '@components/NotificationList/NotificationList';
@ -18,13 +14,6 @@ import OrganizationEdit from './OrganizationEdit';
import OrganizationTeams from './OrganizationTeams';
import { OrganizationsAPI } 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 Organization extends Component {
constructor(props) {
super(props);
@ -141,7 +130,7 @@ class Organization extends Component {
}
let cardHeader = (
<CardHeader style={{ padding: 0 }}>
<TabbedCardHeader>
<RoutedTabs
match={match}
history={history}
@ -149,7 +138,7 @@ class Organization extends Component {
tabsArray={tabsArray}
/>
<CardCloseButton linkTo="/organizations" />
</CardHeader>
</TabbedCardHeader>
);
if (!isInitialized) {

View File

@ -3,16 +3,11 @@ import PropTypes from 'prop-types';
import { useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react';
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 { OrganizationsAPI } from '@api';
import { Config } from '@contexts/Config';
import { CardBody } from '@components/Card';
import CardCloseButton from '@components/CardCloseButton';
import OrganizationForm from '../shared/OrganizationForm';

View File

@ -2,19 +2,13 @@ import React, { useEffect, useState } from 'react';
import { Link, useRouteMatch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { CardBody as PFCardBody, Button } from '@patternfly/react-core';
import styled from 'styled-components';
import { Button } from '@patternfly/react-core';
import { OrganizationsAPI } from '@api';
import { DetailList, Detail } from '@components/DetailList';
import { DetailList, Detail, UserDateDetail } from '@components/DetailList';
import { CardBody } from '@components/Card';
import { ChipGroup, Chip } from '@components/Chip';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import { formatDateString } from '@util/dates';
const CardBody = styled(PFCardBody)`
padding-top: 20px;
`;
function OrganizationDetail({ i18n, organization }) {
const {
@ -72,10 +66,15 @@ function OrganizationDetail({ i18n, organization }) {
label={i18n._(t`Ansible Environment`)}
value={custom_virtualenv}
/>
<Detail label={i18n._(t`Created`)} value={formatDateString(created)} />
<Detail
<UserDateDetail
label={i18n._(t`Created`)}
date={created}
user={summary_fields.created_by}
/>
<UserDateDetail
label={i18n._(t`Last Modified`)}
value={formatDateString(modified)}
date={modified}
user={summary_fields.modified_by}
/>
{instanceGroups && instanceGroups.length > 0 && (
<Detail

View File

@ -1,8 +1,7 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { useHistory } from 'react-router-dom';
import { CardBody } from '@patternfly/react-core';
import { CardBody } from '@components/Card';
import { OrganizationsAPI } from '@api';
import { Config } from '@contexts/Config';

View File

@ -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';
@ -19,13 +15,6 @@ import ProjectJobTemplates from './ProjectJobTemplates';
import ProjectSchedules from './ProjectSchedules';
import { OrganizationsAPI, ProjectsAPI } 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 Project extends Component {
constructor(props) {
super(props);
@ -161,7 +150,7 @@ class Project extends Component {
});
let cardHeader = (
<CardHeader style={{ padding: 0 }}>
<TabbedCardHeader>
<RoutedTabs
match={match}
history={history}
@ -169,7 +158,7 @@ class Project extends Component {
tabsArray={tabsArray}
/>
<CardCloseButton linkTo="/projects" />
</CardHeader>
</TabbedCardHeader>
);
if (!isInitialized) {

View File

@ -5,12 +5,12 @@ import { t } from '@lingui/macro';
import styled from 'styled-components';
import {
Card as _Card,
CardBody,
CardHeader,
PageSection,
Tooltip,
} from '@patternfly/react-core';
import CardCloseButton from '@components/CardCloseButton';
import { CardBody } from '@components/Card';
import ProjectForm from '../shared/ProjectForm';
import { ProjectsAPI } from '@api';
@ -44,7 +44,7 @@ function ProjectAdd({ history, i18n }) {
return (
<PageSection>
<Card>
<CardHeader css="text-align: right">
<CardHeader className="at-u-textRight">
<Tooltip content={i18n._(t`Close`)} position="top">
<CardCloseButton onClick={handleCancel} />
</Tooltip>

View File

@ -4,10 +4,10 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import { Project } from '@types';
import { formatDateString } from '@util/dates';
import { Config } from '@contexts/Config';
import { Button, CardBody, List, ListItem } from '@patternfly/react-core';
import { DetailList, Detail } from '@components/DetailList';
import { Button, List, ListItem } from '@patternfly/react-core';
import { CardBody } from '@components/Card';
import { DetailList, Detail, UserDateDetail } from '@components/DetailList';
import { CredentialChip } from '@components/Chip';
import { toTitleCase } from '@util/strings';
@ -64,32 +64,8 @@ function ProjectDetail({ project, i18n }) {
);
}
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 (
<CardBody css="padding-top: 20px">
<CardBody>
<DetailList gutter="sm">
<Detail
label={i18n._(t`Name`)}
@ -150,10 +126,16 @@ function ProjectDetail({ project, i18n }) {
)}
</Config>
<Detail label={i18n._(t`Playbook Directory`)} value={local_path} />
{/* TODO: Link to user in users */}
<Detail label={i18n._(t`Created`)} value={createdBy} />
{/* TODO: Link to user in users */}
<Detail label={i18n._(t`Last Modified`)} value={modifiedBy} />
<UserDateDetail
label={i18n._(t`Created`)}
date={created}
user={summary_fields.created_by}
/>
<UserDateDetail
label={i18n._(t`Last Modified`)}
date={modified}
user={summary_fields.modified_by}
/>
</DetailList>
<ActionButtonWrapper>
{summary_fields.user_capabilities &&

View File

@ -98,13 +98,15 @@ describe('<ProjectDetail />', () => {
`${mockProject.scm_update_cache_timeout} Seconds`
);
assertDetail('Ansible Environment', mockProject.custom_virtualenv);
assertDetail(
'Created',
`10/10/2019, 1:15:06 AM by ${mockProject.summary_fields.created_by.username}`
const dateDetails = wrapper.find('UserDateDetail');
expect(dateDetails).toHaveLength(2);
expect(dateDetails.at(0).prop('label')).toEqual('Created');
expect(dateDetails.at(0).prop('date')).toEqual(
'2019-10-10T01:15:06.780472Z'
);
assertDetail(
'Last Modified',
`10/10/2019, 1:15:06 AM by ${mockProject.summary_fields.modified_by.username}`
expect(dateDetails.at(1).prop('label')).toEqual('Last Modified');
expect(dateDetails.at(1).prop('date')).toEqual(
'2019-10-10T01:15:06.780490Z'
);
expect(
wrapper

View File

@ -3,16 +3,14 @@ import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import {
Card as _Card,
CardBody,
CardHeader,
Tooltip,
} from '@patternfly/react-core';
import { Card as _Card, CardHeader, Tooltip } from '@patternfly/react-core';
import { CardBody } from '@components/Card';
import CardCloseButton from '@components/CardCloseButton';
import ProjectForm from '../shared/ProjectForm';
import { ProjectsAPI } from '@api';
// TODO: we are doing this in multiple add/edit screens -- move to
// common component?
const Card = styled(_Card)`
--pf-c-card--child--PaddingLeft: 0;
--pf-c-card--child--PaddingRight: 0;
@ -41,7 +39,7 @@ function ProjectEdit({ project, history, i18n }) {
return (
<Card>
<CardHeader css="text-align: right">
<CardHeader className="at-u-textRight">
<Tooltip content={i18n._(t`Close`)} position="top">
<CardCloseButton onClick={handleCancel} />
</Tooltip>

View File

@ -1,5 +1,5 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
import { CardBody } from '@components/Card';
class ProjectJobTemplates extends Component {
render() {

View File

@ -1,5 +1,5 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
import { CardBody } from '@components/Card';
class ProjectSchedules extends Component {
render() {

View File

@ -2,26 +2,15 @@ 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 CardCloseButton from '@components/CardCloseButton';
import { TabbedCardHeader } from '@components/Card';
import RoutedTabs from '@components/RoutedTabs';
import ContentError from '@components/ContentError';
import TeamDetail from './TeamDetail';
import TeamEdit from './TeamEdit';
import { TeamsAPI } 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 Team extends Component {
constructor(props) {
super(props);
@ -81,7 +70,7 @@ class Team extends Component {
];
let cardHeader = (
<CardHeader style={{ padding: 0 }}>
<TabbedCardHeader>
<RoutedTabs
match={match}
history={history}
@ -89,7 +78,7 @@ class Team extends Component {
tabsArray={tabsArray}
/>
<CardCloseButton linkTo="/teams" />
</CardHeader>
</TabbedCardHeader>
);
if (!isInitialized) {

View File

@ -2,16 +2,11 @@ import React from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
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 { TeamsAPI } from '@api';
import { Config } from '@contexts/Config';
import { CardBody } from '@components/Card';
import CardCloseButton from '@components/CardCloseButton';
import TeamForm from '../shared/TeamForm';

View File

@ -2,16 +2,12 @@ import React, { Component } from 'react';
import { Link, withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { CardBody as PFCardBody, Button } from '@patternfly/react-core';
import styled from 'styled-components';
import { Button } from '@patternfly/react-core';
import { CardBody } from '@components/Card';
import { DetailList, Detail } from '@components/DetailList';
import { formatDateString } from '@util/dates';
const CardBody = styled(PFCardBody)`
padding-top: 20px;
`;
class TeamDetail extends Component {
render() {
const {

View File

@ -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 { TeamsAPI } from '@api';
import { Config } from '@contexts/Config';

View File

@ -2,13 +2,8 @@ import React, { useState } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Card,
CardBody,
CardHeader,
PageSection,
Tooltip,
} from '@patternfly/react-core';
import { Card, CardHeader, PageSection, Tooltip } from '@patternfly/react-core';
import { CardBody } from '@components/Card';
import CardCloseButton from '@components/CardCloseButton';
import JobTemplateForm from '../shared/JobTemplateForm';
import { JobTemplatesAPI } from '@api';

View File

@ -2,7 +2,6 @@ import React, { Component, Fragment } from 'react';
import { Link, withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import {
CardBody,
Button,
TextList,
TextListItem,
@ -12,12 +11,12 @@ import {
import styled from 'styled-components';
import { t } from '@lingui/macro';
import { CardBody } from '@components/Card';
import ContentError from '@components/ContentError';
import LaunchButton from '@components/LaunchButton';
import ContentLoading from '@components/ContentLoading';
import { ChipGroup, Chip, CredentialChip } from '@components/Chip';
import { DetailList, Detail } from '@components/DetailList';
import { formatDateString } from '@util/dates';
import { DetailList, Detail, UserDateDetail } from '@components/DetailList';
import { JobTemplatesAPI } from '@api';
const ButtonGroup = styled.div`
@ -112,32 +111,6 @@ class JobTemplateDetail extends Component {
const renderOptionsField =
become_enabled || host_config_key || allow_simultaneous || use_fact_cache;
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);
}
}
const renderOptions = (
<TextList component={TextListVariants.ul}>
{become_enabled && (
@ -195,7 +168,7 @@ class JobTemplateDetail extends Component {
return (
isInitialized && (
<CardBody css="padding-top: 20px;">
<CardBody>
<DetailList gutter="sm">
<Detail
label={i18n._(t`Name`)}
@ -239,18 +212,16 @@ class JobTemplateDetail extends Component {
value={verbosityDetails[0].details}
/>
<Detail label={i18n._(t`Timeout`)} value={timeout || '0'} />
{createdBy && (
<Detail
label={i18n._(t`Created`)}
value={createdBy} // TODO: link to user in users
/>
)}
{modifiedBy && (
<Detail
label={i18n._(t`Last Modified`)}
value={modifiedBy} // TODO: link to user in users
/>
)}
<UserDateDetail
label={i18n._(t`Created`)}
date={created}
user={summary_fields.created_by}
/>
<UserDateDetail
label={i18n._(t`Last Modified`)}
date={modified}
user={summary_fields.modified_by}
/>
<Detail
label={i18n._(t`Show Changes`)}
value={diff_mode ? 'On' : 'Off'}

View File

@ -1,7 +1,7 @@
/* eslint react/no-unused-state: 0 */
import React, { Component } from 'react';
import { withRouter, Redirect } from 'react-router-dom';
import { CardBody } from '@patternfly/react-core';
import { CardBody } from '@components/Card';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import { JobTemplatesAPI, ProjectsAPI } from '@api';

View File

@ -1,8 +1,9 @@
import React, { Component } 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 NotificationList from '@components/NotificationList';
@ -121,10 +122,10 @@ class Template extends Component {
});
let cardHeader = (
<CardHeader style={{ padding: 0 }}>
<TabbedCardHeader>
<RoutedTabs history={history} tabsArray={tabsArray} />
<CardCloseButton linkTo="/templates" />
</CardHeader>
</TabbedCardHeader>
);
if (location.pathname.endsWith('edit')) {

View File

@ -1,8 +1,9 @@
import React, { Component } 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 AppendBody from '@components/AppendBody';
import CardCloseButton from '@components/CardCloseButton';
import ContentError from '@components/ContentError';
@ -65,10 +66,10 @@ class WorkflowJobTemplate extends Component {
});
let cardHeader = hasContentLoading ? null : (
<CardHeader style={{ padding: 0 }}>
<TabbedCardHeader>
<RoutedTabs history={history} tabsArray={tabsArray} />
<CardCloseButton linkTo="/templates" />
</CardHeader>
</TabbedCardHeader>
);
if (location.pathname.endsWith('edit')) {

View File

@ -1,14 +1,13 @@
import React, { Component } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { CardBody } from '@patternfly/react-core';
import { CardBody } from '@components/Card';
import { DetailList } from '@components/DetailList';
class WorkflowJobTemplateDetail extends Component {
render() {
return (
<CardBody css="padding-top: 20px;">
<CardBody>
<DetailList gutter="sm" />
</CardBody>
);

View File

@ -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 UserTeams from './UserTeams';
import UserTokens from './UserTokens';
import { UsersAPI } 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 User extends Component {
constructor(props) {
super(props);
@ -90,7 +79,7 @@ class User extends Component {
];
let cardHeader = (
<CardHeader style={{ padding: 0 }}>
<TabbedCardHeader>
<RoutedTabs
match={match}
history={history}
@ -98,7 +87,7 @@ class User extends Component {
tabsArray={tabsArray}
/>
<CardCloseButton linkTo="/users" />
</CardHeader>
</TabbedCardHeader>
);
if (!isInitialized) {

View File

@ -5,11 +5,11 @@ import { t } from '@lingui/macro';
import styled from 'styled-components';
import {
Card as _Card,
CardBody,
CardHeader,
PageSection,
Tooltip,
} from '@patternfly/react-core';
import { CardBody } from '@components/Card';
import CardCloseButton from '@components/CardCloseButton';
import UserForm from '../shared/UserForm';
import { UsersAPI } from '@api';
@ -41,7 +41,7 @@ function UserAdd({ history, i18n }) {
return (
<PageSection>
<Card>
<CardHeader css="text-align: right">
<CardHeader className="at-u-textRight">
<Tooltip content={i18n._(t`Close`)} position="top">
<CardCloseButton onClick={handleCancel} />
</Tooltip>

View File

@ -2,16 +2,12 @@ import React, { Component } from 'react';
import { Link, withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { CardBody as PFCardBody, Button } from '@patternfly/react-core';
import styled from 'styled-components';
import { Button } from '@patternfly/react-core';
import { CardBody } from '@components/Card';
import { DetailList, Detail } from '@components/DetailList';
import { formatDateString } from '@util/dates';
const CardBody = styled(PFCardBody)`
padding-top: 20px;
`;
class UserDetail extends Component {
render() {
const {

View File

@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { CardBody } from '@patternfly/react-core';
import { CardBody } from '@components/Card';
import UserForm from '../shared/UserForm';
import { UsersAPI } from '@api';

View File

@ -1,5 +1,5 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
import { CardBody } from '@components/Card';
class UserAdd extends Component {
render() {

View File

@ -1,5 +1,5 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
import { CardBody } from '@components/Card';
class UserAdd extends Component {
render() {

View File

@ -1,5 +1,5 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
import { CardBody } from '@components/Card';
class UserAdd extends Component {
render() {

View File

@ -228,6 +228,14 @@ export const User = shape({
last_login: string,
});
// stripped-down User object found in summary_fields (e.g. modified_by)
export const SummaryFieldUser = shape({
id: number.isRequired,
username: string.isRequired,
first_name: string,
last_name: string,
});
export const Group = shape({
id: number.isRequired,
type: oneOf(['group']),