mirror of
https://github.com/ansible/awx.git
synced 2026-03-02 09:18:48 -03:30
Add Toast messages when resources are copied (#11758)
* create useToast hook * add copy success toast message to credentials/inventories * add Toast tests * add copy success toast to template/ee/project lists * move Toast type to types.js
This commit is contained in:
@@ -12,6 +12,7 @@ import useSelected from 'hooks/useSelected';
|
||||
import useExpanded from 'hooks/useExpanded';
|
||||
import { getQSConfig, parseQueryString } from 'util/qs';
|
||||
import useWsTemplates from 'hooks/useWsTemplates';
|
||||
import useToast, { AlertVariant } from 'hooks/useToast';
|
||||
import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails';
|
||||
import AlertModal from '../AlertModal';
|
||||
import DatalistToolbar from '../DataListToolbar';
|
||||
@@ -41,6 +42,8 @@ function TemplateList({ defaultParams }) {
|
||||
);
|
||||
|
||||
const location = useLocation();
|
||||
const { addToast, Toast, toastProps } = useToast();
|
||||
|
||||
const {
|
||||
result: {
|
||||
results,
|
||||
@@ -123,6 +126,18 @@ function TemplateList({ defaultParams }) {
|
||||
}
|
||||
);
|
||||
|
||||
const handleCopy = useCallback(
|
||||
(newTemplateId) => {
|
||||
addToast({
|
||||
id: newTemplateId,
|
||||
title: t`Template copied successfully`,
|
||||
variant: AlertVariant.success,
|
||||
hasTimeout: true,
|
||||
});
|
||||
},
|
||||
[addToast]
|
||||
);
|
||||
|
||||
const handleTemplateDelete = async () => {
|
||||
await deleteTemplates();
|
||||
clearSelected();
|
||||
@@ -266,6 +281,7 @@ function TemplateList({ defaultParams }) {
|
||||
onSelect={() => handleSelect(template)}
|
||||
isExpanded={expanded.some((row) => row.id === template.id)}
|
||||
onExpand={() => handleExpand(template)}
|
||||
onCopy={handleCopy}
|
||||
isSelected={selected.some((row) => row.id === template.id)}
|
||||
fetchTemplates={fetchTemplates}
|
||||
rowIndex={index}
|
||||
@@ -274,6 +290,7 @@ function TemplateList({ defaultParams }) {
|
||||
emptyStateControls={(canAddJT || canAddWFJT) && addButton}
|
||||
/>
|
||||
</Card>
|
||||
<Toast {...toastProps} />
|
||||
<AlertModal
|
||||
aria-label={t`Deletion Error`}
|
||||
isOpen={deletionError}
|
||||
|
||||
@@ -39,6 +39,7 @@ function TemplateListItem({
|
||||
template,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onCopy,
|
||||
detailUrl,
|
||||
fetchTemplates,
|
||||
rowIndex,
|
||||
@@ -52,17 +53,21 @@ function TemplateListItem({
|
||||
)}/html/upgrade-migration-guide/upgrade_to_ees.html`;
|
||||
|
||||
const copyTemplate = useCallback(async () => {
|
||||
let response;
|
||||
if (template.type === 'job_template') {
|
||||
await JobTemplatesAPI.copy(template.id, {
|
||||
response = await JobTemplatesAPI.copy(template.id, {
|
||||
name: `${template.name} @ ${timeOfDay()}`,
|
||||
});
|
||||
} else {
|
||||
await WorkflowJobTemplatesAPI.copy(template.id, {
|
||||
response = await WorkflowJobTemplatesAPI.copy(template.id, {
|
||||
name: `${template.name} @ ${timeOfDay()}`,
|
||||
});
|
||||
}
|
||||
if (response.status === 201) {
|
||||
onCopy(response.data.id);
|
||||
}
|
||||
await fetchTemplates();
|
||||
}, [fetchTemplates, template.id, template.name, template.type]);
|
||||
}, [fetchTemplates, template.id, template.name, template.type, onCopy]);
|
||||
|
||||
const handleCopyStart = useCallback(() => {
|
||||
setIsDisabled(true);
|
||||
|
||||
64
awx/ui/src/hooks/useToast.js
Normal file
64
awx/ui/src/hooks/useToast.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
AlertGroup,
|
||||
Alert,
|
||||
AlertActionCloseButton,
|
||||
AlertVariant,
|
||||
} from '@patternfly/react-core';
|
||||
import { arrayOf, func } from 'prop-types';
|
||||
import { Toast as ToastType } from 'types';
|
||||
|
||||
export default function useToast() {
|
||||
const [toasts, setToasts] = useState([]);
|
||||
|
||||
const addToast = useCallback((newToast) => {
|
||||
setToasts((oldToasts) => [...oldToasts, newToast]);
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((toastId) => {
|
||||
setToasts((oldToasts) => oldToasts.filter((t) => t.id !== toastId));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
addToast,
|
||||
removeToast,
|
||||
Toast,
|
||||
toastProps: {
|
||||
toasts,
|
||||
removeToast,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function Toast({ toasts, removeToast }) {
|
||||
if (!toasts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertGroup data-cy="toast-container" isToast>
|
||||
{toasts.map((toast) => (
|
||||
<Alert
|
||||
actionClose={
|
||||
<AlertActionCloseButton onClose={() => removeToast(toast.id)} />
|
||||
}
|
||||
onTimeout={() => removeToast(toast.id)}
|
||||
timeout={toast.hasTimeout}
|
||||
title={toast.title}
|
||||
variant={toast.variant}
|
||||
key={`toast-message-${toast.id}`}
|
||||
ouiaId={`toast-message-${toast.id}`}
|
||||
>
|
||||
{toast.message}
|
||||
</Alert>
|
||||
))}
|
||||
</AlertGroup>
|
||||
);
|
||||
}
|
||||
|
||||
Toast.propTypes = {
|
||||
toasts: arrayOf(ToastType).isRequired,
|
||||
removeToast: func.isRequired,
|
||||
};
|
||||
|
||||
export { AlertVariant };
|
||||
124
awx/ui/src/hooks/useToast.test.js
Normal file
124
awx/ui/src/hooks/useToast.test.js
Normal file
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { shallow, mount } from 'enzyme';
|
||||
import useToast, { Toast, AlertVariant } from './useToast';
|
||||
|
||||
describe('useToast', () => {
|
||||
const Child = () => <div />;
|
||||
const Test = () => {
|
||||
const toastVals = useToast();
|
||||
return <Child {...toastVals} />;
|
||||
};
|
||||
|
||||
test('should provide Toast component', () => {
|
||||
const wrapper = mount(<Test />);
|
||||
|
||||
expect(wrapper.find('Child').prop('Toast')).toEqual(Toast);
|
||||
});
|
||||
|
||||
test('should add toast', () => {
|
||||
const wrapper = mount(<Test />);
|
||||
|
||||
expect(wrapper.find('Child').prop('toastProps').toasts).toEqual([]);
|
||||
act(() => {
|
||||
wrapper.find('Child').prop('addToast')({
|
||||
message: 'one',
|
||||
id: 1,
|
||||
variant: 'success',
|
||||
});
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('Child').prop('toastProps').toasts).toEqual([
|
||||
{
|
||||
message: 'one',
|
||||
id: 1,
|
||||
variant: 'success',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should remove toast', () => {
|
||||
const wrapper = mount(<Test />);
|
||||
|
||||
act(() => {
|
||||
wrapper.find('Child').prop('addToast')({
|
||||
message: 'one',
|
||||
id: 1,
|
||||
variant: 'success',
|
||||
});
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('Child').prop('toastProps').toasts).toHaveLength(1);
|
||||
act(() => {
|
||||
wrapper.find('Child').prop('removeToast')(1);
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('Child').prop('toastProps').toasts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Toast', () => {
|
||||
test('should render nothing with no toasts', () => {
|
||||
const wrapper = shallow(<Toast toasts={[]} removeToast={() => {}} />);
|
||||
expect(wrapper).toEqual({});
|
||||
});
|
||||
|
||||
test('should render toast alert', () => {
|
||||
const toast = {
|
||||
title: 'Inventory saved',
|
||||
variant: AlertVariant.success,
|
||||
id: 1,
|
||||
message: 'the message',
|
||||
};
|
||||
const wrapper = shallow(<Toast toasts={[toast]} removeToast={() => {}} />);
|
||||
|
||||
const alert = wrapper.find('Alert');
|
||||
expect(alert.prop('title')).toEqual('Inventory saved');
|
||||
expect(alert.prop('variant')).toEqual('success');
|
||||
expect(alert.prop('ouiaId')).toEqual('toast-message-1');
|
||||
expect(alert.prop('children')).toEqual('the message');
|
||||
});
|
||||
|
||||
test('should call removeToast', () => {
|
||||
const removeToast = jest.fn();
|
||||
const toast = {
|
||||
title: 'Inventory saved',
|
||||
variant: AlertVariant.success,
|
||||
id: 1,
|
||||
};
|
||||
const wrapper = shallow(
|
||||
<Toast toasts={[toast]} removeToast={removeToast} />
|
||||
);
|
||||
|
||||
const alert = wrapper.find('Alert');
|
||||
alert.prop('actionClose').props.onClose(1);
|
||||
expect(removeToast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should render multiple alerts', () => {
|
||||
const toasts = [
|
||||
{
|
||||
title: 'Inventory saved',
|
||||
variant: AlertVariant.success,
|
||||
id: 1,
|
||||
message: 'the message',
|
||||
},
|
||||
{
|
||||
title: 'error saving',
|
||||
variant: AlertVariant.danger,
|
||||
id: 2,
|
||||
},
|
||||
];
|
||||
const wrapper = shallow(<Toast toasts={toasts} removeToast={() => {}} />);
|
||||
|
||||
const alert = wrapper.find('Alert');
|
||||
expect(alert).toHaveLength(2);
|
||||
|
||||
expect(alert.at(0).prop('title')).toEqual('Inventory saved');
|
||||
expect(alert.at(0).prop('variant')).toEqual('success');
|
||||
expect(alert.at(1).prop('title')).toEqual('error saving');
|
||||
expect(alert.at(1).prop('variant')).toEqual('danger');
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { t, Plural } from '@lingui/macro';
|
||||
import { Card, PageSection } from '@patternfly/react-core';
|
||||
import { CredentialsAPI } from 'api';
|
||||
import useSelected from 'hooks/useSelected';
|
||||
import useToast, { AlertVariant } from 'hooks/useToast';
|
||||
import AlertModal from 'components/AlertModal';
|
||||
import ErrorDetail from 'components/ErrorDetail';
|
||||
import DataListToolbar from 'components/DataListToolbar';
|
||||
@@ -27,6 +28,8 @@ const QS_CONFIG = getQSConfig('credential', {
|
||||
|
||||
function CredentialList() {
|
||||
const location = useLocation();
|
||||
const { addToast, Toast, toastProps } = useToast();
|
||||
|
||||
const {
|
||||
result: {
|
||||
credentials,
|
||||
@@ -104,100 +107,116 @@ function CredentialList() {
|
||||
setSelected([]);
|
||||
};
|
||||
|
||||
const handleCopy = useCallback(
|
||||
(newCredentialId) => {
|
||||
addToast({
|
||||
id: newCredentialId,
|
||||
title: t`Credential copied successfully`,
|
||||
variant: AlertVariant.success,
|
||||
hasTimeout: true,
|
||||
});
|
||||
},
|
||||
[addToast]
|
||||
);
|
||||
|
||||
const canAdd =
|
||||
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
|
||||
const deleteDetailsRequests = relatedResourceDeleteRequests.credential(
|
||||
selected[0]
|
||||
);
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<PaginatedTable
|
||||
contentError={contentError}
|
||||
hasContentLoading={isLoading || isDeleteLoading}
|
||||
items={credentials}
|
||||
itemCount={credentialCount}
|
||||
qsConfig={QS_CONFIG}
|
||||
clearSelected={clearSelected}
|
||||
toolbarSearchableKeys={searchableKeys}
|
||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||
toolbarSearchColumns={[
|
||||
{
|
||||
name: t`Name`,
|
||||
key: 'name__icontains',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: t`Description`,
|
||||
key: 'description__icontains',
|
||||
},
|
||||
{
|
||||
name: t`Created By (Username)`,
|
||||
key: 'created_by__username__icontains',
|
||||
},
|
||||
{
|
||||
name: t`Modified By (Username)`,
|
||||
key: 'modified_by__username__icontains',
|
||||
},
|
||||
]}
|
||||
headerRow={
|
||||
<HeaderRow qsConfig={QS_CONFIG}>
|
||||
<HeaderCell sortKey="name">{t`Name`}</HeaderCell>
|
||||
<HeaderCell>{t`Type`}</HeaderCell>
|
||||
<HeaderCell>{t`Actions`}</HeaderCell>
|
||||
</HeaderRow>
|
||||
}
|
||||
renderRow={(item, index) => (
|
||||
<CredentialListItem
|
||||
key={item.id}
|
||||
credential={item}
|
||||
fetchCredentials={fetchCredentials}
|
||||
detailUrl={`/credentials/${item.id}/details`}
|
||||
isSelected={selected.some((row) => row.id === item.id)}
|
||||
onSelect={() => handleSelect(item)}
|
||||
rowIndex={index}
|
||||
/>
|
||||
)}
|
||||
renderToolbar={(props) => (
|
||||
<DataListToolbar
|
||||
{...props}
|
||||
isAllSelected={isAllSelected}
|
||||
onSelectAll={selectAll}
|
||||
qsConfig={QS_CONFIG}
|
||||
additionalControls={[
|
||||
...(canAdd
|
||||
? [<ToolbarAddButton key="add" linkTo="/credentials/add" />]
|
||||
: []),
|
||||
<ToolbarDeleteButton
|
||||
key="delete"
|
||||
onDelete={handleDelete}
|
||||
itemsToDelete={selected}
|
||||
pluralizedItemName={t`Credentials`}
|
||||
deleteDetailsRequests={deleteDetailsRequests}
|
||||
deleteMessage={
|
||||
<Plural
|
||||
value={selected.length}
|
||||
one="This credential is currently being used by other resources. Are you sure you want to delete it?"
|
||||
other="Deleting these credentials could impact other resources that rely on them. Are you sure you want to delete anyway?"
|
||||
/>
|
||||
}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
<AlertModal
|
||||
aria-label={t`Deletion Error`}
|
||||
isOpen={deletionError}
|
||||
variant="error"
|
||||
title={t`Error!`}
|
||||
onClose={clearDeletionError}
|
||||
>
|
||||
{t`Failed to delete one or more credentials.`}
|
||||
<ErrorDetail error={deletionError} />
|
||||
</AlertModal>
|
||||
</PageSection>
|
||||
<>
|
||||
<PageSection>
|
||||
<Card>
|
||||
<PaginatedTable
|
||||
contentError={contentError}
|
||||
hasContentLoading={isLoading || isDeleteLoading}
|
||||
items={credentials}
|
||||
itemCount={credentialCount}
|
||||
qsConfig={QS_CONFIG}
|
||||
clearSelected={clearSelected}
|
||||
toolbarSearchableKeys={searchableKeys}
|
||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||
toolbarSearchColumns={[
|
||||
{
|
||||
name: t`Name`,
|
||||
key: 'name__icontains',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: t`Description`,
|
||||
key: 'description__icontains',
|
||||
},
|
||||
{
|
||||
name: t`Created By (Username)`,
|
||||
key: 'created_by__username__icontains',
|
||||
},
|
||||
{
|
||||
name: t`Modified By (Username)`,
|
||||
key: 'modified_by__username__icontains',
|
||||
},
|
||||
]}
|
||||
headerRow={
|
||||
<HeaderRow qsConfig={QS_CONFIG}>
|
||||
<HeaderCell sortKey="name">{t`Name`}</HeaderCell>
|
||||
<HeaderCell>{t`Type`}</HeaderCell>
|
||||
<HeaderCell>{t`Actions`}</HeaderCell>
|
||||
</HeaderRow>
|
||||
}
|
||||
renderRow={(item, index) => (
|
||||
<CredentialListItem
|
||||
key={item.id}
|
||||
credential={item}
|
||||
fetchCredentials={fetchCredentials}
|
||||
detailUrl={`/credentials/${item.id}/details`}
|
||||
isSelected={selected.some((row) => row.id === item.id)}
|
||||
onSelect={() => handleSelect(item)}
|
||||
onCopy={handleCopy}
|
||||
rowIndex={index}
|
||||
/>
|
||||
)}
|
||||
renderToolbar={(props) => (
|
||||
<DataListToolbar
|
||||
{...props}
|
||||
isAllSelected={isAllSelected}
|
||||
onSelectAll={selectAll}
|
||||
qsConfig={QS_CONFIG}
|
||||
additionalControls={[
|
||||
...(canAdd
|
||||
? [<ToolbarAddButton key="add" linkTo="/credentials/add" />]
|
||||
: []),
|
||||
<ToolbarDeleteButton
|
||||
key="delete"
|
||||
onDelete={handleDelete}
|
||||
itemsToDelete={selected}
|
||||
pluralizedItemName={t`Credentials`}
|
||||
deleteDetailsRequests={deleteDetailsRequests}
|
||||
deleteMessage={
|
||||
<Plural
|
||||
value={selected.length}
|
||||
one="This credential is currently being used by other resources. Are you sure you want to delete it?"
|
||||
other="Deleting these credentials could impact other resources that rely on them. Are you sure you want to delete anyway?"
|
||||
/>
|
||||
}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
<AlertModal
|
||||
aria-label={t`Deletion Error`}
|
||||
isOpen={deletionError}
|
||||
variant="error"
|
||||
title={t`Error!`}
|
||||
onClose={clearDeletionError}
|
||||
>
|
||||
{t`Failed to delete one or more credentials.`}
|
||||
<ErrorDetail error={deletionError} />
|
||||
</AlertModal>
|
||||
</PageSection>
|
||||
<Toast {...toastProps} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ function CredentialListItem({
|
||||
detailUrl,
|
||||
isSelected,
|
||||
onSelect,
|
||||
|
||||
onCopy,
|
||||
fetchCredentials,
|
||||
rowIndex,
|
||||
}) {
|
||||
@@ -28,11 +28,14 @@ function CredentialListItem({
|
||||
const canEdit = credential.summary_fields.user_capabilities.edit;
|
||||
|
||||
const copyCredential = useCallback(async () => {
|
||||
await CredentialsAPI.copy(credential.id, {
|
||||
const response = await CredentialsAPI.copy(credential.id, {
|
||||
name: `${credential.name} @ ${timeOfDay()}`,
|
||||
});
|
||||
if (response.status === 201) {
|
||||
onCopy(response.data.id);
|
||||
}
|
||||
await fetchCredentials();
|
||||
}, [credential.id, credential.name, fetchCredentials]);
|
||||
}, [credential.id, credential.name, fetchCredentials, onCopy]);
|
||||
|
||||
const handleCopyStart = useCallback(() => {
|
||||
setIsDisabled(true);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ExecutionEnvironmentsAPI } from 'api';
|
||||
import { getQSConfig, parseQueryString } from 'util/qs';
|
||||
import useRequest, { useDeleteItems } from 'hooks/useRequest';
|
||||
import useSelected from 'hooks/useSelected';
|
||||
import useToast, { AlertVariant } from 'hooks/useToast';
|
||||
import PaginatedTable, {
|
||||
HeaderRow,
|
||||
HeaderCell,
|
||||
@@ -29,6 +30,7 @@ const QS_CONFIG = getQSConfig('execution_environments', {
|
||||
function ExecutionEnvironmentList() {
|
||||
const location = useLocation();
|
||||
const match = useRouteMatch();
|
||||
const { addToast, Toast, toastProps } = useToast();
|
||||
|
||||
const {
|
||||
error: contentError,
|
||||
@@ -94,6 +96,18 @@ function ExecutionEnvironmentList() {
|
||||
}
|
||||
);
|
||||
|
||||
const handleCopy = useCallback(
|
||||
(newId) => {
|
||||
addToast({
|
||||
id: newId,
|
||||
title: t`Execution environment copied successfully`,
|
||||
variant: AlertVariant.success,
|
||||
hasTimeout: true,
|
||||
});
|
||||
},
|
||||
[addToast]
|
||||
);
|
||||
|
||||
const handleDelete = async () => {
|
||||
await deleteExecutionEnvironments();
|
||||
clearSelected();
|
||||
@@ -194,6 +208,7 @@ function ExecutionEnvironmentList() {
|
||||
executionEnvironment={executionEnvironment}
|
||||
detailUrl={`${match.url}/${executionEnvironment.id}/details`}
|
||||
onSelect={() => handleSelect(executionEnvironment)}
|
||||
onCopy={handleCopy}
|
||||
isSelected={selected.some(
|
||||
(row) => row.id === executionEnvironment.id
|
||||
)}
|
||||
@@ -218,6 +233,7 @@ function ExecutionEnvironmentList() {
|
||||
{t`Failed to delete one or more execution environments`}
|
||||
<ErrorDetail error={deletionError} />
|
||||
</AlertModal>
|
||||
<Toast {...toastProps} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,20 +18,28 @@ function ExecutionEnvironmentListItem({
|
||||
detailUrl,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onCopy,
|
||||
rowIndex,
|
||||
fetchExecutionEnvironments,
|
||||
}) {
|
||||
const [isDisabled, setIsDisabled] = useState(false);
|
||||
|
||||
const copyExecutionEnvironment = useCallback(async () => {
|
||||
await ExecutionEnvironmentsAPI.copy(executionEnvironment.id, {
|
||||
name: `${executionEnvironment.name} @ ${timeOfDay()}`,
|
||||
});
|
||||
const response = await ExecutionEnvironmentsAPI.copy(
|
||||
executionEnvironment.id,
|
||||
{
|
||||
name: `${executionEnvironment.name} @ ${timeOfDay()}`,
|
||||
}
|
||||
);
|
||||
if (response.status === 201) {
|
||||
onCopy(response.data.id);
|
||||
}
|
||||
await fetchExecutionEnvironments();
|
||||
}, [
|
||||
executionEnvironment.id,
|
||||
executionEnvironment.name,
|
||||
fetchExecutionEnvironments,
|
||||
onCopy,
|
||||
]);
|
||||
|
||||
const handleCopyStart = useCallback(() => {
|
||||
@@ -114,6 +122,7 @@ ExecutionEnvironmentListItem.prototype = {
|
||||
detailUrl: string.isRequired,
|
||||
isSelected: bool.isRequired,
|
||||
onSelect: func.isRequired,
|
||||
onCopy: func.isRequired,
|
||||
};
|
||||
|
||||
export default ExecutionEnvironmentListItem;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Card, PageSection, DropdownItem } from '@patternfly/react-core';
|
||||
import { InventoriesAPI } from 'api';
|
||||
import useRequest, { useDeleteItems } from 'hooks/useRequest';
|
||||
import useSelected from 'hooks/useSelected';
|
||||
import useToast, { AlertVariant } from 'hooks/useToast';
|
||||
import AlertModal from 'components/AlertModal';
|
||||
import DatalistToolbar from 'components/DataListToolbar';
|
||||
import ErrorDetail from 'components/ErrorDetail';
|
||||
@@ -29,6 +30,7 @@ const QS_CONFIG = getQSConfig('inventory', {
|
||||
function InventoryList() {
|
||||
const location = useLocation();
|
||||
const match = useRouteMatch();
|
||||
const { addToast, Toast, toastProps } = useToast();
|
||||
|
||||
const {
|
||||
result: {
|
||||
@@ -112,6 +114,18 @@ function InventoryList() {
|
||||
clearSelected();
|
||||
};
|
||||
|
||||
const handleCopy = useCallback(
|
||||
(newInventoryId) => {
|
||||
addToast({
|
||||
id: newInventoryId,
|
||||
title: t`Inventory copied successfully`,
|
||||
variant: AlertVariant.success,
|
||||
hasTimeout: true,
|
||||
});
|
||||
},
|
||||
[addToast]
|
||||
);
|
||||
|
||||
const hasContentLoading = isDeleteLoading || isLoading;
|
||||
const canAdd = actions && actions.POST;
|
||||
|
||||
@@ -149,130 +163,134 @@ function InventoryList() {
|
||||
);
|
||||
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<PaginatedTable
|
||||
contentError={contentError}
|
||||
hasContentLoading={hasContentLoading}
|
||||
items={inventories}
|
||||
itemCount={itemCount}
|
||||
pluralizedItemName={t`Inventories`}
|
||||
qsConfig={QS_CONFIG}
|
||||
toolbarSearchColumns={[
|
||||
{
|
||||
name: t`Name`,
|
||||
key: 'name__icontains',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: t`Inventory Type`,
|
||||
key: 'or__kind',
|
||||
options: [
|
||||
['', t`Inventory`],
|
||||
['smart', t`Smart Inventory`],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t`Organization`,
|
||||
key: 'organization__name',
|
||||
},
|
||||
{
|
||||
name: t`Description`,
|
||||
key: 'description__icontains',
|
||||
},
|
||||
{
|
||||
name: t`Created By (Username)`,
|
||||
key: 'created_by__username__icontains',
|
||||
},
|
||||
{
|
||||
name: t`Modified By (Username)`,
|
||||
key: 'modified_by__username__icontains',
|
||||
},
|
||||
]}
|
||||
toolbarSortColumns={[
|
||||
{
|
||||
name: t`Name`,
|
||||
key: 'name',
|
||||
},
|
||||
]}
|
||||
toolbarSearchableKeys={searchableKeys}
|
||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||
clearSelected={clearSelected}
|
||||
headerRow={
|
||||
<HeaderRow qsConfig={QS_CONFIG}>
|
||||
<HeaderCell sortKey="name">{t`Name`}</HeaderCell>
|
||||
<HeaderCell>{t`Status`}</HeaderCell>
|
||||
<HeaderCell>{t`Type`}</HeaderCell>
|
||||
<HeaderCell>{t`Organization`}</HeaderCell>
|
||||
<HeaderCell>{t`Actions`}</HeaderCell>
|
||||
</HeaderRow>
|
||||
}
|
||||
renderToolbar={(props) => (
|
||||
<DatalistToolbar
|
||||
{...props}
|
||||
isAllSelected={isAllSelected}
|
||||
onSelectAll={selectAll}
|
||||
qsConfig={QS_CONFIG}
|
||||
additionalControls={[
|
||||
...(canAdd ? [addButton] : []),
|
||||
<ToolbarDeleteButton
|
||||
key="delete"
|
||||
onDelete={handleInventoryDelete}
|
||||
itemsToDelete={selected}
|
||||
pluralizedItemName={t`Inventories`}
|
||||
deleteDetailsRequests={deleteDetailsRequests}
|
||||
deleteMessage={
|
||||
<Plural
|
||||
value={selected.length}
|
||||
one="This inventory is currently being used by some templates. Are you sure you want to delete it?"
|
||||
other="Deleting these inventories could impact some templates that rely on them. Are you sure you want to delete anyway?"
|
||||
/>
|
||||
}
|
||||
warningMessage={
|
||||
<Plural
|
||||
value={selected.length}
|
||||
one="The inventory will be in a pending status until the final delete is processed."
|
||||
other="The inventories will be in a pending status until the final delete is processed."
|
||||
/>
|
||||
}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
renderRow={(inventory, index) => (
|
||||
<InventoryListItem
|
||||
key={inventory.id}
|
||||
value={inventory.name}
|
||||
inventory={inventory}
|
||||
rowIndex={index}
|
||||
fetchInventories={fetchInventories}
|
||||
detailUrl={
|
||||
inventory.kind === 'smart'
|
||||
? `${match.url}/smart_inventory/${inventory.id}/details`
|
||||
: `${match.url}/inventory/${inventory.id}/details`
|
||||
}
|
||||
onSelect={() => {
|
||||
if (!inventory.pending_deletion) {
|
||||
handleSelect(inventory);
|
||||
<>
|
||||
<PageSection>
|
||||
<Card>
|
||||
<PaginatedTable
|
||||
contentError={contentError}
|
||||
hasContentLoading={hasContentLoading}
|
||||
items={inventories}
|
||||
itemCount={itemCount}
|
||||
pluralizedItemName={t`Inventories`}
|
||||
qsConfig={QS_CONFIG}
|
||||
toolbarSearchColumns={[
|
||||
{
|
||||
name: t`Name`,
|
||||
key: 'name__icontains',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: t`Inventory Type`,
|
||||
key: 'or__kind',
|
||||
options: [
|
||||
['', t`Inventory`],
|
||||
['smart', t`Smart Inventory`],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t`Organization`,
|
||||
key: 'organization__name',
|
||||
},
|
||||
{
|
||||
name: t`Description`,
|
||||
key: 'description__icontains',
|
||||
},
|
||||
{
|
||||
name: t`Created By (Username)`,
|
||||
key: 'created_by__username__icontains',
|
||||
},
|
||||
{
|
||||
name: t`Modified By (Username)`,
|
||||
key: 'modified_by__username__icontains',
|
||||
},
|
||||
]}
|
||||
toolbarSortColumns={[
|
||||
{
|
||||
name: t`Name`,
|
||||
key: 'name',
|
||||
},
|
||||
]}
|
||||
toolbarSearchableKeys={searchableKeys}
|
||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||
clearSelected={clearSelected}
|
||||
headerRow={
|
||||
<HeaderRow qsConfig={QS_CONFIG}>
|
||||
<HeaderCell sortKey="name">{t`Name`}</HeaderCell>
|
||||
<HeaderCell>{t`Status`}</HeaderCell>
|
||||
<HeaderCell>{t`Type`}</HeaderCell>
|
||||
<HeaderCell>{t`Organization`}</HeaderCell>
|
||||
<HeaderCell>{t`Actions`}</HeaderCell>
|
||||
</HeaderRow>
|
||||
}
|
||||
renderToolbar={(props) => (
|
||||
<DatalistToolbar
|
||||
{...props}
|
||||
isAllSelected={isAllSelected}
|
||||
onSelectAll={selectAll}
|
||||
qsConfig={QS_CONFIG}
|
||||
additionalControls={[
|
||||
...(canAdd ? [addButton] : []),
|
||||
<ToolbarDeleteButton
|
||||
key="delete"
|
||||
onDelete={handleInventoryDelete}
|
||||
itemsToDelete={selected}
|
||||
pluralizedItemName={t`Inventories`}
|
||||
deleteDetailsRequests={deleteDetailsRequests}
|
||||
deleteMessage={
|
||||
<Plural
|
||||
value={selected.length}
|
||||
one="This inventory is currently being used by some templates. Are you sure you want to delete it?"
|
||||
other="Deleting these inventories could impact some templates that rely on them. Are you sure you want to delete anyway?"
|
||||
/>
|
||||
}
|
||||
warningMessage={
|
||||
<Plural
|
||||
value={selected.length}
|
||||
one="The inventory will be in a pending status until the final delete is processed."
|
||||
other="The inventories will be in a pending status until the final delete is processed."
|
||||
/>
|
||||
}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
renderRow={(inventory, index) => (
|
||||
<InventoryListItem
|
||||
key={inventory.id}
|
||||
value={inventory.name}
|
||||
inventory={inventory}
|
||||
rowIndex={index}
|
||||
fetchInventories={fetchInventories}
|
||||
detailUrl={
|
||||
inventory.kind === 'smart'
|
||||
? `${match.url}/smart_inventory/${inventory.id}/details`
|
||||
: `${match.url}/inventory/${inventory.id}/details`
|
||||
}
|
||||
}}
|
||||
isSelected={selected.some((row) => row.id === inventory.id)}
|
||||
/>
|
||||
)}
|
||||
emptyStateControls={canAdd && addButton}
|
||||
/>
|
||||
</Card>
|
||||
<AlertModal
|
||||
isOpen={deletionError}
|
||||
variant="error"
|
||||
aria-label={t`Deletion Error`}
|
||||
title={t`Error!`}
|
||||
onClose={clearDeletionError}
|
||||
>
|
||||
{t`Failed to delete one or more inventories.`}
|
||||
<ErrorDetail error={deletionError} />
|
||||
</AlertModal>
|
||||
</PageSection>
|
||||
onSelect={() => {
|
||||
if (!inventory.pending_deletion) {
|
||||
handleSelect(inventory);
|
||||
}
|
||||
}}
|
||||
onCopy={handleCopy}
|
||||
isSelected={selected.some((row) => row.id === inventory.id)}
|
||||
/>
|
||||
)}
|
||||
emptyStateControls={canAdd && addButton}
|
||||
/>
|
||||
</Card>
|
||||
<AlertModal
|
||||
isOpen={deletionError}
|
||||
variant="error"
|
||||
aria-label={t`Deletion Error`}
|
||||
title={t`Error!`}
|
||||
onClose={clearDeletionError}
|
||||
>
|
||||
{t`Failed to delete one or more inventories.`}
|
||||
<ErrorDetail error={deletionError} />
|
||||
</AlertModal>
|
||||
</PageSection>
|
||||
<Toast {...toastProps} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ function InventoryListItem({
|
||||
rowIndex,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onCopy,
|
||||
detailUrl,
|
||||
fetchInventories,
|
||||
}) {
|
||||
@@ -30,11 +31,14 @@ function InventoryListItem({
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
|
||||
const copyInventory = useCallback(async () => {
|
||||
await InventoriesAPI.copy(inventory.id, {
|
||||
const response = await InventoriesAPI.copy(inventory.id, {
|
||||
name: `${inventory.name} @ ${timeOfDay()}`,
|
||||
});
|
||||
if (response.status === 201) {
|
||||
onCopy(response.data.id);
|
||||
}
|
||||
await fetchInventories();
|
||||
}, [inventory.id, inventory.name, fetchInventories]);
|
||||
}, [inventory.id, inventory.name, fetchInventories, onCopy]);
|
||||
|
||||
const handleCopyStart = useCallback(() => {
|
||||
setIsCopying(true);
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useLocation, useRouteMatch } from 'react-router-dom';
|
||||
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Alert,
|
||||
AlertActionCloseButton,
|
||||
AlertGroup,
|
||||
Card,
|
||||
PageSection,
|
||||
} from '@patternfly/react-core';
|
||||
import { Card, PageSection } from '@patternfly/react-core';
|
||||
import { NotificationTemplatesAPI } from 'api';
|
||||
import PaginatedTable, {
|
||||
HeaderRow,
|
||||
@@ -22,6 +16,7 @@ import ErrorDetail from 'components/ErrorDetail';
|
||||
import DataListToolbar from 'components/DataListToolbar';
|
||||
import useRequest, { useDeleteItems } from 'hooks/useRequest';
|
||||
import useSelected from 'hooks/useSelected';
|
||||
import useToast, { AlertVariant } from 'hooks/useToast';
|
||||
import { getQSConfig, parseQueryString } from 'util/qs';
|
||||
import NotificationTemplateListItem from './NotificationTemplateListItem';
|
||||
|
||||
@@ -34,7 +29,8 @@ const QS_CONFIG = getQSConfig('notification-templates', {
|
||||
function NotificationTemplatesList() {
|
||||
const location = useLocation();
|
||||
const match = useRouteMatch();
|
||||
const [testToasts, setTestToasts] = useState([]);
|
||||
// const [testToasts, setTestToasts] = useState([]);
|
||||
const { addToast, Toast, toastProps } = useToast();
|
||||
|
||||
const addUrl = `${match.url}/add`;
|
||||
|
||||
@@ -107,18 +103,7 @@ function NotificationTemplatesList() {
|
||||
clearSelected();
|
||||
};
|
||||
|
||||
const addTestToast = useCallback((notification) => {
|
||||
setTestToasts((oldToasts) => [...oldToasts, notification]);
|
||||
}, []);
|
||||
|
||||
const removeTestToast = (notificationId) => {
|
||||
setTestToasts((oldToasts) =>
|
||||
oldToasts.filter((toast) => toast.id !== notificationId)
|
||||
);
|
||||
};
|
||||
|
||||
const canAdd = actions && actions.POST;
|
||||
const alertGroupDataCy = 'notification-template-alerts';
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -198,7 +183,35 @@ function NotificationTemplatesList() {
|
||||
}
|
||||
renderRow={(template, index) => (
|
||||
<NotificationTemplateListItem
|
||||
onAddToast={addTestToast}
|
||||
onAddToast={(notification) => {
|
||||
if (notification.status === 'pending') {
|
||||
return;
|
||||
}
|
||||
|
||||
let message;
|
||||
if (notification.status === 'successful') {
|
||||
message = t`Notification sent successfully`;
|
||||
}
|
||||
if (notification.status === 'failed') {
|
||||
if (notification?.error === 'timed out') {
|
||||
message = t`Notification timed out`;
|
||||
} else {
|
||||
message = notification.error;
|
||||
}
|
||||
}
|
||||
|
||||
addToast({
|
||||
id: notification.id,
|
||||
title:
|
||||
notification.summary_fields.notification_template.name,
|
||||
variant:
|
||||
notification.status === 'failed'
|
||||
? AlertVariant.danger
|
||||
: AlertVariant.success,
|
||||
hasTimeout: notification.status !== 'failed',
|
||||
message,
|
||||
});
|
||||
}}
|
||||
key={template.id}
|
||||
fetchTemplates={fetchTemplates}
|
||||
template={template}
|
||||
@@ -223,39 +236,7 @@ function NotificationTemplatesList() {
|
||||
{t`Failed to delete one or more notification template.`}
|
||||
<ErrorDetail error={deletionError} />
|
||||
</AlertModal>
|
||||
<AlertGroup data-cy={alertGroupDataCy} isToast>
|
||||
{testToasts
|
||||
.filter((notification) => notification.status !== 'pending')
|
||||
.map((notification) => (
|
||||
<Alert
|
||||
actionClose={
|
||||
<AlertActionCloseButton
|
||||
onClose={() => removeTestToast(notification.id)}
|
||||
/>
|
||||
}
|
||||
onTimeout={() => removeTestToast(notification.id)}
|
||||
timeout={notification.status !== 'failed'}
|
||||
title={notification.summary_fields.notification_template.name}
|
||||
variant={notification.status === 'failed' ? 'danger' : 'success'}
|
||||
key={`notification-template-alert-${notification.id}`}
|
||||
ouiaId={`notification-template-alert-${notification.id}`}
|
||||
>
|
||||
<>
|
||||
{notification.status === 'successful' && (
|
||||
<p>{t`Notification sent successfully`}</p>
|
||||
)}
|
||||
{notification.status === 'failed' &&
|
||||
notification?.error === 'timed out' && (
|
||||
<p>{t`Notification timed out`}</p>
|
||||
)}
|
||||
{notification.status === 'failed' &&
|
||||
notification?.error !== 'timed out' && (
|
||||
<p>{notification.error}</p>
|
||||
)}
|
||||
</>
|
||||
</Alert>
|
||||
))}
|
||||
</AlertGroup>
|
||||
<Toast {...toastProps} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import PaginatedTable, {
|
||||
} from 'components/PaginatedTable';
|
||||
import useSelected from 'hooks/useSelected';
|
||||
import useExpanded from 'hooks/useExpanded';
|
||||
import useToast, { AlertVariant } from 'hooks/useToast';
|
||||
import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails';
|
||||
import { getQSConfig, parseQueryString } from 'util/qs';
|
||||
import useWsProjects from './useWsProjects';
|
||||
@@ -34,6 +35,7 @@ const QS_CONFIG = getQSConfig('project', {
|
||||
function ProjectList() {
|
||||
const location = useLocation();
|
||||
const match = useRouteMatch();
|
||||
const { addToast, Toast, toastProps } = useToast();
|
||||
|
||||
const {
|
||||
request: fetchUpdatedProject,
|
||||
@@ -123,6 +125,18 @@ function ProjectList() {
|
||||
}
|
||||
);
|
||||
|
||||
const handleCopy = useCallback(
|
||||
(newId) => {
|
||||
addToast({
|
||||
id: newId,
|
||||
title: t`Project copied successfully`,
|
||||
variant: AlertVariant.success,
|
||||
hasTimeout: true,
|
||||
});
|
||||
},
|
||||
[addToast]
|
||||
);
|
||||
|
||||
const handleProjectDelete = async () => {
|
||||
await deleteProjects();
|
||||
setSelected([]);
|
||||
@@ -255,6 +269,7 @@ function ProjectList() {
|
||||
detailUrl={`${match.url}/${project.id}`}
|
||||
isSelected={selected.some((row) => row.id === project.id)}
|
||||
onSelect={() => handleSelect(project)}
|
||||
onCopy={handleCopy}
|
||||
rowIndex={index}
|
||||
onRefreshRow={(projectId) => fetchUpdatedProject(projectId)}
|
||||
/>
|
||||
@@ -267,6 +282,7 @@ function ProjectList() {
|
||||
/>
|
||||
</Card>
|
||||
</PageSection>
|
||||
<Toast {...toastProps} />
|
||||
{deletionError && (
|
||||
<AlertModal
|
||||
isOpen={deletionError}
|
||||
|
||||
@@ -39,6 +39,7 @@ function ProjectListItem({
|
||||
project,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onCopy,
|
||||
detailUrl,
|
||||
fetchProjects,
|
||||
rowIndex,
|
||||
@@ -53,11 +54,14 @@ function ProjectListItem({
|
||||
};
|
||||
|
||||
const copyProject = useCallback(async () => {
|
||||
await ProjectsAPI.copy(project.id, {
|
||||
const response = await ProjectsAPI.copy(project.id, {
|
||||
name: `${project.name} @ ${timeOfDay()}`,
|
||||
});
|
||||
if (response.status === 201) {
|
||||
onCopy(response.data.id);
|
||||
}
|
||||
await fetchProjects();
|
||||
}, [project.id, project.name, fetchProjects]);
|
||||
}, [project.id, project.name, fetchProjects, onCopy]);
|
||||
|
||||
const generateLastJobTooltip = (job) => (
|
||||
<>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
oneOf,
|
||||
oneOfType,
|
||||
} from 'prop-types';
|
||||
import { AlertVariant } from '@patternfly/react-core';
|
||||
|
||||
export const Role = shape({
|
||||
descendent_roles: arrayOf(string),
|
||||
@@ -428,3 +429,11 @@ export const SearchableKeys = arrayOf(
|
||||
type: string.isRequired,
|
||||
})
|
||||
);
|
||||
|
||||
export const Toast = shape({
|
||||
title: string.isRequired,
|
||||
variant: oneOf(Object.values(AlertVariant)).isRequired,
|
||||
id: oneOfType([string, number]).isRequired,
|
||||
hasTimeout: bool,
|
||||
message: string,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user