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:
Keith Grant
2022-03-01 12:59:24 -08:00
committed by GitHub
parent a988ad0c4e
commit 4f505486e3
14 changed files with 567 additions and 278 deletions

View File

@@ -12,6 +12,7 @@ import useSelected from 'hooks/useSelected';
import useExpanded from 'hooks/useExpanded'; import useExpanded from 'hooks/useExpanded';
import { getQSConfig, parseQueryString } from 'util/qs'; import { getQSConfig, parseQueryString } from 'util/qs';
import useWsTemplates from 'hooks/useWsTemplates'; import useWsTemplates from 'hooks/useWsTemplates';
import useToast, { AlertVariant } from 'hooks/useToast';
import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails'; import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails';
import AlertModal from '../AlertModal'; import AlertModal from '../AlertModal';
import DatalistToolbar from '../DataListToolbar'; import DatalistToolbar from '../DataListToolbar';
@@ -41,6 +42,8 @@ function TemplateList({ defaultParams }) {
); );
const location = useLocation(); const location = useLocation();
const { addToast, Toast, toastProps } = useToast();
const { const {
result: { result: {
results, 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 () => { const handleTemplateDelete = async () => {
await deleteTemplates(); await deleteTemplates();
clearSelected(); clearSelected();
@@ -266,6 +281,7 @@ function TemplateList({ defaultParams }) {
onSelect={() => handleSelect(template)} onSelect={() => handleSelect(template)}
isExpanded={expanded.some((row) => row.id === template.id)} isExpanded={expanded.some((row) => row.id === template.id)}
onExpand={() => handleExpand(template)} onExpand={() => handleExpand(template)}
onCopy={handleCopy}
isSelected={selected.some((row) => row.id === template.id)} isSelected={selected.some((row) => row.id === template.id)}
fetchTemplates={fetchTemplates} fetchTemplates={fetchTemplates}
rowIndex={index} rowIndex={index}
@@ -274,6 +290,7 @@ function TemplateList({ defaultParams }) {
emptyStateControls={(canAddJT || canAddWFJT) && addButton} emptyStateControls={(canAddJT || canAddWFJT) && addButton}
/> />
</Card> </Card>
<Toast {...toastProps} />
<AlertModal <AlertModal
aria-label={t`Deletion Error`} aria-label={t`Deletion Error`}
isOpen={deletionError} isOpen={deletionError}

View File

@@ -39,6 +39,7 @@ function TemplateListItem({
template, template,
isSelected, isSelected,
onSelect, onSelect,
onCopy,
detailUrl, detailUrl,
fetchTemplates, fetchTemplates,
rowIndex, rowIndex,
@@ -52,17 +53,21 @@ function TemplateListItem({
)}/html/upgrade-migration-guide/upgrade_to_ees.html`; )}/html/upgrade-migration-guide/upgrade_to_ees.html`;
const copyTemplate = useCallback(async () => { const copyTemplate = useCallback(async () => {
let response;
if (template.type === 'job_template') { if (template.type === 'job_template') {
await JobTemplatesAPI.copy(template.id, { response = await JobTemplatesAPI.copy(template.id, {
name: `${template.name} @ ${timeOfDay()}`, name: `${template.name} @ ${timeOfDay()}`,
}); });
} else { } else {
await WorkflowJobTemplatesAPI.copy(template.id, { response = await WorkflowJobTemplatesAPI.copy(template.id, {
name: `${template.name} @ ${timeOfDay()}`, name: `${template.name} @ ${timeOfDay()}`,
}); });
} }
if (response.status === 201) {
onCopy(response.data.id);
}
await fetchTemplates(); await fetchTemplates();
}, [fetchTemplates, template.id, template.name, template.type]); }, [fetchTemplates, template.id, template.name, template.type, onCopy]);
const handleCopyStart = useCallback(() => { const handleCopyStart = useCallback(() => {
setIsDisabled(true); setIsDisabled(true);

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

View 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');
});
});

View File

@@ -4,6 +4,7 @@ import { t, Plural } from '@lingui/macro';
import { Card, PageSection } from '@patternfly/react-core'; import { Card, PageSection } from '@patternfly/react-core';
import { CredentialsAPI } from 'api'; import { CredentialsAPI } from 'api';
import useSelected from 'hooks/useSelected'; import useSelected from 'hooks/useSelected';
import useToast, { AlertVariant } from 'hooks/useToast';
import AlertModal from 'components/AlertModal'; import AlertModal from 'components/AlertModal';
import ErrorDetail from 'components/ErrorDetail'; import ErrorDetail from 'components/ErrorDetail';
import DataListToolbar from 'components/DataListToolbar'; import DataListToolbar from 'components/DataListToolbar';
@@ -27,6 +28,8 @@ const QS_CONFIG = getQSConfig('credential', {
function CredentialList() { function CredentialList() {
const location = useLocation(); const location = useLocation();
const { addToast, Toast, toastProps } = useToast();
const { const {
result: { result: {
credentials, credentials,
@@ -104,100 +107,116 @@ function CredentialList() {
setSelected([]); setSelected([]);
}; };
const handleCopy = useCallback(
(newCredentialId) => {
addToast({
id: newCredentialId,
title: t`Credential copied successfully`,
variant: AlertVariant.success,
hasTimeout: true,
});
},
[addToast]
);
const canAdd = const canAdd =
actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
const deleteDetailsRequests = relatedResourceDeleteRequests.credential( const deleteDetailsRequests = relatedResourceDeleteRequests.credential(
selected[0] selected[0]
); );
return ( return (
<PageSection> <>
<Card> <PageSection>
<PaginatedTable <Card>
contentError={contentError} <PaginatedTable
hasContentLoading={isLoading || isDeleteLoading} contentError={contentError}
items={credentials} hasContentLoading={isLoading || isDeleteLoading}
itemCount={credentialCount} items={credentials}
qsConfig={QS_CONFIG} itemCount={credentialCount}
clearSelected={clearSelected} qsConfig={QS_CONFIG}
toolbarSearchableKeys={searchableKeys} clearSelected={clearSelected}
toolbarRelatedSearchableKeys={relatedSearchableKeys} toolbarSearchableKeys={searchableKeys}
toolbarSearchColumns={[ toolbarRelatedSearchableKeys={relatedSearchableKeys}
{ toolbarSearchColumns={[
name: t`Name`, {
key: 'name__icontains', name: t`Name`,
isDefault: true, key: 'name__icontains',
}, isDefault: true,
{ },
name: t`Description`, {
key: 'description__icontains', name: t`Description`,
}, key: 'description__icontains',
{ },
name: t`Created By (Username)`, {
key: 'created_by__username__icontains', name: t`Created By (Username)`,
}, key: 'created_by__username__icontains',
{ },
name: t`Modified By (Username)`, {
key: 'modified_by__username__icontains', name: t`Modified By (Username)`,
}, key: 'modified_by__username__icontains',
]} },
headerRow={ ]}
<HeaderRow qsConfig={QS_CONFIG}> headerRow={
<HeaderCell sortKey="name">{t`Name`}</HeaderCell> <HeaderRow qsConfig={QS_CONFIG}>
<HeaderCell>{t`Type`}</HeaderCell> <HeaderCell sortKey="name">{t`Name`}</HeaderCell>
<HeaderCell>{t`Actions`}</HeaderCell> <HeaderCell>{t`Type`}</HeaderCell>
</HeaderRow> <HeaderCell>{t`Actions`}</HeaderCell>
} </HeaderRow>
renderRow={(item, index) => ( }
<CredentialListItem renderRow={(item, index) => (
key={item.id} <CredentialListItem
credential={item} key={item.id}
fetchCredentials={fetchCredentials} credential={item}
detailUrl={`/credentials/${item.id}/details`} fetchCredentials={fetchCredentials}
isSelected={selected.some((row) => row.id === item.id)} detailUrl={`/credentials/${item.id}/details`}
onSelect={() => handleSelect(item)} isSelected={selected.some((row) => row.id === item.id)}
rowIndex={index} onSelect={() => handleSelect(item)}
/> onCopy={handleCopy}
)} rowIndex={index}
renderToolbar={(props) => ( />
<DataListToolbar )}
{...props} renderToolbar={(props) => (
isAllSelected={isAllSelected} <DataListToolbar
onSelectAll={selectAll} {...props}
qsConfig={QS_CONFIG} isAllSelected={isAllSelected}
additionalControls={[ onSelectAll={selectAll}
...(canAdd qsConfig={QS_CONFIG}
? [<ToolbarAddButton key="add" linkTo="/credentials/add" />] additionalControls={[
: []), ...(canAdd
<ToolbarDeleteButton ? [<ToolbarAddButton key="add" linkTo="/credentials/add" />]
key="delete" : []),
onDelete={handleDelete} <ToolbarDeleteButton
itemsToDelete={selected} key="delete"
pluralizedItemName={t`Credentials`} onDelete={handleDelete}
deleteDetailsRequests={deleteDetailsRequests} itemsToDelete={selected}
deleteMessage={ pluralizedItemName={t`Credentials`}
<Plural deleteDetailsRequests={deleteDetailsRequests}
value={selected.length} deleteMessage={
one="This credential is currently being used by other resources. Are you sure you want to delete it?" <Plural
other="Deleting these credentials could impact other resources that rely on them. Are you sure you want to delete anyway?" 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`} </Card>
isOpen={deletionError} <AlertModal
variant="error" aria-label={t`Deletion Error`}
title={t`Error!`} isOpen={deletionError}
onClose={clearDeletionError} variant="error"
> title={t`Error!`}
{t`Failed to delete one or more credentials.`} onClose={clearDeletionError}
<ErrorDetail error={deletionError} /> >
</AlertModal> {t`Failed to delete one or more credentials.`}
</PageSection> <ErrorDetail error={deletionError} />
</AlertModal>
</PageSection>
<Toast {...toastProps} />
</>
); );
} }

View File

@@ -18,7 +18,7 @@ function CredentialListItem({
detailUrl, detailUrl,
isSelected, isSelected,
onSelect, onSelect,
onCopy,
fetchCredentials, fetchCredentials,
rowIndex, rowIndex,
}) { }) {
@@ -28,11 +28,14 @@ function CredentialListItem({
const canEdit = credential.summary_fields.user_capabilities.edit; const canEdit = credential.summary_fields.user_capabilities.edit;
const copyCredential = useCallback(async () => { const copyCredential = useCallback(async () => {
await CredentialsAPI.copy(credential.id, { const response = await CredentialsAPI.copy(credential.id, {
name: `${credential.name} @ ${timeOfDay()}`, name: `${credential.name} @ ${timeOfDay()}`,
}); });
if (response.status === 201) {
onCopy(response.data.id);
}
await fetchCredentials(); await fetchCredentials();
}, [credential.id, credential.name, fetchCredentials]); }, [credential.id, credential.name, fetchCredentials, onCopy]);
const handleCopyStart = useCallback(() => { const handleCopyStart = useCallback(() => {
setIsDisabled(true); setIsDisabled(true);

View File

@@ -7,6 +7,7 @@ import { ExecutionEnvironmentsAPI } from 'api';
import { getQSConfig, parseQueryString } from 'util/qs'; import { getQSConfig, parseQueryString } from 'util/qs';
import useRequest, { useDeleteItems } from 'hooks/useRequest'; import useRequest, { useDeleteItems } from 'hooks/useRequest';
import useSelected from 'hooks/useSelected'; import useSelected from 'hooks/useSelected';
import useToast, { AlertVariant } from 'hooks/useToast';
import PaginatedTable, { import PaginatedTable, {
HeaderRow, HeaderRow,
HeaderCell, HeaderCell,
@@ -29,6 +30,7 @@ const QS_CONFIG = getQSConfig('execution_environments', {
function ExecutionEnvironmentList() { function ExecutionEnvironmentList() {
const location = useLocation(); const location = useLocation();
const match = useRouteMatch(); const match = useRouteMatch();
const { addToast, Toast, toastProps } = useToast();
const { const {
error: contentError, 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 () => { const handleDelete = async () => {
await deleteExecutionEnvironments(); await deleteExecutionEnvironments();
clearSelected(); clearSelected();
@@ -194,6 +208,7 @@ function ExecutionEnvironmentList() {
executionEnvironment={executionEnvironment} executionEnvironment={executionEnvironment}
detailUrl={`${match.url}/${executionEnvironment.id}/details`} detailUrl={`${match.url}/${executionEnvironment.id}/details`}
onSelect={() => handleSelect(executionEnvironment)} onSelect={() => handleSelect(executionEnvironment)}
onCopy={handleCopy}
isSelected={selected.some( isSelected={selected.some(
(row) => row.id === executionEnvironment.id (row) => row.id === executionEnvironment.id
)} )}
@@ -218,6 +233,7 @@ function ExecutionEnvironmentList() {
{t`Failed to delete one or more execution environments`} {t`Failed to delete one or more execution environments`}
<ErrorDetail error={deletionError} /> <ErrorDetail error={deletionError} />
</AlertModal> </AlertModal>
<Toast {...toastProps} />
</> </>
); );
} }

View File

@@ -18,20 +18,28 @@ function ExecutionEnvironmentListItem({
detailUrl, detailUrl,
isSelected, isSelected,
onSelect, onSelect,
onCopy,
rowIndex, rowIndex,
fetchExecutionEnvironments, fetchExecutionEnvironments,
}) { }) {
const [isDisabled, setIsDisabled] = useState(false); const [isDisabled, setIsDisabled] = useState(false);
const copyExecutionEnvironment = useCallback(async () => { const copyExecutionEnvironment = useCallback(async () => {
await ExecutionEnvironmentsAPI.copy(executionEnvironment.id, { const response = await ExecutionEnvironmentsAPI.copy(
name: `${executionEnvironment.name} @ ${timeOfDay()}`, executionEnvironment.id,
}); {
name: `${executionEnvironment.name} @ ${timeOfDay()}`,
}
);
if (response.status === 201) {
onCopy(response.data.id);
}
await fetchExecutionEnvironments(); await fetchExecutionEnvironments();
}, [ }, [
executionEnvironment.id, executionEnvironment.id,
executionEnvironment.name, executionEnvironment.name,
fetchExecutionEnvironments, fetchExecutionEnvironments,
onCopy,
]); ]);
const handleCopyStart = useCallback(() => { const handleCopyStart = useCallback(() => {
@@ -114,6 +122,7 @@ ExecutionEnvironmentListItem.prototype = {
detailUrl: string.isRequired, detailUrl: string.isRequired,
isSelected: bool.isRequired, isSelected: bool.isRequired,
onSelect: func.isRequired, onSelect: func.isRequired,
onCopy: func.isRequired,
}; };
export default ExecutionEnvironmentListItem; export default ExecutionEnvironmentListItem;

View File

@@ -5,6 +5,7 @@ import { Card, PageSection, DropdownItem } from '@patternfly/react-core';
import { InventoriesAPI } from 'api'; import { InventoriesAPI } from 'api';
import useRequest, { useDeleteItems } from 'hooks/useRequest'; import useRequest, { useDeleteItems } from 'hooks/useRequest';
import useSelected from 'hooks/useSelected'; import useSelected from 'hooks/useSelected';
import useToast, { AlertVariant } from 'hooks/useToast';
import AlertModal from 'components/AlertModal'; import AlertModal from 'components/AlertModal';
import DatalistToolbar from 'components/DataListToolbar'; import DatalistToolbar from 'components/DataListToolbar';
import ErrorDetail from 'components/ErrorDetail'; import ErrorDetail from 'components/ErrorDetail';
@@ -29,6 +30,7 @@ const QS_CONFIG = getQSConfig('inventory', {
function InventoryList() { function InventoryList() {
const location = useLocation(); const location = useLocation();
const match = useRouteMatch(); const match = useRouteMatch();
const { addToast, Toast, toastProps } = useToast();
const { const {
result: { result: {
@@ -112,6 +114,18 @@ function InventoryList() {
clearSelected(); clearSelected();
}; };
const handleCopy = useCallback(
(newInventoryId) => {
addToast({
id: newInventoryId,
title: t`Inventory copied successfully`,
variant: AlertVariant.success,
hasTimeout: true,
});
},
[addToast]
);
const hasContentLoading = isDeleteLoading || isLoading; const hasContentLoading = isDeleteLoading || isLoading;
const canAdd = actions && actions.POST; const canAdd = actions && actions.POST;
@@ -149,130 +163,134 @@ function InventoryList() {
); );
return ( return (
<PageSection> <>
<Card> <PageSection>
<PaginatedTable <Card>
contentError={contentError} <PaginatedTable
hasContentLoading={hasContentLoading} contentError={contentError}
items={inventories} hasContentLoading={hasContentLoading}
itemCount={itemCount} items={inventories}
pluralizedItemName={t`Inventories`} itemCount={itemCount}
qsConfig={QS_CONFIG} pluralizedItemName={t`Inventories`}
toolbarSearchColumns={[ qsConfig={QS_CONFIG}
{ toolbarSearchColumns={[
name: t`Name`, {
key: 'name__icontains', name: t`Name`,
isDefault: true, key: 'name__icontains',
}, isDefault: true,
{ },
name: t`Inventory Type`, {
key: 'or__kind', name: t`Inventory Type`,
options: [ key: 'or__kind',
['', t`Inventory`], options: [
['smart', t`Smart Inventory`], ['', t`Inventory`],
], ['smart', t`Smart Inventory`],
}, ],
{ },
name: t`Organization`, {
key: 'organization__name', name: t`Organization`,
}, key: 'organization__name',
{ },
name: t`Description`, {
key: 'description__icontains', name: t`Description`,
}, key: 'description__icontains',
{ },
name: t`Created By (Username)`, {
key: 'created_by__username__icontains', name: t`Created By (Username)`,
}, key: 'created_by__username__icontains',
{ },
name: t`Modified By (Username)`, {
key: 'modified_by__username__icontains', name: t`Modified By (Username)`,
}, key: 'modified_by__username__icontains',
]} },
toolbarSortColumns={[ ]}
{ toolbarSortColumns={[
name: t`Name`, {
key: 'name', name: t`Name`,
}, key: 'name',
]} },
toolbarSearchableKeys={searchableKeys} ]}
toolbarRelatedSearchableKeys={relatedSearchableKeys} toolbarSearchableKeys={searchableKeys}
clearSelected={clearSelected} toolbarRelatedSearchableKeys={relatedSearchableKeys}
headerRow={ clearSelected={clearSelected}
<HeaderRow qsConfig={QS_CONFIG}> headerRow={
<HeaderCell sortKey="name">{t`Name`}</HeaderCell> <HeaderRow qsConfig={QS_CONFIG}>
<HeaderCell>{t`Status`}</HeaderCell> <HeaderCell sortKey="name">{t`Name`}</HeaderCell>
<HeaderCell>{t`Type`}</HeaderCell> <HeaderCell>{t`Status`}</HeaderCell>
<HeaderCell>{t`Organization`}</HeaderCell> <HeaderCell>{t`Type`}</HeaderCell>
<HeaderCell>{t`Actions`}</HeaderCell> <HeaderCell>{t`Organization`}</HeaderCell>
</HeaderRow> <HeaderCell>{t`Actions`}</HeaderCell>
} </HeaderRow>
renderToolbar={(props) => ( }
<DatalistToolbar renderToolbar={(props) => (
{...props} <DatalistToolbar
isAllSelected={isAllSelected} {...props}
onSelectAll={selectAll} isAllSelected={isAllSelected}
qsConfig={QS_CONFIG} onSelectAll={selectAll}
additionalControls={[ qsConfig={QS_CONFIG}
...(canAdd ? [addButton] : []), additionalControls={[
<ToolbarDeleteButton ...(canAdd ? [addButton] : []),
key="delete" <ToolbarDeleteButton
onDelete={handleInventoryDelete} key="delete"
itemsToDelete={selected} onDelete={handleInventoryDelete}
pluralizedItemName={t`Inventories`} itemsToDelete={selected}
deleteDetailsRequests={deleteDetailsRequests} pluralizedItemName={t`Inventories`}
deleteMessage={ deleteDetailsRequests={deleteDetailsRequests}
<Plural deleteMessage={
value={selected.length} <Plural
one="This inventory is currently being used by some templates. Are you sure you want to delete it?" value={selected.length}
other="Deleting these inventories could impact some templates that rely on them. Are you sure you want to delete anyway?" 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 warningMessage={
value={selected.length} <Plural
one="The inventory will be in a pending status until the final delete is processed." value={selected.length}
other="The inventories will be in a pending status until the final delete is processed." 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 renderRow={(inventory, index) => (
key={inventory.id} <InventoryListItem
value={inventory.name} key={inventory.id}
inventory={inventory} value={inventory.name}
rowIndex={index} inventory={inventory}
fetchInventories={fetchInventories} rowIndex={index}
detailUrl={ fetchInventories={fetchInventories}
inventory.kind === 'smart' detailUrl={
? `${match.url}/smart_inventory/${inventory.id}/details` inventory.kind === 'smart'
: `${match.url}/inventory/${inventory.id}/details` ? `${match.url}/smart_inventory/${inventory.id}/details`
} : `${match.url}/inventory/${inventory.id}/details`
onSelect={() => {
if (!inventory.pending_deletion) {
handleSelect(inventory);
} }
}} onSelect={() => {
isSelected={selected.some((row) => row.id === inventory.id)} if (!inventory.pending_deletion) {
/> handleSelect(inventory);
)} }
emptyStateControls={canAdd && addButton} }}
/> onCopy={handleCopy}
</Card> isSelected={selected.some((row) => row.id === inventory.id)}
<AlertModal />
isOpen={deletionError} )}
variant="error" emptyStateControls={canAdd && addButton}
aria-label={t`Deletion Error`} />
title={t`Error!`} </Card>
onClose={clearDeletionError} <AlertModal
> isOpen={deletionError}
{t`Failed to delete one or more inventories.`} variant="error"
<ErrorDetail error={deletionError} /> aria-label={t`Deletion Error`}
</AlertModal> title={t`Error!`}
</PageSection> onClose={clearDeletionError}
>
{t`Failed to delete one or more inventories.`}
<ErrorDetail error={deletionError} />
</AlertModal>
</PageSection>
<Toast {...toastProps} />
</>
); );
} }

View File

@@ -18,6 +18,7 @@ function InventoryListItem({
rowIndex, rowIndex,
isSelected, isSelected,
onSelect, onSelect,
onCopy,
detailUrl, detailUrl,
fetchInventories, fetchInventories,
}) { }) {
@@ -30,11 +31,14 @@ function InventoryListItem({
const [isCopying, setIsCopying] = useState(false); const [isCopying, setIsCopying] = useState(false);
const copyInventory = useCallback(async () => { const copyInventory = useCallback(async () => {
await InventoriesAPI.copy(inventory.id, { const response = await InventoriesAPI.copy(inventory.id, {
name: `${inventory.name} @ ${timeOfDay()}`, name: `${inventory.name} @ ${timeOfDay()}`,
}); });
if (response.status === 201) {
onCopy(response.data.id);
}
await fetchInventories(); await fetchInventories();
}, [inventory.id, inventory.name, fetchInventories]); }, [inventory.id, inventory.name, fetchInventories, onCopy]);
const handleCopyStart = useCallback(() => { const handleCopyStart = useCallback(() => {
setIsCopying(true); setIsCopying(true);

View File

@@ -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 { useLocation, useRouteMatch } from 'react-router-dom';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { import { Card, PageSection } from '@patternfly/react-core';
Alert,
AlertActionCloseButton,
AlertGroup,
Card,
PageSection,
} from '@patternfly/react-core';
import { NotificationTemplatesAPI } from 'api'; import { NotificationTemplatesAPI } from 'api';
import PaginatedTable, { import PaginatedTable, {
HeaderRow, HeaderRow,
@@ -22,6 +16,7 @@ import ErrorDetail from 'components/ErrorDetail';
import DataListToolbar from 'components/DataListToolbar'; import DataListToolbar from 'components/DataListToolbar';
import useRequest, { useDeleteItems } from 'hooks/useRequest'; import useRequest, { useDeleteItems } from 'hooks/useRequest';
import useSelected from 'hooks/useSelected'; import useSelected from 'hooks/useSelected';
import useToast, { AlertVariant } from 'hooks/useToast';
import { getQSConfig, parseQueryString } from 'util/qs'; import { getQSConfig, parseQueryString } from 'util/qs';
import NotificationTemplateListItem from './NotificationTemplateListItem'; import NotificationTemplateListItem from './NotificationTemplateListItem';
@@ -34,7 +29,8 @@ const QS_CONFIG = getQSConfig('notification-templates', {
function NotificationTemplatesList() { function NotificationTemplatesList() {
const location = useLocation(); const location = useLocation();
const match = useRouteMatch(); const match = useRouteMatch();
const [testToasts, setTestToasts] = useState([]); // const [testToasts, setTestToasts] = useState([]);
const { addToast, Toast, toastProps } = useToast();
const addUrl = `${match.url}/add`; const addUrl = `${match.url}/add`;
@@ -107,18 +103,7 @@ function NotificationTemplatesList() {
clearSelected(); 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 canAdd = actions && actions.POST;
const alertGroupDataCy = 'notification-template-alerts';
return ( return (
<> <>
@@ -198,7 +183,35 @@ function NotificationTemplatesList() {
} }
renderRow={(template, index) => ( renderRow={(template, index) => (
<NotificationTemplateListItem <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} key={template.id}
fetchTemplates={fetchTemplates} fetchTemplates={fetchTemplates}
template={template} template={template}
@@ -223,39 +236,7 @@ function NotificationTemplatesList() {
{t`Failed to delete one or more notification template.`} {t`Failed to delete one or more notification template.`}
<ErrorDetail error={deletionError} /> <ErrorDetail error={deletionError} />
</AlertModal> </AlertModal>
<AlertGroup data-cy={alertGroupDataCy} isToast> <Toast {...toastProps} />
{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>
</> </>
); );
} }

View File

@@ -19,6 +19,7 @@ import PaginatedTable, {
} from 'components/PaginatedTable'; } from 'components/PaginatedTable';
import useSelected from 'hooks/useSelected'; import useSelected from 'hooks/useSelected';
import useExpanded from 'hooks/useExpanded'; import useExpanded from 'hooks/useExpanded';
import useToast, { AlertVariant } from 'hooks/useToast';
import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails'; import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails';
import { getQSConfig, parseQueryString } from 'util/qs'; import { getQSConfig, parseQueryString } from 'util/qs';
import useWsProjects from './useWsProjects'; import useWsProjects from './useWsProjects';
@@ -34,6 +35,7 @@ const QS_CONFIG = getQSConfig('project', {
function ProjectList() { function ProjectList() {
const location = useLocation(); const location = useLocation();
const match = useRouteMatch(); const match = useRouteMatch();
const { addToast, Toast, toastProps } = useToast();
const { const {
request: fetchUpdatedProject, 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 () => { const handleProjectDelete = async () => {
await deleteProjects(); await deleteProjects();
setSelected([]); setSelected([]);
@@ -255,6 +269,7 @@ function ProjectList() {
detailUrl={`${match.url}/${project.id}`} detailUrl={`${match.url}/${project.id}`}
isSelected={selected.some((row) => row.id === project.id)} isSelected={selected.some((row) => row.id === project.id)}
onSelect={() => handleSelect(project)} onSelect={() => handleSelect(project)}
onCopy={handleCopy}
rowIndex={index} rowIndex={index}
onRefreshRow={(projectId) => fetchUpdatedProject(projectId)} onRefreshRow={(projectId) => fetchUpdatedProject(projectId)}
/> />
@@ -267,6 +282,7 @@ function ProjectList() {
/> />
</Card> </Card>
</PageSection> </PageSection>
<Toast {...toastProps} />
{deletionError && ( {deletionError && (
<AlertModal <AlertModal
isOpen={deletionError} isOpen={deletionError}

View File

@@ -39,6 +39,7 @@ function ProjectListItem({
project, project,
isSelected, isSelected,
onSelect, onSelect,
onCopy,
detailUrl, detailUrl,
fetchProjects, fetchProjects,
rowIndex, rowIndex,
@@ -53,11 +54,14 @@ function ProjectListItem({
}; };
const copyProject = useCallback(async () => { const copyProject = useCallback(async () => {
await ProjectsAPI.copy(project.id, { const response = await ProjectsAPI.copy(project.id, {
name: `${project.name} @ ${timeOfDay()}`, name: `${project.name} @ ${timeOfDay()}`,
}); });
if (response.status === 201) {
onCopy(response.data.id);
}
await fetchProjects(); await fetchProjects();
}, [project.id, project.name, fetchProjects]); }, [project.id, project.name, fetchProjects, onCopy]);
const generateLastJobTooltip = (job) => ( const generateLastJobTooltip = (job) => (
<> <>

View File

@@ -9,6 +9,7 @@ import {
oneOf, oneOf,
oneOfType, oneOfType,
} from 'prop-types'; } from 'prop-types';
import { AlertVariant } from '@patternfly/react-core';
export const Role = shape({ export const Role = shape({
descendent_roles: arrayOf(string), descendent_roles: arrayOf(string),
@@ -428,3 +429,11 @@ export const SearchableKeys = arrayOf(
type: string.isRequired, 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,
});