From 3caf337fffff0758ba5c38d65bfe8b1edd0f0d44 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 12 Dec 2023 17:59:05 +0000 Subject: [PATCH 1/4] Add debounced redux subscriber to save redux state to localStorage, & load on initialise. Remove use of Set types from redux state --- plugin-hrm-form/src/HrmFormPlugin.tsx | 4 +- .../states/contacts/existingContacts.test.ts | 120 +++++++++--------- .../src/states/contacts/contactState.ts | 4 +- .../src/states/contacts/existingContacts.ts | 17 ++- .../src/states/contacts/reducer.ts | 6 +- .../src/states/contacts/saveContact.ts | 26 ++-- .../contacts/selectContactSaveStatus.ts | 5 +- plugin-hrm-form/src/states/contacts/types.ts | 3 +- plugin-hrm-form/src/states/index.ts | 8 +- plugin-hrm-form/src/states/persistState.ts | 46 +++++++ plugin-hrm-form/src/states/serializableSet.ts | 36 ++++++ plugin-hrm-form/src/types/types.ts | 1 + plugin-hrm-form/src/utils/sharedState.ts | 4 +- 13 files changed, 181 insertions(+), 99 deletions(-) create mode 100644 plugin-hrm-form/src/states/persistState.ts create mode 100644 plugin-hrm-form/src/states/serializableSet.ts diff --git a/plugin-hrm-form/src/HrmFormPlugin.tsx b/plugin-hrm-form/src/HrmFormPlugin.tsx index fac6eadd73..4320548c7e 100644 --- a/plugin-hrm-form/src/HrmFormPlugin.tsx +++ b/plugin-hrm-form/src/HrmFormPlugin.tsx @@ -43,10 +43,11 @@ import { setUpReferrableResources } from './components/resources/setUpReferrable import { subscribeNewMessageAlertOnPluginInit } from './notifications/newMessage'; import { subscribeReservedTaskAlert } from './notifications/reservedTask'; import { setUpCounselorToolkits } from './components/toolkits/setUpCounselorToolkits'; -import { setupConferenceComponents, setUpConferenceActions } from './conference'; +import { setUpConferenceActions, setupConferenceComponents } from './conference'; import { setUpTransferActions } from './transfer/setUpTransferActions'; import { playNotification } from './notifications/playNotification'; import { namespace } from './states/storeNamespaces'; +import { activateStatePersistence } from './states/persistState'; const PLUGIN_NAME = 'HrmFormPlugin'; @@ -245,6 +246,7 @@ export default class HrmFormPlugin extends FlexPlugin { * This is a workaround until we deprecate 'getConfig' in it's current form after we migrate to Flex 2.0 */ subscribeToConfigUpdates(manager); + activateStatePersistence(); } } diff --git a/plugin-hrm-form/src/___tests__/states/contacts/existingContacts.test.ts b/plugin-hrm-form/src/___tests__/states/contacts/existingContacts.test.ts index fad656b7cf..0ac700c316 100644 --- a/plugin-hrm-form/src/___tests__/states/contacts/existingContacts.test.ts +++ b/plugin-hrm-form/src/___tests__/states/contacts/existingContacts.test.ts @@ -42,8 +42,10 @@ import { import { Contact } from '../../../types/types'; import { ConfigurationState } from '../../../states/configuration/reducer'; import { VALID_EMPTY_CONTACT, VALID_EMPTY_METADATA } from '../../testContacts'; +import { has, size } from '../../../states/serializableSet'; const baseContact: Contact = { + profileId: 0, id: '1337', accountSid: '', timeOfContact: '', @@ -79,7 +81,7 @@ const baseContact: Contact = { const baseState: ExistingContactsState = { [baseContact.id]: { savedContact: baseContact, - references: new Set('x'), + references: { x: true }, metadata: VALID_EMPTY_METADATA, }, } as const; @@ -89,8 +91,8 @@ describe('loadContactReducer', () => { test('Nothing currently for that ID - adds the contact with provided reference and blank categories state', () => { const newState = loadContactReducer({}, loadContact(baseContact, 'TEST_REFERENCE')); expect(newState[baseContact.id].savedContact).toStrictEqual(baseContact); - expect(newState[baseContact.id].references.size).toStrictEqual(1); - expect(newState[baseContact.id].references.has('TEST_REFERENCE')).toBeTruthy(); + expect(size(newState[baseContact.id].references)).toStrictEqual(1); + expect(has(newState[baseContact.id].references, 'TEST_REFERENCE')).toBeTruthy(); expect(newState[baseContact.id].categories).toStrictEqual({ gridView: false, expanded: {} }); }); @@ -99,17 +101,15 @@ describe('loadContactReducer', () => { { [baseContact.id]: { savedContact: baseContact, - references: new Set(['TEST_REFERENCE']), + references: { TEST_REFERENCE: true }, metadata: VALID_EMPTY_METADATA, }, }, loadContact(baseContact, 'ANOTHER_TEST_REFERENCE'), ); expect(newState[baseContact.id].savedContact).toStrictEqual(baseContact); - expect(newState[baseContact.id].references.size).toStrictEqual(2); - expect([...newState[baseContact.id].references]).toEqual( - expect.arrayContaining(['TEST_REFERENCE', 'ANOTHER_TEST_REFERENCE']), - ); + expect(size(newState[baseContact.id].references)).toStrictEqual(2); + expect(newState[baseContact.id].references).toEqual({ TEST_REFERENCE: true, ANOTHER_TEST_REFERENCE: true }); }); test('Different contact currently for that ID - leaves contact the same and adds the reference', () => { @@ -128,17 +128,15 @@ describe('loadContactReducer', () => { { [baseContact.id]: { savedContact: baseContact, - references: new Set(['TEST_REFERENCE']), + references: { TEST_REFERENCE: true }, metadata: VALID_EMPTY_METADATA, }, }, loadContact(changedContact, 'ANOTHER_TEST_REFERENCE'), ); expect(newState[baseContact.id].savedContact).toStrictEqual(baseContact); - expect(newState[baseContact.id].references.size).toStrictEqual(2); - expect([...newState[baseContact.id].references]).toEqual( - expect.arrayContaining(['TEST_REFERENCE', 'ANOTHER_TEST_REFERENCE']), - ); + expect(size(newState[baseContact.id].references)).toStrictEqual(2); + expect(newState[baseContact.id].references).toEqual({ TEST_REFERENCE: true, ANOTHER_TEST_REFERENCE: true }); }); test('Same reference as a contact already loaded - does nothing', () => { @@ -157,15 +155,15 @@ describe('loadContactReducer', () => { { [baseContact.id]: { savedContact: baseContact, - references: new Set(['TEST_REFERENCE']), + references: { TEST_REFERENCE: true }, metadata: VALID_EMPTY_METADATA, }, }, loadContact(changedContact, 'TEST_REFERENCE'), ); expect(newState[baseContact.id].savedContact).toStrictEqual(baseContact); - expect(newState[baseContact.id].references.size).toStrictEqual(1); - expect(newState[baseContact.id].references.has('TEST_REFERENCE')).toBeTruthy(); + expect(size(newState[baseContact.id].references)).toStrictEqual(1); + expect(has(newState[baseContact.id].references, 'TEST_REFERENCE')).toBeTruthy(); }); test('Multiple contacts in different states - applies rules to each contact separately', () => { const changedContact = { @@ -183,12 +181,12 @@ describe('loadContactReducer', () => { { [baseContact.id]: { savedContact: baseContact, - references: new Set(['TEST_REFERENCE']), + references: { TEST_REFERENCE: true }, metadata: VALID_EMPTY_METADATA, }, '666': { savedContact: { ...baseContact, id: '666' }, - references: new Set(['ANOTHER_TEST_REFERENCE']), + references: { ANOTHER_TEST_REFERENCE: true }, metadata: VALID_EMPTY_METADATA, }, }, @@ -200,12 +198,12 @@ describe('loadContactReducer', () => { }, ); expect(newState[baseContact.id].savedContact).toStrictEqual(baseContact); - expect([...newState[baseContact.id].references]).toStrictEqual(['TEST_REFERENCE']); + expect(newState[baseContact.id].references).toStrictEqual({ TEST_REFERENCE: true }); expect(newState['42'].savedContact).toStrictEqual({ ...changedContact, id: '42' }); - expect([...newState['42'].references]).toStrictEqual(['TEST_REFERENCE']); + expect(newState['42'].references).toStrictEqual({ TEST_REFERENCE: true }); expect(newState['666'].savedContact).toStrictEqual({ ...baseContact, id: '666' }); - expect([...newState['666'].references]).toMatchObject(['ANOTHER_TEST_REFERENCE', 'TEST_REFERENCE']); - expect(newState['666'].references.size).toEqual(2); + expect(newState['666'].references).toMatchObject({ TEST_REFERENCE: true, ANOTHER_TEST_REFERENCE: true }); + expect(size(newState['666'].references)).toEqual(2); }); }); @@ -213,8 +211,8 @@ describe('loadContactReducer', () => { test('Nothing currently for that ID - adds the contact with provided reference and blank categories state', () => { const newState = loadContactReducer({}, loadContact(baseContact, 'TEST_REFERENCE', true)); expect(newState[baseContact.id].savedContact).toStrictEqual(baseContact); - expect(newState[baseContact.id].references.size).toStrictEqual(1); - expect(newState[baseContact.id].references.has('TEST_REFERENCE')).toBeTruthy(); + expect(size(newState[baseContact.id].references)).toStrictEqual(1); + expect(has(newState[baseContact.id].references, 'TEST_REFERENCE')).toBeTruthy(); expect(newState[baseContact.id].categories).toStrictEqual({ gridView: false, expanded: {} }); }); @@ -223,17 +221,15 @@ describe('loadContactReducer', () => { { [baseContact.id]: { savedContact: baseContact, - references: new Set(['TEST_REFERENCE']), + references: { TEST_REFERENCE: true }, metadata: VALID_EMPTY_METADATA, }, }, loadContact(baseContact, 'ANOTHER_TEST_REFERENCE', true), ); expect(newState[baseContact.id].savedContact).toStrictEqual(baseContact); - expect(newState[baseContact.id].references.size).toStrictEqual(2); - expect([...newState[baseContact.id].references]).toEqual( - expect.arrayContaining(['TEST_REFERENCE', 'ANOTHER_TEST_REFERENCE']), - ); + expect(size(newState[baseContact.id].references)).toStrictEqual(2); + expect(newState[baseContact.id].references).toEqual({ TEST_REFERENCE: true, ANOTHER_TEST_REFERENCE: true }); }); test('Different contact currently for that ID - replaces and adds the reference', () => { @@ -252,17 +248,15 @@ describe('loadContactReducer', () => { { [baseContact.id]: { savedContact: baseContact, - references: new Set(['TEST_REFERENCE']), + references: { TEST_REFERENCE: true }, metadata: VALID_EMPTY_METADATA, }, }, loadContact(changedContact, 'ANOTHER_TEST_REFERENCE', true), ); expect(newState[baseContact.id].savedContact).toStrictEqual(changedContact); - expect(newState[baseContact.id].references.size).toStrictEqual(2); - expect([...newState[baseContact.id].references]).toEqual( - expect.arrayContaining(['TEST_REFERENCE', 'ANOTHER_TEST_REFERENCE']), - ); + expect(size(newState[baseContact.id].references)).toStrictEqual(2); + expect(newState[baseContact.id].references).toEqual({ TEST_REFERENCE: true, ANOTHER_TEST_REFERENCE: true }); }); test('Same reference as a contact already loaded - replaces contact but leaves references the same', () => { @@ -281,15 +275,15 @@ describe('loadContactReducer', () => { { [baseContact.id]: { savedContact: baseContact, - references: new Set(['TEST_REFERENCE']), + references: { TEST_REFERENCE: true }, metadata: VALID_EMPTY_METADATA, }, }, loadContact(changedContact, 'TEST_REFERENCE', true), ); expect(newState[baseContact.id].savedContact).toStrictEqual(changedContact); - expect(newState[baseContact.id].references.size).toStrictEqual(1); - expect(newState[baseContact.id].references.has('TEST_REFERENCE')).toBeTruthy(); + expect(size(newState[baseContact.id].references)).toStrictEqual(1); + expect(has(newState[baseContact.id].references, 'TEST_REFERENCE')).toBeTruthy(); }); test('Multiple contacts in different states - applies rules to each contact separately', () => { @@ -308,12 +302,12 @@ describe('loadContactReducer', () => { { [baseContact.id]: { savedContact: baseContact, - references: new Set(['TEST_REFERENCE']), + references: { TEST_REFERENCE: true }, metadata: VALID_EMPTY_METADATA, }, '666': { savedContact: { ...baseContact, id: '666' }, - references: new Set(['ANOTHER_TEST_REFERENCE']), + references: { ANOTHER_TEST_REFERENCE: true }, metadata: VALID_EMPTY_METADATA, }, }, @@ -325,12 +319,12 @@ describe('loadContactReducer', () => { }, ); expect(newState[baseContact.id].savedContact).toStrictEqual(changedContact); - expect([...newState[baseContact.id].references]).toStrictEqual(['TEST_REFERENCE']); + expect(newState[baseContact.id].references).toStrictEqual({ TEST_REFERENCE: true }); expect(newState['42'].savedContact).toStrictEqual({ ...changedContact, id: '42' }); - expect([...newState['42'].references]).toStrictEqual(['TEST_REFERENCE']); + expect(newState['42'].references).toStrictEqual({ TEST_REFERENCE: true }); expect(newState['666'].savedContact).toStrictEqual({ ...changedContact, id: '666' }); - expect([...newState['666'].references]).toMatchObject(['ANOTHER_TEST_REFERENCE', 'TEST_REFERENCE']); - expect(newState['666'].references.size).toEqual(2); + expect(newState['666'].references).toMatchObject({ TEST_REFERENCE: true, ANOTHER_TEST_REFERENCE: true }); + expect(size(newState['666'].references)).toEqual(2); }); }); @@ -363,7 +357,7 @@ describe('releaseContactReducer', () => { { [baseContact.id]: { savedContact: baseContact, - references: new Set(['TEST_REFERENCE', 'ANOTHER_TEST_REFERENCE']), + references: { TEST_REFERENCE: true, ANOTHER_TEST_REFERENCE: true }, metadata: VALID_EMPTY_METADATA, }, @@ -371,15 +365,15 @@ describe('releaseContactReducer', () => { releaseContact(baseContact.id, 'TEST_REFERENCE'), ); expect(newState[baseContact.id].savedContact).toStrictEqual(baseContact); - expect(newState[baseContact.id].references.size).toStrictEqual(1); - expect(newState[baseContact.id].references.has('ANOTHER_TEST_REFERENCE')).toBeTruthy(); + expect(size(newState[baseContact.id].references)).toStrictEqual(1); + expect(has(newState[baseContact.id].references, 'ANOTHER_TEST_REFERENCE')).toBeTruthy(); }); test('Contact loaded for that ID with just that reference - removes contact from state', () => { const newState = releaseContactReducer( { [baseContact.id]: { savedContact: baseContact, - references: new Set(['TEST_REFERENCE']), + references: { TEST_REFERENCE: true }, metadata: { ...VALID_EMPTY_METADATA, categories: { gridView: false, expanded: {} }, @@ -395,7 +389,7 @@ describe('releaseContactReducer', () => { { [baseContact.id]: { savedContact: baseContact, - references: new Set(['ANOTHER_REFERENCE']), + references: { ANOTHER_REFERENCE: true }, metadata: { ...VALID_EMPTY_METADATA, categories: { gridView: false, expanded: {} }, @@ -405,15 +399,15 @@ describe('releaseContactReducer', () => { releaseContact(baseContact.id, 'TEST_REFERENCE'), ); expect(newState[baseContact.id].savedContact).toStrictEqual(baseContact); - expect(newState[baseContact.id].references.size).toStrictEqual(1); - expect(newState[baseContact.id].references.has('ANOTHER_REFERENCE')).toBeTruthy(); + expect(size(newState[baseContact.id].references)).toStrictEqual(1); + expect(has(newState[baseContact.id].references, 'ANOTHER_REFERENCE')).toBeTruthy(); }); test('Contact loaded for that ID with no references - should never be in this state but removes contact from state', () => { const newState = releaseContactReducer( { [baseContact.id]: { savedContact: baseContact, - references: new Set(), + references: {}, metadata: { ...VALID_EMPTY_METADATA, categories: { gridView: false, expanded: {} }, @@ -429,12 +423,12 @@ describe('releaseContactReducer', () => { { [baseContact.id]: { savedContact: baseContact, - references: new Set(['TEST_REFERENCE', 'ANOTHER_TEST_REFERENCE']), + references: { TEST_REFERENCE: true, ANOTHER_TEST_REFERENCE: true }, metadata: VALID_EMPTY_METADATA, }, '666': { savedContact: { ...baseContact, id: '666' }, - references: new Set(['TEST_REFERENCE']), + references: { TEST_REFERENCE: true }, metadata: { ...VALID_EMPTY_METADATA, @@ -445,8 +439,8 @@ describe('releaseContactReducer', () => { releaseContacts([baseContact.id, '666'], 'TEST_REFERENCE'), ); expect(newState[baseContact.id].savedContact).toStrictEqual(baseContact); - expect(newState[baseContact.id].references.size).toStrictEqual(1); - expect(newState[baseContact.id].references.has('ANOTHER_TEST_REFERENCE')).toBeTruthy(); + expect(size(newState[baseContact.id].references)).toStrictEqual(1); + expect(has(newState[baseContact.id].references, 'ANOTHER_TEST_REFERENCE')).toBeTruthy(); expect(newState['666']).toBeUndefined(); }); test('Multiple contacts that are not all present - removes references and removes contacts left with no references', () => { @@ -454,7 +448,7 @@ describe('releaseContactReducer', () => { { [baseContact.id]: { savedContact: baseContact, - references: new Set(['ANOTHER_TEST_REFERENCE']), + references: { ANOTHER_TEST_REFERENCE: true }, metadata: { ...VALID_EMPTY_METADATA, categories: { gridView: false, expanded: {} }, @@ -462,7 +456,7 @@ describe('releaseContactReducer', () => { }, '666': { savedContact: { ...baseContact, id: '666' }, - references: new Set(['TEST_REFERENCE']), + references: { TEST_REFERENCE: true }, metadata: { ...VALID_EMPTY_METADATA, @@ -473,8 +467,8 @@ describe('releaseContactReducer', () => { releaseContacts([baseContact.id, '666', '42'], 'TEST_REFERENCE'), ); expect(newState[baseContact.id].savedContact).toStrictEqual(baseContact); - expect(newState[baseContact.id].references.size).toStrictEqual(1); - expect(newState[baseContact.id].references.has('ANOTHER_TEST_REFERENCE')).toBeTruthy(); + expect(size(newState[baseContact.id].references)).toStrictEqual(1); + expect(has(newState[baseContact.id].references, 'ANOTHER_TEST_REFERENCE')).toBeTruthy(); expect(newState['666']).toBeUndefined(); }); }); @@ -507,7 +501,7 @@ describe('loadTranscriptReducer', () => { { [baseContact.id]: { savedContact: baseContact, - references: new Set(['x']), + references: { x: true }, metadata: { ...VALID_EMPTY_METADATA, categories: { @@ -531,7 +525,7 @@ describe('toggleCategoryExpandedReducer', () => { { [baseContact.id]: { savedContact: baseContact, - references: new Set(['x']), + references: { x: true }, metadata: { ...VALID_EMPTY_METADATA, categories: { @@ -556,7 +550,7 @@ describe('toggleCategoryExpandedReducer', () => { { [baseContact.id]: { savedContact: baseContact, - references: new Set('x'), + references: { x: true }, metadata: VALID_EMPTY_METADATA, }, }, @@ -577,7 +571,7 @@ describe('setCategoriesGridViewReducer', () => { { [baseContact.id]: { savedContact: baseContact, - references: new Set('x'), + references: { x: true }, metadata: { ...VALID_EMPTY_METADATA, categories: { diff --git a/plugin-hrm-form/src/states/contacts/contactState.ts b/plugin-hrm-form/src/states/contacts/contactState.ts index 7cfc790813..c2f25b9a49 100644 --- a/plugin-hrm-form/src/states/contacts/contactState.ts +++ b/plugin-hrm-form/src/states/contacts/contactState.ts @@ -20,7 +20,7 @@ import { ITask, TaskHelper } from '@twilio/flex-ui'; import type { ContactMetadata } from './types'; import { ReferralLookupStatus } from './resourceReferral'; import type { ContactState } from './existingContacts'; -import { Contact, ContactRawJson, OfflineContactTask, isOfflineContactTask } from '../../types/types'; +import { Contact, ContactRawJson, isOfflineContactTask, OfflineContactTask } from '../../types/types'; import { createStateItem, getInitialValue } from '../../components/common/forms/formGenerators'; import { createContactlessTaskTabDefinition } from '../../components/tabbedForms/ContactlessTaskTabDefinition'; import { getHrmConfig } from '../../hrmConfig'; @@ -111,5 +111,5 @@ export const newContactState = (definitions: DefinitionVersion, task?: ITask | O savedContact: newContact(definitions, task), metadata: newContactMetaData(recreated), draftContact: {}, - references: new Set(), + references: {}, }); diff --git a/plugin-hrm-form/src/states/contacts/existingContacts.ts b/plugin-hrm-form/src/states/contacts/existingContacts.ts index f4c4ecde63..4c5016df6c 100644 --- a/plugin-hrm-form/src/states/contacts/existingContacts.ts +++ b/plugin-hrm-form/src/states/contacts/existingContacts.ts @@ -22,6 +22,7 @@ import { ConfigurationState } from '../configuration/reducer'; import { transformValuesForContactForm } from './contactDetailsAdapter'; import { ContactMetadata } from './types'; import { newContactMetaData } from './contactState'; +import { add, has, remove, SerializableSet, size } from '../serializableSet'; export enum ContactDetailsRoute { EDIT_CALLER_INFORMATION = 'editCallerInformation', @@ -85,7 +86,7 @@ export type TranscriptResult = { }; export type ContactState = { - references: Set; + references: SerializableSet; savedContact: Contact; draftContact?: ContactDraftChanges; metadata: ContactMetadata; @@ -130,12 +131,10 @@ export const refreshContact = (contact: any) => loadContact(contact, undefined, export const loadContactReducer = (state = initialState, action: LoadContactAction) => { const updateEntries = action.contacts .filter(c => { - return ( - (action.reference && !(state[c.id]?.references ?? new Set()).has(action.reference)) || action.replaceExisting - ); + return (action.reference && !has(state[c.id]?.references ?? {}, action.reference)) || action.replaceExisting; }) .map(c => { - const current = state[c.id] ?? { references: new Set() }; + const current = state[c.id] ?? { references: {} }; const { draftContact, ...currentContact } = state[c.id] ?? { categories: { expanded: {}, @@ -147,8 +146,8 @@ export const loadContactReducer = (state = initialState, action: LoadContactActi { metadata: newContactMetaData(true), ...currentContact, - savedContact: action.replaceExisting || !current.references.size ? c : state[c.id].savedContact, - references: action.reference ? current.references.add(action.reference) : current.references, + savedContact: action.replaceExisting || !size(current.references) ? c : state[c.id].savedContact, + references: action.reference ? add(current.references, action.reference) : current.references, draftContact: action.replaceExisting ? undefined : draftContact, }, ]; @@ -189,10 +188,10 @@ export const releaseContactReducer = (state: ExistingContactsState, action: Rele ); return [id, undefined]; } - current.references.delete(action.reference); + remove(current.references, action.reference); return [id, current]; }) - .filter(([, ecs]) => typeof ecs === 'object' && ecs.references.size > 0); + .filter(([, ecs]) => typeof ecs === 'object' && size(ecs.references) > 0); return { ...omit(state, ...action.ids), ...Object.fromEntries(updateKvps), diff --git a/plugin-hrm-form/src/states/contacts/reducer.ts b/plugin-hrm-form/src/states/contacts/reducer.ts index b67ee5d31c..eb8430f24c 100644 --- a/plugin-hrm-form/src/states/contacts/reducer.ts +++ b/plugin-hrm-form/src/states/contacts/reducer.ts @@ -23,7 +23,8 @@ import { ContactsState, CREATE_CONTACT_ACTION, LOAD_CONTACT_FROM_HRM_BY_TASK_ID_ACTION, - SET_SAVED_CONTACT, UPDATE_CONTACT_ACTION, + SET_SAVED_CONTACT, + UPDATE_CONTACT_ACTION, } from './types'; import { REMOVE_CONTACT_STATE, RemoveContactStateAction } from '../types'; import { @@ -34,7 +35,6 @@ import { EXISTING_CONTACT_TOGGLE_CATEGORY_EXPANDED_ACTION, EXISTING_CONTACT_UPDATE_DRAFT_ACTION, ExistingContactAction, - initialState as existingContactInitialState, LOAD_CONTACT_ACTION, loadContactReducer, loadTranscriptReducer, @@ -66,7 +66,7 @@ export const emptyCategories = []; // exposed for testing export const initialState: ContactsState = { existingContacts: {}, - contactsBeingCreated: new Set(), + contactsBeingCreated: {}, contactDetails: { [DetailsContext.CASE_DETAILS]: { detailsExpanded: {} }, [DetailsContext.CONTACT_SEARCH]: { detailsExpanded: {} }, diff --git a/plugin-hrm-form/src/states/contacts/saveContact.ts b/plugin-hrm-form/src/states/contacts/saveContact.ts index 163f4584e6..9461573a6a 100644 --- a/plugin-hrm-form/src/states/contacts/saveContact.ts +++ b/plugin-hrm-form/src/states/contacts/saveContact.ts @@ -20,28 +20,29 @@ import { format } from 'date-fns'; import { submitContactForm } from '../../services/formSubmissionHelpers'; import { connectToCase, - removeFromCase, createContact, getContactById, getContactByTaskSid, + removeFromCase, updateContactInHrm, } from '../../services/ContactService'; -import { Case, CustomITask, Contact } from '../../types/types'; +import { Case, Contact, CustomITask } from '../../types/types'; import { CONNECT_TO_CASE, - REMOVE_FROM_CASE, ContactMetadata, ContactsState, CREATE_CONTACT_ACTION, LOAD_CONTACT_FROM_HRM_BY_ID_ACTION, LOAD_CONTACT_FROM_HRM_BY_TASK_ID_ACTION, + REMOVE_FROM_CASE, SET_SAVED_CONTACT, UPDATE_CONTACT_ACTION, } from './types'; import { ContactDraftChanges } from './existingContacts'; import { newContactMetaData } from './contactState'; -import { cancelCase, getCase } from '../../services/CaseService'; +import { getCase } from '../../services/CaseService'; import { getUnsavedContact } from './getUnsavedContact'; +import { add, remove } from '../serializableSet'; export const createContactAsyncAction = createAsyncAction( CREATE_CONTACT_ACTION, @@ -197,13 +198,13 @@ const loadContactIntoRedux = ( newMetadata?: ContactMetadata, ): ContactsState => { const { existingContacts } = state; - const references = existingContacts[contact.id]?.references ?? new Set(); + const references = existingContacts[contact.id]?.references ?? {}; if (reference) { - references.add(reference); + add(references, reference); } const metadata = newMetadata ?? existingContacts[contact.id]?.metadata; - const contactsBeingCreated = new Set(state.contactsBeingCreated); - contactsBeingCreated.delete(contact.taskId); + const contactsBeingCreated = { ...state.contactsBeingCreated }; + remove(contactsBeingCreated, contact.taskId); return { ...state, contactsBeingCreated, @@ -288,11 +289,10 @@ export const saveContactReducer = (initialState: ContactsState) => handleAction( createContactAsyncAction.pending as typeof createContactAsyncAction, (state, { meta: { taskSid } }): ContactsState => { - const contactsBeingCreated = new Set(state.contactsBeingCreated); - contactsBeingCreated.add(taskSid); + const contactsBeingCreated = { ...state.contactsBeingCreated }; return { ...state, - contactsBeingCreated, + contactsBeingCreated: add(contactsBeingCreated, taskSid), }; }, ), @@ -310,8 +310,8 @@ export const saveContactReducer = (initialState: ContactsState) => } = action as typeof action & { meta: { taskSid: string }; }; - const contactsBeingCreated = new Set(state.contactsBeingCreated); - contactsBeingCreated.delete(taskSid); + const contactsBeingCreated = { ...state.contactsBeingCreated }; + remove(contactsBeingCreated, taskSid); return { ...state, contactsBeingCreated, diff --git a/plugin-hrm-form/src/states/contacts/selectContactSaveStatus.ts b/plugin-hrm-form/src/states/contacts/selectContactSaveStatus.ts index 5ef33a173a..a13495eb53 100644 --- a/plugin-hrm-form/src/states/contacts/selectContactSaveStatus.ts +++ b/plugin-hrm-form/src/states/contacts/selectContactSaveStatus.ts @@ -16,6 +16,7 @@ import { RootState } from '..'; import { namespace } from '../storeNamespaces'; +import { has, size } from '../serializableSet'; export const selectIsContactCreating = ( { @@ -24,12 +25,12 @@ export const selectIsContactCreating = ( }, }: RootState, taskSid: string, -) => contactsBeingCreated.has(taskSid); +) => has(contactsBeingCreated, taskSid); export const selectAnyContactIsSaving = ({ [namespace]: { activeContacts: { contactsBeingCreated, existingContacts }, }, }: RootState) => - contactsBeingCreated.size > 0 || + size(contactsBeingCreated) > 0 || Object.values(existingContacts).some(({ metadata }) => metadata?.saveStatus === 'saving'); diff --git a/plugin-hrm-form/src/states/contacts/types.ts b/plugin-hrm-form/src/states/contacts/types.ts index 7aebdb3fe1..466db4801d 100644 --- a/plugin-hrm-form/src/states/contacts/types.ts +++ b/plugin-hrm-form/src/states/contacts/types.ts @@ -20,6 +20,7 @@ import { Case, Contact } from '../../types/types'; import { DraftResourceReferralState } from './resourceReferral'; import { ContactState, ExistingContactsState } from './existingContacts'; import { ContactDetailsState } from './contactDetails'; +import { SerializableSet } from '../serializableSet'; // Action types export const SAVE_END_MILLIS = 'SAVE_END_MILLIS'; @@ -63,7 +64,7 @@ export type ContactMetadata = { export type ContactsState = { existingContacts: ExistingContactsState; - contactsBeingCreated: Set; + contactsBeingCreated: SerializableSet; contactDetails: ContactDetailsState; isCallTypeCaller: boolean; }; diff --git a/plugin-hrm-form/src/states/index.ts b/plugin-hrm-form/src/states/index.ts index 57b1c20353..9af4fcf120 100644 --- a/plugin-hrm-form/src/states/index.ts +++ b/plugin-hrm-form/src/states/index.ts @@ -34,6 +34,7 @@ import { CaseState } from './case/types'; import { ContactsState } from './contacts/types'; import { caseListBase, + caseMergingBannersBase, conferencingBase, configurationBase, connectedCaseBase, @@ -42,14 +43,14 @@ import { csamReportBase, dualWriteBase, namespace, + profileBase, queuesStatusBase, referrableResourcesBase, routingBase, searchContactsBase, - caseMergingBannersBase, - profileBase, } from './storeNamespaces'; import { reduce as CaseMergingBannersReducer } from './case/caseBanners'; +import { readPersistedState } from './persistState'; const reducers = { [searchContactsBase]: SearchFormReducer, @@ -78,7 +79,8 @@ export type RootState = FlexState & { [namespace]: HrmState }; const combinedReducers = combineReducers(reducers); // Combine the reducers -const reducer = (state: HrmState, action): HrmState => { +const reducer = (currentState: HrmState, action): HrmState => { + const state = currentState ?? readPersistedState(); return { ...combinedReducers(state, action), /* diff --git a/plugin-hrm-form/src/states/persistState.ts b/plugin-hrm-form/src/states/persistState.ts new file mode 100644 index 0000000000..d70727987e --- /dev/null +++ b/plugin-hrm-form/src/states/persistState.ts @@ -0,0 +1,46 @@ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { Manager } from '@twilio/flex-ui'; +import _ from 'lodash'; + +import { RootState } from '.'; +import { namespace } from './storeNamespaces'; +import { getAseloFeatureFlags } from '../hrmConfig'; + +// Quick & dirty module to persist redux state to localStorage via subscriptions since we can't add middleware like redux-persist to do it for us +export const activateStatePersistence = () => { + if (getAseloFeatureFlags().enable_local_redux_persist) { + const debouncedWrite = _.debounce(() => { + // Exclude configuration from persisted state, since it contains non serializable elements, and is read only in the client anyway + const { + [namespace]: { configuration, ...persistableState }, + } = Manager.getInstance().store.getState() as RootState; + localStorage.setItem('redux-state/plugin-hrm-form', JSON.stringify(persistableState)); + }, 1000); + Manager.getInstance().store.subscribe(debouncedWrite); + } +}; + +export const readPersistedState = (): RootState[typeof namespace] | null => { + if (getAseloFeatureFlags().enable_local_redux_persist) { + const persistedState = localStorage.getItem('redux-state/plugin-hrm-form'); + if (persistedState) { + return JSON.parse(persistedState); + } + } + return undefined; +}; diff --git a/plugin-hrm-form/src/states/serializableSet.ts b/plugin-hrm-form/src/states/serializableSet.ts new file mode 100644 index 0000000000..dd1eb3447a --- /dev/null +++ b/plugin-hrm-form/src/states/serializableSet.ts @@ -0,0 +1,36 @@ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +// The JS set doesn't serialize to JSON, so we need to use a plain object instead if we want to keep it in redux state, now we store it in localStorage + +export type SerializableSet = Record; + +export const has = (set: SerializableSet, key: string): boolean => Boolean(set[key]); + +export const add = (set: SerializableSet, key: string): SerializableSet => { + set[key] = true; + return set; +}; + +export const remove = (set: SerializableSet, key: string): boolean => { + if (!has(set, key)) { + return false; + } + delete set[key]; + return true; +}; + +export const size = (set: SerializableSet): number => Object.keys(set).length; diff --git a/plugin-hrm-form/src/types/types.ts b/plugin-hrm-form/src/types/types.ts index 13326064cd..7eafb3565a 100644 --- a/plugin-hrm-form/src/types/types.ts +++ b/plugin-hrm-form/src/types/types.ts @@ -278,6 +278,7 @@ export type FeatureFlags = { enable_client_profiles: boolean; // Enables Client Profiles enable_case_merging: boolean; // Enables adding contacts to existing cases enable_confirm_on_browser_close: boolean; // Enables confirmation dialog on browser close when there are unsaved changes + enable_local_redux_persist: boolean; // Enables storing redux state in localStorage }; /* eslint-enable camelcase */ diff --git a/plugin-hrm-form/src/utils/sharedState.ts b/plugin-hrm-form/src/utils/sharedState.ts index 9dd0fa6988..a481e200b8 100644 --- a/plugin-hrm-form/src/utils/sharedState.ts +++ b/plugin-hrm-form/src/utils/sharedState.ts @@ -21,7 +21,7 @@ import { ITask, Manager } from '@twilio/flex-ui'; import { recordBackendError } from '../fullStory'; import { issueSyncToken } from '../services/ServerlessService'; import { getAseloFeatureFlags, getDefinitionVersions, getHrmConfig, getTemplateStrings } from '../hrmConfig'; -import { CSAMReportEntry, Contact } from '../types/types'; +import { Contact, CSAMReportEntry } from '../types/types'; import { ContactMetadata } from '../states/contacts/types'; import { ChannelTypes } from '../states/DomainConstants'; import { ResourceReferral } from '../states/contacts/resourceReferral'; @@ -89,7 +89,7 @@ const transferFormToContactState = (transferForm: TransferForm, baselineContact: ...metadata, draft: form.draft, }, - references: new Set(), + references: {}, }; }; From d899800752081f851af4f1b46570282c2940b4ad Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 12 Dec 2023 18:01:48 +0000 Subject: [PATCH 2/4] Fix test --- plugin-hrm-form/src/___tests__/utils/sharedState.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugin-hrm-form/src/___tests__/utils/sharedState.test.ts b/plugin-hrm-form/src/___tests__/utils/sharedState.test.ts index a0f060d1a2..fe411e6b76 100644 --- a/plugin-hrm-form/src/___tests__/utils/sharedState.test.ts +++ b/plugin-hrm-form/src/___tests__/utils/sharedState.test.ts @@ -66,7 +66,7 @@ const metadata = {} as ContactMetadata; const form: ContactState = { savedContact: contact, metadata, - references: new Set(), + references: {}, }; const task = createTask(); @@ -126,7 +126,7 @@ beforeEach(async () => { metadata: { draft: undefined, } as ContactMetadata, - references: new Set(), + references: {}, }; mockFlexManager = { From 73e45e0949f9385897d785286fdde2df7e92f511 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 13 Dec 2023 11:07:40 +0000 Subject: [PATCH 3/4] Add simple version stamp to flex to use in persisted state, so redux in localStorage is invalidated on upgrade / rollback --- .github/actions/main-action/action.yml | 11 +++++++++++ plugin-hrm-form/src/hrmConfig.ts | 3 +++ plugin-hrm-form/src/states/persistState.ts | 14 +++++++++----- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/.github/actions/main-action/action.yml b/.github/actions/main-action/action.yml index 119414961f..c2d2570f09 100644 --- a/.github/actions/main-action/action.yml +++ b/.github/actions/main-action/action.yml @@ -84,6 +84,15 @@ runs: TWILIO_ACCOUNT_SID: ${{ inputs.account-sid }} with: filename: ./plugin-hrm-form/public/appConfig.js + # Get latest tag for this version + - name: Get latest tag + uses: oprypin/find-latest-tag@v1 + with: + repository: ${{ inputs.repository }} + # releases-only: false + regex: "^v\d+\.\d+\.\d+.*$" + id: latest_matching_tag + continue-on-error: true - name: Create secret.js run: | touch ./src/private/secret.js @@ -95,6 +104,8 @@ runs: export const datadogAccessToken = '$DATADOG_ACCESS_TOKEN'; export const datadogApplicationID = '$DATADOG_APP_ID'; export const fullStoryId = '$FULLSTORY_ID'; + export const versionId = '${{ steps.latest_matching_tag.outputs.tag }}' + export const githubSha = '{{ github.sha }}'; EOT working-directory: ./plugin-hrm-form shell: bash diff --git a/plugin-hrm-form/src/hrmConfig.ts b/plugin-hrm-form/src/hrmConfig.ts index ca73fed716..efe557c5bb 100644 --- a/plugin-hrm-form/src/hrmConfig.ts +++ b/plugin-hrm-form/src/hrmConfig.ts @@ -20,6 +20,7 @@ import { buildFormDefinitionsBaseUrlGetter, inferConfiguredFormDefinitionsBaseUr import { ConfigFlags, FeatureFlags } from './types/types'; import type { RootState } from './states'; import { namespace } from './states/storeNamespaces'; +import { githubSha, versionId } from './private/secret'; const featureFlagEnvVarPrefix = 'REACT_APP_FF_'; type ContactSaveFrequency = 'onTabChange' | 'onFinalSaveAndTransfer'; @@ -167,3 +168,5 @@ export const getAseloFeatureFlags = (): FeatureFlags => cachedConfig.featureFlag export const getDefinitionVersions = () => { return (Flex.Manager.getInstance().store.getState() as RootState)[namespace].configuration; }; + +export const pluginVersionDescription = `${versionId}${githubSha ? `#${githubSha}` : ''}`; diff --git a/plugin-hrm-form/src/states/persistState.ts b/plugin-hrm-form/src/states/persistState.ts index d70727987e..1ebfe50415 100644 --- a/plugin-hrm-form/src/states/persistState.ts +++ b/plugin-hrm-form/src/states/persistState.ts @@ -19,7 +19,7 @@ import _ from 'lodash'; import { RootState } from '.'; import { namespace } from './storeNamespaces'; -import { getAseloFeatureFlags } from '../hrmConfig'; +import { getAseloFeatureFlags, pluginVersionDescription } from '../hrmConfig'; // Quick & dirty module to persist redux state to localStorage via subscriptions since we can't add middleware like redux-persist to do it for us export const activateStatePersistence = () => { @@ -29,7 +29,10 @@ export const activateStatePersistence = () => { const { [namespace]: { configuration, ...persistableState }, } = Manager.getInstance().store.getState() as RootState; - localStorage.setItem('redux-state/plugin-hrm-form', JSON.stringify(persistableState)); + localStorage.setItem( + 'redux-state/plugin-hrm-form', + JSON.stringify({ [pluginVersionDescription]: persistableState }), + ); }, 1000); Manager.getInstance().store.subscribe(debouncedWrite); } @@ -37,9 +40,10 @@ export const activateStatePersistence = () => { export const readPersistedState = (): RootState[typeof namespace] | null => { if (getAseloFeatureFlags().enable_local_redux_persist) { - const persistedState = localStorage.getItem('redux-state/plugin-hrm-form'); - if (persistedState) { - return JSON.parse(persistedState); + const persistedStateJson = localStorage.getItem('redux-state/plugin-hrm-form'); + if (persistedStateJson) { + const persistedState = JSON.parse(persistedStateJson); + return persistedState[pluginVersionDescription]; } } return undefined; From 4b71d1770764154dff01ce7746727ea1a38bcb9b Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 13 Dec 2023 20:34:21 +0000 Subject: [PATCH 4/4] Use sessionStorage for redux instead of localstorage --- plugin-hrm-form/src/states/persistState.ts | 4 ++-- plugin-hrm-form/src/utils/setUpComponents.tsx | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/plugin-hrm-form/src/states/persistState.ts b/plugin-hrm-form/src/states/persistState.ts index 1ebfe50415..98fd7d6b34 100644 --- a/plugin-hrm-form/src/states/persistState.ts +++ b/plugin-hrm-form/src/states/persistState.ts @@ -29,7 +29,7 @@ export const activateStatePersistence = () => { const { [namespace]: { configuration, ...persistableState }, } = Manager.getInstance().store.getState() as RootState; - localStorage.setItem( + sessionStorage.setItem( 'redux-state/plugin-hrm-form', JSON.stringify({ [pluginVersionDescription]: persistableState }), ); @@ -40,7 +40,7 @@ export const activateStatePersistence = () => { export const readPersistedState = (): RootState[typeof namespace] | null => { if (getAseloFeatureFlags().enable_local_redux_persist) { - const persistedStateJson = localStorage.getItem('redux-state/plugin-hrm-form'); + const persistedStateJson = sessionStorage.getItem('redux-state/plugin-hrm-form'); if (persistedStateJson) { const persistedState = JSON.parse(persistedStateJson); return persistedState[pluginVersionDescription]; diff --git a/plugin-hrm-form/src/utils/setUpComponents.tsx b/plugin-hrm-form/src/utils/setUpComponents.tsx index 61d0925996..39794b7588 100644 --- a/plugin-hrm-form/src/utils/setUpComponents.tsx +++ b/plugin-hrm-form/src/utils/setUpComponents.tsx @@ -44,11 +44,12 @@ import { TLHPaddingLeft } from '../styles/GlobalOverrides'; import { Container } from '../styles/queuesStatus'; import { FeatureFlags, isInMyBehalfITask, standaloneTaskSid } from '../types/types'; import { colors } from '../channels/colors'; -import { getHrmConfig, getAseloConfigFlags } from '../hrmConfig'; +import { getAseloConfigFlags, getHrmConfig } from '../hrmConfig'; import { AseloMessageInput, AseloMessageList } from '../components/AseloMessaging'; import { namespace, routingBase } from '../states/storeNamespaces'; import { changeRoute } from '../states/routing/actions'; import { ChangeRouteMode } from '../states/routing/types'; +import { versionId } from '../private/secret'; type SetupObject = ReturnType; /** @@ -352,6 +353,7 @@ export const removeTaskCanvasHeaderActions = (featureFlags: FeatureFlags) => { export const setLogo = url => { if (url) { Flex.MainHeader.defaultProps.logoUrl = url; + Flex.MainHeader.defaultProps.logoAltText = `Twilio Flex running Aselo (${versionId})`; } else { Flex.MainHeader.Content.remove('logo'); }