From 4f505486e39945fb4e253b6d51d73847b819a29a Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Tue, 1 Mar 2022 12:59:24 -0800 Subject: [PATCH] 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 --- .../components/TemplateList/TemplateList.js | 17 ++ .../TemplateList/TemplateListItem.js | 11 +- awx/ui/src/hooks/useToast.js | 64 +++++ awx/ui/src/hooks/useToast.test.js | 124 ++++++++ .../CredentialList/CredentialList.js | 195 +++++++------ .../CredentialList/CredentialListItem.js | 9 +- .../ExecutionEnvironmentList.js | 16 ++ .../ExecutionEnvironmentListItem.js | 15 +- .../Inventory/InventoryList/InventoryList.js | 264 ++++++++++-------- .../InventoryList/InventoryListItem.js | 8 +- .../NotificationTemplateList.js | 89 +++--- .../Project/ProjectList/ProjectList.js | 16 ++ .../Project/ProjectList/ProjectListItem.js | 8 +- awx/ui/src/types.js | 9 + 14 files changed, 567 insertions(+), 278 deletions(-) create mode 100644 awx/ui/src/hooks/useToast.js create mode 100644 awx/ui/src/hooks/useToast.test.js diff --git a/awx/ui/src/components/TemplateList/TemplateList.js b/awx/ui/src/components/TemplateList/TemplateList.js index 01fef24065..9e9997dd63 100644 --- a/awx/ui/src/components/TemplateList/TemplateList.js +++ b/awx/ui/src/components/TemplateList/TemplateList.js @@ -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} /> + { + 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); diff --git a/awx/ui/src/hooks/useToast.js b/awx/ui/src/hooks/useToast.js new file mode 100644 index 0000000000..0f5ec1da61 --- /dev/null +++ b/awx/ui/src/hooks/useToast.js @@ -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 ( + + {toasts.map((toast) => ( + 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} + + ))} + + ); +} + +Toast.propTypes = { + toasts: arrayOf(ToastType).isRequired, + removeToast: func.isRequired, +}; + +export { AlertVariant }; diff --git a/awx/ui/src/hooks/useToast.test.js b/awx/ui/src/hooks/useToast.test.js new file mode 100644 index 0000000000..23b6ca845f --- /dev/null +++ b/awx/ui/src/hooks/useToast.test.js @@ -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 = () =>
; + const Test = () => { + const toastVals = useToast(); + return ; + }; + + test('should provide Toast component', () => { + const wrapper = mount(); + + expect(wrapper.find('Child').prop('Toast')).toEqual(Toast); + }); + + test('should add toast', () => { + const wrapper = mount(); + + 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(); + + 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( {}} />); + expect(wrapper).toEqual({}); + }); + + test('should render toast alert', () => { + const toast = { + title: 'Inventory saved', + variant: AlertVariant.success, + id: 1, + message: 'the message', + }; + const wrapper = shallow( {}} />); + + 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( + + ); + + 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( {}} />); + + 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'); + }); +}); diff --git a/awx/ui/src/screens/Credential/CredentialList/CredentialList.js b/awx/ui/src/screens/Credential/CredentialList/CredentialList.js index ab8fa76004..c02b8d7047 100644 --- a/awx/ui/src/screens/Credential/CredentialList/CredentialList.js +++ b/awx/ui/src/screens/Credential/CredentialList/CredentialList.js @@ -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 ( - - - - {t`Name`} - {t`Type`} - {t`Actions`} - - } - renderRow={(item, index) => ( - row.id === item.id)} - onSelect={() => handleSelect(item)} - rowIndex={index} - /> - )} - renderToolbar={(props) => ( - ] - : []), - - } - />, - ]} - /> - )} - /> - - - {t`Failed to delete one or more credentials.`} - - - + <> + + + + {t`Name`} + {t`Type`} + {t`Actions`} + + } + renderRow={(item, index) => ( + row.id === item.id)} + onSelect={() => handleSelect(item)} + onCopy={handleCopy} + rowIndex={index} + /> + )} + renderToolbar={(props) => ( + ] + : []), + + } + />, + ]} + /> + )} + /> + + + {t`Failed to delete one or more credentials.`} + + + + + ); } diff --git a/awx/ui/src/screens/Credential/CredentialList/CredentialListItem.js b/awx/ui/src/screens/Credential/CredentialList/CredentialListItem.js index ad54179832..83470149ae 100644 --- a/awx/ui/src/screens/Credential/CredentialList/CredentialListItem.js +++ b/awx/ui/src/screens/Credential/CredentialList/CredentialListItem.js @@ -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); diff --git a/awx/ui/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnvironmentList.js b/awx/ui/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnvironmentList.js index 547dd28507..6153f3217c 100644 --- a/awx/ui/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnvironmentList.js +++ b/awx/ui/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnvironmentList.js @@ -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`} + ); } diff --git a/awx/ui/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnvironmentListItem.js b/awx/ui/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnvironmentListItem.js index 35280d0204..8281c55a68 100644 --- a/awx/ui/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnvironmentListItem.js +++ b/awx/ui/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnvironmentListItem.js @@ -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; diff --git a/awx/ui/src/screens/Inventory/InventoryList/InventoryList.js b/awx/ui/src/screens/Inventory/InventoryList/InventoryList.js index b1c6bcd032..22108eb30d 100644 --- a/awx/ui/src/screens/Inventory/InventoryList/InventoryList.js +++ b/awx/ui/src/screens/Inventory/InventoryList/InventoryList.js @@ -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 ( - - - - {t`Name`} - {t`Status`} - {t`Type`} - {t`Organization`} - {t`Actions`} - - } - renderToolbar={(props) => ( - - } - warningMessage={ - - } - />, - ]} - /> - )} - renderRow={(inventory, index) => ( - { - if (!inventory.pending_deletion) { - handleSelect(inventory); + <> + + + + {t`Name`} + {t`Status`} + {t`Type`} + {t`Organization`} + {t`Actions`} + + } + renderToolbar={(props) => ( + + } + warningMessage={ + + } + />, + ]} + /> + )} + renderRow={(inventory, index) => ( + row.id === inventory.id)} - /> - )} - emptyStateControls={canAdd && addButton} - /> - - - {t`Failed to delete one or more inventories.`} - - - + onSelect={() => { + if (!inventory.pending_deletion) { + handleSelect(inventory); + } + }} + onCopy={handleCopy} + isSelected={selected.some((row) => row.id === inventory.id)} + /> + )} + emptyStateControls={canAdd && addButton} + /> + + + {t`Failed to delete one or more inventories.`} + + + + + ); } diff --git a/awx/ui/src/screens/Inventory/InventoryList/InventoryListItem.js b/awx/ui/src/screens/Inventory/InventoryList/InventoryListItem.js index 49a0456e8d..c692c32f51 100644 --- a/awx/ui/src/screens/Inventory/InventoryList/InventoryListItem.js +++ b/awx/ui/src/screens/Inventory/InventoryList/InventoryListItem.js @@ -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); diff --git a/awx/ui/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateList.js b/awx/ui/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateList.js index 81c3845e42..defa2ef920 100644 --- a/awx/ui/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateList.js +++ b/awx/ui/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateList.js @@ -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) => ( { + 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.`} - - {testToasts - .filter((notification) => notification.status !== 'pending') - .map((notification) => ( - 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' && ( -

{t`Notification sent successfully`}

- )} - {notification.status === 'failed' && - notification?.error === 'timed out' && ( -

{t`Notification timed out`}

- )} - {notification.status === 'failed' && - notification?.error !== 'timed out' && ( -

{notification.error}

- )} - -
- ))} -
+ ); } diff --git a/awx/ui/src/screens/Project/ProjectList/ProjectList.js b/awx/ui/src/screens/Project/ProjectList/ProjectList.js index e71571f2c6..6c3e829048 100644 --- a/awx/ui/src/screens/Project/ProjectList/ProjectList.js +++ b/awx/ui/src/screens/Project/ProjectList/ProjectList.js @@ -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() { /> + {deletionError && ( { - 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) => ( <> diff --git a/awx/ui/src/types.js b/awx/ui/src/types.js index f0f95b1aa6..55fa23a6c6 100644 --- a/awx/ui/src/types.js +++ b/awx/ui/src/types.js @@ -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, +});