diff --git a/.github/workflows/cypress-testing.yml b/.github/workflows/cypress-testing.yml index b85dce6a0..33f8c0eff 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 templates cd .. cp -r cypress-testing/cypress cypress yarn add cypress@13.6.2 diff --git a/src/components/UI/Form/AutoComplete/AutoComplete.tsx b/src/components/UI/Form/AutoComplete/AutoComplete.tsx index bea7f7541..05af0350a 100644 --- a/src/components/UI/Form/AutoComplete/AutoComplete.tsx +++ b/src/components/UI/Form/AutoComplete/AutoComplete.tsx @@ -178,7 +178,9 @@ export const AutoComplete = ({ freeSolo={freeSolo} autoSelect={autoSelect} disableClearable={disableClearable} - getOptionLabel={(option: any) => (option[optionLabel] != null ? option[optionLabel] : option)} + getOptionLabel={(option: any) => + option[optionLabel] != null ? option[optionLabel] : typeof option === 'string' ? option : '' + } getOptionDisabled={getOptionDisabled} isOptionEqualToValue={(option, value) => { if (value) { diff --git a/src/containers/Chat/ChatMessages/AddToMessageTemplate/AddToMessageTemplate.test.tsx b/src/containers/Chat/ChatMessages/AddToMessageTemplate/AddToMessageTemplate.test.tsx index cbbfb3b0d..cc142af53 100644 --- a/src/containers/Chat/ChatMessages/AddToMessageTemplate/AddToMessageTemplate.test.tsx +++ b/src/containers/Chat/ChatMessages/AddToMessageTemplate/AddToMessageTemplate.test.tsx @@ -2,13 +2,13 @@ import { render, fireEvent, cleanup, waitFor } from '@testing-library/react'; import { MockedProvider } from '@apollo/client/testing'; import { SAVE_MESSAGE_TEMPLATE_MUTATION } from 'graphql/mutations/MessageTemplate'; -import { TEMPLATE_MOCKS } from 'mocks/Template'; +import { filterTemplatesQuery } from 'mocks/Template'; import AddToMessageTemplate from './AddToMessageTemplate'; let resultReturned = false; const mocks = [ - ...TEMPLATE_MOCKS, + filterTemplatesQuery('', {}), { request: { query: SAVE_MESSAGE_TEMPLATE_MUTATION, diff --git a/src/containers/Chat/ChatMessages/ChatInput/ChatInput.test.tsx b/src/containers/Chat/ChatMessages/ChatInput/ChatInput.test.tsx index d031b5576..68169e95e 100644 --- a/src/containers/Chat/ChatMessages/ChatInput/ChatInput.test.tsx +++ b/src/containers/Chat/ChatMessages/ChatInput/ChatInput.test.tsx @@ -3,11 +3,11 @@ import { render, waitFor, fireEvent, screen } from '@testing-library/react'; import { vi } from 'vitest'; import ChatInput from './ChatInput'; -import { TEMPLATE_MOCKS } from 'mocks/Template'; import { createMediaMessageMock, getAttachmentPermissionMock, uploadBlobMock } from 'mocks/Attachment'; import { searchInteractive, searchInteractiveHi } from 'mocks/InteractiveMessage'; import '../VoiceRecorder/VoiceRecorder'; import { LexicalWrapper } from 'common/LexicalWrapper'; +import { TEMPLATE_MOCKS } from 'mocks/Template'; const mocks = [ searchInteractive, diff --git a/src/containers/Chat/ChatMessages/ChatTemplates/ChatTemplates.test.tsx b/src/containers/Chat/ChatMessages/ChatTemplates/ChatTemplates.test.tsx index 11a314651..a2f5b5081 100644 --- a/src/containers/Chat/ChatMessages/ChatTemplates/ChatTemplates.test.tsx +++ b/src/containers/Chat/ChatMessages/ChatTemplates/ChatTemplates.test.tsx @@ -1,7 +1,7 @@ import { MockedProvider } from '@apollo/client/testing'; import ChatTemplates from './ChatTemplates'; -import { TEMPLATE_MOCKS } from '../../../../mocks/Template'; import { render, fireEvent, waitFor } from '@testing-library/react'; +import { TEMPLATE_MOCKS } from 'mocks/Template'; const mocks = TEMPLATE_MOCKS; diff --git a/src/containers/Chat/ChatMessages/TemplateButtons/TemplateButtons.tsx b/src/containers/Chat/ChatMessages/TemplateButtons/TemplateButtons.tsx index 0f96bf11f..49a165569 100644 --- a/src/containers/Chat/ChatMessages/TemplateButtons/TemplateButtons.tsx +++ b/src/containers/Chat/ChatMessages/TemplateButtons/TemplateButtons.tsx @@ -31,6 +31,7 @@ export const TemplateButtons = ({ onClick={() => handleButtonClick(type, value)} startIcon={icon} disabled={!isSimulator} + data-testid="templateButton" > {title} diff --git a/src/containers/Form/FormLayout.tsx b/src/containers/Form/FormLayout.tsx index a0a45d44b..ee7ce54b7 100644 --- a/src/containers/Form/FormLayout.tsx +++ b/src/containers/Form/FormLayout.tsx @@ -179,7 +179,7 @@ export const FormLayout = ({ } }); // for template create media for attachment - if (isAttachment && payload.type !== 'TEXT' && payload.type) { + if (isAttachment && payload.type !== 'TEXT' && payload.type && !entityId) { getMediaId(payload) .then((data: any) => { if (data) { diff --git a/src/containers/HSM/HSM.helper.ts b/src/containers/HSM/HSM.helper.ts new file mode 100644 index 000000000..657db54c9 --- /dev/null +++ b/src/containers/HSM/HSM.helper.ts @@ -0,0 +1,106 @@ +import { CALL_TO_ACTION, MEDIA_MESSAGE_TYPES, QUICK_REPLY } from 'common/constants'; + +export interface CallToActionTemplate { + type: string; + title: string; + value: string; +} + +export interface QuickReplyTemplate { + value: string; +} + +export const mediaOptions = MEDIA_MESSAGE_TYPES.map((option: string) => ({ id: option, label: option })).filter( + ({ label }) => label !== 'AUDIO' && label !== 'STICKER' +); + +export const removeFirstLineBreak = (text: any) => + text?.length === 1 ? text.slice(0, 1).replace(/(\r\n|\n|\r)/, '') : text; + +/** + * Function to convert buttons to template format + * + * @param templateButtons buttons that need to be converted to gupshup format + * @param templateType depending on template type convert button to gupshup format + * + * @return array result + */ +export const convertButtonsToTemplate = (templateButtons: Array, templateType: string | null) => + templateButtons.reduce((result: any, temp: any) => { + const { title, value } = temp; + if (templateType === CALL_TO_ACTION && value && title) { + result.push(`[${title}, ${value}]`); + } + if (templateType === QUICK_REPLY && value) { + result.push(`[${value}]`); + } + return result; + }, []); + +/** + * As messages and buttons are now separated + * we are combining both message and buttons, + * so that you can see preview in simulator + * + * @param templateType template type + * @param message + * @param buttons + * + * @return object {buttons, template} + */ +export const getTemplateAndButtons = (templateType: string, message: string, buttons: string) => { + const templateButtons = JSON.parse(buttons); + let result: any; + if (templateType === CALL_TO_ACTION) { + result = templateButtons.map((button: any) => { + const { phone_number: phoneNo, url, type, text } = button; + return { type, value: url || phoneNo, title: text }; + }); + } + + if (templateType === QUICK_REPLY) { + result = templateButtons.map((button: any) => { + const { text, type } = button; + return { type, value: text }; + }); + } + + // Getting in template format of gupshup + const templateFormat = convertButtonsToTemplate(result, templateType); + // Pre-pending message with buttons + const template = `${message} | ${templateFormat.join(' | ')}`; + return { buttons: result, template }; +}; + +export const getExampleFromBody = (body: string, variables: Array) => { + return body.replace(/{{(\d+)}}/g, (match, number) => { + let index = parseInt(number) - 1; + + return variables[index]?.text ? (variables[index] ? `[${variables[index]?.text}]` : match) : `{{${number}}}`; + }); +}; + +export const getVariables = (message: string, variables: any) => { + const regex = /{{\d+}}/g; + const matches = message.match(regex); + + if (!matches) { + return []; + } + + return matches.map((match, index) => (variables[index]?.text ? variables[index] : { text: '', id: index + 1 })); +}; + +export const getExampleValue = (example: string) => { + const regex = /\[([^\]]+)\]/g; + let match; + const variables = []; + let id = 1; + + while ((match = regex.exec(example)) !== null) { + variables.push({ text: match[1], id }); + id++; + } + + return variables; +}; diff --git a/src/containers/HSM/HSM.module.css b/src/containers/HSM/HSM.module.css new file mode 100644 index 000000000..2872755ec --- /dev/null +++ b/src/containers/HSM/HSM.module.css @@ -0,0 +1,102 @@ +.Template { + margin: 20px auto; + width: 80%; + text-align: center; + box-shadow: 0 2px 3px #cccccc; + border: 1px solid #eeeeee; + padding: 10px; + box-sizing: border-box; +} + +@media (min-width: 600px) { + .Template { + width: 500px; + } +} + +.DeleteIcon { + margin-right: 9px !important; +} + +.DialogText { + margin-top: 0px; + text-align: center; + color: #073f24; + font-weight: 400; +} + +.DeleteButton { + margin-left: auto !important; +} + +.Title { + margin-left: 24px !important; + margin-top: 16px !important; + vertical-align: middle; + font-weight: 500 !important; + color: #073f24; +} + +.Input { + display: flex; + padding: 8px; +} + +.Label { + width: 50%; + align-self: center; + font-weight: bold; +} + +.TemplateAdd { + width: fit-content; +} + +.Form { + padding: 16px 16px; + width: 470px; +} + +.Buttons { + margin-top: 24px; + margin-left: 8px; + display: flex; + justify-content: flex-start; +} + +.Icon { + background-color: #eaedec !important; + margin-right: 10px !important; +} + +.ButtonsCenter { + justify-content: center !important; +} + +.Button { + margin-right: 24px !important; +} + +.Warning { + color: #ff0000; + margin-left: -43px; +} + +.IsActive { + color: #555555; + font-weight: 400; + line-height: 18px; + font-size: 16px; +} + +.TemplateIcon { + width: 29px; + height: 29px; +} + +.Checkbox { + color: #555555; + font-weight: 400; + line-height: 18px; + font-size: 16px; +} diff --git a/src/containers/Template/Form/HSM/HSM.test.tsx b/src/containers/HSM/HSM.test.tsx similarity index 89% rename from src/containers/Template/Form/HSM/HSM.test.tsx rename to src/containers/HSM/HSM.test.tsx index aeddd9fbb..d926a138b 100644 --- a/src/containers/Template/Form/HSM/HSM.test.tsx +++ b/src/containers/HSM/HSM.test.tsx @@ -1,18 +1,25 @@ -import { render, waitFor, within, fireEvent, screen } from '@testing-library/react'; +import { render, waitFor, within, fireEvent, screen, cleanup } from '@testing-library/react'; import { MockedProvider } from '@apollo/client/testing'; import userEvent from '@testing-library/user-event'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { HSM } from './HSM'; -import { - TEMPLATE_MOCKS, - getHSMTemplateTypeMedia, - getHSMTemplateTypeText, -} from 'containers/Template/Template.test.helper'; +import { HSM_TEMPLATE_MOCKS, getHSMTemplateTypeMedia, getHSMTemplateTypeText } from 'mocks/Template'; +import { setNotification } from 'common/notification'; -const mocks = TEMPLATE_MOCKS; +const mocks = HSM_TEMPLATE_MOCKS; + +vi.mock('common/notification', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + setNotification: vi.fn((...args) => { + return args[1]; + }), + }; +}); beforeEach(() => { - vi.restoreAllMocks(); + cleanup(); }); vi.mock('lexical-beautiful-mentions', async (importOriginal) => { @@ -80,6 +87,7 @@ describe('Add mode', () => { ); + const user = userEvent.setup(); test('check for validations for the HSM form', async () => { @@ -120,7 +128,6 @@ describe('Add mode', () => { const inputs = screen.getAllByRole('textbox'); fireEvent.change(inputs[0], { target: { value: 'element_name' } }); - fireEvent.change(inputs[1], { target: { value: 'element_name' } }); const lexicalEditor = inputs[2]; await user.click(lexicalEditor); @@ -144,11 +151,19 @@ describe('Add mode', () => { await waitFor(() => { expect(screen.getByText('Hi, How are you {{1}}')).toBeInTheDocument(); }); - fireEvent.change(inputs[1], { target: { value: 'element_name' } }); fireEvent.change(screen.getByPlaceholderText('Define value'), { target: { value: 'User' } }); + autocompletes[3].focus(); + fireEvent.keyDown(autocompletes[3], { key: 'ArrowDown' }); + fireEvent.click(screen.getByText('Messages'), { key: 'Enter' }); + fireEvent.change(inputs[1], { target: { value: 'title' } }); + fireEvent.click(screen.getByTestId('submitActionButton')); + + await waitFor(() => { + expect(setNotification).toHaveBeenCalled(); + }); }); test('it should add and remove variables', async () => { @@ -176,6 +191,10 @@ describe('Add mode', () => { }); fireEvent.click(screen.getAllByTestId('delete-variable')[0]); + + await waitFor(() => { + expect(screen.queryByPlaceholderText('Define value ')).not.toBeInTheDocument(); + }); }); test('it adds quick reply buttons', async () => { @@ -224,7 +243,10 @@ describe('Add mode', () => { fireEvent.click(screen.getByText('ACCOUNT_UPDATE'), { key: 'Enter' }); fireEvent.click(screen.getByTestId('submitActionButton')); - fireEvent.click(screen.getByTestId('submitActionButton')); + + await waitFor(() => { + expect(setNotification).toHaveBeenCalled(); + }); }); test('it adds call to action buttons', async () => { @@ -272,7 +294,10 @@ describe('Add mode', () => { fireEvent.click(screen.getByText('ACCOUNT_UPDATE'), { key: 'Enter' }); fireEvent.click(screen.getByTestId('submitActionButton')); - fireEvent.click(screen.getByTestId('submitActionButton')); + + await waitFor(() => { + expect(setNotification).toHaveBeenCalled(); + }); }); test('adding attachments', async () => { diff --git a/src/containers/HSM/HSM.tsx b/src/containers/HSM/HSM.tsx new file mode 100644 index 000000000..243d72301 --- /dev/null +++ b/src/containers/HSM/HSM.tsx @@ -0,0 +1,730 @@ +import { useMutation, useQuery } from '@apollo/client'; +import { Typography } from '@mui/material'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useLocation, useParams } from 'react-router'; +import * as Yup from 'yup'; + +import TemplateIcon from 'assets/images/icons/Template/UnselectedDark.svg?react'; + +import { CALL_TO_ACTION, QUICK_REPLY } from 'common/constants'; +import { validateMedia } from 'common/utils'; +import { AutoComplete } from 'components/UI/Form/AutoComplete/AutoComplete'; +import { Checkbox } from 'components/UI/Form/Checkbox/Checkbox'; +import { CreateAutoComplete } from 'components/UI/Form/CreateAutoComplete/CreateAutoComplete'; +import { EmojiInput } from 'components/UI/Form/EmojiInput/EmojiInput'; +import { Input } from 'components/UI/Form/Input/Input'; +import { Loading } from 'components/UI/Layout/Loading/Loading'; +import Simulator from 'components/simulator/Simulator'; +import { FormLayout } from 'containers/Form/FormLayout'; +import { TemplateOptions } from 'containers/TemplateOptions/TemplateOptions'; + +import { USER_LANGUAGES } from 'graphql/queries/Organization'; +import { GET_TAGS } from 'graphql/queries/Tags'; +import { GET_HSM_CATEGORIES, GET_SHORTCODES, GET_TEMPLATE } from 'graphql/queries/Template'; +import { CREATE_MEDIA_MESSAGE } from 'graphql/mutations/Chat'; +import { CREATE_TEMPLATE, DELETE_TEMPLATE, UPDATE_TEMPLATE } from 'graphql/mutations/Template'; + +import { TemplateVariables } from './TemplateVariables/TemplateVariables'; +import styles from './HSM.module.css'; +import { + convertButtonsToTemplate, + getExampleFromBody, + getVariables, + getExampleValue, + getTemplateAndButtons, + mediaOptions, + removeFirstLineBreak, + CallToActionTemplate, + QuickReplyTemplate, +} from './HSM.helper'; + +const queries = { + getItemQuery: GET_TEMPLATE, + createItemQuery: CREATE_TEMPLATE, + updateItemQuery: UPDATE_TEMPLATE, + deleteItemQuery: DELETE_TEMPLATE, +}; + +const templateIcon = ; +const regexForShortcode = /^[a-z0-9_]+$/g; +const dialogMessage = ' It will stop showing when you are drafting a customized message.'; + +export const HSM = () => { + const [language, setLanguageId] = useState(null); + const [label, setLabel] = useState(''); + const [body, setBody] = useState(''); + const [type, setType] = useState(null); + const [attachmentURL, setAttachmentURL] = useState(''); + const [isActive, setIsActive] = useState(true); + const [category, setCategory] = useState([]); + const [tagId, setTagId] = useState(null); + const [variables, setVariables] = useState([]); + const [editorState, setEditorState] = useState(''); + const [templateButtons, setTemplateButtons] = useState>([]); + const [isAddButtonChecked, setIsAddButtonChecked] = useState(false); + const [languageVariant, setLanguageVariant] = useState(false); + const [allowTemplateCategoryChange, setAllowTemplateCategoryChange] = useState(true); + const [existingShortcode, setExistingShortcode] = useState(''); + const [newShortcode, setNewShortcode] = useState(''); + const [languageOptions, setLanguageOptions] = useState([]); + const [validatingURL, setValidatingURL] = useState(false); + const [isUrlValid, setIsUrlValid] = useState(); + const [templateType, setTemplateType] = useState(null); + const [sampleMessages, setSampleMessages] = useState({ + type: 'TEXT', + location: null, + media: {}, + body: '', + }); + const { t } = useTranslation(); + const location: any = useLocation(); + const params = useParams(); + let timer: any = null; + + const { data: categoryList, loading: categoryLoading } = useQuery(GET_HSM_CATEGORIES); + const { data: shortCodes, loading: shortcodesLoading } = useQuery(GET_SHORTCODES, { + variables: { + filter: { + isHsm: true, + }, + }, + }); + const { data: tag, loading: tagLoading } = useQuery(GET_TAGS, { + variables: {}, + fetchPolicy: 'network-only', + }); + + const { data: languages, loading: languageLoading } = useQuery(USER_LANGUAGES, { + variables: { opts: { order: 'ASC' } }, + }); + const [createMediaMessage] = useMutation(CREATE_MEDIA_MESSAGE); + + let isEditing = false; + let mode; + const copyMessage = t('Copy of the template has been created!'); + + const isCopyState = location.state === 'copy'; + if (isCopyState) { + queries.updateItemQuery = CREATE_TEMPLATE; + mode = 'copy'; + } else { + queries.updateItemQuery = UPDATE_TEMPLATE; + } + if (params.id && !isCopyState) { + isEditing = true; + } + + const shortCodeOptions: any = []; + if (shortCodes) { + shortCodes.sessionTemplates.forEach((value: any, index: number) => { + shortCodeOptions.push({ label: value?.shortcode, id: index }); + }); + } + + const categoryOpn: any = []; + if (categoryList) { + categoryList.whatsappHsmCategories.forEach((categories: any, index: number) => { + categoryOpn.push({ label: categories, id: index }); + }); + } + + const states = { + language, + label, + body, + type, + attachmentURL, + category, + tagId, + isActive, + templateButtons, + isAddButtonChecked, + languageVariant, + variables, + newShortcode, + existingShortcode, + allowTemplateCategoryChange, + }; + + const getLanguageId = (value: any) => { + let result: any; + const selected = languageOptions.find((option: any) => option.label === value); + result = selected; + + if (result) setLanguageId(result); + }; + + // Creating payload for button template + const getButtonTemplatePayload = () => { + const buttons = templateButtons.reduce((result: any, button) => { + const { type: buttonType, value, title }: any = button; + if (templateType === CALL_TO_ACTION) { + const typeObj: any = { + phone_number: 'PHONE_NUMBER', + url: 'URL', + }; + const obj: any = { type: typeObj[buttonType], text: title, [buttonType]: value }; + result.push(obj); + } + + if (templateType === QUICK_REPLY) { + const obj: any = { type: QUICK_REPLY, text: value }; + result.push(obj); + } + return result; + }, []); + + // get template body + const templateBody = getTemplateAndButton(body); + const templateExample = getTemplateAndButton(getExampleFromBody(body, variables)); + + return { + hasButtons: true, + buttons: JSON.stringify(buttons), + buttonType: templateType, + body: templateBody.message, + example: templateExample.message, + }; + }; + + const setStates = ({ + isActive: isActiveValue, + language: languageIdValue, + label: labelValue, + body: bodyValue, + example: exampleValue, + type: typeValue, + MessageMedia: MessageMediaValue, + shortcode: shortcodeValue, + category: categoryValue, + tag: tagIdValue, + buttonType: templateButtonType, + buttons, + hasButtons, + allowTemplateCategoryChange: allowCategoryChangeValue, + }: any) => { + let variables: any = []; + + if (languageOptions.length > 0 && languageIdValue) { + if (!language?.id) { + const selectedLangauge = languageOptions.find((lang: any) => lang.id === languageIdValue.id); + setLanguageId(selectedLangauge); + } else { + setLanguageId(language); + } + } + + if (isCopyState) { + setIsAddButtonChecked(hasButtons); + setTemplateType(templateButtonType); + setSimulatorMessage(''); + setEditorState(''); + setBody(''); + setLabel(''); + } else { + setBody(bodyValue); + setLabel(labelValue); + setEditorState(bodyValue); + setIsActive(isActiveValue); + setCategory(categoryValue); + setTagId(tagIdValue); + setAllowTemplateCategoryChange(allowCategoryChangeValue); + variables = getExampleValue(exampleValue); + setVariables(variables); + addButtonsToSampleMessage(getExampleFromBody(bodyValue, variables)); + setNewShortcode(shortcodeValue); + + if (hasButtons) { + const { buttons: buttonsVal } = getTemplateAndButtons(templateButtonType, exampleValue, buttons); + setTemplateButtons(buttonsVal); + setTemplateType(templateButtonType); + setIsAddButtonChecked(hasButtons); + const parse = convertButtonsToTemplate(buttonsVal, templateButtonType); + const parsedText = parse.length ? `| ${parse.join(' | ')}` : null; + const { message }: any = getTemplateAndButton(getExampleFromBody(bodyValue, variables)); + const sampleText: any = parsedText && message + parsedText; + setSimulatorMessage(sampleText); + } else { + setSimulatorMessage(getExampleFromBody(bodyValue, variables)); + } + + if (typeValue && typeValue !== 'TEXT') { + setType({ id: typeValue, label: typeValue }); + } else { + setType(null); + } + + if (MessageMediaValue) { + setAttachmentURL(MessageMediaValue.sourceUrl); + } else { + setAttachmentURL(''); + } + } + }; + + const setPayload = (payload: any) => { + let payloadCopy = { ...payload, isHsm: true }; + if (isEditing) { + payloadCopy.shortcode = payloadCopy.newShortcode; + } else { + payloadCopy.category = category.label; + payloadCopy.shortcode = languageVariant ? payloadCopy.existingShortcode.label : payloadCopy.newShortcode; + } + payloadCopy.languageId = payload.language.id; + payloadCopy.example = getExampleFromBody(payloadCopy.body, variables); + if (isAddButtonChecked && templateType) { + const templateButtonData = getButtonTemplatePayload(); + Object.assign(payloadCopy, { ...templateButtonData }); + } + if (payloadCopy.type) { + payloadCopy.type = payloadCopy.type.id; + // STICKER is a type of IMAGE + if (payloadCopy.type.id === 'STICKER') { + payloadCopy.type = 'IMAGE'; + } + } else { + payloadCopy.type = 'TEXT'; + } + + if (tagId) { + payloadCopy.tagId = payload.tagId.id; + } + + delete payloadCopy.isAddButtonChecked; + delete payloadCopy.templateButtons; + delete payloadCopy.language; + delete payloadCopy.languageVariant; + delete payloadCopy.variables; + delete payloadCopy.existingShortcode; + delete payloadCopy.newShortcode; + delete payloadCopy.attachmentURL; + return payloadCopy; + }; + + const validateURL = (value: string) => { + if (value && type) { + setValidatingURL(true); + validateMedia(value, type.id, false).then((response: any) => { + if (!response.data.is_valid) { + setIsUrlValid(response.data.message); + } else { + setIsUrlValid(''); + } + setValidatingURL(false); + }); + } + }; + + const addTemplateButtons = (addFromTemplate: boolean = true) => { + let buttons: any = []; + const buttonType: any = { + QUICK_REPLY: { value: '' }, + CALL_TO_ACTION: { type: '', title: '', value: '' }, + }; + + if (templateType) { + buttons = addFromTemplate ? [...templateButtons, buttonType[templateType]] : [buttonType[templateType]]; + } + + setTemplateButtons(buttons); + }; + + const removeTemplateButtons = (index: number) => { + const result = templateButtons.filter((val, idx) => idx !== index); + setTemplateButtons(result); + }; + + const getTemplateAndButton = (text: string) => { + const exp = /(\|\s\[)|(\|\[)/; + const areButtonsPresent = text.search(exp); + + let message: any = text; + let buttons: any = null; + + if (areButtonsPresent !== -1) { + buttons = text.substr(areButtonsPresent); + message = text.substr(0, areButtonsPresent); + } + + return { message, buttons }; + }; + + const addButtonsToSampleMessage = (buttonTemplate: string) => { + const message: any = { ...sampleMessages }; + message.body = buttonTemplate; + setSampleMessages(message); + }; + + const handeInputChange = (event: any, row: any, index: any, eventType: any) => { + const { value } = event.target; + const obj = { ...row }; + obj[eventType] = value; + + const result = templateButtons.map((val: any, idx: number) => { + if (idx === index) return obj; + return val; + }); + + setTemplateButtons(result); + }; + + const getMediaId = async (payload: any) => { + const data = await createMediaMessage({ + variables: { + input: { + caption: payload.body, + sourceUrl: payload.attachmentURL, + url: payload.attachmentURL, + }, + }, + }); + return data; + }; + + const setSimulatorMessage = (messages: any) => { + const message = removeFirstLineBreak(messages); + const mediaBody: any = { ...sampleMessages.media }; + let typeValue; + let text = message; + + mediaBody.caption = getExampleFromBody(body, variables); + mediaBody.url = attachmentURL; + typeValue = type?.id || 'TEXT'; + + setSampleMessages({ ...sampleMessages, body: text, media: mediaBody, type: typeValue }); + }; + + const fields = [ + { + component: AutoComplete, + name: 'language', + options: languageOptions, + optionLabel: 'label', + multiple: false, + label: `${t('Language')}*`, + disabled: isEditing, + onChange: getLanguageId, + }, + { + component: Checkbox, + name: 'languageVariant', + title: ( + + Translate existing HSM? + + ), + handleChange: (value: any) => setLanguageVariant(value), + skip: isEditing, + }, + { + component: Input, + name: 'newShortcode', + placeholder: `${t('Element name')}*`, + label: `${t('Element name')}*`, + disabled: isEditing, + skip: languageVariant ? true : false, + onChange: (value: any) => { + setNewShortcode(value); + }, + }, + { + component: AutoComplete, + name: 'existingShortcode', + options: shortCodeOptions, + optionLabel: 'label', + multiple: false, + label: `${t('Element name')}*`, + placeholder: `${t('Element name')}*`, + disabled: isEditing, + onChange: (event: any) => { + setExistingShortcode(event); + }, + skip: languageVariant ? false : true, + }, + { + component: Input, + name: 'label', + label: t('Title'), + disabled: isEditing, + helperText: t('Define what use case does this template serve eg. OTP, optin, activity preference'), + inputProp: { + onBlur: (event: any) => setLabel(event.target.value), + }, + }, + { + component: Checkbox, + name: 'isActive', + title: ( + + Active? + + ), + darkCheckbox: true, + }, + { + component: EmojiInput, + name: 'body', + label: t('Message'), + rows: 5, + convertToWhatsApp: true, + textArea: true, + disabled: isEditing, + helperText: + 'You can provide variable values in your HSM templates to personalize the message. To add: click on the variable button and provide an example value for the variable in the field provided below', + handleChange: (value: any) => { + setBody(value); + }, + defaultValue: isEditing && editorState, + }, + { + component: TemplateVariables, + message: body, + variables: variables, + setVariables: setVariables, + isEditing: isEditing, + }, + { + component: Checkbox, + title: ( + + Add buttons + + ), + name: 'isAddButtonChecked', + disabled: !!(isEditing && !isCopyState), + handleChange: (value: boolean) => setIsAddButtonChecked(value), + }, + { + component: TemplateOptions, + isAddButtonChecked, + templateType, + inputFields: templateButtons, + disabled: isEditing, + onAddClick: addTemplateButtons, + onRemoveClick: removeTemplateButtons, + onInputChange: handeInputChange, + onTemplateTypeChange: (value: string) => setTemplateType(value), + }, + { + component: AutoComplete, + name: 'category', + options: categoryOpn, + optionLabel: 'label', + multiple: false, + label: `${t('Category')}*`, + placeholder: `${t('Category')}*`, + disabled: isEditing, + helperText: t('Select the most relevant category'), + onChange: (event: any) => { + setCategory(event); + }, + skip: isEditing, + }, + { + component: Input, + name: 'category', + type: 'text', + label: `${t('Category')}*`, + placeholder: `${t('Category')}*`, + disabled: isEditing, + helperText: t('Select the most relevant category'), + skip: !isEditing, + }, + { + component: Checkbox, + name: 'allowTemplateCategoryChange', + title: ( + + Allow meta to re-categorize template? + + ), + darkCheckbox: true, + disabled: isEditing, + handleChange: (value: boolean) => setAllowTemplateCategoryChange(value), + }, + { + component: AutoComplete, + name: 'type', + options: mediaOptions, + optionLabel: 'label', + multiple: false, + label: t('Attachment Type'), + disabled: isEditing, + onChange: (event: any) => { + const val = event; + if (!event) { + setIsUrlValid(val); + } + setType(val); + }, + }, + { + component: Input, + name: 'attachmentURL', + type: 'text', + label: t('Attachment URL'), + validate: () => isUrlValid, + disabled: isEditing, + helperText: t( + '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); + }, + onChange: (event: any) => { + clearTimeout(timer); + timer = setTimeout(() => setAttachmentURL(event.target.value), 1000); + }, + }, + }, + { + component: CreateAutoComplete, + name: 'tagId', + options: tag ? tag.tags : [], + optionLabel: 'label', + disabled: isEditing, + hasCreateOption: true, + multiple: false, + onChange: (value: any) => { + setTagId(value); + }, + label: t('Tag'), + helperText: t('Use this to categorize your templates.'), + }, + ]; + + const validation: any = { + language: Yup.object().nullable().required('Language is required.'), + label: Yup.string().required(t('Title is required.')).max(50, t('Title length is too long.')), + type: Yup.object() + .nullable() + .when('attachmentURL', { + is: (val: string) => val && val !== '', + then: (schema) => schema.nullable().required(t('Type is required.')), + }), + attachmentURL: Yup.string() + .nullable() + .when('type', { + is: (val: any) => val && val.id, + then: (schema) => schema.required(t('Attachment URL is required.')), + }), + body: Yup.string().required(t('Message is required.')).max(1024, 'Maximum 1024 characters are allowed'), + category: Yup.object().nullable().required(t('Category is required.')), + variables: Yup.array().of( + Yup.object().shape({ + text: Yup.string().required('Variable is required').min(1, 'Text cannot be empty'), + }) + ), + newShortcode: Yup.string().when('languageVariant', { + is: (val: any) => val === true, + then: (schema) => schema.nullable(), + otherwise: (schema) => + schema + .required(t('Element name is required.')) + .matches(regexForShortcode, 'Only lowercase alphanumeric characters and underscores are allowed.'), + }), + existingShortcode: Yup.object().when('languageVariant', { + is: (val: any) => val === true, + then: (schema) => schema.nullable().required(t('Element name is required.')), + otherwise: (schema) => schema.nullable(), + }), + }; + + const FormSchema = Yup.object().shape(validation, [['type', 'attachmentURL']]); + + useEffect(() => { + if (languages) { + const lang = languages.currentUser.user.organization.activeLanguages.slice(); + // sort languages by thaeir name + lang.sort((first: any, second: any) => (first.label > second.label ? 1 : -1)); + + setLanguageOptions(lang); + if (!isEditing) setLanguageId(lang[0]); + } + }, [languages]); + + useEffect(() => { + setSimulatorMessage(getExampleFromBody(body, variables)); + + if ((type === '' || type) && attachmentURL) { + validateURL(attachmentURL); + } + }, [type, attachmentURL]); + + useEffect(() => { + if (templateType && !isEditing) { + addTemplateButtons(false); + } + }, [templateType]); + + useEffect(() => { + if (!isEditing) { + const { message }: any = getTemplateAndButton(getExampleFromBody(body, variables)); + setSimulatorMessage(message || ''); + } + }, [isAddButtonChecked]); + + useEffect(() => { + if (templateButtons.length > 0 && !isEditing) { + const parse = convertButtonsToTemplate(templateButtons, templateType); + + const parsedText = parse.length ? `| ${parse.join(' | ')}` : null; + + const { message }: any = getTemplateAndButton(getExampleFromBody(body, variables)); + + const sampleText: any = parsedText && message + parsedText; + if (sampleText) { + setSimulatorMessage(sampleText); + } + } + }, [templateButtons]); + + useEffect(() => { + if (!isEditing) { + setSimulatorMessage(getExampleFromBody(body, variables)); + } + }, [body, variables]); + + useEffect(() => { + setVariables(getVariables(body, variables)); + }, [body]); + + if (languageLoading || categoryLoading || tagLoading || shortcodesLoading) { + return ; + } + + return ( + <> + + + + ); +}; + +export default HSM; diff --git a/src/containers/HSM/HSMList/HSMList.module.css b/src/containers/HSM/HSMList/HSMList.module.css new file mode 100644 index 000000000..0468b6bcf --- /dev/null +++ b/src/containers/HSM/HSMList/HSMList.module.css @@ -0,0 +1,180 @@ +.Name { + width: 25%; + min-width: 200px; +} + +.Label { + width: 200px; +} + +.Body { + width: 36%; + min-width: 200px; + word-break: break-word; +} + +.Reason { + width: 20%; + margin-right: 10px; + margin-left: 10px; + word-break: break-word; +} + +.Actions { + width: 19%; + min-width: 200px; + text-align: end; +} + +.LabelContainer { + display: flex; + flex-direction: column; + row-gap: 0.5rem; +} + +.LabelText { + font-weight: 500; + font-size: 17px; + word-break: break-all; +} + +.Quality { + font-size: 12px; + width: fit-content; + padding: 2px 6px; + background: #f0f7f1; + color: #555555; + border-radius: 8px; +} + +.TableText { + text-align: left; + font-size: 14px; + color: #93a29b; + white-space: pre-line; + margin: 0; + padding: 0; +} + +.Status { + text-align: center; +} + +.AlignCenter { + display: flex; + align-items: center; + justify-content: center; +} + +.Status svg { + margin-right: 8px; +} + +.LastModified { + width: 20%; + min-width: 185px; + color: #93a29b; +} + +.Status { + width: 150px; +} + +.Filters { + margin-left: 24px; +} + +.FilterLabel { + color: #073f24 !important; + font-size: 16px; + font-weight: 500; + line-height: 1; +} + +.Input { + display: inline; +} + +.HelperText { + position: absolute; + color: #2ea36a; + font-weight: 400; + font-size: 10px; + bottom: -20px; + right: 10px; + text-decoration: none !important; + padding: 0 12px; +} + +.ImportButton { + position: relative; + display: flex; + flex-direction: row; + align-items: center; +} + +.Uuid { + display: flex; + align-items: center; + cursor: pointer; + font-size: 12px; + color: #93a29b !important; +} + +.Copy { + margin-left: 2px; + font-size: 12px; +} + +.DropDown { + border-radius: 24px; + border: 1px solid #cccccc; + display: flex; + width: 130px; + margin: auto; + height: 36px !important; + background: #ffffff; + min-height: 36px !important; +} + +.DropDown>fieldset { + border: none !important; +} + +.FormStyle { + width: 150px !important; +} + +.Category { + width: 20%; + min-width: 100px; +} + +.TemplateIcon { + width: 29px; + height: 29px; +} + +.HsmUpdates { + margin-left: 8px; + cursor: pointer; + height: 36px; + border: 1px solid #cccccc; + background: #ffffff; + box-shadow: none; + font-size: 14px; + color: #2ea36a; +} + +.FilterContainer { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.SecondaryButton { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0 0.5rem; +} \ No newline at end of file diff --git a/src/containers/Template/List/HSMList/HSMList.test.tsx b/src/containers/HSM/HSMList/HSMList.test.tsx similarity index 90% rename from src/containers/Template/List/HSMList/HSMList.test.tsx rename to src/containers/HSM/HSMList/HSMList.test.tsx index b7116f167..b408cbef9 100644 --- a/src/containers/Template/List/HSMList/HSMList.test.tsx +++ b/src/containers/HSM/HSMList/HSMList.test.tsx @@ -2,9 +2,8 @@ import { render, waitFor } from '@testing-library/react'; import { BrowserRouter as Router } from 'react-router-dom'; import { MockedProvider } from '@apollo/client/testing'; -import { HSM_LIST, TEMPLATE_MOCKS } from 'containers/Template/Template.test.helper'; +import { HSM_LIST } from 'mocks/Template'; import { HSMList } from './HSMList'; -import { hsmTemplatesCountQuery } from 'mocks/Template'; import userEvent from '@testing-library/user-event'; import { SYNC_HSM_TEMPLATES } from 'graphql/mutations/Template'; import { setNotification } from 'common/notification'; @@ -53,15 +52,7 @@ export const syncTemplateQueryFailedQuery = { }; // Todo: multiple calls are made here. We need to refactor this code -const mocks = [ - ...TEMPLATE_MOCKS, - ...TEMPLATE_MOCKS, - hsmTemplatesCountQuery, - ...HSM_LIST, - ...HSM_LIST, - ...HSM_LIST, - ...HSM_LIST, -]; +const mocks = [...HSM_LIST, ...HSM_LIST, ...HSM_LIST, ...HSM_LIST]; const template = (mockQuery: any) => ( diff --git a/src/containers/Template/List/Template.tsx b/src/containers/HSM/HSMList/HSMList.tsx similarity index 60% rename from src/containers/Template/List/Template.tsx rename to src/containers/HSM/HSMList/HSMList.tsx index 6edbd0b41..a0b0c238a 100644 --- a/src/containers/Template/List/Template.tsx +++ b/src/containers/HSM/HSMList/HSMList.tsx @@ -1,40 +1,58 @@ -import { useContext, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useMutation, useQuery } from '@apollo/client'; +import { FormControl, MenuItem, Select } from '@mui/material'; import dayjs from 'dayjs'; +import { useContext, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { FormControl, MenuItem, Select } from '@mui/material'; -import { useMutation, useQuery } from '@apollo/client'; +import { useNavigate } from 'react-router'; -import { List } from 'containers/List/List'; -import { WhatsAppToJsx } from 'common/RichEditor'; -import { STANDARD_DATE_TIME_FORMAT, GUPSHUP_ENTERPRISE_SHORTCODE } from 'common/constants'; -import { capitalizeFirstLetter } from 'common/utils'; -import { GET_TEMPLATES_COUNT, FILTER_TEMPLATES, FILTER_SESSION_TEMPLATES } from 'graphql/queries/Template'; -import { BULK_APPLY_TEMPLATES, DELETE_TEMPLATE, IMPORT_TEMPLATES } from 'graphql/mutations/Template'; -import { GET_TAGS } from 'graphql/queries/Tags'; -import { ImportButton } from 'components/UI/ImportButton/ImportButton'; -import DownArrow from 'assets/images/icons/DownArrow.svg?react'; +import TemplateIcon from 'assets/images/icons/Template/UnselectedDark.svg?react'; +import DuplicateIcon from 'assets/images/icons/Duplicate.svg?react'; +import CopyAllOutlined from 'assets/images/icons/Flow/Copy.svg?react'; import ApprovedIcon from 'assets/images/icons/Template/Approved.svg?react'; import RejectedIcon from 'assets/images/icons/Template/Rejected.svg?react'; import ReportIcon from 'assets/images/icons/Template/Report.svg?react'; import PendingIcon from 'assets/images/icons/Template/Pending.svg?react'; -import DuplicateIcon from 'assets/images/icons/Duplicate.svg?react'; -import CopyAllOutlined from 'assets/images/icons/Flow/Copy.svg?react'; -import { ProviderContext } from 'context/session'; -import { copyToClipboardMethod, exportCsvFile, getFileExtension } from 'common/utils'; + +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 { templateInfo, templateStatusInfo } from 'common/HelpData'; +import { setNotification } from 'common/notification'; +import { WhatsAppToJsx } from 'common/RichEditor'; +import { capitalizeFirstLetter, copyToClipboardMethod, exportCsvFile, getFileExtension } from 'common/utils'; + import { AutoComplete } from 'components/UI/Form/AutoComplete/AutoComplete'; +import { Button } from 'components/UI/Form/Button/Button'; +import { ImportButton } from 'components/UI/ImportButton/ImportButton'; import HelpIcon from 'components/UI/HelpIcon/HelpIcon'; import { Loading } from 'components/UI/Layout/Loading/Loading'; -import { setNotification } from 'common/notification'; -import { BULK_APPLY_SAMPLE_LINK } from 'config'; -import { speedSendInfo, templateInfo, templateStatusInfo } from 'common/HelpData'; -import styles from './Template.module.css'; -import { RaiseToGupShup } from './RaiseToGupshupDialog/RaiseToGupShup'; -const getLabel = (label: string, quality?: string, isHsm?: boolean) => ( +import { GET_TAGS } from 'graphql/queries/Tags'; +import { + BULK_APPLY_TEMPLATES, + DELETE_TEMPLATE, + IMPORT_TEMPLATES, + 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'; + +const templateIcon = ; + +const statusFilter = { + APPROVED: false, + PENDING: false, + REJECTED: false, + FAILED: false, +}; + +const getLabel = (label: string, quality?: string) => (
{label}
- {isHsm &&
{quality && quality !== 'UNKNOWN' ? quality : 'Not Rated'}
} +
{quality && quality !== 'UNKNOWN' ? quality : 'Not Rated'}
); @@ -42,80 +60,32 @@ const getBody = (text: string) =>

{WhatsAppToJsx const getReason = (reason: string) =>

{reason}

; -const getUpdatedAt = (date: string) => ( -
{dayjs(date).format(STANDARD_DATE_TIME_FORMAT)}
-); - -const getTranslations = (language: any, data: string) => { - const dataObj = JSON.parse(data); - if (Object.prototype.hasOwnProperty.call(dataObj, language.id)) { - delete dataObj[language.id]; - } - return JSON.stringify(dataObj); -}; - const getCategory = (category: string) => { // let's make category more user friendly let categoryName = category.split('_').join(' ').toLowerCase(); return

{capitalizeFirstLetter(categoryName)}

; }; -export interface TemplateProps { - title: string; - listItem: string; - listItemName: string; - pageLink: string; - listIcon: any; - filters: any; - isHSM?: boolean; - loading?: boolean; - syncHSMButton?: any; -} +const getUpdatedAt = (date: string) => ( +
{dayjs(date).format(STANDARD_DATE_TIME_FORMAT)}
+); -const statusFilter = { - APPROVED: false, - PENDING: false, - REJECTED: false, +const queries = { + countQuery: GET_TEMPLATES_COUNT, + filterItemsQuery: FILTER_TEMPLATES, + deleteItemQuery: DELETE_TEMPLATE, }; -export const Template = ({ - title, - listItem, - listItemName, - pageLink, - listIcon, - filters: templateFilters, - isHSM, - loading = false, - syncHSMButton, -}: TemplateProps) => { - const [open, setOpen] = useState(false); - const [Id, setId] = useState(''); - const { t } = useTranslation(); - const navigate = useNavigate(); - - const { provider } = useContext(ProviderContext); - const [selectedTag, setSelectedTag] = useState(null); +export const HSMList = () => { const [importing, setImporting] = useState(false); - - const [filters, setFilters] = useState({ ...statusFilter, APPROVED: true }); - const [raiseToGupshupTemplate, setRaiseToGupshupTemplate] = useState(null); + const [filters, setFilters] = useState({ ...statusFilter, APPROVED: true }); + const [selectedTag, setSelectedTag] = useState(null); + const [syncTemplateLoad, setSyncTemplateLoad] = useState(false); - const { data: tag } = useQuery(GET_TAGS, { - variables: {}, - fetchPolicy: 'network-only', - }); - - 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 { provider } = useContext(ProviderContext); + const { t } = useTranslation(); + const navigate = useNavigate(); const [bulkApplyTemplates] = useMutation(BULK_APPLY_TEMPLATES, { onCompleted: (data: any) => { @@ -131,11 +101,36 @@ export const Template = ({ }, }); - const queries = { - countQuery: GET_TEMPLATES_COUNT, - filterItemsQuery: isHSM ? FILTER_TEMPLATES : FILTER_SESSION_TEMPLATES, - deleteItemQuery: DELETE_TEMPLATE, - }; + 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('HSMs updated successfully.'), '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 { data: tag } = useQuery(GET_TAGS, { + variables: {}, + fetchPolicy: 'network-only', + }); const getStatus = (status: string) => { let statusValue; @@ -179,66 +174,42 @@ export const Template = ({ statusValue = status; } - return {statusValue}; + return statusValue; }; const columnNames: any = [ { name: 'label', label: t('Title') }, { name: 'body', label: t('Body') }, + { name: 'category', label: t('Category') }, + { name: 'status', label: t('Status') }, ]; + const columnStyles: any = [styles.Name, styles.Body, styles.Category, styles.Status]; - if (isHSM) { - columnNames.push({ name: 'category', label: t('Category') }); - columnNames.push({ name: 'status', label: t('Status') }); - if (filters.REJECTED || filters.FAILED) { - columnNames.push({ label: t('Reason') }); - } + if (filters.REJECTED || filters.FAILED) { + columnNames.push({ label: t('Reason') }); + columnStyles.push(styles.Reason); } else { columnNames.push({ name: 'updated_at', label: t('Last modified') }); + columnStyles.push(styles.LastModified); } - columnNames.push({ label: t('Actions') }); + columnStyles.push(styles.Actions); - let columnStyles: any = [styles.Name, styles.Body]; - - columnStyles = isHSM - ? [ - ...columnStyles, - styles.Category, - styles.Status, - ...(filters.REJECTED || filters.FAILED ? [styles.Reason] : []), - styles.Actions, - ] - : [...columnStyles, styles.LastModified, styles.Actions]; - - const getColumns = ({ - id, - language, - label, - body, - updatedAt, - translations, - status, - reason, - quality, - category, - }: any) => { + const getColumns = ({ id, label, body, status, reason, quality, category, updatedAt }: any) => { const columns: any = { id, - label: getLabel(label, quality, isHSM), + label: getLabel(label, quality), body: getBody(body), + category: getCategory(category), + status: getStatus(status), }; - if (isHSM) { - columns.category = getCategory(category); - columns.status = getStatus(status); - if (filters.REJECTED || filters.FAILED) { - columns.reason = getReason(reason); - } + if (filters.REJECTED || filters.FAILED) { + columns.reason = getReason(reason); } else { columns.updatedAt = getUpdatedAt(updatedAt); - columns.translations = getTranslations(language, translations); } + return columns; }; @@ -248,6 +219,26 @@ export const Template = ({ columnStyles, }; + const handleHsmUpdates = () => { + setSyncTemplateLoad(true); + syncHsmTemplates(); + }; + + let filterValue: any = ''; + const statusList = ['Approved', 'Pending', 'Rejected', 'Failed']; + const defaultSortBy = 'STATUS'; + const button = { show: true, label: t('Create') }; + + const filterStatusName = Object.keys(filters).filter((status) => filters[status] === true); + if (filterStatusName.length === 1) { + [filterValue] = filterStatusName; + } + const appliedFilters = { isHsm: true, status: filterValue }; + + const setCopyDialog = (id: any) => { + navigate(`/template/${id}/edit`, { state: 'copy' }); + }; + const copyUuid = (_id: string, item: any) => { if (item.bspId) { copyToClipboardMethod(item.bspId); @@ -256,14 +247,6 @@ export const Template = ({ } }; - const setCopyDialog = (id: any) => { - let redirectPath = 'speed-send'; - if (isHSM) { - redirectPath = 'template'; - } - navigate(`/${redirectPath}/${id}/edit`, { state: 'copy' }); - }; - const showRaiseToGupShupDialog = (id: any, item: any) => { setRaiseToGupshupTemplate(item); }; @@ -272,48 +255,25 @@ export const Template = ({ setRaiseToGupshupTemplate(null); }; - const setDialog = (id: string) => { - if (Id !== id) { - setId(id); - setOpen(true); - } else { - setOpen(!open); - } - }; - - const copyAction = { - label: t('Copy'), - icon: , - parameter: 'id', - dialog: setCopyDialog, - insideMore: true, - }; - - let additionalAction: any = () => [ - { - label: t('Show all languages'), - icon: , - parameter: 'id', - dialog: setDialog, - }, - copyAction, - ]; - - let defaultSortBy; - - const dialogMessage = t('It will stop showing when you draft a customized message'); - - let filterValue: any = ''; - const statusList = ['Approved', 'Pending', 'Rejected', 'Failed']; - const handleCheckedBox = (event: any) => { setFilters({ ...statusFilter, [event.target.value.toUpperCase()]: true }); }; - const filterStatusName = Object.keys(filters).filter((status) => filters[status] === true); - if (filterStatusName.length === 1) { - [filterValue] = filterStatusName; - } + const syncHSMButton = ( + + ); + + const dialogMessage = t('It will stop showing when you draft a customized message'); const filterTemplateStatus = (
@@ -351,74 +311,29 @@ export const Template = ({
); - let appliedFilters = templateFilters; - - const raiseToGupshup = { - label: t('Report'), - icon: , - parameter: 'id', - dialog: showRaiseToGupShupDialog, - hidden: filterValue !== 'REJECTED', - insideMore: true, - }; - - if (isHSM) { - additionalAction = () => [ - { - label: t('Copy UUID'), - icon: , - parameter: 'id', - dialog: copyUuid, - }, - copyAction, - raiseToGupshup, - ]; - defaultSortBy = 'STATUS'; - appliedFilters = { ...templateFilters, status: filterValue }; - } - let dialogBox; - if (raiseToGupshupTemplate) { - dialogBox = ( - - ); - } - - if (importing) { - return ; - } - - const button = { show: true, label: t('Create') }; - let secondaryButton = null; - - if (isHSM) { - secondaryButton = ( -
- {syncHSMButton} -
- - 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 } }); - } - }} - /> -
+ let secondaryButton = ( +
+ {syncHSMButton} +
+ + 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 } }); + } + }} + />
- ); - } +
+ ); if (provider === GUPSHUP_ENTERPRISE_SHORTCODE) { secondaryButton = ( @@ -442,23 +357,57 @@ export const Template = ({ button.show = false; } - appliedFilters = { - ...appliedFilters, - ...(selectedTag?.id && { tagIds: [parseInt(selectedTag?.id)] }), - }; + let additionalAction: any = () => [ + { + label: t('Copy UUID'), + icon: , + parameter: 'id', + dialog: copyUuid, + }, + { + label: t('Copy'), + icon: , + parameter: 'id', + dialog: setCopyDialog, + insideMore: true, + }, + { + label: t('Report'), + icon: , + parameter: 'id', + dialog: showRaiseToGupShupDialog, + hidden: filterValue !== 'REJECTED', + insideMore: true, + }, + ]; + + let dialogBox; + if (raiseToGupshupTemplate) { + dialogBox = ( + + ); + } + + if (importing) { + return ; + } return ( <> {dialogBox} ); }; -export default Template; +export default HSMList; diff --git a/src/containers/Template/List/RaiseToGupshupDialog/RaiseToGupShup.test.tsx b/src/containers/HSM/RaiseToGupshupDialog/RaiseToGupShup.test.tsx similarity index 100% rename from src/containers/Template/List/RaiseToGupshupDialog/RaiseToGupShup.test.tsx rename to src/containers/HSM/RaiseToGupshupDialog/RaiseToGupShup.test.tsx diff --git a/src/containers/Template/List/RaiseToGupshupDialog/RaiseToGupShup.tsx b/src/containers/HSM/RaiseToGupshupDialog/RaiseToGupShup.tsx similarity index 100% rename from src/containers/Template/List/RaiseToGupshupDialog/RaiseToGupShup.tsx rename to src/containers/HSM/RaiseToGupshupDialog/RaiseToGupShup.tsx diff --git a/src/containers/Template/TemplateVariables/TemplateVariable.module.css b/src/containers/HSM/TemplateVariables/TemplateVariable.module.css similarity index 100% rename from src/containers/Template/TemplateVariables/TemplateVariable.module.css rename to src/containers/HSM/TemplateVariables/TemplateVariable.module.css diff --git a/src/containers/Template/TemplateVariables/TemplateVariables.tsx b/src/containers/HSM/TemplateVariables/TemplateVariables.tsx similarity index 100% rename from src/containers/Template/TemplateVariables/TemplateVariables.tsx rename to src/containers/HSM/TemplateVariables/TemplateVariables.tsx diff --git a/src/containers/Template/Form/Template.module.css b/src/containers/SpeedSend/SpeedSend.module.css similarity index 96% rename from src/containers/Template/Form/Template.module.css rename to src/containers/SpeedSend/SpeedSend.module.css index 3bfd4e2c0..44bb09d3a 100644 --- a/src/containers/Template/Form/Template.module.css +++ b/src/containers/SpeedSend/SpeedSend.module.css @@ -1,3 +1,8 @@ +.SpeedSendIcon { + width: 29px; + height: 29px; +} + .Template { margin: 20px auto; width: 80%; diff --git a/src/containers/SpeedSend/SpeedSend.test.tsx b/src/containers/SpeedSend/SpeedSend.test.tsx new file mode 100644 index 000000000..29c31f4b4 --- /dev/null +++ b/src/containers/SpeedSend/SpeedSend.test.tsx @@ -0,0 +1,295 @@ +import { MockedProvider } from '@apollo/client/testing'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes } from 'react-router'; + +import * as Notification from 'common/notification'; +import { LexicalWrapper } from 'common/LexicalWrapper'; +import * as utilsModule from 'common/utils'; +import { SPEED_SENDS_MOCKS } from 'mocks/Template'; +import SpeedSendList from './SpeedSendList/SpeedSendList'; +import SpeedSend from './SpeedSend'; +import axios from 'axios'; + +vi.mock('lexical-beautiful-mentions', async (importOriginal) => { + const actual = (await importOriginal()) as typeof import('lexical-beautiful-mentions'); + return { + ...actual, + BeautifulMentionsPlugin: ({ children }: any) =>
{children}
, + BeautifulMentionsMenuProps: {}, + BeautifulMentionsMenuItemProps: {}, + }; +}); + +const mockedAxios = axios as any; +vitest.mock('axios'); + +beforeEach(() => { + cleanup(); +}); + +afterEach(() => { + mockedAxios.get.mockReset(); +}); + +const addSpeedSendContainer = ( + + + + + } /> + } /> + } /> + + + + +); + +const editSpeedSendContainer = (id: string) => ( + + + + + } /> + } /> + + + + +); + +const user = userEvent.setup(); +const notificationSpy = vi.spyOn(Notification, 'setNotification'); +const validateMediaSpy = vi.spyOn(utilsModule, 'validateMedia'); + +describe('test creating a speed send', () => { + test('should render the speed send form', async () => { + render(addSpeedSendContainer); + + await waitFor(() => { + expect(screen.getByText('Add a new Speed send')).toBeInTheDocument(); + }); + }); + + test('should validate media', async () => { + mockedAxios.get.mockImplementationOnce(() => + Promise.resolve({ data: { is_valid: true, message: 'valid media' } }) + ); + + render(addSpeedSendContainer); + + await waitFor(() => { + expect(screen.getByText('Add a new Speed send')).toBeInTheDocument(); + }); + + const inputs = screen.getAllByRole('textbox'); + + const autocompletes = screen.getAllByTestId('autocomplete-element'); + autocompletes[0].focus(); + fireEvent.keyDown(autocompletes[0], { key: 'ArrowDown' }); + + fireEvent.click(screen.getByText('IMAGE'), { key: 'Enter' }); + + fireEvent.change(inputs[2], { + target: { + value: 'https://www.buildquickbots.com/whatsapp/media/sample/jpg/sample02.jpg', + }, + }); + + await waitFor(() => { + expect(validateMediaSpy).toHaveBeenCalled(); + expect(screen.getByText('Validating URL')).toBeInTheDocument(); + }); + }); + + test('should show invalid media message', async () => { + mockedAxios.get.mockImplementationOnce(() => + Promise.resolve({ data: { message: 'This media URL is invalid', is_valid: false } }) + ); + + render(addSpeedSendContainer); + + await waitFor(() => { + expect(screen.getByText('Add a new Speed send')).toBeInTheDocument(); + }); + + const inputs = screen.getAllByRole('textbox'); + + const autocompletes = screen.getAllByTestId('autocomplete-element'); + autocompletes[0].focus(); + fireEvent.keyDown(autocompletes[0], { key: 'ArrowDown' }); + + fireEvent.click(screen.getByText('IMAGE'), { key: 'Enter' }); + + fireEvent.change(inputs[2], { + target: { + value: 'invalid media', + }, + }); + + await waitFor(() => { + expect(validateMediaSpy).toHaveBeenCalled(); + expect(screen.getByText('Validating URL')).toBeInTheDocument(); + }); + }); + + test('should create a speed send', async () => { + mockedAxios.get.mockImplementationOnce(() => + Promise.resolve({ data: { is_valid: true, message: 'valid media' } }) + ); + + render(addSpeedSendContainer); + + await waitFor(() => { + expect(screen.getByText('Add a new Speed send')).toBeInTheDocument(); + }); + + const inputs = screen.getAllByRole('textbox'); + + const lexicalEditor = inputs[1]; + + await user.click(lexicalEditor); + await user.tab(); + fireEvent.input(lexicalEditor, { data: 'Hi, How are you' }); + + await waitFor(() => { + expect(screen.getByText('Hi, How are you')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Marathi')); + + await waitFor(() => { + expect(notificationSpy).toHaveBeenCalled(); + }); + + const autocompletes = screen.getAllByTestId('autocomplete-element'); + autocompletes[0].focus(); + fireEvent.keyDown(autocompletes[0], { key: 'ArrowDown' }); + + fireEvent.click(screen.getByText('STICKER'), { key: 'Enter' }); + + fireEvent.change(inputs[2], { + target: { + value: 'https://www.buildquickbots.com/whatsapp/media/sample/jpg/sample02.jpg', + }, + }); + + fireEvent.change(inputs[0], { target: { value: 'Template' } }); + + fireEvent.click(screen.getByText('Marathi')); + + await waitFor(() => { + expect(notificationSpy).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(screen.getByTestId('translation')).toBeInTheDocument(); + }); + }); + + test('it should display warning message', async () => { + render(addSpeedSendContainer); + + await waitFor(() => { + expect(screen.getByText('Add a new Speed send')).toBeInTheDocument(); + }); + + const autocompletes = screen.getAllByTestId('autocomplete-element'); + autocompletes[0].focus(); + fireEvent.keyDown(autocompletes[0], { key: 'ArrowDown' }); + + fireEvent.click(screen.getByText('STICKER'), { key: 'Enter' }); + + await waitFor(() => { + expect(screen.getByText('Animated stickers are not supported.')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('CloseIcon')); + + autocompletes[0].focus(); + fireEvent.keyDown(autocompletes[0], { key: 'ArrowDown' }); + + fireEvent.click(screen.getByText('AUDIO'), { key: 'Enter' }); + + await waitFor(() => { + expect(screen.getByText('Captions along with audio are not supported.')).toBeInTheDocument(); + }); + }); + + test('it should show errors if they exist', async () => { + render(addSpeedSendContainer); + + await waitFor(() => { + expect(screen.getByText('Add a new Speed send')).toBeInTheDocument(); + }); + + const inputs = screen.getAllByRole('textbox'); + const autocompletes = screen.getAllByTestId('autocomplete-element'); + + autocompletes[0].focus(); + fireEvent.keyDown(autocompletes[0], { key: 'ArrowDown' }); + fireEvent.click(screen.getByText('IMAGE'), { key: 'Enter' }); + + fireEvent.change(inputs[0], { target: { value: 'Template' } }); + + fireEvent.click(screen.getByText('Marathi')); + + await waitFor(() => { + expect(notificationSpy).toHaveBeenCalled(); + }); + }); +}); + +describe('test editing a speed send', () => { + test('should render speed send form', async () => { + mockedAxios.get.mockImplementationOnce(() => + Promise.resolve({ data: { is_valid: true, message: 'valid media' } }) + ); + + render(editSpeedSendContainer('2')); + + await waitFor(() => { + expect(screen.getByText('Edit Speed send')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByText('Marathi')).toBeInTheDocument(); + }); + }); + + test('should show translations', async () => { + render(editSpeedSendContainer('1')); + + await waitFor(() => { + expect(screen.getByText('Edit Speed send')).toBeInTheDocument(); + }); + + const inputs = screen.getAllByRole('textbox'); + + await waitFor(() => { + expect(inputs[0]).toHaveValue('title1'); + }); + + fireEvent.click(screen.getByText('Marathi')); + + await waitFor(() => { + expect(screen.getByTestId('translation')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('English')); + + await waitFor(() => { + expect(screen.getByText('title1')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByText('Save')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Save')); + }); + + await waitFor(() => { + expect(notificationSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/containers/SpeedSend/SpeedSend.tsx b/src/containers/SpeedSend/SpeedSend.tsx new file mode 100644 index 000000000..30c1b0b7c --- /dev/null +++ b/src/containers/SpeedSend/SpeedSend.tsx @@ -0,0 +1,531 @@ +import { MEDIA_MESSAGE_TYPES } from 'common/constants'; +import { FormLayout } from 'containers/Form/FormLayout'; +import { CREATE_TEMPLATE, DELETE_TEMPLATE, UPDATE_TEMPLATE } from 'graphql/mutations/Template'; +import { GET_SPEED_SEND } from 'graphql/queries/Template'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useLocation, useNavigate, useParams } from 'react-router'; +import * as Yup from 'yup'; +import SpeedSendIcon from 'assets/images/icons/SpeedSend/Selected.svg?react'; +import { useLazyQuery, useMutation, useQuery } from '@apollo/client'; +import { CREATE_MEDIA_MESSAGE } from 'graphql/mutations/Chat'; +import styles from './SpeedSend.module.css'; +import { AutoComplete } from 'components/UI/Form/AutoComplete/AutoComplete'; +import { Checkbox } from 'components/UI/Form/Checkbox/Checkbox'; +import { Typography } from '@mui/material'; +import { Input } from 'components/UI/Form/Input/Input'; +import { EmojiInput } from 'components/UI/Form/EmojiInput/EmojiInput'; +import { USER_LANGUAGES } from 'graphql/queries/Organization'; +import { Loading } from 'components/UI/Layout/Loading/Loading'; +import { validateMedia } from 'common/utils'; +import { LanguageBar } from 'components/UI/LanguageBar/LanguageBar'; +import { setNotification } from 'common/notification'; + +const queries = { + getItemQuery: GET_SPEED_SEND, + createItemQuery: CREATE_TEMPLATE, + updateItemQuery: UPDATE_TEMPLATE, + deleteItemQuery: DELETE_TEMPLATE, +}; + +const redirectionLink = 'speed-send'; + +const dialogMessage = 'It will stop showing when you are drafting a customized message.'; +const mediaTypes = MEDIA_MESSAGE_TYPES.map((option: string) => ({ + id: option, + label: option, +})); +const speedSendIcon = ; + +export const getTranslation = (attribute: any, translations: any, defaultLanguage: any) => { + const defaultTemplate = JSON.parse(translations)[defaultLanguage.id]; + + if (!defaultTemplate) { + return null; + } + + return defaultTemplate[attribute] || null; +}; + +export const SpeedSend = () => { + const [label, setLabel] = useState(''); + const [body, setBody] = useState(''); + const [language, setLanguageId] = useState(null); + const [type, setType] = useState(null); + const [translations, setTranslations] = useState('{}'); + const [attachmentURL, setAttachmentURL] = useState(''); + const [languageOptions, setLanguageOptions] = useState([]); + const [isActive, setIsActive] = useState(true); + const [validatingURL, setValidatingURL] = useState(false); + const [warning, setWarning] = useState(); + const [isUrlValid, setIsUrlValid] = useState(); + const [nextLanguage, setNextLanguage] = useState(''); + const [editorState, setEditorState] = useState(''); + const [defaultLanguage, setDefaultLanguage] = useState({}); + + const { t } = useTranslation(); + const navigate = useNavigate(); + const location: any = useLocation(); + const params = useParams(); + let isEditing = false; + let mode; + + if (params.id) { + isEditing = true; + } + + const hasTranslations = params.id && defaultLanguage?.id !== language?.id; + + const [createMediaMessage] = useMutation(CREATE_MEDIA_MESSAGE); + + const { data: languages, loading: languageLoading } = useQuery(USER_LANGUAGES, { + variables: { opts: { order: 'ASC' } }, + }); + + const [getSessionTemplate, { data: template, loading: templateLoading }] = + useLazyQuery(GET_SPEED_SEND); + + useEffect(() => { + if (languages) { + const lang = languages.currentUser.user.organization.activeLanguages.slice(); + // sort languages by thaeir name + lang.sort((first: any, second: any) => (first.label > second.label ? 1 : -1)); + + setLanguageOptions(lang); + if (!isEditing) setLanguageId(lang[0]); + } + }, [languages]); + + useEffect(() => { + if (params.id) { + getSessionTemplate({ variables: { id: params.id } }); + } + }, [params]); + + useEffect(() => { + if ((type === '' || type) && attachmentURL) { + validateURL(attachmentURL); + } + }, [type, attachmentURL]); + + useEffect(() => { + displayWarning(); + }, [type]); + + const states = { + language, + label, + body, + type, + attachmentURL, + isActive, + }; + + const setStates = ({ + isActive: isActiveValue, + language: languageIdValue, + label: labelValue, + body: bodyValue, + type: typeValue, + translations: translationsValue, + messageMedia: MessageMediaValue, + }: any) => { + let title = labelValue; + let body = bodyValue; + + if (translationsValue) { + const translationsCopy = JSON.parse(translationsValue); + const currentLanguage = language?.id || languageIdValue.id; + + if ( + Object.keys(translationsCopy).length > 0 && + translationsCopy[currentLanguage] && + !location.state?.language + ) { + let content = translationsCopy[currentLanguage]; + title = content.label; + body = content.body || ''; + } else if (template) { + // translations for current language doesn't exist + title = ''; + body = ''; + } + + setTranslations(translationsValue); + } + + if (languageOptions.length > 0 && languageIdValue) { + if (location.state && location.state !== 'copy') { + const selectedLangauge = languageOptions.find( + (lang: any) => lang.label === location.state.language + ); + navigate(location.pathname); + + setLanguageId(selectedLangauge); + } else if (!language?.id) { + const selectedLangauge = languageOptions.find( + (lang: any) => lang.id === languageIdValue.id + ); + setLanguageId(selectedLangauge); + } else { + setLanguageId(language); + } + } + + if (typeValue && typeValue !== 'TEXT') { + setType({ id: typeValue, label: typeValue }); + } else { + setType(null); + } + + if (MessageMediaValue) { + setAttachmentURL(MessageMediaValue.sourceUrl); + } else { + setAttachmentURL(''); + } + + setDefaultLanguage(languageIdValue); + setLabel(title); + setIsActive(isActiveValue); + setBody(body || ''); + setEditorState(body || ''); + }; + + const setPayload = (payload: any) => { + let payloadCopy = payload; + let translationsCopy: any = {}; + + // Create template + payloadCopy.languageId = payload.language?.id; + if (payloadCopy.type) { + // STICKER is a type of IMAGE + if (payloadCopy.type.id === 'STICKER') { + payloadCopy.type = 'IMAGE'; + } else { + payloadCopy.type = payloadCopy.type.id; + } + } else { + payloadCopy.type = 'TEXT'; + } + + if (payloadCopy.type === 'TEXT') { + delete payloadCopy.attachmentURL; + } + + // Update template translation + if (translations) { + translationsCopy = JSON.parse(translations); + translationsCopy[language?.id] = payloadCopy; + setTranslations(JSON.stringify(translationsCopy)); + } + + payloadCopy.translations = JSON.stringify(translationsCopy); + + delete payloadCopy.isAddButtonChecked; + delete payloadCopy.templateButtons; + delete payloadCopy.language; + delete payloadCopy.languageVariant; + delete payloadCopy.variables; + delete payloadCopy.exisitingShortCode; + + delete payloadCopy.newShortcode; + + return payloadCopy; + }; + + const getMediaId = async (payload: any) => { + const data = await createMediaMessage({ + variables: { + input: { + caption: payload.body, + sourceUrl: payload.attachmentURL, + url: payload.attachmentURL, + }, + }, + }); + return data; + }; + + const validateURL = (value: string) => { + if (value && type) { + setValidatingURL(true); + validateMedia(value, type.id, false).then((response: any) => { + if (!response.data.is_valid) { + setIsUrlValid(response.data.message); + } else { + setIsUrlValid(''); + } + setValidatingURL(false); + }); + } + }; + + const updateStates = ({ + language: languageIdValue, + label: labelValue, + body: bodyValue, + type: typeValue, + MessageMedia: MessageMediaValue, + }: any) => { + if (languageIdValue) { + setLanguageId(languageIdValue); + } + + setLabel(labelValue); + setBody(bodyValue || ''); + setEditorState(bodyValue || ''); + + if (typeValue && typeValue !== 'TEXT') { + setType({ id: typeValue, label: typeValue }); + } else { + setType(null); + } + + if (MessageMediaValue) { + setAttachmentURL(MessageMediaValue.sourceUrl); + } else { + setAttachmentURL(''); + } + }; + + const updateTranslation = (value: any) => { + const translationId = value.id; + // restore if selected language is same as template + if (template && template.sessionTemplate.sessionTemplate.language.id === value.id) { + updateStates({ + language: value, + label: template.sessionTemplate.sessionTemplate.label, + body: template.sessionTemplate.sessionTemplate.body, + type: template.sessionTemplate.sessionTemplate.type, + MessageMedia: template.sessionTemplate.sessionTemplate.MessageMedia, + }); + } else if (translations) { + const translationsCopy = JSON.parse(translations); + // restore if translations present for selected language + if (translationsCopy[translationId]) { + updateStates({ + language: value, + label: translationsCopy[translationId].label, + body: translationsCopy[translationId].body, + type: translationsCopy[translationId].MessageMedia + ? translationsCopy[translationId].MessageMedia.type + : null, + MessageMedia: translationsCopy[translationId].MessageMedia, + }); + } else { + updateStates({ + language: value, + label: '', + body: '', + type: null, + MessageMedia: null, + }); + } + } + }; + + const handleLanguageChange = (value: any) => { + const selected = languageOptions.find( + ({ label: languageLabel }: any) => languageLabel === value + ); + if (selected && isEditing) { + updateTranslation(selected); + } else if (selected) { + setLanguageId(selected); + } + }; + + const onLanguageChange = (option: string, form: any) => { + setNextLanguage(option); + const { values, errors } = form; + + if (values.type) { + if (values.label || values.body) { + if (errors) { + setNotification(t('Please check the errors'), 'warning'); + } + } else { + handleLanguageChange(option); + } + } + if (values.body) { + if (Object.keys(errors).length !== 0) { + setNotification(t('Please check the errors'), 'warning'); + } + } else { + handleLanguageChange(option); + } + }; + + const displayWarning = () => { + if (type && type.id === 'STICKER') { + setWarning( +
+
    +
  1. {t('Animated stickers are not supported.')}
  2. +
  3. {t('Captions along with stickers are not supported.')}
  4. +
+
+ ); + } else if (type && type.id === 'AUDIO') { + setWarning( +
+
    +
  1. {t('Captions along with audio are not supported.')}
  2. +
+
+ ); + } else { + setWarning(null); + } + }; + + const FormSchema = Yup.object().shape( + { + language: Yup.object().nullable().required('Language is required.'), + label: Yup.string().required(t('Title is required.')).max(50, t('Title length is too long.')), + type: Yup.object() + .nullable() + .when('attachmentURL', { + is: (val: string) => val && val !== '', + then: (schema) => schema.nullable().required(t('Type is required.')), + }), + attachmentURL: Yup.string() + .nullable() + .when('type', { + is: (val: any) => val && val.id, + then: (schema) => schema.required(t('Attachment URL is required.')), + }), + body: Yup.string() + .required(t('Message is required.')) + .max(1024, 'Maximum 1024 characters are allowed'), + }, + [['type', 'attachmentURL']] + ); + + const attachmentField = [ + { + component: AutoComplete, + name: 'type', + options: mediaTypes, + optionLabel: 'label', + multiple: false, + label: t('Attachment Type'), + helperText: warning, + onChange: (event: any) => { + const val = event; + if (!event) { + setIsUrlValid(val); + } + setType(val); + }, + }, + { + component: Input, + name: 'attachmentURL', + type: 'text', + label: t('Attachment URL'), + validate: () => isUrlValid, + helperText: t( + 'Please provide a sample attachment for approval purpose. You may send a similar but different attachment when sending the HSM to users.' + ), + inputProp: { + onChange: (event: any) => { + setAttachmentURL(event.target.value); + }, + }, + }, + ]; + + const langOptions = languageOptions && languageOptions.map((val: any) => val.label); + + const formFields = [ + { + field: 'languageBar', + component: LanguageBar, + options: langOptions || [], + selectedLangauge: language && language.label, + onLanguageChange, + }, + { + component: Input, + name: 'label', + label: t('Title'), + inputProp: { + onChange: (event: any) => setLabel(event.target.value), + }, + translation: hasTranslations && getTranslation('label', translations, defaultLanguage), + }, + { + component: Checkbox, + name: 'isActive', + title: ( + + Active? + + ), + darkCheckbox: true, + }, + { + component: EmojiInput, + name: 'body', + label: t('Message'), + rows: 5, + convertToWhatsApp: true, + textArea: true, + handleChange: (value: any) => { + setBody(value); + }, + defaultValue: isEditing && editorState, + translation: hasTranslations && getTranslation('body', translations, defaultLanguage), + }, + ...attachmentField, + ]; + + const afterSave = (data: any, saveClick: boolean) => { + if (!saveClick) { + if (params.id) { + handleLanguageChange(nextLanguage); + } else { + const { sessionTemplate } = data.createSessionTemplate; + navigate(`/speed-send/${sessionTemplate.id}/edit`, { + state: { language: nextLanguage }, + }); + } + } + }; + + if (languageLoading || templateLoading) { + return ; + } + + return ( + + ); +}; + +export default SpeedSend; diff --git a/src/containers/SpeedSend/SpeedSendList/SpeedSendList.module.css b/src/containers/SpeedSend/SpeedSendList/SpeedSendList.module.css new file mode 100644 index 000000000..1393a40cc --- /dev/null +++ b/src/containers/SpeedSend/SpeedSendList/SpeedSendList.module.css @@ -0,0 +1,48 @@ +.SpeedSendIcon { + width: 29px; + height: 29px; +} + +.LabelContainer { + display: flex; + flex-direction: column; + row-gap: 0.5rem; +} + +.LabelText { + font-weight: 500; + font-size: 17px; + word-break: break-all; +} + +.TableText { + text-align: left; + font-size: 14px; + color: #93a29b; + white-space: pre-line; + margin: 0; + padding: 0; +} + +.LastModified { + width: 20%; + min-width: 185px; + color: #93a29b; +} + +.Name { + width: 25%; + min-width: 200px; +} + +.Body { + width: 36%; + min-width: 200px; + word-break: break-word; +} + +.Actions { + width: 19%; + min-width: 200px; + text-align: end; +} diff --git a/src/containers/Template/List/SpeedSendList/SpeedSendList.test.tsx b/src/containers/SpeedSend/SpeedSendList/SpeedSendList.test.tsx similarity index 87% rename from src/containers/Template/List/SpeedSendList/SpeedSendList.test.tsx rename to src/containers/SpeedSend/SpeedSendList/SpeedSendList.test.tsx index 15c2fd2e6..306b8e2de 100644 --- a/src/containers/Template/List/SpeedSendList/SpeedSendList.test.tsx +++ b/src/containers/SpeedSend/SpeedSendList/SpeedSendList.test.tsx @@ -2,12 +2,12 @@ import { render, waitFor, cleanup } from '@testing-library/react'; import { BrowserRouter as Router } from 'react-router-dom'; import { MockedProvider } from '@apollo/client/testing'; -import { TEMPLATE_MOCKS } from 'containers/Template/Template.test.helper'; +import { SPEED_SEND_LIST } from 'mocks/Template'; import { setUserSession } from 'services/AuthService'; import { SpeedSendList } from './SpeedSendList'; afterEach(cleanup); -const mocks = [...TEMPLATE_MOCKS, ...TEMPLATE_MOCKS]; +const mocks = [...SPEED_SEND_LIST, ...SPEED_SEND_LIST]; const speedSend = ( diff --git a/src/containers/SpeedSend/SpeedSendList/SpeedSendList.tsx b/src/containers/SpeedSend/SpeedSendList/SpeedSendList.tsx new file mode 100644 index 000000000..5da9bbe2a --- /dev/null +++ b/src/containers/SpeedSend/SpeedSendList/SpeedSendList.tsx @@ -0,0 +1,138 @@ +import { speedSendInfo } from 'common/HelpData'; +import SpeedSendIcon from 'assets/images/icons/SpeedSend/Dark.svg?react'; +import DuplicateIcon from 'assets/images/icons/Duplicate.svg?react'; +import DownArrow from 'assets/images/icons/DownArrow.svg?react'; +import { useState } from 'react'; +import { useNavigate } from 'react-router'; +import { useTranslation } from 'react-i18next'; + +import styles from './SpeedSendList.module.css'; +import { FILTER_SESSION_TEMPLATES, GET_TEMPLATES_COUNT } from 'graphql/queries/Template'; +import { DELETE_TEMPLATE } from 'graphql/mutations/Template'; +import { WhatsAppToJsx } from 'common/RichEditor'; +import dayjs from 'dayjs'; +import { STANDARD_DATE_TIME_FORMAT } from 'common/constants'; +import { List } from 'containers/List/List'; + +const speedSendIcon = ; + +const getLabel = (label: string) => ( +
+
{label}
+
+); +const getBody = (text: string) =>

{WhatsAppToJsx(text)}

; +const getUpdatedAt = (date: string) => ( +
{dayjs(date).format(STANDARD_DATE_TIME_FORMAT)}
+); + +const getTranslations = (language: any, data: string) => { + const dataObj = JSON.parse(data); + if (Object.prototype.hasOwnProperty.call(dataObj, language.id)) { + delete dataObj[language.id]; + } + return JSON.stringify(dataObj); +}; + +export const SpeedSendList = () => { + const [open, setOpen] = useState(false); + const [Id, setId] = useState(''); + const [selectedTag, setSelectedTag] = useState(null); + const { t } = useTranslation(); + const navigate = useNavigate(); + + const queries = { + countQuery: GET_TEMPLATES_COUNT, + filterItemsQuery: FILTER_SESSION_TEMPLATES, + deleteItemQuery: DELETE_TEMPLATE, + }; + + const columnNames: any = [ + { name: 'label', label: t('Title') }, + { name: 'body', label: t('Body') }, + { name: 'updated_at', label: t('Last modified') }, + { label: t('Actions') }, + ]; + + let columnStyles: any = [styles.Name, styles.Body, styles.LastModified, styles.Actions]; + + const getColumns = ({ id, language, label, body, updatedAt, translations }: any) => { + const columns: any = { + id, + label: getLabel(label), + body: getBody(body), + updatedAt: getUpdatedAt(updatedAt), + translations: getTranslations(language, translations), + }; + + return columns; + }; + + const columnAttributes = { + columnNames, + columns: getColumns, + columnStyles, + }; + + const setDialog = (id: string) => { + if (Id !== id) { + setId(id); + setOpen(true); + } else { + setOpen(!open); + } + }; + + const setCopyDialog = (id: any) => { + navigate(`/speed-send/${id}/edit`, { state: 'copy' }); + }; + + let additionalAction: any = () => [ + { + label: t('Show all languages'), + icon: , + parameter: 'id', + dialog: setDialog, + }, + { + label: t('Copy'), + icon: , + parameter: 'id', + dialog: setCopyDialog, + insideMore: true, + }, + ]; + + const button = { show: true, label: t('Create') }; + const dialogMessage = t('It will stop showing when you draft a customized message'); + let appliedFilters = { isHsm: false }; + + appliedFilters = { + ...appliedFilters, + ...(selectedTag?.id && { tagIds: [parseInt(selectedTag?.id)] }), + }; + + return ( + <> + {/* {dialogBox} */} + + + ); +}; + +export default SpeedSendList; diff --git a/src/containers/Template/Form/HSM/HSM.module.css b/src/containers/Template/Form/HSM/HSM.module.css deleted file mode 100644 index caf736fa7..000000000 --- a/src/containers/Template/Form/HSM/HSM.module.css +++ /dev/null @@ -1,11 +0,0 @@ -.TemplateIcon { - width: 29px; - height: 29px; -} - -.Checkbox { - color: #555555; - font-weight: 400; - line-height: 18px; - font-size: 16px; -} diff --git a/src/containers/Template/Form/HSM/HSM.tsx b/src/containers/Template/Form/HSM/HSM.tsx deleted file mode 100644 index 81ee4bf17..000000000 --- a/src/containers/Template/Form/HSM/HSM.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { useState } from 'react'; -import { useQuery } from '@apollo/client'; -import { useTranslation } from 'react-i18next'; -import { useParams, useLocation } from 'react-router-dom'; - -import TemplateIcon from 'assets/images/icons/Template/UnselectedDark.svg?react'; -import { GET_HSM_CATEGORIES, GET_SHORTCODES } from 'graphql/queries/Template'; -import { AutoComplete } from 'components/UI/Form/AutoComplete/AutoComplete'; -import { Input } from 'components/UI/Form/Input/Input'; -import { Loading } from 'components/UI/Layout/Loading/Loading'; -import Simulator from 'components/simulator/Simulator'; -import Template from '../Template'; -import styles from './HSM.module.css'; -import { Checkbox } from 'components/UI/Form/Checkbox/Checkbox'; -import { Typography } from '@mui/material'; - -const defaultAttribute = { - isHsm: true, -}; - -const templateIcon = ; - -export const HSM = () => { - const [sampleMessages, setSampleMessages] = useState({ - type: 'TEXT', - location: null, - media: {}, - body: '', - }); - - const [exisitingShortCode, setExistingShortcode] = useState(''); - const [newShortcode, setNewShortcode] = useState(''); - const [category, setCategory] = useState(undefined); - const [languageVariant, setLanguageVariant] = useState(false); - const [allowTemplateCategoryChange, setAllowTemplateCategoryChange] = useState(true); - - const { t } = useTranslation(); - const params = useParams(); - const location: any = useLocation(); - - const { data: categoryList, loading } = useQuery(GET_HSM_CATEGORIES); - const { data: shortCodes } = useQuery(GET_SHORTCODES, { - variables: { - filter: { - isHsm: true, - }, - }, - }); - - if (loading) { - return ; - } - - const categoryOpn: any = []; - if (categoryList) { - categoryList.whatsappHsmCategories.forEach((categories: any, index: number) => { - categoryOpn.push({ label: categories, id: index }); - }); - } - - const shortCodeOptions: any = []; - if (shortCodes) { - shortCodes.sessionTemplates.forEach((value: any, index: number) => { - shortCodeOptions.push({ label: value?.shortcode, id: index }); - }); - } - - const removeFirstLineBreak = (text: any) => - text?.length === 1 ? text.slice(0, 1).replace(/(\r\n|\n|\r)/, '') : text; - - const getTemplate = (text: string) => { - const { body } = sampleMessages; - /** - * Regular expression to check if message contains given pattern - * If pattern is present search will return first index of given pattern - * otherwise it will return -1 - */ - const exp = /(\|\s\[)|(\|\[)/; - - const areButtonsPresent = body.search(exp); - if (areButtonsPresent > -1) { - const buttons = body.substr(areButtonsPresent); - return text + buttons; - } - return text; - }; - - const getSimulatorMessage = (messages: any) => { - const message = removeFirstLineBreak(messages); - const media: any = { ...sampleMessages.media }; - const text = getTemplate(message); - media.caption = text; - setSampleMessages((val) => ({ ...val, body: text, media })); - }; - - const getAttachmentUrl = (type: any, media: any) => { - const mediaBody = { ...media }; - const mediaObj: any = sampleMessages.media; - mediaBody.caption = mediaObj.caption; - setSampleMessages((val) => ({ ...val, type, media: mediaBody })); - }; - - const addButtonsToSampleMessage = (buttonTemplate: string) => { - const message: any = { ...sampleMessages }; - message.body = buttonTemplate; - setSampleMessages(message); - }; - - const isCopyState = location.state === 'copy'; - let isEditing = false; - if (params.id && !isCopyState) { - isEditing = true; - } - - const formFields = [ - { - component: Checkbox, - name: 'languageVariant', - title: ( - - Translate existing HSM? - - ), - handleChange: (value: any) => setLanguageVariant(value), - skip: isEditing, - }, - { - component: Input, - name: 'newShortCode', - placeholder: `${t('Element name')}*`, - label: `${t('Element name')}*`, - disabled: isEditing, - skip: languageVariant ? true : false, - onChange: (value: any) => { - setNewShortcode(value); - }, - }, - { - component: AutoComplete, - name: 'existingShortCode', - options: shortCodeOptions, - optionLabel: 'label', - multiple: false, - label: `${t('Element name')}*`, - placeholder: `${t('Element name')}*`, - disabled: isEditing, - onChange: (event: any) => { - setExistingShortcode(event); - }, - skip: languageVariant ? false : true, - }, - { - component: AutoComplete, - name: 'category', - options: categoryOpn, - optionLabel: 'label', - multiple: false, - label: `${t('Category')}*`, - placeholder: `${t('Category')}*`, - disabled: isEditing, - helperText: t('Select the most relevant category'), - onChange: (event: any) => { - setCategory(event); - }, - skip: isEditing, - }, - { - component: Input, - name: 'category', - type: 'text', - label: `${t('Category')}*`, - placeholder: `${t('Category')}*`, - disabled: isEditing, - helperText: t('Select the most relevant category'), - skip: !isEditing, - }, - { - component: Checkbox, - name: 'allowTemplateCategoryChange', - title: ( - - Allow meta to re-categorize template? - - ), - darkCheckbox: true, - disabled: isEditing, - handleChange: (value: boolean) => setAllowTemplateCategoryChange(value), - }, - ]; - - return ( -
-