diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 702ebf3f5..77543782f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,10 +84,13 @@ jobs: env: API_SOCKET_URL: ${{ secrets.API_SOCKET_URL }} API_URL: ${{ secrets.API_URL }} - INSTRUMENTATION_KEY: ${{ secrets.WEBAPP_APPINSIGHTS_KEY }} + INSTRUMENTATION_KEY: ${{ secrets.APPINSIGHTS_KEY }} WEBAPP_ORIGIN: ${{ secrets.WEBAPP_ORIGIN }} WEBAPP_STORAGE_ACCOUNT: ${{ secrets.WEBAPP_STORAGE_ACCOUNT }} WEBAPP_STORAGE_KEY: ${{ secrets.WEBAPP_STORAGE_KEY }} + TAKE_PHOTO_MODE_ENABLED: ${{ secrets.TAKE_PHOTO_MODE_ENABLED }} + OFFLINE_MODE_ENABLED: ${{ secrets.OFFLINE_MODE_ENABLED }} + DURABLE_CACHE_ENABLED: ${{ secrets.DURABLE_CACHE_ENABLED }} deploy-staging: runs-on: ubuntu-latest @@ -117,11 +120,13 @@ jobs: env: API_SOCKET_URL: ${{ secrets.API_SOCKET_URL }} API_URL: ${{ secrets.API_URL }} - INSTRUMENTATION_KEY: ${{ secrets.WEBAPP_APPINSIGHTS_KEY }} + INSTRUMENTATION_KEY: ${{ secrets.APPINSIGHTS_KEY }} WEBAPP_ORIGIN: ${{ secrets.WEBAPP_ORIGIN }} WEBAPP_STORAGE_ACCOUNT: ${{ secrets.WEBAPP_STORAGE_ACCOUNT }} WEBAPP_STORAGE_KEY: ${{ secrets.WEBAPP_STORAGE_KEY }} TAKE_PHOTO_MODE_ENABLED: ${{ secrets.TAKE_PHOTO_MODE_ENABLED }} + OFFLINE_MODE_ENABLED: ${{ secrets.OFFLINE_MODE_ENABLED }} + DURABLE_CACHE_ENABLED: ${{ secrets.DURABLE_CACHE_ENABLED }} deploy-demo: runs-on: ubuntu-latest @@ -151,10 +156,13 @@ jobs: env: API_SOCKET_URL: ${{ secrets.API_SOCKET_URL }} API_URL: ${{ secrets.API_URL }} - INSTRUMENTATION_KEY: ${{ secrets.WEBAPP_APPINSIGHTS_KEY }} + INSTRUMENTATION_KEY: ${{ secrets.APPINSIGHTS_KEY }} WEBAPP_ORIGIN: ${{ secrets.WEBAPP_ORIGIN }} WEBAPP_STORAGE_ACCOUNT: ${{ secrets.WEBAPP_STORAGE_ACCOUNT }} WEBAPP_STORAGE_KEY: ${{ secrets.WEBAPP_STORAGE_KEY }} + TAKE_PHOTO_MODE_ENABLED: ${{ secrets.TAKE_PHOTO_MODE_ENABLED }} + OFFLINE_MODE_ENABLED: ${{ secrets.OFFLINE_MODE_ENABLED }} + DURABLE_CACHE_ENABLED: ${{ secrets.DURABLE_CACHE_ENABLED }} deploy-production: runs-on: ubuntu-latest @@ -184,7 +192,7 @@ jobs: env: API_SOCKET_URL: ${{ secrets.API_SOCKET_URL }} API_URL: ${{ secrets.API_URL }} - INSTRUMENTATION_KEY: ${{ secrets.WEBAPP_APPINSIGHTS_KEY }} + INSTRUMENTATION_KEY: ${{ secrets.APPINSIGHTS_KEY }} WEBAPP_ORIGIN: ${{ secrets.WEBAPP_ORIGIN }} WEBAPP_STORAGE_ACCOUNT: ${{ secrets.WEBAPP_STORAGE_ACCOUNT }} WEBAPP_STORAGE_KEY: ${{ secrets.WEBAPP_STORAGE_KEY }} diff --git a/packages/api/config/default.json b/packages/api/config/default.json index 2ca184727..015360b3b 100644 --- a/packages/api/config/default.json +++ b/packages/api/config/default.json @@ -29,7 +29,7 @@ }, "constants": { "defaultPageOffset": 0, - "defaultPageLimit": 10 + "defaultPageLimit": 1000 }, "security": { "jwtSecret": null, diff --git a/packages/api/src/db/CollectionBase.ts b/packages/api/src/db/CollectionBase.ts index eba9da21a..705522e10 100644 --- a/packages/api/src/db/CollectionBase.ts +++ b/packages/api/src/db/CollectionBase.ts @@ -90,7 +90,7 @@ export abstract class CollectionBase { /** * Finds a set of items - * @param pagination The pagiantion arguments + * @param pagination The pagination arguments * @param filter The filter criteria to apply, optional * @returns A DbListItem */ diff --git a/packages/api/src/interactors/query/ExportDataInteractor.ts b/packages/api/src/interactors/query/AllEngagementsInteractor.ts similarity index 77% rename from packages/api/src/interactors/query/ExportDataInteractor.ts rename to packages/api/src/interactors/query/AllEngagementsInteractor.ts index 5a5261c5c..7d91c28e3 100644 --- a/packages/api/src/interactors/query/ExportDataInteractor.ts +++ b/packages/api/src/interactors/query/AllEngagementsInteractor.ts @@ -3,7 +3,7 @@ * Licensed under the MIT license. See LICENSE file in the project. */ -import { QueryExportDataArgs, Engagement } from '@cbosuite/schema/dist/provider-types' +import { QueryAllEngagementsArgs, Engagement } from '@cbosuite/schema/dist/provider-types' import { createGQLEngagement } from '~dto' import { Interactor, RequestContext } from '~types' import { sortByDate } from '~utils' @@ -15,14 +15,14 @@ import { Telemetry } from '~components/Telemetry' const QUERY = {} @singleton() -export class ExportDataInteractor - implements Interactor +export class AllEngagementsInteractor + implements Interactor { public constructor(private engagements: EngagementCollection, private telemetry: Telemetry) {} public async execute( _: unknown, - { orgId }: QueryExportDataArgs, + { orgId }: QueryAllEngagementsArgs, ctx: RequestContext ): Promise { // out-of-org users should not export org data @@ -31,7 +31,7 @@ export class ExportDataInteractor } const result = await this.engagements.items(QUERY, { org_id: orgId }) - this.telemetry.trackEvent('ExportData') + this.telemetry.trackEvent('AllEngagements') return result.items .sort((a, b) => sortByDate({ date: a.start_date }, { date: b.start_date })) .map(createGQLEngagement) diff --git a/packages/api/src/interactors/query/GetEngagementsInteractorBase.ts b/packages/api/src/interactors/query/GetEngagementsInteractorBase.ts index 110a16f09..14a303c2d 100644 --- a/packages/api/src/interactors/query/GetEngagementsInteractorBase.ts +++ b/packages/api/src/interactors/query/GetEngagementsInteractorBase.ts @@ -8,7 +8,7 @@ import { QueryActiveEngagementsArgs, EngagementStatus } from '@cbosuite/schema/dist/provider-types' -import { Condition } from 'mongodb' +import { Condition, FilterQuery } from 'mongodb' import { Configuration } from '~components/Configuration' import { EngagementCollection } from '~db/EngagementCollection' import { DbEngagement } from '~db/types' @@ -26,7 +26,7 @@ export abstract class GetEngagementsInteractorBase public async execute( _: unknown, - { orgId, offset, limit }: QueryActiveEngagementsArgs, + { orgId, userId, offset, limit }: QueryActiveEngagementsArgs, ctx: RequestContext ): Promise { offset = offset ?? this.config.defaultPageOffset @@ -37,13 +37,16 @@ export abstract class GetEngagementsInteractorBase return empty } - const result = await this.engagements.items( - { offset, limit }, - { - org_id: orgId, - status: { $nin: [EngagementStatus.Closed, EngagementStatus.Completed] } - } - ) + const filter: FilterQuery = { + org_id: orgId, + status: { $nin: [EngagementStatus.Closed, EngagementStatus.Completed] } + } + + if (userId) { + filter.user_id = { $ne: userId as string } + } + + const result = await this.engagements.items({ offset, limit }, filter) return result.items.sort(this.sortBy).map(createGQLEngagement) } diff --git a/packages/api/src/interactors/query/GetUserActiveEngagementsInteractor.ts b/packages/api/src/interactors/query/GetUserActiveEngagementsInteractor.ts new file mode 100644 index 000000000..eae31bb9e --- /dev/null +++ b/packages/api/src/interactors/query/GetUserActiveEngagementsInteractor.ts @@ -0,0 +1,55 @@ +/*! + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project. + */ +import { + Engagement, + EngagementStatus, + QueryUserActiveEngagementsArgs +} from '@cbosuite/schema/dist/provider-types' +import { singleton } from 'tsyringe' +import { Configuration } from '~components/Configuration' +import { EngagementCollection } from '~db/EngagementCollection' +import { DbEngagement } from '~db/types' +import { createGQLEngagement } from '~dto' +import { Interactor, RequestContext } from '~types' +import { sortByDate } from '~utils' +import { empty } from '~utils/noop' + +@singleton() +export class GetUserActiveEngagementsInteractor + implements Interactor +{ + public constructor( + protected engagements: EngagementCollection, + protected config: Configuration + ) {} + + protected sortBy(a: DbEngagement, b: DbEngagement) { + return sortByDate({ date: a.end_date as string }, { date: b.end_date as string }) + } + public async execute( + _: unknown, + { orgId, userId, offset, limit }: QueryUserActiveEngagementsArgs, + ctx: RequestContext + ): Promise { + offset = offset ?? this.config.defaultPageOffset + limit = limit ?? this.config.defaultPageLimit + + // out-of-org users should not see org engagements + if (!ctx.identity?.roles.some((r) => r.org_id === orgId)) { + return empty + } + + const result = await this.engagements.items( + { offset, limit }, + { + org_id: orgId, + user_id: userId, + status: { $nin: [EngagementStatus.Closed, EngagementStatus.Completed] } + } + ) + + return result.items.sort(this.sortBy).map(createGQLEngagement) + } +} diff --git a/packages/api/src/resolvers/resolvers.ts b/packages/api/src/resolvers/resolvers.ts index 6b83c612d..36d6a7ea8 100644 --- a/packages/api/src/resolvers/resolvers.ts +++ b/packages/api/src/resolvers/resolvers.ts @@ -13,8 +13,9 @@ import { GetContactInteractor } from '~interactors/query/GetContactInteractor' import { GetContactsInteractor } from '~interactors/query/GetContactsInteractor' import { GetEngagementInteractor } from '~interactors/query/GetEngagementInteractor' import { GetActiveEngagementsInteractor } from '~interactors/query/GetActiveEngagementsInteractor' +import { GetUserActiveEngagementsInteractor } from '~interactors/query/GetUserActiveEngagementsInteractor' import { GetInactiveEngagementsInteractor } from '~interactors/query/GetInactiveEngagementsInteractor' -import { ExportDataInteractor } from '~interactors/query/ExportDataInteractor' +import { AllEngagementsInteractor } from '~interactors/query/AllEngagementsInteractor' import { GetServicesInteractor } from '~interactors/query/GetServicesInteractor' import { GetServicesAnswersInteractor } from '~interactors/query/GetServiceAnswersInteractor' import { AuthenticateInteractor } from '~interactors/mutation/AuthenticateInteractor' @@ -91,8 +92,9 @@ export const resolvers: Resolvers & IResolvers { isDurableCacheInitialized = true logger('durable cache is setup and enabled') @@ -37,3 +67,7 @@ export function getCache() { } return cache } + +export const isCacheInitialized = (): boolean => { + return isDurableCacheInitialized +} diff --git a/packages/webapp/src/api/createErrorLink.ts b/packages/webapp/src/api/createErrorLink.ts index 6a9c3bfa5..5e440d209 100644 --- a/packages/webapp/src/api/createErrorLink.ts +++ b/packages/webapp/src/api/createErrorLink.ts @@ -36,6 +36,6 @@ export function createErrorLink(history: History) { }) } -const UNAUTHENTICATED = 'UNAUTHENTICATED' +export const UNAUTHENTICATED = 'UNAUTHENTICATED' const TOKEN_EXPIRED = 'TOKEN_EXPIRED' const TOKEN_EXPIRED_ERROR = 'TokenExpiredError' diff --git a/packages/webapp/src/api/getHeaders.ts b/packages/webapp/src/api/getHeaders.ts index 77a7fe43c..1e7d8a5cf 100644 --- a/packages/webapp/src/api/getHeaders.ts +++ b/packages/webapp/src/api/getHeaders.ts @@ -3,7 +3,8 @@ * Licensed under the MIT license. See LICENSE file in the project. */ -import { retrieveAccessToken, retrieveLocale } from '~utils/localStorage' +import { retrieveLocale } from '~utils/localStorage' +import { getAccessToken, getCurrentUserId } from '~utils/localCrypto' export interface RequestHeaders { authorization?: string @@ -21,7 +22,8 @@ export function getHeaders(): RequestHeaders { if (typeof window === 'undefined') return {} // Get values from recoil local store - const accessToken = retrieveAccessToken() + const currentUserId = getCurrentUserId() + const accessToken = getAccessToken(currentUserId) const accept_language = retrieveLocale() // Return node friendly headers diff --git a/packages/webapp/src/api/index.ts b/packages/webapp/src/api/index.ts index 2167df916..b77e8b7cd 100644 --- a/packages/webapp/src/api/index.ts +++ b/packages/webapp/src/api/index.ts @@ -10,7 +10,7 @@ import type { History } from 'history' import { createHttpLink } from './createHttpLink' import { createWebSocketLink } from './createWebSocketLink' import { createErrorLink } from './createErrorLink' -import type QueueLink from '../utils/queueLink' +import type QueueLink from '~utils/queueLink' /** * Configures and creates the Apollo Client. @@ -24,12 +24,13 @@ const isNodeServer = typeof window === 'undefined' export function createApolloClient( history: History, - queueLink: QueueLink + queueLink: QueueLink, + reloadCache: boolean ): ApolloClient { return new ApolloClient({ ssrMode: isNodeServer, link: createRootLink(history, queueLink), - cache: getCache() + cache: getCache(reloadCache) }) } @@ -54,3 +55,5 @@ function isSubscriptionOperation({ query }: Operation) { const definition = getMainDefinition(query) return definition.kind === 'OperationDefinition' && definition.operation === 'subscription' } + +export { UNAUTHENTICATED } from './createErrorLink' diff --git a/packages/webapp/src/api/local-forage-encrypted-wrapper.ts b/packages/webapp/src/api/local-forage-encrypted-wrapper.ts new file mode 100644 index 000000000..c0406b175 --- /dev/null +++ b/packages/webapp/src/api/local-forage-encrypted-wrapper.ts @@ -0,0 +1,69 @@ +/*! + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project. + */ +import { LocalForageWrapper } from 'apollo3-cache-persist' +import * as CryptoJS from 'crypto-js' +import { currentUserStore } from '~utils/current-user-store' +import { checkSalt, setPwdHash, setCurrentUserId, getCurrentUserId } from '~utils/localCrypto' + +export class LocalForageWrapperEncrypted extends LocalForageWrapper { + constructor( + storage: LocalForageInterface, + user = 'inituser', // need a default uid and pwd to load. Actual info will be stored after login. + passwd = 'notusedbyusers' + ) { + super(storage) + const currentUser = getCurrentUserId() + if (!currentUser) { + checkSalt(user) + setPwdHash(user, passwd) + setCurrentUserId(user) + } + } + + getItem(key: string): Promise { + const currentUid = getCurrentUserId() + return super.getItem(currentUid.concat('-', key)).then((item) => { + if (item != null && item.length > 0) { + return this.decrypt(item, currentUid) + } + return null + }) + } + + removeItem(key: string): Promise { + const currentUid = getCurrentUserId() + return super.removeItem(currentUid.concat('-', key)) + } + + setItem(key: string, value: string | object | null): Promise { + const currentUid = getCurrentUserId() + const secData = this.encrypt(value, currentUid) + return super.setItem(currentUid.concat('-', key), secData) + } + + private encrypt(data, currentUid): string { + if (!currentUserStore.state.sessionPassword) { + return + } + const edata = CryptoJS.AES.encrypt(data, currentUserStore.state.sessionPassword).toString() + return edata + } + + private decrypt(cdata, currentUid): string { + if (!currentUserStore.state.sessionPassword) { + return null + } + + const dataBytes = CryptoJS.AES.decrypt(cdata, currentUserStore.state.sessionPassword) + return dataBytes.toString(CryptoJS.enc.Utf8) + } +} + +interface LocalForageInterface { + // Actual type definition: https://github.com/localForage/localForage/blob/master/typings/localforage.d.ts#L17 + getItem(key: string): Promise + setItem(key: string, value: string | object | null): Promise + removeItem(key: string): Promise +} diff --git a/packages/webapp/src/api/queries.ts b/packages/webapp/src/api/queries.ts new file mode 100644 index 000000000..067a071d3 --- /dev/null +++ b/packages/webapp/src/api/queries.ts @@ -0,0 +1,84 @@ +/*! + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project. + */ + +import { gql } from '@apollo/client' + +/* + * Exhaustive List of ALL GraphQL queries (Query & Mutations). + */ + +// ENGAGEMENTS === Request + +import { EngagementFields } from '../hooks/api/fragments' + +export const GET_ENGAGEMENTS = gql` + ${EngagementFields} + + query allEngagements($orgId: String!) { + allEngagements(orgId: $orgId) { + ...EngagementFields + } + } +` + +export const GET_ACTIVES_ENGAGEMENTS = gql` + ${EngagementFields} + + query activeEngagements($orgId: String!) { + activeEngagements(orgId: $orgId) { + ...EngagementFields + } + } +` + +export const GET_USER_ACTIVES_ENGAGEMENTS = gql` + ${EngagementFields} + + query activeEngagements($orgId: String!, $userId: String!) { + activeEngagements(orgId: $orgId, userId: $userId) { + ...EngagementFields + } + userActiveEngagements(orgId: $orgId, userId: $userId) { + ...EngagementFields + } + } +` + +export const GET_INACTIVE_ENGAGEMENTS = gql` + ${EngagementFields} + + query inactiveEngagements($orgId: String!) { + inactiveEngagements(orgId: $orgId) { + ...EngagementFields + } + } +` + +export const SUBSCRIBE_TO_ORG_ENGAGEMENTS = gql` + ${EngagementFields} + + subscription engagementUpdate($orgId: String!) { + engagements(orgId: $orgId) { + message + action + engagement { + ...EngagementFields + } + } + } +` + +export const CREATE_ENGAGEMENT = gql` + ${EngagementFields} + + mutation createEngagement($engagement: EngagementInput!) { + createEngagement(engagement: $engagement) { + message + engagement { + ...EngagementFields + } + } + } +` diff --git a/packages/webapp/src/components/app/App.tsx b/packages/webapp/src/components/app/App.tsx index c9f93db5e..f5a3a62aa 100644 --- a/packages/webapp/src/components/app/App.tsx +++ b/packages/webapp/src/components/app/App.tsx @@ -12,6 +12,7 @@ import type { FC } from 'react' import { memo } from 'react' import { BrowserRouter } from 'react-router-dom' import { config } from '~utils/config' +import { RecoilRoot } from 'recoil' export const App: FC = memo(function App() { // Set the environment name as an attribute @@ -24,15 +25,17 @@ export const App: FC = memo(function App() { return ( - - - - - - - - - + + + + + + + + + + + ) diff --git a/packages/webapp/src/components/app/Routes.tsx b/packages/webapp/src/components/app/Routes.tsx index 8e91a04df..859c4b3f7 100644 --- a/packages/webapp/src/components/app/Routes.tsx +++ b/packages/webapp/src/components/app/Routes.tsx @@ -10,6 +10,9 @@ import { AuthorizedRoutes } from './AuthorizedRoutes' import { ApplicationRoute } from '~types/ApplicationRoute' import { useCurrentUser } from '~hooks/api/useCurrentUser' import { LoadingPlaceholder } from '~ui/LoadingPlaceholder' +import { config } from '~utils/config' +import { currentUserStore } from '~utils/current-user-store' + const logger = createLogger('Routes') const Login = lazy(() => /* webpackChunkName: "LoginPage" */ import('~pages/login')) @@ -20,6 +23,15 @@ const PasswordReset = lazy( export const Routes: FC = memo(function Routes() { const location = useLocation() const { currentUser } = useCurrentUser() + + // When saving encrypted data (durableCache), a session key is required (stored during login) + if (Boolean(config.features.durableCache.enabled)) { + const sessionPassword = currentUserStore.state.sessionPassword + if (!sessionPassword) { + location.pathname = '/login' + } + } + useEffect(() => { logger('routes rendering', location.pathname) }, [location.pathname]) diff --git a/packages/webapp/src/components/app/Stateful.tsx b/packages/webapp/src/components/app/Stateful.tsx index 64e8b2d7f..ed3a2d898 100644 --- a/packages/webapp/src/components/app/Stateful.tsx +++ b/packages/webapp/src/components/app/Stateful.tsx @@ -7,18 +7,20 @@ import type { History } from 'history' import type { FC } from 'react' import { useEffect, memo } from 'react' import { useHistory } from 'react-router-dom' -import { RecoilRoot } from 'recoil' +import { useRecoilState } from 'recoil' import { createApolloClient } from '~api' -import QueueLink from '../../utils/queueLink' +import QueueLink from '~utils/queueLink' import { useOffline } from '~hooks/useOffline' +import { sessionPasswordState } from '~store' // Create an Apollo Link to queue request while offline const queueLink = new QueueLink() export const Stateful: FC = memo(function Stateful({ children }) { const history: History = useHistory() as any - const apiClient = createApolloClient(history, queueLink) + let apiClient = createApolloClient(history, queueLink, false) const isOffline = useOffline() + const [sessionPassword] = useRecoilState(sessionPasswordState) useEffect(() => { if (isOffline) { @@ -28,9 +30,13 @@ export const Stateful: FC = memo(function Stateful({ children }) { } }, [isOffline]) - return ( - - {children} - - ) + useEffect(() => { + if (sessionPassword) { + // TODO: fix lint error generated by line below + // eslint-disable-next-line + apiClient = createApolloClient(history, queueLink, true) + } + }, [sessionPassword]) + + return {children} }) diff --git a/packages/webapp/src/components/forms/AddClientForm/index.tsx b/packages/webapp/src/components/forms/AddClientForm/index.tsx index 3a373f64c..2b0806b13 100644 --- a/packages/webapp/src/components/forms/AddClientForm/index.tsx +++ b/packages/webapp/src/components/forms/AddClientForm/index.tsx @@ -438,6 +438,7 @@ export const AddClientForm: StandardFC = wrap(function AddCl {t('addClient.buttons.createClient')} diff --git a/packages/webapp/src/components/forms/AddRequestForm/index.tsx b/packages/webapp/src/components/forms/AddRequestForm/index.tsx index daff4bdae..dbe03d27d 100644 --- a/packages/webapp/src/components/forms/AddRequestForm/index.tsx +++ b/packages/webapp/src/components/forms/AddRequestForm/index.tsx @@ -80,7 +80,7 @@ export const AddRequestForm: StandardFC = wrap(function Add onSubmit, showAssignSpecialist = true }) { - const { t } = useTranslation(Namespace.Requests) + const { c, t } = useTranslation(Namespace.Requests) const { orgId } = useCurrentUser() const location = useLocation() const [locale] = useLocale() @@ -201,7 +201,7 @@ export const AddRequestForm: StandardFC = wrap(function Add placeholder={t('addRequestFields.addEndDatePlaceholder')} allowTextInput showMonthPickerAsOverlay={false} - ariaLabel={t('formElements.datePickerAriaLabel')} + ariaLabel={c('formElements.datePickerAriaLabel')} value={values.endDate ? new Date(values.endDate) : null} onSelectDate={(date) => { setFieldValue('endDate', date) @@ -255,6 +255,7 @@ export const AddRequestForm: StandardFC = wrap(function Add {t('addRequestButtons.createRequest')} diff --git a/packages/webapp/src/components/forms/LoginForm/index.tsx b/packages/webapp/src/components/forms/LoginForm/index.tsx index b84bf1d60..f9a6201b4 100644 --- a/packages/webapp/src/components/forms/LoginForm/index.tsx +++ b/packages/webapp/src/components/forms/LoginForm/index.tsx @@ -10,7 +10,11 @@ import { FormikField } from '~ui/FormikField' import { Formik, Form } from 'formik' import cx from 'classnames' import { useAuthUser } from '~hooks/api/useAuth' +import { useRecoilState } from 'recoil' import { useCallback, useState } from 'react' +import { useHistory } from 'react-router-dom' +import { currentUserState, sessionPasswordState } from '~store' +import type { User } from '@cbosuite/schema/dist/client-types' import { Namespace, useTranslation } from '~hooks/useTranslation' import { FormSectionTitle } from '~components/ui/FormSectionTitle' import { wrap } from '~utils/appinsights' @@ -18,6 +22,26 @@ import { Checkbox } from '@fluentui/react' import { noop } from '~utils/noop' import { useNavCallback } from '~hooks/useNavCallback' import { ApplicationRoute } from '~types/ApplicationRoute' +import { + getUser, + testPassword, + APOLLO_KEY, + setPreQueueLoadRequired, + setCurrentUserId +} from '~utils/localCrypto' +import { createLogger } from '~utils/createLogger' +import localforage from 'localforage' +import { config } from '~utils/config' +import { useStore } from 'react-stores' +import { currentUserStore } from '~utils/current-user-store' +import * as CryptoJS from 'crypto-js' +import { StatusType } from '~hooks/api' +import { useOffline } from '~hooks/useOffline' +import { navigate } from '~utils/navigate' +import { OfflineEntityCreationNotice } from '~components/ui/OfflineEntityCreationNotice' +import { UNAUTHENTICATED } from '~api' + +const logger = createLogger('authenticate') interface LoginFormProps { onLoginClick?: (status: string) => void @@ -28,21 +52,89 @@ export const LoginForm: StandardFC = wrap(function LoginForm({ onLoginClick = noop, error }) { + const isDurableCacheEnabled = Boolean(config.features.durableCache.enabled) + const localUserStore = useStore(currentUserStore) const { t } = useTranslation(Namespace.Login) const { login } = useAuthUser() const [acceptedAgreement, setAcceptedAgreement] = useState(false) + const isOffline = useOffline() + const [, setCurrentUser] = useRecoilState(currentUserState) + const [, setSessionPassword] = useRecoilState(sessionPasswordState) + + const history = useHistory() const handleLoginClick = useCallback( async (values) => { const resp = await login(values.username, values.password) + + if (isDurableCacheEnabled) { + setPreQueueLoadRequired() + setCurrentUserId(values.username) + const onlineAuthStatus = resp.status === 'SUCCESS' + const offlineAuthStatus = testPassword(values.username, values.password) + localUserStore.username = values.username + if (onlineAuthStatus && offlineAuthStatus) { + // Store session password in react store so we can use it in LocalForageWrapperEncrypted class (recoil hook cannot be used in class) + // Store the session password in recoil so we can trigger loading and decrypting the cache from persistent storage (see Stateful.tsx) + localUserStore.sessionPassword = CryptoJS.SHA512(values.password).toString( + CryptoJS.enc.Hex + ) + setSessionPassword(localUserStore.sessionPassword) + + logger('Online and offline authentication successful!') + } else if (onlineAuthStatus && !offlineAuthStatus) { + // Store session password in react store so we can use it in LocalForageWrapperEncrypted class (recoil hook cannot be used in class) + // Store the session password in recoil so we can trigger loading and decrypting the cache from persistent storage (see Stateful.tsx) + localUserStore.sessionPassword = CryptoJS.SHA512(values.password).toString( + CryptoJS.enc.Hex + ) + setSessionPassword(localUserStore.sessionPassword) + + localforage + .removeItem(values.username.concat(APOLLO_KEY)) + .then(() => logger(`Apollo persistent storage has been cleared.`)) + logger('Password seems to have changed, clearing stored encrypted data.') + } else if (!onlineAuthStatus && offlineAuthStatus && isOffline) { + // Store session password in react store so we can use it in LocalForageWrapperEncrypted class (recoil hook cannot be used in class) + // Store the session password in recoil so we can trigger loading and decrypting the cache from persistent storage (see Stateful.tsx) + localUserStore.sessionPassword = CryptoJS.SHA512(values.password).toString( + CryptoJS.enc.Hex + ) + setSessionPassword(localUserStore.sessionPassword) + + const userJsonString = getUser(values.username) + const user = JSON.parse(userJsonString) + setCurrentUser(user) + resp.status = StatusType.Success + + logger('Offline authentication successful') + } else if (!offlineAuthStatus && isOffline) { + navigate(history, ApplicationRoute.Login, { error: UNAUTHENTICATED }) + + logger('Handle offline login failure: WIP/TBD, limited retry?') + } else { + logger('Durable cache authentication problem.') + } + } + onLoginClick(resp.status) }, - [login, onLoginClick] + [ + login, + onLoginClick, + isDurableCacheEnabled, + localUserStore, + isOffline, + setCurrentUser, + history, + setSessionPassword + ] ) const handlePasswordResetClick = useNavCallback(ApplicationRoute.PasswordReset) return ( <> +

{t('login.title')}

diff --git a/packages/webapp/src/components/lists/InactiveRequestList/index.tsx b/packages/webapp/src/components/lists/InactiveRequestList/index.tsx index 5ce964700..a3c003c7c 100644 --- a/packages/webapp/src/components/lists/InactiveRequestList/index.tsx +++ b/packages/webapp/src/components/lists/InactiveRequestList/index.tsx @@ -2,7 +2,7 @@ * Copyright (c) Microsoft. All rights reserved. * Licensed under the MIT license. See LICENSE file in the project. */ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useWindowSize } from '~hooks/useWindowSize' import type { StandardFC } from '~types/StandardFC' import type { Engagement } from '@cbosuite/schema/dist/client-types' @@ -12,33 +12,47 @@ import styles from './index.module.scss' import { wrap } from '~utils/appinsights' import { usePageColumns, useMobileColumns } from './columns' import { useEngagementSearchHandler } from '~hooks/useEngagementSearchHandler' +import { Namespace, useTranslation } from '~hooks/useTranslation' -interface InactiveRequestListProps { - title: string - requests?: Engagement[] - loading?: boolean - onPageChange?: (items: Engagement[], currentPage: number) => void +type RequestsListProps = { + engagements: Engagement[] + loading: boolean } -export const InactiveRequestList: StandardFC = wrap( - function InactiveRequestList({ title, requests, loading, onPageChange }) { +export const InactiveRequestList: StandardFC = wrap( + function InactiveRequestList({ engagements, loading }) { + const { t } = useTranslation(Namespace.Requests) const { isMD } = useWindowSize() - const [filteredList, setFilteredList] = useState(requests) - const searchList = useEngagementSearchHandler(requests, setFilteredList) + + const [filteredList, setFilteredList] = useState(engagements) + const searchList = useEngagementSearchHandler(engagements, setFilteredList) + + // Update the filteredList when useQuery triggers. + // TODO: This is an ugly hack based on the fact that the search is handle here, + // but triggered by a child component. PaginatedList component needs to be fixed. + useEffect(() => { + if (engagements) { + const searchField = document.querySelector( + '.inactiveRequestList input[type=text]' + ) as HTMLInputElement + searchList(searchField?.value ?? '') + } + }, [engagements, searchList]) + const pageColumns = usePageColumns() const mobileColumns = useMobileColumns() + return (
void - onEdit?: (form: any) => void +type RequestsListProps = { + engagements: Engagement[] + loading: boolean } -export const MyRequestsList: StandardFC = wrap(function MyRequestsList({ - title, - requests, - loading, - onEdit = noop, - onPageChange = noop +export const MyRequestsList: StandardFC = wrap(function MyRequestsList({ + engagements, + loading }) { const { t } = useTranslation(Namespace.Requests) const { isMD } = useWindowSize() - const [isEditFormOpen, { setTrue: openEditRequestPanel, setFalse: dismissEditRequestPanel }] = - useBoolean(false) + const { userId, orgId } = useCurrentUser() + const { editEngagement } = useEngagementList(orgId, userId) - const [filteredList, setFilteredList] = useState(requests) + const [isEditFormOpen, { setTrue: openPanel, setFalse: dismissPanel }] = useBoolean(false) + const [filteredList, setFilteredList] = useState(engagements) const [engagement, setSelectedEngagement] = useState() - const searchList = useEngagementSearchHandler(requests, setFilteredList) + const searchList = useEngagementSearchHandler(engagements, setFilteredList) + + // Update the filteredList when useQuery triggers. + // TODO: This is an ugly hack based on the fact that the search is handle here, + // but triggered by a child component. PaginatedList component needs to be fixed. + useEffect(() => { + if (engagements) { + const searchField = document.querySelector( + '.myRequestList input[type=text]' + ) as HTMLInputElement + searchList(searchField?.value ?? '') + } + }, [engagements, searchList]) const handleEdit = (values: EngagementInput) => { - dismissEditRequestPanel() - onEdit(values) + dismissPanel() + editEngagement(values) } const columnActionButtons: IMultiActionButtons[] = [ @@ -54,7 +69,7 @@ export const MyRequestsList: StandardFC = wrap(function MyRe className: cx(styles.editButton), onActionClick(engagement: Engagement) { setSelectedEngagement(engagement) - openEditRequestPanel() + openPanel() } } ] @@ -62,25 +77,26 @@ export const MyRequestsList: StandardFC = wrap(function MyRe const pageColumns = usePageColumns(columnActionButtons) const mobileColumns = useMobileColumns(columnActionButtons) + const rowClassName = isMD ? 'align-items-center' : undefined + return ( <>
- + = wrap(function ReportList( } = useFilteredData(unfilteredData, setFilteredData) // Exporting const { downloadCSV, setCsvFields, csvFields } = useCsvExport(filteredData) - const { print } = usePrinter() + const print = usePrinter() // Top-row options const [reportType, setReportType] = useRecoilState(selectedReportTypeState) @@ -154,8 +154,8 @@ export const ReportList: StandardFC = wrap(function ReportList( ) const areFiltersApplied = useCallback(() => { - return Object.values(hiddenFields).filter((field) => !!field).length > 0 - }, [hiddenFields]) + return unfilteredData.length > filteredData.length + }, [filteredData, unfilteredData]) const handlePrint = useCallback(() => { const printableData = [] diff --git a/packages/webapp/src/components/lists/ReportList/usePrinter.ts b/packages/webapp/src/components/lists/ReportList/usePrinter.ts index 9476e6a9d..5c0b9afaa 100644 --- a/packages/webapp/src/components/lists/ReportList/usePrinter.ts +++ b/packages/webapp/src/components/lists/ReportList/usePrinter.ts @@ -19,7 +19,7 @@ export function usePrinter() { ) const { orgId } = useCurrentUser() - const print = useCallback( + return useCallback( function print( printableJsonData: Array, printableFields: Array, @@ -63,8 +63,4 @@ export function usePrinter() { }, [t, orgId] ) - - return { - print - } } diff --git a/packages/webapp/src/components/lists/RequestList/index.tsx b/packages/webapp/src/components/lists/RequestList/index.tsx index 97d51e74c..ace6998d5 100644 --- a/packages/webapp/src/components/lists/RequestList/index.tsx +++ b/packages/webapp/src/components/lists/RequestList/index.tsx @@ -2,79 +2,90 @@ * Copyright (c) Microsoft. All rights reserved. * Licensed under the MIT license. See LICENSE file in the project. */ + import { useBoolean } from '@fluentui/react-hooks' -import { useCallback, useState } from 'react' +import { useEffect, useState } from 'react' import { EditRequestForm } from '~forms/EditRequestForm' -import { useWindowSize } from '~hooks/useWindowSize' import { Panel } from '~ui/Panel' import type { StandardFC } from '~types/StandardFC' import type { Engagement, EngagementInput } from '@cbosuite/schema/dist/client-types' import { PaginatedList } from '~components/ui/PaginatedList' import cx from 'classnames' import styles from './index.module.scss' -import { Namespace, useTranslation } from '~hooks/useTranslation' + +// Utils import { wrap } from '~utils/appinsights' -import { noop } from '~utils/noop' + +// Hooks import { useMobileColumns, usePageColumns } from './columns' +import { useCurrentUser } from '~hooks/api/useCurrentUser' +import { useEngagementList } from '~hooks/api/useEngagementList' import { useEngagementSearchHandler } from '~hooks/useEngagementSearchHandler' +import { Namespace, useTranslation } from '~hooks/useTranslation' +import { useWindowSize } from '~hooks/useWindowSize' -interface RequestListProps { - title: string - requests?: Engagement[] - loading?: boolean - onPageChange?: (items: Engagement[], currentPage: number) => void - onEdit?: (form: any) => void - onClaim?: (form: any) => void +type RequestsListProps = { + engagements: Engagement[] + loading: boolean } -export const RequestList: StandardFC = wrap(function RequestList({ - title, - requests, - loading, - onEdit = noop, - onClaim = noop, - onPageChange = noop +export const RequestList: StandardFC = wrap(function RequestList({ + engagements, + loading }) { const { t } = useTranslation(Namespace.Requests) const { isMD } = useWindowSize() + const { userId, orgId } = useCurrentUser() + const { editEngagement, claimEngagement } = useEngagementList(orgId, userId) + + const [filteredList, setFilteredList] = useState(engagements) const [isEditFormOpen, { setTrue: openEditRequestPanel, setFalse: dismissEditRequestPanel }] = useBoolean(false) - const [filteredList, setFilteredList] = useState(requests) const [selectedEngagement, setSelectedEngagement] = useState() - const searchList = useEngagementSearchHandler(requests, setFilteredList) + const searchList = useEngagementSearchHandler(engagements, setFilteredList) - const handleEdit = useCallback( - (values: EngagementInput) => { - dismissEditRequestPanel() - onEdit(values) - }, - [onEdit, dismissEditRequestPanel] - ) + // Update the filteredList when useQuery triggers. + // TODO: This is an ugly hack based on the fact that the search is handle here, + // but triggered by a child component. PaginatedList component needs to be fixed. + useEffect(() => { + if (engagements) { + const searchField = document.querySelector( + '.requestList input[type=text]' + ) as HTMLInputElement + searchList(searchField?.value ?? '') + } + }, [engagements, searchList]) + + const handleEdit = (values: EngagementInput) => { + dismissEditRequestPanel() + editEngagement(values) + } + + const handleOnEdit = (engagement: Engagement) => { + setSelectedEngagement(engagement) + openEditRequestPanel() + } - const handleOnEdit = useCallback( - (engagement: Engagement) => { - setSelectedEngagement(engagement) - openEditRequestPanel() - }, - [setSelectedEngagement, openEditRequestPanel] + const pageColumns = usePageColumns((form: any) => claimEngagement(form.id, userId), handleOnEdit) + const mobileColumn = useMobileColumns( + (form: any) => claimEngagement(form.id, userId), + handleOnEdit ) - const pageColumns = usePageColumns(onClaim, handleOnEdit) - const mobileColumn = useMobileColumns(onClaim, handleOnEdit) + const rowClassName = isMD ? 'align-items-center' : undefined return ( <>
= wrap(function ServiceLi const { logout } = useAuthUser() const onLogout = useNavCallback(ApplicationRoute.Logout) + useEffect(() => { + setFilteredList(services) + }, [services]) + return (
= memo(function ActionBar({ title }) { const { isMD } = useWindowSize() const { c } = useTranslation() - const showEnvironmentInfo = 'show-environment-info' + function hideEnvironmentInfo(event: React.MouseEvent) { // We are only interested on the header const header = (event?.target as HTMLElement)?.closest('header') diff --git a/packages/webapp/src/components/ui/EngagementStatusColumnItem/index.tsx b/packages/webapp/src/components/ui/EngagementStatusColumnItem/index.tsx index 027e9dd60..f9b8d4a63 100644 --- a/packages/webapp/src/components/ui/EngagementStatusColumnItem/index.tsx +++ b/packages/webapp/src/components/ui/EngagementStatusColumnItem/index.tsx @@ -8,14 +8,16 @@ import type { FC } from 'react' import { memo } from 'react' import { Namespace, useTranslation } from '~hooks/useTranslation' import { UsernameTag } from '~ui/UsernameTag' +import { isLocal } from '~utils/engagements' export const EngagementStatusColumnItem: FC<{ engagement: Engagement }> = memo( function EngagementStatusColumnItem({ engagement }) { const { t } = useTranslation(Namespace.Requests) + const local: string = isLocal(engagement) ? t('requestStatus.local').concat(' - ') : '' if (engagement.user) { return (
- {t('requestStatus.assigned')}:{' '} + {local.concat(t('requestStatus.assigned'))}:{' '} = memo(
) } else { - return t('requestStatus.notStarted') + return <>{local.concat(t('requestStatus.notStarted'))} } } ) diff --git a/packages/webapp/src/components/ui/FormGenerator/ContactForm.tsx b/packages/webapp/src/components/ui/FormGenerator/ContactForm.tsx index 6bea1513e..946d5ffb3 100644 --- a/packages/webapp/src/components/ui/FormGenerator/ContactForm.tsx +++ b/packages/webapp/src/components/ui/FormGenerator/ContactForm.tsx @@ -19,6 +19,7 @@ import { useRecoilValue } from 'recoil' import { addedContactState } from '~store' import { useOrganization } from '~hooks/api/useOrganization' import { useCurrentUser } from '~hooks/api/useCurrentUser' +import { LOCAL_ONLY_ID_PREFIX } from '~constants' export const ContactForm: FC<{ previewMode: boolean @@ -59,9 +60,28 @@ export const ContactForm: FC<{ // When adding a contact in kiosk mode, we want to trigger the same update as if // we had selected a one from the dropdown. useEffect(() => { - if (addedContact) { - const newContactOption = transformClient(addedContact) - const allFormContacts = kioskMode ? [newContactOption] : [...contacts, newContactOption] + if (addedContact && addedContact.contact) { + const newContactOption = transformClient(addedContact.contact) + + let allFormContacts = [] + const localContactIndex = contacts.findIndex( + (contact) => contact.value === addedContact.localId + ) + + if (kioskMode) { + // We only show one client in kiosk mode + allFormContacts = [newContactOption] + } else if ( + addedContact.contact.id.startsWith(LOCAL_ONLY_ID_PREFIX) || + localContactIndex < 0 + ) { + // addedContact is a locally created contact or one that we haven't seen yet, so add it to the list + allFormContacts = [...contacts, newContactOption] + } else { + // addedContact must be a server persisted contact, so replace the locally created contact with this + allFormContacts = [...contacts] + allFormContacts[localContactIndex] = newContactOption + } updateContacts(allFormContacts) } }, [addedContact, kioskMode, mgr, onChange, onContactsChange]) // eslint-disable-line react-hooks/exhaustive-deps diff --git a/packages/webapp/src/components/ui/FormGenerator/hooks.ts b/packages/webapp/src/components/ui/FormGenerator/hooks.ts index cefacf6f3..b0d420e8c 100644 --- a/packages/webapp/src/components/ui/FormGenerator/hooks.ts +++ b/packages/webapp/src/components/ui/FormGenerator/hooks.ts @@ -7,6 +7,7 @@ import type { Contact, ServiceAnswerInput, ServiceAnswer } from '@cbosuite/schem import { useCallback, useEffect } from 'react' import type { FormFieldManager } from './FormFieldManager' import { addedContactState } from '~store' +import type { AddedContactState } from '~hooks/api/useContacts/useCreateContactCallback' import { useRecoilState } from 'recoil' export function useSubmitHandler( @@ -14,7 +15,8 @@ export function useSubmitHandler( contacts: Contact[], onSubmit: (answer: ServiceAnswerInput) => void ) { - const [, setAddedContact] = useRecoilState(addedContactState) + const [, setAddedContact] = useRecoilState(addedContactState) + return useCallback(() => { onSubmit({ ...mgr.value }) mgr.reset() diff --git a/packages/webapp/src/components/ui/HappySubmitButton/index.tsx b/packages/webapp/src/components/ui/HappySubmitButton/index.tsx deleted file mode 100644 index 3919da87a..000000000 --- a/packages/webapp/src/components/ui/HappySubmitButton/index.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/*! - * Copyright (c) Microsoft. All rights reserved. - * Licensed under the MIT license. See LICENSE file in the project. - */ -import { PrimaryButton } from '@fluentui/react' -import { memo, useCallback, useState } from 'react' -import Confetti from 'react-dom-confetti' -import type { StandardFC } from '~types/StandardFC' - -const confettiConfig = { - angle: 90, - spread: 230, - startVelocity: 40, - elementCount: 60, - dragFriction: 0.36, - duration: 2000, - stagger: 3, - width: '6px', - height: '6px', - perspective: '500px', - colors: ['#a864fd', '#29cdff', '#78ff44', '#ff718d', '#fdff6a'] -} - -interface HappySubmitButtonProps { - title?: string - text?: string - options?: Record - clickFunction?: () => void -} - -export const HappySubmitButton: StandardFC = memo( - function HappySubmitButton({ options, children, text, className, clickFunction }) { - const [active, setActive] = useState(false) - const config = { - ...confettiConfig, - ...options - } - - const handleClick = useCallback(() => { - if (!active) { - setActive(true) - if (clickFunction) { - clickFunction() - } - setTimeout(() => { - setActive(false) - }, 200) - } - }, [active, clickFunction]) - - return ( - - - - {children} - - ) - } -) diff --git a/packages/webapp/src/components/ui/NewFormPanel/NewFormPanel.tsx b/packages/webapp/src/components/ui/NewFormPanel/NewFormPanel.tsx index 12a6225c4..88a9722ea 100644 --- a/packages/webapp/src/components/ui/NewFormPanel/NewFormPanel.tsx +++ b/packages/webapp/src/components/ui/NewFormPanel/NewFormPanel.tsx @@ -30,30 +30,29 @@ export const NewFormPanel: FC = memo(function NewFormPanel({ onNewFormPanelDismiss = noop, kioskMode = false }) { - const [isNewFormPanelOpen, { setTrue: openNewFormPanel, setFalse: dismissNewFormPanel }] = - useBoolean(false) + const [isOpen, { setTrue: open, setFalse: dismiss }] = useBoolean(false) const { t: clientT } = useTranslation(Namespace.Clients) - const [newFormPanelNameState, setNewFormPanelName] = useState(newFormPanelName) + const [nameState, setNameState] = useState(newFormPanelName) - const handleNewFormPanelDismiss = useCallback(() => { - dismissNewFormPanel() + const handleDismiss = useCallback(() => { + dismiss() onNewFormPanelDismiss() - }, [dismissNewFormPanel, onNewFormPanelDismiss]) + }, [dismiss, onNewFormPanelDismiss]) - const handleNewFormPanelSubmit = useCallback( + const handleSubmit = useCallback( (values: any) => { - onNewFormPanelSubmit(values, newFormPanelNameState) - handleNewFormPanelDismiss() + onNewFormPanelSubmit(values, nameState) + handleDismiss() }, - [onNewFormPanelSubmit, handleNewFormPanelDismiss, newFormPanelNameState] + [onNewFormPanelSubmit, handleDismiss, nameState] ) const handleQuickActionsButton = useCallback( (buttonName: string) => { - setNewFormPanelName(buttonName) + setNameState(buttonName) }, - [setNewFormPanelName] + [setNameState] ) const renderNewFormPanel = useCallback( @@ -64,11 +63,11 @@ export const NewFormPanel: FC = memo(function NewFormPanel({ ) case 'addRequestForm': - return + return case 'quickActionsPanel': return case 'startServiceForm': @@ -77,30 +76,21 @@ export const NewFormPanel: FC = memo(function NewFormPanel({ return null } }, - [ - clientT, - handleNewFormPanelDismiss, - handleNewFormPanelSubmit, - handleQuickActionsButton, - newClientName - ] + [clientT, handleDismiss, handleSubmit, handleQuickActionsButton, newClientName] ) useEffect(() => { - setNewFormPanelName(newFormPanelName) + setNameState(newFormPanelName) if (showNewFormPanel) { - openNewFormPanel() + open() } else { - dismissNewFormPanel() + dismiss() } - }, [showNewFormPanel, newFormPanelName, openNewFormPanel, dismissNewFormPanel]) + }, [showNewFormPanel, newFormPanelName, open, dismiss]) + return ( - - {newFormPanelNameState && renderNewFormPanel(newFormPanelNameState)} + + {nameState && renderNewFormPanel(nameState)} ) }) diff --git a/packages/webapp/src/components/ui/OfflineEntityCreationNotice/index.tsx b/packages/webapp/src/components/ui/OfflineEntityCreationNotice/index.tsx index b82ef1eed..22cb6b47f 100644 --- a/packages/webapp/src/components/ui/OfflineEntityCreationNotice/index.tsx +++ b/packages/webapp/src/components/ui/OfflineEntityCreationNotice/index.tsx @@ -2,19 +2,20 @@ * Copyright (c) Microsoft. All rights reserved. * Licensed under the MIT license. See LICENSE file in the project. */ +import type { FC } from 'react' import { wrap } from '~utils/appinsights' import { useOffline } from '~hooks/useOffline' import { useTranslation } from '~hooks/useTranslation' import styles from './index.module.scss' import cx from 'classnames' -export const OfflineEntityCreationNotice = wrap(function OfflineEntityCreationNotice() { - const isOffline = useOffline() - const { c } = useTranslation() +export const OfflineEntityCreationNotice: FC<{ isEntityCreation?: boolean }> = wrap( + function OfflineEntityCreationNotice({ isEntityCreation = true }) { + const isOffline = useOffline() + const { c } = useTranslation() - return ( - <> - {isOffline &&
{c('offline.entityCreationNotice')}
} - - ) -}) + const notice = isEntityCreation ? c('offline.entityCreationNotice') : c('offline.generalNotice') + + return <>{isOffline &&
{notice}
} + } +) diff --git a/packages/webapp/src/components/ui/RequestPanelBody/index.tsx b/packages/webapp/src/components/ui/RequestPanelBody/index.tsx index 0a60cf5a0..50afc93fc 100644 --- a/packages/webapp/src/components/ui/RequestPanelBody/index.tsx +++ b/packages/webapp/src/components/ui/RequestPanelBody/index.tsx @@ -8,7 +8,6 @@ import cx from 'classnames' import { Col, Row } from 'react-bootstrap' import { PrimaryButton, DefaultButton } from '@fluentui/react' import { ShortString } from '~ui/ShortString' -import { HappySubmitButton } from '~ui/HappySubmitButton' import { SpecialistSelect } from '~ui/SpecialistSelect' import { FormikSubmitButton } from '~components/ui/FormikSubmitButton' import { RequestActionHistory } from '~lists/RequestActionHistory' @@ -89,8 +88,8 @@ export const RequestPanelBody: StandardFC = memo(function setTimeout(onClose, 500) } - const handleCloseRequest = async () => { - await setStatus(EngagementStatus.Closed) + const handleCloseRequest = () => { + setStatus(EngagementStatus.Closed) setTimeout(onClose, 500) } @@ -139,10 +138,10 @@ export const RequestPanelBody: StandardFC = memo(function {showCompleteRequest && isNotInactive && (
{/* TODO: get string from localizations */} - {/* TODO: get string from localizations */} diff --git a/packages/webapp/src/components/ui/ScanImageBody/index.tsx b/packages/webapp/src/components/ui/ScanImageBody/index.tsx index cca303007..86a392f10 100644 --- a/packages/webapp/src/components/ui/ScanImageBody/index.tsx +++ b/packages/webapp/src/components/ui/ScanImageBody/index.tsx @@ -4,7 +4,7 @@ */ import type { StandardFC } from '~types/StandardFC' import { memo } from 'react' -import { ScanImagePanel } from '~components/ui/ScanImagePanel' +import { ScanOcrDemo } from '~components/ui/ScanOcrDemo' interface ScanImageBodyProps { onClose?: () => void @@ -12,5 +12,5 @@ interface ScanImageBodyProps { } export const ScanImageBody: StandardFC = memo(function ScanFormPanelBody() { - return + return }) diff --git a/packages/webapp/src/components/ui/ScanManagerBody/index.module.scss b/packages/webapp/src/components/ui/ScanManagerBody/index.module.scss deleted file mode 100644 index 2e9cc3719..000000000 --- a/packages/webapp/src/components/ui/ScanManagerBody/index.module.scss +++ /dev/null @@ -1,3 +0,0 @@ -.scanManagerTopButtonContainer { - padding: 60px; -} diff --git a/packages/webapp/src/components/ui/ScanManagerBody/index.tsx b/packages/webapp/src/components/ui/ScanManagerBody/index.tsx index f79116e02..6e2e837d7 100644 --- a/packages/webapp/src/components/ui/ScanManagerBody/index.tsx +++ b/packages/webapp/src/components/ui/ScanManagerBody/index.tsx @@ -3,14 +3,8 @@ * Licensed under the MIT license. See LICENSE file in the project. */ import type { StandardFC } from '~types/StandardFC' -import { Namespace, useTranslation } from '~hooks/useTranslation' import { memo } from 'react' -import { DefaultButton } from '@fluentui/react/lib/Button' -import cx from 'classnames' -import styles from './index.module.scss' -import type { IIconProps } from '@fluentui/react' -import { useNavCallback } from '~hooks/useNavCallback' -import { ApplicationRoute } from '~types/ApplicationRoute' +import { ScanOcrDemo } from '~components/ui/ScanOcrDemo' interface ScanManagerBodyProps { onClose?: () => void @@ -19,22 +13,6 @@ interface ScanManagerBodyProps { export const ScanManagerBody: StandardFC = memo( function ScanFormPanelBody({}) { - const onTakePhotoClick = useNavCallback(ApplicationRoute.ScanImage) - const { t } = useTranslation(Namespace.Scan) - const circleIcon: IIconProps = { iconName: 'CircleShapeSolid' } - - return ( - <> -
- { - onTakePhotoClick() - }} - /> -
- - ) + return } ) diff --git a/packages/webapp/src/components/ui/ScanOcrDemo/index.module.scss b/packages/webapp/src/components/ui/ScanOcrDemo/index.module.scss new file mode 100644 index 000000000..33be4fca8 --- /dev/null +++ b/packages/webapp/src/components/ui/ScanOcrDemo/index.module.scss @@ -0,0 +1,36 @@ +/*! + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project. + */ +@use '~styles/lib/colors' as *; +@use '~styles/lib/space' as *; +@use '~styles/lib/transitions' as *; + +.formikField { + width: 100%; + border: 1px solid color('gray-4'); + box-sizing: border-box; + border-radius: space(1); + transition: border transition(); + padding: space(1); + outline: none; + + &:focus, + &:hover, + &:active { + border-color: color('primary'); + outline: none; + } +} + +.startCameraButton { + min-width: 186px; + height: 38px; + border-color: color('primary'); + color: color('primary'); + margin-bottom: 10px; + + &:hover { + color: color('primary'); + } +} diff --git a/packages/webapp/src/components/ui/ScanOcrDemo/index.tsx b/packages/webapp/src/components/ui/ScanOcrDemo/index.tsx new file mode 100644 index 000000000..44130732d --- /dev/null +++ b/packages/webapp/src/components/ui/ScanOcrDemo/index.tsx @@ -0,0 +1,191 @@ +/*! + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project. + */ + +/** + * Demo Code + * Needs to be clean up in next sprint + */ + +import { useRef, useState, memo } from 'react' +import scanFile from '~utils/ocrDemo' +import { ScanOcrDemoDatePicker } from '~components/ui/ScanOcrDemoDataPicker' +import { DefaultButton } from '@fluentui/react/lib/Button' +import type { StandardFC } from '~types/StandardFC' +import { Checkbox, Stack } from '@fluentui/react' +import { TextField } from '@fluentui/react/lib/TextField' +import { Spinner, SpinnerSize } from '@fluentui/react/lib/Spinner' +import cx from 'classnames' +import styles from './index.module.scss' + +let inputElement = null +let imgSrc = null +let imgResult = null + +function confidenceColour(confidence) { + let colour = '' + if (confidence > 80.0) { + colour = 'green' + } else if (confidence < 80.0 && confidence > 50.0) { + colour = 'goldenrod' + } else { + colour = 'red' + } + return colour +} + +function containsDateInKey(key) { + return key.toLowerCase().includes('date') ? true : false +} + +function createDateField(key, possibleDate) { + const date = new Date(possibleDate) + return +} + +function input(key, inputValue, formattedConfidence, className) { + if (inputValue === ':selected:') { + return ( +
+ + + +
{`Confidence: ${formattedConfidence}%`}
+
+ ) + } else if (inputValue === ':unselected:') { + return ( + <> +
+ + + +
{`Confidence: ${formattedConfidence}%`}
+
+ + ) + } else { + return ( +
+ {containsDateInKey(key) ? ( + createDateField(key, inputValue) + ) : ( + + )} + +
{`Confidence: ${formattedConfidence}%`}
+
+ ) + } +} + +function showResult(results) { + const show = [] + for (const { key, value, confidence } of results) { + const formattedConfidence = (confidence * 100).toFixed(2) + show.push( +
+ {input(key, value, formattedConfidence, styles.formikField)} +
+ ) + } + return show +} + +interface ScanOcrDemoProps { + onClose?: () => void + isLoaded?: (loaded: boolean) => void +} + +export const ScanOcrDemo: StandardFC = memo(function ScanOcrDemo({}) { + const imgRef = useRef(null) + const [scanResult, setScanResult] = useState(null) + const [imgHeight, setImgHeight] = useState('700px') + const [imgWidth, setImgWidth] = useState('700px') + const [isSpinnerShowing, setIsSpinnerShowing] = useState(false) + const turnOnNativeCameraApp = () => { + inputElement.click() + } + + return ( + <> +
+ + (inputElement = input)} + id='camerFileInput' + type='file' + accept='image/png' + capture='environment' //'environment' Or 'user' + style={{ display: 'none' }} + onChange={async (event) => { + imgRef.current.style.opacity = 1 + setScanResult(null) + setIsSpinnerShowing(true) + const imgFile = event.target.files[0] + imgSrc = window.URL.createObjectURL(imgFile) + imgRef.current.setAttribute('src', imgSrc) + imgResult = await scanFile(imgFile) + setScanResult(imgResult) + setImgHeight(imgRef.current.height + 'px') + setImgWidth(imgRef.current.width + 'px') + }} + /> +
+
+ Taken from mobile + {scanResult !== null ? ( +
+
{showResult(imgResult)}
+
+ ) : ( +
+ {isSpinnerShowing ? :

} +
+ )} +
+ + ) +}) diff --git a/packages/webapp/src/components/ui/ScanOcrDemoDataPicker/index.tsx b/packages/webapp/src/components/ui/ScanOcrDemoDataPicker/index.tsx new file mode 100644 index 000000000..f348d7d80 --- /dev/null +++ b/packages/webapp/src/components/ui/ScanOcrDemoDataPicker/index.tsx @@ -0,0 +1,58 @@ +/*! + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project. + */ + +/** + * Demo Code + * Needs to be clean up in next sprint + */ +import { useRef, useState, useCallback, memo } from 'react' +import type { StandardFC } from '~types/StandardFC' +import type { IDatePicker } from '@fluentui/react' +import { + DatePicker, + mergeStyleSets, + defaultDatePickerStrings, + DefaultButton +} from '@fluentui/react' + +const styles = mergeStyleSets({ + root: { selectors: { '> *': { marginBottom: 15 } } }, + control: { maxWidth: 300, marginBottom: 15 } +}) + +interface ScanOcrDemoDatePickerProps { + label?: string + inputValue?: Date + onClose?: () => void + isLoaded?: (loaded: boolean) => void +} + +export const ScanOcrDemoDatePicker: StandardFC = memo( + function ScanOcrDemoDatePicker({ label, inputValue }) { + const [value, setValue] = useState(inputValue) + const datePickerRef = useRef(null) + + const onClick = useCallback((): void => { + setValue(undefined) + datePickerRef.current?.focus() + }, []) + + return ( +
+ void} + className={styles.control} + strings={defaultDatePickerStrings} + /> + +
+ ) + } +) diff --git a/packages/webapp/src/components/ui/HappySubmitButton/index.module.scss b/packages/webapp/src/constants/OPTIMISTIC_RESPONSE.ts similarity index 73% rename from packages/webapp/src/components/ui/HappySubmitButton/index.module.scss rename to packages/webapp/src/constants/OPTIMISTIC_RESPONSE.ts index 0ccd4b44e..f2f561737 100644 --- a/packages/webapp/src/components/ui/HappySubmitButton/index.module.scss +++ b/packages/webapp/src/constants/OPTIMISTIC_RESPONSE.ts @@ -2,8 +2,4 @@ * Copyright (c) Microsoft. All rights reserved. * Licensed under the MIT license. See LICENSE file in the project. */ -.happySubmitButton { -} - -.confetti { -} +export const LOCAL_ONLY_ID_PREFIX = 'LOCAL_' diff --git a/packages/webapp/src/constants/index.ts b/packages/webapp/src/constants/index.ts index 1a2757fda..4a131a95c 100644 --- a/packages/webapp/src/constants/index.ts +++ b/packages/webapp/src/constants/index.ts @@ -5,3 +5,4 @@ export * from './REQUEST_DURATIONS' export * from './TAG_CATEGORIES' export * from './CLIENT_DEMOGRAPHICS' +export * from './OPTIMISTIC_RESPONSE' diff --git a/packages/webapp/src/hooks/api/useAuth/useLoginCallback.ts b/packages/webapp/src/hooks/api/useAuth/useLoginCallback.ts index 4ff9115ff..632ea7b3d 100644 --- a/packages/webapp/src/hooks/api/useAuth/useLoginCallback.ts +++ b/packages/webapp/src/hooks/api/useAuth/useLoginCallback.ts @@ -13,10 +13,18 @@ import { currentUserState } from '~store' import { CurrentUserFields } from '../fragments' import { useToasts } from '~hooks/useToasts' import { useTranslation } from '~hooks/useTranslation' +import { useOffline } from '~hooks/useOffline' import type { MessageResponse } from '../types' import { useCallback } from 'react' -import { storeAccessToken } from '~utils/localStorage' import { handleGraphqlResponse } from '~utils/handleGraphqlResponse' +import { StatusType } from '~hooks/api' +import { + setPwdHash, + setUser, + checkSalt, + setAccessToken, + setCurrentUserId +} from '~utils/localCrypto' const AUTHENTICATE_USER = gql` ${CurrentUserFields} @@ -37,21 +45,33 @@ export type BasicAuthCallback = (username: string, password: string) => Promise< export function useLoginCallback(): BasicAuthCallback { const { c } = useTranslation() const toast = useToasts() + const isOffline = useOffline() const [authenticate] = useMutation(AUTHENTICATE_USER) const [, setCurrentUser] = useRecoilState(currentUserState) return useCallback( async (username: string, password: string) => { + setCurrentUserId(username) + + if (isOffline) { + return Promise.resolve({ + status: StatusType.Failed, + message: 'Application is offline, cannot authenticate' + }) + } return handleGraphqlResponse(authenticate({ variables: { username, password } }), { toast, failureToast: c('hooks.useAuth.loginFailed'), onSuccess: ({ authenticate }: { authenticate: AuthenticationResponse }) => { - storeAccessToken(authenticate.accessToken) + checkSalt(username) // will create new salt if none found + setPwdHash(username, password) setCurrentUser(authenticate.user) + setUser(username, authenticate.user) + setAccessToken(username, authenticate.accessToken) return authenticate.message } }) }, - [c, toast, authenticate, setCurrentUser] + [c, toast, authenticate, setCurrentUser, isOffline] ) } diff --git a/packages/webapp/src/hooks/api/useContacts/useCreateContactCallback.ts b/packages/webapp/src/hooks/api/useContacts/useCreateContactCallback.ts index 021612c75..0d85df38b 100644 --- a/packages/webapp/src/hooks/api/useContacts/useCreateContactCallback.ts +++ b/packages/webapp/src/hooks/api/useContacts/useCreateContactCallback.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft. All rights reserved. * Licensed under the MIT license. See LICENSE file in the project. */ -import { gql, useMutation } from '@apollo/client' +import { gql, useApolloClient, useMutation } from '@apollo/client' import type { Contact, ContactInput, @@ -17,6 +17,14 @@ import { useToasts } from '~hooks/useToasts' import type { MessageResponse } from '../types' import { useCallback } from 'react' import { handleGraphqlResponseSync } from '~utils/handleGraphqlResponse' +import { GET_ORGANIZATION } from '../useOrganization' +import { CLIENT_SERVICE_ENTRY_ID_MAP } from '~hooks/api/useServiceAnswerList/useAddServiceAnswerCallback' +import { GET_SERVICE_ANSWERS } from '~hooks/api/useServiceAnswerList/useLoadServiceAnswersCallback' +import { useCurrentUser } from '../useCurrentUser' +import { useUpdateServiceAnswerCallback } from '~hooks/api/useServiceAnswerList/useUpdateServiceAnswerCallback' +import { updateServiceAnswerClient } from '~utils/serviceAnswers' +import { noop } from '~utils/noop' +import { LOCAL_ONLY_ID_PREFIX } from '~constants' const CREATE_CONTACT = gql` ${ContactFields} @@ -31,24 +39,119 @@ const CREATE_CONTACT = gql` } ` -export type CreateContactCallback = (contact: ContactInput) => Promise +export type CreateContactCallback = (contact: ContactInput) => MessageResponse + +export interface AddedContactState { + contact: Contact + localId: string +} export function useCreateContactCallback(): CreateContactCallback { const toast = useToasts() + const { orgId } = useCurrentUser() const [createContactGQL] = useMutation(CREATE_CONTACT) const [organization, setOrganization] = useRecoilState(organizationState) - const [, setAddedContact] = useRecoilState(addedContactState) + const [, setAddedContact] = useRecoilState(addedContactState) + const updateServiceAnswer = useUpdateServiceAnswerCallback(noop) + + const client = useApolloClient() + return useCallback( - async (contact) => { + (contact) => { let result: MessageResponse - await createContactGQL({ + const newContactTempId = `${LOCAL_ONLY_ID_PREFIX}${crypto.randomUUID()}` + + // When service entries with new clients are created offline we can't add the persisted client to the service answer when sending the service request since we don't + // yet have the persisted client (when offline requests get queued and we don't yet have the ability to wait for create client requests to return before sending + // create service answer requests). So in order to link persisted service entries with the persisted client we create the service request without the client and + // we keep a map of locally created service entries with locally created clients and when the persisted entities are returned from he server we use the map to + // and update persisted service entries with the proper persisted clients. This solution is rather complicated. It would be better if we could manage the request + // queues so that service answer requests would wait for client creation requests to return. Then the service answer request could be properly constructed + createContactGQL({ variables: { contact }, - update(_cache, resp) { + // TODO: We need to add some properties to out optimistic response. These properties are also populated by the GQL resolvers so now we have 2 spots where + // this logic happens. It would be nice if we could DRY this out. + optimisticResponse: { + createContact: { + message: 'Success', + contact: { + ...contact, + name: { first: contact.first, middle: contact.middle || null, last: contact.last }, + status: 'ACTIVE', + engagements: [], + id: newContactTempId, + __typename: 'Contact' + }, + __typename: 'ContactResponse' + } + }, + update(cache, resp) { + // Get the offline status directly from local storage. If we try to use the isOffline hook here the status will be stale. We should try to refactor + // so we can use the isOffline hook + const isOfflineLocalStorage = localStorage.getItem('isOffline') ? true : false + + const hideSuccessToast = + !isOfflineLocalStorage && resp.data?.createContact.contact.id === newContactTempId result = handleGraphqlResponseSync(resp, { toast, - successToast: ({ createContact }: { createContact: ContactResponse }) => - createContact.message, + successToast: hideSuccessToast + ? null + : ({ createContact }: { createContact: ContactResponse }) => createContact.message, onSuccess: ({ createContact }: { createContact: ContactResponse }) => { + // Get our client service answer id map so we can use it to determine if service answer needs to be updated + const cachedClientServiceEntryIdMap = client.readQuery({ + query: CLIENT_SERVICE_ENTRY_ID_MAP + }) + + const clientServiceEntryIdMap = { + ...cachedClientServiceEntryIdMap?.clientServiceEntryIdMap + } + + if (!createContact.contact.id.startsWith(LOCAL_ONLY_ID_PREFIX)) { + // The response came from the server. Check if the corresponding locally created client is in our client/service answer map + if (clientServiceEntryIdMap.hasOwnProperty(newContactTempId)) { + const serviceAnswerForContact = clientServiceEntryIdMap[newContactTempId] + + if (!serviceAnswerForContact.id.startsWith(LOCAL_ONLY_ID_PREFIX)) { + // The client is in our map, and we have the server persisted service answer. So we can update the service answer with our + // newly persisted client + const queryOptions = { + query: GET_SERVICE_ANSWERS, + variables: { serviceId: serviceAnswerForContact.serviceId } + } + + const existingServiceAnswers = cache.readQuery(queryOptions) as any + + const serviceAnswerToUpdate = existingServiceAnswers.serviceAnswers.find( + (serviceAnswer) => serviceAnswer.id === serviceAnswerForContact.id + ) + + if (serviceAnswerToUpdate) { + updateServiceAnswerClient( + serviceAnswerToUpdate, + createContact.contact.id, + serviceAnswerForContact.serviceId, + updateServiceAnswer + ) + + clientServiceEntryIdMap[newContactTempId] = null + } + } else { + // We don't yet have the server persisted service answer, so update the map with the server persisted client id. This will be used + // when we get the server persisted service answer. + clientServiceEntryIdMap[createContact.contact.id] = + clientServiceEntryIdMap[newContactTempId] + } + } + + client.writeQuery({ + query: CLIENT_SERVICE_ENTRY_ID_MAP, + data: { + clientServiceEntryIdMap: clientServiceEntryIdMap + } + }) + } + // In kiosk mode, we haven't set this in the store as it would expose other client's data, // so we should not try to update it either as it'd cause an error. if (organization?.contacts) { @@ -58,7 +161,40 @@ export function useCreateContactCallback(): CreateContactCallback { }) } // however, we do need the new contact, especially when in that kiosk mode: - setAddedContact(createContact.contact) + + // The createContactGQL mutation's update function gets called several times with the optimistic response after returning online (if we keep this + // solution we should look into why that is). We don't want to update any forms with the new client in those cases. We also do not want to update + // any forms with newly created clients if we've already used their optimistic response when submitting the form. So we need this complicated guard. + if ( + (isOfflineLocalStorage && + createContact.contact.id.startsWith(LOCAL_ONLY_ID_PREFIX)) || + (!createContact.contact.id.startsWith(LOCAL_ONLY_ID_PREFIX) && + !clientServiceEntryIdMap.hasOwnProperty(newContactTempId)) + ) { + setAddedContact({ contact: createContact.contact, localId: newContactTempId }) + } + + // Update the cache with out optimistic response + // optimisticResponse or serverResponse + const newContact = resp.data.createContact.contact + + const existingOrgData = cache.readQuery({ + query: GET_ORGANIZATION, + variables: { orgId } + }) as any + + cache.writeQuery({ + query: GET_ORGANIZATION, + variables: { orgId }, + data: { + organization: { + ...existingOrgData.organization, + contacts: [...existingOrgData.organization.contacts, newContact].sort( + byFirstName + ) + } + } + }) return createContact.message } }) @@ -67,7 +203,16 @@ export function useCreateContactCallback(): CreateContactCallback { return result }, - [createContactGQL, organization, setAddedContact, setOrganization, toast] + [ + createContactGQL, + organization, + orgId, + setAddedContact, + setOrganization, + toast, + client, + updateServiceAnswer + ] ) } diff --git a/packages/webapp/src/hooks/api/useEngagement/useAddActionCallback.ts b/packages/webapp/src/hooks/api/useEngagement/useAddActionCallback.ts index 6af22fd85..53e4bb99c 100644 --- a/packages/webapp/src/hooks/api/useEngagement/useAddActionCallback.ts +++ b/packages/webapp/src/hooks/api/useEngagement/useAddActionCallback.ts @@ -43,7 +43,7 @@ export function useAddActionCallback(id: string) { ) return useCallback( - async (action) => { + (action) => { const userId = currentUserId const orgId = currentOrgId const nextAction = { @@ -52,18 +52,13 @@ export function useAddActionCallback(id: string) { orgId } - try { - await addEngagementAction({ - variables: { engagementId: id, action: nextAction }, - update(cache, { data }) { - setEngagementData(data.addEngagementAction.engagement) - } - }) - - // No success message needed - } catch (error) { - failure(c('hooks.useEngagement.addAction.failed'), error) - } + addEngagementAction({ + variables: { engagementId: id, action: nextAction }, + update(cache, { data }) { + // Recoil State + setEngagementData(data.addEngagementAction.engagement) + } + }).catch((error) => failure(c('hooks.useEngagement.addActionFailed'), error)) }, [id, setEngagementData, failure, currentUserId, currentOrgId, c, addEngagementAction] ) diff --git a/packages/webapp/src/hooks/api/useEngagement/useAssignEngagementCallback.ts b/packages/webapp/src/hooks/api/useEngagement/useAssignEngagementCallback.ts index 4cf77a27a..b3967bd35 100644 --- a/packages/webapp/src/hooks/api/useEngagement/useAssignEngagementCallback.ts +++ b/packages/webapp/src/hooks/api/useEngagement/useAssignEngagementCallback.ts @@ -35,9 +35,9 @@ export function useAssignEngagementCallback(id: string): AssignEngagementCallbac variables: { engagementId: id, userId } }) - success(c('hooks.useEngagement.assign.success')) + success(c('hooks.useEngagement.assignSuccess')) } catch (error) { - failure(c('hooks.useEngagement.assign.failed'), error) + failure(c('hooks.useEngagement.assignFailed'), error) } }, [c, failure, id, success, assignEngagement] diff --git a/packages/webapp/src/hooks/api/useEngagement/useCompleteEngagementCallback.ts b/packages/webapp/src/hooks/api/useEngagement/useCompleteEngagementCallback.ts index ceee38eb5..5c3c65312 100644 --- a/packages/webapp/src/hooks/api/useEngagement/useCompleteEngagementCallback.ts +++ b/packages/webapp/src/hooks/api/useEngagement/useCompleteEngagementCallback.ts @@ -6,7 +6,6 @@ import { useMutation, gql } from '@apollo/client' import { EngagementFields } from '../fragments' import { useToasts } from '~hooks/useToasts' import { useTranslation } from '~hooks/useTranslation' -import { useCallback } from 'react' import type { MutationCompleteEngagementArgs } from '@cbosuite/schema/dist/client-types' const COMPLETE_ENGAGEMENT = gql` @@ -30,15 +29,11 @@ export function useCompleteEngagementCallback(id?: string): CompleteEngagementCa const [markEngagementComplete] = useMutation( COMPLETE_ENGAGEMENT ) - return useCallback(async () => { - try { - await markEngagementComplete({ - variables: { engagementId: id } - }) - - success(c('hooks.useEngagement.complete.success')) - } catch (error) { - failure(c('hooks.useEngagement.complete.failed'), error) - } - }, [markEngagementComplete, success, failure, id, c]) + return () => { + markEngagementComplete({ + variables: { engagementId: id } + }) + .then(() => success(c('hooks.useEngagement.completeSuccess'))) + .catch((error) => failure(c('hooks.useEngagement.completeFailed'), error)) + } } diff --git a/packages/webapp/src/hooks/api/useEngagement/useSetStatusCallback.ts b/packages/webapp/src/hooks/api/useEngagement/useSetStatusCallback.ts index 4f2db4eb8..541eef65a 100644 --- a/packages/webapp/src/hooks/api/useEngagement/useSetStatusCallback.ts +++ b/packages/webapp/src/hooks/api/useEngagement/useSetStatusCallback.ts @@ -4,25 +4,14 @@ */ import { useMutation, gql } from '@apollo/client' import type { - Engagement, EngagementStatus, MutationSetEngagementStatusArgs } from '@cbosuite/schema/dist/client-types' -import { GET_ENGAGEMENTS } from '../useEngagementList' import { EngagementFields } from '../fragments' import { useToasts } from '~hooks/useToasts' import { useTranslation } from '~hooks/useTranslation' import { useCallback } from 'react' -const GET_ENGAGEMENT = gql` - ${EngagementFields} - - query engagement($engagementId: String!) { - engagement(engagementId: $engagementId) { - ...EngagementFields - } - } -` const SET_ENGAGEMENT_STATUS = gql` ${EngagementFields} @@ -40,49 +29,19 @@ export type SetStatusCallback = (status: EngagementStatus) => void export function useSetStatusCallback(id: string, orgId: string): SetStatusCallback { const { c } = useTranslation() - const { success, failure } = useToasts() + const { failure, success } = useToasts() const [setEngagementStatus] = useMutation( SET_ENGAGEMENT_STATUS ) return useCallback( - async (status: EngagementStatus) => { - try { - await setEngagementStatus({ - variables: { engagementId: id, status }, - update(cache, { data }) { - const updatedID = data.setEngagementStatus.engagement.id - const existingEngagements = cache.readQuery({ - query: GET_ENGAGEMENTS, - variables: { orgId, limit: 30 } - }) as { engagements: Engagement[] } - - const newEngagements = existingEngagements?.engagements.map((e) => { - if (e.id === updatedID) { - return data.setEngagementStatus.engagement - } - return e - }) - - cache.writeQuery({ - query: GET_ENGAGEMENTS, - variables: { orgId, limit: 30 }, - data: { engagements: newEngagements } - }) - - cache.writeQuery({ - query: GET_ENGAGEMENT, - variables: { engagementId: updatedID }, - data: { engagement: data.setEngagementStatus.engagement } - }) - } - }) - - success(c('hooks.useEngagement.setStatus.success', { status })) - } catch (error) { - failure(c('hooks.useEngagement.setStatus.failed', { status }), error) - } + (status: EngagementStatus) => { + setEngagementStatus({ + variables: { engagementId: id, status }, + onCompleted: () => success(c('hooks.useEngagement.setStatusSuccess', { status })), + onError: (e) => failure(c('hooks.useEngagement.setStatusFailed'), e.message) + }) }, - [c, success, failure, id, orgId, setEngagementStatus] + [c, success, failure, id, setEngagementStatus] ) } diff --git a/packages/webapp/src/hooks/api/useEngagementList/addEngagementCallback.ts b/packages/webapp/src/hooks/api/useEngagementList/addEngagementCallback.ts index 6895b96bb..17990058f 100644 --- a/packages/webapp/src/hooks/api/useEngagementList/addEngagementCallback.ts +++ b/packages/webapp/src/hooks/api/useEngagementList/addEngagementCallback.ts @@ -2,52 +2,90 @@ * Copyright (c) Microsoft. All rights reserved. * Licensed under the MIT license. See LICENSE file in the project. */ -import { gql, useMutation } from '@apollo/client' +import { useApolloClient, useMutation } from '@apollo/client' import { useToasts } from '~hooks/useToasts' import type { EngagementInput, - MutationCreateEngagementArgs + MutationCreateEngagementArgs, + Engagement } from '@cbosuite/schema/dist/client-types' -import { EngagementFields } from '../fragments' +import { ContactFields, UserFields } from '../fragments' import { useCallback } from 'react' import { Namespace, useTranslation } from '~hooks/useTranslation' -import { useCurrentUser } from '../useCurrentUser' +import { CREATE_ENGAGEMENT, GET_ENGAGEMENTS } from '~queries' +import { LOCAL_ONLY_ID_PREFIX } from '~constants' -const CREATE_ENGAGEMENT = gql` - ${EngagementFields} +export type AddEngagementCallback = (e: EngagementInput) => void - mutation createEngagement($engagement: EngagementInput!) { - createEngagement(engagement: $engagement) { - message - engagement { - ...EngagementFields - } - } - } -` - -export type AddEngagementCallback = (e: EngagementInput) => Promise - -export function useAddEngagementCallback(): AddEngagementCallback { +export function useAddEngagementCallback(orgId: string): AddEngagementCallback { const { c } = useTranslation(Namespace.Common) const { success, failure } = useToasts() - const { orgId } = useCurrentUser() const [createEngagement] = useMutation(CREATE_ENGAGEMENT) + const apolloClient = useApolloClient() return useCallback( - async (engagementInput: EngagementInput) => { - const engagement = { ...engagementInput, orgId } - try { - // execute mutator - await createEngagement({ - variables: { engagement } + (engagementInput: EngagementInput) => { + const user = apolloClient.readFragment({ + id: `User:${engagementInput.userId}`, + fragment: UserFields + }) + + const contacts = engagementInput.contactIds.map((contactId) => { + return apolloClient.readFragment({ + id: `Contact:${contactId}`, + fragment: ContactFields }) + }) + + const optimisticResponse = { + createEngagement: { + message: 'Success', + engagement: { + id: LOCAL_ONLY_ID_PREFIX + crypto.randomUUID(), // Random ID that will be replaced by the server version + orgId: orgId, + title: engagementInput.title, + description: engagementInput.description, + status: engagementInput.userId ? 'ASSIGNED' : 'OPEN', + startDate: Date.now(), // TODO: This should be set by the front-end, not the back-end... + endDate: engagementInput.endDate ?? null, + user: user, + tags: engagementInput.tags ?? [], + contacts: contacts, + actions: [], + __typename: 'Engagement' + }, + __typename: 'EngagementResponse' + } + } + + function update(cache, result) { + // optimisticResponse or serverResponse + const newEngagement: Engagement = result.data.createEngagement.engagement + + // Fetch all the activeEngagements + const queryOptions = { + variables: { orgId }, + query: GET_ENGAGEMENTS + } - success(c('hooks.useEngagementList.addEngagement.success')) - } catch (error) { - failure(c('hooks.useEngagementList.addEngagement.failed'), error) + // Now we combine the newEngagement we passed in earlier with the existing data + const addOptimisticResponse = (data) => { + if (data) { + return { allEngagements: [...data.allEngagements, newEngagement] } + } + } + + cache.updateQuery(queryOptions, addOptimisticResponse) } + + createEngagement({ + optimisticResponse, + onCompleted: () => success(c('hooks.useEngagementList.addEngagement.success')), + onError: (e) => failure(c('hooks.useEngagementList.addEngagement.failed'), e.message), + update, + variables: { engagement: { ...engagementInput, orgId } } + }) }, - [orgId, success, failure, c, createEngagement] + [apolloClient, c, createEngagement, failure, orgId, success] ) } diff --git a/packages/webapp/src/hooks/api/useEngagementList/index.ts b/packages/webapp/src/hooks/api/useEngagementList/index.ts index 8ae6ba3ab..c5b8730c0 100644 --- a/packages/webapp/src/hooks/api/useEngagementList/index.ts +++ b/packages/webapp/src/hooks/api/useEngagementList/index.ts @@ -10,10 +10,8 @@ import type { EditEngagementCallback } from './useEditEngagementCallback' import { useEditEngagementCallback } from './useEditEngagementCallback' import type { AddEngagementCallback } from './addEngagementCallback' import { useAddEngagementCallback } from './addEngagementCallback' -import { useEngagementSubscription } from './useEngagementSubscription' import { useEngagementData } from './useEngagementListData' import { useMemo } from 'react' -export { GET_ENGAGEMENTS } from './useEngagementListData' interface useEngagementListReturn extends ApiResponse { addEngagement: AddEngagementCallback @@ -25,12 +23,13 @@ interface useEngagementListReturn extends ApiResponse { // FIXME: update to only have ONE input as an object export function useEngagementList(orgId?: string, userId?: string): useEngagementListReturn { - const { loading, error, refetch, fetchMore, engagementList, myEngagementList } = - useEngagementData(orgId, userId) + const { data, error, loading } = useEngagementData(orgId, userId) + const { engagementList = [] as Engagement[], myEngagementList = [] as Engagement[] } = data ?? { + engagementList: [] as Engagement[], + myEngagementList: [] as Engagement[] + } - // Subscribe to engagement updates - useEngagementSubscription(orgId) - const addEngagement = useAddEngagementCallback() + const addEngagement = useAddEngagementCallback(orgId) const editEngagement = useEditEngagementCallback() const claimEngagement = useClaimEngagementCallback() @@ -38,8 +37,6 @@ export function useEngagementList(orgId?: string, userId?: string): useEngagemen () => ({ loading, error, - refetch, - fetchMore, addEngagement, editEngagement, claimEngagement, @@ -49,8 +46,6 @@ export function useEngagementList(orgId?: string, userId?: string): useEngagemen [ loading, error, - refetch, - fetchMore, addEngagement, editEngagement, claimEngagement, diff --git a/packages/webapp/src/hooks/api/useEngagementList/useEditEngagementCallback.ts b/packages/webapp/src/hooks/api/useEngagementList/useEditEngagementCallback.ts index 51440f89b..a15c382f4 100644 --- a/packages/webapp/src/hooks/api/useEngagementList/useEditEngagementCallback.ts +++ b/packages/webapp/src/hooks/api/useEngagementList/useEditEngagementCallback.ts @@ -26,7 +26,7 @@ const UPDATE_ENGAGEMENT = gql` } ` -export type EditEngagementCallback = (engagementInput: EngagementInput) => Promise +export type EditEngagementCallback = (engagementInput: EngagementInput) => void export function useEditEngagementCallback(): EditEngagementCallback { const { c } = useTranslation(Namespace.Common) @@ -35,20 +35,12 @@ export function useEditEngagementCallback(): EditEngagementCallback { const [updateEngagement] = useMutation(UPDATE_ENGAGEMENT) return useCallback( - async (engagementInput: EngagementInput) => { - const engagement = { - ...engagementInput, - orgId - } - - try { - // execute mutator - await updateEngagement({ variables: { engagement } }) - - success(c('hooks.useEngagementList.editEngagement.success')) - } catch (error) { - failure(c('hooks.useEngagementList.editEngagement.failed'), error) - } + (engagementInput: EngagementInput) => { + updateEngagement({ + variables: { engagement: { ...engagementInput, orgId } }, + onCompleted: () => success(c('hooks.useEngagementList.editEngagement.success')), + onError: (e) => failure(c('hooks.useEngagementList.editEngagement.failed'), e.message) + }) }, [c, success, failure, updateEngagement, orgId] ) diff --git a/packages/webapp/src/hooks/api/useEngagementList/useEngagementListData.ts b/packages/webapp/src/hooks/api/useEngagementList/useEngagementListData.ts index 412352d6f..09745d30a 100644 --- a/packages/webapp/src/hooks/api/useEngagementList/useEngagementListData.ts +++ b/packages/webapp/src/hooks/api/useEngagementList/useEngagementListData.ts @@ -2,107 +2,38 @@ * Copyright (c) Microsoft. All rights reserved. * Licensed under the MIT license. See LICENSE file in the project. */ -import type { ApolloQueryResult } from '@apollo/client' -import { useLazyQuery, gql } from '@apollo/client' import type { Engagement } from '@cbosuite/schema/dist/client-types' -import { EngagementFields } from '../fragments' -import { useRecoilState } from 'recoil' -import { engagementListState, myEngagementListState } from '~store' -import { useEffect } from 'react' -import { Namespace, useTranslation } from '~hooks/useTranslation' import { createLogger } from '~utils/createLogger' -import { empty } from '~utils/noop' -const logger = createLogger('useEngagementList') +import { GET_USER_ACTIVES_ENGAGEMENTS } from '~queries' +import { Namespace, useTranslation } from '~hooks/useTranslation' +import { useQuery } from '@apollo/client' -export const GET_ENGAGEMENTS = gql` - ${EngagementFields} +const logger = createLogger('useEngagementList') - query inactiveEngagements($orgId: String!, $offset: Int, $limit: Int) { - activeEngagements(orgId: $orgId, offset: $offset, limit: $limit) { - ...EngagementFields - } - } -` export interface EngagementDataResult { - loading: boolean + data: { + engagementList: Engagement[] + myEngagementList: Engagement[] + } error: Error - refetch?: (variables: Record) => Promise> - fetchMore?: (variables: Record) => Promise> - engagementList: Engagement[] - myEngagementList: Engagement[] + loading: boolean } -// FIXME: update to only have ONE input as an object export function useEngagementData(orgId?: string, userId?: string): EngagementDataResult { const { c } = useTranslation(Namespace.Common) - // Store used to save engagements list - const [engagementList, setEngagementList] = useRecoilState(engagementListState) - const [myEngagementList, setMyEngagementList] = - useRecoilState(myEngagementListState) - - // Engagements query - const [load, { loading, error, refetch, fetchMore }] = useLazyQuery(GET_ENGAGEMENTS, { + const { loading, error, data } = useQuery(GET_USER_ACTIVES_ENGAGEMENTS, { fetchPolicy: 'cache-and-network', - onCompleted: (data) => { - if (data?.activeEngagements && userId) { - const [myEngagementListNext, engagementListNext] = seperateEngagements( - userId, - data?.activeEngagements - ) - setEngagementList(engagementListNext.sort(sortByDuration)) - setMyEngagementList(myEngagementListNext.sort(sortByDuration)) - } - }, - onError: (error) => { - if (error) { - logger(c('hooks.useEngagementList.loadData.failed'), error) - } - } + variables: { orgId: orgId, userId: userId }, + onError: (error) => logger(c('hooks.useEngagementList.loadDataFailed'), error) }) - useEffect(() => { - if (orgId) { - load({ variables: { orgId, offset: 0, limit: 800 } }) - } - }, [orgId, load]) - return { - loading, + data: { + engagementList: data?.activeEngagements ?? [], + myEngagementList: data?.userActiveEngagements ?? [] + }, error, - refetch, - fetchMore, - engagementList: engagementList || empty, - myEngagementList: myEngagementList || empty + loading } } - -function sortByDuration(a: Engagement, b: Engagement) { - const currDate = new Date() - const aDate = a?.endDate ? new Date(a.endDate) : currDate - const bDate = b?.endDate ? new Date(b.endDate) : currDate - - const aDuration = currDate.getTime() - aDate.getTime() - const bDuration = currDate.getTime() - bDate.getTime() - - return aDuration > bDuration ? -1 : 1 -} - -function seperateEngagements(userId: string, engagements?: Engagement[]): Array> { - if (!engagements) return [[], []] - - const [currUserEngagements, otherEngagements] = engagements.reduce( - (r, e) => { - if (!!e.user?.id && e.user.id === userId) { - r[0].push(e) - } else { - r[1].push(e) - } - - return r - }, - [[], []] - ) - - return [currUserEngagements, otherEngagements] -} diff --git a/packages/webapp/src/hooks/api/useEngagementList/useEngagementSubscription.ts b/packages/webapp/src/hooks/api/useEngagementList/useEngagementSubscription.ts deleted file mode 100644 index 47ee78875..000000000 --- a/packages/webapp/src/hooks/api/useEngagementList/useEngagementSubscription.ts +++ /dev/null @@ -1,176 +0,0 @@ -/*! - * Copyright (c) Microsoft. All rights reserved. - * Licensed under the MIT license. See LICENSE file in the project. - */ -import { gql, useSubscription } from '@apollo/client' -import { EngagementFields } from '../fragments' -import { get } from 'lodash' -import type { - Engagement, - EngagementResponse, - SubscriptionEngagementsArgs -} from '@cbosuite/schema/dist/client-types' -import { engagementListState, myEngagementListState } from '~store' -import { useCurrentUser } from '../useCurrentUser' -import { useRecoilState } from 'recoil' -import { sortByDate } from '~utils/sorting' -import { Namespace, useTranslation } from '~hooks/useTranslation' -import { useEffect } from 'react' -import { createLogger } from '~utils/createLogger' -const logger = createLogger('useEngagementList') - -export const SUBSCRIBE_TO_ORG_ENGAGEMENTS = gql` - ${EngagementFields} - - subscription engagementUpdate($orgId: String!) { - engagements(orgId: $orgId) { - message - action - engagement { - ...EngagementFields - } - } - } -` - -export function useEngagementSubscription(orgId?: string) { - const { c } = useTranslation(Namespace.Common) - - // Store used to save engagements list - const [engagementList, setEngagementList] = useRecoilState(engagementListState) - const [myEngagementList, setMyEngagementList] = - useRecoilState(myEngagementListState) - - // Local user - const { userId: currentUserId } = useCurrentUser() - - // Helper funtion to add engagement to local store - const addEngagementToList = (engagement: Engagement) => { - // Check which list to add to - const isMyEngagement = isCurrentUserEngagement(engagement, currentUserId) - - // Update local list - const nextEngagementList: Engagement[] = [ - engagement, - ...(isMyEngagement ? myEngagementList : engagementList) - ].sort((a, b) => sortByDate({ date: a.startDate }, { date: b.startDate })) - - // Set recoil variable - if (isMyEngagement) setMyEngagementList(nextEngagementList) - else setEngagementList(nextEngagementList) - } - - // Helper funtion to remove engagement to local store - const removeEngagementFromList = (engagement: Engagement) => { - // Check which list to add to - if (!engagement) throw new Error(c('hooks.useEngagementList.remove.failed')) - - const engagementListIndex = engagementList.findIndex((e) => e.id === engagement.id) - if (engagementListIndex > -1) { - setEngagementList([ - ...engagementList.slice(0, engagementListIndex), - ...engagementList.slice(engagementListIndex + 1) - ]) - } - const myEngagementListIndex = myEngagementList.findIndex((e) => e.id === engagement.id) - if (myEngagementListIndex > -1) { - setMyEngagementList([ - ...myEngagementList.slice(0, myEngagementListIndex), - ...myEngagementList.slice(myEngagementListIndex + 1) - ]) - } - } - - // Helper funtion to update engagement in local store - const updateEngagementInList = (engagement: Engagement) => { - // If updated list element currently exists in engagement list - const engagementIdx = engagementList.findIndex((e) => e.id === engagement.id) - - // Engagement in engagementList - if (engagementIdx > -1) { - if (engagement.user?.id === currentUserId) { - // Remove engagement from engList add to myEngList - setEngagementList([ - ...engagementList.slice(0, engagementIdx), - ...engagementList.slice(engagementIdx + 1) - ]) - setMyEngagementList( - [...myEngagementList, engagement].sort((a, b) => - sortByDate({ date: a.startDate }, { date: b.startDate }) - ) - ) - } else { - // Replace engagement in list - const nextEngagementList = [ - ...engagementList.slice(0, engagementIdx), - ...engagementList.slice(engagementIdx + 1) - ] - - setEngagementList( - [...nextEngagementList, engagement].sort((a, b) => - sortByDate({ date: a.startDate }, { date: b.startDate }) - ) - ) - } - } - - const myEngagementIdx = myEngagementList.findIndex((e) => e.id === engagement.id) - if (myEngagementIdx > -1) { - // Replace engagement in list - const nextEngagementList = [ - ...myEngagementList.slice(0, myEngagementIdx), - ...myEngagementList.slice(myEngagementIdx + 1) - ] - - setMyEngagementList( - [...nextEngagementList, engagement].sort((a, b) => - sortByDate({ date: a.startDate }, { date: b.startDate }) - ) - ) - } - } - - // Subscribe to engagement updates - const { error: subscriptionError } = useSubscription< - EngagementResponse, - SubscriptionEngagementsArgs - >(SUBSCRIBE_TO_ORG_ENGAGEMENTS, { - variables: { orgId }, - onSubscriptionData: ({ subscriptionData }) => { - // Update subscriptions here - const updateType = get(subscriptionData, 'data.engagements.action') - const engagementUpdate = get(subscriptionData, 'data.engagements.engagement') - - // If the subscription updated sucessfully - if (engagementUpdate) { - // Handle socket update - switch (updateType) { - case 'CREATED': - addEngagementToList(engagementUpdate) - break - case 'CLOSED': - case 'COMPLETED': - removeEngagementFromList(engagementUpdate) - break - case 'UPDATE': - updateEngagementInList(engagementUpdate) - break - } - } - } - }) - - // Listen for errors to enagementUpdates subsciption - useEffect(() => { - if (subscriptionError) { - logger('subscriptionError', subscriptionError) - } - }, [subscriptionError]) -} - -// Function to determine if the engagement belongs to the current user -function isCurrentUserEngagement(engagement: Engagement, currentUserId: string): boolean { - const euid = engagement.user?.id - const isMyEngagement = !!euid && euid === currentUserId - return isMyEngagement -} diff --git a/packages/webapp/src/hooks/api/useInactiveEngagementList.ts b/packages/webapp/src/hooks/api/useInactiveEngagementList.ts index 86b4f55b7..0ff80af17 100644 --- a/packages/webapp/src/hooks/api/useInactiveEngagementList.ts +++ b/packages/webapp/src/hooks/api/useInactiveEngagementList.ts @@ -2,30 +2,14 @@ * Copyright (c) Microsoft. All rights reserved. * Licensed under the MIT license. See LICENSE file in the project. */ -import { useLazyQuery, gql, useSubscription } from '@apollo/client' +import { useQuery } from '@apollo/client' import type { ApiResponse } from './types' import type { Engagement } from '@cbosuite/schema/dist/client-types' -import { EngagementFields } from './fragments' -import { get } from 'lodash' -import { useRecoilState } from 'recoil' -import { inactiveEngagementListState } from '~store' -import { useEffect } from 'react' -import { sortByDate } from '~utils/sorting' import { Namespace, useTranslation } from '~hooks/useTranslation' -import { SUBSCRIBE_TO_ORG_ENGAGEMENTS } from './useEngagementList/useEngagementSubscription' +import { GET_INACTIVE_ENGAGEMENTS } from '~queries' import { createLogger } from '~utils/createLogger' const logger = createLogger('useInativeEngagementList') -export const GET_INACTIVE_ENGAGEMENTS = gql` - ${EngagementFields} - - query inactiveEngagements($orgId: String!, $offset: Int, $limit: Int) { - inactiveEngagements(orgId: $orgId, offset: $offset, limit: $limit) { - ...EngagementFields - } - } -` - interface useInactiveEngagementListReturn extends ApiResponse { inactiveEngagementList: Engagement[] } @@ -33,74 +17,16 @@ interface useInactiveEngagementListReturn extends ApiResponse { export function useInactiveEngagementList(orgId?: string): useInactiveEngagementListReturn { const { c } = useTranslation(Namespace.Common) - // Store used to save engagements list - const [inactiveEngagementList, setInactiveEngagementList] = useRecoilState( - inactiveEngagementListState - ) - // Engagements query - const [load, { loading, error }] = useLazyQuery(GET_INACTIVE_ENGAGEMENTS, { + const { loading, data, error } = useQuery(GET_INACTIVE_ENGAGEMENTS, { fetchPolicy: 'cache-and-network', - onCompleted: (data) => { - if (data?.inactiveEngagements) { - setInactiveEngagementList(data.inactiveEngagements) - } - }, - onError: (error) => { - if (error) { - logger(c('hooks.useInactiveEngagementList.loadData.failed'), error) - } - } - }) - - useEffect(() => { - if (orgId) { - load({ variables: { orgId, offset: 0, limit: 800 } }) - } - }, [orgId, load]) - - // Subscribe to engagement updates - const { error: subscriptionError } = useSubscription(SUBSCRIBE_TO_ORG_ENGAGEMENTS, { - variables: { orgId }, - onSubscriptionData: ({ subscriptionData }) => { - // Update subscriptions here - const updateType = get(subscriptionData, 'data.engagementUpdate.action') - const engagementUpdate = get(subscriptionData, 'data.engagementUpdate.engagement') - - // If the subscription updated sucessfully - if (engagementUpdate) { - // Handle socket update - switch (updateType) { - case 'CLOSED': - case 'COMPLETED': - addEngagementToList(engagementUpdate) - break - } - } - } + variables: { orgId: orgId }, + onError: (error) => logger(c('hooks.useInactiveEngagementList.loadDataFailed'), error) }) - // Listen for errors to enagementUpdates subsciption - useEffect(() => { - if (subscriptionError) { - logger('subscriptionError', subscriptionError) - } - }, [subscriptionError]) - - // Helper funtion to add engagement to local store - const addEngagementToList = (engagement: Engagement) => { - // Update local list - const nextEngagementList: Engagement[] = [engagement, ...inactiveEngagementList].sort((a, b) => - sortByDate({ date: a.startDate }, { date: b.startDate }) - ) - - // Set recoil variable - setInactiveEngagementList(nextEngagementList) - } - return { - loading, error, - inactiveEngagementList: inactiveEngagementList || [] + loading, + inactiveEngagementList: data?.inactiveEngagements ?? [] } } diff --git a/packages/webapp/src/hooks/api/useOrganization.ts b/packages/webapp/src/hooks/api/useOrganization.ts index 94da63d24..31f0d1cff 100644 --- a/packages/webapp/src/hooks/api/useOrganization.ts +++ b/packages/webapp/src/hooks/api/useOrganization.ts @@ -32,6 +32,9 @@ export function useOrganization(orgId?: string): UseOranizationReturn { // Common translations const { c } = useTranslation() + // Recoil state used to store and return the cached organization + const [organization, setOrg] = useRecoilState(organizationState) + /** * Lazy graphql query. * @params @@ -40,10 +43,17 @@ export function useOrganization(orgId?: string): UseOranizationReturn { * * */ const [load, { loading, error }] = useLazyQuery(GET_ORGANIZATION, { - fetchPolicy: 'cache-and-network', + fetchPolicy: 'cache-first', onCompleted: (data) => { if (data?.organization) { - setOrg(data.organization) + // Use a setTimeout here to avoid an error: "Cannot update a component (`Notifications2`) while rendering a + // different component (`ContainerLayout2`). To locate the bad setState() call inside `ContainerLayout2`" + // when toggling online/offline. This error appeared after switching the fetch policy from cache-and-network + // to cache-first so now the load function returns immediately if data is present in the cache. This hook + // likely needs a refactor. Perhaps a useQuery is more appropriate. + setTimeout(() => { + setOrg(data.organization) + }) } }, onError: (error) => { @@ -51,9 +61,6 @@ export function useOrganization(orgId?: string): UseOranizationReturn { } }) - // Recoil state used to store and return the cached organization - const [organization, setOrg] = useRecoilState(organizationState) - // If an orgId was passed execute the load function immediately // Otherwise, just return the organization and do NOT make a graph query useEffect(() => { diff --git a/packages/webapp/src/hooks/api/useReports.ts b/packages/webapp/src/hooks/api/useReports.ts index 9408096fd..f39ac8b4c 100644 --- a/packages/webapp/src/hooks/api/useReports.ts +++ b/packages/webapp/src/hooks/api/useReports.ts @@ -14,8 +14,8 @@ const logger = createLogger('useReports') // TODO: Create fragment and use that instead of full field description export const EXPORT_ENGAGEMENT_DATA = gql` - query exportData($orgId: String!) { - exportData(orgId: $orgId) { + query allEngagements($orgId: String!) { + allEngagements(orgId: $orgId) { id description status @@ -81,7 +81,7 @@ export function useReports(): ApiResponse { logger(c('hooks.useReports.loadDataFailed'), error) } - const engagements: Engagement[] = !loading && (data?.exportData as Engagement[]) + const engagements: Engagement[] = !loading && (data?.allEngagements as Engagement[]) return { loading, diff --git a/packages/webapp/src/hooks/api/useServiceAnswerList/useAddServiceAnswerCallback.ts b/packages/webapp/src/hooks/api/useServiceAnswerList/useAddServiceAnswerCallback.ts index 9c662ce80..5c944eb4d 100644 --- a/packages/webapp/src/hooks/api/useServiceAnswerList/useAddServiceAnswerCallback.ts +++ b/packages/webapp/src/hooks/api/useServiceAnswerList/useAddServiceAnswerCallback.ts @@ -2,19 +2,23 @@ * Copyright (c) Microsoft. All rights reserved. * Licensed under the MIT license. See LICENSE file in the project. */ -import { gql, useMutation, useApolloClient } from '@apollo/client' +import { gql, useApolloClient, useMutation } from '@apollo/client' import type { MutationCreateServiceAnswerArgs, - ServiceAnswerInput + ServiceAnswerInput, + Organization } from '@cbosuite/schema/dist/client-types' import { ServiceAnswerFields } from '../fragments' import { useToasts } from '~hooks/useToasts' import { useTranslation } from '~hooks/useTranslation' +import { useUpdateServiceAnswerCallback } from '~hooks/api/useServiceAnswerList/useUpdateServiceAnswerCallback' import { GET_SERVICE_ANSWERS } from './useLoadServiceAnswersCallback' +import { updateServiceAnswerClient } from '~utils/serviceAnswers' import { useCallback } from 'react' -import { useCurrentUser } from '~hooks/api/useCurrentUser' - -import { OrgFields } from '~hooks/api/fragments' +import { organizationState } from '~store' +import { useRecoilState } from 'recoil' +import { noop } from '~utils/noop' +import { LOCAL_ONLY_ID_PREFIX } from '~constants' const CREATE_SERVICE_ANSWERS = gql` ${ServiceAnswerFields} @@ -29,13 +33,9 @@ const CREATE_SERVICE_ANSWERS = gql` } ` -export const GET_ORGANIZATION = gql` - ${OrgFields} - - query organization($orgId: String!) { - organization(orgId: $orgId) { - ...OrgFields - } +export const CLIENT_SERVICE_ENTRY_ID_MAP = gql` + query clientServiceEntryIdMap { + clientServiceEntryIdMap @client } ` @@ -43,16 +43,52 @@ export type AddServiceAnswerCallback = (service: ServiceAnswerInput) => boolean export function useAddServiceAnswerCallback(refetch: () => void): AddServiceAnswerCallback { const { c } = useTranslation() - const { orgId } = useCurrentUser() const { success, failure } = useToasts() + const [organization] = useRecoilState(organizationState) const [addServiceAnswers] = useMutation( CREATE_SERVICE_ANSWERS ) const client = useApolloClient() - + const updateServiceAnswer = useUpdateServiceAnswerCallback(noop) + + // When service entries with new clients are created offline we can't add the persisted client to the service answer when sending the service request since we don't + // yet have the persisted client (when offline requests get queued and we don't yet have the ability to wait for create client requests to return before sending + // create service answer requests). So in order to link persisted service entries with the persisted client we create the service request without the client and + // we keep a map of locally created service entries with locally created clients and when the persisted entities are returned from he server we use the map to + // and update persisted service entries with the proper persisted clients. This solution is rather complicated. It would be better if we could manage the request + // queues so that service answer requests would wait for client creation requests to return. Then the service answer request could be properly constructed return useCallback( (_serviceAnswer: ServiceAnswerInput) => { try { + const optimisticResponseId = `${LOCAL_ONLY_ID_PREFIX}${crypto.randomUUID()}` + const serviceAnswerContacts = [..._serviceAnswer.contacts] + + // if the service answer has any contacts with a local id prefix that means the client has not yet been persisted to the server (ie it was created while offline). + // so we need to store a map of the local client id to the service answer id so that when we do have server persisted client it can be added to the service answer + + // We keep a map of { local client id: {service answer id, service id}} to track service entries that will need to be updated + const clientServiceEntryIdMapQueryResult = client.readQuery({ + query: CLIENT_SERVICE_ENTRY_ID_MAP + }) + const clientServiceEntryIdMap = { + ...clientServiceEntryIdMapQueryResult?.clientServiceEntryIdMap + } + serviceAnswerContacts.forEach((contact) => { + if (contact.startsWith(LOCAL_ONLY_ID_PREFIX)) { + // The service answer has a local client, add it to the map + clientServiceEntryIdMap[contact] = { + id: optimisticResponseId, + serviceId: _serviceAnswer.serviceId + } + } + }) + client.writeQuery({ + query: CLIENT_SERVICE_ENTRY_ID_MAP, + data: { + clientServiceEntryIdMap: clientServiceEntryIdMap + } + }) + // Filter out empty answers const serviceAnswer = { ..._serviceAnswer, @@ -66,16 +102,22 @@ export function useAddServiceAnswerCallback(refetch: () => void): AddServiceAnsw if (typeof field.values !== 'undefined' && !field.values) f.values = [] return f - }) + }), + // Remove any non persisted clients so we don't get server errors about not being able to find the client + contacts: _serviceAnswer.contacts.filter( + (contact) => !contact.startsWith(LOCAL_ONLY_ID_PREFIX) + ) } - const cachedOrganizations = client.readQuery({ - query: GET_ORGANIZATION, - variables: { orgId } - }) + // TODO: I tried to use the cache to get offline created clients but they were not present is the response from this query. So I resorted to using recoil. + // I left this code here so it can be investigated at a later date + // const cachedOrganizations = client.readQuery({ + // query: GET_ORGANIZATION, + // variables: { orgId } + // }) // The service answer we will use for our optimistic response. Need to ensure value and values are populated - // when writing to the cache + // when writing to the cache. This optimistic response will be added to the cache and eventually updated with the server response const optimisticServiceAnswer = { ..._serviceAnswer, fields: _serviceAnswer.fields.map((field) => { @@ -89,10 +131,14 @@ export function useAddServiceAnswerCallback(refetch: () => void): AddServiceAnsw return f }), - contacts: _serviceAnswer.contacts.map((contactId) => { - const contact = cachedOrganizations?.organization.contacts.find( - (contact) => contact.id === contactId - ) + contacts: serviceAnswerContacts.map((contactId) => { + const contact = organization.contacts.find((contact) => contact.id === contactId) + + // TODO: see comment above regarding cached clients + // const cachedContact = cachedOrganizations?.organization.contacts.find( + // (contact) => contact.id === contactId + // ) + return contact }) } @@ -104,23 +150,73 @@ export function useAddServiceAnswerCallback(refetch: () => void): AddServiceAnsw message: 'Success', serviceAnswer: { ...optimisticServiceAnswer, - id: crypto.randomUUID(), + id: optimisticResponseId, __typename: 'ServiceAnswer' }, __typename: 'ServiceAnswerResponse' } }, update: (cache, result) => { - // optimisticResponse or serverResponse + // newServiceAnswer is either optimisticResponse or serverResponse const newServiceAnswer = result.data.createServiceAnswer.serviceAnswer - // Fetch all the activeEngagements + if (!newServiceAnswer.id.startsWith(LOCAL_ONLY_ID_PREFIX)) { + // The result contains a server response. Check if we need to update the service answer with a server persisted client + // (ie at the time of creation the service answer we only had a locally persisted client) + + // look up the service answer in our map, using tbe optimistic response local id + const cachedMap = client.readQuery({ + query: CLIENT_SERVICE_ENTRY_ID_MAP + }) + + const clientServiceEntryIdMap = { ...cachedMap?.clientServiceEntryIdMap } + + const contactIds = Object.keys(clientServiceEntryIdMap).filter( + (contactId) => + clientServiceEntryIdMap[contactId] && + clientServiceEntryIdMap[contactId].id === optimisticResponseId + ) + + if (contactIds) { + // This service answer was created with local clients. Check if we now have the server persisted versions of those clients, and update the service + // answer if we do + contactIds.forEach((contactId) => { + if (!contactId.startsWith(LOCAL_ONLY_ID_PREFIX)) { + // We do have the server persisted client, so update the service answer + updateServiceAnswerClient( + newServiceAnswer, + contactId, + clientServiceEntryIdMap[contactId].serviceId, + updateServiceAnswer + ) + + delete clientServiceEntryIdMap[contactId] + } else { + // We don't yet have the server persisted client, so update our map with the server persisted service answer, so the service answer can + // be updated once we have the server persisted client + clientServiceEntryIdMap[contactId] = { + id: newServiceAnswer.id, + serviceId: clientServiceEntryIdMap[contactId].serviceId + } + } + }) + } + + client.writeQuery({ + query: CLIENT_SERVICE_ENTRY_ID_MAP, + data: { + clientServiceEntryIdMap: clientServiceEntryIdMap + } + }) + } + + // Fetch all the service answers const queryOptions = { query: GET_SERVICE_ANSWERS, variables: { serviceId: serviceAnswer.serviceId } } - // Now we combine the newEngagement we passed in earlier with the existing data + // Now we combine the new service answer we passed in earlier with the existing service answers const addOptimisticResponse = (data) => { if (data) { return { @@ -129,17 +225,19 @@ export function useAddServiceAnswerCallback(refetch: () => void): AddServiceAnsw } } + // Update the cache cache.updateQuery(queryOptions, addOptimisticResponse) - refetch() } + }).then(() => { + refetch() }) success(c('hooks.useServicelist.createAnswerSuccess')) return true } catch (error) { - failure(c('hooks.useServicelist.createAnswerFailed')) + failure(c('hooks.useServicelist.createAnswerFailed'), error) return false } }, - [c, success, failure, refetch, addServiceAnswers, orgId, client] + [c, success, failure, refetch, addServiceAnswers, organization, client, updateServiceAnswer] ) } diff --git a/packages/webapp/src/hooks/api/useServiceAnswerList/useDeleteServiceAnswerCallback.ts b/packages/webapp/src/hooks/api/useServiceAnswerList/useDeleteServiceAnswerCallback.ts index afd8786d2..13ca1a4f8 100644 --- a/packages/webapp/src/hooks/api/useServiceAnswerList/useDeleteServiceAnswerCallback.ts +++ b/packages/webapp/src/hooks/api/useServiceAnswerList/useDeleteServiceAnswerCallback.ts @@ -33,7 +33,7 @@ export function useDeleteServiceAnswerCallback(refetch: () => void): DeleteServi success(c('hooks.useServicelist.deleteAnswerSuccess')) return true } catch (error) { - failure(c('hooks.useServicelist.deleteAnswerFailed')) + failure(c('hooks.useServicelist.deleteAnswerFailed'), error) return false } }, diff --git a/packages/webapp/src/hooks/api/useServiceAnswerList/useUpdateServiceAnswerCallback.ts b/packages/webapp/src/hooks/api/useServiceAnswerList/useUpdateServiceAnswerCallback.ts index 5bdeebb93..50f2a9150 100644 --- a/packages/webapp/src/hooks/api/useServiceAnswerList/useUpdateServiceAnswerCallback.ts +++ b/packages/webapp/src/hooks/api/useServiceAnswerList/useUpdateServiceAnswerCallback.ts @@ -58,7 +58,7 @@ export function useUpdateServiceAnswerCallback(refetch: () => void): UpdateServi const answer = result.data?.updateServiceAnswer?.serviceAnswer return answer } catch (error) { - failure(c('hooks.useServicelist.updateAnswerFailed')) + failure(c('hooks.useServicelist.updateAnswerFailed'), error) return null } }, diff --git a/packages/webapp/src/hooks/useEngagementSearchHandler.ts b/packages/webapp/src/hooks/useEngagementSearchHandler.ts index c150b2bd2..0ebc33db6 100644 --- a/packages/webapp/src/hooks/useEngagementSearchHandler.ts +++ b/packages/webapp/src/hooks/useEngagementSearchHandler.ts @@ -6,20 +6,22 @@ import type { Engagement } from '@cbosuite/schema/dist/client-types' import { useSearchHandler } from './useSearchHandler' +function contains(string: string, search: string): boolean { + return string.toLowerCase().includes(search.toLowerCase()) +} + +function predicate(engagement: Engagement, search: string): boolean { + return ( + contains(engagement.title, search) || + engagement.contacts.some((contact) => + [contact.name.first, contact.name.last].some((name) => contains(name, search)) + ) + ) +} + export function useEngagementSearchHandler( - items: Engagement[], + engagements: Engagement[], onFilter: (filted: Engagement[]) => void ) { - return useSearchHandler( - items, - onFilter, - (engagement: Engagement, search: string) => - engagement.contacts.some((contact) => - contact.name.first.toLowerCase().includes(search.toLowerCase()) - ) || - engagement.contacts.some((contact) => - contact.name.last.toLowerCase().includes(search.toLowerCase()) - ) || - engagement.title.toLowerCase().includes(search.toLowerCase()) - ) + return useSearchHandler(engagements, onFilter, predicate) } diff --git a/packages/webapp/src/hooks/useSearchHandler.ts b/packages/webapp/src/hooks/useSearchHandler.ts index 959b178f8..24adc720f 100644 --- a/packages/webapp/src/hooks/useSearchHandler.ts +++ b/packages/webapp/src/hooks/useSearchHandler.ts @@ -73,7 +73,7 @@ export function useSearchHandler( * @param reset The reset function (e.g. set local state) */ function useResetFilterOnDataChange(items: T[], reset: (items: T[]) => void) { - useEffect(() => { + useCallback(() => { if (items) { reset(items) } diff --git a/packages/webapp/src/locales/en-US/common.json b/packages/webapp/src/locales/en-US/common.json index c2e89eccb..7f4de252e 100644 --- a/packages/webapp/src/locales/en-US/common.json +++ b/packages/webapp/src/locales/en-US/common.json @@ -287,6 +287,8 @@ "_notApplicable.comment": "Not Applicable" }, "offline": { + "generalNotice": "You are currently offline. Any records created while offline will be stored on your device and sync automatically when you connect to the internet.", + "_generalNotice.comment": "Used when the user is offline as a general notification", "entityCreationNotice": "You are currently offline. This record will be stored on your device and sync automatically when you connect to the internet.", "_entityCreationNotice.comment": "Used when the user is offline and the form to create a record is open", "connectToTheInternet": "Connect to the Internet", diff --git a/packages/webapp/src/locales/en-US/requests.json b/packages/webapp/src/locales/en-US/requests.json index e79bdb1c7..c4fb93774 100644 --- a/packages/webapp/src/locales/en-US/requests.json +++ b/packages/webapp/src/locales/en-US/requests.json @@ -69,7 +69,9 @@ "inProgress": "In Progress", "_inProgress.comment": "Status label for In Progress request", "completed": "Completed", - "_completed.comment": "Status label for Completed request" + "_completed.comment": "Status label for Completed request", + "local": "Local only", + "_local.comment": "Status label for request reated locally and not sync with the server yet" }, "requestEditButton": "Edit Request", "_requestEditButton.comment": "Edit Request button text", diff --git a/packages/webapp/src/pages/index.tsx b/packages/webapp/src/pages/index.tsx index dac9c4e7d..707aa77e0 100644 --- a/packages/webapp/src/pages/index.tsx +++ b/packages/webapp/src/pages/index.tsx @@ -7,40 +7,46 @@ import { MyRequestsList } from '~lists/MyRequestsList' import { RequestList } from '~lists/RequestList' import { InactiveRequestList } from '~lists/InactiveRequestList' import { Namespace, useTranslation } from '~hooks/useTranslation' -import type { FC } from 'react' -import { useCallback, useState } from 'react' -import { useInactiveEngagementList } from '~hooks/api/useInactiveEngagementList' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useCurrentUser } from '~hooks/api/useCurrentUser' -import type { IPageTopButtons } from '~components/ui/PageTopButtons' import { PageTopButtons } from '~components/ui/PageTopButtons' -import { wrap } from '~utils/appinsights' import { Title } from '~components/ui/Title' import { NewFormPanel } from '~components/ui/NewFormPanel' +import { useOffline } from '~hooks/useOffline' + +// Types +import type { Engagement } from '@cbosuite/schema/dist/client-types' +import type { FC } from 'react' +import type { IPageTopButtons } from '~components/ui/PageTopButtons' + +// Apollo +import { GET_ENGAGEMENTS } from '~queries' +import { useQuery } from '@apollo/client' + +// Utils +import { wrap } from '~utils/appinsights' +import { sortByDuration, sortByIsLocal } from '~utils/engagements' +import { isCacheInitialized } from '../api/cache' +import { + clearPreQueueLoadRequired, + getPreQueueLoadRequired, + getPreQueueRequest, + setPreQueueRequest +} from '~utils/localCrypto' +import { config } from '~utils/config' const HomePage: FC = wrap(function Home() { const { t } = useTranslation(Namespace.Requests) const { userId, orgId } = useCurrentUser() - const { - engagementList, - myEngagementList, - addEngagement: addRequest, - editEngagement: editRequest, - claimEngagement: claimRequest, - loading - } = useEngagementList(orgId, userId) - - const { inactiveEngagementList, loading: inactiveLoading } = useInactiveEngagementList(orgId) + const { addEngagement } = useEngagementList(orgId, userId) + const isOffline = useOffline() const [openNewFormPanel, setOpenNewFormPanel] = useState(false) const [newFormName, setNewFormName] = useState(null) - - const handleEditMyEngagements = async (form: any) => { - await editRequest({ - ...form - }) - } - - const handleClaimEngagements = async (form: any) => { - await claimRequest(form.id, userId) + const isDurableCacheEnabled = Boolean(config.features.durableCache.enabled) + const saveQueuedData = (value) => { + const queue: any[] = getPreQueueRequest() + queue.push(value) + setPreQueueRequest(queue) } const buttons: IPageTopButtons[] = [ @@ -79,17 +85,126 @@ const HomePage: FC = wrap(function Home() { (values: any) => { switch (newFormName) { case 'addRequestForm': - addRequest(values) + if (isOffline && isDurableCacheEnabled) { + saveQueuedData(values) + } + addEngagement(values) break } }, - [addRequest, newFormName] + [addEngagement, newFormName, isOffline, isDurableCacheEnabled] + ) + + // Fetch allEngagements + const { data, loading } = useQuery(GET_ENGAGEMENTS, { + fetchPolicy: 'cache-and-network', + variables: { orgId: orgId } + }) + + // Update the Query cached results with the subscription + // https://www.apollographql.com/docs/react/data/subscriptions#subscribing-to-updates-for-a-query + /* useEffect(() => { + subscribeToMore({ + document: SUBSCRIBE_TO_ORG_ENGAGEMENTS, + variables: { orgId: orgId }, + updateQuery: (previous, { subscriptionData }) => { + if (!subscriptionData || !subscriptionData?.data?.engagements) { + return previous + } + + const { action, engagement, message } = subscriptionData.data.engagements + if (message !== 'Success') return previous + + // Setup the engagements to replace in the cache + let userActiveEngagements = [...previous.userActiveEngagements] + + // If it's a CLOSED or COMPLETED, we remove it + if (['CLOSED', 'COMPLETED'].includes(action)) { + userActiveEngagements = userActiveEngagements.filter((e) => e.id !== engagement.id) + } + + // If it's a new or existing engagement from the currentUser, we update it + if (['UPDATE', 'CREATED'].includes(action)) { + userActiveEngagements = userActiveEngagements.filter((e) => e.id !== engagement.id) + if (engagement?.user?.id === userId) { + userActiveEngagements = [...userActiveEngagements, engagement] + } + } + + return { activeEngagements: previous.activeEngagements, userActiveEngagements } + } + }) + }, [orgId, userId, subscribeToMore]) */ + + // Memoized the Engagements to only update when useQuery is triggered + const engagements: Engagement[] = useMemo(() => [...(data?.allEngagements ?? [])], [data]) + + // If the browser has been restarted/reloaded and persistent pending values + // are available, requeue them. + useEffect(() => { + if (isCacheInitialized() && getPreQueueLoadRequired()) { + // Find the Optimistic Responses, and stringify the values `preQueued` + // from the `createEngagement` form + const localEngagements = engagements + .filter((engagement) => engagement.id.includes('LOCAL')) + .map((engagement) => { + return JSON.stringify({ + title: engagement.title, + userId: engagement.user.id, + contactIds: engagement.contacts.map((contact) => contact.id), + endDate: new Date(engagement.endDate).valueOf(), + description: engagement.description + }) + }) + + getPreQueueRequest().forEach((item) => { + // Only add missing engagements + const itemInfo = JSON.stringify({ + title: item.title, + userId: item.userId, + contactIds: item.contactIds, + endDate: new Date(item.endDate).valueOf(), + description: item.description + }) + if (!localEngagements.includes(itemInfo)) { + addEngagement(item) + } + }) + clearPreQueueLoadRequired() + } + }, [addEngagement, engagements]) + + // Split the engagements per lists + const { userEngagements, otherEngagements, inactivesEngagements } = useMemo( + () => + engagements + .sort(sortByDuration) + .sort(sortByIsLocal) + .reduce( + function (lists, engagement) { + if (['CLOSED', 'COMPLETED'].includes(engagement.status)) { + lists.inactivesEngagements.push(engagement) + } else { + if (engagement?.user?.id === userId) { + lists.userEngagements.push(engagement) + } else { + lists.otherEngagements.push(engagement) + } + } + return lists + }, + { + userEngagements: [] as Engagement[], + otherEngagements: [] as Engagement[], + inactivesEngagements: [] as Engagement[] + } + ), + [engagements, userId] ) - const title = t('pageTitle') return ( <> - + <Title title={t('pageTitle')} /> <NewFormPanel showNewFormPanel={openNewFormPanel} @@ -98,24 +213,9 @@ const HomePage: FC = wrap(function Home() { onNewFormPanelSubmit={handleNewFormPanelSubmit} /> <PageTopButtons buttons={buttons} /> - <MyRequestsList - title={t('myRequestsTitle')} - requests={myEngagementList} - onEdit={handleEditMyEngagements} - loading={loading && myEngagementList.length === 0} - /> - <RequestList - title={t('requestsTitle')} - requests={engagementList} - onEdit={editRequest} - onClaim={handleClaimEngagements} - loading={loading && engagementList.length === 0} - /> - <InactiveRequestList - title={t('closedRequestsTitle')} - requests={inactiveEngagementList} - loading={inactiveLoading && inactiveEngagementList.length === 0} - /> + <MyRequestsList engagements={userEngagements} loading={loading} /> + <RequestList engagements={otherEngagements} loading={loading} /> + <InactiveRequestList engagements={inactivesEngagements} loading={loading} /> </> ) }) diff --git a/packages/webapp/src/pages/services/serviceEntry.tsx b/packages/webapp/src/pages/services/serviceEntry.tsx index 0d246322e..d2735bf8d 100644 --- a/packages/webapp/src/pages/services/serviceEntry.tsx +++ b/packages/webapp/src/pages/services/serviceEntry.tsx @@ -28,7 +28,7 @@ const ServiceEntry: FC<{ service: Service; sid: string }> = ({ service, sid }) = const title = t('pageTitle') const { addServiceAnswer } = useServiceAnswerList(sid) const [showForm, setShowForm] = useState(true) - const { addEngagement: addRequest } = useEngagementList() + const { addEngagement } = useEngagementList() const { orgId } = useCurrentUser() const location = useLocation() const kioskMode = location.pathname === ApplicationRoute.ServiceEntryKiosk @@ -58,7 +58,7 @@ const ServiceEntry: FC<{ service: Service; sid: string }> = ({ service, sid }) = ) { switch (formName ?? newFormName) { case 'addRequestForm': - addRequest(values) + addEngagement(values) break } } diff --git a/packages/webapp/src/store/index.ts b/packages/webapp/src/store/index.ts index 79d416ae1..ab9dbcc38 100644 --- a/packages/webapp/src/store/index.ts +++ b/packages/webapp/src/store/index.ts @@ -14,6 +14,7 @@ import { recoilPersist } from 'recoil-persist' import { empty } from '~utils/noop' import type { IFieldFilter } from '~components/lists/ReportList/types' import { ReportType } from '~components/lists/ReportList/types' +import type { AddedContactState } from '~hooks/api/useContacts/useCreateContactCallback' /** * @@ -30,7 +31,7 @@ export const currentUserState = atom<User | null>({ }) // Atomic state for addedContact -export const addedContactState = atom<Contact | null>({ +export const addedContactState = atom<AddedContactState | null>({ key: 'addedContact', default: null }) @@ -118,3 +119,9 @@ export const fieldFiltersState = atom<IFieldFilter[]>({ default: [], effects_UNSTABLE: [persistAtom] }) + +// +export const sessionPasswordState = atom<string>({ + key: 'sessionPassword', + default: '' +}) diff --git a/packages/webapp/src/utils/current-user-store.ts b/packages/webapp/src/utils/current-user-store.ts new file mode 100644 index 000000000..c7f4422ee --- /dev/null +++ b/packages/webapp/src/utils/current-user-store.ts @@ -0,0 +1,11 @@ +/*! + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project. + */ +import { Store } from 'react-stores' + +// TODO: may not need to using recoil to store this +export const currentUserStore = new Store({ + username: '', + sessionPassword: '' +}) diff --git a/packages/webapp/src/utils/engagements.ts b/packages/webapp/src/utils/engagements.ts new file mode 100644 index 000000000..37614e423 --- /dev/null +++ b/packages/webapp/src/utils/engagements.ts @@ -0,0 +1,31 @@ +/*! + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project. + */ + +import type { Engagement } from '@cbosuite/schema/dist/client-types' + +export function isLocal(engagement: any): boolean { + return engagement.id.includes('LOCAL') +} + +export function sortByDuration(a: Engagement, b: Engagement) { + const currDate = new Date() + + const aDate = a?.endDate ? new Date(a.endDate) : currDate + const bDate = b?.endDate ? new Date(b.endDate) : currDate + + const aDuration = currDate.getTime() - aDate.getTime() + const bDuration = currDate.getTime() - bDate.getTime() + + return aDuration > bDuration ? -1 : 1 +} + +export function sortByIsLocal(a: Engagement, b: Engagement) { + const aIsLocal = isLocal(a) + const bIsLocal = isLocal(b) + + if (!aIsLocal && bIsLocal) return 1 + if (aIsLocal && !bIsLocal) return -1 + return 0 +} diff --git a/packages/webapp/src/utils/localCrypto.ts b/packages/webapp/src/utils/localCrypto.ts new file mode 100644 index 000000000..29ae7277a --- /dev/null +++ b/packages/webapp/src/utils/localCrypto.ts @@ -0,0 +1,249 @@ +/*! + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project. + */ +import * as bcrypt from 'bcryptjs' +import * as CryptoJS from 'crypto-js' +import { currentUserStore } from '~utils/current-user-store' +import type { User } from '@cbosuite/schema/dist/client-types' + +const APOLLO_KEY = '-apollo-cache-persist' +const SALT_KEY = '-hash-salt' +const SALT_ROUNDS = 12 +const HASH_PWD_KEY = '-hash-pwd' +const CURRENT_USER_KEY = 'current-user' +const REQUEST_QUEUE_KEY = '-request-queue' +const PRE_QUEUE_REQUEST_KEY = '-pre-queue-request' +const VERIFY_TEXT = 'DECRYPT ME' +const VERIFY_TEXT_KEY = '-verify' +const PRE_QUEUE_LOAD_REQUIRED = 'pre-queue-load-required' +const USER_KEY = '-user' +const ACCESS_TOKEN_KEY = '-access-token' + +/** + * Check if a salt value has been stored for the given user. Each user will need a salt value to generate an encrypted + * password that will be stored in the session to allow decryption of the apollo persistent cache. + * If the salt doesn't exist, create it and return false (indicating the data will be purged). + * If the salt does exist, return true (indicating the client may use the existing salt). + * + * @param userid + * + * @returns boolean - value that indicates if the salt had to be created (false) or exists (true) + */ +const checkSalt = (userid: string): boolean => { + if (userid) { + const saltKey = userid.concat(SALT_KEY) + const salt = window.localStorage.getItem(saltKey) + + if (!salt) { + const saltNew = bcrypt.genSaltSync(SALT_ROUNDS) + setSalt(saltKey, saltNew) + + return false + } + return true + } + return false +} + +const setSalt = (saltKey: string, value: string) => { + window.localStorage.setItem(saltKey, value) +} + +const getSalt = (saltKey: string): string | void => { + return window.localStorage.getItem(saltKey) +} + +const setPwdHash = (uid: string, pwd: string): boolean => { + if (uid) { + const salt = getSalt(uid.concat(SALT_KEY)) + if (!salt) { + return false + } + const hashPwd = bcrypt.hashSync(pwd, salt) + window.localStorage.setItem(uid.concat(HASH_PWD_KEY), hashPwd) + + const edata = CryptoJS.AES.encrypt(VERIFY_TEXT, getPwdHash(uid)).toString() + window.localStorage.setItem(uid.concat(VERIFY_TEXT_KEY), edata) + return true + } + return false +} + +const getPwdHash = (uid: string): string => { + return window.localStorage.getItem(uid.concat(HASH_PWD_KEY)) +} + +const testPassword = (uid: string, passwd: string) => { + const currentPwdHash = getPwdHash(uid) + + const salt = getSalt(uid.concat(SALT_KEY)) + if (!currentPwdHash || !salt) { + return false + } + const encryptedPasswd = bcrypt.hashSync(passwd, salt) + + return encryptedPasswd === currentPwdHash +} + +const getCurrentUserId = (): string => { + return window.localStorage.getItem(CURRENT_USER_KEY) +} + +const setCurrentUserId = (uid: string) => { + window.localStorage.setItem(CURRENT_USER_KEY, uid) +} + +const getUser = (userId: string): string => { + const currentPwdHash = getPwdHash(userId) + const encryptedUser = window.localStorage.getItem(userId.concat(USER_KEY)) + const dataBytes = CryptoJS.AES.decrypt(encryptedUser, currentPwdHash) + const user = dataBytes.toString(CryptoJS.enc.Utf8) + + return user +} + +const setUser = (userId: string, user: User) => { + const encryptedUser = CryptoJS.AES.encrypt(JSON.stringify(user), getPwdHash(userId)).toString() + window.localStorage.setItem(userId.concat(USER_KEY), encryptedUser) +} + +const getAccessToken = (userId: string): string => { + if (!userId) { + return null + } + const currentPwdHash = getPwdHash(userId) + const encryptedAccessToken = window.localStorage.getItem(userId.concat(ACCESS_TOKEN_KEY)) + + if (!currentPwdHash || !encryptedAccessToken) { + return null + } + const dataBytes = CryptoJS.AES.decrypt(encryptedAccessToken, currentPwdHash) + const accessToken = dataBytes.toString(CryptoJS.enc.Utf8) + + return accessToken +} + +const setAccessToken = (userId: string, accessToken: string) => { + const encryptedAccessToken = CryptoJS.AES.encrypt(accessToken, getPwdHash(userId)).toString() + window.localStorage.setItem(userId.concat(ACCESS_TOKEN_KEY), encryptedAccessToken) +} + +const clearUser = (uid: string): void => { + window.localStorage.removeItem(uid.concat(VERIFY_TEXT_KEY)) + window.localStorage.removeItem(uid.concat(HASH_PWD_KEY)) + window.localStorage.removeItem(uid.concat(SALT_KEY)) +} + +const setCurrentRequestQueue = (queue: string): boolean => { + return setQueue(queue, REQUEST_QUEUE_KEY) +} + +const setPreQueueRequest = (queue: any[]): boolean => { + return setQueue(JSON.stringify(queue), PRE_QUEUE_REQUEST_KEY) +} + +const setQueue = (queue: string, key: string): boolean => { + const uid = getCurrentUserId() + if (uid && queue) { + const hash = getPwdHash(uid) + if (hash) { + const edata = CryptoJS.AES.encrypt(queue, currentUserStore.state.sessionPassword).toString() + window.localStorage.setItem(uid.concat(key), edata) + return true + } + } + return false +} + +const getCurrentRequestQueue = (): string => { + return getQueue(REQUEST_QUEUE_KEY) +} + +const getPreQueueRequest = (): any[] => { + const requests = getQueue(PRE_QUEUE_REQUEST_KEY) + if (requests) { + return JSON.parse(requests) + } + return [] +} + +const getQueue = (key: string): string => { + const empty = '[]' + const uid = getCurrentUserId() + if (uid) { + const hash = getPwdHash(uid) + if (hash) { + const edata = window.localStorage.getItem(uid.concat(key)) + if (!edata) { + setQueue(empty, key) + } else { + const sessionKey = currentUserStore.state.sessionPassword + const dataBytes = CryptoJS.AES.decrypt(edata, sessionKey) + return dataBytes.toString(CryptoJS.enc.Utf8) + } + } + } + return empty +} + +const clearCurrentRequestQueue = (): boolean => { + const uid = getCurrentUserId() + if (uid) { + window.localStorage.removeItem(uid.concat(REQUEST_QUEUE_KEY)) + return true + } + return false +} + +const clearPreQueueRequest = (): boolean => { + const uid = getCurrentUserId() + + if (uid) { + window.localStorage.removeItem(uid.concat(PRE_QUEUE_REQUEST_KEY)) + return true + } + return false +} + +const setPreQueueLoadRequired = (): void => { + window.localStorage.setItem(PRE_QUEUE_LOAD_REQUIRED, 'true') +} + +const clearPreQueueLoadRequired = (): void => { + window.localStorage.setItem(PRE_QUEUE_LOAD_REQUIRED, 'false') +} + +const getPreQueueLoadRequired = (): boolean => { + const setting = window.localStorage.getItem(PRE_QUEUE_LOAD_REQUIRED) + if (setting) { + return setting === 'true' + } + return false +} + +export { + setCurrentUserId, + getCurrentUserId, + getUser, + setUser, + checkSalt, + setSalt, + getSalt, + setPwdHash, + getPwdHash, + testPassword, + clearUser, + getCurrentRequestQueue, + setCurrentRequestQueue, + clearCurrentRequestQueue, + getPreQueueRequest, + setPreQueueRequest, + clearPreQueueRequest, + setPreQueueLoadRequired, + clearPreQueueLoadRequired, + getPreQueueLoadRequired, + getAccessToken, + setAccessToken, + APOLLO_KEY +} diff --git a/packages/webapp/src/utils/localStorage.ts b/packages/webapp/src/utils/localStorage.ts index c8970a04b..f1fffdf42 100644 --- a/packages/webapp/src/utils/localStorage.ts +++ b/packages/webapp/src/utils/localStorage.ts @@ -14,14 +14,6 @@ export function clearStoredState() { localStorage.removeItem(RECOIL_PERSIST_KEY) } -export function storeAccessToken(token: string) { - localStorage.setItem(ACCESS_TOKEN_KEY, token) -} - -export function retrieveAccessToken() { - return localStorage.getItem(ACCESS_TOKEN_KEY) -} - export function storeLocale(locale: string) { localStorage.setItem(LOCALE_KEY, locale) } diff --git a/packages/webapp/src/utils/ocrDemo.ts b/packages/webapp/src/utils/ocrDemo.ts new file mode 100644 index 000000000..0c23e3e55 --- /dev/null +++ b/packages/webapp/src/utils/ocrDemo.ts @@ -0,0 +1,27 @@ +/*! + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project. + */ + +/** + * Demo Code + * Needs to be clean up in next sprint + */ + +const requestOcrData = async (file) => { + const formdata = new FormData() + formdata.append('file', file) + + const requestOptions = { + method: 'POST', + body: formdata + } + return fetch('https://cbo-ops-suite.azurewebsites.net/api/ocr', requestOptions) +} + +const scanFile = async (file) => { + const result = await Promise.all([requestOcrData(file).then((result) => result.json())]) + return result[0] +} + +export default scanFile diff --git a/packages/webapp/src/utils/queueLink.ts b/packages/webapp/src/utils/queueLink.ts index 294b62527..389005e49 100644 --- a/packages/webapp/src/utils/queueLink.ts +++ b/packages/webapp/src/utils/queueLink.ts @@ -3,17 +3,11 @@ * Licensed under the MIT license. See LICENSE file in the project. */ -import type { - Operation, - FetchResult, - NextLink, - DocumentNode -} from '@apollo/client/link/core'; -import { - ApolloLink -} from '@apollo/client/link/core' -import type { Observer } from '@apollo/client/utilities'; +import type { Operation, FetchResult, NextLink, DocumentNode } from '@apollo/client/link/core' +import { ApolloLink } from '@apollo/client/link/core' +import type { Observer } from '@apollo/client/utilities' import { Observable } from '@apollo/client/utilities' +import { clearPreQueueRequest } from '~utils/localCrypto' export interface OperationQueueEntry { operation: Operation @@ -53,8 +47,10 @@ export default class QueueLink extends ApolloLink { public open() { this.isOpen = true - const opQueueCopy = [...this.opQueue] + let opQueueCopy = [] + opQueueCopy = [...this.opQueue] this.opQueue = [] + opQueueCopy.forEach(({ operation, forward, observer }) => { const key: string = QueueLink.key(operation.operationName, 'dequeue') if (key in QueueLink.listeners) { @@ -68,8 +64,11 @@ export default class QueueLink extends ApolloLink { listener({ operation, forward, observer }) }) } + forward(operation).subscribe(observer) }) + + clearPreQueueRequest() } public static addLinkQueueEventListener = ( diff --git a/packages/webapp/src/utils/serviceAnswers.ts b/packages/webapp/src/utils/serviceAnswers.ts new file mode 100644 index 000000000..5a9d53409 --- /dev/null +++ b/packages/webapp/src/utils/serviceAnswers.ts @@ -0,0 +1,37 @@ +/*! + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project. + */ + +import { cloneDeep } from 'lodash' +import type { UpdateServiceAnswerCallback } from '~hooks/api/useServiceAnswerList/useUpdateServiceAnswerCallback' + +export function updateServiceAnswerClient( + serviceAnswerToUpdate, + contactId: string, + serviceId: string, + updateServiceAnswerCallback: UpdateServiceAnswerCallback +) { + if (serviceAnswerToUpdate) { + const serviceAnswerCopy = cloneDeep(serviceAnswerToUpdate) + delete serviceAnswerCopy.__typename + for (let i = serviceAnswerCopy.fields.length - 1; i >= 0; --i) { + const field = serviceAnswerCopy.fields[i] + if (field.values === null && field.value === null) { + serviceAnswerCopy.fields.splice(i, 1) + } else if (field.values === null) { + delete field.values + } else if (field.value === null) { + delete field.value + } + delete field.__typename + } + const contacts = [...serviceAnswerCopy.contacts, contactId] + + updateServiceAnswerCallback({ + ...serviceAnswerCopy, + contacts, + serviceId: serviceId + }) + } +} diff --git a/packages/webapp/tsconfig.json b/packages/webapp/tsconfig.json index f2348e91b..ed5f87f7b 100644 --- a/packages/webapp/tsconfig.json +++ b/packages/webapp/tsconfig.json @@ -17,7 +17,8 @@ "~hooks/*": ["src/hooks/*"], "~types/*": ["src/types/*"], "~pages/*": ["src/pages/*"], - "~api": ["src/api/index.ts"] + "~api": ["src/api/index.ts"], + "~queries": ["src/api/queries.ts"] }, "target": "es2019", "lib": ["dom", "dom.iterable", "esnext"], diff --git a/packages/webapp/workers/app.sw.tmpl b/packages/webapp/workers/app.sw.tmpl index dcc3c1634..33541bb13 100644 --- a/packages/webapp/workers/app.sw.tmpl +++ b/packages/webapp/workers/app.sw.tmpl @@ -11,6 +11,7 @@ const noCacheURLS = [ <%= urlAPI %>, "visualstudio.com", "beacon-v2.helpscout.net", + "cbo-ops-suite.azurewebsites.net" ]; /* diff --git a/yarn.lock b/yarn.lock index be6f76e79..3af683fb5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3265,7 +3265,9 @@ __metadata: "@tsconfig/node14": ^1.0.1 "@types/babel__core": ^7.1.18 "@types/babel__preset-env": ^7.9.2 + "@types/bcryptjs": ^2.4.2 "@types/config": ^0.0.41 + "@types/crypto-js": ^4.1.1 "@types/debug": ^4.1.7 "@types/express": ^4.17.13 "@types/jest": ^27.4.1 @@ -3283,11 +3285,13 @@ __metadata: apollo3-cache-persist: ^0.14.0 autoprefixer: ^10.4.2 babel-plugin-transform-import-meta: ^2.1.1 + bcryptjs: ^2.4.3 bootstrap: ^5.1.3 classnames: ^2.3.1 config: ^3.3.7 core-js: ^3.21.1 cross-fetch: ^3.1.5 + crypto-js: ^4.1.1 debug: ^4.3.3 dotenv: ^10.0.0 firebase: ^8.10.1 @@ -3318,6 +3322,7 @@ __metadata: react-paginated-list: ^1.1.5 react-router-dom: ^5.3.0 react-select: ^4.3.1 + react-stores: ^5.5.0 react-toast-notifications: ^2.5.1 react-transition-group: ^4.4.2 recoil: ^0.6.1 @@ -6444,6 +6449,13 @@ __metadata: languageName: node linkType: hard +"@types/crypto-js@npm:^4.1.1": + version: 4.1.1 + resolution: "@types/crypto-js@npm:4.1.1" + checksum: ea3d6a67b69f88baeb6af96004395903d2367a41bd5cd86306da23a44dd96589749495da50974a9b01bb5163c500764c8a33706831eade036bddae016417e3ea + languageName: node + linkType: hard + "@types/debug@npm:^4.1.7": version: 4.1.7 resolution: "@types/debug@npm:4.1.7" @@ -9575,6 +9587,17 @@ __metadata: languageName: node linkType: hard +"clone-deep@npm:^4.0.1": + version: 4.0.1 + resolution: "clone-deep@npm:4.0.1" + dependencies: + is-plain-object: ^2.0.4 + kind-of: ^6.0.2 + shallow-clone: ^3.0.0 + checksum: 770f912fe4e6f21873c8e8fbb1e99134db3b93da32df271d00589ea4a29dbe83a9808a322c93f3bcaf8584b8b4fa6fc269fc8032efbaa6728e0c9886c74467d2 + languageName: node + linkType: hard + "clone-response@npm:^1.0.2": version: 1.0.2 resolution: "clone-response@npm:1.0.2" @@ -10164,6 +10187,13 @@ __metadata: languageName: node linkType: hard +"crypto-js@npm:^4.1.1": + version: 4.1.1 + resolution: "crypto-js@npm:4.1.1" + checksum: b3747c12ee3a7632fab3b3e171ea50f78b182545f0714f6d3e7e2858385f0f4101a15f2517e033802ce9d12ba50a391575ff4638c9de3dd9b2c4bc47768d5425 + languageName: node + linkType: hard + "crypto-random-string@npm:^2.0.0": version: 2.0.0 resolution: "crypto-random-string@npm:2.0.0" @@ -19462,6 +19492,17 @@ __metadata: languageName: node linkType: hard +"react-stores@npm:^5.5.0": + version: 5.5.0 + resolution: "react-stores@npm:5.5.0" + dependencies: + clone-deep: ^4.0.1 + peerDependencies: + react: ^17.0.2 + checksum: 7dbacbf210859b98058e43c45827d594ac21a5679f63649fac3494757a603912ccc30fa68aa971817e801fe4d41e0802592f58089ff06894352440708075eb5e + languageName: node + linkType: hard + "react-toast-notifications@npm:^2.5.1": version: 2.5.1 resolution: "react-toast-notifications@npm:2.5.1" @@ -20555,6 +20596,15 @@ resolve@^2.0.0-next.3: languageName: node linkType: hard +"shallow-clone@npm:^3.0.0": + version: 3.0.1 + resolution: "shallow-clone@npm:3.0.1" + dependencies: + kind-of: ^6.0.2 + checksum: 39b3dd9630a774aba288a680e7d2901f5c0eae7b8387fc5c8ea559918b29b3da144b7bdb990d7ccd9e11be05508ac9e459ce51d01fd65e583282f6ffafcba2e7 + languageName: node + linkType: hard + "shallowequal@npm:^1.1.0": version: 1.1.0 resolution: "shallowequal@npm:1.1.0"