diff --git a/.github/workflows/cypress-testing.yml b/.github/workflows/cypress-testing.yml index b85dce6a0..921ae671a 100644 --- a/.github/workflows/cypress-testing.yml +++ b/.github/workflows/cypress-testing.yml @@ -96,7 +96,7 @@ jobs: git clone https://github.com/glific/cypress-testing.git echo done. go to dir. cd cypress-testing - git checkout main + git checkout feat/contacts-import cd .. cp -r cypress-testing/cypress cypress yarn add cypress@13.6.2 diff --git a/src/assets/images/icons/Info.svg b/src/assets/images/icons/Info.svg index cbb2c043e..d50233276 100644 --- a/src/assets/images/icons/Info.svg +++ b/src/assets/images/icons/Info.svg @@ -1,10 +1,10 @@ - Icon/Info + Info - \ No newline at end of file + diff --git a/src/components/UI/Form/Checkbox/Checkbox.module.css b/src/components/UI/Form/Checkbox/Checkbox.module.css index 0791e8105..e435947d4 100644 --- a/src/components/UI/Form/Checkbox/Checkbox.module.css +++ b/src/components/UI/Form/Checkbox/Checkbox.module.css @@ -5,7 +5,7 @@ .Checkbox { margin-bottom: -10px; display: flex; - align-items: center; + flex-direction: column; } .Label { @@ -33,3 +33,12 @@ .Disabled { opacity: 60%; } + +.DangerText { + margin-left: 30px; + font-size: 12px; + margin-top: -12px; + line-height: 18px; + font-weight: 400; + color: #fb5c5c; +} diff --git a/src/components/UI/Form/Checkbox/Checkbox.tsx b/src/components/UI/Form/Checkbox/Checkbox.tsx index 1fa3c3fbd..747b7e23e 100644 --- a/src/components/UI/Form/Checkbox/Checkbox.tsx +++ b/src/components/UI/Form/Checkbox/Checkbox.tsx @@ -1,4 +1,4 @@ -import { Checkbox as CheckboxElement, FormControlLabel } from '@mui/material'; +import { Checkbox as CheckboxElement, FormControlLabel, FormHelperText } from '@mui/material'; import InfoIcon from 'assets/images/icons/Info.svg?react'; import Tooltip from 'components/UI/Tooltip/Tooltip'; import styles from './Checkbox.module.css'; @@ -38,37 +38,42 @@ export const Checkbox = ({ return (
- - } - labelPlacement="end" - label={title} - classes={{ - label: addLabelStyle ? styles.Label : undefined, - root: styles.Root, - }} - /> - {info?.title && infoType === 'tooltip' && ( - - - - )} - {info && infoType === 'dialog' && ( - handleInfoClick()} +
+ + } + labelPlacement="end" + label={title} + classes={{ + label: addLabelStyle ? styles.Label : undefined, + root: styles.Root, + }} /> - )} + {info?.title && infoType === 'tooltip' && ( + + + + )} + {info && infoType === 'dialog' && ( + handleInfoClick()} + /> + )} +
+ {form && form.errors[field.name] && form.touched[field.name] ? ( + {form.errors[field.name]} + ) : null}
); }; diff --git a/src/components/UI/ImportButton/ImportButton.module.css b/src/components/UI/ImportButton/ImportButton.module.css index e0e4cc004..6d4c1cd3f 100644 --- a/src/components/UI/ImportButton/ImportButton.module.css +++ b/src/components/UI/ImportButton/ImportButton.module.css @@ -1,4 +1,4 @@ -.ImportIcon { +.FileIcon { height: 24px; width: 24px; margin-right: 5px; diff --git a/src/components/UI/ImportButton/ImportButton.tsx b/src/components/UI/ImportButton/ImportButton.tsx index a1109a35a..08e64ac47 100644 --- a/src/components/UI/ImportButton/ImportButton.tsx +++ b/src/components/UI/ImportButton/ImportButton.tsx @@ -1,15 +1,23 @@ import { useRef } from 'react'; -import ImportIcon from 'assets/images/icons/Flow/Import.svg?react'; +import FileIcon from 'assets/images/icons/Document/Light.svg?react'; import { Button } from 'components/UI/Form/Button/Button'; import styles from './ImportButton.module.css'; export interface ImportButtonProps { title: string; - onImport: any; + onImport?: any; afterImport: any; + id?: string; + fileType?: string; } -export const ImportButton = ({ title, onImport, afterImport }: ImportButtonProps) => { +export const ImportButton = ({ + title, + onImport, + afterImport, + id, + fileType = '*', +}: ImportButtonProps) => { const inputRef = useRef(null); const changeHandler = (event: any) => { const media = event.target.files[0]; @@ -17,7 +25,9 @@ export const ImportButton = ({ title, onImport, afterImport }: ImportButtonProps fileReader.onload = function setImport() { afterImport(fileReader.result, media); }; - onImport(); + if (onImport) { + onImport(); + } fileReader.readAsText(media); }; return ( @@ -29,15 +39,17 @@ export const ImportButton = ({ title, onImport, afterImport }: ImportButtonProps name="file" onChange={changeHandler} data-testid="import" + id={id} + accept={fileType} /> diff --git a/src/components/UI/Layout/Navigation/SideMenus/SideMenus.tsx b/src/components/UI/Layout/Navigation/SideMenus/SideMenus.tsx index 6cdba3e82..ed4ceb039 100644 --- a/src/components/UI/Layout/Navigation/SideMenus/SideMenus.tsx +++ b/src/components/UI/Layout/Navigation/SideMenus/SideMenus.tsx @@ -71,7 +71,6 @@ const SideMenus = ({ opened }: SideMenusProps) => { variables: { filter: { is_read: false, - severity: 'critical', }, }, fetchPolicy: 'cache-and-network', diff --git a/src/config/index.ts b/src/config/index.ts index 40aedfd3e..a425a6d30 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -55,7 +55,8 @@ export const ONBOARD_URL_UPDATE = `${GLIFIC_API_URL}/v1/onboard/update-registrat export const ONBOARD_URL_REACT_OUT = `${GLIFIC_API_URL}/v1/onboard/reachout`; export const ONBOARD_URL = `${GLIFIC_API_URL}/v1/onboard/setup`; export const RECAPTCHA_CLIENT_KEY = envVariables.VITE_RECAPTCHA_CLIENT_KEY; -export const UPLOAD_CONTACTS_SAMPLE = 'https://storage.googleapis.com/cc-tides/sample_import.csv'; +export const UPLOAD_CONTACTS_SAMPLE = + 'https://storage.googleapis.com/cc-tides/sample_contacts_import.csv'; export const UPLOAD_CONTACTS_ADMIN_SAMPLE = 'https://storage.googleapis.com/cc-tides/sample_import_admin.csv'; export const REGISTRATION_HELP_LINK = diff --git a/src/containers/Collection/CollectionList/CollectionList.test.tsx b/src/containers/Collection/CollectionList/CollectionList.test.tsx index e2b23ecd0..cc8a4f107 100644 --- a/src/containers/Collection/CollectionList/CollectionList.test.tsx +++ b/src/containers/Collection/CollectionList/CollectionList.test.tsx @@ -25,16 +25,28 @@ import { updateCollectionWaGroupQuery, } from 'mocks/Groups'; import { setNotification } from 'common/notification'; -import { setVariables } from 'common/constants'; +import { CONTACTS_COLLECTION, setVariables } from 'common/constants'; import { setUserRolePermissions } from 'context/role'; +const variables = { + filter: { + groupType: CONTACTS_COLLECTION, + }, + opts: { + limit: 50, + offset: 0, + order: 'ASC', + orderWith: 'label', + }, +}; + const mocks = [ countCollectionQuery, countCollectionQuery, countCollectionQuery, - filterCollectionQuery, - filterCollectionQuery, - filterCollectionQuery, + filterCollectionQuery(variables), + filterCollectionQuery(variables), + filterCollectionQuery(variables), getPublishedFlowQuery, getPublishedFlowQuery, getCollectionContactsQuery, diff --git a/src/containers/ContactManagement/AdminContactManagement/AdminContactManagement.module.css b/src/containers/ContactManagement/AdminContactManagement/AdminContactManagement.module.css index b3476b256..1b1494415 100644 --- a/src/containers/ContactManagement/AdminContactManagement/AdminContactManagement.module.css +++ b/src/containers/ContactManagement/AdminContactManagement/AdminContactManagement.module.css @@ -1,11 +1,16 @@ .Container { - padding-left: 40px; - margin-top: 36px; + display: flex; + flex-direction: column; + justify-content: space-between; + width: 50%; + max-height: 65%; + background-color: #fff; + border-radius: 15px; + padding: 1rem 2rem; } .Instructions { max-width: 500px; - color: #93a29b; font-size: 16px; } @@ -67,10 +72,6 @@ cursor: not-allowed; } -.FileIcon { - margin-right: 10px; -} - .WaitUpload { font-size: 14px; margin-left: 16px; @@ -105,3 +106,8 @@ position: absolute; right: 12px; } + +.Buttons button { + border-radius: 8px; + width: 30%; +} diff --git a/src/containers/ContactManagement/AdminContactManagement/AdminContactManagement.test.tsx b/src/containers/ContactManagement/AdminContactManagement/AdminContactManagement.test.tsx index 74debaafd..2181fd1a2 100644 --- a/src/containers/ContactManagement/AdminContactManagement/AdminContactManagement.test.tsx +++ b/src/containers/ContactManagement/AdminContactManagement/AdminContactManagement.test.tsx @@ -1,24 +1,22 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MockedProvider } from '@apollo/client/testing'; import { BrowserRouter as Router } from 'react-router-dom'; import { getAllOrganizations } from 'mocks/Organization'; -import { moveContacts } from 'mocks/Contact'; import { setUserSession } from 'services/AuthService'; -import { setNotification } from 'common/notification'; import { AdminContactManagement } from './AdminContactManagement'; -const mocks = [...getAllOrganizations, moveContacts]; +const mocks = getAllOrganizations; setUserSession(JSON.stringify({ roles: [{ label: 'Admin' }], organization: { id: '1' } })); const contactManagement = ( - + ); @@ -43,7 +41,7 @@ test('Admin contact management form renders correctly', async () => { test('the page should have a disabled upload button by default', async () => { render(contactManagement); - const uploadButton = await screen.getByTestId('uploadButton'); + const uploadButton = await screen.getByTestId('moveContactsBtn'); expect(uploadButton).toBeInTheDocument(); expect(uploadButton).toHaveAttribute('disabled'); }); @@ -63,10 +61,11 @@ test('Files other than .csv should raise a warning message upon upload', async ( }); }); -test('Success Notification should be called upon successful CSV upload', async () => { +test('it removes the selected file', async () => { render(contactManagement); - // Valid CSV + fireEvent.click(screen.getByTestId('uploadFile')); + const csvContent = `name,phone,collection John Doe,919876543210,"Optin collection,Optout Collection" Virat Kohli,919876543220,Cricket`; @@ -76,15 +75,15 @@ test('Success Notification should be called upon successful CSV upload', async ( const fileInput = screen.getByTestId('uploadFile'); userEvent.upload(fileInput, file); }); + await waitFor(() => { // the filename should be visible instead of Select .csv after upload expect(screen.getByText('test.csv')).toBeInTheDocument(); }); - const uploadBtn = screen.getByTestId('uploadButton'); - userEvent.click(uploadBtn); + fireEvent.click(screen.getByTestId('cross-icon')); await waitFor(() => { - expect(setNotification).toHaveBeenCalled(); + expect(screen.queryByText('test.csv')).not.toBeInTheDocument(); }); }); diff --git a/src/containers/ContactManagement/AdminContactManagement/AdminContactManagement.tsx b/src/containers/ContactManagement/AdminContactManagement/AdminContactManagement.tsx index 530cb0c1d..44edad810 100644 --- a/src/containers/ContactManagement/AdminContactManagement/AdminContactManagement.tsx +++ b/src/containers/ContactManagement/AdminContactManagement/AdminContactManagement.tsx @@ -1,34 +1,31 @@ import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; import { useMutation } from '@apollo/client'; - import { CONTACT_MANAGE_HELP_LINK, UPLOAD_CONTACTS_ADMIN_SAMPLE } from 'config'; import { Button } from 'components/UI/Form/Button/Button'; -import { Heading } from 'components/UI/Heading/Heading'; -import UploadIcon from 'assets/images/icons/UploadLight.svg?react'; import FileIcon from 'assets/images/icons/Document/Light.svg?react'; import CrossIcon from 'assets/images/icons/Cross.svg?react'; import { MOVE_CONTACTS } from 'graphql/mutations/Contact'; -import { exportCsvFile, slicedString } from 'common/utils'; -import { setNotification } from 'common/notification'; +import { slicedString } from 'common/utils'; import styles from './AdminContactManagement.module.css'; -import { contactVariablesInfo } from 'common/HelpData'; -export const AdminContactManagement = () => { +export interface AdminContactManagementProps { + setShowStatus: any; +} + +export const AdminContactManagement = ({ setShowStatus }: AdminContactManagementProps) => { const [fileName, setFileName] = useState(''); const [errors, setErrors] = useState([]); const [csvContent, setCsvContent] = useState(''); const [uploadingContacts, setUploadingContacts] = useState(false); - const { t } = useTranslation(); const [moveContacts] = useMutation(MOVE_CONTACTS, { - onCompleted: (data: any) => { - if (data.errors) { - setErrors(data.errors); + onCompleted: ({ moveContacts }) => { + const { errors } = moveContacts; + if (errors) { + setErrors(errors); } else { - exportCsvFile(data.moveContacts.csvRows, 'results'); setUploadingContacts(false); - setNotification(t('Contacts have been updated')); + setShowStatus(true); } setFileName(''); }, @@ -58,13 +55,10 @@ export const AdminContactManagement = () => { }; return ( -
- -
+
+
+

Move contacts

+
You can move contacts to collections in bulk or update their contact information. Please create csv file that exactly matches the sample. Here are the   @@ -85,6 +79,7 @@ export const AdminContactManagement = () => { <> {fileName} { event.preventDefault(); @@ -121,8 +116,11 @@ export const AdminContactManagement = () => {
))}
+
+ +
diff --git a/src/containers/ContactManagement/ContactManagement.module.css b/src/containers/ContactManagement/ContactManagement.module.css new file mode 100644 index 000000000..f9b6e8b39 --- /dev/null +++ b/src/containers/ContactManagement/ContactManagement.module.css @@ -0,0 +1,30 @@ +.MainContainer { + display: flex; + column-gap: 1rem; + width: 100% !important; + height: 100vh !important; + background-color: #f8faf5; + padding: 1rem; + color: #000; +} + +.MainContainer h2 { + margin-top: 0; + margin-bottom: 1rem; +} + +.Container { + display: flex; + flex-direction: column; + justify-content: space-between; + width: 50%; + max-height: 65%; + background-color: #fff; + border-radius: 15px; + padding: 1.5rem 2rem; +} + +.Buttons button { + border-radius: 8px; + width: 30%; +} diff --git a/src/containers/ContactManagement/ContactManagement.test.tsx b/src/containers/ContactManagement/ContactManagement.test.tsx index 3a7a3b414..8d0b35d02 100644 --- a/src/containers/ContactManagement/ContactManagement.test.tsx +++ b/src/containers/ContactManagement/ContactManagement.test.tsx @@ -1,13 +1,28 @@ -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { MockedProvider } from '@apollo/client/testing'; import { BrowserRouter as Router } from 'react-router-dom'; -import { getAllOrganizations } from 'mocks/Organization'; -import { setUserSession } from 'services/AuthService'; import ContactManagement from './ContactManagement'; +import { filterCollectionQuery } from 'mocks/Collection'; +import { CONTACTS_COLLECTION } from 'common/constants'; +import userEvent from '@testing-library/user-event'; +import { importContacts, moveContacts } from 'mocks/Contact'; -const mocks = getAllOrganizations; +const mocks = [ + filterCollectionQuery({ + filter: { + groupType: CONTACTS_COLLECTION, + }, + opts: { + limit: 50, + offset: 0, + order: 'ASC', + }, + }), + importContacts, + moveContacts, +]; const contactManagement = ( @@ -17,9 +32,92 @@ const contactManagement = ( ); -setUserSession(JSON.stringify({ roles: [{ label: 'Staff' }], organization: { id: '1' } })); +test('it opens contact upload dialog', async () => { + render(contactManagement); + + await waitFor(() => { + expect(screen.getByText('Contact Management')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('uploadContactsBtn')); + + await waitFor(() => { + expect(screen.getByText('Upload Contacts')).toBeInTheDocument(); + }); +}); + +test('Should be able import contacts', async () => { + render(contactManagement); + + await waitFor(() => { + expect(screen.getByText('Contact Management')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('uploadContactsBtn')); + + await waitFor(() => { + expect(screen.getByText('Upload Contacts')).toBeInTheDocument(); + }); + + // Valid CSV + const csvContent = `name,phone,collection + John Doe,919876543210,"Optin collection,Optout Collection" + Virat Kohli,919876543220,Cricket`; + const file = new File([csvContent], 'test.csv', { type: 'text/csv' }); + + await waitFor(() => { + const fileInput = screen.getByTestId('import'); + userEvent.upload(fileInput, file); + }); + await waitFor(() => { + // the filename should be visible instead of Select .csv after upload + expect(screen.getByText('test.csv')).toBeInTheDocument(); + }); -test('Show unauthorized access for staff user', async () => { + const autocomplete = screen.getByTestId('autocomplete-element'); + + fireEvent.click(autocomplete); + fireEvent.keyDown(autocomplete, { key: 'ArrowDown' }); + fireEvent.click(screen.getByText('Staff group')); + + fireEvent.click(screen.getByTestId('ok-button')); + fireEvent.click(screen.getByText('Are these contacts opted in?')); + fireEvent.click(screen.getByTestId('ok-button')); + + await waitFor(() => { + expect(screen.getByText('Contact import is in progress.')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Go to notifications')); +}); + +test('Should be able to move contacts', async () => { render(contactManagement); - expect(screen.getByText('Unauthorized access')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText('Contact Management')).toBeInTheDocument(); + }); + + // Valid CSV + const csvContent = `name,phone,collection + John Doe,919876543210,"Optin collection,Optout Collection" + Virat Kohli,919876543220,Cricket`; + const file = new File([csvContent], 'test.csv', { type: 'text/csv' }); + + await waitFor(() => { + const fileInput = screen.getByTestId('uploadFile'); + userEvent.upload(fileInput, file); + }); + await waitFor(() => { + // the filename should be visible instead of Select .csv after upload + expect(screen.getByText('test.csv')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('moveContactsBtn')); + + await waitFor(() => { + expect(screen.getByText('Contact import is in progress.')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Go to notifications')); }); diff --git a/src/containers/ContactManagement/ContactManagement.tsx b/src/containers/ContactManagement/ContactManagement.tsx index 7829bf9b2..c5ed87b29 100644 --- a/src/containers/ContactManagement/ContactManagement.tsx +++ b/src/containers/ContactManagement/ContactManagement.tsx @@ -1,18 +1,82 @@ -import { getUserRole } from 'context/role'; -import SuperAdminContactManagement from './SuperAdminContactManagement/SuperAdminContactManagement'; +import { Instructions } from './Instructions/Instructions'; +import styles from './ContactManagement.module.css'; +import { Heading } from 'components/UI/Heading/Heading'; +import { useState } from 'react'; +import UploadContactsDialog from './UploadContactsDialog/UploadContactsDialog'; +import { Button } from 'components/UI/Form/Button/Button'; import AdminContactManagement from './AdminContactManagement/AdminContactManagement'; +import { contactVariablesInfo } from 'common/HelpData'; +import { DialogBox } from 'components/UI/DialogBox/DialogBox'; +import { useNavigate } from 'react-router'; export const ContactManagement = () => { - const role = getUserRole(); - if (role.includes('Glific_admin')) { - return ; + const [showUploadDialog, setShowUploadDialog] = useState(false); + const [showStatus, setShowStatus] = useState(false); + const navigate = useNavigate(); + let dialog; + let statusDialog; + + if (showUploadDialog) { + dialog = ; } - if (role.includes('Admin')) { - return ; + if (showStatus) { + statusDialog = ( + { + navigate('/notifications'); + setShowStatus(false); + setShowUploadDialog(false); + }} + handleCancel={() => { + setShowStatus(false); + setShowUploadDialog(false); + }} + skipCancel + buttonOk={'Go to notifications'} + alignButtons="left" + > +
+ Please check notifications to see the status of import. +
+
+ ); } - return
Unauthorized access
; + return ( + <> + +
+
+
+

Import contacts

+ +
+ +
+ +
+
+ + +
+ + {dialog} + {statusDialog} + + ); }; export default ContactManagement; diff --git a/src/containers/ContactManagement/Instructions/Instructions.module.css b/src/containers/ContactManagement/Instructions/Instructions.module.css new file mode 100644 index 000000000..3d7a0a607 --- /dev/null +++ b/src/containers/ContactManagement/Instructions/Instructions.module.css @@ -0,0 +1,19 @@ +.Instructions h5 { + font-size: 1rem; + line-height: 2rem; + margin: 0; + color: #000; +} + +.Instructions div { + margin-bottom: 1rem; +} + +.Instructions ul { + padding-left: 1rem; + margin: 0; +} + +.Instructions ul li { + font-size: 1rem; +} diff --git a/src/containers/ContactManagement/Instructions/Instructions.tsx b/src/containers/ContactManagement/Instructions/Instructions.tsx new file mode 100644 index 000000000..efabf6ec2 --- /dev/null +++ b/src/containers/ContactManagement/Instructions/Instructions.tsx @@ -0,0 +1,35 @@ +import styles from './Instructions.module.css'; + +export const Instructions = () => { + return ( +
+
+
Instructions
+
    +
  • Use this to import new contacts in bulk
  • +
  • + Ensure you have prior permission from the contacts to message them through the chatbot +
  • +
  • + Ensure the first message being sent to the newly onboarded contacts is the opt-in + message +
  • +
+
+ +
+
Disclaimer
+
    +
  • + Contacts who block a chatbot number leads to the reduction in quality rating of the + chatbot by Meta. This can reduce the limit of business initiated conversations. +
  • +
  • + Kindly plan to message large number of new contacts in batches to avoid having quality + reduced or having your chatbot blocked by Meta. +
  • +
+
+
+ ); +}; diff --git a/src/containers/ContactManagement/SuperAdminContactManagement/SuperAdminContactManagement.module.css b/src/containers/ContactManagement/SuperAdminContactManagement/SuperAdminContactManagement.module.css deleted file mode 100644 index 61ea8bb99..000000000 --- a/src/containers/ContactManagement/SuperAdminContactManagement/SuperAdminContactManagement.module.css +++ /dev/null @@ -1,77 +0,0 @@ -.OrgIcon { - width: 29px; - height: 29px; -} - -.Label { - width: 40%; -} - -.LabelText { - font-weight: 500; - font-size: 20px; - color: #073f24; - line-height: 1; -} - -.SubLabelText { - font-weight: 500; - font-size: 12px; - margin-top: 4px; - color: #cacaca; - line-height: 1; -} - -.StatusText { - font-weight: 400; - font-size: 16px; - color: #93a29b; - line-height: 1.25; -} - -.LabelContainer { - height: fit-content; - align-items: baseline; - align-content: center; -} - -.Status { - display: flex; - align-content: center; - width: 20%; -} - -.Actions { - width: 20%; - text-align: end; - margin-left: auto; -} - -.additonalButton { - width: 42px; - height: 42px; -} - -.DialogSubText { - font-size: 16px; - line-height: 1.25; - color: #073f24; - margin: 0px !important; -} - -.DialogSubInput { - margin-top: 16px; - margin-bottom: 16px; -} - -.UploadIconIcon { - width: 18px; -} - -.Delete { - color: #dd1f1f; -} - -.Inactive { - color: #93a29b; -} diff --git a/src/containers/ContactManagement/SuperAdminContactManagement/SuperAdminContactManagement.test.tsx b/src/containers/ContactManagement/SuperAdminContactManagement/SuperAdminContactManagement.test.tsx deleted file mode 100644 index 5ab619648..000000000 --- a/src/containers/ContactManagement/SuperAdminContactManagement/SuperAdminContactManagement.test.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { render, cleanup, act, screen } from '@testing-library/react'; -import { MockedProvider } from '@apollo/client/testing'; - -import { BrowserRouter as Router } from 'react-router-dom'; - -import { getAllOrganizations } from 'mocks/Organization'; -import { setUserSession } from 'services/AuthService'; -import { SuperAdminContactManagement } from './SuperAdminContactManagement'; - -afterEach(cleanup); -const mocks = getAllOrganizations; -setUserSession(JSON.stringify({ organization: { id: '1' }, roles: ['Glific_admin'] })); - -const list = ( - - - - - -); - -test('Super admin contact management list renders correctly', async () => { - render(list); - - expect(screen.getByTestId('loading')).toBeInTheDocument(); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - const label = await screen.findByText('Contact management'); - const nameLabel = await screen.findByText('Name'); - const actionLabel = await screen.findByText('Actions'); - - expect(label).toBeInTheDocument(); - expect(nameLabel).toBeInTheDocument(); - expect(actionLabel).toBeInTheDocument(); -}); diff --git a/src/containers/ContactManagement/SuperAdminContactManagement/SuperAdminContactManagement.tsx b/src/containers/ContactManagement/SuperAdminContactManagement/SuperAdminContactManagement.tsx deleted file mode 100644 index dc0e3a2e4..000000000 --- a/src/containers/ContactManagement/SuperAdminContactManagement/SuperAdminContactManagement.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { GET_ORGANIZATION_COUNT, FILTER_ORGANIZATIONS } from 'graphql/queries/Organization'; - -import CollectionIcon from 'assets/images/icons/Collection/Dark.svg?react'; -import UploadIcon from 'assets/images/icons/Upload/Dark.svg?react'; - -import { List } from 'containers/List/List'; -import styles from './SuperAdminContactManagement.module.css'; -import UploadContactsDialog from '../UploadContactsDialog/UploadContactsDialog'; - -const queries = { - countQuery: GET_ORGANIZATION_COUNT, - filterItemsQuery: FILTER_ORGANIZATIONS, - deleteItemQuery: null, -}; - -export const listIcon = ; -const extensionIcon = ; - -export const SuperAdminContactManagement = () => { - const { t } = useTranslation(); - - const [showUploadDialog, setShowUploadDialog] = useState(false); - const [organizationDetails, setOrganizationDetails] = useState({}); - - let dialog; - - if (showUploadDialog) { - dialog = ( - - ); - } - - const columnNames = [{ name: 'name', label: t('Name') }, { label: t('Actions') }]; - - const getName = (label: string) => ( -
-

{label}

-
- ); - - const columnStyles: any = [styles.Label, styles.Actions]; - - const getColumns = ({ name }: any) => ({ - name: getName(name), - }); - - const columnAttributes = { - columnNames, - columns: getColumns, - columnStyles, - }; - - const uploadContacts = (_: any, details: any) => { - setOrganizationDetails(details); - setShowUploadDialog(true); - }; - - const restrictedAction = () => ({ delete: false, edit: false }); - - const additionalActions = () => [ - { - icon: extensionIcon, - parameter: 'id', - label: t('Upload contacts'), - dialog: uploadContacts, - }, - ]; - const addNewButton = { show: false, label: 'Add New' }; - - return ( - <> - {dialog} - - - ); -}; - -export default SuperAdminContactManagement; diff --git a/src/containers/ContactManagement/UploadContactsDialog/UploadContactsDialog.module.css b/src/containers/ContactManagement/UploadContactsDialog/UploadContactsDialog.module.css index 3b0f4d48b..62ee933b8 100644 --- a/src/containers/ContactManagement/UploadContactsDialog/UploadContactsDialog.module.css +++ b/src/containers/ContactManagement/UploadContactsDialog/UploadContactsDialog.module.css @@ -78,3 +78,17 @@ position: absolute; right: 12px; } + +.ImportContainer { + display: flex; + flex-direction: column; +} + +.FileName { + font-size: 12px; + margin: 0; +} + +.DialogContent { + text-align: center; +} diff --git a/src/containers/ContactManagement/UploadContactsDialog/UploadContactsDialog.test.tsx b/src/containers/ContactManagement/UploadContactsDialog/UploadContactsDialog.test.tsx index 8043f9696..3080e31a9 100644 --- a/src/containers/ContactManagement/UploadContactsDialog/UploadContactsDialog.test.tsx +++ b/src/containers/ContactManagement/UploadContactsDialog/UploadContactsDialog.test.tsx @@ -1,5 +1,4 @@ -import { render, waitFor, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { render, waitFor } from '@testing-library/react'; import { MockedProvider } from '@apollo/client/testing'; import { BrowserRouter as Router } from 'react-router-dom'; @@ -7,9 +6,22 @@ import { BrowserRouter as Router } from 'react-router-dom'; import { getAllOrganizations } from 'mocks/Organization'; import UploadContactsDialog from './UploadContactsDialog'; -import { getOrganizationCollections } from 'mocks/Collection'; - -const mocks = [...getAllOrganizations, getOrganizationCollections]; +import { filterCollectionQuery } from 'mocks/Collection'; +import { CONTACTS_COLLECTION } from 'common/constants'; + +const mocks = [ + ...getAllOrganizations, + filterCollectionQuery({ + filter: { + groupType: CONTACTS_COLLECTION, + }, + opts: { + limit: 50, + offset: 0, + order: 'ASC', + }, + }), +]; const setDialogMock = vi.fn(); const props = { @@ -18,6 +30,7 @@ const props = { name: 'Glific', }, setDialog: setDialogMock, + setShowStatus: vi.fn(), }; const dialogBox = ( @@ -33,38 +46,6 @@ test('Upload contact dialog renders correctly', async () => { expect(getByText('Loading...')).toBeInTheDocument(); await waitFor(() => { - expect(getByText('Upload contacts: Glific')).toBeInTheDocument(); - }); -}); - -test('Files other than .csv should raise a warning message upon upload', async () => { - render(dialogBox); - - const nonCSVFile = new File(['This is not a CSV File'], 'test.pdf', { type: 'application/pdf' }); - await waitFor(() => { - const fileInput = screen.getByTestId('uploadFile'); - userEvent.upload(fileInput, nonCSVFile); - }); - await waitFor(() => { - expect(screen.getByTestId('invalidCsvFormat')).toBeInTheDocument(); - }); -}); - -test('Should be able to upload valid CSV', async () => { - render(dialogBox); - - // Valid CSV - const csvContent = `name,phone,collection - John Doe,919876543210,"Optin collection,Optout Collection" - Virat Kohli,919876543220,Cricket`; - const file = new File([csvContent], 'test.csv', { type: 'text/csv' }); - - await waitFor(() => { - const fileInput = screen.getByTestId('uploadFile'); - userEvent.upload(fileInput, file); - }); - await waitFor(() => { - // the filename should be visible instead of Select .csv after upload - expect(screen.getByText('test.csv')).toBeInTheDocument(); + expect(getByText('Upload Contacts')).toBeInTheDocument(); }); }); diff --git a/src/containers/ContactManagement/UploadContactsDialog/UploadContactsDialog.tsx b/src/containers/ContactManagement/UploadContactsDialog/UploadContactsDialog.tsx index aaa8ef915..039d82343 100644 --- a/src/containers/ContactManagement/UploadContactsDialog/UploadContactsDialog.tsx +++ b/src/containers/ContactManagement/UploadContactsDialog/UploadContactsDialog.tsx @@ -1,63 +1,59 @@ -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import * as Yup from 'yup'; import { DialogBox } from 'components/UI/DialogBox/DialogBox'; import { AutoComplete } from 'components/UI/Form/AutoComplete/AutoComplete'; -import { useLazyQuery, useMutation } from '@apollo/client'; +import { useMutation, useQuery } from '@apollo/client'; import { useTranslation } from 'react-i18next'; -import { GET_ORGANIZATION_COLLECTIONS } from 'graphql/queries/Collection'; +import { FILTER_COLLECTIONS } from 'graphql/queries/Collection'; import { Loading } from 'components/UI/Layout/Loading/Loading'; import { Checkbox } from 'components/UI/Form/Checkbox/Checkbox'; import { Field, Form, Formik } from 'formik'; -import UploadIcon from 'assets/images/icons/Upload.svg?react'; -import CrossIcon from 'assets/images/icons/Cross.svg?react'; import { UPLOAD_CONTACTS_SAMPLE } from 'config'; import { IMPORT_CONTACTS } from 'graphql/mutations/Contact'; -import { slicedString } from 'common/utils'; +import { getUserSession } from 'services/AuthService'; import { setNotification } from 'common/notification'; import styles from './UploadContactsDialog.module.css'; +import { ImportButton } from 'components/UI/ImportButton/ImportButton'; export interface UploadContactsDialogProps { - organizationDetails: any; + organizationDetails?: any; setDialog: Function; + setShowStatus: any; } -export const UploadContactsDialog = ({ - organizationDetails, - setDialog, -}: UploadContactsDialogProps) => { - const [error, setError] = useState(false); +export const UploadContactsDialog = ({ setDialog, setShowStatus }: UploadContactsDialogProps) => { + const [fileName, setFileName] = useState(''); const [csvContent, setCsvContent] = useState(''); const [uploadingContacts, setUploadingContacts] = useState(false); - const [fileName, setFileName] = useState(''); + const orgId = getUserSession('organizationId'); const { t } = useTranslation(); const [collection] = useState(); const [optedIn] = useState(false); - const [getCollections, { data: collections, loading }] = useLazyQuery( - GET_ORGANIZATION_COLLECTIONS - ); - - useEffect(() => { - if (organizationDetails.id) { - getCollections({ - variables: { - organizationGroupsId: organizationDetails.id, - }, - }); - } - }, [organizationDetails]); + const { data: collections, loading } = useQuery(FILTER_COLLECTIONS, { + variables: { + filter: { + groupType: 'WABA', + }, + opts: { + limit: 50, + offset: 0, + order: 'ASC', + }, + }, + }); const [importContacts] = useMutation(IMPORT_CONTACTS, { onCompleted: (data: any) => { - if (data.errors) { + const { errors } = data.importContacts; + if (errors) { setNotification(data.errors[0].message, 'warning'); } else { setUploadingContacts(false); - setNotification(t('Contacts have been uploaded')); + setShowStatus(true); } - setDialog(false); }, onError: (errors) => { setDialog(false); @@ -66,31 +62,13 @@ export const UploadContactsDialog = ({ }, }); - const addAttachment = (event: any) => { - const media = event.target.files[0]; - const reader = new FileReader(); - reader.readAsText(media); - - reader.onload = () => { - const mediaName = media.name; - const extension = mediaName.slice((Math.max(0, mediaName.lastIndexOf('.')) || Infinity) + 1); - if (extension !== 'csv') { - setError(true); - } else { - const shortenedName = slicedString(mediaName, 15); - setFileName(shortenedName); - setCsvContent(reader.result); - } - }; - }; - const uploadContacts = (details: any) => { importContacts({ variables: { type: 'DATA', data: csvContent, groupLabel: details.collection.label, - importContactsId: organizationDetails.id, + importContactsId: orgId, }, }); }; @@ -101,6 +79,9 @@ export const UploadContactsDialog = ({ const validationSchema = Yup.object().shape({ collection: Yup.object().nullable().required(t('Collection is required')), + optedIn: Yup.boolean() + .oneOf([true], 'Please confirm if contacts are opted in.') + .required('Please confirm if contacts are opted in.'), }); const formFieldItems: any = [ @@ -108,7 +89,7 @@ export const UploadContactsDialog = ({ component: AutoComplete, name: 'collection', placeholder: t('Select collection'), - options: collections.organizationGroups, + options: collections.groups, multiple: false, optionLabel: 'label', label: t('Collection'), @@ -118,6 +99,9 @@ export const UploadContactsDialog = ({ name: 'optedIn', title: t('Are these contacts opted in?'), darkCheckbox: true, + info: { + title: 'Please obtain prior consent from contacts to message them on WhatsApp', + }, }, ]; @@ -135,7 +119,7 @@ export const UploadContactsDialog = ({
{ submitForm(); }} @@ -146,6 +130,7 @@ export const UploadContactsDialog = ({ buttonOkLoading={uploadingContacts} buttonOk={t('Upload')} alignButtons="left" + disableOk={!csvContent} >
{formFieldItems.map((field: any) => ( @@ -153,59 +138,27 @@ export const UploadContactsDialog = ({ ))}
-
- +
+ { + setFileName(media.name); + setCsvContent(result); + }} + fileType=".csv" + />
- - {error && ( -
- 1. Please make sure the file format matches the sample -
- )} )} ); - return form; + return <>{form}; }; export default UploadContactsDialog; diff --git a/src/containers/NotificationList/NotificationList.test.tsx b/src/containers/NotificationList/NotificationList.test.tsx index 8c0e6b661..2b0e7bee3 100644 --- a/src/containers/NotificationList/NotificationList.test.tsx +++ b/src/containers/NotificationList/NotificationList.test.tsx @@ -10,9 +10,12 @@ import { getCountWithEmptyFilter, markAllNotificationAsRead, getInfoNotificationsQuery, + getStatus, + getStatusWithError, } from 'mocks/Notifications'; import { setUserSession } from 'services/AuthService'; import { NotificationList } from './NotificationList'; +import * as Notification from 'common/notification'; setUserSession(JSON.stringify({ roles: ['Admin'] })); @@ -33,13 +36,19 @@ const mocks: any = [ getInfoNotificationsQuery(), ]; -const notifications = ( - - - - - -); +const notifications = (mock?: any) => { + let MOCKS = mocks; + if (mock) { + MOCKS = [...MOCKS, mock]; + } + return ( + + + + + + ); +}; const mockedUsedNavigate = vi.fn(); vi.mock('react-router-dom', async () => ({ @@ -48,7 +57,7 @@ vi.mock('react-router-dom', async () => ({ })); test('It should load notifications', async () => { - render(notifications); + render(notifications()); await waitFor(() => { expect(screen.getByTestId('loading')).toBeInTheDocument(); @@ -73,7 +82,7 @@ test('It should load notifications', async () => { }); test('click on forward arrrow', async () => { - render(notifications); + render(notifications(getStatus)); await waitFor(() => { expect(screen.getByTestId('loading')).toBeInTheDocument(); @@ -95,7 +104,7 @@ test('click on forward arrrow', async () => { }); test('it should show copy text and view option on clicking entity ', async () => { - const { getByTestId, getByText } = render(notifications); + const { getByTestId, getByText } = render(notifications()); await waitFor(() => { const entityMenu = screen.getAllByTestId('NotificationRowMenu'); expect(entityMenu[0]).toBeInTheDocument(); @@ -124,7 +133,7 @@ test('it should show copy text and view option on clicking entity ', async () => }); test('it should show filter radio button', async () => { - render(notifications); + render(notifications()); await waitFor(() => { const checkboxInput = screen.getAllByTestId('radio'); @@ -133,7 +142,7 @@ test('it should show filter radio button', async () => { }); test('it should have Info, Warning and critical checkbox', async () => { - render(notifications); + render(notifications()); await waitFor(() => { const checkboxInput = screen.getAllByTestId('radio'); @@ -143,3 +152,26 @@ test('it should have Info, Warning and critical checkbox', async () => { expect(checkboxInput[3]).toHaveTextContent('All'); }); }); + +test('it should show "Contact import is in progress" message', async () => { + const notificationSpy = vi.spyOn(Notification, 'setNotification'); + render(notifications(getStatusWithError)); + + await waitFor(() => { + expect(screen.getByTestId('loading')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByText('Notifications')).toBeInTheDocument(); + }); + + const arrowButtons = screen.getAllByTestId('ArrowForwardIcon'); + + arrowButtons.forEach(async (button) => { + fireEvent.click(button); + + await waitFor(() => { + expect(notificationSpy).toHaveBeenCalledWith(); + }); + }); +}); diff --git a/src/containers/NotificationList/NotificationList.tsx b/src/containers/NotificationList/NotificationList.tsx index 54686efb6..725fb4a63 100644 --- a/src/containers/NotificationList/NotificationList.tsx +++ b/src/containers/NotificationList/NotificationList.tsx @@ -12,11 +12,13 @@ import CopyIcon from 'assets/images/icons/Copy.png'; import { List } from 'containers/List/List'; import Menu from 'components/UI/Menu/Menu'; import { Button } from 'components/UI/Form/Button/Button'; -import { copyToClipboard } from 'common/utils'; +import { copyToClipboard, exportCsvFile } from 'common/utils'; import { FILTER_NOTIFICATIONS, GET_NOTIFICATIONS_COUNT } from 'graphql/queries/Notifications'; import MARK_NOTIFICATIONS_AS_READ from 'graphql/mutations/Notifications'; import styles from './NotificationList.module.css'; import { SHORT_DATE_TIME_FORMAT } from 'common/constants'; +import { GET_CONTACT_IMPORT_STATUS } from 'graphql/mutations/Contact'; +import { setErrorMessage, setNotification } from 'common/notification'; const getDot = (isRead: boolean) =>
{!isRead ?
: null}
; @@ -71,6 +73,21 @@ export const NotificationList = () => { }, }); + const [getStatus] = useMutation(GET_CONTACT_IMPORT_STATUS, { + onCompleted: ({ getContactUploadReport }) => { + const { csvRows, error } = getContactUploadReport; + if (error) { + setNotification(error, 'warning'); + return; + } + exportCsvFile(csvRows, `Contact_Upload_Status`); + setNotification('Downloaded the status of the contact upload', 'success'); + }, + onError: (error) => { + setErrorMessage(error); + }, + }); + useEffect(() => { setTimeout(() => { markNotificationAsRead(); @@ -102,6 +119,14 @@ export const NotificationList = () => { case 'WA Group': destination = `/group/chat/${entity.id}`; break; + case 'Contact Upload': + getStatus({ + variables: { + userJobId: entity?.user_job_id, + }, + }); + break; + default: // Handle unknown category return; diff --git a/src/graphql/mutations/Contact.ts b/src/graphql/mutations/Contact.ts index 547259387..b9c411220 100644 --- a/src/graphql/mutations/Contact.ts +++ b/src/graphql/mutations/Contact.ts @@ -50,7 +50,7 @@ export const MOVE_CONTACTS = gql` message key } - csvRows + status } } `; @@ -81,3 +81,12 @@ export const DELETE_CONTACT_PROFILE = gql` } } `; + +export const GET_CONTACT_IMPORT_STATUS = gql` + mutation GetContactUploadReport($userJobId: ID) { + getContactUploadReport(userJobId: $userJobId) { + csvRows + error + } + } +`; diff --git a/src/i18n/en/en.json b/src/i18n/en/en.json index ec7930c35..09ea81d46 100644 --- a/src/i18n/en/en.json +++ b/src/i18n/en/en.json @@ -155,7 +155,7 @@ "Contact Variables": "Contact Variables", "Back to flows": "Back to flows", "Contacts have been updated": "Contacts have been updated", - "Upload contacts": "Upload contacts", + "Upload Contacts": "Upload Contacts", "Contact management": "Contact management", "Contacts have been uploaded": "Contacts have been uploaded", "Collection is required": "Collection is required", diff --git a/src/mocks/Collection.tsx b/src/mocks/Collection.tsx index 1b7d6402d..f99f918d9 100644 --- a/src/mocks/Collection.tsx +++ b/src/mocks/Collection.tsx @@ -227,20 +227,10 @@ export const countCollectionQuery = { }, }; -export const filterCollectionQuery = { +export const filterCollectionQuery = (variables: any) => ({ request: { query: FILTER_COLLECTIONS, - variables: { - filter: { - groupType: CONTACTS_COLLECTION, - }, - opts: { - limit: 50, - offset: 0, - order: 'ASC', - orderWith: 'label', - }, - }, + variables, }, result: { data: { @@ -257,7 +247,7 @@ export const filterCollectionQuery = { ], }, }, -}; +}); export const filterCollectionQueryWAGroups = { request: { diff --git a/src/mocks/Contact.tsx b/src/mocks/Contact.tsx index 941152794..1f86cc9b5 100644 --- a/src/mocks/Contact.tsx +++ b/src/mocks/Contact.tsx @@ -12,7 +12,7 @@ import { } from 'graphql/queries/Contact'; import { addFlowToContactQuery } from 'mocks/Flow'; import { getOrganizationLanguagesQuery, getOrganizationQuery } from 'mocks/Organization'; -import { UPDATE_CONTACT, MOVE_CONTACTS } from 'graphql/mutations/Contact'; +import { UPDATE_CONTACT, MOVE_CONTACTS, IMPORT_CONTACTS } from 'graphql/mutations/Contact'; import { UPDATE_CONTACT_COLLECTIONS } from 'graphql/mutations/Collection'; import { CLEAR_MESSAGES } from 'graphql/mutations/Chat'; import { setVariables } from 'common/constants'; @@ -211,6 +211,7 @@ export const moveContacts = { moveContacts: { errors: null, csvRows: 'Test Row', + status: 'Import contacts done successfully', }, }, }, @@ -620,3 +621,18 @@ export const getExcludedContactsQuery = (excludeGroups: any) => ({ }, }, }); + +export const importContacts = { + request: { + query: IMPORT_CONTACTS, + }, + result: { + data: { + importContacts: { + errors: null, + status: 'Test Row', + }, + }, + }, + variableMatcher: (variables: any) => true, +}; diff --git a/src/mocks/Notifications.tsx b/src/mocks/Notifications.tsx index e6cb07dc1..02660bd7f 100644 --- a/src/mocks/Notifications.tsx +++ b/src/mocks/Notifications.tsx @@ -1,5 +1,6 @@ import { FILTER_NOTIFICATIONS, GET_NOTIFICATIONS_COUNT } from 'graphql/queries/Notifications'; import { MARK_NOTIFICATIONS_AS_READ } from 'graphql/mutations/Notifications'; +import { GET_CONTACT_IMPORT_STATUS } from 'graphql/mutations/Contact'; export const getNotificationsQuery = { request: { @@ -70,6 +71,24 @@ export const getNotificationsQuery = { severity: '"Critical"', updatedAt: '2024-03-29T11:14:13Z', }, + { + category: 'Contact Upload', + entity: '{"user_job_id":1}', + id: '7', + isRead: true, + message: 'Contact upload completed', + severity: '"Information"', + updatedAt: '2024-08-09T05:50:00Z', + }, + { + category: 'unknown category', + entity: '{}', + id: '8', + isRead: true, + message: '', + severity: '', + updatedAt: '2024-08-09T05:50:00Z', + }, ], }, }, @@ -147,7 +166,7 @@ export const getNotificationCountQuery = { request: { query: GET_NOTIFICATIONS_COUNT, variables: { - filter: { is_read: false, severity: 'critical' }, + filter: { is_read: false }, }, }, result: { @@ -200,3 +219,37 @@ export const markAllNotificationAsRead = { }, }, }; + +export const getStatus = { + request: { + query: GET_CONTACT_IMPORT_STATUS, + variables: { + userJobId: 1, + }, + }, + result: { + data: { + getContactUploadReport: { + csvRows: 'Contact import done', + error: null, + }, + }, + }, +}; + +export const getStatusWithError = { + request: { + query: GET_CONTACT_IMPORT_STATUS, + variables: { + userJobId: 1, + }, + }, + result: { + data: { + getContactUploadReport: { + csvRows: null, + error: 'Contact upload is in progress', + }, + }, + }, +};