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 { 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}

View File

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

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 { 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} />
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

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 { 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} />
</>
);
}

View File

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

View File

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

View File

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