diff --git a/.github/workflows/cypress-testing.yml b/.github/workflows/cypress-testing.yml index f51061446d..9c7ae3d0b3 100644 --- a/.github/workflows/cypress-testing.yml +++ b/.github/workflows/cypress-testing.yml @@ -98,6 +98,7 @@ jobs: git clone https://github.com/glific/cypress-testing.git echo done. go to dir. cd cypress-testing + git checkout apollo_migration cd .. cp -r cypress-testing/cypress cypress yarn add cypress@13.6.2 @@ -135,4 +136,4 @@ jobs: name: phoenix-server-log-shard-${{ matrix.shard }} path: ${{ env.artifacts_path }} if-no-files-found: warn - retention-days: 7 \ No newline at end of file + retention-days: 7 diff --git a/src/components/UI/Layout/Navigation/SideMenus/SideMenus.tsx b/src/components/UI/Layout/Navigation/SideMenus/SideMenus.tsx index 4339eadefa..1aac4d2015 100644 --- a/src/components/UI/Layout/Navigation/SideMenus/SideMenus.tsx +++ b/src/components/UI/Layout/Navigation/SideMenus/SideMenus.tsx @@ -67,19 +67,17 @@ const SideMenus = ({ opened }: SideMenusProps) => { const { t } = useTranslation(); // handle count for notifictions - const [notificationCount, setNotificationCount] = useState(0); - const [getNotificationCount] = useLazyQuery(GET_NOTIFICATIONS_COUNT, { + const [getNotificationCount, { data: notificationData }] = useLazyQuery(GET_NOTIFICATIONS_COUNT, { variables: { filter: { is_read: false, }, }, fetchPolicy: 'cache-and-network', - onCompleted: (countData) => { - setNotificationCount(countData.countNotifications); - }, }); + const notificationCount = notificationData?.countNotifications ?? 0; + useEffect(() => { getNotificationCount(); }, []); diff --git a/src/containers/Flow/FlowList/FlowList.test.tsx b/src/containers/Flow/FlowList/FlowList.test.tsx index 3f6a658bb9..f7196531a9 100644 --- a/src/containers/Flow/FlowList/FlowList.test.tsx +++ b/src/containers/Flow/FlowList/FlowList.test.tsx @@ -23,7 +23,8 @@ import { Flow } from '../Flow'; import { getFilterTagQuery } from 'mocks/Tag'; import { getRoleNameQuery } from 'mocks/Role'; import * as Notification from 'common/notification'; -import { IMPORT_FLOW } from 'graphql/mutations/Flow'; +import { IMPORT_FLOW, PIN_FLOW } from 'graphql/mutations/Flow'; +import { EXPORT_FLOW } from 'graphql/queries/Flow'; const isActiveFilter = { isActive: true, isTemplate: false }; @@ -42,21 +43,17 @@ const mocks = [ getFlowCountNewQuery, getFlowQuery({ id: 1 }), getFlowQuery({ id: '1' }), - importFlow, releaseFlow, - exportFlow, getFilterTagQuery, getRoleNameQuery, getFlowCountQuery({ isTemplate: true }), filterTemplateFlows, - pinFlowQuery('2', true), - pinFlowQuery('1'), ...getOrganizationQuery, ]; const normalize = (s: string) => s.replace(/\s+/g, ' ').trim().toLowerCase(); -const flowList = ( - +const flowList = (customMocks?: any[]) => ( + @@ -81,7 +78,7 @@ const notificationSpy = vi.spyOn(Notification, 'setNotification'); describe('', () => { test('should render Flow', async () => { - const { getByText, getByTestId } = render(flowList); + const { getByText, getByTestId } = render(flowList()); expect(getByTestId('loading')).toBeInTheDocument(); await waitFor(() => { expect(getByText('Flows')); @@ -94,7 +91,7 @@ describe('', () => { }); test('should search flow and check if flow keywords are present below the name', async () => { - const { getByText, getByTestId, queryByPlaceholderText } = render(flowList); + const { getByText, getByTestId, queryByPlaceholderText } = render(flowList()); await waitFor(() => { // type "Help Workflow" in search box and enter expect(getByTestId('searchInput')).toBeInTheDocument(); @@ -121,7 +118,7 @@ describe('', () => { }); test('click on Make a copy', async () => { - const { getAllByTestId } = render(flowList); + const { getAllByTestId } = render(flowList()); await waitFor(() => { expect(getAllByTestId('copy-icon')[0]).toBeInTheDocument(); @@ -135,7 +132,7 @@ describe('', () => { }); test('should import flow using json file', async () => { - render(flowList); + render(flowList([...mocks, importFlow])); await waitFor(() => { expect(screen.getAllByTestId('import-icon')[0]).toBeInTheDocument(); @@ -167,7 +164,7 @@ describe('', () => { test('should export flow to json file', async () => { globalThis.URL.createObjectURL = vi.fn(); - render(flowList); + render(flowList([...mocks, exportFlow])); await waitFor(() => { screen.getAllByTestId('MoreIcon'); @@ -183,7 +180,7 @@ describe('', () => { }); test('should create from scratch ', async () => { - render(flowList); + render(flowList()); await waitFor(() => { expect(screen.getByText('Flows')).toBeInTheDocument(); @@ -203,7 +200,7 @@ describe('', () => { }); test('it should pin/unpin the flows', async () => { - render(flowList); + render(flowList([...mocks, pinFlowQuery('2', true), pinFlowQuery('1')])); await waitFor(() => { expect(screen.getByText('Flows')).toBeInTheDocument(); @@ -223,7 +220,7 @@ describe('', () => { }); test('it should navigate to create page with selected tag', async () => { - render(flowList); + render(flowList()); await waitFor(() => { expect(screen.getByText('Flows')).toBeInTheDocument(); @@ -247,7 +244,7 @@ describe('', () => { }); test('should navigate to edit page on clicking the edit button', async () => { - render(flowList); + render(flowList()); await waitFor(() => { expect(screen.getByText('Flows')).toBeInTheDocument(); @@ -267,7 +264,7 @@ describe('', () => { }); test('should open responder link dialog on clicking the share button', async () => { - render(flowList); + render(flowList()); await waitFor(() => { expect(screen.getByText('Flows')).toBeInTheDocument(); @@ -281,7 +278,7 @@ describe('', () => { }); test('should show warning when no keywords are selected and share button is clicked', async () => { - render(flowList); + render(flowList()); await waitFor(() => { expect(screen.getByText('Flows')).toBeInTheDocument(); @@ -297,7 +294,7 @@ describe('', () => { describe('Template flows', () => { test('it opens and closes dialog box', async () => { - render(flowList); + render(flowList()); await waitFor(() => { expect(screen.getByText('Flows')).toBeInTheDocument(); @@ -322,7 +319,7 @@ describe('Template flows', () => { }); test('it shows and creates a template flows', async () => { - render(flowList); + render(flowList()); await waitFor(() => { expect(screen.getByText('Flows')).toBeInTheDocument(); @@ -348,7 +345,7 @@ describe('Template flows', () => { }); test('click on Use it for templates', async () => { - render(flowList); + render(flowList()); await waitFor(() => { expect(screen.getByText('Flows')).toBeInTheDocument(); @@ -457,3 +454,94 @@ describe('Template flows', () => { }); }); }); + +describe('Error handling', () => { + test('should show error notification when export flow fails', async () => { + const exportFlowError = { + request: { + query: EXPORT_FLOW, + variables: { id: '1' }, + }, + error: new Error('Network error'), + }; + + render(flowList([...mocks, exportFlowError])); + + await waitFor(() => { + screen.getAllByTestId('MoreIcon'); + }); + + fireEvent.click(screen.getAllByTestId('MoreIcon')[0]); + + await waitFor(() => { + expect(screen.getAllByTestId('export-icon')[0]).toBeInTheDocument(); + }); + + fireEvent.click(screen.getAllByTestId('export-icon')[0]); + + await waitFor(() => { + expect(notificationSpy).toHaveBeenCalled(); + }); + }); + + test('should show error notification when import flow fails', async () => { + const importFlowError = { + request: { + query: IMPORT_FLOW, + }, + error: new Error('Import failed'), + variableMatcher: () => true, + }; + + class FileReaderMock { + onload: null | ((e: unknown) => void) = null; + result: string | null = null; + readAsText() { + const text = '{"flows":[]}'; + this.result = text; + setTimeout(() => { + if (this.onload) { + this.onload({ target: { result: text } } as unknown as ProgressEvent); + } + }, 0); + } + } + vi.stubGlobal('FileReader', FileReaderMock); + + render(flowList([...mocks, importFlowError])); + + await screen.findAllByTestId('import-icon'); + fireEvent.click(screen.getAllByTestId('import-icon')[0]); + + const file = new File(['{}'], 'test.json', { type: 'application/json' }); + const input = await screen.findByTestId('import'); + Object.defineProperty(input, 'files', { value: [file] }); + fireEvent.change(input); + + await waitFor(() => { + expect(notificationSpy).toHaveBeenCalledWith('An error occured while importing the flow', 'warning'); + }); + }); + + test('should show error notification when pin flow fails', async () => { + const pinFlowError = { + request: { + query: PIN_FLOW, + variables: { updateFlowId: '2', input: { isPinned: true } }, + }, + error: new Error('Pin failed'), + }; + + render(flowList([...mocks, pinFlowError])); + + await waitFor(() => { + expect(screen.getByText('Flows')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getAllByTestId('pin-button')[0]); + + await waitFor(() => { + expect(notificationSpy).toHaveBeenCalledWith('Failed to update pin status', 'warning'); + }); + }); +}); diff --git a/src/containers/Flow/FlowList/FlowList.tsx b/src/containers/Flow/FlowList/FlowList.tsx index 3166b8bea6..49c6e4a967 100644 --- a/src/containers/Flow/FlowList/FlowList.tsx +++ b/src/containers/Flow/FlowList/FlowList.tsx @@ -75,7 +75,6 @@ export const FlowList = () => { const { t } = useTranslation(); const [filter, setFilter] = useState(true); const [selectedtag, setSelectedTag] = useState(null); - const [flowName, setFlowName] = useState(''); const [importing, setImporting] = useState(false); const [importStatus, setImportStatus] = useState([]); const [showDialog, setShowDialog] = useState(false); @@ -89,28 +88,10 @@ export const FlowList = () => { releaseFlow(); }, []); - const [importFlow] = useMutation(IMPORT_FLOW, { - onCompleted: (result: any) => { - const { status } = result.importFlow; - setImportStatus(status); - setImporting(false); - }, - onError: (error: any) => { - setNotification('An error occured while importing the flow', 'warning'); - setImporting(false); - }, - }); + const [importFlow] = useMutation(IMPORT_FLOW); const [exportFlowMutation] = useLazyQuery(EXPORT_FLOW, { fetchPolicy: 'network-only', - onCompleted: async ({ exportFlow }) => { - const { exportData } = exportFlow; - await exportFlowMethod(exportData, flowName); - setNotification('Flow exported successfully'); - }, - onError: (error: any) => { - setErrorMessage(error); - }, }); const [updatePinned] = useMutation(PIN_FLOW); @@ -123,38 +104,41 @@ export const FlowList = () => { navigate(`/flow/${id}/edit`, { state: 'copy' }); }; - const exportFlow = (id: any, item: any) => { - setFlowName(item.name); - exportFlowMutation({ variables: { id } }); + const exportFlow = async (id: any, item: any) => { + try { + const result = await exportFlowMutation({ variables: { id } }); + const { exportData } = result.data.exportFlow; + await exportFlowMethod(exportData, item.name); + setNotification('Flow exported successfully'); + } catch (error: any) { + setErrorMessage(error); + } }; - const handlePin = (updateFlowId: any, pin: boolean = false) => { - if (pin) { - updatePinned({ - variables: { - updateFlowId, - input: { - isPinned: true, - }, - }, - onCompleted: () => { - setRefreshList(!refreshList); - setNotification('Flow pinned successfully'); - }, - }); - } else { - updatePinned({ + const handleImport = async (result: string) => { + try { + const { data } = await importFlow({ variables: { flow: result } }); + const { status } = data.importFlow; + setImportStatus(status); + } catch { + setNotification('An error occured while importing the flow', 'warning'); + } finally { + setImporting(false); + } + }; + + const handlePin = async (updateFlowId: any, pin: boolean = false) => { + try { + await updatePinned({ variables: { updateFlowId, - input: { - isPinned: false, - }, - }, - onCompleted: () => { - setRefreshList(!refreshList); - setNotification('Flow unpinned successfully'); + input: { isPinned: pin }, }, }); + setRefreshList(!refreshList); + setNotification(pin ? 'Flow pinned successfully' : 'Flow unpinned successfully'); + } catch { + setNotification('Failed to update pin status', 'warning'); } }; @@ -247,11 +231,7 @@ export const FlowList = () => { } const importButton = ( - setImporting(true)} - afterImport={(result: string) => importFlow({ variables: { flow: result } })} - /> + setImporting(true)} afterImport={handleImport} /> ); const templateFlowActions = [ diff --git a/src/containers/Form/FormLayout.tsx b/src/containers/Form/FormLayout.tsx index cfb5ad4092..a780497992 100644 --- a/src/containers/Form/FormLayout.tsx +++ b/src/containers/Form/FormLayout.tsx @@ -2,7 +2,7 @@ import { useState, Fragment, useEffect } from 'react'; import { Navigate, useParams } from 'react-router'; import { Field, useFormik, FormikProvider } from 'formik'; // eslint-disable-next-line no-unused-vars -import { DocumentNode, ApolloError, useQuery, useMutation } from '@apollo/client'; +import { DocumentNode, useQuery, useMutation } from '@apollo/client'; import { Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; @@ -153,18 +153,28 @@ export const FormLayout = ({ }: FormLayoutProps) => { const [showDialog, setShowDialog] = useState(false); const [formSubmitted, setFormSubmitted] = useState(false); - const [languageId, setLanguageId] = useState(''); const [formCancelled, setFormCancelled] = useState(false); const [action, setAction] = useState(false); const [link, setLink] = useState(undefined); const [deleted, setDeleted] = useState(false); const [saveClick, onSaveClick] = useState(false); - const [isLoadedData, setIsLoadedData] = useState(false); const [customError, setCustomError] = useState(null); const [showConfirmationDialog, setShowConfirmationDialog] = useState(false); const params = useParams(); - const saveHandler = ({ languageId: languageIdValue, ...itemData }: any) => { + const capitalListItemName = listItemName[0].toUpperCase() + listItemName.slice(1); + const camelCaseItem = listItem[0].toUpperCase() + listItem.slice(1); + let itemId = entityId; + if (!itemId) { + itemId = params.id; + } + + let variables: any = itemId ? { [idType]: itemId } : false; + if (listItem === 'credential') { + variables = params.type ? { shortcode: params.type } : false; + } + + const saveHandler = ({ languageId: languageIdValue, ...itemData }: any, isSaveClick: boolean = false) => { let payload = { ...itemData, ...defaultAttribute, @@ -198,20 +208,107 @@ export const FormLayout = ({ const payloadCopy = payload; delete payloadCopy.attachmentURL; payloadCopy.messageMediaId = parseInt(data.data.createMessageMedia.messageMedia.id, 10); - performTask(payloadCopy); + performTask(payloadCopy, isSaveClick); } }) .catch((e: any) => { setErrorMessage(e); }); } else { - performTask(payload); + performTask(payload, isSaveClick); + } + }; + + const handleUpdateCompleted = (data: any, isSaveClick: boolean) => { + setShowConfirmationDialog(false); + let itemUpdatedObject: any = Object.keys(data)[0]; + itemUpdatedObject = data[itemUpdatedObject]; + const updatedItem = itemUpdatedObject[listItem]; + const { errors, message } = itemUpdatedObject; + + if (errors) { + if (customHandler) { + customHandler(errors); + } else { + setErrorMessage(errors[0]); + } + } else if (updatedItem && typeof updatedItem.isValid === 'boolean' && !updatedItem.isValid) { + if (customError) { + const codeErrors = { code: 'Failed to compile code. Please check again' }; + customError.setErrors(codeErrors); + } + } else { + if (type === 'copy') setLink(updatedItem[linkParameter]); + if (additionalQuery) { + additionalQuery(itemId); + } + + if (saveOnPageChange || isSaveClick) { + setFormSubmitted(true); + let notificationMessage = `${capitalListItemName} edited successfully!`; + if (type === 'copy') { + notificationMessage = copyNotification; + } + setNotification(notificationMessage); + } else { + setNotification('Your changes have been autosaved'); + } + if (afterSave) { + afterSave(data, isSaveClick, message); + } + } + onSaveClick(false); + }; + + const handleCreateCompleted = (data: any, isSaveClick: boolean) => { + setShowConfirmationDialog(false); + let itemCreatedObject: any = `create${camelCaseItem}`; + itemCreatedObject = data[itemCreatedObject]; + const itemCreated = itemCreatedObject[listItem]; + + const { errors } = itemCreatedObject; + if (errors) { + if (customHandler) { + customHandler(errors); + } else { + setErrorMessage(errors[0]); + } + } else if (itemCreated && typeof itemCreated.isValid === 'boolean' && !itemCreated.isValid) { + if (customError) { + const codeErrors = { code: 'Failed to compile code. Please check again' }; + customError.setErrors(codeErrors); + } + } else { + if (additionalQuery) { + additionalQuery(itemCreated.id); + } + if (!itemId) setLink(itemCreated[linkParameter]); + if (saveOnPageChange || isSaveClick) { + setFormSubmitted(true); + setNotification(`${capitalListItemName} created successfully!`); + } else { + setNotification('Your changes have been autosaved'); + } + if (afterSave) { + afterSave(data, isSaveClick); + } + } + onSaveClick(false); + }; + + const handleMutationError = (e: any) => { + setShowConfirmationDialog(false); + onSaveClick(false); + if (customHandler) { + customHandler(e.message); + } else { + setErrorMessage(e); } }; - const performTask = (payload: any) => { - if (itemId) { - if (isLoadedData) { + const performTask = async (payload: any, isSaveClick: boolean) => { + try { + if (itemId && isLoadedData) { let idKey = idType; let idVal = itemId; @@ -225,32 +322,67 @@ export const FormLayout = ({ if (idType === 'organizationId') { idKey = 'id'; idVal = payloadBody.billingId; - // Clearning unnecessary fields delete payloadBody.billingId; } - updateItem({ + const { data } = await updateItem({ variables: { [idKey]: idVal, input: payloadBody, }, }); + if (data) handleUpdateCompleted(data, isSaveClick); } else { - createItem({ + const { data } = await createItem({ variables: { input: payload, }, }); + if (data) handleCreateCompleted(data, isSaveClick); } - } else { - createItem({ - variables: { - input: payload, - }, - }); + } catch (e: any) { + handleMutationError(e); } }; + const organization = useQuery(USER_LANGUAGES, { + skip: !languageSupport, + }); + + const { + loading, + error, + data: itemData, + refetch, + } = useQuery(getItemQuery, { + variables, + skip: !itemId, + fetchPolicy: getQueryFetchPolicy, + }); + + const fetchedItem: any = itemData + ? (itemData[listItem]?.[listItem] ?? itemData[Object.keys(itemData)[0]]?.[listItem] ?? null) + : null; + + useEffect(() => { + if (fetchedItem && setStates) { + setStates(fetchedItem); + } + }, [itemData]); + + const isLoadedData = Boolean(fetchedItem); + + const getLanguageIdValue = () => { + if (fetchedItem) { + return languageSupport ? (fetchedItem.language?.id ?? '') : null; + } + if (!itemId && organization.data) { + return organization.data.currentUser.user.organization.defaultLanguage.id; + } + return ''; + }; + const languageId = getLanguageIdValue(); + const formik = useFormik({ initialValues: { languageId, @@ -284,25 +416,7 @@ export const FormLayout = ({ } }, [entityId]); - const capitalListItemName = listItemName[0].toUpperCase() + listItemName.slice(1); - let item: any = null; - - let itemId = entityId; - if (!itemId) { - itemId = params.id; - } - - let variables: any = itemId ? { [idType]: itemId } : false; - const [deleteItem] = useMutation(deleteItemQuery, { - onCompleted: () => { - setNotification(`${capitalListItemName} deleted successfully`); - setDeleted(true); - }, - onError: (err: ApolloError) => { - setShowDialog(false); - setErrorMessage(err); - }, awaitRefetchQueries: true, refetchQueries: [ { @@ -312,96 +426,7 @@ export const FormLayout = ({ ], }); - // get the organization for current user and have languages option set to that. - - const organization = useQuery(USER_LANGUAGES, { - skip: !languageSupport, - onCompleted: (data: any) => { - if (!itemId) { - setLanguageId(data.currentUser.user.organization.defaultLanguage.id); - } - }, - }); - if (listItem === 'credential') { - variables = params.type ? { shortcode: params.type } : false; - } - - const { loading, error, refetch } = useQuery(getItemQuery, { - variables, - skip: !itemId, - fetchPolicy: getQueryFetchPolicy, - onCompleted: (data) => { - if (data) { - item = data[listItem] ? data[listItem][listItem] : data[Object.keys(data)[0]][listItem]; - if (item) { - setIsLoadedData(true); - setLink(data[listItem] ? data[listItem][listItem][linkParameter] : item.linkParameter); - setLanguageId(languageSupport ? item.language.id : null); - if (setStates) { - setStates(item); - } - } - } - }, - }); - - const camelCaseItem = listItem[0].toUpperCase() + listItem.slice(1); - const [updateItem] = useMutation(updateItemQuery, { - onCompleted: (data) => { - setShowConfirmationDialog(false); - let itemUpdatedObject: any = Object.keys(data)[0]; - itemUpdatedObject = data[itemUpdatedObject]; - const updatedItem = itemUpdatedObject[listItem]; - const { errors, message } = itemUpdatedObject; - - if (errors) { - if (customHandler) { - customHandler(errors); - } else { - setErrorMessage(errors[0]); - } - } else if (updatedItem && typeof updatedItem.isValid === 'boolean' && !updatedItem.isValid) { - if (customError) { - // this is a custom error for extensions. We need to move this out of this component - const codeErrors = { code: 'Failed to compile code. Please check again' }; - customError.setErrors(codeErrors); - } - } else { - if (type === 'copy') setLink(updatedItem[linkParameter]); - if (additionalQuery) { - additionalQuery(itemId); - } - - if (saveOnPageChange || saveClick) { - setFormSubmitted(true); - // display successful message after update - let message = `${capitalListItemName} edited successfully!`; - if (type === 'copy') { - message = copyNotification; - } - setNotification(message); - } else { - setNotification('Your changes have been autosaved'); - } - // emit data after save - if (afterSave) { - afterSave(data, saveClick, message); - } - } - onSaveClick(false); - }, - onError: (e: ApolloError) => { - setShowConfirmationDialog(false); - onSaveClick(false); - if (customHandler) { - customHandler(e.message); - } else { - setErrorMessage(e); - } - - return null; - }, refetchQueries: () => { if (refetchQueries) return refetchQueries.map((refetchQuery: any) => ({ @@ -413,44 +438,6 @@ export const FormLayout = ({ }); const [createItem] = useMutation(createItemQuery, { - onCompleted: (data) => { - setShowConfirmationDialog(false); - let itemCreatedObject: any = `create${camelCaseItem}`; - itemCreatedObject = data[itemCreatedObject]; - const itemCreated = itemCreatedObject[listItem]; - - const { errors } = itemCreatedObject; - if (errors) { - if (customHandler) { - customHandler(errors); - } else { - setErrorMessage(errors[0]); - } - } else if (itemCreated && typeof itemCreated.isValid === 'boolean' && !itemCreated.isValid) { - if (customError) { - const codeErrors = { code: 'Failed to compile code. Please check again' }; - customError.setErrors(codeErrors); - } - } else { - if (additionalQuery) { - additionalQuery(itemCreated.id); - } - if (!itemId) setLink(itemCreated[linkParameter]); - if (saveOnPageChange || saveClick) { - setFormSubmitted(true); - // display successful message after create - setNotification(`${capitalListItemName} created successfully!`); - } else { - setNotification('Your changes have been autosaved'); - } - // emit data after save - if (afterSave) { - afterSave(data, saveClick); - } - } - setIsLoadedData(true); - onSaveClick(false); - }, refetchQueries: () => { if (refetchQueries) return refetchQueries.map((refetchQuery: any) => ({ @@ -460,24 +447,18 @@ export const FormLayout = ({ return []; }, - onError: (e: ApolloError) => { - setShowConfirmationDialog(false); - onSaveClick(false); - if (customHandler) { - customHandler(e.message); - } else { - setErrorMessage(e); - } - - return null; - }, }); if (loading) return ; + if (error) { setErrorMessage(error); return null; } + if (languageSupport && organization.error) { + setErrorMessage(organization.error); + return null; + } const cancelHandler = () => { // for chat screen searches @@ -563,13 +544,6 @@ export const FormLayout = ({ ) : null; - const onSaveButtonClick = (errors: any) => { - if (Object.keys(errors).length > 0) { - return; - } - onSaveClick(true); - }; - const form = (
@@ -599,8 +573,17 @@ export const FormLayout = ({ color="primary" onClick={() => { formik.validateForm().then((errors) => { - onSaveButtonClick(errors); - formik.submitForm(); + if (Object.keys(errors).length > 0) { + formik.submitForm(); + return; + } + onSaveClick(true); + if (confirmationState?.show) { + setShowConfirmationDialog(true); + } else { + setCustomError({ setErrors: formik.setErrors }); + saveHandler(formik.values, true); + } }); }} className={styles.Button} @@ -642,8 +625,15 @@ export const FormLayout = ({ ); - const handleDeleteItem = () => { - deleteItem({ variables: { id: itemId } }); + const handleDeleteItem = async () => { + try { + await deleteItem({ variables: { id: itemId } }); + setNotification(`${capitalListItemName} deleted successfully`); + setDeleted(true); + } catch (err: any) { + setShowDialog(false); + setErrorMessage(err); + } }; let dialogBox; @@ -687,7 +677,7 @@ export const FormLayout = ({ { - saveHandler(formik.values); + saveHandler(formik.values, true); }} handleCancel={() => { onSaveClick(false); diff --git a/src/containers/HSM/HSM.tsx b/src/containers/HSM/HSM.tsx index 48328476a2..dacc955d82 100644 --- a/src/containers/HSM/HSM.tsx +++ b/src/containers/HSM/HSM.tsx @@ -127,20 +127,9 @@ export const HSM = () => { setUploadedFile(null); }; - const [uploadMedia] = useMutation(UPLOAD_MEDIA, { - onCompleted: (data: { uploadMedia: string }) => { - setAttachmentURL(data.uploadMedia); - setNotification('File uploaded successfully'); - setUploadingFile(false); - }, - onError: (error: Error) => { - console.error('Upload error:', error); - setNotification('File upload failed. Please try again.'); - resetUploadState(); - }, - }); + const [uploadMedia] = useMutation(UPLOAD_MEDIA); - const handleFileUpload = (file: File): void => { + const handleFileUpload = async (file: File): Promise => { if (!file) return; const mediaName = file.name; @@ -156,12 +145,16 @@ export const HSM = () => { setType({ id: 'DOCUMENT', label: 'DOCUMENT' }); } - uploadMedia({ - variables: { - media: file, - extension, - }, - }); + try { + const result = await uploadMedia({ variables: { media: file, extension } }); + setAttachmentURL(result.data.uploadMedia); + setNotification('File uploaded successfully'); + setUploadingFile(false); + } catch (error) { + console.error('Upload error:', error); + setNotification('File upload failed. Please try again.', 'error'); + resetUploadState(); + } }; let attachmentOptions = mediaOptions; @@ -736,8 +729,8 @@ export const HSM = () => { helperText: uploadedFile ? `File uploaded: ${uploadedFile.name}` : t( - 'Please provide a sample attachment for approval purpose. You may send a similar but different attachment when sending the HSM to users.' - ), + 'Please provide a sample attachment for approval purpose. You may send a similar but different attachment when sending the HSM to users.' + ), inputProp: { onBlur: (event: any) => { setAttachmentURL(event.target.value.trim()); diff --git a/src/containers/HSM/HSMList/HSMList.test.tsx b/src/containers/HSM/HSMList/HSMList.test.tsx index 5ae466a779..406de53f95 100644 --- a/src/containers/HSM/HSMList/HSMList.test.tsx +++ b/src/containers/HSM/HSMList/HSMList.test.tsx @@ -3,6 +3,7 @@ import { BrowserRouter as Router } from 'react-router'; import { MockedProvider } from '@apollo/client/testing'; import { HSM_LIST, bulkApplyMutation, bulkApplyMutationWIthError } from 'mocks/Template'; +import { BULK_APPLY_TEMPLATES } from 'graphql/mutations/Template'; import { HSMList } from './HSMList'; import userEvent from '@testing-library/user-event'; import { SYNC_HSM_TEMPLATES } from 'graphql/mutations/Template'; @@ -196,7 +197,7 @@ test('bulk apply templates', async () => { }); }); -test('bulk apply templates', async () => { +test('bulk apply templates with network error', async () => { const { getByTestId } = render(template(bulkApplyMutationWIthError)); await waitFor(() => { @@ -215,3 +216,36 @@ test('bulk apply templates', async () => { expect(setNotification).toHaveBeenCalledWith('An error occured! Please check the format of the file', 'warning'); }); }); + +test('bulk apply templates with application-level errors', async () => { + const bulkApplyWithAppErrors = { + request: { + query: BULK_APPLY_TEMPLATES, + variables: { data: 'csv data with errors' }, + }, + result: { + data: { + bulkApplyTemplates: { + errors: [{ message: 'Template not found' }], + csv_rows: 'Title,Status\nWelcome,Failed', + }, + }, + }, + }; + + const { getByTestId } = render(template(bulkApplyWithAppErrors)); + + await waitFor(() => { + expect(getByTestId('updateHsm')).toBeInTheDocument(); + }); + + const mockFile = new File(['csv data with errors'], 'testFile.csv', { type: 'text/csv' }); + fireEvent.change(getByTestId('import'), { target: { files: [mockFile] } }); + + await waitFor(() => { + expect(setNotification).toHaveBeenCalledWith( + 'Templates were processed with errors. Please check the csv file for details.', + 'warning' + ); + }); +}); diff --git a/src/containers/HSM/HSMList/HSMList.tsx b/src/containers/HSM/HSMList/HSMList.tsx index f67c0cea71..da4a94cbe6 100644 --- a/src/containers/HSM/HSMList/HSMList.tsx +++ b/src/containers/HSM/HSMList/HSMList.tsx @@ -1,7 +1,7 @@ import { useMutation, useQuery } from '@apollo/client'; import { FormControl, MenuItem, Select } from '@mui/material'; import dayjs from 'dayjs'; -import { useContext, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; @@ -16,7 +16,7 @@ import PendingIcon from 'assets/images/icons/Template/Pending.svg?react'; import { BULK_APPLY_SAMPLE_LINK } from 'config'; import { List } from 'containers/List/List'; import { RaiseToGupShup } from 'containers/HSM/RaiseToGupshupDialog/RaiseToGupShup'; -import { GUPSHUP_ENTERPRISE_SHORTCODE, STANDARD_DATE_TIME_FORMAT } from 'common/constants'; +import { STANDARD_DATE_TIME_FORMAT } from 'common/constants'; import { templateInfo, templateStatusInfo } from 'common/HelpData'; import { setNotification } from 'common/notification'; import { WhatsAppToJsx } from 'common/RichEditor'; @@ -30,16 +30,10 @@ import HelpIcon from 'components/UI/HelpIcon/HelpIcon'; import { Loading } from 'components/UI/Layout/Loading/Loading'; import { GET_TAGS } from 'graphql/queries/Tags'; -import { - BULK_APPLY_TEMPLATES, - DELETE_TEMPLATE, - IMPORT_TEMPLATES, - SYNC_HSM_TEMPLATES, -} from 'graphql/mutations/Template'; +import { BULK_APPLY_TEMPLATES, DELETE_TEMPLATE, SYNC_HSM_TEMPLATES } from 'graphql/mutations/Template'; import { FILTER_TEMPLATES, GET_TEMPLATES_COUNT } from 'graphql/queries/Template'; import styles from './HSMList.module.css'; -import { ProviderContext } from 'context/session'; import { useSearchParams } from 'react-router'; const templateIcon = ; @@ -86,49 +80,11 @@ export const HSMList = () => { const [syncTemplateLoad, setSyncTemplateLoad] = useState(false); const [searchParams, setSearchParams] = useSearchParams(); - const { provider } = useContext(ProviderContext); const { t } = useTranslation(); const navigate = useNavigate(); - const [bulkApplyTemplates] = useMutation(BULK_APPLY_TEMPLATES, { - onCompleted: (data: any) => { - setImporting(false); - if (data && data.bulkApplyTemplates) { - exportCsvFile(data.bulkApplyTemplates.csv_rows, 'result'); - setNotification(t('Templates applied successfully. Please check the csv file for the results')); - } - }, - onError: () => { - setImporting(false); - setNotification(t('An error occured! Please check the format of the file'), 'warning'); - }, - }); - - const [syncHsmTemplates] = useMutation(SYNC_HSM_TEMPLATES, { - fetchPolicy: 'network-only', - onCompleted: (data) => { - if (data.errors) { - setNotification(t('Sorry, failed to sync HSM updates.'), 'warning'); - } else { - setNotification(t('HSM queued for sync. Check notifications for updates.'), 'success'); - } - setSyncTemplateLoad(false); - }, - onError: () => { - setNotification(t('Sorry, failed to sync HSM updates.'), 'warning'); - setSyncTemplateLoad(false); - }, - }); - - const [importTemplatesMutation] = useMutation(IMPORT_TEMPLATES, { - onCompleted: (data: any) => { - setImporting(false); - const { errors } = data.importTemplates; - if (errors && errors.length > 0) { - setNotification(t('Error importing templates'), 'warning'); - } - }, - }); + const [bulkApplyTemplates] = useMutation(BULK_APPLY_TEMPLATES); + const [syncHsmTemplates] = useMutation(SYNC_HSM_TEMPLATES, { fetchPolicy: 'network-only' }); const { data: tags } = useQuery(GET_TAGS, { variables: {}, @@ -222,9 +178,46 @@ export const HSMList = () => { columnStyles, }; - const handleHsmUpdates = () => { + const handleHsmUpdates = async () => { setSyncTemplateLoad(true); - syncHsmTemplates(); + try { + const { data } = await syncHsmTemplates(); + const errors = data?.syncHsmTemplate?.errors; + if (!data?.syncHsmTemplate || errors?.length) { + setNotification(t('Sorry, failed to sync HSM updates.'), 'warning'); + } else { + setNotification(t('HSM queued for sync. Check notifications for updates.'), 'success'); + } + } catch { + setNotification(t('Sorry, failed to sync HSM updates.'), 'warning'); + } finally { + setSyncTemplateLoad(false); + } + }; + + const handleBulkApply = async (result: string, media: any) => { + const extension = getFileExtension(media.name); + if (extension !== 'csv') { + setNotification(t('Please upload a valid CSV file'), 'warning'); + setImporting(false); + } else { + try { + const { data } = await bulkApplyTemplates({ variables: { data: result } }); + const response = data?.bulkApplyTemplates; + if (response?.csv_rows) { + exportCsvFile(response.csv_rows, 'result'); + } + if (response?.errors?.length) { + setNotification(t('Templates were processed with errors. Please check the csv file for details.'), 'warning'); + } else if (response) { + setNotification(t('Templates applied successfully. Please check the csv file for the results')); + } + } catch { + setNotification(t('An error occured! Please check the format of the file'), 'warning'); + } finally { + setImporting(false); + } + } }; let filterValue: any = ''; @@ -268,7 +261,7 @@ export const HSMList = () => { loading={syncTemplateLoad} className={styles.HsmUpdates} data-testid="updateHsm" - onClick={() => handleHsmUpdates()} + onClick={handleHsmUpdates} aria-hidden="true" > SYNC HSM @@ -348,45 +341,11 @@ export const HSMList = () => { View Sample - setImporting(true)} - afterImport={(result: string, media: any) => { - const extension = getFileExtension(media.name); - if (extension !== 'csv') { - setNotification('Please upload a valid CSV file', 'warning'); - setImporting(false); - } else { - bulkApplyTemplates({ variables: { data: result } }); - } - }} - /> + setImporting(true)} afterImport={handleBulkApply} /> ); - if (provider === GUPSHUP_ENTERPRISE_SHORTCODE) { - secondaryButton = ( -
- {syncHSMButton} - setImporting(true)} - afterImport={(result: string, media: any) => { - const extension = getFileExtension(media.name); - if (extension !== 'csv') { - setNotification('Please upload a valid CSV file', 'warning'); - setImporting(false); - } else { - importTemplatesMutation({ variables: { data: result } }); - } - }} - /> -
- ); - button.show = false; - } - const handleView = (id: any) => { navigate(`/template/${id}/edit`); }; diff --git a/src/containers/List/List.tsx b/src/containers/List/List.tsx index f373374387..08710e074a 100644 --- a/src/containers/List/List.tsx +++ b/src/containers/List/List.tsx @@ -381,14 +381,6 @@ export const List = ({ // Make a new count request for a new count of the # of rows from this query in the back-end. if (deleteItemQuery) { [deleteItem] = useMutation(deleteItemQuery, { - onCompleted: () => { - setNotification(`${capitalListItemName} deleted successfully`); - checkUserRole(); - countQuery && refetchCount(); - if (refetchValues) { - refetchValues(filterPayload()); - } - }, refetchQueries: () => { if (refetchQueries) return refetchQueries.map((refetchQuery: any) => ({ @@ -397,9 +389,6 @@ export const List = ({ })); return []; }, - onError: () => { - setNotification(`Sorry! An error occurred!`, 'warning'); - }, }); } @@ -412,9 +401,19 @@ export const List = ({ setDeleteItemID(null); }; - const deleteHandler = (id: number) => { + const deleteHandler = async (id: number) => { const variables = deleteModifier.variables ? deleteModifier.variables(id) : { id }; - deleteItem({ variables }); + try { + await deleteItem({ variables }); + setNotification(`${capitalListItemName} deleted successfully`); + checkUserRole(); + countQuery && refetchCount(); + if (refetchValues) { + refetchValues(filterPayload()); + } + } catch { + setNotification(`Sorry! An error occurred!`, 'warning'); + } }; const handleDeleteItem = () => { diff --git a/src/containers/SettingList/Organization/Organisation.test.tsx b/src/containers/SettingList/Organization/Organisation.test.tsx index 362d21f330..f4e90fd344 100644 --- a/src/containers/SettingList/Organization/Organisation.test.tsx +++ b/src/containers/SettingList/Organization/Organisation.test.tsx @@ -10,7 +10,7 @@ const user = userEvent.setup(); const mocks = ORGANIZATION_MOCKS; const wrapper = ( - + @@ -60,7 +60,7 @@ test('it renders component and clicks cancel', async () => { test('it renders component in edit mode', async () => { const { getByText, getByTestId } = render( - + @@ -98,7 +98,7 @@ test('it renders component in edit mode', async () => { test('it renders confirmation popup with new phone number when allowBotNumberUpdate is true', async () => { const { getByText, getByTestId } = render( - + @@ -130,7 +130,7 @@ test('it renders confirmation popup with new phone number when allowBotNumberUpd test('It does not show confirmation popup with new phone number when allowBotNumberUpdate is false', async () => { const { getByText, getByTestId } = render( - + diff --git a/src/containers/SettingList/Organization/Organization.tsx b/src/containers/SettingList/Organization/Organization.tsx index d8141e26a9..0e8c242c73 100644 --- a/src/containers/SettingList/Organization/Organization.tsx +++ b/src/containers/SettingList/Organization/Organization.tsx @@ -34,35 +34,31 @@ export const Organization = () => { const [defaultLanguage, setDefaultLanguage] = useState(null); const [signaturePhrase, setSignaturePhrase] = useState(''); const [phone, setPhone] = useState(''); - const [tier, setTier] = useState(); const [lowBalanceThreshold, setLowBalanceThreshold] = useState(''); const [criticalBalanceThreshold, setCriticalBalanceThreshold] = useState(''); const [sendWarningMail, setSendWarningMail] = useState(false); const { t } = useTranslation(); + const { data: languages } = useQuery(GET_LANGUAGES, { + variables: { opts: { order: 'ASC' } }, + }); + + const { data: tierData } = useQuery(GET_QUALITY_RATING); + const qualityRatingTier = tierData?.qualityRating?.currentLimit; + const States = { name, activeLanguages, defaultLanguage, signaturePhrase, phone, - tier, + tier: qualityRatingTier, lowBalanceThreshold, criticalBalanceThreshold, sendWarningMail, }; - const { data: languages } = useQuery(GET_LANGUAGES, { - variables: { opts: { order: 'ASC' } }, - }); - - useQuery(GET_QUALITY_RATING, { - onCompleted: (tierData) => { - if (tierData) setTier(tierData.qualityRating?.currentLimit); - }, - }); - const [getOrg, { data: orgData }] = useLazyQuery(GET_ORGANIZATION); const setSettings = (data: any) => { @@ -202,7 +198,7 @@ export const Organization = () => { type: 'text', placeholder: t('WhatsApp tier'), label: t('WhatsApp tier'), - skip: !tier, + skip: !qualityRatingTier, disabled: true, }, { diff --git a/src/containers/SettingList/Providers/Providers.tsx b/src/containers/SettingList/Providers/Providers.tsx index c352abc3f1..e7bdfe5ac1 100644 --- a/src/containers/SettingList/Providers/Providers.tsx +++ b/src/containers/SettingList/Providers/Providers.tsx @@ -203,6 +203,11 @@ export const Providers = () => { setKeys(providerKeys); setSecrets(providerSecrets); }); + + const credentialData = credential?.credential?.credential; + if (credentialData) { + setCredential(credentialData); + } } }, [providerData, credential]); diff --git a/src/containers/SettingList/SettingList.test.helper.ts b/src/containers/SettingList/SettingList.test.helper.ts index af6edddcf5..90e2598674 100644 --- a/src/containers/SettingList/SettingList.test.helper.ts +++ b/src/containers/SettingList/SettingList.test.helper.ts @@ -7,7 +7,7 @@ import { getOrganizationSettings, getCredential, getQualityRating, - getOrganizationSettingsAllowBot + getOrganizationSettingsAllowBot, } from 'mocks/Organization'; import { FLOW_STATUS_PUBLISHED, setVariables } from 'common/constants'; import { UPDATE_ORGANIZATION } from 'graphql/mutations/Organization'; @@ -96,7 +96,7 @@ const updateOrganizationMock = { setting: { lowBalanceThreshold: '10', criticalBalanceThreshold: '5', - sendWarningMail: false + sendWarningMail: false, }, }, }, @@ -163,7 +163,7 @@ const updateOrganizationMock = { criticalBalanceThreshold: '3', lowBalanceThreshold: '10', sendWarningMail: true, - allowBotNumberUpdate: false + allowBotNumberUpdate: false, }, shortcode: 'glific', }, @@ -185,7 +185,7 @@ const updateOrganizationMock2 = { setting: { lowBalanceThreshold: '10', criticalBalanceThreshold: '5', - sendWarningMail: false + sendWarningMail: false, }, }, }, @@ -252,7 +252,7 @@ const updateOrganizationMock2 = { criticalBalanceThreshold: '3', lowBalanceThreshold: '10', sendWarningMail: true, - allowBotNumberUpdate: false + allowBotNumberUpdate: false, }, shortcode: 'glific', }, @@ -269,7 +269,7 @@ export const ORGANIZATION_MOCKS = [ flowsMock, ...getOrganizationQuery, updateOrganizationMock, - updateOrganizationMock2 + updateOrganizationMock2, ]; export const ORGANIZATION_MOCKS2 = [ @@ -280,5 +280,5 @@ export const ORGANIZATION_MOCKS2 = [ flowsMock, ...getOrganizationSettingsAllowBot, updateOrganizationMock, - updateOrganizationMock2 -] + updateOrganizationMock2, +]; diff --git a/src/containers/Trigger/Trigger.test.tsx b/src/containers/Trigger/Trigger.test.tsx index f3ad57469b..35b0c55793 100644 --- a/src/containers/Trigger/Trigger.test.tsx +++ b/src/containers/Trigger/Trigger.test.tsx @@ -4,6 +4,7 @@ import { MemoryRouter, Route, Routes } from 'react-router'; import { vi } from 'vitest'; import * as Notification from 'common/notification'; import { TRIGGER_MOCKS, createTriggerQuery, getTriggerQuery } from 'mocks/Trigger'; +import { VALIDATE_TRIGGER } from 'graphql/mutations/Trigger'; import { Trigger } from './Trigger'; import dayjs from 'dayjs'; import utc from 'dayjs'; @@ -604,3 +605,46 @@ describe('Whatsapp group collections', () => { }); }); }); + +describe('handleFlowChange error handling', () => { + test('should show warning when flow validation fails with network error', async () => { + const validateTriggerError = { + request: { + query: VALIDATE_TRIGGER, + variables: { input: { flowId: '2' } }, + }, + error: new Error('Network error'), + }; + + const mocksWithError = [ + validateTriggerError, + ...MOCKS.filter( + (m: any) => !(m?.request?.query === VALIDATE_TRIGGER && m?.request?.variables?.input?.flowId === '2') + ), + ]; + + render( + + + + } /> + } /> + + + + ); + + await waitFor(() => { + expect(screen.getByText('Select flow*')).toBeInTheDocument(); + }); + + const flowAutocomplete = screen.getAllByRole('combobox')[0]; + flowAutocomplete.focus(); + fireEvent.keyDown(flowAutocomplete, { key: 'ArrowDown' }); + fireEvent.click(screen.getByText('SoL Feedback')); + + await waitFor(() => { + expect(screen.getByText(/Failed to validate flow/)).toBeInTheDocument(); + }); + }); +}); diff --git a/src/containers/Trigger/Trigger.tsx b/src/containers/Trigger/Trigger.tsx index 040961a96c..d9ad526be1 100644 --- a/src/containers/Trigger/Trigger.tsx +++ b/src/containers/Trigger/Trigger.tsx @@ -246,24 +246,26 @@ export const Trigger = () => { variables: isEditing ? setVariables() : setVariables({ groupType }), }); - const [validateTriggerFlow, { loading }] = useMutation(VALIDATE_TRIGGER, { - onCompleted: ({ validateTrigger }) => { - if (!validateTrigger.success && validateTrigger.errors && validateTrigger.errors.length > 0) { - setTriggerFlowWarning(validateTrigger.errors[0].message); - } - }, - }); + const [validateTriggerFlow, { loading }] = useMutation(VALIDATE_TRIGGER); - const handleFlowChange = (flow: any) => { + const handleFlowChange = async (input: any) => { setTriggerFlowWarning(undefined); - if (flow) { - validateTriggerFlow({ - variables: { - input: { - flowId: flow.id, + if (input) { + try { + const result = await validateTriggerFlow({ + variables: { + input: { + flowId: input.id, + }, }, - }, - }); + }); + const { validateTrigger } = result.data; + if (!validateTrigger.success && validateTrigger.errors && validateTrigger.errors.length > 0) { + setTriggerFlowWarning(validateTrigger.errors[0].message); + } + } catch { + setTriggerFlowWarning(t('Failed to validate flow. Please try again.')); + } } }; diff --git a/src/i18n/en/en.json b/src/i18n/en/en.json index df4efde9b3..733d71555a 100644 --- a/src/i18n/en/en.json +++ b/src/i18n/en/en.json @@ -599,9 +599,11 @@ "Version set as live successfully": "Version set as live successfully", "Add notes on changes made to this version": "Add notes on changes made to this version", "Select a version to view and edit.": "Select a version to view and edit.", - "Copy ID": "Copy ID", "Back to assistants": "Back to assistants", "You have unsaved changes. Are you sure you want to leave?": "You have unsaved changes. Are you sure you want to leave?", "Stay": "Stay", - "Leave": "Leave" -} + "Leave": "Leave", + "Please upload a valid CSV file": "Please upload a valid CSV file", + "Templates were processed with errors. Please check the csv file for details.": "Templates were processed with errors. Please check the csv file for details.", + "Failed to validate flow. Please try again.": "Failed to validate flow. Please try again." +} \ No newline at end of file diff --git a/src/mocks/Tag.tsx b/src/mocks/Tag.tsx index 9d06f9d97d..7f0a9d9fff 100644 --- a/src/mocks/Tag.tsx +++ b/src/mocks/Tag.tsx @@ -85,12 +85,10 @@ export const getFilterTagQuery = { data: { tags: [ { - __typename: 'Tag', id: '1', label: 'Messages', }, { - __typename: 'Tag', id: '2', label: 'Contacts', },