diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js
index f5f0c05330..cddf01e259 100644
--- a/awx/ui_next/src/api/index.js
+++ b/awx/ui_next/src/api/index.js
@@ -1,3 +1,4 @@
+import ActivityStream from './models/ActivityStream';
import AdHocCommands from './models/AdHocCommands';
import Applications from './models/Applications';
import Auth from './models/Auth';
@@ -39,6 +40,7 @@ import WorkflowJobTemplateNodes from './models/WorkflowJobTemplateNodes';
import WorkflowJobTemplates from './models/WorkflowJobTemplates';
import WorkflowJobs from './models/WorkflowJobs';
+const ActivityStreamAPI = new ActivityStream();
const AdHocCommandsAPI = new AdHocCommands();
const ApplicationsAPI = new Applications();
const AuthAPI = new Auth();
@@ -81,6 +83,7 @@ const WorkflowJobTemplatesAPI = new WorkflowJobTemplates();
const WorkflowJobsAPI = new WorkflowJobs();
export {
+ ActivityStreamAPI,
AdHocCommandsAPI,
ApplicationsAPI,
AuthAPI,
diff --git a/awx/ui_next/src/api/models/ActivityStream.js b/awx/ui_next/src/api/models/ActivityStream.js
new file mode 100644
index 0000000000..99b65bc634
--- /dev/null
+++ b/awx/ui_next/src/api/models/ActivityStream.js
@@ -0,0 +1,10 @@
+import Base from '../Base';
+
+class ActivityStream extends Base {
+ constructor(http) {
+ super(http);
+ this.baseUrl = '/api/v2/activity_stream/';
+ }
+}
+
+export default ActivityStream;
diff --git a/awx/ui_next/src/components/Breadcrumbs/Breadcrumbs.jsx b/awx/ui_next/src/components/Breadcrumbs/Breadcrumbs.jsx
deleted file mode 100644
index 93a9b3d7f4..0000000000
--- a/awx/ui_next/src/components/Breadcrumbs/Breadcrumbs.jsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import React, { Fragment } from 'react';
-import PropTypes from 'prop-types';
-import {
- PageSection as PFPageSection,
- PageSectionVariants,
- Breadcrumb,
- BreadcrumbItem,
- BreadcrumbHeading,
-} from '@patternfly/react-core';
-import { Link, Route, useRouteMatch } from 'react-router-dom';
-
-import styled from 'styled-components';
-
-const PageSection = styled(PFPageSection)`
- padding-top: 10px;
- padding-bottom: 10px;
-`;
-
-const Breadcrumbs = ({ breadcrumbConfig }) => {
- const { light } = PageSectionVariants;
-
- return (
-
-
-
-
-
-
-
- );
-};
-
-const Crumb = ({ breadcrumbConfig, showDivider }) => {
- const match = useRouteMatch();
- const crumb = breadcrumbConfig[match.url];
-
- let crumbElement = (
-
- {crumb}
-
- );
-
- if (match.isExact) {
- crumbElement = (
-
- {crumb}
-
- );
- }
-
- if (!crumb) {
- crumbElement = null;
- }
-
- return (
-
- {crumbElement}
-
-
-
-
- );
-};
-
-Breadcrumbs.propTypes = {
- breadcrumbConfig: PropTypes.objectOf(PropTypes.string).isRequired,
-};
-
-Crumb.propTypes = {
- breadcrumbConfig: PropTypes.objectOf(PropTypes.string).isRequired,
-};
-
-export default Breadcrumbs;
diff --git a/awx/ui_next/src/components/Breadcrumbs/index.js b/awx/ui_next/src/components/Breadcrumbs/index.js
deleted file mode 100644
index 3ff68ca589..0000000000
--- a/awx/ui_next/src/components/Breadcrumbs/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from './Breadcrumbs';
diff --git a/awx/ui_next/src/components/ListHeader/ListHeader.jsx b/awx/ui_next/src/components/ListHeader/ListHeader.jsx
index 6e1f9cb4e9..d5e359c3bc 100644
--- a/awx/ui_next/src/components/ListHeader/ListHeader.jsx
+++ b/awx/ui_next/src/components/ListHeader/ListHeader.jsx
@@ -85,7 +85,12 @@ class ListHeader extends React.Component {
pushHistoryState(params) {
const { history, qsConfig } = this.props;
const { pathname } = history.location;
- const encodedParams = encodeNonDefaultQueryString(qsConfig, params);
+ const nonNamespacedParams = parseQueryString({}, history.location.search);
+ const encodedParams = encodeNonDefaultQueryString(
+ qsConfig,
+ params,
+ nonNamespacedParams
+ );
history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname);
}
diff --git a/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx b/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx
index 7f5fe9afdd..92b471c8c3 100644
--- a/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx
+++ b/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx
@@ -61,7 +61,12 @@ function PaginatedDataList({
};
const pushHistoryState = params => {
- const encodedParams = encodeNonDefaultQueryString(qsConfig, params);
+ const nonNamespacedParams = parseQueryString({}, history.location.search);
+ const encodedParams = encodeNonDefaultQueryString(
+ qsConfig,
+ params,
+ nonNamespacedParams
+ );
history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname);
};
diff --git a/awx/ui_next/src/components/PaginatedTable/HeaderRow.jsx b/awx/ui_next/src/components/PaginatedTable/HeaderRow.jsx
index 259cc39bac..14c5c0b8ee 100644
--- a/awx/ui_next/src/components/PaginatedTable/HeaderRow.jsx
+++ b/awx/ui_next/src/components/PaginatedTable/HeaderRow.jsx
@@ -23,7 +23,12 @@ export default function HeaderRow({ qsConfig, children }) {
order_by: order === 'asc' ? key : `-${key}`,
page: null,
});
- const encodedParams = encodeNonDefaultQueryString(qsConfig, newParams);
+ const nonNamespacedParams = parseQueryString({}, history.location.search);
+ const encodedParams = encodeNonDefaultQueryString(
+ qsConfig,
+ newParams,
+ nonNamespacedParams
+ );
history.push(
encodedParams
? `${location.pathname}?${encodedParams}`
diff --git a/awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx b/awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx
index 9892df34fe..42bf01a638 100644
--- a/awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx
+++ b/awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx
@@ -40,8 +40,13 @@ function PaginatedTable({
const history = useHistory();
const pushHistoryState = params => {
- const { pathname } = history.location;
- const encodedParams = encodeNonDefaultQueryString(qsConfig, params);
+ const { pathname, search } = history.location;
+ const nonNamespacedParams = parseQueryString({}, search);
+ const encodedParams = encodeNonDefaultQueryString(
+ qsConfig,
+ params,
+ nonNamespacedParams
+ );
history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname);
};
diff --git a/awx/ui_next/src/components/ScreenHeader/ScreenHeader.jsx b/awx/ui_next/src/components/ScreenHeader/ScreenHeader.jsx
new file mode 100644
index 0000000000..d856a430ee
--- /dev/null
+++ b/awx/ui_next/src/components/ScreenHeader/ScreenHeader.jsx
@@ -0,0 +1,135 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import {
+ Button,
+ PageSection,
+ PageSectionVariants,
+ Breadcrumb,
+ BreadcrumbItem,
+ Title,
+ Tooltip,
+} from '@patternfly/react-core';
+import { HistoryIcon } from '@patternfly/react-icons';
+import { Link, Route, useRouteMatch } from 'react-router-dom';
+
+const ScreenHeader = ({ breadcrumbConfig, i18n, streamType }) => {
+ const { light } = PageSectionVariants;
+ const oneCrumbMatch = useRouteMatch({
+ path: Object.keys(breadcrumbConfig)[0],
+ strict: true,
+ });
+ const isOnlyOneCrumb = oneCrumbMatch && oneCrumbMatch.isExact;
+
+ return (
+
+
+
+ {!isOnlyOneCrumb && (
+
+
+
+
+
+ )}
+
+
+ {streamType !== 'none' && (
+
+
+
+
+
+ )}
+
+
+ );
+};
+
+const ActualTitle = ({ breadcrumbConfig }) => {
+ const match = useRouteMatch();
+ const title = breadcrumbConfig[match.url];
+ let titleElement;
+
+ if (match.isExact) {
+ titleElement = (
+
+ {title}
+
+ );
+ }
+
+ if (!title) {
+ titleElement = null;
+ }
+
+ return (
+
+ {titleElement}
+
+
+
+
+ );
+};
+
+const Crumb = ({ breadcrumbConfig, showDivider }) => {
+ const match = useRouteMatch();
+ const crumb = breadcrumbConfig[match.url];
+
+ let crumbElement = (
+
+ {crumb}
+
+ );
+
+ if (match.isExact) {
+ crumbElement = null;
+ }
+
+ if (!crumb) {
+ crumbElement = null;
+ }
+ return (
+
+ {crumbElement}
+
+
+
+
+ );
+};
+
+ScreenHeader.propTypes = {
+ breadcrumbConfig: PropTypes.objectOf(PropTypes.string).isRequired,
+};
+
+Crumb.propTypes = {
+ breadcrumbConfig: PropTypes.objectOf(PropTypes.string).isRequired,
+};
+
+export default withI18n()(ScreenHeader);
diff --git a/awx/ui_next/src/components/Breadcrumbs/Breadcrumbs.test.jsx b/awx/ui_next/src/components/ScreenHeader/ScreenHeader.test.jsx
similarity index 70%
rename from awx/ui_next/src/components/Breadcrumbs/Breadcrumbs.test.jsx
rename to awx/ui_next/src/components/ScreenHeader/ScreenHeader.test.jsx
index 83e5e8c3fe..64f3a92c53 100644
--- a/awx/ui_next/src/components/Breadcrumbs/Breadcrumbs.test.jsx
+++ b/awx/ui_next/src/components/ScreenHeader/ScreenHeader.test.jsx
@@ -1,9 +1,15 @@
import React from 'react';
-import { mount } from 'enzyme';
import { MemoryRouter } from 'react-router-dom';
-import Breadcrumbs from './Breadcrumbs';
-describe('', () => {
+import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
+
+import ScreenHeader from './ScreenHeader';
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+}));
+
+describe('', () => {
let breadcrumbWrapper;
let breadcrumb;
let breadcrumbItem;
@@ -17,15 +23,15 @@ describe('', () => {
};
const findChildren = () => {
- breadcrumb = breadcrumbWrapper.find('Breadcrumb');
+ breadcrumb = breadcrumbWrapper.find('ScreenHeader');
breadcrumbItem = breadcrumbWrapper.find('BreadcrumbItem');
- breadcrumbHeading = breadcrumbWrapper.find('BreadcrumbHeading');
+ breadcrumbHeading = breadcrumbWrapper.find('Title');
};
test('initially renders succesfully', () => {
- breadcrumbWrapper = mount(
+ breadcrumbWrapper = mountWithContexts(
-
+
);
@@ -51,9 +57,9 @@ describe('', () => {
];
routes.forEach(([location, crumbLength]) => {
- breadcrumbWrapper = mount(
+ breadcrumbWrapper = mountWithContexts(
-
+
);
diff --git a/awx/ui_next/src/components/ScreenHeader/index.js b/awx/ui_next/src/components/ScreenHeader/index.js
new file mode 100644
index 0000000000..7f5ab32733
--- /dev/null
+++ b/awx/ui_next/src/components/ScreenHeader/index.js
@@ -0,0 +1 @@
+export { default } from './ScreenHeader';
diff --git a/awx/ui_next/src/routeConfig.js b/awx/ui_next/src/routeConfig.js
index 0b9e0591fa..a343a7d1e0 100644
--- a/awx/ui_next/src/routeConfig.js
+++ b/awx/ui_next/src/routeConfig.js
@@ -1,5 +1,6 @@
import { t } from '@lingui/macro';
+import ActivityStream from './screens/ActivityStream';
import Applications from './screens/Application';
import Credentials from './screens/Credential';
import CredentialTypes from './screens/CredentialType';
@@ -44,6 +45,11 @@ function getRouteConfig(i18n) {
path: '/schedules',
screen: Schedules,
},
+ {
+ title: i18n._(t`Activity Stream`),
+ path: '/activity_stream',
+ screen: ActivityStream,
+ },
{
title: i18n._(t`Workflow Approvals`),
path: '/workflow_approvals',
diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx
new file mode 100644
index 0000000000..84dd9a6066
--- /dev/null
+++ b/awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx
@@ -0,0 +1,269 @@
+import React, { Fragment, useState, useEffect, useCallback } from 'react';
+import { useLocation, useHistory } from 'react-router-dom';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import {
+ Card,
+ PageSection,
+ PageSectionVariants,
+ SelectGroup,
+ Select,
+ SelectVariant,
+ SelectOption,
+ Title,
+} from '@patternfly/react-core';
+
+import DatalistToolbar from '../../components/DataListToolbar';
+import PaginatedTable, {
+ HeaderRow,
+ HeaderCell,
+} from '../../components/PaginatedTable';
+import useRequest from '../../util/useRequest';
+import {
+ getQSConfig,
+ parseQueryString,
+ replaceParams,
+ encodeNonDefaultQueryString,
+} from '../../util/qs';
+import { ActivityStreamAPI } from '../../api';
+
+import ActivityStreamListItem from './ActivityStreamListItem';
+
+function ActivityStream({ i18n }) {
+ const { light } = PageSectionVariants;
+
+ const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
+ const location = useLocation();
+ const history = useHistory();
+ const urlParams = new URLSearchParams(location.search);
+
+ const activityStreamType = urlParams.get('type') || 'all';
+
+ let typeParams = {};
+
+ if (activityStreamType !== 'all') {
+ typeParams = {
+ or__object1__in: activityStreamType,
+ or__object2__in: activityStreamType,
+ };
+ }
+
+ const QS_CONFIG = getQSConfig(
+ 'activity_stream',
+ {
+ page: 1,
+ page_size: 20,
+ order_by: '-timestamp',
+ },
+ ['id', 'page', 'page_size']
+ );
+
+ const {
+ result: { results, count, relatedSearchableKeys, searchableKeys },
+ error: contentError,
+ isLoading,
+ request: fetchActivityStream,
+ } = useRequest(
+ useCallback(
+ async () => {
+ const params = parseQueryString(QS_CONFIG, location.search);
+ const [response, actionsResponse] = await Promise.all([
+ ActivityStreamAPI.read({ ...params, ...typeParams }),
+ ActivityStreamAPI.readOptions(),
+ ]);
+ return {
+ results: response.data.results,
+ count: response.data.count,
+ relatedSearchableKeys: (
+ actionsResponse?.data?.related_search_fields || []
+ ).map(val => val.slice(0, -8)),
+ searchableKeys: Object.keys(
+ actionsResponse.data.actions?.GET || {}
+ ).filter(key => actionsResponse.data.actions?.GET[key].filterable),
+ };
+ },
+ [location] // eslint-disable-line react-hooks/exhaustive-deps
+ ),
+ {
+ results: [],
+ count: 0,
+ relatedSearchableKeys: [],
+ searchableKeys: [],
+ }
+ );
+ useEffect(() => {
+ fetchActivityStream();
+ }, [fetchActivityStream]);
+
+ const pushHistoryState = urlParamsToAdd => {
+ let searchParams = parseQueryString(QS_CONFIG, location.search);
+ searchParams = replaceParams(searchParams, { page: 1 });
+ const encodedParams = encodeNonDefaultQueryString(QS_CONFIG, searchParams, {
+ type: urlParamsToAdd.get('type'),
+ });
+ history.push(
+ encodedParams
+ ? `${location.pathname}?${encodedParams}`
+ : location.pathname
+ );
+ };
+
+ return (
+
+
+
+ {i18n._(t`Activity Stream`)}
+
+
+ {i18n._(t`Activity Stream type selector`)}
+
+
+
+
+
+
+ {i18n._(t`Time`)}
+
+ {i18n._(t`Initiated by`)}
+
+ {i18n._(t`Event`)}
+ {i18n._(t`Actions`)}
+
+ }
+ renderToolbar={props => (
+
+ )}
+ renderRow={streamItem => (
+
+ )}
+ />
+
+
+
+ );
+}
+
+export default withI18n()(ActivityStream);
diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStream.test.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStream.test.jsx
new file mode 100644
index 0000000000..b5afeef3d6
--- /dev/null
+++ b/awx/ui_next/src/screens/ActivityStream/ActivityStream.test.jsx
@@ -0,0 +1,25 @@
+import React from 'react';
+
+import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
+
+import ActivityStream from './ActivityStream';
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+}));
+
+describe('', () => {
+ let pageWrapper;
+
+ beforeEach(() => {
+ pageWrapper = mountWithContexts();
+ });
+
+ afterEach(() => {
+ pageWrapper.unmount();
+ });
+
+ test('initially renders without crashing', () => {
+ expect(pageWrapper.length).toBe(1);
+ });
+});
diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStreamDescription.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDescription.jsx
new file mode 100644
index 0000000000..d933e0c259
--- /dev/null
+++ b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDescription.jsx
@@ -0,0 +1,584 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+import { t } from '@lingui/macro';
+import { withI18n } from '@lingui/react';
+
+const buildAnchor = (obj, resource, activity) => {
+ let url;
+ let name;
+ // try/except pattern asserts that:
+ // if we encounter a case where a UI url can't or
+ // shouldn't be generated, just supply the name of the resource
+ try {
+ // catch-all case to avoid generating urls if a resource has been deleted
+ // if a resource still exists, it'll be serialized in the activity's summary_fields
+ if (!activity.summary_fields[resource]) {
+ throw new Error('The referenced resource no longer exists');
+ }
+ switch (resource) {
+ case 'custom_inventory_script':
+ url = `/inventory_scripts/${obj.id}/`;
+ break;
+ case 'group':
+ if (
+ activity.operation === 'create' ||
+ activity.operation === 'delete'
+ ) {
+ // the API formats the changes.inventory field as str 'myInventoryName-PrimaryKey'
+ const [inventory_id] = activity.changes.inventory
+ .split('-')
+ .slice(-1);
+ url = `/inventories/inventory/${inventory_id}/groups/${activity.changes.id}/details/`;
+ } else {
+ url = `/inventories/inventory/${
+ activity.summary_fields.inventory[0].id
+ }/groups/${activity.changes.id ||
+ activity.changes.object1_pk}/details/`;
+ }
+ break;
+ case 'host':
+ url = `/hosts/${obj.id}/`;
+ break;
+ case 'job':
+ url = `/jobs/${obj.id}/`;
+ break;
+ case 'inventory':
+ url =
+ obj?.kind === 'smart'
+ ? `/inventories/smart_inventory/${obj.id}/`
+ : `/inventories/inventory/${obj.id}/`;
+ break;
+ case 'schedule':
+ // schedule urls depend on the resource they're associated with
+ if (activity.summary_fields.job_template) {
+ const jt_id = activity.summary_fields.job_template[0].id;
+ url = `/templates/job_template/${jt_id}/schedules/${obj.id}/`;
+ } else if (activity.summary_fields.workflow_job_template) {
+ const wfjt_id = activity.summary_fields.workflow_job_template[0].id;
+ url = `/templates/workflow_job_template/${wfjt_id}/schedules/${obj.id}/`;
+ } else if (activity.summary_fields.project) {
+ url = `/projects/${activity.summary_fields.project[0].id}/schedules/${obj.id}/`;
+ } else if (activity.summary_fields.system_job_template) {
+ url = null;
+ } else {
+ // urls for inventory sync schedules currently depend on having
+ // an inventory id and group id
+ throw new Error(
+ 'activity.summary_fields to build this url not implemented yet'
+ );
+ }
+ break;
+ case 'setting':
+ url = `/settings/`;
+ break;
+ case 'notification_template':
+ url = `/notification_templates/${obj.id}/`;
+ break;
+ case 'role':
+ throw new Error(
+ 'role object management is not consolidated to a single UI view'
+ );
+ case 'job_template':
+ url = `/templates/job_template/${obj.id}/`;
+ break;
+ case 'workflow_job_template':
+ url = `/templates/workflow_job_template/${obj.id}/`;
+ break;
+ case 'workflow_job_template_node': {
+ const {
+ id: wfjt_id,
+ name: wfjt_name,
+ } = activity.summary_fields.workflow_job_template[0];
+ url = `/templates/workflow_job_template/${wfjt_id}/`;
+ name = wfjt_name;
+ break;
+ }
+ case 'workflow_job':
+ url = `/workflows/${obj.id}/`;
+ break;
+ case 'label':
+ url = null;
+ break;
+ case 'inventory_source': {
+ const inventoryId = (obj.inventory || '').split('-').reverse()[0];
+ url = `/inventories/inventory/${inventoryId}/sources/${obj.id}/details/`;
+ break;
+ }
+ case 'o_auth2_application':
+ url = `/applications/${obj.id}/`;
+ break;
+ case 'workflow_approval':
+ url = `/jobs/workflow/${activity.summary_fields.workflow_job[0].id}/output/`;
+ name = `${activity.summary_fields.workflow_job[0].name} | ${activity.summary_fields.workflow_approval[0].name}`;
+ break;
+ case 'workflow_approval_template':
+ url = `/templates/workflow_job_template/${activity.summary_fields.workflow_job_template[0].id}/visualizer/`;
+ name = `${activity.summary_fields.workflow_job_template[0].name} | ${activity.summary_fields.workflow_approval_template[0].name}`;
+ break;
+ default:
+ url = `/${resource}s/${obj.id}/`;
+ }
+
+ name = name || obj.name || obj.username;
+
+ if (url) {
+ return {name};
+ }
+
+ return {name};
+ } catch (err) {
+ return {obj.name || obj.username || ''};
+ }
+};
+
+const getPastTense = item => {
+ return /e$/.test(item) ? `${item}d` : `${item}ed`;
+};
+
+const isGroupRelationship = item => {
+ return (
+ item.object1 === 'group' &&
+ item.object2 === 'group' &&
+ item.summary_fields.group.length > 1
+ );
+};
+
+const buildLabeledLink = (label, link) => {
+ return (
+
+ {label} {link}
+
+ );
+};
+
+function ActivityStreamDescription({ i18n, activity }) {
+ const labeledLinks = [];
+ // Activity stream objects will outlive the resources they reference
+ // in that case, summary_fields will not be available - show generic error text instead
+ try {
+ switch (activity.object_association) {
+ // explicit role dis+associations
+ case 'role': {
+ let { object1, object2 } = activity;
+
+ // if object1 winds up being the role's resource, we need to swap the objects
+ // in order to make the sentence make sense.
+ if (activity.object_type === object1) {
+ object1 = activity.object2;
+ object2 = activity.object1;
+ }
+
+ // object1 field is resource targeted by the dis+association
+ // object2 field is the resource the role is inherited from
+ // summary_field.role[0] contains ref info about the role
+ switch (activity.operation) {
+ // expected outcome: "disassociated role_name from "
+ case 'disassociate':
+ if (isGroupRelationship(activity)) {
+ labeledLinks.push(
+ buildLabeledLink(
+ getPastTense(activity.operation),
+ buildAnchor(
+ activity.summary_fields.group[1],
+ object2,
+ activity
+ )
+ )
+ );
+ labeledLinks.push(
+ buildLabeledLink(
+ `${activity.summary_fields.role[0].role_field} from`,
+ buildAnchor(
+ activity.summary_fields.group[0],
+ object1,
+ activity
+ )
+ )
+ );
+ } else {
+ labeledLinks.push(
+ buildLabeledLink(
+ getPastTense(activity.operation),
+ buildAnchor(
+ activity.summary_fields[object2][0],
+ object2,
+ activity
+ )
+ )
+ );
+ labeledLinks.push(
+ buildLabeledLink(
+ `${activity.summary_fields.role[0].role_field} from`,
+ buildAnchor(
+ activity.summary_fields[object1][0],
+ object1,
+ activity
+ )
+ )
+ );
+ }
+ break;
+ // expected outcome: "associated role_name to "
+ case 'associate':
+ if (isGroupRelationship(activity)) {
+ labeledLinks.push(
+ buildLabeledLink(
+ getPastTense(activity.operation),
+ buildAnchor(
+ activity.summary_fields.group[1],
+ object2,
+ activity
+ )
+ )
+ );
+ labeledLinks.push(
+ buildLabeledLink(
+ `${activity.summary_fields.role[0].role_field} to`,
+ buildAnchor(
+ activity.summary_fields.group[0],
+ object1,
+ activity
+ )
+ )
+ );
+ } else {
+ labeledLinks.push(
+ buildLabeledLink(
+ getPastTense(activity.operation),
+ buildAnchor(
+ activity.summary_fields[object2][0],
+ object2,
+ activity
+ )
+ )
+ );
+ labeledLinks.push(
+ buildLabeledLink(
+ `${activity.summary_fields.role[0].role_field} to`,
+ buildAnchor(
+ activity.summary_fields[object1][0],
+ object1,
+ activity
+ )
+ )
+ );
+ }
+ break;
+ default:
+ break;
+ }
+ break;
+ // inherited role dis+associations (logic identical to case 'role')
+ }
+ case 'parents':
+ // object1 field is resource targeted by the dis+association
+ // object2 field is the resource the role is inherited from
+ // summary_field.role[0] contains ref info about the role
+ switch (activity.operation) {
+ // expected outcome: "disassociated role_name from "
+ case 'disassociate':
+ if (isGroupRelationship(activity)) {
+ labeledLinks.push(
+ buildLabeledLink(
+ `${getPastTense(activity.operation)} ${activity.object2}`,
+ buildAnchor(
+ activity.summary_fields.group[1],
+ activity.object2,
+ activity
+ )
+ )
+ );
+ labeledLinks.push(
+ buildLabeledLink(
+ `from ${activity.object1}`,
+ buildAnchor(
+ activity.summary_fields.group[0],
+ activity.object1,
+ activity
+ )
+ )
+ );
+ } else {
+ labeledLinks.push(
+ buildLabeledLink(
+ getPastTense(activity.operation),
+ buildAnchor(
+ activity.summary_fields[activity.object2][0],
+ activity.object2,
+ activity
+ )
+ )
+ );
+ labeledLinks.push(
+ buildLabeledLink(
+ `${activity.summary_fields.role[0].role_field} from`,
+ buildAnchor(
+ activity.summary_fields[activity.object1][0],
+ activity.object1,
+ activity
+ )
+ )
+ );
+ }
+ break;
+ // expected outcome: "associated role_name to "
+ case 'associate':
+ if (isGroupRelationship(activity)) {
+ labeledLinks.push(
+ buildLabeledLink(
+ `${getPastTense(activity.operation)} ${activity.object1}`,
+ buildAnchor(
+ activity.summary_fields.group[0],
+ activity.object1,
+ activity
+ )
+ )
+ );
+ labeledLinks.push(
+ buildLabeledLink(
+ `to ${activity.object2}`,
+ buildAnchor(
+ activity.summary_fields.group[1],
+ activity.object2,
+ activity
+ )
+ )
+ );
+ } else {
+ labeledLinks.push(
+ buildLabeledLink(
+ getPastTense(activity.operation),
+ buildAnchor(
+ activity.summary_fields[activity.object2][0],
+ activity.object2,
+ activity
+ )
+ )
+ );
+ labeledLinks.push(
+ buildLabeledLink(
+ `${activity.summary_fields.role[0].role_field} to`,
+ buildAnchor(
+ activity.summary_fields[activity.object1][0],
+ activity.object1,
+ activity
+ )
+ )
+ );
+ }
+ break;
+ default:
+ break;
+ }
+ break;
+ // CRUD operations / resource on resource dis+associations
+ default:
+ switch (activity.operation) {
+ // expected outcome: "disassociated from "
+ case 'disassociate':
+ if (isGroupRelationship(activity)) {
+ labeledLinks.push(
+ buildLabeledLink(
+ `${getPastTense(activity.operation)} ${activity.object2}`,
+ buildAnchor(
+ activity.summary_fields.group[1],
+ activity.object2,
+ activity
+ )
+ )
+ );
+ labeledLinks.push(
+ buildLabeledLink(
+ `from ${activity.object1}`,
+ buildAnchor(
+ activity.summary_fields.group[0],
+ activity.object1,
+ activity
+ )
+ )
+ );
+ } else if (
+ activity.object1 === 'workflow_job_template_node' &&
+ activity.object2 === 'workflow_job_template_node'
+ ) {
+ labeledLinks.push(
+ buildLabeledLink(
+ `${getPastTense(activity.operation)} two nodes on workflow`,
+ buildAnchor(
+ activity.summary_fields[activity.object1[0]],
+ activity.object1,
+ activity
+ )
+ )
+ );
+ } else {
+ labeledLinks.push(
+ buildLabeledLink(
+ `${getPastTense(activity.operation)} ${activity.object2}`,
+ buildAnchor(
+ activity.summary_fields[activity.object2][0],
+ activity.object2,
+ activity
+ )
+ )
+ );
+ labeledLinks.push(
+ buildLabeledLink(
+ `from ${activity.object1}`,
+ buildAnchor(
+ activity.summary_fields[activity.object1][0],
+ activity.object1,
+ activity
+ )
+ )
+ );
+ }
+ break;
+ // expected outcome "associated to "
+ case 'associate':
+ // groups are the only resource that can be associated/disassociated into each other
+ if (isGroupRelationship(activity)) {
+ labeledLinks.push(
+ buildLabeledLink(
+ `${getPastTense(activity.operation)} ${activity.object1}`,
+ buildAnchor(
+ activity.summary_fields.group[0],
+ activity.object1,
+ activity
+ )
+ )
+ );
+ labeledLinks.push(
+ buildLabeledLink(
+ `to ${activity.object2}`,
+ buildAnchor(
+ activity.summary_fields.group[1],
+ activity.object2,
+ activity
+ )
+ )
+ );
+ } else if (
+ activity.object1 === 'workflow_job_template_node' &&
+ activity.object2 === 'workflow_job_template_node'
+ ) {
+ labeledLinks.push(
+ buildLabeledLink(
+ `${getPastTense(activity.operation)} two nodes on workflow`,
+ buildAnchor(
+ activity.summary_fields[activity.object1[0]],
+ activity.object1,
+ activity
+ )
+ )
+ );
+ } else {
+ labeledLinks.push(
+ buildLabeledLink(
+ `${getPastTense(activity.operation)} ${activity.object1}`,
+ buildAnchor(
+ activity.summary_fields[activity.object1][0],
+ activity.object1,
+ activity
+ )
+ )
+ );
+ labeledLinks.push(
+ buildLabeledLink(
+ `to ${activity.object2}`,
+ buildAnchor(
+ activity.summary_fields[activity.object2][0],
+ activity.object2,
+ activity
+ )
+ )
+ );
+ }
+ break;
+ case 'delete':
+ labeledLinks.push(
+ buildLabeledLink(
+ `${getPastTense(activity.operation)} ${activity.object1}`,
+ buildAnchor(activity.changes, activity.object1, activity)
+ )
+ );
+ break;
+ // expected outcome: "operation "
+ case 'update':
+ if (
+ activity.object1 === 'workflow_approval' &&
+ activity?.changes?.status?.length === 2
+ ) {
+ let operationText = '';
+ if (activity.changes.status[1] === 'successful') {
+ operationText = i18n._(t`approved`);
+ } else if (activity.changes.status[1] === 'failed') {
+ if (
+ activity.changes.timed_out &&
+ activity.changes.timed_out[1] === true
+ ) {
+ operationText = i18n._(t`timed out`);
+ } else {
+ operationText = i18n._(t`denied`);
+ }
+ } else {
+ operationText = i18n._(t`updated`);
+ }
+ labeledLinks.push(
+ buildLabeledLink(
+ `${operationText} ${activity.object1}`,
+ buildAnchor(
+ activity.summary_fields[activity.object1][0],
+ activity.object1,
+ activity
+ )
+ )
+ );
+ } else {
+ labeledLinks.push(
+ buildLabeledLink(
+ `${getPastTense(activity.operation)} ${activity.object1}`,
+ buildAnchor(
+ activity.summary_fields[activity.object1][0],
+ activity.object1,
+ activity
+ )
+ )
+ );
+ }
+ break;
+ case 'create':
+ labeledLinks.push(
+ buildLabeledLink(
+ `${getPastTense(activity.operation)} ${activity.object1}`,
+ buildAnchor(activity.changes, activity.object1, activity)
+ )
+ );
+ break;
+ default:
+ break;
+ }
+ break;
+ }
+ } catch (err) {
+ return {i18n._(t`Event summary not available`)};
+ }
+
+ return (
+
+ {labeledLinks.reduce(
+ (acc, x) =>
+ acc === null ? (
+ x
+ ) : (
+ <>
+ {acc} {x}
+ >
+ ),
+ null
+ )}
+
+ );
+}
+
+export default withI18n()(ActivityStreamDescription);
diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStreamDescription.test.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDescription.test.jsx
new file mode 100644
index 0000000000..9f3a2982d0
--- /dev/null
+++ b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDescription.test.jsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
+import ActivityStreamDescription from './ActivityStreamDescription';
+
+describe('ActivityStreamDescription', () => {
+ test('initially renders succesfully', () => {
+ const description = mountWithContexts(
+
+ );
+ expect(description.find('span').length).toBe(1);
+ });
+});
diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStreamDetailButton.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDetailButton.jsx
new file mode 100644
index 0000000000..22559831b2
--- /dev/null
+++ b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDetailButton.jsx
@@ -0,0 +1,66 @@
+import React, { useState } from 'react';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { Button, Modal } from '@patternfly/react-core';
+import { SearchPlusIcon } from '@patternfly/react-icons';
+
+import { formatDateString } from '../../util/dates';
+
+import { DetailList, Detail } from '../../components/DetailList';
+import { VariablesDetail } from '../../components/CodeMirrorInput';
+
+function ActivityStreamDetailButton({ i18n, streamItem, user, description }) {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const setting = streamItem?.summary_fields?.setting;
+ const changeRows = Math.max(
+ Object.keys(streamItem?.changes || []).length + 2,
+ 6
+ );
+
+ return (
+ <>
+
+ setIsOpen(false)}
+ >
+
+
+
+
+
+
+ {streamItem?.changes && (
+
+ )}
+
+
+ >
+ );
+}
+
+export default withI18n()(ActivityStreamDetailButton);
diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStreamDetailButton.test.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDetailButton.test.jsx
new file mode 100644
index 0000000000..40dc104117
--- /dev/null
+++ b/awx/ui_next/src/screens/ActivityStream/ActivityStreamDetailButton.test.jsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+
+import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
+import ActivityStreamDetailButton from './ActivityStreamDetailButton';
+
+jest.mock('../../api/models/ActivityStream');
+
+describe('', () => {
+ test('initially renders succesfully', () => {
+ mountWithContexts(
+ Bob}
+ description={foo}
+ />
+ );
+ });
+});
diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStreamListItem.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStreamListItem.jsx
new file mode 100644
index 0000000000..c5463565bb
--- /dev/null
+++ b/awx/ui_next/src/screens/ActivityStream/ActivityStreamListItem.jsx
@@ -0,0 +1,61 @@
+import React from 'react';
+import { shape } from 'prop-types';
+import { withI18n } from '@lingui/react';
+import { Tr, Td } from '@patternfly/react-table';
+import { t } from '@lingui/macro';
+import { Link } from 'react-router-dom';
+
+import { formatDateString } from '../../util/dates';
+import { ActionsTd, ActionItem } from '../../components/PaginatedTable';
+
+import ActivityStreamDetailButton from './ActivityStreamDetailButton';
+import ActivityStreamDescription from './ActivityStreamDescription';
+
+function ActivityStreamListItem({ streamItem, i18n }) {
+ ActivityStreamListItem.propTypes = {
+ streamItem: shape({}).isRequired,
+ };
+
+ const buildUser = item => {
+ let link;
+ if (item?.summary_fields?.actor?.id) {
+ link = (
+
+ {item.summary_fields.actor.username}
+
+ );
+ } else if (item?.summary_fields?.actor) {
+ link = i18n._(t`${item.summary_fields.actor.username} (deleted)`);
+ } else {
+ link = i18n._(t`system`);
+ }
+ return link;
+ };
+
+ const labelId = `check-action-${streamItem.id}`;
+ const user = buildUser(streamItem);
+ const description = ;
+
+ return (
+
+ |
+
+ {streamItem.timestamp ? formatDateString(streamItem.timestamp) : ''}
+ |
+ {user} |
+
+ {description}
+ |
+
+
+
+
+
+
+ );
+}
+export default withI18n()(ActivityStreamListItem);
diff --git a/awx/ui_next/src/screens/ActivityStream/ActivityStreamListItem.test.jsx b/awx/ui_next/src/screens/ActivityStream/ActivityStreamListItem.test.jsx
new file mode 100644
index 0000000000..c0f03d23ee
--- /dev/null
+++ b/awx/ui_next/src/screens/ActivityStream/ActivityStreamListItem.test.jsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
+import ActivityStreamListItem from './ActivityStreamListItem';
+
+jest.mock('../../api/models/ActivityStream');
+
+describe('', () => {
+ test('initially renders succesfully', () => {
+ mountWithContexts(
+
+ );
+ });
+});
diff --git a/awx/ui_next/src/screens/ActivityStream/index.js b/awx/ui_next/src/screens/ActivityStream/index.js
new file mode 100644
index 0000000000..5c0c72d9ef
--- /dev/null
+++ b/awx/ui_next/src/screens/ActivityStream/index.js
@@ -0,0 +1 @@
+export { default } from './ActivityStream';
diff --git a/awx/ui_next/src/screens/Application/Applications.jsx b/awx/ui_next/src/screens/Application/Applications.jsx
index 85995c8512..ae6fa94af1 100644
--- a/awx/ui_next/src/screens/Application/Applications.jsx
+++ b/awx/ui_next/src/screens/Application/Applications.jsx
@@ -12,7 +12,7 @@ import {
import ApplicationsList from './ApplicationsList';
import ApplicationAdd from './ApplicationAdd';
import Application from './Application';
-import Breadcrumbs from '../../components/Breadcrumbs';
+import ScreenHeader from '../../components/ScreenHeader';
import { Detail, DetailList } from '../../components/DetailList';
const ApplicationAlert = styled(Alert)`
@@ -45,7 +45,10 @@ function Applications({ i18n }) {
return (
<>
-
+
({
+ ...jest.requireActual('react-router-dom'),
+}));
+
describe('', () => {
let wrapper;
diff --git a/awx/ui_next/src/screens/Credential/Credentials.jsx b/awx/ui_next/src/screens/Credential/Credentials.jsx
index beca652fd5..d883aa0dca 100644
--- a/awx/ui_next/src/screens/Credential/Credentials.jsx
+++ b/awx/ui_next/src/screens/Credential/Credentials.jsx
@@ -3,7 +3,7 @@ import { Route, Switch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Config } from '../../contexts/Config';
-import Breadcrumbs from '../../components/Breadcrumbs';
+import ScreenHeader from '../../components/ScreenHeader';
import Credential from './Credential';
import CredentialAdd from './CredentialAdd';
import { CredentialList } from './CredentialList';
@@ -34,7 +34,10 @@ function Credentials({ i18n }) {
return (
<>
-
+
{({ me }) => }
diff --git a/awx/ui_next/src/screens/Credential/Credentials.test.jsx b/awx/ui_next/src/screens/Credential/Credentials.test.jsx
index f63c9d9b65..e5cb618fb2 100644
--- a/awx/ui_next/src/screens/Credential/Credentials.test.jsx
+++ b/awx/ui_next/src/screens/Credential/Credentials.test.jsx
@@ -3,6 +3,10 @@ import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Credentials from './Credentials';
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+}));
+
describe('', () => {
let wrapper;
@@ -30,8 +34,8 @@ describe('', () => {
},
});
- expect(wrapper.find('Crumb').length).toBe(1);
- expect(wrapper.find('BreadcrumbHeading').text()).toBe('Credentials');
+ expect(wrapper.find('Crumb').length).toBe(0);
+ expect(wrapper.find('Title').text()).toBe('Credentials');
});
test('should display create new credential breadcrumb heading', () => {
@@ -51,8 +55,6 @@ describe('', () => {
});
expect(wrapper.find('Crumb').length).toBe(2);
- expect(wrapper.find('BreadcrumbHeading').text()).toBe(
- 'Create New Credential'
- );
+ expect(wrapper.find('Title').text()).toBe('Create New Credential');
});
});
diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypes.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypes.jsx
index 7cdbbccb00..4eb47c1892 100644
--- a/awx/ui_next/src/screens/CredentialType/CredentialTypes.jsx
+++ b/awx/ui_next/src/screens/CredentialType/CredentialTypes.jsx
@@ -6,7 +6,7 @@ import { Route, Switch } from 'react-router-dom';
import CredentialTypeAdd from './CredentialTypeAdd';
import CredentialTypeList from './CredentialTypeList';
import CredentialType from './CredentialType';
-import Breadcrumbs from '../../components/Breadcrumbs';
+import ScreenHeader from '../../components/ScreenHeader';
function CredentialTypes({ i18n }) {
const [breadcrumbConfig, setBreadcrumbConfig] = useState({
@@ -33,7 +33,10 @@ function CredentialTypes({ i18n }) {
);
return (
<>
-
+
diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypes.test.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypes.test.jsx
index 468a55ac7a..bd8eff8e21 100644
--- a/awx/ui_next/src/screens/CredentialType/CredentialTypes.test.jsx
+++ b/awx/ui_next/src/screens/CredentialType/CredentialTypes.test.jsx
@@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import CredentialTypes from './CredentialTypes';
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+}));
+
describe('', () => {
let pageWrapper;
let pageSections;
diff --git a/awx/ui_next/src/screens/Dashboard/Dashboard.jsx b/awx/ui_next/src/screens/Dashboard/Dashboard.jsx
index f60e049631..714e6cf152 100644
--- a/awx/ui_next/src/screens/Dashboard/Dashboard.jsx
+++ b/awx/ui_next/src/screens/Dashboard/Dashboard.jsx
@@ -18,7 +18,7 @@ import {
import useRequest from '../../util/useRequest';
import { DashboardAPI } from '../../api';
-import Breadcrumbs from '../../components/Breadcrumbs';
+import ScreenHeader from '../../components/ScreenHeader';
import JobList from '../../components/JobList';
import ContentLoading from '../../components/ContentLoading';
import LineChart from './shared/LineChart';
@@ -117,7 +117,10 @@ function Dashboard({ i18n }) {
}
return (
-
+
({
+ ...jest.requireActual('react-router-dom'),
+}));
describe('', () => {
let pageWrapper;
diff --git a/awx/ui_next/src/screens/Host/Hosts.jsx b/awx/ui_next/src/screens/Host/Hosts.jsx
index f66a28aa94..4c128bc202 100644
--- a/awx/ui_next/src/screens/Host/Hosts.jsx
+++ b/awx/ui_next/src/screens/Host/Hosts.jsx
@@ -4,7 +4,7 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Config } from '../../contexts/Config';
-import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
+import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
import HostList from './HostList';
import HostAdd from './HostAdd';
@@ -37,7 +37,7 @@ function Hosts({ i18n }) {
return (
<>
-
+
diff --git a/awx/ui_next/src/screens/Host/Hosts.test.jsx b/awx/ui_next/src/screens/Host/Hosts.test.jsx
index ba199f842f..1c0b9821c0 100644
--- a/awx/ui_next/src/screens/Host/Hosts.test.jsx
+++ b/awx/ui_next/src/screens/Host/Hosts.test.jsx
@@ -5,6 +5,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Hosts from './Hosts';
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+}));
+
describe('', () => {
test('initially renders succesfully', () => {
mountWithContexts();
@@ -27,7 +31,7 @@ describe('', () => {
},
},
});
- expect(wrapper.find('BreadcrumbHeading').length).toBe(1);
+ expect(wrapper.find('Title').length).toBe(1);
wrapper.unmount();
});
diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.jsx
index 4fbdd5d9b2..a133bd91f2 100644
--- a/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.jsx
+++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.jsx
@@ -9,7 +9,7 @@ import InstanceGroup from './InstanceGroup';
import ContainerGroupAdd from './ContainerGroupAdd';
import ContainerGroup from './ContainerGroup';
-import Breadcrumbs from '../../components/Breadcrumbs';
+import ScreenHeader from '../../components/ScreenHeader';
function InstanceGroups({ i18n }) {
const [breadcrumbConfig, setBreadcrumbConfig] = useState({
@@ -54,7 +54,10 @@ function InstanceGroups({ i18n }) {
);
return (
<>
-
+
diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.test.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.test.jsx
index 321b6ca71b..db32e7e4eb 100644
--- a/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.test.jsx
+++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroups.test.jsx
@@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import InstanceGroups from './InstanceGroups';
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+}));
+
describe('', () => {
let pageWrapper;
let pageSections;
diff --git a/awx/ui_next/src/screens/Inventory/Inventories.jsx b/awx/ui_next/src/screens/Inventory/Inventories.jsx
index cf286c05eb..17ee02b3be 100644
--- a/awx/ui_next/src/screens/Inventory/Inventories.jsx
+++ b/awx/ui_next/src/screens/Inventory/Inventories.jsx
@@ -1,10 +1,10 @@
-import React, { useState, useCallback } from 'react';
+import React, { useState, useCallback, useRef } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Route, Switch } from 'react-router-dom';
import { Config } from '../../contexts/Config';
-import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
+import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
import { InventoryList } from './InventoryList';
import Inventory from './Inventory';
import SmartInventory from './SmartInventory';
@@ -12,14 +12,34 @@ import InventoryAdd from './InventoryAdd';
import SmartInventoryAdd from './SmartInventoryAdd';
function Inventories({ i18n }) {
- const [breadcrumbConfig, setBreadcrumbConfig] = useState({
+ const initScreenHeader = useRef({
'/inventories': i18n._(t`Inventories`),
'/inventories/inventory/add': i18n._(t`Create new inventory`),
'/inventories/smart_inventory/add': i18n._(t`Create new smart inventory`),
});
- const buildBreadcrumbConfig = useCallback(
- (inventory, nested, schedule) => {
+ const [breadcrumbConfig, setScreenHeader] = useState(
+ initScreenHeader.current
+ );
+
+ const [inventory, setInventory] = useState();
+ const [nestedObject, setNestedGroup] = useState();
+ const [schedule, setSchedule] = useState();
+
+ const setBreadcrumbConfig = useCallback(
+ (passedInventory, passedNestedObject, passedSchedule) => {
+ if (passedInventory && passedInventory.name !== inventory?.name) {
+ setInventory(passedInventory);
+ }
+ if (
+ passedNestedObject &&
+ passedNestedObject.name !== nestedObject?.name
+ ) {
+ setNestedGroup(passedNestedObject);
+ }
+ if (passedSchedule && passedSchedule.name !== schedule?.name) {
+ setSchedule(passedSchedule);
+ }
if (!inventory) {
return;
}
@@ -32,13 +52,8 @@ function Inventories({ i18n }) {
const inventoryGroupsPath = `${inventoryPath}/groups`;
const inventorySourcesPath = `${inventoryPath}/sources`;
- setBreadcrumbConfig({
- '/inventories': i18n._(t`Inventories`),
- '/inventories/inventory/add': i18n._(t`Create new inventory`),
- '/inventories/smart_inventory/add': i18n._(
- t`Create new smart inventory`
- ),
-
+ setScreenHeader({
+ ...initScreenHeader.current,
[inventoryPath]: `${inventory.name}`,
[`${inventoryPath}/access`]: i18n._(t`Access`),
[`${inventoryPath}/completed_jobs`]: i18n._(t`Completed jobs`),
@@ -47,55 +62,74 @@ function Inventories({ i18n }) {
[inventoryHostsPath]: i18n._(t`Hosts`),
[`${inventoryHostsPath}/add`]: i18n._(t`Create new host`),
- [`${inventoryHostsPath}/${nested?.id}`]: `${nested?.name}`,
- [`${inventoryHostsPath}/${nested?.id}/edit`]: i18n._(t`Edit details`),
- [`${inventoryHostsPath}/${nested?.id}/details`]: i18n._(
+ [`${inventoryHostsPath}/${nestedObject?.id}`]: `${nestedObject?.name}`,
+ [`${inventoryHostsPath}/${nestedObject?.id}/edit`]: i18n._(
+ t`Edit details`
+ ),
+ [`${inventoryHostsPath}/${nestedObject?.id}/details`]: i18n._(
t`Host details`
),
- [`${inventoryHostsPath}/${nested?.id}/completed_jobs`]: i18n._(
+ [`${inventoryHostsPath}/${nestedObject?.id}/completed_jobs`]: i18n._(
t`Completed jobs`
),
- [`${inventoryHostsPath}/${nested?.id}/facts`]: i18n._(t`Facts`),
- [`${inventoryHostsPath}/${nested?.id}/groups`]: i18n._(t`Groups`),
+ [`${inventoryHostsPath}/${nestedObject?.id}/facts`]: i18n._(t`Facts`),
+ [`${inventoryHostsPath}/${nestedObject?.id}/groups`]: i18n._(t`Groups`),
[inventoryGroupsPath]: i18n._(t`Groups`),
[`${inventoryGroupsPath}/add`]: i18n._(t`Create new group`),
- [`${inventoryGroupsPath}/${nested?.id}`]: `${nested?.name}`,
- [`${inventoryGroupsPath}/${nested?.id}/edit`]: i18n._(t`Edit details`),
- [`${inventoryGroupsPath}/${nested?.id}/details`]: i18n._(
+ [`${inventoryGroupsPath}/${nestedObject?.id}`]: `${nestedObject?.name}`,
+ [`${inventoryGroupsPath}/${nestedObject?.id}/edit`]: i18n._(
+ t`Edit details`
+ ),
+ [`${inventoryGroupsPath}/${nestedObject?.id}/details`]: i18n._(
t`Group details`
),
- [`${inventoryGroupsPath}/${nested?.id}/nested_hosts`]: i18n._(t`Hosts`),
- [`${inventoryGroupsPath}/${nested?.id}/nested_hosts/add`]: i18n._(
+ [`${inventoryGroupsPath}/${nestedObject?.id}/nested_hosts`]: i18n._(
+ t`Hosts`
+ ),
+ [`${inventoryGroupsPath}/${nestedObject?.id}/nested_hosts/add`]: i18n._(
t`Create new host`
),
- [`${inventoryGroupsPath}/${nested?.id}/nested_groups`]: i18n._(
- t`Groups`
+ [`${inventoryGroupsPath}/${nestedObject?.id}/nested_groups`]: i18n._(
+ t`Related Groups`
),
- [`${inventoryGroupsPath}/${nested?.id}/nested_groups/add`]: i18n._(
+ [`${inventoryGroupsPath}/${nestedObject?.id}/nested_groups/add`]: i18n._(
t`Create new group`
),
[`${inventorySourcesPath}`]: i18n._(t`Sources`),
[`${inventorySourcesPath}/add`]: i18n._(t`Create new source`),
- [`${inventorySourcesPath}/${nested?.id}`]: `${nested?.name}`,
- [`${inventorySourcesPath}/${nested?.id}/details`]: i18n._(t`Details`),
- [`${inventorySourcesPath}/${nested?.id}/edit`]: i18n._(t`Edit details`),
- [`${inventorySourcesPath}/${nested?.id}/schedules`]: i18n._(
+ [`${inventorySourcesPath}/${nestedObject?.id}`]: `${nestedObject?.name}`,
+ [`${inventorySourcesPath}/${nestedObject?.id}/details`]: i18n._(
+ t`Details`
+ ),
+ [`${inventorySourcesPath}/${nestedObject?.id}/edit`]: i18n._(
+ t`Edit details`
+ ),
+ [`${inventorySourcesPath}/${nestedObject?.id}/schedules`]: i18n._(
t`Schedules`
),
- [`${inventorySourcesPath}/${nested?.id}/schedules/${schedule?.id}`]: `${schedule?.name}`,
- [`${inventorySourcesPath}/${nested?.id}/schedules/${schedule?.id}/details`]: i18n._(
+ [`${inventorySourcesPath}/${nestedObject?.id}/schedules/${schedule?.id}`]: `${schedule?.name}`,
+ [`${inventorySourcesPath}/${nestedObject?.id}/schedules/add`]: i18n._(
+ t`Create New Schedule`
+ ),
+ [`${inventorySourcesPath}/${nestedObject?.id}/schedules/${schedule?.id}/details`]: i18n._(
t`Schedule details`
),
+ [`${inventorySourcesPath}/${nestedObject?.id}/notifications`]: i18n._(
+ t`Notifcations`
+ ),
});
},
- [i18n]
+ [i18n, inventory, nestedObject, schedule]
);
return (
<>
-
+
@@ -106,12 +140,12 @@ function Inventories({ i18n }) {
{({ me }) => (
-
+
)}
-
+
diff --git a/awx/ui_next/src/screens/Inventory/Inventories.test.jsx b/awx/ui_next/src/screens/Inventory/Inventories.test.jsx
index ca452f877a..35e1ee5da0 100644
--- a/awx/ui_next/src/screens/Inventory/Inventories.test.jsx
+++ b/awx/ui_next/src/screens/Inventory/Inventories.test.jsx
@@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Inventories from './Inventories';
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+}));
+
describe('', () => {
let pageWrapper;
diff --git a/awx/ui_next/src/screens/Job/Job.test.jsx b/awx/ui_next/src/screens/Job/Job.test.jsx
index 7b902984f0..afb1f6e148 100644
--- a/awx/ui_next/src/screens/Job/Job.test.jsx
+++ b/awx/ui_next/src/screens/Job/Job.test.jsx
@@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Job from './Jobs';
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+}));
+
describe('', () => {
test('initially renders succesfully', () => {
mountWithContexts();
diff --git a/awx/ui_next/src/screens/Job/Jobs.jsx b/awx/ui_next/src/screens/Job/Jobs.jsx
index 83c8d0d11f..318729407a 100644
--- a/awx/ui_next/src/screens/Job/Jobs.jsx
+++ b/awx/ui_next/src/screens/Job/Jobs.jsx
@@ -3,7 +3,7 @@ import { Route, Switch, useParams, useRouteMatch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { PageSection } from '@patternfly/react-core';
-import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
+import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
import Job from './Job';
import JobTypeRedirect from './JobTypeRedirect';
import JobList from '../../components/JobList';
@@ -40,7 +40,7 @@ function Jobs({ i18n }) {
return (
<>
-
+
diff --git a/awx/ui_next/src/screens/Job/Jobs.test.jsx b/awx/ui_next/src/screens/Job/Jobs.test.jsx
index 5782335404..e866a6d20d 100644
--- a/awx/ui_next/src/screens/Job/Jobs.test.jsx
+++ b/awx/ui_next/src/screens/Job/Jobs.test.jsx
@@ -5,6 +5,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Jobs from './Jobs';
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+}));
+
describe('', () => {
test('initially renders succesfully', () => {
mountWithContexts();
@@ -27,7 +31,7 @@ describe('', () => {
},
},
});
- expect(wrapper.find('BreadcrumbHeading').length).toBe(1);
+ expect(wrapper.find('Title').length).toBe(1);
wrapper.unmount();
});
});
diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobs.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobs.jsx
index 774eb3e235..94f5a077c5 100644
--- a/awx/ui_next/src/screens/ManagementJob/ManagementJobs.jsx
+++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobs.jsx
@@ -2,12 +2,13 @@ import React, { Fragment } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
-import Breadcrumbs from '../../components/Breadcrumbs';
+import ScreenHeader from '../../components/ScreenHeader';
function ManagementJobs({ i18n }) {
return (
-
diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobs.test.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobs.test.jsx
index a667a47690..df422fe8ec 100644
--- a/awx/ui_next/src/screens/ManagementJob/ManagementJobs.test.jsx
+++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobs.test.jsx
@@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import ManagementJobs from './ManagementJobs';
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+}));
+
describe('', () => {
let pageWrapper;
@@ -17,6 +21,6 @@ describe('', () => {
test('initially renders without crashing', () => {
expect(pageWrapper.length).toBe(1);
- expect(pageWrapper.find('Breadcrumbs').length).toBe(1);
+ expect(pageWrapper.find('ScreenHeader').length).toBe(1);
});
});
diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.jsx
index 2ae913202f..9d41166d1d 100644
--- a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.jsx
+++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.jsx
@@ -5,7 +5,7 @@ import { t } from '@lingui/macro';
import NotificationTemplateList from './NotificationTemplateList';
import NotificationTemplateAdd from './NotificationTemplateAdd';
import NotificationTemplate from './NotificationTemplate';
-import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
+import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
function NotificationTemplates({ i18n }) {
const match = useRouteMatch();
@@ -32,7 +32,10 @@ function NotificationTemplates({ i18n }) {
return (
<>
-
+
diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.test.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.test.jsx
index 9333850cf9..f8b02d4735 100644
--- a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.test.jsx
+++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.test.jsx
@@ -2,6 +2,10 @@ import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import NotificationTemplates from './NotificationTemplates';
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+}));
+
describe('', () => {
let pageWrapper;
let pageSections;
diff --git a/awx/ui_next/src/screens/Organization/Organizations.jsx b/awx/ui_next/src/screens/Organization/Organizations.jsx
index 5942d75147..6c7b17dc69 100644
--- a/awx/ui_next/src/screens/Organization/Organizations.jsx
+++ b/awx/ui_next/src/screens/Organization/Organizations.jsx
@@ -4,7 +4,7 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Config } from '../../contexts/Config';
-import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
+import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
import OrganizationsList from './OrganizationList/OrganizationList';
import OrganizationAdd from './OrganizationAdd/OrganizationAdd';
@@ -42,7 +42,10 @@ function Organizations({ i18n }) {
return (
-
+
diff --git a/awx/ui_next/src/screens/Organization/Organizations.test.jsx b/awx/ui_next/src/screens/Organization/Organizations.test.jsx
index 4f510463a4..819b86d88d 100644
--- a/awx/ui_next/src/screens/Organization/Organizations.test.jsx
+++ b/awx/ui_next/src/screens/Organization/Organizations.test.jsx
@@ -5,6 +5,9 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Organizations from './Organizations';
jest.mock('../../api');
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+}));
describe('', () => {
test('initially renders succesfully', async () => {
diff --git a/awx/ui_next/src/screens/Project/Projects.jsx b/awx/ui_next/src/screens/Project/Projects.jsx
index a45dd1890b..8063d4c1e6 100644
--- a/awx/ui_next/src/screens/Project/Projects.jsx
+++ b/awx/ui_next/src/screens/Project/Projects.jsx
@@ -3,7 +3,7 @@ import { Route, withRouter, Switch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
-import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
+import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
import ProjectsList from './ProjectList/ProjectList';
import ProjectAdd from './ProjectAdd/ProjectAdd';
@@ -45,7 +45,7 @@ function Projects({ i18n }) {
return (
<>
-
+
diff --git a/awx/ui_next/src/screens/Project/Projects.test.jsx b/awx/ui_next/src/screens/Project/Projects.test.jsx
index b46f37ae23..4d522a3d14 100644
--- a/awx/ui_next/src/screens/Project/Projects.test.jsx
+++ b/awx/ui_next/src/screens/Project/Projects.test.jsx
@@ -5,6 +5,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Projects from './Projects';
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+}));
+
describe('', () => {
test('initially renders succesfully', () => {
mountWithContexts();
@@ -27,7 +31,7 @@ describe('', () => {
},
},
});
- expect(wrapper.find('BreadcrumbHeading').length).toBe(1);
+ expect(wrapper.find('Title').length).toBe(1);
wrapper.unmount();
});
});
diff --git a/awx/ui_next/src/screens/Schedule/AllSchedules.jsx b/awx/ui_next/src/screens/Schedule/AllSchedules.jsx
index a55e9df3bb..4e778e9623 100644
--- a/awx/ui_next/src/screens/Schedule/AllSchedules.jsx
+++ b/awx/ui_next/src/screens/Schedule/AllSchedules.jsx
@@ -4,7 +4,7 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core';
-import Breadcrumbs from '../../components/Breadcrumbs';
+import ScreenHeader from '../../components/ScreenHeader';
import { ScheduleList } from '../../components/Schedule';
import { SchedulesAPI } from '../../api';
@@ -19,7 +19,8 @@ function AllSchedules({ i18n }) {
return (
<>
- ({
+ ...jest.requireActual('react-router-dom'),
+}));
+
describe('', () => {
let wrapper;
@@ -30,7 +34,6 @@ describe('', () => {
},
});
- expect(wrapper.find('Crumb').length).toBe(1);
- expect(wrapper.find('BreadcrumbHeading').text()).toBe('Schedules');
+ expect(wrapper.find('Title').text()).toBe('Schedules');
});
});
diff --git a/awx/ui_next/src/screens/Setting/Settings.jsx b/awx/ui_next/src/screens/Setting/Settings.jsx
index ae3356f950..a535384afa 100644
--- a/awx/ui_next/src/screens/Setting/Settings.jsx
+++ b/awx/ui_next/src/screens/Setting/Settings.jsx
@@ -5,7 +5,7 @@ import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../components/ContentError';
import ContentLoading from '../../components/ContentLoading';
-import Breadcrumbs from '../../components/Breadcrumbs';
+import ScreenHeader from '../../components/ScreenHeader';
import ActivityStream from './ActivityStream';
import AzureAD from './AzureAD';
import GitHub from './GitHub';
@@ -129,7 +129,7 @@ function Settings({ i18n }) {
return (
-
+
diff --git a/awx/ui_next/src/screens/Setting/Settings.test.jsx b/awx/ui_next/src/screens/Setting/Settings.test.jsx
index 24a82986f8..3c63750e71 100644
--- a/awx/ui_next/src/screens/Setting/Settings.test.jsx
+++ b/awx/ui_next/src/screens/Setting/Settings.test.jsx
@@ -13,6 +13,9 @@ jest.mock('../../api/models/Settings');
SettingsAPI.readAllOptions.mockResolvedValue({
data: mockAllOptions,
});
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+}));
describe('', () => {
let wrapper;
diff --git a/awx/ui_next/src/screens/Team/Teams.jsx b/awx/ui_next/src/screens/Team/Teams.jsx
index 3022ca98c7..0797a6685e 100644
--- a/awx/ui_next/src/screens/Team/Teams.jsx
+++ b/awx/ui_next/src/screens/Team/Teams.jsx
@@ -4,7 +4,7 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Config } from '../../contexts/Config';
-import Breadcrumbs from '../../components/Breadcrumbs';
+import ScreenHeader from '../../components/ScreenHeader';
import TeamList from './TeamList';
import TeamAdd from './TeamAdd';
import Team from './Team';
@@ -29,6 +29,7 @@ function Teams({ i18n }) {
[`/teams/${team.id}/details`]: i18n._(t`Details`),
[`/teams/${team.id}/users`]: i18n._(t`Users`),
[`/teams/${team.id}/access`]: i18n._(t`Access`),
+ [`/teams/${team.id}/roles`]: i18n._(t`Roles`),
});
},
[i18n]
@@ -36,7 +37,7 @@ function Teams({ i18n }) {
return (
<>
-
+
diff --git a/awx/ui_next/src/screens/Team/Teams.test.jsx b/awx/ui_next/src/screens/Team/Teams.test.jsx
index db73b4d7d4..181b814077 100644
--- a/awx/ui_next/src/screens/Team/Teams.test.jsx
+++ b/awx/ui_next/src/screens/Team/Teams.test.jsx
@@ -3,6 +3,9 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Teams from './Teams';
jest.mock('../../api');
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+}));
describe('', () => {
test('initially renders succesfully', () => {
diff --git a/awx/ui_next/src/screens/Template/Templates.jsx b/awx/ui_next/src/screens/Template/Templates.jsx
index 167d458141..f3905608cc 100644
--- a/awx/ui_next/src/screens/Template/Templates.jsx
+++ b/awx/ui_next/src/screens/Template/Templates.jsx
@@ -4,7 +4,7 @@ import { t } from '@lingui/macro';
import { Route, withRouter, Switch } from 'react-router-dom';
import { PageSection } from '@patternfly/react-core';
-import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
+import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
import { TemplateList } from './TemplateList';
import Template from './Template';
import WorkflowJobTemplate from './WorkflowJobTemplate';
@@ -12,22 +12,34 @@ import JobTemplateAdd from './JobTemplateAdd';
import WorkflowJobTemplateAdd from './WorkflowJobTemplateAdd';
function Templates({ i18n }) {
- const initBreadcrumbs = useRef({
+ const initScreenHeader = useRef({
'/templates': i18n._(t`Templates`),
'/templates/job_template/add': i18n._(t`Create New Job Template`),
'/templates/workflow_job_template/add': i18n._(
t`Create New Workflow Template`
),
});
- const [breadcrumbConfig, setBreadcrumbs] = useState(initBreadcrumbs.current);
+ const [breadcrumbConfig, setScreenHeader] = useState(
+ initScreenHeader.current
+ );
+
+ const [schedule, setSchedule] = useState();
+ const [template, setTemplate] = useState();
+
const setBreadcrumbConfig = useCallback(
- (template, schedule) => {
+ (passedTemplate, passedSchedule) => {
+ if (passedTemplate && passedTemplate.name !== template?.name) {
+ setTemplate(passedTemplate);
+ }
+ if (passedSchedule && passedSchedule.name !== schedule?.name) {
+ setSchedule(passedSchedule);
+ }
if (!template) return;
const templatePath = `/templates/${template.type}/${template.id}`;
const schedulesPath = `${templatePath}/schedules`;
const surveyPath = `${templatePath}/survey`;
- setBreadcrumbs({
- ...initBreadcrumbs.current,
+ setScreenHeader({
+ ...initScreenHeader.current,
[templatePath]: `${template.name}`,
[`${templatePath}/details`]: i18n._(t`Details`),
[`${templatePath}/edit`]: i18n._(t`Edit Details`),
@@ -40,16 +52,21 @@ function Templates({ i18n }) {
[schedulesPath]: i18n._(t`Schedules`),
[`${schedulesPath}/add`]: i18n._(t`Create New Schedule`),
[`${schedulesPath}/${schedule?.id}`]: `${schedule?.name}`,
- [`${schedulesPath}/details`]: i18n._(t`Schedule Details`),
- [`${schedulesPath}/edit`]: i18n._(t`Edit Details`),
+ [`${schedulesPath}/${schedule?.id}/details`]: i18n._(
+ t`Schedule Details`
+ ),
+ [`${schedulesPath}/${schedule?.id}/edit`]: i18n._(t`Edit Schedule`),
});
},
- [i18n]
+ [i18n, template, schedule]
);
return (
<>
-
+
diff --git a/awx/ui_next/src/screens/Template/Templates.test.jsx b/awx/ui_next/src/screens/Template/Templates.test.jsx
index f39643053d..1b126cda31 100644
--- a/awx/ui_next/src/screens/Template/Templates.test.jsx
+++ b/awx/ui_next/src/screens/Template/Templates.test.jsx
@@ -3,6 +3,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Templates from './Templates';
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+}));
+
describe('', () => {
let pageWrapper;
diff --git a/awx/ui_next/src/screens/User/Users.jsx b/awx/ui_next/src/screens/User/Users.jsx
index 35e4c4517b..323e8e37a4 100644
--- a/awx/ui_next/src/screens/User/Users.jsx
+++ b/awx/ui_next/src/screens/User/Users.jsx
@@ -3,7 +3,7 @@ import { Route, useRouteMatch, Switch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
-import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
+import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
import { Config } from '../../contexts/Config';
import UsersList from './UserList/UserList';
@@ -46,7 +46,7 @@ function Users({ i18n }) {
);
return (
-
+
diff --git a/awx/ui_next/src/screens/User/Users.test.jsx b/awx/ui_next/src/screens/User/Users.test.jsx
index 7193234f8f..862934e99b 100644
--- a/awx/ui_next/src/screens/User/Users.test.jsx
+++ b/awx/ui_next/src/screens/User/Users.test.jsx
@@ -5,6 +5,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Users from './Users';
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+}));
+
describe('', () => {
test('initially renders successfully', () => {
mountWithContexts();
@@ -27,7 +31,7 @@ describe('', () => {
},
},
});
- expect(wrapper.find('BreadcrumbHeading').length).toBe(1);
+ expect(wrapper.find('Title').length).toBe(1);
wrapper.unmount();
});
});
diff --git a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovals.jsx b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovals.jsx
index a8d66ccdba..84654810fa 100644
--- a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovals.jsx
+++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovals.jsx
@@ -4,7 +4,7 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import WorkflowApprovalList from './WorkflowApprovalList';
import WorkflowApproval from './WorkflowApproval';
-import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
+import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
function WorkflowApprovals({ i18n }) {
const match = useRouteMatch();
@@ -26,7 +26,10 @@ function WorkflowApprovals({ i18n }) {
return (
<>
-
+
diff --git a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovals.test.jsx b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovals.test.jsx
index b5bfdcf2a0..45aaffa7bf 100644
--- a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovals.test.jsx
+++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovals.test.jsx
@@ -3,6 +3,10 @@ import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import WorkflowApprovals from './WorkflowApprovals';
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+}));
+
describe('', () => {
test('initially renders succesfully', () => {
mountWithContexts();
@@ -29,7 +33,8 @@ describe('', () => {
},
},
});
- expect(wrapper.find('BreadcrumbHeading').length).toBe(1);
+
+ expect(wrapper.find('Title').length).toBe(1);
wrapper.unmount();
});
});
diff --git a/awx/ui_next/src/util/qs.js b/awx/ui_next/src/util/qs.js
index c02e3be075..729a28790d 100644
--- a/awx/ui_next/src/util/qs.js
+++ b/awx/ui_next/src/util/qs.js
@@ -118,15 +118,20 @@ function encodeValue(key, value) {
* removing defaults. Used to put into url bar after ui route
* @param {object} qs config object for namespacing params, filtering defaults
* @param {object} query param object
+ * @param {object} any non-namespaced params to append
* @return {string} url query string
*/
-export const encodeNonDefaultQueryString = (config, params) => {
+export const encodeNonDefaultQueryString = (
+ config,
+ params,
+ nonNamespacedParams = {}
+) => {
if (!params) return '';
-
const paramsWithoutDefaults = removeParams({}, params, config.defaultParams);
- return encodeQueryString(
- namespaceParams(config.namespace, paramsWithoutDefaults)
- );
+ return encodeQueryString({
+ ...namespaceParams(config.namespace, paramsWithoutDefaults),
+ ...nonNamespacedParams,
+ });
};
/**