From da70807290ebd7e1de1ff9d4b6980606ae7c0c4f Mon Sep 17 00:00:00 2001 From: Jamari McFarlane <71823011+JamarTG@users.noreply.github.com> Date: Thu, 30 Nov 2023 21:11:20 -0500 Subject: [PATCH] Custom Data and Custom Fields (#1096) * save ppf admin changes * fixed failing tests * restoring EventCard test * coverage increase * increased coverage some more * increase orgprofilefieldsettings' coverage --- package-lock.json | 9 + package.json | 1 + public/locales/en.json | 14 +- public/locales/fr.json | 14 +- public/locales/hi.json | 14 +- public/locales/sp.json | 14 +- public/locales/zh.json | 14 +- src/GraphQl/Mutations/mutations.ts | 26 ++ src/GraphQl/Queries/Queries.ts | 10 + .../EditCustomFieldDropDown.test.tsx | 60 +++++ .../EditCustomFieldDropDown.tsx | 53 +++++ .../OrgProfileFieldSettings.module.css | 24 ++ .../OrgProfileFieldSettings.test.tsx | 223 ++++++++++++++++++ .../OrgProfileFieldSettings.tsx | 166 +++++++++++++ src/screens/OrgSettings/OrgSettings.tsx | 13 + src/utils/fieldTypes.ts | 3 + 16 files changed, 653 insertions(+), 5 deletions(-) create mode 100644 src/components/EditCustomFieldDropDown/EditCustomFieldDropDown.test.tsx create mode 100644 src/components/EditCustomFieldDropDown/EditCustomFieldDropDown.tsx create mode 100644 src/components/OrgProfileFieldSettings/OrgProfileFieldSettings.module.css create mode 100644 src/components/OrgProfileFieldSettings/OrgProfileFieldSettings.test.tsx create mode 100644 src/components/OrgProfileFieldSettings/OrgProfileFieldSettings.tsx create mode 100644 src/utils/fieldTypes.ts diff --git a/package-lock.json b/package-lock.json index f11ca8eb3f..66da892af6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "react-dom": "^17.0.2", "react-google-recaptcha": "^2.1.0", "react-i18next": "^11.18.1", + "react-icons": "^4.12.0", "react-infinite-scroll-component": "^6.1.0", "react-redux": "^7.2.5", "react-router-dom": "^5.2.0", @@ -20021,6 +20022,14 @@ } } }, + "node_modules/react-icons": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.12.0.tgz", + "integrity": "sha512-IBaDuHiShdZqmfc/TwHu6+d6k2ltNCf3AszxNmjJc1KUfXdEeRJOKyNvLmAHaarhzGmTSVygNdyu8/opXv2gaw==", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-infinite-scroll-component": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.0.tgz", diff --git a/package.json b/package.json index 1fbc68abf3..3a10e00509 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "react-dom": "^17.0.2", "react-google-recaptcha": "^2.1.0", "react-i18next": "^11.18.1", + "react-icons": "^4.12.0", "react-infinite-scroll-component": "^6.1.0", "react-redux": "^7.2.5", "react-router-dom": "^5.2.0", diff --git a/public/locales/en.json b/public/locales/en.json index bfefeec253..72ad23c5cb 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -418,7 +418,8 @@ "settings": "Settings", "noData": "No data", "otherSettings": "Other Settings", - "changeLanguage": "Change Language" + "changeLanguage": "Change Language", + "manageCustomFields": "Manage Custom Fields" }, "deleteOrg": { "deleteOrganization": "Delete Organization", @@ -686,5 +687,16 @@ "userChatRoom": { "selectContact": "Select a contact to start conversation", "sendMessage": "Send Message" + }, + "orgProfileField": { + "loading": "Loading...", + "noCustomField": "No custom fields available", + "customFieldName": "Field Name", + "enterCustomFieldName": "Enter Field name", + "customFieldType": "Field Type", + "saveChanges": "Save Changes", + "Remove Custom Field": "Remove Custom Field", + "fieldSuccessMessage": "Field added successfully", + "fieldRemovalSuccess": "Field removed successfully" } } diff --git a/public/locales/fr.json b/public/locales/fr.json index 818ffd6fcd..07cd92e71d 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -409,7 +409,8 @@ "settings": "Réglages", "noData": "Pas de données", "otherSettings": "Autres paramètres", - "changeLanguage": "Changer la langue" + "changeLanguage": "Changer la langue", + "manageCustomFields": "Gérer les Champs Personnalisés" }, "deleteOrg": { "deleteOrganization": "Supprimer l'organisation", @@ -664,5 +665,16 @@ "userChatRoom": { "selectContact": "Sélectionnez un contact pour démarrer la conversation", "sendMessage": "Envoyer le message" + }, + "orgProfileField": { + "loading": "Chargement...", + "noCustomField": "Aucun champ personnalisé disponible", + "customFieldName": "Nom du champ", + "enterCustomFieldName": "Entrez le nom du champ", + "customFieldType": "Type de champ", + "saveChanges": "Enregistrer les modifications", + "Supprimer le champ personnalisé": "Supprimer le champ personnalisé", + "fieldSuccessMessage": "Champ ajouté avec succès", + "fieldRemovalSuccess": "Champ supprimé avec succès" } } diff --git a/public/locales/hi.json b/public/locales/hi.json index fb9ae1ab23..48d5fa1287 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -408,7 +408,8 @@ "settings": "समायोजन", "noData": "कोई डेटा नहीं", "otherSettings": "अन्य सेटिंग्स", - "changeLanguage": "भाषा बदलें" + "changeLanguage": "भाषा बदलें", + "manageCustomFields": "कस्टम फ़ील्ड प्रबंधन करें" }, "deleteOrg": { "deleteOrganization": "संगठन हटाएं", @@ -664,5 +665,16 @@ "userChatRoom": { "selectContact": "बातचीत शुरू करने के लिए एक संपर्क चुनें", "sendMessage": "मेसेज भेजें" + }, + "ऑर्गप्रोफ़ाइलफ़ील्ड": { + "लोड हो रहा है": "लोड हो रहा है...", + "noCustomField": "कोई कस्टम फ़ील्ड उपलब्ध नहीं", + "customFieldName": "फ़ील्ड नाम", + "enterCustomFieldName": "फ़ील्ड नाम दर्ज करें", + "customFieldType": "फ़ील्ड प्रकार", + "saveChanges": "परिवर्तन सहेजें", + "कस्टम फ़ील्ड हटाएँ": "कस्टम फ़ील्ड हटाएँ", + "fieldSuccessMessage": "फ़ील्ड सफलतापूर्वक जोड़ा गया", + "fieldRemovalSuccess": "फ़ील्ड सफलतापूर्वक हटा दिया गया" } } diff --git a/public/locales/sp.json b/public/locales/sp.json index 09d6472753..fb4f5d265d 100644 --- a/public/locales/sp.json +++ b/public/locales/sp.json @@ -408,7 +408,8 @@ "settings": "Ajustes", "noData": "Sin datos", "otherSettings": "Otras Configuraciones", - "changeLanguage": "Cambiar Idioma" + "changeLanguage": "Cambiar Idioma", + "manageCustomFields": "Gestionar Campos Personalizados" }, "deleteOrg": { "deleteOrganization": "Eliminar organización", @@ -664,5 +665,16 @@ "userChatRoom": { "selectContact": "Seleccione un contacto para iniciar una conversación", "sendMessage": "Enviar mensaje" + }, + "campoPerfildeOrganización": { + "cargando": "Cargando...", + "noCustomField": "No hay campos personalizados disponibles", + "customFieldName": "Nombre de campo", + "enterCustomFieldName": "Ingrese el nombre del campo", + "customFieldType": "Tipo de campo", + "saveChanges": "Guardar cambios", + "Eliminar campo personalizado": "Eliminar campo personalizado", + "fieldSuccessMessage": "Campo agregado exitosamente", + "fieldRemovalSuccess": "Campo eliminado exitosamente" } } diff --git a/public/locales/zh.json b/public/locales/zh.json index 138a345206..71f7f59fc0 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -408,7 +408,8 @@ "settings": "設置", "noData": "沒有數據", "otherSettings": "其他设置", - "changeLanguage": "更改语言" + "changeLanguage": "更改语言", + "manageCustomFields": "管理自定义字段" }, "deleteOrg": { "deleteOrganization": "删除组织", @@ -664,5 +665,16 @@ "userChatRoom": { "selectContact": "選擇聯絡人開始對話", "sendMessage": "傳訊息" + }, + "orgProfileField": { + "loading": "正在加载...", + "noCustomField": "没有可用的自定义字段", + "customFieldName": "字段名称", + "enterCustomFieldName": "输入字段名称", + "customFieldType": "字段类型", + "saveChanges": "保存更改", + "删除自定义字段": "删除自定义字段", + "fieldSuccessMessage": "字段添加成功", + "fieldRemovalSuccess": "字段删除成功" } } diff --git a/src/GraphQl/Mutations/mutations.ts b/src/GraphQl/Mutations/mutations.ts index 720d655b53..f25abab4ef 100644 --- a/src/GraphQl/Mutations/mutations.ts +++ b/src/GraphQl/Mutations/mutations.ts @@ -740,3 +740,29 @@ export const TOGGLE_PINNED_POST = gql` } } `; + +// Handles custom organization fields +export const ADD_CUSTOM_FIELD = gql` + mutation ($organizationId: ID!, $type: String!, $name: String!) { + addOrganizationCustomField( + organizationId: $organizationId + type: $type + name: $name + ) { + name + type + } + } +`; + +export const REMOVE_CUSTOM_FIELD = gql` + mutation ($organizationId: ID!, $customFieldId: ID!) { + removeOrganizationCustomField( + organizationId: $organizationId + customFieldId: $customFieldId + ) { + type + name + } + } +`; diff --git a/src/GraphQl/Queries/Queries.ts b/src/GraphQl/Queries/Queries.ts index 7bf99d48ee..826d9d8076 100644 --- a/src/GraphQl/Queries/Queries.ts +++ b/src/GraphQl/Queries/Queries.ts @@ -843,3 +843,13 @@ export const IS_SAMPLE_ORGANIZATION_QUERY = gql` isSampleOrganization(id: $isSampleOrganizationId) } `; + +export const ORGANIZATION_CUSTOM_FIELDS = gql` + query ($customFieldsByOrganizationId: ID!) { + customFieldsByOrganization(id: $customFieldsByOrganizationId) { + _id + type + name + } + } +`; diff --git a/src/components/EditCustomFieldDropDown/EditCustomFieldDropDown.test.tsx b/src/components/EditCustomFieldDropDown/EditCustomFieldDropDown.test.tsx new file mode 100644 index 0000000000..d79264855e --- /dev/null +++ b/src/components/EditCustomFieldDropDown/EditCustomFieldDropDown.test.tsx @@ -0,0 +1,60 @@ +import type { Dispatch, SetStateAction } from 'react'; +import React from 'react'; +import { act, render } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import EditOrgCustomFieldDropDown from './EditCustomFieldDropDown'; +import type { InterfaceCustomFieldData } from 'components/OrgProfileFieldSettings/OrgProfileFieldSettings'; +import userEvent from '@testing-library/user-event'; +import availableFieldTypes from 'utils/fieldTypes'; + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +describe('Testing Custom Field Dropdown', () => { + test('Component Should be rendered properly', async () => { + const customFieldData = { + type: 'Number', + name: 'Age', + }; + + const setCustomFieldData: Dispatch< + SetStateAction + > = (val) => { + { + val; + } + }; + const props = { + customFieldData: customFieldData as InterfaceCustomFieldData, + setCustomFieldData: setCustomFieldData, + parentContainerStyle: 'parentContainerStyle', + btnStyle: 'btnStyle', + btnTextStyle: 'btnTextStyle', + }; + + const { getByTestId, getByText } = render( + + + + ); + + expect(getByText('Number')).toBeInTheDocument(); + + act(() => { + userEvent.click(getByTestId('toggleBtn')); + }); + + await wait(); + + availableFieldTypes.forEach(async (_, index) => { + act(() => { + userEvent.click(getByTestId(`dropdown-btn-${index}`)); + }); + }); + }); +}); diff --git a/src/components/EditCustomFieldDropDown/EditCustomFieldDropDown.tsx b/src/components/EditCustomFieldDropDown/EditCustomFieldDropDown.tsx new file mode 100644 index 0000000000..a2fabb2a61 --- /dev/null +++ b/src/components/EditCustomFieldDropDown/EditCustomFieldDropDown.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import type { SetStateAction, Dispatch } from 'react'; +import { Dropdown } from 'react-bootstrap'; +import availableFieldTypes from 'utils/fieldTypes'; +import type { InterfaceCustomFieldData } from 'components/OrgProfileFieldSettings/OrgProfileFieldSettings'; + +interface InterfaceEditCustomFieldDropDownProps { + customFieldData: InterfaceCustomFieldData; + setCustomFieldData: Dispatch>; + parentContainerStyle?: string; + btnStyle?: string; + btnTextStyle?: string; +} +[]; + +const EditOrgCustomFieldDropDown = ( + props: InterfaceEditCustomFieldDropDownProps +): JSX.Element => { + return ( + + + {props.customFieldData.type || 'None'} + + + {availableFieldTypes.map((customFieldType, index: number) => ( + { + props.setCustomFieldData({ + ...props.customFieldData, + type: customFieldType, + }); + }} + disabled={props.customFieldData.type == customFieldType} + > + {customFieldType} + + ))} + + + ); +}; + +export default EditOrgCustomFieldDropDown; diff --git a/src/components/OrgProfileFieldSettings/OrgProfileFieldSettings.module.css b/src/components/OrgProfileFieldSettings/OrgProfileFieldSettings.module.css new file mode 100644 index 0000000000..851f70ce39 --- /dev/null +++ b/src/components/OrgProfileFieldSettings/OrgProfileFieldSettings.module.css @@ -0,0 +1,24 @@ +.customDataTable { + width: 100%; + border-collapse: collapse; +} + +.customDataTable th, +.customDataTable td { + padding: 8px; + text-align: left; +} + +.customDataTable th { + background-color: #f2f2f2; +} +form { + display: flex; + flex-direction: column; + gap: 10px; +} + +.saveButton { + width: 10em; + align-self: self-end; +} diff --git a/src/components/OrgProfileFieldSettings/OrgProfileFieldSettings.test.tsx b/src/components/OrgProfileFieldSettings/OrgProfileFieldSettings.test.tsx new file mode 100644 index 0000000000..3684ae88da --- /dev/null +++ b/src/components/OrgProfileFieldSettings/OrgProfileFieldSettings.test.tsx @@ -0,0 +1,223 @@ +import React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import OrgProfileFieldSettings from './OrgProfileFieldSettings'; +import i18nForTest from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { + ADD_CUSTOM_FIELD, + REMOVE_CUSTOM_FIELD, +} from 'GraphQl/Mutations/mutations'; +import { ORGANIZATION_CUSTOM_FIELDS } from 'GraphQl/Queries/Queries'; + +const MOCKS = [ + { + request: { + query: ADD_CUSTOM_FIELD, + variables: { + type: '', + name: '', + }, + }, + result: { + data: { + addOrganizationCustomField: { + name: 'Custom Field Name', + type: 'string', + }, + }, + }, + }, + + { + request: { + query: REMOVE_CUSTOM_FIELD, + variables: {}, + }, + result: { + data: { + removeOrganizationCustomField: { + type: '', + name: '', + }, + }, + }, + }, + + { + request: { + query: ORGANIZATION_CUSTOM_FIELDS, + variables: {}, + }, + result: { + data: { + customFieldsByOrganization: [ + { + _id: 'adsdasdsa334343yiu423434', + type: 'fieldType', + name: 'fieldName', + }, + ], + }, + }, + }, +]; + +const ERROR_MOCKS = [ + { + request: { + query: ADD_CUSTOM_FIELD, + variables: { + type: '', + name: '', + }, + }, + result: { + data: { + addOrganizationCustomField: { + name: 'Custom Field Name', + type: 'string', + }, + }, + }, + }, + { + request: { + query: REMOVE_CUSTOM_FIELD, + variables: { + organizationId: '', + customFieldId: '', + }, + }, + error: new Error('Failed to remove custom field'), + }, + { + request: { + query: ORGANIZATION_CUSTOM_FIELDS, + variables: {}, + }, + result: { + data: { + customFieldsByOrganization: [ + { + _id: 'adsdasdsa334343yiu423434', + type: 'fieldType', + name: 'fieldName', + }, + ], + }, + }, + }, +]; + +const NO_C_FIELD_MOCK = [ + { + request: { + query: ADD_CUSTOM_FIELD, + variables: { + type: 'fieldType', + name: 'fieldName', + }, + }, + result: { + data: { + addOrganizationCustomField: { + name: 'Custom Field Name', + type: 'string', + }, + }, + }, + }, + + { + request: { + query: ORGANIZATION_CUSTOM_FIELDS, + variables: {}, + }, + result: { + data: { + customFieldsByOrganization: [], + }, + }, + }, +]; + +const link = new StaticMockLink(MOCKS, true); +const link2 = new StaticMockLink(NO_C_FIELD_MOCK, true); +const link3 = new StaticMockLink(ERROR_MOCKS, true); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +describe('Testing Save Button', () => { + test('Saving Organization Custom Field', async () => { + render( + + + + + + ); + + await wait(); + userEvent.click(screen.getByTestId('saveChangesBtn')); + }); + + test('Testing Typing Organization Custom Field Name', async () => { + const { getByTestId } = render( + + + + + + ); + + await wait(); + + const fieldNameInput = getByTestId('customFieldInput'); + userEvent.type(fieldNameInput, 'Age'); + }); + test('When No Custom Data is Present', async () => { + const { getByText } = render( + + + + + + ); + + await wait(); + expect(getByText('No custom fields available')).toBeInTheDocument(); + }); + test('Testing Remove Custom Field Button', async () => { + render( + + + + + + ); + + await wait(); + userEvent.click(screen.getByTestId('removeCustomFieldBtn')); + }); + + test('Testing Failure Case for Removing Custom Field', async () => { + const { getByText } = render( + + + + + + ); + await wait(); + expect(getByText('Field Type')).toBeInTheDocument(); + }); +}); diff --git a/src/components/OrgProfileFieldSettings/OrgProfileFieldSettings.tsx b/src/components/OrgProfileFieldSettings/OrgProfileFieldSettings.tsx new file mode 100644 index 0000000000..a3840921d9 --- /dev/null +++ b/src/components/OrgProfileFieldSettings/OrgProfileFieldSettings.tsx @@ -0,0 +1,166 @@ +import { useMutation, useQuery } from '@apollo/client'; +import React, { useState } from 'react'; +import { FaTrash } from 'react-icons/fa'; +import { Form } from 'react-bootstrap'; +import Button from 'react-bootstrap/Button'; +import { + ADD_CUSTOM_FIELD, + REMOVE_CUSTOM_FIELD, +} from 'GraphQl/Mutations/mutations'; +import { ORGANIZATION_CUSTOM_FIELDS } from 'GraphQl/Queries/Queries'; +import styles from './OrgProfileFieldSettings.module.css'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import EditOrgCustomFieldDropDown from 'components/EditCustomFieldDropDown/EditCustomFieldDropDown'; + +export interface InterfaceCustomFieldData { + type: string; + name: string; +} + +const OrgProfileFieldSettings = (): any => { + const { t } = useTranslation('translation', { + keyPrefix: 'orgProfileField', + }); + + const [customFieldData, setCustomFieldData] = + useState({ + type: '', + name: '', + }); + const currentOrgId = window.location.href.split('=')[1]; + + const [addCustomField] = useMutation(ADD_CUSTOM_FIELD); + const [removeCustomField] = useMutation(REMOVE_CUSTOM_FIELD); + + const { loading, error, data, refetch } = useQuery( + ORGANIZATION_CUSTOM_FIELDS, + { + variables: { + customFieldsByOrganizationId: currentOrgId, + }, + } + ); + + const handleSave = async (): Promise => { + try { + await addCustomField({ + variables: { + organizationId: currentOrgId, + ...customFieldData, + }, + }); + toast.success(t('fieldSuccessMessage')); + setCustomFieldData({ type: '', name: '' }); + refetch(); + } catch (error) { + toast.success((error as Error).message); + } + }; + + const handleRemove = async (customFieldId: string): Promise => { + try { + await removeCustomField({ + variables: { + organizationId: currentOrgId, + customFieldId: customFieldId, + }, + }); + + toast.success(t('fieldRemovalSuccess')); + refetch(); + } catch (error) { + toast.error((error as Error).message); + } + }; + + if (loading) return

Loading... {t('loading')}

; + if (error) return

{error.message}

; + + return ( +
+ {data.customFieldsByOrganization.length === 0 ? ( +

{t('noCustomField')}

+ ) : ( + + + {data.customFieldsByOrganization.map( + ( + field: { + _id: string; + name: string; + type: string; + }, + index: number + ) => ( + + + + + + ) + )} + +
{field.name}{field.type} + +
+ )} +
+
+
+
+
+
+ {t('customFieldName')} + { + setCustomFieldData({ + ...customFieldData, + name: event.target.value, + }); + }} + /> +
+ +
+ + {t('customFieldType')} + + +
+ + +
+
+
+
+
+ ); +}; + +export default OrgProfileFieldSettings; diff --git a/src/screens/OrgSettings/OrgSettings.tsx b/src/screens/OrgSettings/OrgSettings.tsx index 91a8874fa1..9509719d5f 100644 --- a/src/screens/OrgSettings/OrgSettings.tsx +++ b/src/screens/OrgSettings/OrgSettings.tsx @@ -8,6 +8,7 @@ import Col from 'react-bootstrap/Col'; import Row from 'react-bootstrap/Row'; import { useTranslation } from 'react-i18next'; import styles from './OrgSettings.module.css'; +import OrgProfileFieldSettings from 'components/OrgProfileFieldSettings/OrgProfileFieldSettings'; function orgSettings(): JSX.Element { const { t } = useTranslation('translation', { @@ -49,6 +50,18 @@ function orgSettings(): JSX.Element { + + +
+
+ {t('manageCustomFields')} +
+
+ + {orgId && } + +
+ diff --git a/src/utils/fieldTypes.ts b/src/utils/fieldTypes.ts new file mode 100644 index 0000000000..b17fb9833a --- /dev/null +++ b/src/utils/fieldTypes.ts @@ -0,0 +1,3 @@ +const availableFieldTypes = ['String', 'Boolean', 'Date', 'Number']; + +export default availableFieldTypes;