diff --git a/.eslintrc b/.eslintrc index 1ff16d91..99655a22 100644 --- a/.eslintrc +++ b/.eslintrc @@ -73,7 +73,26 @@ ] } ], - "tailwindcss/classnames-order": ["error"] + "tailwindcss/classnames-order": ["error"], + "no-restricted-exports": "off", // This rule goes against sonar cloud recommendations + "no-restricted-properties": [ + "warn", + { + "object": "it", + "property": "only", + "message": "Remove .only from tests before committing!" + }, + { + "object": "describe", + "property": "only", + "message": "Remove .only from tests before committing!" + }, + { + "object": "test", + "property": "only", + "message": "Remove .only from tests before committing!" + } + ] }, "settings": { "tailwindcss": { diff --git a/.gitignore b/.gitignore index 54641707..2a6daa14 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,4 @@ build /scripts/allLicenseResults.json vcr.yml +.scannerwork/ diff --git a/frontend/package.json b/frontend/package.json index 7838a788..0989aa9f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -37,15 +37,18 @@ "autolinker": "^4.0.0", "autoprefixer": "^10.4.19", "axios": "^1.12.0", + "classnames": "^2.5.1", "events": "^3.3.0", "i18next": "^25.3.2", "i18next-browser-languagedetector": "^8.2.0", + "json-storage-formatter": "^2.0.9", "lodash": "^4.17.21", "opentok-layout-js": "^5.4.0", "opentok-solutions-logging": "^1.1.5", "postcss": "^8.4.38", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-global-state-hooks": "^14.0.2", "react-i18next": "^15.6.1", "react-router-dom": "^6.11.0", "resize-observer-polyfill": "^1.5.1", @@ -54,8 +57,8 @@ "ua-parser-js": "^1.0.37", "uuid": "^9.0.1", "vite": "^5.4.19", - "vitest": "^1.6", - "vite-plugin-checker": "^0.11.0" + "vite-plugin-checker": "^0.11.0", + "vitest": "^1.6" }, "devDependencies": { "@types/lodash": "^4.17.1", diff --git a/frontend/src/App.spec.tsx b/frontend/src/App.spec.tsx index 8770b5d2..d87edc22 100644 --- a/frontend/src/App.spec.tsx +++ b/frontend/src/App.spec.tsx @@ -10,31 +10,9 @@ vi.mock('./pages/UnsupportedBrowserPage', () => ({ default: () =>
Unsupported Browser
, })); -// Mock context providers and wrappers -vi.mock('./Context/PreviewPublisherProvider', () => ({ - __esModule: true, - PreviewPublisherProvider: ({ children }: PropsWithChildren) => children, - default: ({ children }: PropsWithChildren) => children, -})); -vi.mock('./Context/PublisherProvider', () => ({ - __esModule: true, - PublisherProvider: ({ children }: PropsWithChildren) => children, - default: ({ children }: PropsWithChildren) => children, -})); -vi.mock('./Context/SessionProvider/session', () => ({ - default: ({ children }: PropsWithChildren) => children, -})); vi.mock('./components/RedirectToWaitingRoom', () => ({ default: ({ children }: PropsWithChildren) => children, })); -vi.mock('./Context/RoomContext', () => ({ - default: ({ children }: PropsWithChildren) => children, -})); -vi.mock('./Context/ConfigProvider', () => ({ - __esModule: true, - ConfigContextProvider: ({ children }: PropsWithChildren) => children, - default: ({ children }: PropsWithChildren) => children, -})); describe('App routing', () => { it('renders LandingPage on unknown route', () => { diff --git a/frontend/src/Context/AppConfig/AppConfigContext.spec.tsx b/frontend/src/Context/AppConfig/AppConfigContext.spec.tsx new file mode 100644 index 00000000..82cb0355 --- /dev/null +++ b/frontend/src/Context/AppConfig/AppConfigContext.spec.tsx @@ -0,0 +1,133 @@ +import { describe, expect, it, vi } from 'vitest'; +import { act, renderHook as renderHookBase, waitFor } from '@testing-library/react'; +import defaultAppConfig from './helpers/defaultAppConfig'; +import appConfigStore from './AppConfigContext'; +import type { AppConfig } from './AppConfigContext.types'; + +describe('AppConfigContext', () => { + it('returns the default config when no config.json is loaded', async () => { + const { result } = renderHook(() => appConfigStore.use()[0]); + + await waitFor(() => { + expect(result.current).toEqual(expect.objectContaining(defaultAppConfig)); + }); + }); + + it('merges config.json values if loaded (mocked fetch)', async () => { + expect.assertions(2); + + // All values in this config should override the defaultConfig + const mockConfig: AppConfig = { + isAppConfigLoaded: true, + videoSettings: { + allowCameraControl: false, + defaultResolution: '640x480', + allowVideoOnJoin: false, + allowBackgroundEffects: false, + }, + audioSettings: { + allowAdvancedNoiseSuppression: false, + allowAudioOnJoin: false, + allowMicrophoneControl: false, + }, + waitingRoomSettings: { + allowDeviceSelection: false, + }, + meetingRoomSettings: { + allowArchiving: false, + allowCaptions: false, + allowChat: false, + allowDeviceSelection: false, + allowEmojis: false, + allowScreenShare: false, + defaultLayoutMode: 'grid', + showParticipantList: false, + }, + }; + + vi.spyOn(global, 'fetch').mockResolvedValue({ + json: async () => mockConfig, + headers: { + get: () => 'application/json', + }, + } as unknown as Response); + + const { result } = renderHook(() => appConfigStore.use()); + let [appConfig, { loadAppConfig }] = result.current; + + expect(appConfig).not.toEqual(mockConfig); + + await loadAppConfig(); + + [appConfig, { loadAppConfig }] = result.current; + + expect(appConfig).toEqual({ + ...mockConfig, + isAppConfigLoaded: true, + }); + }); + + it('falls back to defaultConfig if fetch fails', async () => { + expect.assertions(4); + + const mockFetchError = new Error('mocking a failure to fetch'); + + vi.spyOn(global, 'fetch').mockRejectedValue(mockFetchError as unknown as Response); + + const { result, rerender } = renderHook(() => appConfigStore.use()); + let [appConfig, { loadAppConfig }] = result.current; + + expect(appConfig.isAppConfigLoaded).toBe(false); + expect(loadAppConfig()).rejects.toThrow('mocking a failure to fetch'); + + await act(() => { + rerender(); + }); + + [appConfig, { loadAppConfig }] = result.current; + + expect(appConfig.isAppConfigLoaded).toBe(true); + + expect(appConfig).toEqual({ + ...defaultAppConfig, + isAppConfigLoaded: true, + }); + }); + + it('falls back to defaultConfig if no config.json is found', async () => { + expect.assertions(4); + + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: { + get: () => 'text/html', + }, + } as unknown as Response); + + const { result, rerender } = renderHook(() => appConfigStore.use()); + let [appConfig, { loadAppConfig }] = result.current; + + expect(appConfig).toEqual(defaultAppConfig); + + expect(loadAppConfig()).rejects.toThrow('No valid JSON found, using default config'); + + await act(() => { + rerender(); + }); + + [appConfig, { loadAppConfig }] = result.current; + + expect(appConfig.isAppConfigLoaded).toBe(true); + + expect(appConfig).toEqual({ + ...defaultAppConfig, + isAppConfigLoaded: true, + }); + }); +}); + +function renderHook(render: (initialProps: Props) => Result) { + return renderHookBase(render, { wrapper: appConfigStore.Provider }); +} diff --git a/frontend/src/Context/AppConfig/AppConfigContext.ts b/frontend/src/Context/AppConfig/AppConfigContext.ts new file mode 100644 index 00000000..a92f51ba --- /dev/null +++ b/frontend/src/Context/AppConfig/AppConfigContext.ts @@ -0,0 +1,23 @@ +import createContext, { type InferContextApi } from 'react-global-state-hooks/createContext'; +import updateAppConfig from './actions/updateAppConfig'; +import loadAppConfig from './actions/loadAppConfig'; +import initialValue from './helpers/defaultAppConfig'; + +/** + * Creates the AppConfig store + * The store includes Context, Provider, and use hook for consuming the context. + */ +const appConfig = createContext(initialValue, { + actions: { + updateAppConfig, + loadAppConfig, + }, +}); + +/** + * The AppConfig context type. + * Represents the shape of the context including state and actions. + */ +export type AppConfigApi = InferContextApi; + +export default appConfig; diff --git a/frontend/src/Context/AppConfig/AppConfigContext.types.ts b/frontend/src/Context/AppConfig/AppConfigContext.types.ts new file mode 100644 index 00000000..1ca9aa5f --- /dev/null +++ b/frontend/src/Context/AppConfig/AppConfigContext.types.ts @@ -0,0 +1,48 @@ +import type { LayoutMode } from '@app-types/session'; + +export type VideoSettings = { + allowBackgroundEffects: boolean; + allowCameraControl: boolean; + allowVideoOnJoin: boolean; + defaultResolution: + | '1920x1080' + | '1280x960' + | '1280x720' + | '640x480' + | '640x360' + | '320x240' + | '320x180'; +}; + +export type AudioSettings = { + allowAdvancedNoiseSuppression: boolean; + allowAudioOnJoin: boolean; + allowMicrophoneControl: boolean; +}; + +export type WaitingRoomSettings = { + allowDeviceSelection: boolean; +}; + +export type MeetingRoomSettings = { + allowArchiving: boolean; + allowCaptions: boolean; + allowChat: boolean; + allowDeviceSelection: boolean; + allowEmojis: boolean; + allowScreenShare: boolean; + defaultLayoutMode: LayoutMode; + showParticipantList: boolean; +}; + +export type AppConfig = { + isAppConfigLoaded: boolean; + + videoSettings: VideoSettings; + + audioSettings: AudioSettings; + + waitingRoomSettings: WaitingRoomSettings; + + meetingRoomSettings: MeetingRoomSettings; +}; diff --git a/frontend/src/Context/AppConfig/actions/loadAppConfig.ts b/frontend/src/Context/AppConfig/actions/loadAppConfig.ts new file mode 100644 index 00000000..eeaa4b01 --- /dev/null +++ b/frontend/src/Context/AppConfig/actions/loadAppConfig.ts @@ -0,0 +1,34 @@ +import type { AppConfig } from '../AppConfigContext.types'; + +export type AppConfigApi = import('../AppConfigContext').AppConfigApi; + +/** + * Loads the application configuration from public/config.json file. + * If the fetch fails or the content is invalid, it falls back to the default configuration. + * Finally, it marks the app config as loaded. + * @returns {Function} The thunk action to load the app config. + */ +function loadAppConfig(this: AppConfigApi['actions']) { + return async (_: AppConfigApi) => { + try { + const response = await fetch('/config.json', { + cache: 'no-cache', + }); + + const contentType = response.headers.get('content-type'); + if (!contentType?.includes('application/json')) { + throw new Error('No valid JSON found, using default config'); + } + + const json: Partial = await response.json(); + + this.updateAppConfig(json); + } finally { + this.updateAppConfig({ + isAppConfigLoaded: true, + }); + } + }; +} + +export default loadAppConfig; diff --git a/frontend/src/Context/AppConfig/actions/updateAppConfig.ts b/frontend/src/Context/AppConfig/actions/updateAppConfig.ts new file mode 100644 index 00000000..70ad3ce8 --- /dev/null +++ b/frontend/src/Context/AppConfig/actions/updateAppConfig.ts @@ -0,0 +1,18 @@ +import type { DeepPartial } from '@app-types/index'; +import type { AppConfig } from '../AppConfigContext.types'; +import mergeAppConfigs from '../helpers/mergeAppConfigs'; + +export type AppConfigApi = import('../AppConfigContext').AppConfigApi; + +/** + * Partially updates the app config state + * @param {DeepPartial} updates - Partial updates to apply to the app config state. + * @returns {Function} A function that updates the app config state. + */ +function updateAppConfig(this: AppConfigApi['actions'], updates: DeepPartial) { + return ({ setState }: AppConfigApi) => { + setState((previous) => mergeAppConfigs({ previous, updates })); + }; +} + +export default updateAppConfig; diff --git a/frontend/src/Context/AppConfig/helpers/defaultAppConfig.ts b/frontend/src/Context/AppConfig/helpers/defaultAppConfig.ts new file mode 100644 index 00000000..0f4a6c1b --- /dev/null +++ b/frontend/src/Context/AppConfig/helpers/defaultAppConfig.ts @@ -0,0 +1,35 @@ +import { AppConfig } from '../AppConfigContext.types'; + +const defaultAppConfig: AppConfig = { + isAppConfigLoaded: false, + + videoSettings: { + allowBackgroundEffects: true, + allowCameraControl: true, + allowVideoOnJoin: true, + defaultResolution: '1280x720', + }, + + audioSettings: { + allowAdvancedNoiseSuppression: true, + allowAudioOnJoin: true, + allowMicrophoneControl: true, + }, + + waitingRoomSettings: { + allowDeviceSelection: true, + }, + + meetingRoomSettings: { + allowArchiving: true, + allowCaptions: true, + allowChat: true, + allowDeviceSelection: true, + allowEmojis: true, + allowScreenShare: true, + defaultLayoutMode: 'active-speaker', + showParticipantList: true, + }, +}; + +export default defaultAppConfig; diff --git a/frontend/src/Context/AppConfig/helpers/mergeAppConfigs.ts b/frontend/src/Context/AppConfig/helpers/mergeAppConfigs.ts new file mode 100644 index 00000000..e59ca1d7 --- /dev/null +++ b/frontend/src/Context/AppConfig/helpers/mergeAppConfigs.ts @@ -0,0 +1,55 @@ +import type { DeepPartial } from '@app-types/index'; +import type { AppConfig } from '../AppConfigContext.types'; +import defaultAppConfig from './defaultAppConfig'; + +/** + * Merge the config with the default app config. + * @param {DeepPartial} updates - Partial updates to apply to the app config. + * @returns {AppConfig} The merged app config. + */ +function mergeAppConfigs(updates: DeepPartial): AppConfig; + +/** + * Merge two app configs. + * @param {{ previous: AppConfig; updates: DeepPartial }} args - Object containing the previous app config and partial updates. + * @param {AppConfig} args.previous - The previous app config. + * @param {DeepPartial} args.updates - Partial updates to apply to the app config. + * @returns {AppConfig} The merged app config. + */ +function mergeAppConfigs(args: { previous: AppConfig; updates: DeepPartial }): AppConfig; + +function mergeAppConfigs( + args: + | { + previous: AppConfig; + updates: DeepPartial; + } + | DeepPartial +): AppConfig { + const shouldUseDefault = !('previous' in args && 'updates' in args); + const previous = shouldUseDefault ? defaultAppConfig : args.previous; + const updates = shouldUseDefault ? args : args.updates; + + return { + ...previous, + ...updates, + videoSettings: { + ...previous.videoSettings, + ...updates.videoSettings, + }, + audioSettings: { + ...previous.audioSettings, + ...updates.audioSettings, + }, + waitingRoomSettings: { + ...previous.waitingRoomSettings, + ...updates.waitingRoomSettings, + }, + meetingRoomSettings: { + ...previous.meetingRoomSettings, + ...updates.meetingRoomSettings, + }, + }; +} + +export default mergeAppConfigs; diff --git a/frontend/src/Context/AppConfig/hooks/useAppConfig.ts b/frontend/src/Context/AppConfig/hooks/useAppConfig.ts new file mode 100644 index 00000000..93c34e9e --- /dev/null +++ b/frontend/src/Context/AppConfig/hooks/useAppConfig.ts @@ -0,0 +1,17 @@ +import appConfig from '../AppConfigContext'; +import { AppConfig } from '../AppConfigContext.types'; + +/** + * Hook to access the AppConfig state with a selector. + * @param {Function} selector - Function to select a part of the AppConfig state. + * @param {unknown[]} [dependencies] - Optional dependencies array to control re-selection. + * @returns {Selection} The selected part of the AppConfig state. + */ +function useAppConfig( + selector: (state: AppConfig) => Selection, + dependencies?: unknown[] +): Selection { + return appConfig.use.select(selector, dependencies); +} + +export default useAppConfig; diff --git a/frontend/src/Context/AppConfig/hooks/useIsCameraControlAllowed.ts b/frontend/src/Context/AppConfig/hooks/useIsCameraControlAllowed.ts new file mode 100644 index 00000000..6829dfbf --- /dev/null +++ b/frontend/src/Context/AppConfig/hooks/useIsCameraControlAllowed.ts @@ -0,0 +1,7 @@ +import appConfig from '../AppConfigContext'; + +const useIsCameraControlAllowed = appConfig.use.createSelectorHook( + ({ isAppConfigLoaded, videoSettings }) => isAppConfigLoaded && videoSettings.allowCameraControl +); + +export default useIsCameraControlAllowed; diff --git a/frontend/src/Context/AppConfig/hooks/useIsMeetingCaptionsAllowed.ts b/frontend/src/Context/AppConfig/hooks/useIsMeetingCaptionsAllowed.ts new file mode 100644 index 00000000..55291ddb --- /dev/null +++ b/frontend/src/Context/AppConfig/hooks/useIsMeetingCaptionsAllowed.ts @@ -0,0 +1,8 @@ +import appConfig from '../AppConfigContext'; + +const useIsMeetingCaptionsAllowed = appConfig.use.createSelectorHook( + ({ isAppConfigLoaded, meetingRoomSettings }) => + isAppConfigLoaded && meetingRoomSettings.allowCaptions +); + +export default useIsMeetingCaptionsAllowed; diff --git a/frontend/src/Context/AppConfig/hooks/useIsMeetingChatAllowed.ts b/frontend/src/Context/AppConfig/hooks/useIsMeetingChatAllowed.ts new file mode 100644 index 00000000..316ac746 --- /dev/null +++ b/frontend/src/Context/AppConfig/hooks/useIsMeetingChatAllowed.ts @@ -0,0 +1,7 @@ +import appConfig from '../AppConfigContext'; + +const useIsMeetingChatAllowed = appConfig.use.createSelectorHook( + ({ isAppConfigLoaded, meetingRoomSettings }) => isAppConfigLoaded && meetingRoomSettings.allowChat +); + +export default useIsMeetingChatAllowed; diff --git a/frontend/src/Context/AppConfig/hooks/useIsMicrophoneControlAllowed.ts b/frontend/src/Context/AppConfig/hooks/useIsMicrophoneControlAllowed.ts new file mode 100644 index 00000000..f68404ed --- /dev/null +++ b/frontend/src/Context/AppConfig/hooks/useIsMicrophoneControlAllowed.ts @@ -0,0 +1,8 @@ +import appConfig from '../AppConfigContext'; + +const useIsMicrophoneControlAllowed = appConfig.use.createSelectorHook( + ({ isAppConfigLoaded, audioSettings }) => + isAppConfigLoaded && audioSettings.allowMicrophoneControl +); + +export default useIsMicrophoneControlAllowed; diff --git a/frontend/src/Context/AppConfig/hooks/useShouldShowParticipantList.ts b/frontend/src/Context/AppConfig/hooks/useShouldShowParticipantList.ts new file mode 100644 index 00000000..b4eed2ca --- /dev/null +++ b/frontend/src/Context/AppConfig/hooks/useShouldShowParticipantList.ts @@ -0,0 +1,8 @@ +import appConfig from '../AppConfigContext'; + +const useShouldShowParticipantList = appConfig.use.createSelectorHook( + ({ isAppConfigLoaded, meetingRoomSettings }) => + isAppConfigLoaded && meetingRoomSettings.showParticipantList +); + +export default useShouldShowParticipantList; diff --git a/frontend/src/Context/AppConfig/index.tsx b/frontend/src/Context/AppConfig/index.tsx new file mode 100644 index 00000000..30689857 --- /dev/null +++ b/frontend/src/Context/AppConfig/index.tsx @@ -0,0 +1,3 @@ +export type { AppConfig } from './AppConfigContext.types'; + +export { default, type AppConfigApi } from './AppConfigContext'; diff --git a/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.spec.tsx b/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.spec.tsx index 4c81490c..94030091 100644 --- a/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.spec.tsx +++ b/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.spec.tsx @@ -1,5 +1,5 @@ -import { act, cleanup, renderHook } from '@testing-library/react'; -import { afterAll, afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { act, renderHook as renderHookBase } from '@testing-library/react'; +import { afterAll, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; import { hasMediaProcessorSupport, initPublisher, Publisher } from '@vonage/client-sdk-video'; import EventEmitter from 'events'; import useUserContext from '@hooks/useUserContext'; @@ -7,6 +7,7 @@ import usePermissions from '@hooks/usePermissions'; import useDevices from '@hooks/useDevices'; import { allMediaDevices, defaultAudioDevice, defaultVideoDevice } from '@utils/mockData/device'; import { DEVICE_ACCESS_STATUS } from '@utils/constants'; +import appConfig from '@Context/AppConfig'; import { UserContextType } from '../../user'; import useBackgroundPublisher from './useBackgroundPublisher'; import usePublisherOptions from '../../PublisherProvider/usePublisherOptions'; @@ -62,10 +63,6 @@ describe('useBackgroundPublisher', () => { }); }); - afterEach(() => { - cleanup(); - }); - describe('initBackgroundLocalPublisher', () => { it('should call initBackgroundLocalPublisher', async () => { mockedInitPublisher.mockReturnValue(mockPublisher); @@ -250,3 +247,7 @@ describe('useBackgroundPublisher', () => { }); }); }); + +function renderHook(render: (initialProps: Props) => Result) { + return renderHookBase(render, { wrapper: appConfig.Provider }); +} diff --git a/frontend/src/Context/ConfigProvider/index.tsx b/frontend/src/Context/ConfigProvider/index.tsx deleted file mode 100644 index ef121cfb..00000000 --- a/frontend/src/Context/ConfigProvider/index.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { createContext, ReactNode, useMemo } from 'react'; -import useConfig, { defaultConfig } from './useConfig'; - -export type ConfigProviderProps = { - children: ReactNode; -}; - -export type ConfigContextType = ReturnType; - -export const ConfigContext = createContext({ - audioSettings: defaultConfig.audioSettings, - meetingRoomSettings: defaultConfig.meetingRoomSettings, - waitingRoomSettings: defaultConfig.waitingRoomSettings, - videoSettings: defaultConfig.videoSettings, -}); - -/** - * ConfigProvider - React Context Provider for ConfigContext - * ConfigContext contains all application configuration including video settings, audio settings, - * waiting room settings, and meeting room settings loaded from config.json. - * We use Context to make the configuration available in many components across the app without - * prop drilling: https://react.dev/learn/passing-data-deeply-with-context#use-cases-for-context - * See useConfig.tsx for configuration structure and loading logic - * @param {ConfigProviderProps} props - The provider properties - * @property {ReactNode} children - The content to be rendered - * @returns {ConfigContext} a context provider for application configuration - */ -export const ConfigProvider = ({ children }: ConfigProviderProps) => { - const config = useConfig(); - const value = useMemo(() => config, [config]); - - return {children}; -}; diff --git a/frontend/src/Context/ConfigProvider/useConfig/index.tsx b/frontend/src/Context/ConfigProvider/useConfig/index.tsx deleted file mode 100644 index f4720b90..00000000 --- a/frontend/src/Context/ConfigProvider/useConfig/index.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import useConfig, { defaultConfig } from './useConfig'; - -export { defaultConfig }; -export default useConfig; diff --git a/frontend/src/Context/ConfigProvider/useConfig/useConfig.spec.tsx b/frontend/src/Context/ConfigProvider/useConfig/useConfig.spec.tsx deleted file mode 100644 index 32057ff5..00000000 --- a/frontend/src/Context/ConfigProvider/useConfig/useConfig.spec.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { renderHook, waitFor } from '@testing-library/react'; -import useConfig, { AppConfig } from './useConfig'; - -describe('useConfig', () => { - const defaultConfig: AppConfig = { - videoSettings: { - allowCameraControl: true, - defaultResolution: '1280x720', - allowVideoOnJoin: true, - allowBackgroundEffects: true, - }, - audioSettings: { - allowAdvancedNoiseSuppression: true, - allowAudioOnJoin: true, - allowMicrophoneControl: true, - }, - waitingRoomSettings: { - allowDeviceSelection: true, - }, - meetingRoomSettings: { - allowArchiving: true, - allowCaptions: true, - allowChat: true, - allowDeviceSelection: true, - allowEmojis: true, - allowScreenShare: true, - defaultLayoutMode: 'active-speaker', - showParticipantList: true, - }, - }; - - beforeEach(() => { - vi.spyOn(console, 'log').mockImplementation(vi.fn()); - vi.spyOn(console, 'info').mockImplementation(vi.fn()); - vi.spyOn(console, 'error').mockImplementation(vi.fn()); - }); - - it('returns the default config when no config.json is loaded', async () => { - const { result } = renderHook(() => useConfig()); - - await waitFor(() => { - expect(result.current).toEqual(defaultConfig); - }); - }); - - it('merges config.json values if loaded (mocked fetch)', async () => { - // All values in this config should override the defaultConfig - const mockConfig: AppConfig = { - videoSettings: { - allowCameraControl: false, - defaultResolution: '640x480', - allowVideoOnJoin: false, - allowBackgroundEffects: false, - }, - audioSettings: { - allowAdvancedNoiseSuppression: false, - allowAudioOnJoin: false, - allowMicrophoneControl: false, - }, - waitingRoomSettings: { - allowDeviceSelection: false, - }, - meetingRoomSettings: { - allowArchiving: false, - allowCaptions: false, - allowChat: false, - allowDeviceSelection: false, - allowEmojis: false, - allowScreenShare: false, - defaultLayoutMode: 'grid', - showParticipantList: false, - }, - }; - - vi.spyOn(global, 'fetch').mockResolvedValue({ - json: async () => mockConfig, - headers: { - get: () => 'application/json', - }, - } as unknown as Response); - - const { result } = renderHook(() => useConfig()); - - await waitFor(() => { - expect(result.current).toMatchObject(mockConfig); - }); - }); - - it('falls back to defaultConfig if fetch fails', async () => { - const mockFetchError = new Error('mocking a failure to fetch'); - - vi.spyOn(global, 'fetch').mockRejectedValue(mockFetchError as unknown as Response); - - const { result } = renderHook(() => useConfig()); - - await waitFor(() => { - expect(result.current).toEqual(defaultConfig); - }); - expect(console.error).toHaveBeenCalledWith('Error loading config:', expect.any(Error)); - }); - - it('falls back to defaultConfig if no config.json is found', async () => { - vi.spyOn(global, 'fetch').mockResolvedValue({ - ok: false, - status: 404, - statusText: 'Not Found', - headers: { - get: () => 'text/html', - }, - } as unknown as Response); - - const { result } = renderHook(() => useConfig()); - - await waitFor(() => { - expect(result.current).toEqual(defaultConfig); - }); - expect(console.info).toHaveBeenCalledWith('No valid JSON found, using default config'); - expect(console.error).not.toHaveBeenCalled(); - }); -}); diff --git a/frontend/src/Context/ConfigProvider/useConfig/useConfig.tsx b/frontend/src/Context/ConfigProvider/useConfig/useConfig.tsx deleted file mode 100644 index 41e42108..00000000 --- a/frontend/src/Context/ConfigProvider/useConfig/useConfig.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { useMemo, useEffect, useState } from 'react'; -import { LayoutMode } from '../../../types/session'; - -export type VideoSettings = { - allowBackgroundEffects: boolean; - allowCameraControl: boolean; - allowVideoOnJoin: boolean; - defaultResolution: - | '1920x1080' - | '1280x960' - | '1280x720' - | '640x480' - | '640x360' - | '320x240' - | '320x180'; -}; - -export type AudioSettings = { - allowAdvancedNoiseSuppression: boolean; - allowAudioOnJoin: boolean; - allowMicrophoneControl: boolean; -}; - -export type WaitingRoomSettings = { - allowDeviceSelection: boolean; -}; - -export type MeetingRoomSettings = { - allowArchiving: boolean; - allowCaptions: boolean; - allowChat: boolean; - allowDeviceSelection: boolean; - allowEmojis: boolean; - allowScreenShare: boolean; - defaultLayoutMode: LayoutMode; - showParticipantList: boolean; -}; - -export type AppConfig = { - videoSettings: VideoSettings; - audioSettings: AudioSettings; - waitingRoomSettings: WaitingRoomSettings; - meetingRoomSettings: MeetingRoomSettings; -}; - -export const defaultConfig: AppConfig = { - videoSettings: { - allowBackgroundEffects: true, - allowCameraControl: true, - allowVideoOnJoin: true, - defaultResolution: '1280x720', - }, - audioSettings: { - allowAdvancedNoiseSuppression: true, - allowAudioOnJoin: true, - allowMicrophoneControl: true, - }, - waitingRoomSettings: { - allowDeviceSelection: true, - }, - meetingRoomSettings: { - allowArchiving: true, - allowCaptions: true, - allowChat: true, - allowDeviceSelection: true, - allowEmojis: true, - allowScreenShare: true, - defaultLayoutMode: 'active-speaker', - showParticipantList: true, - }, -}; - -/** - * Hook wrapper for application configuration. Provides comprehensive application configuration - * including video settings (background effects, camera control, resolution), audio settings - * (noise suppression, microphone control), waiting room settings (device selection), and - * meeting room settings (layout mode, UI button visibility). To configure settings, edit the - * `vonage-video-react-app/public/config.json` file. - * @returns {AppConfig} The application configuration with video, audio, waiting room, and meeting room settings - */ -const useConfig = (): AppConfig => { - const [config, setConfig] = useState(defaultConfig); - - useEffect(() => { - // Try to load config from JSON file located at frontend/public/config.json - const loadConfig = async () => { - try { - const response = await fetch('/config.json'); - - const contentType = response.headers.get('content-type'); - if (!contentType || !contentType.includes('application/json')) { - console.info('No valid JSON found, using default config'); - return; - } - - const json = await response.json(); - setConfig(json); - } catch (error) { - console.error('Error loading config:', error); - } - }; - - loadConfig(); - }, []); - - const mergedConfig: AppConfig = useMemo(() => { - const typedConfigFile = config as Partial; - - return { - ...defaultConfig, - ...typedConfigFile, - videoSettings: { - ...defaultConfig.videoSettings, - ...(typedConfigFile.videoSettings || {}), - }, - audioSettings: { - ...defaultConfig.audioSettings, - ...(typedConfigFile.audioSettings || {}), - }, - waitingRoomSettings: { - ...defaultConfig.waitingRoomSettings, - ...(typedConfigFile.waitingRoomSettings || {}), - }, - meetingRoomSettings: { - ...defaultConfig.meetingRoomSettings, - ...(typedConfigFile.meetingRoomSettings || {}), - }, - }; - }, [config]); - - return mergedConfig; -}; - -export default useConfig; diff --git a/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.spec.tsx b/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.spec.tsx index b6f0e8cd..717c625b 100644 --- a/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.spec.tsx +++ b/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.spec.tsx @@ -1,4 +1,4 @@ -import { act, renderHook } from '@testing-library/react'; +import { act, renderHook as renderHookBase } from '@testing-library/react'; import { afterAll, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; import { hasMediaProcessorSupport, initPublisher, Publisher } from '@vonage/client-sdk-video'; import EventEmitter from 'events'; @@ -7,6 +7,7 @@ import usePermissions from '@hooks/usePermissions'; import useDevices from '@hooks/useDevices'; import { allMediaDevices, defaultAudioDevice, defaultVideoDevice } from '@utils/mockData/device'; import { DEVICE_ACCESS_STATUS } from '@utils/constants'; +import appConfig from '@Context/AppConfig'; import { UserContextType } from '../../user'; import usePreviewPublisher from './usePreviewPublisher'; @@ -237,3 +238,7 @@ describe('usePreviewPublisher', () => { }); }); }); + +function renderHook(render: (initialProps: Props) => Result) { + return renderHookBase(render, { wrapper: appConfig.Provider }); +} diff --git a/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.tsx b/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.tsx index 75d90ae8..a499771b 100644 --- a/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.tsx +++ b/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.tsx @@ -7,6 +7,7 @@ import { hasMediaProcessorSupport, PublisherProperties, } from '@vonage/client-sdk-video'; +import useAppConfig from '@Context/AppConfig/hooks/useAppConfig'; import setMediaDevices from '../../../utils/mediaDeviceUtils'; import useDevices from '../../../hooks/useDevices'; import usePermissions from '../../../hooks/usePermissions'; @@ -17,7 +18,6 @@ import { AccessDeniedEvent } from '../../PublisherProvider/usePublisher/usePubli import DeviceStore from '../../../utils/DeviceStore'; import { setStorageItem, STORAGE_KEYS } from '../../../utils/storage'; import applyBackgroundFilter from '../../../utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter'; -import useConfigContext from '../../../hooks/useConfigContext'; type PublisherVideoElementCreatedEvent = Event<'videoElementCreated', Publisher> & { element: HTMLVideoElement | HTMLObjectElement; @@ -67,7 +67,7 @@ export type PreviewPublisherContextType = { */ const usePreviewPublisher = (): PreviewPublisherContextType => { const { setUser, user } = useUserContext(); - const config = useConfigContext(); + const defaultResolution = useAppConfig(({ videoSettings }) => videoSettings.defaultResolution); const { allMediaDevices, getAllMediaDevices } = useDevices(); const [publisherVideoElement, setPublisherVideoElement] = useState< HTMLVideoElement | HTMLObjectElement @@ -87,7 +87,6 @@ const usePreviewPublisher = (): PreviewPublisherContextType => { const [localVideoSource, setLocalVideoSource] = useState(undefined); const [localAudioSource, setLocalAudioSource] = useState(undefined); const deviceStoreRef = useRef(new DeviceStore()); - const { defaultResolution } = config.videoSettings; /* This sets the default devices in use so that the user knows what devices they are using */ useEffect(() => { diff --git a/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.spec.tsx b/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.spec.tsx index 0603e83e..7e92f746 100644 --- a/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.spec.tsx +++ b/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.spec.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, it, expect, vi } from 'vitest'; -import { act, renderHook, waitFor } from '@testing-library/react'; +import { act, renderHook as renderHookBase, waitFor } from '@testing-library/react'; import { initPublisher, Publisher, @@ -9,6 +9,7 @@ import { import EventEmitter from 'events'; import useUserContext from '@hooks/useUserContext'; import useSessionContext from '@hooks/useSessionContext'; +import appConfig from '@Context/AppConfig'; import usePublisher from './usePublisher'; import { UserContextType } from '../../user'; import { SessionContextType } from '../../SessionProvider/session'; @@ -284,3 +285,7 @@ describe('usePublisher', () => { }); }); }); + +function renderHook(render: (initialProps: Props) => Result) { + return renderHookBase(render, { wrapper: appConfig.Provider }); +} diff --git a/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.spec.tsx b/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.spec.tsx index 0f081a66..5e5c2596 100644 --- a/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.spec.tsx +++ b/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.spec.tsx @@ -1,17 +1,15 @@ import { describe, expect, it, vi, beforeEach, afterAll } from 'vitest'; -import { renderHook, waitFor } from '@testing-library/react'; +import { renderHook as renderHookBase, waitFor } from '@testing-library/react'; import OT from '@vonage/client-sdk-video'; import useUserContext from '@hooks/useUserContext'; import localStorageMock from '@utils/mockData/localStorageMock'; import DeviceStore from '@utils/DeviceStore'; import { setStorageItem, STORAGE_KEYS } from '@utils/storage'; -import useConfigContext from '@hooks/useConfigContext'; +import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers'; import usePublisherOptions from './usePublisherOptions'; import { UserContextType } from '../../user'; -import { ConfigContextType } from '../../ConfigProvider'; vi.mock('@hooks/useUserContext.tsx'); -vi.mock('@hooks/useConfigContext'); const defaultSettings = { publishAudio: false, @@ -52,7 +50,6 @@ const mockUserContextWithCustomSettings = { describe('usePublisherOptions', () => { let enumerateDevicesMock: ReturnType; let deviceStore: DeviceStore; - let configContext: ConfigContextType; beforeEach(async () => { enumerateDevicesMock = vi.fn(); @@ -68,17 +65,6 @@ describe('usePublisherOptions', () => { deviceStore = new DeviceStore(); enumerateDevicesMock.mockResolvedValue([]); await deviceStore.init(); - configContext = { - audioSettings: { - allowAudioOnJoin: true, - }, - videoSettings: { - defaultResolution: '1280x720', - allowVideoOnJoin: true, - }, - } as Partial as ConfigContextType; - - vi.mocked(useConfigContext).mockReturnValue(configContext); vi.mocked(useUserContext).mockImplementation(() => mockUserContextWithDefaultSettings); }); @@ -158,27 +144,62 @@ describe('usePublisherOptions', () => { describe('configurable features', () => { it('should disable audio publishing when allowAudioOnJoin is false', async () => { - configContext.audioSettings.allowAudioOnJoin = false; - const { result } = renderHook(() => usePublisherOptions()); + const { result } = renderHook(() => usePublisherOptions(), { + appConfigOptions: { + value: { + audioSettings: { + allowAudioOnJoin: false, + }, + }, + }, + }); + await waitFor(() => { expect(result.current?.publishAudio).toBe(false); }); }); it('should disable video publishing when allowVideoOnJoin is false', async () => { - configContext.videoSettings.allowVideoOnJoin = false; - const { result } = renderHook(() => usePublisherOptions()); + const { result } = renderHook(() => usePublisherOptions(), { + appConfigOptions: { + value: { + audioSettings: { + allowAudioOnJoin: false, + }, + }, + }, + }); + await waitFor(() => { expect(result.current?.publishVideo).toBe(false); }); }); it('should configure resolution from config', async () => { - configContext.videoSettings.defaultResolution = '640x480'; - const { result } = renderHook(() => usePublisherOptions()); + const { result } = renderHook(() => usePublisherOptions(), { + appConfigOptions: { + value: { + videoSettings: { + defaultResolution: '640x480', + }, + }, + }, + }); + await waitFor(() => { expect(result.current?.resolution).toBe('640x480'); }); }); }); }); + +function renderHook( + render: (initialProps: Props) => Result, + options?: { + appConfigOptions?: AppConfigProviderWrapperOptions; + } +) { + const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions); + + return renderHookBase(render, { ...options, wrapper: AppConfigWrapper }); +} diff --git a/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.tsx b/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.tsx index 2ab22c74..5e6fb964 100644 --- a/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.tsx +++ b/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.tsx @@ -5,10 +5,10 @@ import { AudioFilter, hasMediaProcessorSupport, } from '@vonage/client-sdk-video'; -import useUserContext from '../../../hooks/useUserContext'; -import getInitials from '../../../utils/getInitials'; -import DeviceStore from '../../../utils/DeviceStore'; -import useConfigContext from '../../../hooks/useConfigContext'; +import useAppConfig from '@Context/AppConfig/hooks/useAppConfig'; +import useUserContext from '@hooks/useUserContext'; +import getInitials from '@utils/getInitials'; +import DeviceStore from '@utils/DeviceStore'; /** * React hook to get PublisherProperties combining default options and options set in UserContext @@ -17,11 +17,13 @@ import useConfigContext from '../../../hooks/useConfigContext'; const usePublisherOptions = (): PublisherProperties | null => { const { user } = useUserContext(); - const config = useConfigContext(); + + const defaultResolution = useAppConfig(({ videoSettings }) => videoSettings.defaultResolution); + const allowVideoOnJoin = useAppConfig(({ videoSettings }) => videoSettings.allowVideoOnJoin); + const allowAudioOnJoin = useAppConfig(({ audioSettings }) => audioSettings.allowAudioOnJoin); + const [publisherOptions, setPublisherOptions] = useState(null); const deviceStoreRef = useRef(null); - const { defaultResolution, allowVideoOnJoin } = config.videoSettings; - const { allowAudioOnJoin } = config.audioSettings; useEffect(() => { const setOptions = async () => { diff --git a/frontend/src/Context/RoomContext.tsx b/frontend/src/Context/RoomContext.tsx index 31cce11c..a7850863 100644 --- a/frontend/src/Context/RoomContext.tsx +++ b/frontend/src/Context/RoomContext.tsx @@ -3,15 +3,18 @@ import { ReactElement } from 'react'; import RedirectToUnsupportedBrowserPage from '../components/RedirectToUnsupportedBrowserPage'; import { AudioOutputProvider } from './AudioOutputProvider'; import UserProvider from './user'; -import { ConfigProvider } from './ConfigProvider'; +import appConfig from './AppConfig'; import { BackgroundPublisherProvider } from './BackgroundPublisherProvider'; +import type { AppConfig, AppConfigApi } from './AppConfig'; /** * Wrapper for all of the contexts used by the waiting room and the meeting room. + * @param {object} props - The component props. + * @param {AppConfig} [props.appConfigValue] - Optional AppConfig value to initialize the context with... For testing purposes. * @returns {ReactElement} The context. */ -const RoomContext = (): ReactElement => ( - +const RoomContext = ({ appConfigValue }: { appConfigValue?: AppConfig }): ReactElement => ( + @@ -21,7 +24,21 @@ const RoomContext = (): ReactElement => ( - + ); +/** + * Fetches the app static configuration if it has not been loaded yet. + * @param {AppConfigApi} context - The AppConfig context. + */ +function fetchAppConfiguration(context: AppConfigApi): void { + const { isAppConfigLoaded } = context.getState(); + + if (isAppConfigLoaded) { + return; + } + + context.actions.loadAppConfig(); +} + export default RoomContext; diff --git a/frontend/src/Context/SessionProvider/session.spec.tsx b/frontend/src/Context/SessionProvider/session.spec.tsx index c0754cf3..3dad382e 100644 --- a/frontend/src/Context/SessionProvider/session.spec.tsx +++ b/frontend/src/Context/SessionProvider/session.spec.tsx @@ -1,47 +1,32 @@ -import { useEffect } from 'react'; -import { describe, expect, it, vi, beforeEach, Mock, afterAll, afterEach } from 'vitest'; -import { act, render, waitFor } from '@testing-library/react'; +import { ReactElement, useEffect } from 'react'; +import { describe, expect, it, vi, beforeEach, Mock } from 'vitest'; +import { act, render as renderBase, waitFor } from '@testing-library/react'; import EventEmitter from 'events'; import { Publisher, Stream } from '@vonage/client-sdk-video'; -import useSessionContext from '../../hooks/useSessionContext'; -import SessionProvider from './session'; -import ActiveSpeakerTracker from '../../utils/ActiveSpeakerTracker'; -import useUserContext from '../../hooks/useUserContext'; -import VonageVideoClient from '../../utils/VonageVideoClient'; -import { Credential, StreamPropertyChangedEvent, SubscriberWrapper } from '../../types/session'; -import fetchCredentials from '../../api/fetchCredentials'; - -vi.mock('../../utils/ActiveSpeakerTracker'); -vi.mock('../../hooks/useUserContext'); -vi.mock('../../utils/VonageVideoClient'); +import { makeSessionProviderWrapper } from '@test/providers'; +import useSessionContext from '@hooks/useSessionContext'; +import ActiveSpeakerTracker from '@utils/ActiveSpeakerTracker'; +import VonageVideoClient from '@utils/VonageVideoClient'; +import { Credential, StreamPropertyChangedEvent, SubscriberWrapper } from '@app-types/session'; +import fetchCredentials from '@api/fetchCredentials'; +import { UserType } from '@Context/user'; + +vi.mock('@utils/ActiveSpeakerTracker'); +vi.mock('@utils/VonageVideoClient'); + // Override the constants for max pinning test -vi.mock('../../utils/constants', () => ({ +vi.mock('@utils/constants', () => ({ MAX_PIN_COUNT_MOBILE: 1, MAX_PIN_COUNT_DESKTOP: 1, })); -vi.mock('../../api/fetchCredentials'); -vi.mock('../../hooks/useConfigContext', () => { - return { - default: () => ({ - meetingRoomSettings: { - defaultLayoutMode: 'active-speaker', - }, - }), - }; -}); + +vi.mock('@api/fetchCredentials'); const mockFetchCredentials = fetchCredentials as Mock; describe('SessionProvider', () => { let activeSpeakerTracker: ActiveSpeakerTracker; - let mockUserContext: { - user: { - defaultSettings: { name: string }; - issues: { - reconnections: number; - }; - }; - }; + let vonageVideoClient: VonageVideoClient; let getByTestId: (id: string) => HTMLElement; @@ -145,14 +130,7 @@ describe('SessionProvider', () => { onSubscriberDestroyed: vi.fn(), onSubscriberAudioLevelUpdated: vi.fn(), }) as unknown as ActiveSpeakerTracker; - mockUserContext = { - user: { - defaultSettings: { name: 'TestUser' }, - issues: { - reconnections: 0, - }, - }, - }; + vonageVideoClient = Object.assign(new EventEmitter(), { unpublish: vi.fn(), publish: vi.fn().mockResolvedValue(undefined), @@ -160,7 +138,7 @@ describe('SessionProvider', () => { disconnect: vi.fn(), forceMuteStream: vi.fn(), }) as unknown as VonageVideoClient; - (useUserContext as Mock).mockReturnValue(mockUserContext); + const mockedActiveSpeakerTracker = vi.mocked(ActiveSpeakerTracker); mockedActiveSpeakerTracker.mockImplementation(() => { return activeSpeakerTracker; @@ -176,23 +154,11 @@ describe('SessionProvider', () => { } as Credential); await act(async () => { - const result = render( - - - - ); + const result = render(); getByTestId = result.getByTestId; }); }); - afterEach(() => { - vi.resetAllMocks(); - }); - - afterAll(() => { - vi.restoreAllMocks(); - }); - it('should update activeSpeaker state when activeSpeakerTracker emits event', async () => { act(() => activeSpeakerTracker.emit('activeSpeakerChanged', { @@ -438,3 +404,18 @@ describe('SessionProvider', () => { expect(vonageVideoClient.connect).toHaveBeenCalledTimes(1); }); }); + +function render(ui: ReactElement) { + const { SessionProviderWrapper } = makeSessionProviderWrapper({ + userOptions: { + value: { + defaultSettings: { name: 'TestUser' }, + issues: { + reconnections: 0, + }, + } as UserType, + }, + }); + + return renderBase(ui, { wrapper: SessionProviderWrapper }); +} diff --git a/frontend/src/Context/SessionProvider/session.tsx b/frontend/src/Context/SessionProvider/session.tsx index 5a835013..dc0d094d 100644 --- a/frontend/src/Context/SessionProvider/session.tsx +++ b/frontend/src/Context/SessionProvider/session.tsx @@ -11,11 +11,13 @@ import { ReactElement, } from 'react'; import { Connection, Publisher, Stream } from '@vonage/client-sdk-video'; -import fetchCredentials from '../../api/fetchCredentials'; -import useUserContext from '../../hooks/useUserContext'; -import useConfigContext from '../../hooks/useConfigContext'; -import ActiveSpeakerTracker from '../../utils/ActiveSpeakerTracker'; -import useRightPanel, { RightPanelActiveTab } from '../../hooks/useRightPanel'; +import useRightPanel, { RightPanelActiveTab } from '@hooks/useRightPanel'; +import useUserContext from '@hooks/useUserContext'; +import useChat from '@hooks/useChat'; +import useEmoji, { EmojiWrapper } from '@hooks/useEmoji'; +import appConfigContext from '@Context/AppConfig'; +import fetchCredentials from '@api/fetchCredentials'; +import ActiveSpeakerTracker from '@utils/ActiveSpeakerTracker'; import { Credential, LocalCaptionReceived, @@ -24,19 +26,17 @@ import { SubscriberAudioLevelUpdatedEvent, SubscriberWrapper, LayoutMode, -} from '../../types/session'; -import useChat from '../../hooks/useChat'; -import { ChatMessageType } from '../../types/chat'; -import { isMobile } from '../../utils/util'; +} from '@app-types/session'; +import { ChatMessageType } from '@app-types/chat'; +import { isMobile } from '@utils/util'; import { sortByDisplayPriority, togglePinAndSortByDisplayOrder, -} from '../../utils/sessionStateOperations'; -import { MAX_PIN_COUNT_DESKTOP, MAX_PIN_COUNT_MOBILE } from '../../utils/constants'; -import VonageVideoClient from '../../utils/VonageVideoClient'; -import useEmoji, { EmojiWrapper } from '../../hooks/useEmoji'; +} from '@utils/sessionStateOperations'; +import { MAX_PIN_COUNT_DESKTOP, MAX_PIN_COUNT_MOBILE } from '@utils/constants'; +import VonageVideoClient from '@utils/VonageVideoClient'; -export type { ChatMessageType } from '../../types/chat'; +export type { ChatMessageType } from '@app-types/chat'; export type SessionContextType = { vonageVideoClient: null | VonageVideoClient; @@ -130,16 +130,19 @@ const MAX_PIN_COUNT = isMobile() ? MAX_PIN_COUNT_MOBILE : MAX_PIN_COUNT_DESKTOP; * @returns {SessionContextType} a context provider for a publisher preview */ const SessionProvider = ({ children }: SessionProviderProps): ReactElement => { - const config = useConfigContext(); + const appConfig = appConfigContext.use.api(); + const [lastStreamUpdate, setLastStreamUpdate] = useState(null); const vonageVideoClient = useRef(null); const [reconnecting, setReconnecting] = useState(false); const [subscriberWrappers, setSubscriberWrappers] = useState([]); const [subscriptionError, setSubscriptionError] = useState(null); const [ownCaptions, setOwnCaptions] = useState(null); - const [layoutMode, setLayoutMode] = useState( - config.meetingRoomSettings.defaultLayoutMode - ); + + const [layoutMode, setLayoutMode] = useState(() => { + return appConfig.getState().meetingRoomSettings.defaultLayoutMode; + }); + const [archiveId, setArchiveId] = useState(null); const activeSpeakerTracker = useRef(new ActiveSpeakerTracker()); const [activeSpeakerId, setActiveSpeakerId] = useState(); diff --git a/frontend/src/Context/tests/RoomContext.spec.tsx b/frontend/src/Context/tests/RoomContext.spec.tsx index 361cb9a8..a11c01c4 100644 --- a/frontend/src/Context/tests/RoomContext.spec.tsx +++ b/frontend/src/Context/tests/RoomContext.spec.tsx @@ -5,17 +5,13 @@ import { PropsWithChildren } from 'react'; import useUserContext from '@hooks/useUserContext'; import useAudioOutputContext from '@hooks/useAudioOutputContext'; import { nativeDevices } from '@utils/mockData/device'; +import mergeAppConfigs from '@Context/AppConfig/helpers/mergeAppConfigs'; import RoomContext from '../RoomContext'; import { UserContextType } from '../user'; import { AudioOutputContextType } from '../AudioOutputProvider'; vi.mock('@hooks/useUserContext'); vi.mock('@hooks/useAudioOutputContext'); -vi.mock('../ConfigProvider', () => ({ - __esModule: true, - ConfigProvider: ({ children }: PropsWithChildren) => children, - default: ({ children }: PropsWithChildren) => children, -})); vi.mock('../BackgroundPublisherProvider', () => ({ __esModule: true, BackgroundPublisherProvider: ({ children }: PropsWithChildren) => children, @@ -35,6 +31,13 @@ const mockUseAudioOutputContextValues = { currentAudioOutputDevice: fakeAudioOutput, } as AudioOutputContextType; +const defaultAppConfigValue = mergeAppConfigs({ + /** + * This flag prevents the provider from attempting to load the config.json file + */ + isAppConfigLoaded: true, +}); + describe('RoomContext', () => { const nativeMediaDevices = global.navigator.mediaDevices; beforeEach(() => { @@ -69,7 +72,7 @@ describe('RoomContext', () => { render( - }> + }> } /> @@ -95,7 +98,7 @@ describe('RoomContext', () => { render( - }> + }> } /> diff --git a/frontend/src/Context/user.tsx b/frontend/src/Context/user.tsx index 4a8876fd..d48ec840 100644 --- a/frontend/src/Context/user.tsx +++ b/frontend/src/Context/user.tsx @@ -41,6 +41,7 @@ export const UserContext = createContext(null); export type UserProviderProps = { children: ReactNode; + value?: UserType; }; /** @@ -48,28 +49,30 @@ export type UserProviderProps = { * @param {UserProviderProps} props - The props for the context. * @returns {ReactElement} a context provider for the User. */ -const UserProvider = ({ children }: UserProviderProps): ReactElement => { +const UserProvider = ({ children, value: initialUserState }: UserProviderProps): ReactElement => { // Load initial settings from local storage const noiseSuppression = getStorageItem(STORAGE_KEYS.NOISE_SUPPRESSION) === 'true'; const backgroundFilter = parseVideoFilter(getStorageItem(STORAGE_KEYS.BACKGROUND_REPLACEMENT)); const name = getStorageItem(STORAGE_KEYS.USERNAME) ?? ''; - const [user, setUser] = useState({ - defaultSettings: { - publishAudio: true, - publishVideo: true, - name, - backgroundFilter, - noiseSuppression, - audioSource: undefined, - videoSource: undefined, - publishCaptions: true, - }, - issues: { - reconnections: 0, // Start with zero reconnections - audioFallbacks: 0, // Start with zero audio fallbacks - }, - }); + const [user, setUser] = useState( + initialUserState ?? { + defaultSettings: { + publishAudio: true, + publishVideo: true, + name, + backgroundFilter, + noiseSuppression, + audioSource: undefined, + videoSource: undefined, + publishCaptions: true, + }, + issues: { + reconnections: 0, // Start with zero reconnections + audioFallbacks: 0, // Start with zero audio fallbacks + }, + } + ); const value = useMemo( () => ({ diff --git a/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.spec.tsx b/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.spec.tsx index ac21a4c1..501f566d 100644 --- a/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.spec.tsx +++ b/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.spec.tsx @@ -1,24 +1,13 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach, Mock, afterEach } from 'vitest'; +import { ReactElement } from 'react'; +import { fireEvent, render as renderBase } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; import userEvent from '@testing-library/user-event'; import { Subscriber } from '@vonage/client-sdk-video'; -import { SubscriberWrapper } from '../../types/session'; +import { makeSessionProviderWrapper, type SessionProviderWrapperOptions } from '@test/providers'; +import { SubscriberWrapper } from '@app-types/session'; import HiddenParticipantsTile from './index'; -import useConfigContext from '../../hooks/useConfigContext'; -import { ConfigContextType } from '../../Context/ConfigProvider'; - -const mockToggleParticipantList = vi.fn(); -vi.mock('../../hooks/useSessionContext', () => ({ - __esModule: true, - default: () => ({ - toggleParticipantList: mockToggleParticipantList, - }), -})); -const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>; -vi.mock('../../hooks/useConfigContext'); describe('HiddenParticipantsTile', () => { - let configContext: ConfigContextType; const box = { height: 100, width: 100, top: 0, left: 0 }; const hiddenSubscribers = [ { @@ -41,19 +30,6 @@ describe('HiddenParticipantsTile', () => { }, ]; - beforeEach(() => { - configContext = { - meetingRoomSettings: { - showParticipantList: true, - }, - } as Partial as ConfigContextType; - mockUseConfigContext.mockReturnValue(configContext); - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - it('should display two hidden participants', async () => { const currentHiddenSubscribers = [ ...hiddenSubscribers, @@ -77,50 +53,85 @@ describe('HiddenParticipantsTile', () => { }, ] as SubscriberWrapper[]; - render(); + const { sessionContext, getByTestId, getByText } = render( + + ); + + // we need to capture it before re-render cause the function doesn't persist across renders + const toggleParticipantListSpy = vi.mocked(sessionContext.current.toggleParticipantList); - const button = screen.getByTestId('hidden-participants'); + const button = getByTestId('hidden-participants'); expect(button).toBeInTheDocument(); await userEvent.click(button); fireEvent.mouseEnter(button); fireEvent.mouseLeave(button); - expect(screen.getByText('JD')).toBeInTheDocument(); - expect(screen.getByText('JS')).toBeInTheDocument(); + expect(getByText('JD')).toBeInTheDocument(); + expect(getByText('JS')).toBeInTheDocument(); - expect(mockToggleParticipantList).toHaveBeenCalled(); + expect(toggleParticipantListSpy).toHaveBeenCalled(); }); it('should display one hidden participant because the other one is empty', async () => { const currentHiddenSubscribers = [...hiddenSubscribers, {}] as SubscriberWrapper[]; - render(); + const { sessionContext, getByText, getByTestId, queryByText } = render( + + ); + + // we need to capture it before re-render cause the function doesn't persist across renders + const toggleParticipantListSpy = vi.mocked(sessionContext.current.toggleParticipantList); - const button = screen.getByTestId('hidden-participants'); + const button = getByTestId('hidden-participants'); expect(button).toBeInTheDocument(); await userEvent.click(button); - expect(screen.getByText('JD')).toBeInTheDocument(); - expect(screen.queryByText('JS')).not.toBeInTheDocument(); + expect(getByText('JD')).toBeInTheDocument(); + expect(queryByText('JS')).not.toBeInTheDocument(); - expect(mockToggleParticipantList).toHaveBeenCalled(); + expect(toggleParticipantListSpy).toHaveBeenCalled(); }); it('does not toggle participant list when showParticipantList is disabled', async () => { const currentHiddenSubscribers = [...hiddenSubscribers, {}] as SubscriberWrapper[]; - mockUseConfigContext.mockReturnValue({ - meetingRoomSettings: { - showParticipantList: false, - }, - } as Partial as ConfigContextType); - - render(); - const button = screen.getByTestId('hidden-participants'); + const { sessionContext, getByTestId } = render( + , + { + appConfigOptions: { + value: { + isAppConfigLoaded: false, + meetingRoomSettings: { + showParticipantList: false, + }, + }, + }, + } + ); + + // we need to capture it before re-render cause the function doesn't persist across renders + const toggleParticipantListSpy = vi.mocked(sessionContext.current.toggleParticipantList); + + const button = getByTestId('hidden-participants'); expect(button).toBeInTheDocument(); await userEvent.click(button); - expect(mockToggleParticipantList).not.toHaveBeenCalled(); + expect(toggleParticipantListSpy).not.toHaveBeenCalled(); }); }); + +function render(ui: ReactElement, options?: SessionProviderWrapperOptions) { + const { SessionProviderWrapper, sessionContext, appConfigContext } = makeSessionProviderWrapper({ + __onCreated: (sessionContext) => { + vi.spyOn(sessionContext, 'toggleParticipantList'); + }, + ...options, + }); + + return { + ...renderBase(ui, { ...options, wrapper: SessionProviderWrapper }), + appConfigContext, + sessionContext, + }; +} diff --git a/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.tsx b/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.tsx index 7854bae5..c0864e73 100644 --- a/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.tsx +++ b/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.tsx @@ -1,11 +1,11 @@ import { ReactElement } from 'react'; import { AvatarGroup } from '@mui/material'; import { Box } from 'opentok-layout-js'; -import { SubscriberWrapper } from '../../types/session'; +import { SubscriberWrapper } from '@app-types/session'; +import getBoxStyle from '@utils/helpers/getBoxStyle'; +import useSessionContext from '@hooks/useSessionContext'; +import useShouldShowParticipantList from '@Context/AppConfig/hooks/useShouldShowParticipantList'; import AvatarInitials from '../AvatarInitials'; -import getBoxStyle from '../../utils/helpers/getBoxStyle'; -import useSessionContext from '../../hooks/useSessionContext'; -import useConfigContext from '../../hooks/useConfigContext'; export type HiddenParticipantsTileProps = { box: Box; @@ -26,8 +26,9 @@ const HiddenParticipantsTile = ({ hiddenSubscribers, }: HiddenParticipantsTileProps): ReactElement => { const { toggleParticipantList } = useSessionContext(); - const config = useConfigContext(); - const { showParticipantList } = config.meetingRoomSettings; + + const showParticipantList = useShouldShowParticipantList(); + const { height, width } = box; const diameter = Math.min(height, width) * 0.38; return ( diff --git a/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.spec.tsx b/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.spec.tsx index 56189cfc..070d4bae 100644 --- a/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.spec.tsx +++ b/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.spec.tsx @@ -1,18 +1,20 @@ import { describe, expect, it, vi, beforeEach, Mock } from 'vitest'; -import { render, screen, act, waitFor } from '@testing-library/react'; -import { startArchiving, stopArchiving } from '../../../api/archiving'; +import { render as renderBase, screen, act, waitFor } from '@testing-library/react'; +import { ReactElement } from 'react'; +import { startArchiving, stopArchiving } from '@api/archiving'; +import useRoomName from '@hooks/useRoomName'; +import useSessionContext from '@hooks/useSessionContext'; +import { SessionContextType } from '@Context/SessionProvider/session'; +import { + type AppConfigProviderWrapperOptions, + makeAppConfigProviderWrapper, +} from '@test/providers'; import ArchivingButton from './ArchivingButton'; -import useRoomName from '../../../hooks/useRoomName'; -import useSessionContext from '../../../hooks/useSessionContext'; -import { SessionContextType } from '../../../Context/SessionProvider/session'; -import useConfigContext from '../../../hooks/useConfigContext'; -import { ConfigContextType } from '../../../Context/ConfigProvider'; -vi.mock('../../../hooks/useSessionContext'); -vi.mock('../../../hooks/useRoomName'); -vi.mock('../../../hooks/useConfigContext'); +vi.mock('@hooks/useSessionContext'); +vi.mock('@hooks/useRoomName'); -vi.mock('../../../api/archiving', () => ({ +vi.mock('@api/archiving', () => ({ startArchiving: vi.fn(), stopArchiving: vi.fn(), })); @@ -21,10 +23,8 @@ describe('ArchivingButton', () => { const mockHandleCloseMenu = vi.fn(); const mockedRoomName = 'test-room-name'; let sessionContext: SessionContextType; - let configContext: ConfigContextType; const mockUseSessionContext = useSessionContext as Mock<[], SessionContextType>; - const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>; const testArchiveId = 'test-archive-id'; beforeEach(() => { @@ -34,14 +34,8 @@ describe('ArchivingButton', () => { subscriberWrappers: [], archiveId: null, } as unknown as SessionContextType; - configContext = { - meetingRoomSettings: { - allowArchiving: true, - }, - } as Partial as ConfigContextType; mockUseSessionContext.mockReturnValue(sessionContext as unknown as SessionContextType); - mockUseConfigContext.mockReturnValue(configContext); }); it('renders the button correctly', () => { @@ -99,13 +93,27 @@ describe('ArchivingButton', () => { }); it('is not rendered when allowArchiving is disabled', () => { - mockUseConfigContext.mockReturnValue({ - meetingRoomSettings: { - allowArchiving: false, + render(, { + appConfigOptions: { + value: { + meetingRoomSettings: { + allowArchiving: false, + }, + }, }, - } as Partial as ConfigContextType); + }); - render(); expect(screen.queryByTestId('archiving-button')).not.toBeInTheDocument(); }); }); + +function render( + ui: ReactElement, + options?: { + appConfigOptions?: AppConfigProviderWrapperOptions; + } +) { + const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions); + + return renderBase(ui, { ...options, wrapper: AppConfigWrapper }); +} diff --git a/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.tsx b/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.tsx index ea10cc71..82d4e391 100644 --- a/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.tsx +++ b/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.tsx @@ -2,12 +2,12 @@ import RadioButtonCheckedIcon from '@mui/icons-material/RadioButtonChecked'; import { Tooltip } from '@mui/material'; import { ReactElement, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import useRoomName from '../../../hooks/useRoomName'; +import useRoomName from '@hooks/useRoomName'; +import { startArchiving, stopArchiving } from '@api/archiving'; +import useSessionContext from '@hooks/useSessionContext'; +import useAppConfig from '@Context/AppConfig/hooks/useAppConfig'; import ToolbarButton from '../ToolbarButton'; import PopupDialog, { DialogTexts } from '../PopupDialog'; -import { startArchiving, stopArchiving } from '../../../api/archiving'; -import useSessionContext from '../../../hooks/useSessionContext'; -import useConfigContext from '../../../hooks/useConfigContext'; export type ArchivingButtonProps = { isOverflowButton?: boolean; @@ -32,14 +32,15 @@ const ArchivingButton = ({ const { t } = useTranslation(); const roomName = useRoomName(); const { archiveId } = useSessionContext(); - const config = useConfigContext(); + const allowArchiving = useAppConfig( + ({ meetingRoomSettings }) => meetingRoomSettings.allowArchiving + ); const isRecording = !!archiveId; const [isModalOpen, setIsModalOpen] = useState(false); const title = isRecording ? t('recording.stop.title') : t('recording.start.title'); const handleButtonClick = () => { setIsModalOpen((prev) => !prev); }; - const { allowArchiving } = config.meetingRoomSettings; const startRecordingText: DialogTexts = { title: t('recording.start.dialog.title'), diff --git a/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsButton.spec.tsx b/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsButton.spec.tsx index c33e0aef..d4ce6753 100644 --- a/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsButton.spec.tsx +++ b/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsButton.spec.tsx @@ -1,26 +1,24 @@ import { describe, expect, it, vi, beforeEach, Mock } from 'vitest'; -import { render, screen, act, waitFor } from '@testing-library/react'; +import { render as renderBase, screen, act, waitFor } from '@testing-library/react'; import type { AxiosResponse } from 'axios'; import { Subscriber } from '@vonage/client-sdk-video'; -import { enableCaptions, disableCaptions } from '../../../api/captions'; +import { ReactElement } from 'react'; +import useRoomName from '@hooks/useRoomName'; +import { SessionContextType } from '@Context/SessionProvider/session'; +import useSessionContext from '@hooks/useSessionContext'; +import { SubscriberWrapper } from '@app-types/session'; +import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers'; +import { enableCaptions, disableCaptions } from '@api/captions'; import CaptionsButton, { CaptionsState } from './CaptionsButton'; -import useRoomName from '../../../hooks/useRoomName'; -import { SessionContextType } from '../../../Context/SessionProvider/session'; -import useSessionContext from '../../../hooks/useSessionContext'; -import { SubscriberWrapper } from '../../../types/session'; -import useConfigContext from '../../../hooks/useConfigContext'; -import { ConfigContextType } from '../../../Context/ConfigProvider'; -vi.mock('../../../hooks/useSessionContext'); -vi.mock('../../../hooks/useRoomName'); -vi.mock('../../../api/captions', () => ({ +vi.mock('@hooks/useSessionContext'); +vi.mock('@hooks/useRoomName'); +vi.mock('@api/captions', () => ({ enableCaptions: vi.fn(), disableCaptions: vi.fn(), })); -vi.mock('../../../hooks/useConfigContext'); const mockUseSessionContext = useSessionContext as Mock<[], SessionContextType>; -const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>; describe('CaptionsButton', () => { const mockHandleCloseMenu = vi.fn(); @@ -34,7 +32,6 @@ describe('CaptionsButton', () => { } as CaptionsState; const mockedRoomName = 'test-room-name'; let sessionContext: SessionContextType; - let configContext: ConfigContextType; const createSubscriberWrapper = (id: string): SubscriberWrapper => { const mockSubscriber = { @@ -69,13 +66,7 @@ describe('CaptionsButton', () => { sessionContext = { subscriberWrappers: [createSubscriberWrapper('subscriber-1')], } as unknown as SessionContextType; - configContext = { - meetingRoomSettings: { - allowCaptions: true, - }, - } as Partial as ConfigContextType; mockUseSessionContext.mockReturnValue(sessionContext as unknown as SessionContextType); - mockUseConfigContext.mockReturnValue(configContext); }); it('renders the button correctly', () => { @@ -89,7 +80,16 @@ describe('CaptionsButton', () => { }); it('turns the captions on when button is pressed', async () => { - render(); + render(, { + appConfigOptions: { + value: { + meetingRoomSettings: { + allowCaptions: true, + }, + }, + }, + }); + act(() => screen.getByTestId('captions-button').click()); await waitFor(() => { @@ -98,12 +98,27 @@ describe('CaptionsButton', () => { }); it('is not rendered when allowCaptions is false', () => { - mockUseConfigContext.mockReturnValue({ - meetingRoomSettings: { - allowCaptions: false, + render(, { + appConfigOptions: { + value: { + meetingRoomSettings: { + allowCaptions: false, + }, + }, }, - } as Partial as ConfigContextType); - render(); + }); + expect(screen.queryByTestId('captions-button')).not.toBeInTheDocument(); }); }); + +function render( + ui: ReactElement, + options?: { + appConfigOptions?: AppConfigProviderWrapperOptions; + } +) { + const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions); + + return renderBase(ui, { ...options, wrapper: AppConfigWrapper }); +} diff --git a/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsButton.tsx b/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsButton.tsx index c384526c..a748081f 100644 --- a/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsButton.tsx +++ b/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsButton.tsx @@ -3,10 +3,10 @@ import { Tooltip } from '@mui/material'; import { Dispatch, ReactElement, useState, SetStateAction } from 'react'; import { AxiosError } from 'axios'; import { useTranslation } from 'react-i18next'; -import useRoomName from '../../../hooks/useRoomName'; +import useIsMeetingCaptionsAllowed from '@Context/AppConfig/hooks/useIsMeetingCaptionsAllowed'; +import { disableCaptions, enableCaptions } from '@api/captions'; +import useRoomName from '@hooks/useRoomName'; import ToolbarButton from '../ToolbarButton'; -import { disableCaptions, enableCaptions } from '../../../api/captions'; -import useConfigContext from '../../../hooks/useConfigContext'; export type CaptionsState = { isUserCaptionsEnabled: boolean; @@ -35,14 +35,14 @@ const CaptionsButton = ({ handleClick, captionsState, }: CaptionsButtonProps): ReactElement | false => { - const config = useConfigContext(); + const isMeetingCaptionsAllowed = useIsMeetingCaptionsAllowed(); + const { t } = useTranslation(); const roomName = useRoomName(); const [captionsId, setCaptionsId] = useState(''); const { isUserCaptionsEnabled, setIsUserCaptionsEnabled, setCaptionsErrorResponse } = captionsState; const title = isUserCaptionsEnabled ? t('captions.disable') : t('captions.enable'); - const { allowCaptions } = config.meetingRoomSettings; const handleClose = () => { if (isOverflowButton && handleClick) { @@ -96,7 +96,7 @@ const CaptionsButton = ({ }; return ( - allowCaptions && ( + isMeetingCaptionsAllowed && ( ; const sessionContext = { unreadCount: 10, } as unknown as SessionContextType; -const mockConfigContext = { - meetingRoomSettings: { - allowChat: true, - }, -} as Partial as ConfigContextType; -const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>; describe('ChatButton', () => { beforeEach(() => { mockUseSessionContext.mockReturnValue(sessionContext); - mockUseConfigContext.mockReturnValue(mockConfigContext); }); it('should show unread message number', () => { @@ -63,12 +55,27 @@ describe('ChatButton', () => { }); it('is not rendered when allowChat is false', () => { - mockUseConfigContext.mockReturnValue({ - meetingRoomSettings: { - allowChat: false, + render( {}} isOpen />, { + appConfigOptions: { + value: { + meetingRoomSettings: { + allowChat: false, + }, + }, }, - } as Partial as ConfigContextType); - render( {}} isOpen />); + }); + expect(screen.queryByTestId('ChatIcon')).not.toBeInTheDocument(); }); }); + +function render( + ui: ReactElement, + options?: { + appConfigOptions?: AppConfigProviderWrapperOptions; + } +) { + const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions); + + return renderBase(ui, { ...options, wrapper: AppConfigWrapper }); +} diff --git a/frontend/src/components/MeetingRoom/ChatButton/ChatButton.tsx b/frontend/src/components/MeetingRoom/ChatButton/ChatButton.tsx index 01cf0a7c..4ad0f30d 100644 --- a/frontend/src/components/MeetingRoom/ChatButton/ChatButton.tsx +++ b/frontend/src/components/MeetingRoom/ChatButton/ChatButton.tsx @@ -3,9 +3,9 @@ import Tooltip from '@mui/material/Tooltip'; import { blue } from '@mui/material/colors'; import { ReactElement } from 'react'; import { useTranslation } from 'react-i18next'; +import useIsMeetingChatAllowed from '@Context/AppConfig/hooks/useIsMeetingChatAllowed'; import ToolbarButton from '../ToolbarButton'; import UnreadMessagesBadge from '../UnreadMessagesBadge'; -import useConfigContext from '../../../hooks/useConfigContext'; export type ChatButtonProps = { handleClick: () => void; @@ -29,12 +29,11 @@ const ChatButton = ({ isOpen, isOverflowButton = false, }: ChatButtonProps): ReactElement | false => { - const config = useConfigContext(); - const { allowChat } = config.meetingRoomSettings; + const isMeetingChatAllowed = useIsMeetingChatAllowed(); const { t } = useTranslation(); return ( - allowChat && ( + isMeetingChatAllowed && ( { +vi.mock('@hooks/useBackgroundPublisherContext', () => { return { default: () => ({ toggleVideo: toggleBackgroundVideoPublisherMock, }), }; }); -vi.mock('../../../hooks/useConfigContext'); vi.mock('react-i18next', () => ({ useTranslation: () => ({ @@ -42,13 +41,11 @@ vi.mock('react-i18next', () => ({ const mockUsePublisherContext = usePublisherContext as Mock<[], PublisherContextType>; const mockUseSpeakingDetector = useSpeakingDetector as Mock<[], boolean>; const mockHandleToggleBackgroundEffects = vi.fn(); -const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>; describe('DeviceControlButton', () => { const nativeMediaDevices = global.navigator.mediaDevices; let mockPublisher: Publisher; let publisherContext: PublisherContextType; - let configContext: ConfigContextType; beforeEach(() => { mockPublisher = Object.assign(new EventEmitter(), { @@ -82,16 +79,6 @@ describe('DeviceControlButton', () => { removeEventListener: vi.fn(() => []), }, }); - - configContext = { - audioSettings: { - allowMicrophoneControl: true, - }, - videoSettings: { - allowCameraControl: true, - }, - } as Partial as ConfigContextType; - mockUseConfigContext.mockReturnValue(configContext); }); afterEach(() => { @@ -142,12 +129,23 @@ describe('DeviceControlButton', () => { }); it('renders the button as disabled with greyed out icon and correct tooltip when allowMicrophoneControl is false', async () => { - configContext.audioSettings.allowMicrophoneControl = false; render( + />, + { + appConfigOptions: { + value: { + audioSettings: { + allowMicrophoneControl: false, + }, + videoSettings: { + allowCameraControl: true, + }, + }, + }, + } ); const micButton = screen.getByLabelText('Microphone'); expect(micButton).toBeInTheDocument(); @@ -178,12 +176,23 @@ describe('DeviceControlButton', () => { }); it('renders the button as disabled with greyed out icon and correct tooltip when allowCameraControl is false', async () => { - configContext.videoSettings.allowCameraControl = false; render( + />, + { + appConfigOptions: { + value: { + audioSettings: { + allowMicrophoneControl: true, + }, + videoSettings: { + allowCameraControl: false, + }, + }, + }, + } ); const videoButton = screen.getByLabelText('Camera'); @@ -198,3 +207,14 @@ describe('DeviceControlButton', () => { }); }); }); + +function render( + ui: ReactElement, + options?: { + appConfigOptions?: AppConfigProviderWrapperOptions; + } +) { + const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions); + + return renderBase(ui, { ...options, wrapper: AppConfigWrapper }); +} diff --git a/frontend/src/components/MeetingRoom/DeviceControlButton/DeviceControlButton.tsx b/frontend/src/components/MeetingRoom/DeviceControlButton/DeviceControlButton.tsx index e862edc9..f46bb879 100644 --- a/frontend/src/components/MeetingRoom/DeviceControlButton/DeviceControlButton.tsx +++ b/frontend/src/components/MeetingRoom/DeviceControlButton/DeviceControlButton.tsx @@ -7,12 +7,13 @@ import ButtonGroup from '@mui/material/ButtonGroup'; import { MicOff, ArrowDropUp, ArrowDropDown } from '@mui/icons-material'; import { useState, useRef, useCallback, ReactElement } from 'react'; import { useTranslation } from 'react-i18next'; -import MutedAlert from '../../MutedAlert'; -import usePublisherContext from '../../../hooks/usePublisherContext'; +import useIsMicrophoneControlAllowed from '@Context/AppConfig/hooks/useIsMicrophoneControlAllowed'; +import useIsCameraControlAllowed from '@Context/AppConfig/hooks/useIsCameraControlAllowed'; +import usePublisherContext from '@hooks/usePublisherContext'; +import useBackgroundPublisherContext from '@hooks/useBackgroundPublisherContext'; +import getControlButtonTooltip from '@utils/getControlButtonTooltip'; import DeviceSettingsMenu from '../DeviceSettingsMenu'; -import useBackgroundPublisherContext from '../../../hooks/useBackgroundPublisherContext'; -import useConfigContext from '../../../hooks/useConfigContext'; -import getControlButtonTooltip from '../../../utils/getControlButtonTooltip'; +import MutedAlert from '../../MutedAlert'; import { colors } from '../../../utils/customTheme/customTheme'; export type DeviceControlButtonProps = { @@ -37,20 +38,21 @@ const DeviceControlButton = ({ const { t } = useTranslation(); const { isVideoEnabled, toggleAudio, toggleVideo, isAudioEnabled } = usePublisherContext(); const { toggleVideo: toggleBackgroundVideoPublisher } = useBackgroundPublisherContext(); - const config = useConfigContext(); + + const isMicrophoneControlAllowed = useIsMicrophoneControlAllowed(); + const isCameraControlAllowed = useIsCameraControlAllowed(); + const isAudio = deviceType === 'audio'; const [open, setOpen] = useState(false); const anchorRef = useRef(null); - const { allowMicrophoneControl } = config.audioSettings; - const { allowCameraControl } = config.videoSettings; - const isButtonDisabled = isAudio ? !allowMicrophoneControl : !allowCameraControl; + const isButtonDisabled = isAudio ? !isMicrophoneControlAllowed : !isCameraControlAllowed; const tooltipTitle = getControlButtonTooltip({ isAudio, isAudioEnabled, isVideoEnabled, - allowMicrophoneControl, - allowCameraControl, + allowMicrophoneControl: isMicrophoneControlAllowed, + allowCameraControl: isCameraControlAllowed, t, }); @@ -67,7 +69,7 @@ const DeviceControlButton = ({ const renderControlIcon = () => { if (isAudio) { - if (!allowMicrophoneControl) { + if (!isMicrophoneControlAllowed) { return ; } if (isAudioEnabled) { @@ -76,7 +78,7 @@ const DeviceControlButton = ({ return ; } - if (!allowCameraControl) { + if (!isCameraControlAllowed) { return ; } if (isVideoEnabled) { diff --git a/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.spec.tsx b/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.spec.tsx index 6aee4ad1..0576d041 100644 --- a/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.spec.tsx +++ b/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.spec.tsx @@ -1,19 +1,25 @@ -import { act, fireEvent, queryByText, render, screen, waitFor } from '@testing-library/react'; +import { + act, + fireEvent, + queryByText, + render as renderBase, + screen, + waitFor, +} from '@testing-library/react'; import { describe, beforeEach, it, Mock, vi, expect, afterAll } from 'vitest'; -import { RefObject } from 'react'; +import { ReactElement, RefObject } from 'react'; import { EventEmitter } from 'stream'; import { hasMediaProcessorSupport } from '@vonage/client-sdk-video'; -import * as util from '../../../utils/util'; -import DeviceSettingsMenu from './DeviceSettingsMenu'; -import { AudioOutputProvider } from '../../../Context/AudioOutputProvider'; +import * as util from '@utils/util'; +import { AudioOutputProvider } from '@Context/AudioOutputProvider'; import { audioInputDevices, audioOutputDevices, nativeDevices, videoInputDevices, -} from '../../../utils/mockData/device'; -import useConfigContext from '../../../hooks/useConfigContext'; -import { ConfigContextType } from '../../../Context/ConfigProvider'; +} from '@utils/mockData/device'; +import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers'; +import DeviceSettingsMenu from './DeviceSettingsMenu'; const { mockHasMediaProcessorSupport, @@ -38,17 +44,14 @@ vi.mock('@vonage/client-sdk-video', () => ({ setAudioOutputDevice: mockSetAudioOutputDevice, })); -vi.mock('../../../utils/util', async () => { - const actual = await vi.importActual('../../../utils/util'); +vi.mock('@utils/util', async () => { + const actual = await vi.importActual('@utils/util'); return { ...actual, isGetActiveAudioOutputDeviceSupported: vi.fn(), }; }); -vi.mock('../../../hooks/useConfigContext'); -const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>; - // This is returned by Vonage SDK if audioOutput is not supported const vonageDefaultEmptyOutputDevice = { deviceId: null, label: null }; @@ -63,7 +66,6 @@ describe('DeviceSettingsMenu Component', () => { const mockHandleClose = vi.fn(); let deviceChangeListener: EventEmitter; const mockedHasMediaProcessorSupport = vi.fn(); - let configContext: ConfigContextType; beforeEach(() => { vi.resetAllMocks(); @@ -88,18 +90,6 @@ describe('DeviceSettingsMenu Component', () => { }); (hasMediaProcessorSupport as Mock).mockImplementation(mockedHasMediaProcessorSupport); mockedHasMediaProcessorSupport.mockReturnValue(false); - configContext = { - audioSettings: { - allowAdvancedNoiseSuppression: true, - }, - videoSettings: { - allowBackgroundEffects: true, - }, - meetingRoomSettings: { - allowDeviceSelection: true, - }, - } as Partial as ConfigContextType; - mockUseConfigContext.mockReturnValue(configContext); }); afterAll(() => { @@ -370,16 +360,6 @@ describe('DeviceSettingsMenu Component', () => { }); it('and does not render the dropdown separator and background effects option when allowBackgroundEffects is false', async () => { - configContext = { - videoSettings: { - allowBackgroundEffects: false, - }, - meetingRoomSettings: { - allowDeviceSelection: true, - }, - } as Partial as ConfigContextType; - mockUseConfigContext.mockReturnValue(configContext); - render( { isOpen anchorRef={mockAnchorRef} setIsOpen={mockSetIsOpen} - /> + />, + { + appConfigOptions: { + value: { + videoSettings: { + allowBackgroundEffects: false, + }, + meetingRoomSettings: { + allowDeviceSelection: true, + }, + }, + }, + } ); await waitFor(() => { @@ -399,3 +391,14 @@ describe('DeviceSettingsMenu Component', () => { }); }); }); + +function render( + ui: ReactElement, + options?: { + appConfigOptions?: AppConfigProviderWrapperOptions; + } +) { + const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions); + + return renderBase(ui, { wrapper: AppConfigWrapper }); +} diff --git a/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.tsx b/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.tsx index 9f3d822f..636d654a 100644 --- a/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.tsx +++ b/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.tsx @@ -6,6 +6,7 @@ import { useTheme } from '@mui/material/styles'; import { ReactElement, RefObject, Dispatch, SetStateAction } from 'react'; import { PopperChildrenProps } from '@mui/base'; import { hasMediaProcessorSupport } from '@vonage/client-sdk-video'; +import useAppConfig from '@Context/AppConfig/hooks/useAppConfig'; import InputDevices from '../InputDevices'; import OutputDevices from '../OutputDevices'; import ReduceNoiseTestSpeakers from '../ReduceNoiseTestSpeakers'; @@ -13,7 +14,6 @@ import useDropdownResizeObserver from '../../../hooks/useDropdownResizeObserver' import VideoDevices from '../VideoDevices'; import DropdownSeparator from '../DropdownSeparator'; import VideoDevicesOptions from '../VideoDevicesOptions'; -import useConfigContext from '../../../hooks/useConfigContext'; import { colors } from '../../../utils/customTheme/customTheme'; export type DeviceSettingsMenuProps = { @@ -53,10 +53,12 @@ const DeviceSettingsMenu = ({ handleClose, setIsOpen, }: DeviceSettingsMenuProps): ReactElement | false => { - const config = useConfigContext(); + const allowBackgroundEffects = useAppConfig( + ({ videoSettings }) => videoSettings.allowBackgroundEffects + ); + const isAudio = deviceType === 'audio'; const theme = useTheme(); - const { allowBackgroundEffects } = config.videoSettings; const shouldDisplayBackgroundEffects = hasMediaProcessorSupport() && allowBackgroundEffects; const handleToggleBackgroundEffects = () => { diff --git a/frontend/src/components/MeetingRoom/EmojiGridButton/EmojiGridButton.spec.tsx b/frontend/src/components/MeetingRoom/EmojiGridButton/EmojiGridButton.spec.tsx index f1050bb6..b8c7c79a 100644 --- a/frontend/src/components/MeetingRoom/EmojiGridButton/EmojiGridButton.spec.tsx +++ b/frontend/src/components/MeetingRoom/EmojiGridButton/EmojiGridButton.spec.tsx @@ -1,10 +1,9 @@ -import { act, render, screen } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi, Mock } from 'vitest'; -import { useState } from 'react'; +import { act, render as renderBase, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi, Mock } from 'vitest'; +import { ReactElement, useState } from 'react'; import * as mui from '@mui/material'; +import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers'; import EmojiGridButton from './EmojiGridButton'; -import useConfigContext from '../../../hooks/useConfigContext'; -import { ConfigContextType } from '../../../Context/ConfigProvider'; vi.mock('@mui/material', async () => { const actual = await vi.importActual('@mui/material'); @@ -13,12 +12,9 @@ vi.mock('@mui/material', async () => { useMediaQuery: vi.fn(), }; }); -vi.mock('../../../utils/emojis', () => ({ +vi.mock('@utils/emojis', () => ({ default: { FAVORITE: '🦧' }, })); -vi.mock('../../../hooks/useConfigContext'); - -const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>; const TestComponent = ({ defaultOpenEmojiGrid = false }: { defaultOpenEmojiGrid?: boolean }) => { const [isEmojiGridOpen, setIsEmojiGridOpen] = useState(defaultOpenEmojiGrid); @@ -32,20 +28,8 @@ const TestComponent = ({ defaultOpenEmojiGrid = false }: { defaultOpenEmojiGrid? }; describe('EmojiGridButton', () => { - let mockConfigContext: ConfigContextType; - beforeEach(() => { (mui.useMediaQuery as Mock).mockReturnValue(false); - mockConfigContext = { - meetingRoomSettings: { - allowEmojis: true, - }, - } as Partial as ConfigContextType; - mockUseConfigContext.mockReturnValue(mockConfigContext); - }); - - afterEach(() => { - vi.resetAllMocks(); }); it('renders', () => { @@ -67,8 +51,25 @@ describe('EmojiGridButton', () => { }); it('is not rendered when allowEmojis is false', () => { - mockConfigContext.meetingRoomSettings.allowEmojis = false; - render(); + render(, { + appConfigOptions: { + value: { + meetingRoomSettings: { allowEmojis: false }, + }, + }, + }); + expect(screen.queryByTestId('emoji-grid-button')).not.toBeInTheDocument(); }); }); + +function render( + ui: ReactElement, + options?: { + appConfigOptions?: AppConfigProviderWrapperOptions; + } +) { + const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions); + + return renderBase(ui, { ...options, wrapper: AppConfigWrapper }); +} diff --git a/frontend/src/components/MeetingRoom/EmojiGridButton/EmojiGridButton.tsx b/frontend/src/components/MeetingRoom/EmojiGridButton/EmojiGridButton.tsx index 8680642e..a43e4be4 100644 --- a/frontend/src/components/MeetingRoom/EmojiGridButton/EmojiGridButton.tsx +++ b/frontend/src/components/MeetingRoom/EmojiGridButton/EmojiGridButton.tsx @@ -2,9 +2,9 @@ import { Tooltip } from '@mui/material'; import { EmojiEmotions } from '@mui/icons-material'; import { Dispatch, ReactElement, SetStateAction, useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import useAppConfig from '@Context/AppConfig/hooks/useAppConfig'; import ToolbarButton from '../ToolbarButton'; import EmojiGrid from '../EmojiGrid/EmojiGrid'; -import useConfigContext from '../../../hooks/useConfigContext'; import { colors } from '../../../utils/customTheme/customTheme'; export type EmojiGridProps = { @@ -31,13 +31,13 @@ const EmojiGridButton = ({ isParentOpen, isOverflowButton = false, }: EmojiGridProps): ReactElement | false => { - const { meetingRoomSettings } = useConfigContext(); + const allowEmojis = useAppConfig(({ meetingRoomSettings }) => meetingRoomSettings.allowEmojis); + const { t } = useTranslation(); const anchorRef = useRef(null); const handleToggle = () => { setIsEmojiGridOpen((prevOpen) => !prevOpen); }; - const { allowEmojis } = meetingRoomSettings; return ( allowEmojis && ( diff --git a/frontend/src/components/MeetingRoom/InputDevices/InputDevices.spec.tsx b/frontend/src/components/MeetingRoom/InputDevices/InputDevices.spec.tsx index 905feaa1..ff7cc304 100644 --- a/frontend/src/components/MeetingRoom/InputDevices/InputDevices.spec.tsx +++ b/frontend/src/components/MeetingRoom/InputDevices/InputDevices.spec.tsx @@ -1,21 +1,20 @@ import { describe, it, beforeEach, afterEach, vi, expect, Mock } from 'vitest'; -import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { render as renderBase, screen, fireEvent, cleanup } from '@testing-library/react'; import { Publisher } from '@vonage/client-sdk-video'; import { EventEmitter } from 'stream'; +import { ReactElement } from 'react'; +import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers'; +import useDevices from '@hooks/useDevices'; +import usePublisherContext from '@hooks/usePublisherContext'; +import { AllMediaDevices } from '@app-types/room'; +import { PublisherContextType } from '@Context/PublisherProvider'; +import { allMediaDevices, defaultAudioDevice } from '@utils/mockData/device'; import InputDevices from './InputDevices'; -import useDevices from '../../../hooks/useDevices'; -import usePublisherContext from '../../../hooks/usePublisherContext'; -import useConfigContext from '../../../hooks/useConfigContext'; -import { AllMediaDevices } from '../../../types'; -import { PublisherContextType } from '../../../Context/PublisherProvider'; -import { ConfigContextType } from '../../../Context/ConfigProvider'; -import { allMediaDevices, defaultAudioDevice } from '../../../utils/mockData/device'; // Mocks -vi.mock('../../../hooks/useDevices'); -vi.mock('../../../hooks/usePublisherContext'); -vi.mock('../../../hooks/useConfigContext'); -vi.mock('../../../utils/storage', () => ({ +vi.mock('@hooks/useDevices'); +vi.mock('@hooks/usePublisherContext'); +vi.mock('@utils/storage', () => ({ setStorageItem: vi.fn(), STORAGE_KEYS: { AUDIO_SOURCE: 'audioSource', @@ -27,7 +26,6 @@ const mockUseDevices = useDevices as Mock< { allMediaDevices: AllMediaDevices; getAllMediaDevices: () => void } >; const mockUsePublisherContext = usePublisherContext as Mock<[], PublisherContextType>; -const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>; describe('InputDevices Component', () => { const mockHandleToggle = vi.fn(); @@ -35,7 +33,6 @@ describe('InputDevices Component', () => { const mockGetAudioSource = vi.fn(); let mockPublisher: Publisher; let publisherContext: PublisherContextType; - let mockConfigContext: ConfigContextType; beforeEach(() => { mockGetAudioSource.mockReturnValue(defaultAudioDevice); @@ -58,14 +55,7 @@ describe('InputDevices Component', () => { initializeLocalPublisher: vi.fn(), } as unknown as PublisherContextType; - mockConfigContext = { - meetingRoomSettings: { - allowDeviceSelection: true, - }, - } as Partial as ConfigContextType; - mockUsePublisherContext.mockImplementation(() => publisherContext); - mockUseConfigContext.mockReturnValue(mockConfigContext); }); afterEach(() => { @@ -128,10 +118,15 @@ describe('InputDevices Component', () => { }); it('is not rendered when allowDeviceSelection is false', () => { - mockConfigContext.meetingRoomSettings.allowDeviceSelection = false; - mockUseConfigContext.mockReturnValue(mockConfigContext); - - render(); + render(, { + appConfigOptions: { + value: { + meetingRoomSettings: { + allowDeviceSelection: false, + }, + }, + }, + }); expect(screen.queryByText('Microphone')).not.toBeInTheDocument(); }); @@ -148,3 +143,14 @@ describe('InputDevices Component', () => { ); }); }); + +function render( + ui: ReactElement, + options?: { + appConfigOptions?: AppConfigProviderWrapperOptions; + } +) { + const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions); + + return renderBase(ui, { ...options, wrapper: AppConfigWrapper }); +} diff --git a/frontend/src/components/MeetingRoom/InputDevices/InputDevices.tsx b/frontend/src/components/MeetingRoom/InputDevices/InputDevices.tsx index 8798c6de..924f84bc 100644 --- a/frontend/src/components/MeetingRoom/InputDevices/InputDevices.tsx +++ b/frontend/src/components/MeetingRoom/InputDevices/InputDevices.tsx @@ -4,10 +4,10 @@ import { Device } from '@vonage/client-sdk-video'; import MicNoneIcon from '@mui/icons-material/MicNone'; import { MouseEvent as ReactMouseEvent, ReactElement, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import useAppConfig from '@Context/AppConfig/hooks/useAppConfig'; import useDevices from '../../../hooks/useDevices'; import usePublisherContext from '../../../hooks/usePublisherContext'; import { setStorageItem, STORAGE_KEYS } from '../../../utils/storage'; -import useConfigContext from '../../../hooks/useConfigContext'; import cleanAndDedupeDeviceLabels from '../../../utils/cleanAndDedupeDeviceLabels'; import { colors } from '../../../utils/customTheme/customTheme'; @@ -26,11 +26,14 @@ export type InputDevicesProps = { const InputDevices = ({ handleToggle }: InputDevicesProps): ReactElement | false => { const { t } = useTranslation(); const { publisher } = usePublisherContext(); - const { meetingRoomSettings } = useConfigContext(); + + const allowDeviceSelection = useAppConfig( + ({ meetingRoomSettings }) => meetingRoomSettings.allowDeviceSelection + ); + const { allMediaDevices: { audioInputDevices }, } = useDevices(); - const { allowDeviceSelection } = meetingRoomSettings; const audioInputDevicesCleaned = useMemo( () => cleanAndDedupeDeviceLabels(audioInputDevices), diff --git a/frontend/src/components/MeetingRoom/OutputDevices/OutputDevices.spec.tsx b/frontend/src/components/MeetingRoom/OutputDevices/OutputDevices.spec.tsx index 679c5ddc..fb9f1f90 100644 --- a/frontend/src/components/MeetingRoom/OutputDevices/OutputDevices.spec.tsx +++ b/frontend/src/components/MeetingRoom/OutputDevices/OutputDevices.spec.tsx @@ -1,35 +1,29 @@ import { describe, it, beforeEach, afterEach, vi, expect, Mock } from 'vitest'; -import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { render as renderBase, screen, fireEvent, cleanup } from '@testing-library/react'; +import { ReactElement } from 'react'; +import useDevices from '@hooks/useDevices'; +import useAudioOutputContext from '@hooks/useAudioOutputContext'; +import { AudioOutputContextType } from '@Context/AudioOutputProvider'; +import { allMediaDevices } from '@utils/mockData/device'; +import * as util from '@utils/util'; +import { AllMediaDevices } from '@app-types/room'; +import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers'; import OutputDevices from './OutputDevices'; -import useDevices from '../../../hooks/useDevices'; -import useAudioOutputContext from '../../../hooks/useAudioOutputContext'; -import useConfigContext from '../../../hooks/useConfigContext'; -import { AllMediaDevices } from '../../../types'; -import { AudioOutputContextType } from '../../../Context/AudioOutputProvider'; -import { ConfigContextType } from '../../../Context/ConfigProvider'; -import { allMediaDevices } from '../../../utils/mockData/device'; -import * as util from '../../../utils/util'; // Mocks -vi.mock('../../../hooks/useDevices'); -vi.mock('../../../hooks/useAudioOutputContext'); -vi.mock('../../../hooks/useConfigContext'); -vi.mock('../../../utils/util', () => ({ - isGetActiveAudioOutputDeviceSupported: vi.fn(), -})); +vi.mock('@hooks/useDevices'); +vi.mock('@hooks/useAudioOutputContext'); const mockUseDevices = useDevices as Mock< [], { allMediaDevices: AllMediaDevices; getAllMediaDevices: () => void } >; const mockUseAudioOutputContext = useAudioOutputContext as Mock<[], AudioOutputContextType>; -const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>; describe('OutputDevices Component', () => { const mockHandleToggle = vi.fn(); const mockSetAudioOutputDevice = vi.fn(); let audioOutputContext: AudioOutputContextType; - let mockConfigContext: ConfigContextType; beforeEach(() => { mockUseDevices.mockReturnValue({ @@ -42,15 +36,9 @@ describe('OutputDevices Component', () => { setAudioOutputDevice: mockSetAudioOutputDevice, } as AudioOutputContextType; - mockConfigContext = { - meetingRoomSettings: { - allowDeviceSelection: true, - }, - } as Partial as ConfigContextType; - mockUseAudioOutputContext.mockImplementation(() => audioOutputContext); - mockUseConfigContext.mockReturnValue(mockConfigContext); - (util.isGetActiveAudioOutputDeviceSupported as Mock).mockReturnValue(true); + + vi.spyOn(util, 'isGetActiveAudioOutputDeviceSupported').mockReturnValue(true); }); afterEach(() => { @@ -125,12 +113,28 @@ describe('OutputDevices Component', () => { }); it('is not rendered when allowDeviceSelection is false', () => { - mockConfigContext.meetingRoomSettings.allowDeviceSelection = false; - mockUseConfigContext.mockReturnValue(mockConfigContext); - - render(); + render(, { + appConfigOptions: { + value: { + meetingRoomSettings: { + allowDeviceSelection: false, + }, + }, + }, + }); expect(screen.queryByTestId('output-device-title')).not.toBeInTheDocument(); expect(screen.queryByTestId('output-devices')).not.toBeInTheDocument(); }); }); + +function render( + ui: ReactElement, + options?: { + appConfigOptions?: AppConfigProviderWrapperOptions; + } +) { + const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions); + + return renderBase(ui, { ...options, wrapper: AppConfigWrapper }); +} diff --git a/frontend/src/components/MeetingRoom/OutputDevices/OutputDevices.tsx b/frontend/src/components/MeetingRoom/OutputDevices/OutputDevices.tsx index a7d20435..990436a6 100644 --- a/frontend/src/components/MeetingRoom/OutputDevices/OutputDevices.tsx +++ b/frontend/src/components/MeetingRoom/OutputDevices/OutputDevices.tsx @@ -4,12 +4,12 @@ import VolumeUpIcon from '@mui/icons-material/VolumeUp'; import { MouseEvent, ReactElement, useMemo } from 'react'; import type { AudioOutputDevice } from '@vonage/client-sdk-video'; import { useTranslation } from 'react-i18next'; -import useDevices from '../../../hooks/useDevices'; +import useAppConfig from '@Context/AppConfig/hooks/useAppConfig'; +import useDevices from '@hooks/useDevices'; +import useAudioOutputContext from '@hooks/useAudioOutputContext'; +import { isGetActiveAudioOutputDeviceSupported } from '@utils/util'; +import cleanAndDedupeDeviceLabels from '@utils/cleanAndDedupeDeviceLabels'; import DropdownSeparator from '../DropdownSeparator'; -import useAudioOutputContext from '../../../hooks/useAudioOutputContext'; -import { isGetActiveAudioOutputDeviceSupported } from '../../../utils/util'; -import useConfigContext from '../../../hooks/useConfigContext'; -import cleanAndDedupeDeviceLabels from '../../../utils/cleanAndDedupeDeviceLabels'; import { colors } from '../../../utils/customTheme/customTheme'; export type OutputDevicesProps = { @@ -27,12 +27,15 @@ export type OutputDevicesProps = { const OutputDevices = ({ handleToggle }: OutputDevicesProps): ReactElement | false => { const { t } = useTranslation(); const { currentAudioOutputDevice, setAudioOutputDevice } = useAudioOutputContext(); - const { meetingRoomSettings } = useConfigContext(); + + const allowDeviceSelection = useAppConfig( + ({ meetingRoomSettings }) => meetingRoomSettings.allowDeviceSelection + ); + const { allMediaDevices: { audioOutputDevices }, } = useDevices(); const defaultOutputDevices = [{ deviceId: 'default', label: t('devices.audio.defaultLabel') }]; - const { allowDeviceSelection } = meetingRoomSettings; const isAudioOutputSupported = isGetActiveAudioOutputDeviceSupported(); diff --git a/frontend/src/components/MeetingRoom/ParticipantListButton/ParticipantListButton.tsx b/frontend/src/components/MeetingRoom/ParticipantListButton/ParticipantListButton.tsx index a37b159a..cf92da65 100644 --- a/frontend/src/components/MeetingRoom/ParticipantListButton/ParticipantListButton.tsx +++ b/frontend/src/components/MeetingRoom/ParticipantListButton/ParticipantListButton.tsx @@ -4,8 +4,8 @@ import { blue } from '@mui/material/colors'; import { Badge } from '@mui/material'; import { ReactElement } from 'react'; import { useTranslation } from 'react-i18next'; +import useShouldShowParticipantList from '@Context/AppConfig/hooks/useShouldShowParticipantList'; import ToolbarButton from '../ToolbarButton'; -import useConfigContext from '../../../hooks/useConfigContext'; export type ParticipantListButtonProps = { handleClick: () => void; @@ -31,8 +31,7 @@ const ParticipantListButton = ({ participantCount, isOverflowButton = false, }: ParticipantListButtonProps): ReactElement | false => { - const { meetingRoomSettings } = useConfigContext(); - const { showParticipantList } = meetingRoomSettings; + const showParticipantList = useShouldShowParticipantList(); const { t } = useTranslation(); return ( diff --git a/frontend/src/components/MeetingRoom/ParticipantListButton/ParticipantsListButton.spec.tsx b/frontend/src/components/MeetingRoom/ParticipantListButton/ParticipantsListButton.spec.tsx index 71c0b445..69cce648 100644 --- a/frontend/src/components/MeetingRoom/ParticipantListButton/ParticipantsListButton.spec.tsx +++ b/frontend/src/components/MeetingRoom/ParticipantListButton/ParticipantsListButton.spec.tsx @@ -1,22 +1,10 @@ -import { render, screen } from '@testing-library/react'; -import { describe, expect, it, Mock, vi, beforeEach } from 'vitest'; +import { render as renderBase, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { ReactElement } from 'react'; +import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers'; import ParticipantListButton from './ParticipantListButton'; -import useConfigContext from '../../../hooks/useConfigContext'; -import { ConfigContextType } from '../../../Context/ConfigProvider'; - -vi.mock('../../../hooks/useConfigContext'); - -const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>; describe('ParticipantListButton', () => { - beforeEach(() => { - mockUseConfigContext.mockReturnValue({ - meetingRoomSettings: { - showParticipantList: true, - }, - } as Partial as ConfigContextType); - }); - it('should show participant number', () => { render( {}} isOpen={false} participantCount={10} />); expect(screen.getByText('10')).toBeVisible(); @@ -36,12 +24,27 @@ describe('ParticipantListButton', () => { expect(handleClick).toHaveBeenCalled(); }); it('is not rendered when showParticipantList is false', () => { - mockUseConfigContext.mockReturnValue({ - meetingRoomSettings: { - showParticipantList: false, + render( {}} isOpen={false} participantCount={10} />, { + appConfigOptions: { + value: { + meetingRoomSettings: { + showParticipantList: false, + }, + }, }, - } as Partial as ConfigContextType); - render( {}} isOpen={false} participantCount={10} />); + }); + expect(screen.queryByTestId('participant-list-button')).not.toBeInTheDocument(); }); }); + +function render( + ui: ReactElement, + options?: { + appConfigOptions?: AppConfigProviderWrapperOptions; + } +) { + const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions); + + return renderBase(ui, { ...options, wrapper: AppConfigWrapper }); +} diff --git a/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/ReduceNoiseTestSpeakers.spec.tsx b/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/ReduceNoiseTestSpeakers.spec.tsx index 0fa37a77..b68c4016 100644 --- a/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/ReduceNoiseTestSpeakers.spec.tsx +++ b/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/ReduceNoiseTestSpeakers.spec.tsx @@ -1,19 +1,17 @@ import { describe, expect, it, vi, afterEach, Mock, beforeEach } from 'vitest'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render as renderBase, screen, waitFor } from '@testing-library/react'; import { EventEmitter } from 'stream'; import { Publisher } from '@vonage/client-sdk-video'; -import { defaultAudioDevice } from '../../../utils/mockData/device'; -import usePublisherContext from '../../../hooks/usePublisherContext'; +import { ReactElement } from 'react'; +import { defaultAudioDevice } from '@utils/mockData/device'; +import usePublisherContext from '@hooks/usePublisherContext'; +import { PublisherContextType } from '@Context/PublisherProvider'; +import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers'; import ReduceNoiseTestSpeakers from './ReduceNoiseTestSpeakers'; -import { PublisherContextType } from '../../../Context/PublisherProvider'; -import useConfigContext from '../../../hooks/useConfigContext'; -import { ConfigContextType } from '../../../Context/ConfigProvider'; -vi.mock('../../../hooks/usePublisherContext'); -vi.mock('../../../hooks/useConfigContext'); +vi.mock('@hooks/usePublisherContext'); const mockUsePublisherContext = usePublisherContext as Mock<[], PublisherContextType>; -const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>; const { mockHasMediaProcessorSupport } = vi.hoisted(() => { return { @@ -27,7 +25,6 @@ vi.mock('@vonage/client-sdk-video', () => ({ describe('ReduceNoiseTestSpeakers', () => { let mockPublisher: Publisher; let publisherContext: PublisherContextType; - let configContext: ConfigContextType; beforeEach(() => { mockPublisher = Object.assign(new EventEmitter(), { @@ -48,13 +45,8 @@ describe('ReduceNoiseTestSpeakers', () => { publisherContext.publisher = mockPublisher; }) as unknown as () => void, } as unknown as PublisherContextType; - configContext = { - audioSettings: { - allowAdvancedNoiseSuppression: true, - }, - } as Partial as ConfigContextType; + mockUsePublisherContext.mockImplementation(() => publisherContext); - mockUseConfigContext.mockReturnValue(configContext); }); afterEach(() => { @@ -137,15 +129,27 @@ describe('ReduceNoiseTestSpeakers', () => { }); it('does not render the Advanced Noise Suppression option if allowAdvancedNoiseSuppression is false', () => { - configContext = { - audioSettings: { - allowAdvancedNoiseSuppression: false, + render(, { + appConfigOptions: { + value: { + audioSettings: { + allowAdvancedNoiseSuppression: false, + }, + }, }, - } as Partial as ConfigContextType; - mockUseConfigContext.mockReturnValue(configContext); - - render(); + }); expect(screen.queryByText('Advanced Noise Suppression')).not.toBeInTheDocument(); }); }); + +function render( + ui: ReactElement, + options?: { + appConfigOptions?: AppConfigProviderWrapperOptions; + } +) { + const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions); + + return renderBase(ui, { ...options, wrapper: AppConfigWrapper }); +} diff --git a/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/ReduceNoiseTestSpeakers.tsx b/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/ReduceNoiseTestSpeakers.tsx index 44ee9b1c..6af353dc 100644 --- a/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/ReduceNoiseTestSpeakers.tsx +++ b/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/ReduceNoiseTestSpeakers.tsx @@ -7,11 +7,11 @@ import HeadsetIcon from '@mui/icons-material/Headset'; import ToggleOffIcon from '@mui/icons-material/ToggleOff'; import ToggleOnIcon from '@mui/icons-material/ToggleOn'; import { useTranslation } from 'react-i18next'; -import usePublisherContext from '../../../hooks/usePublisherContext'; +import useAppConfig from '@Context/AppConfig/hooks/useAppConfig'; +import usePublisherContext from '@hooks/usePublisherContext'; +import { setStorageItem, STORAGE_KEYS } from '@utils/storage'; import DropdownSeparator from '../DropdownSeparator'; import SoundTest from '../../SoundTest'; -import { setStorageItem, STORAGE_KEYS } from '../../../utils/storage'; -import useConfigContext from '../../../hooks/useConfigContext'; import { colors } from '../../../utils/customTheme/customTheme'; /** @@ -24,9 +24,12 @@ import { colors } from '../../../utils/customTheme/customTheme'; const ReduceNoiseTestSpeakers = (): ReactElement | false => { const { t } = useTranslation(); const { publisher, isPublishing } = usePublisherContext(); - const config = useConfigContext(); + + const allowAdvancedNoiseSuppression = useAppConfig( + ({ audioSettings }) => audioSettings.allowAdvancedNoiseSuppression + ); + const [isToggled, setIsToggled] = useState(false); - const { allowAdvancedNoiseSuppression } = config.audioSettings; const shouldDisplayANS = hasMediaProcessorSupport() && allowAdvancedNoiseSuppression; const handleToggle = async () => { diff --git a/frontend/src/components/MeetingRoom/Toolbar/Toolbar.spec.tsx b/frontend/src/components/MeetingRoom/Toolbar/Toolbar.spec.tsx index 274f0281..296d6565 100644 --- a/frontend/src/components/MeetingRoom/Toolbar/Toolbar.spec.tsx +++ b/frontend/src/components/MeetingRoom/Toolbar/Toolbar.spec.tsx @@ -1,14 +1,16 @@ import { describe, expect, it, vi, beforeEach, Mock, afterAll } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render as renderBase, screen } from '@testing-library/react'; import { useLocation } from 'react-router-dom'; -import useSpeakingDetector from '../../../hooks/useSpeakingDetector'; -import Toolbar, { ToolbarProps, CaptionsState } from './Toolbar'; -import isReportIssueEnabled from '../../../utils/isReportIssueEnabled'; +import { ReactElement } from 'react'; +import useSpeakingDetector from '@hooks/useSpeakingDetector'; +import isReportIssueEnabled from '@utils/isReportIssueEnabled'; import useToolbarButtons, { UseToolbarButtons, UseToolbarButtonsProps, -} from '../../../hooks/useToolbarButtons'; -import { RIGHT_PANEL_BUTTON_COUNT } from '../../../utils/constants'; +} from '@hooks/useToolbarButtons'; +import { RIGHT_PANEL_BUTTON_COUNT } from '@utils/constants'; +import { makeAppConfigProviderWrapper } from '@test/providers'; +import Toolbar, { ToolbarProps, CaptionsState } from './Toolbar'; const mockedRoomName = { roomName: 'test-room-name' }; @@ -18,9 +20,9 @@ vi.mock('react-router-dom', () => ({ useParams: () => mockedRoomName, })); -vi.mock('../../../hooks/useSpeakingDetector'); -vi.mock('../../../utils/isReportIssueEnabled'); -vi.mock('../../../hooks/useToolbarButtons'); +vi.mock('@hooks/useSpeakingDetector'); +vi.mock('@utils/isReportIssueEnabled'); +vi.mock('@hooks/useToolbarButtons'); const mockUseSpeakingDetector = useSpeakingDetector as Mock<[], boolean>; const mockIsReportIssueEnabled = isReportIssueEnabled as Mock<[], boolean>; @@ -115,3 +117,9 @@ describe('Toolbar', () => { expect(screen.queryByTestId('captions-button')).toBeVisible(); }); }); + +function render(ui: ReactElement) { + const { AppConfigWrapper } = makeAppConfigProviderWrapper(); + + return renderBase(ui, { wrapper: AppConfigWrapper }); +} diff --git a/frontend/src/components/MeetingRoom/Toolbar/Toolbar.tsx b/frontend/src/components/MeetingRoom/Toolbar/Toolbar.tsx index cf844a35..5a8e4bc4 100644 --- a/frontend/src/components/MeetingRoom/Toolbar/Toolbar.tsx +++ b/frontend/src/components/MeetingRoom/Toolbar/Toolbar.tsx @@ -1,21 +1,21 @@ import { Dispatch, ReactElement, SetStateAction, useCallback, useRef, useState } from 'react'; +import useSessionContext from '@hooks/useSessionContext'; +import { RightPanelActiveTab } from '@hooks/useRightPanel'; +import isReportIssueEnabled from '@utils/isReportIssueEnabled'; +import useToolbarButtons from '@hooks/useToolbarButtons'; +import useBackgroundPublisherContext from '@hooks/useBackgroundPublisherContext'; import ScreenSharingButton from '../../ScreenSharingButton'; import TimeRoomNameMeetingRoom from '../TimeRoomName'; import ExitButton from '../ExitButton'; -import useSessionContext from '../../../hooks/useSessionContext'; import LayoutButton from '../LayoutButton'; import ParticipantListButton from '../ParticipantListButton'; import ArchivingButton from '../ArchivingButton'; import CaptionsButton from '../CaptionsButton'; import ChatButton from '../ChatButton'; -import { RightPanelActiveTab } from '../../../hooks/useRightPanel'; import ReportIssueButton from '../ReportIssueButton'; import ToolbarOverflowButton from '../ToolbarOverflowButton'; import EmojiGridButton from '../EmojiGridButton'; -import isReportIssueEnabled from '../../../utils/isReportIssueEnabled'; -import useToolbarButtons from '../../../hooks/useToolbarButtons'; import DeviceControlButton from '../DeviceControlButton'; -import useBackgroundPublisherContext from '../../../hooks/useBackgroundPublisherContext'; export type CaptionsState = { isUserCaptionsEnabled: boolean; diff --git a/frontend/src/components/MeetingRoom/ToolbarOverflowButton/ToolbarOverflowButton.spec.tsx b/frontend/src/components/MeetingRoom/ToolbarOverflowButton/ToolbarOverflowButton.spec.tsx index efe73916..82ecaf25 100644 --- a/frontend/src/components/MeetingRoom/ToolbarOverflowButton/ToolbarOverflowButton.spec.tsx +++ b/frontend/src/components/MeetingRoom/ToolbarOverflowButton/ToolbarOverflowButton.spec.tsx @@ -1,18 +1,20 @@ import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { act, render, screen } from '@testing-library/react'; -import useSessionContext from '../../../hooks/useSessionContext'; -import { SessionContextType } from '../../../Context/SessionProvider/session'; +import { act, render as renderBase, screen } from '@testing-library/react'; +import { ReactElement } from 'react'; +import useSessionContext from '@hooks/useSessionContext'; +import { SessionContextType } from '@Context/SessionProvider/session'; +import useUserContext from '@hooks/useUserContext'; +import { UserContextType } from '@Context/user'; +import { makeAppConfigProviderWrapper } from '@test/providers'; import ToolbarOverflowButton from './ToolbarOverflowButton'; -import useUserContext from '../../../hooks/useUserContext'; -import { UserContextType } from '../../../Context/user'; import { ToolbarOverflowMenuProps, CaptionsState, } from '../ToolbarOverflowMenu/ToolbarOverflowMenu'; -vi.mock('../../../hooks/useSessionContext'); -vi.mock('../../../hooks/useUserContext'); -vi.mock('../../../hooks/useRoomName'); +vi.mock('@hooks/useSessionContext'); +vi.mock('@hooks/useUserContext'); +vi.mock('@hooks/useRoomName'); const mockUseSessionContext = useSessionContext as Mock<[], SessionContextType>; const mockUseUserContext = useUserContext as Mock<[], UserContextType>; const mockSetUser = vi.fn(); @@ -81,3 +83,9 @@ describe('ToolbarOverflowButton', () => { expect(screen.queryAllByTestId('chat-button-unread-count').length).toBe(2); }); }); + +function render(ui: ReactElement) { + const { AppConfigWrapper } = makeAppConfigProviderWrapper(); + + return renderBase(ui, { wrapper: AppConfigWrapper }); +} diff --git a/frontend/src/components/MeetingRoom/ToolbarOverflowMenu/ToolbarOverflowMenu.spec.tsx b/frontend/src/components/MeetingRoom/ToolbarOverflowMenu/ToolbarOverflowMenu.spec.tsx index 4b339162..c9a225c0 100644 --- a/frontend/src/components/MeetingRoom/ToolbarOverflowMenu/ToolbarOverflowMenu.spec.tsx +++ b/frontend/src/components/MeetingRoom/ToolbarOverflowMenu/ToolbarOverflowMenu.spec.tsx @@ -1,19 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import { useRef } from 'react'; +import { render as renderBase, screen } from '@testing-library/react'; +import { ReactElement, useRef } from 'react'; import { Button } from '@mui/material'; +import * as util from '@utils/util'; +import isReportIssueEnabled from '@utils/isReportIssueEnabled'; +import { makeAppConfigProviderWrapper } from '@test/providers'; import ToolbarOverflowMenu, { CaptionsState } from './ToolbarOverflowMenu'; -import * as util from '../../../utils/util'; -import isReportIssueEnabled from '../../../utils/isReportIssueEnabled'; -vi.mock('../../../hooks/useSessionContext', () => ({ +vi.mock('@hooks/useSessionContext', () => ({ default: () => ({ subscriberWrappers: [], }), })); -vi.mock('../../../hooks/useRoomName'); -vi.mock('../../../utils/util', () => ({ isMobile: vi.fn() })); -vi.mock('../../../utils/isReportIssueEnabled'); +vi.mock('@hooks/useRoomName'); +vi.mock('@utils/util', () => ({ isMobile: vi.fn() })); +vi.mock('@utils/isReportIssueEnabled'); const mockOpenEmojiGrid = vi.fn(); const mockHandleClickAway = vi.fn(); @@ -109,3 +110,9 @@ describe('ToolbarOverflowMenu', () => { }); }); }); + +function render(ui: ReactElement) { + const { AppConfigWrapper } = makeAppConfigProviderWrapper(); + + return renderBase(ui, { wrapper: AppConfigWrapper }); +} diff --git a/frontend/src/components/MeetingRoom/UnreadMessagesBadge/UnreadMessagesBadge.spec.tsx b/frontend/src/components/MeetingRoom/UnreadMessagesBadge/UnreadMessagesBadge.spec.tsx index 1aeac66b..6eef5b40 100644 --- a/frontend/src/components/MeetingRoom/UnreadMessagesBadge/UnreadMessagesBadge.spec.tsx +++ b/frontend/src/components/MeetingRoom/UnreadMessagesBadge/UnreadMessagesBadge.spec.tsx @@ -1,31 +1,24 @@ import { describe, it, vi, expect, Mock, beforeEach } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render as renderBase, screen } from '@testing-library/react'; import BiotechIcon from '@mui/icons-material/Biotech'; -import useSessionContext from '../../../hooks/useSessionContext'; -import { SessionContextType } from '../../../Context/SessionProvider/session'; +import { ReactElement } from 'react'; +import useSessionContext from '@hooks/useSessionContext'; +import { SessionContextType } from '@Context/SessionProvider/session'; +import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers'; import UnreadMessagesBadge from './UnreadMessagesBadge'; import ToolbarButton from '../ToolbarButton'; -import useConfigContext from '../../../hooks/useConfigContext'; -import { ConfigContextType } from '../../../Context/ConfigProvider'; -vi.mock('../../../hooks/useSessionContext'); -vi.mock('../../../hooks/useConfigContext'); +vi.mock('@hooks/useSessionContext'); + const mockUseSessionContext = useSessionContext as Mock<[], SessionContextType>; const sessionContext = { unreadCount: 0, } as unknown as SessionContextType; const LittleButton = () => {}} icon={} />; -const mockConfigContext = { - meetingRoomSettings: { - allowChat: true, - }, -} as Partial as ConfigContextType; -const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>; describe('UnreadMessagesBadge', () => { beforeEach(() => { mockUseSessionContext.mockReturnValue(sessionContext); - mockUseConfigContext.mockReturnValue(mockConfigContext); }); it('shows badge with correct unread message count', () => { @@ -177,16 +170,20 @@ describe('UnreadMessagesBadge', () => { unreadCount: 8, } as unknown as SessionContextType; mockUseSessionContext.mockReturnValue(sessionContextWithMessages); - mockUseConfigContext.mockReturnValue({ - meetingRoomSettings: { - allowChat: false, - }, - } as Partial as ConfigContextType); render( - + , + { + appConfigOptions: { + value: { + meetingRoomSettings: { + allowChat: false, + }, + }, + }, + } ); const badge = screen.getByTestId('chat-button-unread-count'); @@ -195,3 +192,14 @@ describe('UnreadMessagesBadge', () => { expect(badge.offsetWidth).toBe(0); }); }); + +function render( + ui: ReactElement, + options?: { + appConfigOptions?: AppConfigProviderWrapperOptions; + } +) { + const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions); + + return renderBase(ui, { ...options, wrapper: AppConfigWrapper }); +} diff --git a/frontend/src/components/MeetingRoom/UnreadMessagesBadge/UnreadMessagesBadge.tsx b/frontend/src/components/MeetingRoom/UnreadMessagesBadge/UnreadMessagesBadge.tsx index bbb23f80..37f264c8 100644 --- a/frontend/src/components/MeetingRoom/UnreadMessagesBadge/UnreadMessagesBadge.tsx +++ b/frontend/src/components/MeetingRoom/UnreadMessagesBadge/UnreadMessagesBadge.tsx @@ -1,7 +1,7 @@ import { Badge } from '@mui/material'; import { ForwardedRef, forwardRef, ReactElement } from 'react'; -import useSessionContext from '../../../hooks/useSessionContext'; -import useConfigContext from '../../../hooks/useConfigContext'; +import useSessionContext from '@hooks/useSessionContext'; +import useIsMeetingChatAllowed from '@Context/AppConfig/hooks/useIsMeetingChatAllowed'; export type UnreadMessagesBadgeProps = { children: ReactElement; @@ -21,12 +21,12 @@ const UnreadMessagesBadge = forwardRef(function UnreadMessagesBadge( props: UnreadMessagesBadgeProps, ref: ForwardedRef ) { - const { meetingRoomSettings } = useConfigContext(); + const isMeetingChatAllowed = useIsMeetingChatAllowed(); + const { children, isToolbarOverflowMenuOpen, ...rest } = props; const { unreadCount } = useSessionContext(); - const { allowChat } = meetingRoomSettings; // If the chat button is not shown, the unread messages badge should also be hidden - const isInvisible = unreadCount === 0 || isToolbarOverflowMenuOpen || !allowChat; + const isInvisible = unreadCount === 0 || isToolbarOverflowMenuOpen || !isMeetingChatAllowed; return ( ({ +vi.mock('@hooks/useDevices'); +vi.mock('@hooks/usePublisherContext'); +vi.mock('@utils/storage', () => ({ setStorageItem: vi.fn(), STORAGE_KEYS: { VIDEO_SOURCE: 'videoSource', }, })); -vi.mock('../../../hooks/useConfigContext'); const mockUseDevices = useDevices as Mock< [], { allMediaDevices: AllMediaDevices; getAllMediaDevices: () => void } >; const mockUsePublisherContext = usePublisherContext as Mock<[], PublisherContextType>; -const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>; describe('VideoDevices Component', () => { const mockHandleToggle = vi.fn(); @@ -38,7 +36,6 @@ describe('VideoDevices Component', () => { })); let mockPublisher: Publisher; let publisherContext: PublisherContextType; - let mockConfigContext: ConfigContextType; beforeEach(() => { mockUseDevices.mockReturnValue({ @@ -63,18 +60,12 @@ describe('VideoDevices Component', () => { publisherContext.publisher = mockPublisher; }) as unknown as () => void, } as unknown as PublisherContextType; - mockConfigContext = { - meetingRoomSettings: { - allowDeviceSelection: true, - }, - } as Partial as ConfigContextType; mockUsePublisherContext.mockImplementation(() => publisherContext); mockGetVideoSource.mockReturnValue({ deviceId: 'a68ec4e4a6bc10dc572bd806414b0da27d0aefb0ad822f7ba4cf9b226bb9b7c2', label: 'FaceTime HD Camera (2C0E:82E3)', }); - mockUseConfigContext.mockReturnValue(mockConfigContext); }); afterEach(() => { @@ -110,11 +101,27 @@ describe('VideoDevices Component', () => { }); it('is not rendered when allowDeviceSelection is false', () => { - mockConfigContext.meetingRoomSettings.allowDeviceSelection = false; - mockUseConfigContext.mockReturnValue(mockConfigContext); - - const { container } = render(); + const { container } = render(, { + appConfigOptions: { + value: { + meetingRoomSettings: { + allowDeviceSelection: false, + }, + }, + }, + }); expect(container.firstChild).toBeNull(); }); }); + +function render( + ui: ReactElement, + options?: { + appConfigOptions?: AppConfigProviderWrapperOptions; + } +) { + const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions); + + return renderBase(ui, { ...options, wrapper: AppConfigWrapper }); +} diff --git a/frontend/src/components/MeetingRoom/VideoDevices/VideoDevices.tsx b/frontend/src/components/MeetingRoom/VideoDevices/VideoDevices.tsx index 283f7a17..6651103e 100644 --- a/frontend/src/components/MeetingRoom/VideoDevices/VideoDevices.tsx +++ b/frontend/src/components/MeetingRoom/VideoDevices/VideoDevices.tsx @@ -4,10 +4,10 @@ import CheckIcon from '@mui/icons-material/Check'; import VideocamIcon from '@mui/icons-material/Videocam'; import { Device } from '@vonage/client-sdk-video'; import { useTranslation } from 'react-i18next'; +import useAppConfig from '@Context/AppConfig/hooks/useAppConfig'; import useDevices from '../../../hooks/useDevices'; import usePublisherContext from '../../../hooks/usePublisherContext'; import { setStorageItem, STORAGE_KEYS } from '../../../utils/storage'; -import useConfigContext from '../../../hooks/useConfigContext'; import cleanAndDedupeDeviceLabels from '../../../utils/cleanAndDedupeDeviceLabels'; import { colors } from '../../../utils/customTheme/customTheme'; @@ -26,11 +26,14 @@ export type VideoDevicesProps = { const VideoDevices = ({ handleToggle }: VideoDevicesProps): ReactElement | false => { const { t } = useTranslation(); const { isPublishing, publisher } = usePublisherContext(); - const { meetingRoomSettings } = useConfigContext(); + + const allowDeviceSelection = useAppConfig( + ({ meetingRoomSettings }) => meetingRoomSettings.allowDeviceSelection + ); + const { allMediaDevices } = useDevices(); const [devicesAvailable, setDevicesAvailable] = useState([]); const [options, setOptions] = useState<{ deviceId: string; label: string }[]>([]); - const { allowDeviceSelection } = meetingRoomSettings; const changeVideoSource = (videoDeviceId: string) => { publisher?.setVideoSource(videoDeviceId); diff --git a/frontend/src/components/ScreenSharingButton/ScreenSharingButton.spec.tsx b/frontend/src/components/ScreenSharingButton/ScreenSharingButton.spec.tsx index c036644a..a37f3dc9 100644 --- a/frontend/src/components/ScreenSharingButton/ScreenSharingButton.spec.tsx +++ b/frontend/src/components/ScreenSharingButton/ScreenSharingButton.spec.tsx @@ -1,23 +1,10 @@ -import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render as renderBase, screen } from '@testing-library/react'; +import { ReactElement } from 'react'; +import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers'; import ScreenSharingButton, { ScreenShareButtonProps } from './ScreenSharingButton'; -import useConfigContext from '../../hooks/useConfigContext'; -import { ConfigContextType } from '../../Context/ConfigProvider'; - -vi.mock('../../hooks/useConfigContext'); - -const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>; -const mockConfigContext = { - meetingRoomSettings: { - allowScreenShare: true, - }, -} as Partial as ConfigContextType; describe('ScreenSharingButton', () => { - beforeEach(() => { - mockUseConfigContext.mockReturnValue(mockConfigContext); - }); - const mockToggleScreenShare = vi.fn(); const defaultProps: ScreenShareButtonProps = { @@ -58,8 +45,27 @@ describe('ScreenSharingButton', () => { }); it('is not rendered when allowScreenShare is false', () => { - mockConfigContext.meetingRoomSettings.allowScreenShare = false; - render(); + render(, { + appConfigOptions: { + value: { + meetingRoomSettings: { + allowScreenShare: false, + }, + }, + }, + }); + expect(screen.queryByTestId('ScreenShareIcon')).not.toBeInTheDocument(); }); }); + +function render( + ui: ReactElement, + options?: { + appConfigOptions?: AppConfigProviderWrapperOptions; + } +) { + const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions); + + return renderBase(ui, { ...options, wrapper: AppConfigWrapper }); +} diff --git a/frontend/src/components/ScreenSharingButton/ScreenSharingButton.tsx b/frontend/src/components/ScreenSharingButton/ScreenSharingButton.tsx index 6dbd1f0b..d195c1b3 100644 --- a/frontend/src/components/ScreenSharingButton/ScreenSharingButton.tsx +++ b/frontend/src/components/ScreenSharingButton/ScreenSharingButton.tsx @@ -3,10 +3,10 @@ import ScreenShare from '@mui/icons-material/ScreenShare'; import Tooltip from '@mui/material/Tooltip'; import { ReactElement, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import useAppConfig from '@Context/AppConfig/hooks/useAppConfig'; +import { isMobile } from '@utils/util'; import ToolbarButton from '../MeetingRoom/ToolbarButton'; import PopupDialog, { DialogTexts } from '../MeetingRoom/PopupDialog'; -import { isMobile } from '../../utils/util'; -import useConfigContext from '../../hooks/useConfigContext'; export type ScreenShareButtonProps = { toggleScreenShare: () => void; @@ -32,11 +32,12 @@ const ScreenSharingButton = ({ isViewingScreenShare, isOverflowButton = false, }: ScreenShareButtonProps): ReactElement | false => { - const { meetingRoomSettings } = useConfigContext(); + const allowScreenShare = useAppConfig( + ({ meetingRoomSettings }) => meetingRoomSettings.allowScreenShare + ); const { t } = useTranslation(); const title = isSharingScreen ? t('screenSharing.title.stop') : t('screenSharing.title.start'); const [isModalOpen, setIsModalOpen] = useState(false); - const { allowScreenShare } = meetingRoomSettings; // Screensharing relies on the getDisplayMedia browser API which is unsupported on mobile devices // See: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getDisplayMedia#browser_compatibility diff --git a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/BackgroundEffectsButton.spec.tsx b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/BackgroundEffectsButton.spec.tsx index 3daca2dd..8c967a27 100644 --- a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/BackgroundEffectsButton.spec.tsx +++ b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/BackgroundEffectsButton.spec.tsx @@ -1,9 +1,9 @@ -import { render, screen } from '@testing-library/react'; +import { render as renderBase, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { ReactElement } from 'react'; +import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers'; import BackgroundEffectsButton from './BackgroundEffectsButton'; -import useConfigContext from '../../../../hooks/useConfigContext'; -import { ConfigContextType } from '../../../../Context/ConfigProvider'; const { mockHasMediaProcessorSupport } = vi.hoisted(() => { return { @@ -14,22 +14,8 @@ vi.mock('@vonage/client-sdk-video', () => ({ hasMediaProcessorSupport: mockHasMediaProcessorSupport, })); -vi.mock('../../../../hooks/useConfigContext'); -const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>; - describe('BackgroundEffectsButton', () => { const mockOnClick = vi.fn(); - let mockConfigContext: ConfigContextType; - - beforeEach(() => { - vi.clearAllMocks(); - mockConfigContext = { - videoSettings: { - allowBackgroundEffects: true, - }, - } as Partial as ConfigContextType; - mockUseConfigContext.mockReturnValue(mockConfigContext); - }); it('renders the button if media processor is supported', () => { mockHasMediaProcessorSupport.mockReturnValue(true); @@ -52,8 +38,26 @@ describe('BackgroundEffectsButton', () => { }); it('is not rendered when background effects are not allowed', () => { - mockConfigContext.videoSettings.allowBackgroundEffects = false; - render(); + render(, { + appConfigOptions: { + value: { + videoSettings: { + allowBackgroundEffects: false, + }, + }, + }, + }); expect(screen.queryByLabelText(/background effects/i)).not.toBeInTheDocument(); }); }); + +function render( + ui: ReactElement, + options?: { + appConfigOptions?: AppConfigProviderWrapperOptions; + } +) { + const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions); + + return renderBase(ui, { ...options, wrapper: AppConfigWrapper }); +} diff --git a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/BackgroundEffectsButton.tsx b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/BackgroundEffectsButton.tsx index 821542b9..e4d4b1e2 100644 --- a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/BackgroundEffectsButton.tsx +++ b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/BackgroundEffectsButton.tsx @@ -3,8 +3,8 @@ import { hasMediaProcessorSupport } from '@vonage/client-sdk-video'; import { ReactElement } from 'react'; import PortraitIcon from '@mui/icons-material/Portrait'; import { useTranslation } from 'react-i18next'; +import useAppConfig from '@Context/AppConfig/hooks/useAppConfig'; import VideoContainerButton from '../../VideoContainerButton'; -import useConfigContext from '../../../../hooks/useConfigContext'; export type BackgroundEffectsButtonProps = { onClick: () => void; @@ -21,8 +21,10 @@ export type BackgroundEffectsButtonProps = { const BackgroundEffectsButton = ({ onClick, }: BackgroundEffectsButtonProps): ReactElement | false => { - const config = useConfigContext(); - const { allowBackgroundEffects } = config.videoSettings; + const allowBackgroundEffects = useAppConfig( + ({ videoSettings }) => videoSettings.allowBackgroundEffects + ); + const shouldDisplayBackgroundEffects = hasMediaProcessorSupport() && allowBackgroundEffects; const { t } = useTranslation(); diff --git a/frontend/src/components/WaitingRoom/CameraButton/CameraButton.spec.tsx b/frontend/src/components/WaitingRoom/CameraButton/CameraButton.spec.tsx index d75dc9b4..fe45d8d4 100644 --- a/frontend/src/components/WaitingRoom/CameraButton/CameraButton.spec.tsx +++ b/frontend/src/components/WaitingRoom/CameraButton/CameraButton.spec.tsx @@ -1,8 +1,8 @@ -import { render, screen, fireEvent } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; +import { render as renderBase, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ReactElement } from 'react'; +import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers'; import CameraButton from './CameraButton'; -import useConfigContext from '../../../hooks/useConfigContext'; -import { ConfigContextType } from '../../../Context/ConfigProvider'; let isVideoEnabled = true; const toggleVideoMock = vi.fn(); @@ -26,21 +26,10 @@ vi.mock('../../../hooks/useBackgroundPublisherContext', () => { }; }); -vi.mock('../../../hooks/useConfigContext'); -const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>; - describe('CameraButton', () => { - let mockConfigContext: ConfigContextType; - beforeEach(() => { vi.clearAllMocks(); isVideoEnabled = true; - mockConfigContext = { - videoSettings: { - allowCameraControl: true, - }, - } as Partial as ConfigContextType; - mockUseConfigContext.mockReturnValue(mockConfigContext); }); it('renders the video on icon when video is enabled', () => { @@ -62,8 +51,28 @@ describe('CameraButton', () => { }); it('is not rendered when allowCameraControl is false', () => { - mockConfigContext.videoSettings.allowCameraControl = false; - render(); + render(, { + appConfigOptions: { + value: { + isAppConfigLoaded: true, + videoSettings: { + allowCameraControl: false, + }, + }, + }, + }); + expect(screen.queryByTestId('VideocamIcon')).not.toBeInTheDocument(); }); }); + +function render( + ui: ReactElement, + options?: { + appConfigOptions?: AppConfigProviderWrapperOptions; + } +) { + const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions); + + return renderBase(ui, { ...options, wrapper: AppConfigWrapper }); +} diff --git a/frontend/src/components/WaitingRoom/CameraButton/CameraButton.tsx b/frontend/src/components/WaitingRoom/CameraButton/CameraButton.tsx index fe0f41ee..4e6c758c 100644 --- a/frontend/src/components/WaitingRoom/CameraButton/CameraButton.tsx +++ b/frontend/src/components/WaitingRoom/CameraButton/CameraButton.tsx @@ -3,10 +3,10 @@ import VideocamIcon from '@mui/icons-material/Videocam'; import VideocamOffIcon from '@mui/icons-material/VideocamOff'; import { ReactElement } from 'react'; import { useTranslation } from 'react-i18next'; -import usePreviewPublisherContext from '../../../hooks/usePreviewPublisherContext'; +import usePreviewPublisherContext from '@hooks/usePreviewPublisherContext'; +import useBackgroundPublisherContext from '@hooks/useBackgroundPublisherContext'; +import useIsCameraControlAllowed from '@Context/AppConfig/hooks/useIsCameraControlAllowed'; import VideoContainerButton from '../VideoContainerButton'; -import useBackgroundPublisherContext from '../../../hooks/useBackgroundPublisherContext'; -import useConfigContext from '../../../hooks/useConfigContext'; /** * CameraButton Component @@ -18,11 +18,11 @@ const CameraButton = (): ReactElement | false => { const { t } = useTranslation(); const { isVideoEnabled, toggleVideo } = usePreviewPublisherContext(); const { toggleVideo: toggleBackgroundVideoPublisher } = useBackgroundPublisherContext(); - const { videoSettings } = useConfigContext(); + const allowCameraControl = useIsCameraControlAllowed(); + const title = isVideoEnabled ? t('devices.video.camera.state.off') : t('devices.video.camera.state.on'); - const { allowCameraControl } = videoSettings; const handleToggleVideo = () => { toggleVideo(); diff --git a/frontend/src/components/WaitingRoom/ControlPanel/ControlPanel.spec.tsx b/frontend/src/components/WaitingRoom/ControlPanel/ControlPanel.spec.tsx index d3df5f6e..cbcc2ad6 100644 --- a/frontend/src/components/WaitingRoom/ControlPanel/ControlPanel.spec.tsx +++ b/frontend/src/components/WaitingRoom/ControlPanel/ControlPanel.spec.tsx @@ -1,19 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { cleanup, screen, render } from '@testing-library/react'; +import { cleanup, screen, render as renderBase } from '@testing-library/react'; +import { ReactElement } from 'react'; +import useDevices from '@hooks/useDevices'; +import { AllMediaDevices } from '@app-types/room'; +import { allMediaDevices } from '@utils/mockData/device'; +import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers'; import ControlPanel from '.'; -import useDevices from '../../../hooks/useDevices'; -import { AllMediaDevices } from '../../../types'; -import { allMediaDevices } from '../../../utils/mockData/device'; -import useConfigContext from '../../../hooks/useConfigContext'; -import { ConfigContextType } from '../../../Context/ConfigProvider'; - -vi.mock('../../../hooks/useDevices.tsx'); -vi.mock('../../../hooks/useConfigContext'); + +vi.mock('@hooks/useDevices.tsx'); + const mockUseDevices = useDevices as Mock< [], { allMediaDevices: AllMediaDevices; getAllMediaDevices: () => void } >; -const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>; describe('ControlPanel', () => { beforeEach(() => { @@ -21,11 +20,6 @@ describe('ControlPanel', () => { getAllMediaDevices: vi.fn(), allMediaDevices, }); - mockUseConfigContext.mockReturnValue({ - waitingRoomSettings: { - allowDeviceSelection: true, - }, - } as Partial as ConfigContextType); }); afterEach(() => { @@ -141,12 +135,6 @@ describe('ControlPanel', () => { }); it('is not rendered when allowDeviceSelection is false', () => { - mockUseConfigContext.mockReturnValue({ - waitingRoomSettings: { - allowDeviceSelection: false, - }, - } as Partial as ConfigContextType); - render( {}} @@ -157,9 +145,29 @@ describe('ControlPanel', () => { openVideoInput={false} openAudioOutput={false} anchorEl={null} - /> + />, + { + appConfigOptions: { + value: { + waitingRoomSettings: { + allowDeviceSelection: false, + }, + }, + }, + } ); expect(screen.queryByTestId('ControlPanel')).not.toBeInTheDocument(); }); }); + +function render( + ui: ReactElement, + options?: { + appConfigOptions?: AppConfigProviderWrapperOptions; + } +) { + const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions); + + return renderBase(ui, { ...options, wrapper: AppConfigWrapper }); +} diff --git a/frontend/src/components/WaitingRoom/ControlPanel/ControlPanel.tsx b/frontend/src/components/WaitingRoom/ControlPanel/ControlPanel.tsx index 4b9e9ccd..52453652 100644 --- a/frontend/src/components/WaitingRoom/ControlPanel/ControlPanel.tsx +++ b/frontend/src/components/WaitingRoom/ControlPanel/ControlPanel.tsx @@ -5,12 +5,12 @@ import VideoCall from '@mui/icons-material/VideoCall'; import Speaker from '@mui/icons-material/Speaker'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import { useTranslation } from 'react-i18next'; +import usePreviewPublisherContext from '@hooks/usePreviewPublisherContext'; +import useDevices from '@hooks/useDevices'; +import useAudioOutputContext from '@hooks/useAudioOutputContext'; +import useIsSmallViewport from '@hooks/useIsSmallViewport'; +import useAppConfig from '@Context/AppConfig/hooks/useAppConfig'; import MenuDevicesWaitingRoom from '../MenuDevices'; -import usePreviewPublisherContext from '../../../hooks/usePreviewPublisherContext'; -import useDevices from '../../../hooks/useDevices'; -import useAudioOutputContext from '../../../hooks/useAudioOutputContext'; -import useIsSmallViewport from '../../../hooks/useIsSmallViewport'; -import useConfigContext from '../../../hooks/useConfigContext'; export type ControlPanelProps = { handleAudioInputOpen: ( @@ -60,8 +60,10 @@ const ControlPanel = ({ const { localAudioSource, localVideoSource, changeAudioSource, changeVideoSource } = usePreviewPublisherContext(); const { currentAudioOutputDevice, setAudioOutputDevice } = useAudioOutputContext(); - const { waitingRoomSettings } = useConfigContext(); - const { allowDeviceSelection } = waitingRoomSettings; + + const allowDeviceSelection = useAppConfig( + ({ waitingRoomSettings }) => waitingRoomSettings.allowDeviceSelection + ); const buttonSx: SxProps = { borderRadius: '10px', diff --git a/frontend/src/components/WaitingRoom/MicButton/MicButton.spec.tsx b/frontend/src/components/WaitingRoom/MicButton/MicButton.spec.tsx index 8d26d02d..b5ebc4c0 100644 --- a/frontend/src/components/WaitingRoom/MicButton/MicButton.spec.tsx +++ b/frontend/src/components/WaitingRoom/MicButton/MicButton.spec.tsx @@ -1,8 +1,8 @@ -import { render, screen, fireEvent } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; +import { render as renderBase, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ReactElement } from 'react'; +import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers'; import MicButton from './MicButton'; -import useConfigContext from '../../../hooks/useConfigContext'; -import { ConfigContextType } from '../../../Context/ConfigProvider'; let isAudioEnabled = true; const toggleAudioMock = vi.fn(); @@ -18,21 +18,9 @@ vi.mock('../../../hooks/usePreviewPublisherContext', () => { }; }); -vi.mock('../../../hooks/useConfigContext'); -const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>; - describe('MicButton', () => { - let mockConfigContext: ConfigContextType; - beforeEach(() => { - vi.clearAllMocks(); isAudioEnabled = true; - mockConfigContext = { - audioSettings: { - allowMicrophoneControl: true, - }, - } as Partial as ConfigContextType; - mockUseConfigContext.mockReturnValue(mockConfigContext); }); it('renders the mic on icon when audio is enabled', () => { @@ -53,8 +41,27 @@ describe('MicButton', () => { }); it('is not rendered when allowMicrophoneControl is false', () => { - mockConfigContext.audioSettings.allowMicrophoneControl = false; - render(); + render(, { + appConfigOptions: { + value: { + audioSettings: { + allowMicrophoneControl: false, + }, + }, + }, + }); + expect(screen.queryByTestId('MicIcon')).not.toBeInTheDocument(); }); }); + +function render( + ui: ReactElement, + options?: { + appConfigOptions?: AppConfigProviderWrapperOptions; + } +) { + const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions); + + return renderBase(ui, { ...options, wrapper: AppConfigWrapper }); +} diff --git a/frontend/src/components/WaitingRoom/MicButton/MicButton.tsx b/frontend/src/components/WaitingRoom/MicButton/MicButton.tsx index 9bd235d3..77ce557b 100644 --- a/frontend/src/components/WaitingRoom/MicButton/MicButton.tsx +++ b/frontend/src/components/WaitingRoom/MicButton/MicButton.tsx @@ -3,9 +3,9 @@ import { MicOff } from '@mui/icons-material'; import MicIcon from '@mui/icons-material/Mic'; import { ReactElement } from 'react'; import { useTranslation } from 'react-i18next'; -import usePreviewPublisherContext from '../../../hooks/usePreviewPublisherContext'; +import useIsMicrophoneControlAllowed from '@Context/AppConfig/hooks/useIsMicrophoneControlAllowed'; +import usePreviewPublisherContext from '@hooks/usePreviewPublisherContext'; import VideoContainerButton from '../VideoContainerButton'; -import useConfigContext from '../../../hooks/useConfigContext'; /** * MicButton Component @@ -16,11 +16,12 @@ import useConfigContext from '../../../hooks/useConfigContext'; const MicButton = (): ReactElement | false => { const { t } = useTranslation(); const { isAudioEnabled, toggleAudio } = usePreviewPublisherContext(); - const config = useConfigContext(); + + const allowMicrophoneControl = useIsMicrophoneControlAllowed(); + const title = isAudioEnabled ? t('devices.audio.microphone.state.off') : t('devices.audio.microphone.state.on'); - const { allowMicrophoneControl } = config.audioSettings; return ( allowMicrophoneControl && ( diff --git a/frontend/src/components/WaitingRoom/VideoLoading/VideoLoading.spec.tsx b/frontend/src/components/WaitingRoom/VideoLoading/VideoLoading.spec.tsx index ee11bc06..3a83e346 100644 --- a/frontend/src/components/WaitingRoom/VideoLoading/VideoLoading.spec.tsx +++ b/frontend/src/components/WaitingRoom/VideoLoading/VideoLoading.spec.tsx @@ -12,10 +12,4 @@ describe('VideoLoading', () => { expect(screen.getByTestId('VideoLoading')).toBeInTheDocument(); }); - - it('should contain a CircularProgress component', () => { - render(); - - expect(screen.getByTestId('CircularProgress')).toBeInTheDocument(); - }); }); diff --git a/frontend/src/hooks/useConfigContext.ts b/frontend/src/hooks/useConfigContext.ts deleted file mode 100644 index 94648ed3..00000000 --- a/frontend/src/hooks/useConfigContext.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { useContext } from 'react'; -import { ConfigContext, ConfigContextType } from '../Context/ConfigProvider'; - -/** - * Custom hook to access the Config context containing comprehensive application configuration settings. - * Provides access to video settings (background effects, camera control, resolution), audio settings - * (noise suppression, microphone control), waiting room settings (device selection), and meeting room - * settings (layout mode, UI button visibility). Configuration is loaded from config.json and merged - * with default values via the useConfig hook. - * @returns {ConfigContextType} The config context value with all application settings - */ -const useConfigContext = (): ConfigContextType => { - const context = useContext(ConfigContext); - - return context; -}; - -export default useConfigContext; diff --git a/frontend/src/pages/MeetingRoom/MeetingRoom.spec.tsx b/frontend/src/pages/MeetingRoom/MeetingRoom.spec.tsx index d788e32a..1e5c9478 100644 --- a/frontend/src/pages/MeetingRoom/MeetingRoom.spec.tsx +++ b/frontend/src/pages/MeetingRoom/MeetingRoom.spec.tsx @@ -1,35 +1,38 @@ import '../../css/index.css'; import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render as renderBase, screen } from '@testing-library/react'; import { Publisher, Subscriber } from '@vonage/client-sdk-video'; import { EventEmitter } from 'stream'; import * as mui from '@mui/material'; -import MeetingRoom from './MeetingRoom'; -import UserProvider, { UserContextType } from '../../Context/user'; -import SessionProvider, { SessionContextType } from '../../Context/SessionProvider/session'; -import { SubscriberWrapper } from '../../types/session'; -import { PublisherContextType, PublisherProvider } from '../../Context/PublisherProvider'; -import usePublisherContext from '../../hooks/usePublisherContext'; -import useUserContext from '../../hooks/useUserContext'; -import useDevices from '../../hooks/useDevices'; -import { AllMediaDevices } from '../../types'; -import { allMediaDevices, defaultAudioDevice } from '../../utils/mockData/device'; -import useSpeakingDetector from '../../hooks/useSpeakingDetector'; -import useLayoutManager, { GetLayout } from '../../hooks/useLayoutManager'; -import useSessionContext from '../../hooks/useSessionContext'; -import useActiveSpeaker from '../../hooks/useActiveSpeaker'; -import useScreenShare, { UseScreenShareType } from '../../hooks/useScreenShare'; -import { RIGHT_PANEL_BUTTON_COUNT } from '../../utils/constants'; +import { ReactElement } from 'react'; +import UserProvider, { UserContextType } from '@Context/user'; +import SessionProvider, { SessionContextType } from '@Context/SessionProvider/session'; +import { SubscriberWrapper } from '@app-types/session'; +import { PublisherContextType, PublisherProvider } from '@Context/PublisherProvider'; +import usePublisherContext from '@hooks/usePublisherContext'; +import useUserContext from '@hooks/useUserContext'; +import useDevices from '@hooks/useDevices'; +import { AllMediaDevices } from '@app-types/room'; +import { allMediaDevices, defaultAudioDevice } from '@utils/mockData/device'; +import useSpeakingDetector from '@hooks/useSpeakingDetector'; +import useLayoutManager, { GetLayout } from '@hooks/useLayoutManager'; +import useSessionContext from '@hooks/useSessionContext'; +import useActiveSpeaker from '@hooks/useActiveSpeaker'; +import useScreenShare, { UseScreenShareType } from '@hooks/useScreenShare'; +import { RIGHT_PANEL_BUTTON_COUNT } from '@utils/constants'; import useToolbarButtons, { UseToolbarButtons, UseToolbarButtonsProps, -} from '../../hooks/useToolbarButtons'; -import usePublisherOptions from '../../Context/PublisherProvider/usePublisherOptions'; +} from '@hooks/useToolbarButtons'; +import usePublisherOptions from '@Context/PublisherProvider/usePublisherOptions'; +import { makeAppConfigProviderWrapper } from '@test/providers'; +import composeProviders from '@utils/composeProviders'; +import MeetingRoom from './MeetingRoom'; const mockedNavigate = vi.fn(); const mockedParams = { roomName: 'test-room-name' }; const mockedLocation = vi.fn(); -vi.mock('../../hooks/useBackgroundPublisherContext', () => ({ +vi.mock('@hooks/useBackgroundPublisherContext', () => ({ __esModule: true, default: () => ({ initBackgroundLocalPublisher: vi.fn(), @@ -54,38 +57,17 @@ vi.mock('@mui/material', async () => { useMediaQuery: vi.fn(), }; }); -vi.mock('../../hooks/useConfigContext', () => { - return { - default: () => ({ - videoSettings: { - allowCameraControl: true, - }, - audioSettings: { - allowMicrophoneControl: true, - }, - meetingRoomSettings: { - defaultLayoutMode: 'active-speaker', - showParticipantList: true, - allowChat: true, - allowScreenShare: true, - allowArchiving: true, - allowCaptions: true, - allowEmojis: true, - }, - }), - }; -}); -vi.mock('../../hooks/useDevices.tsx'); -vi.mock('../../hooks/usePublisherContext.tsx'); -vi.mock('../../hooks/useUserContext.tsx'); -vi.mock('../../hooks/useSpeakingDetector.tsx'); -vi.mock('../../hooks/useLayoutManager.tsx'); -vi.mock('../../hooks/useSessionContext.tsx'); -vi.mock('../../hooks/useActiveSpeaker.tsx'); -vi.mock('../../hooks/useScreenShare.tsx'); -vi.mock('../../hooks/useToolbarButtons'); -vi.mock('../../Context/PublisherProvider/usePublisherOptions'); +vi.mock('@hooks/useDevices.tsx'); +vi.mock('@hooks/usePublisherContext.tsx'); +vi.mock('@hooks/useUserContext.tsx'); +vi.mock('@hooks/useSpeakingDetector.tsx'); +vi.mock('@hooks/useLayoutManager.tsx'); +vi.mock('@hooks/useSessionContext.tsx'); +vi.mock('@hooks/useActiveSpeaker.tsx'); +vi.mock('@hooks/useScreenShare.tsx'); +vi.mock('@hooks/useToolbarButtons'); +vi.mock('@Context/PublisherProvider/usePublisherOptions'); const mockUseDevices = useDevices as Mock< [], @@ -112,16 +94,6 @@ const mockUseToolbarButtons = useToolbarButtons as Mock< UseToolbarButtons >; -const MeetingRoomWithProviders = () => ( - - - - - - - -); - const createSubscriberWrapper = (id: string): SubscriberWrapper => { const mockSubscriber = { id, @@ -217,39 +189,39 @@ describe('MeetingRoom', () => { }); it('should render', () => { - render(); + render(); const meetingRoom = screen.getByTestId('meetingRoom'); expect(meetingRoom).not.toBeNull(); }); it('renders the small viewport header bar if it is on a small tab or device', () => { (mui.useMediaQuery as Mock).mockReturnValue(true); - render(); + render(); expect(screen.getByTestId('smallViewportHeader')).not.toBeNull(); }); it('does not render the small viewport header bar if it is on desktop', () => { // we do not need to mock the small port view value here given we already do it in beforeEach - render(); + render(); expect(screen.queryByTestId('smallViewportHeader')).toBeNull(); }); it('should call joinRoom on render only once', () => { - const { rerender } = render(); + const { rerender } = render(); expect(sessionContext.joinRoom).toHaveBeenCalledWith('test-room-name'); expect(sessionContext.joinRoom).toHaveBeenCalledTimes(1); - rerender(); - rerender(); - rerender(); - rerender(); + rerender(); + rerender(); + rerender(); + rerender(); expect(sessionContext.joinRoom).toHaveBeenCalledTimes(1); }); it('should call publish after connected', () => { - const { rerender } = render(); + const { rerender } = render(); expect(sessionContext.joinRoom).toHaveBeenCalledWith('test-room-name'); sessionContext.connected = true; - rerender(); + rerender(); expect(publisherContext.initializeLocalPublisher).toHaveBeenCalledTimes(1); expect(publisherContext.publish).toHaveBeenCalledTimes(1); }); @@ -257,19 +229,19 @@ describe('MeetingRoom', () => { it('should display publisher', () => { sessionContext.connected = true; publisherContext.publisher = mockPublisher; - const { rerender } = render(); - rerender(); + const { rerender } = render(); + rerender(); expect(screen.getByTestId('publisher-container')).toBeInTheDocument(); }); it('should display spinner until session is connected', () => { sessionContext.connected = false; publisherContext.publisher = mockPublisher; - const { rerender } = render(); - rerender(); + const { rerender } = render(); + rerender(); expect(screen.getByTestId('progress-spinner')).toBeInTheDocument(); sessionContext.connected = true; - rerender(); + rerender(); expect(screen.queryByTestId('progress-spinner')).not.toBeInTheDocument(); }); @@ -286,8 +258,8 @@ describe('MeetingRoom', () => { createSubscriberWrapper('sub7'), ]; publisherContext.publisher = mockPublisher; - const { rerender } = render(); - rerender(); + const { rerender } = render(); + rerender(); expect(screen.getByTestId('subscriber-container-sub1')).toBeVisible(); expect(screen.getByTestId('subscriber-container-sub2')).toBeVisible(); expect(screen.getByTestId('subscriber-container-sub3')).toBeVisible(); @@ -306,10 +278,10 @@ describe('MeetingRoom', () => { .map((_s, index) => createSubscriberWrapper(`sub${index + 1}`)); sessionContext.subscriberWrappers = [sub1]; publisherContext.publisher = mockPublisher; - const { rerender } = render(); + const { rerender } = render(); sessionContext.subscriberWrappers = [sub2, sub1]; - rerender(); + rerender(); const getSubIdsInRenderOrder = () => screen.getAllByTestId('subscriber-container', { exact: false }).map((element) => element?.id); @@ -318,7 +290,7 @@ describe('MeetingRoom', () => { expect(getSubIdsInRenderOrder()).toEqual(['sub1', 'sub2']); sessionContext.subscriberWrappers = [sub3, sub2, sub1]; - rerender(); + rerender(); // sub1 and sub2 joined first so should stay in position ahead of sub3 expect(getSubIdsInRenderOrder()).toEqual(['sub1', 'sub2', 'sub3']); @@ -327,10 +299,10 @@ describe('MeetingRoom', () => { it('should display chat unread number', () => { sessionContext.connected = true; publisherContext.publisher = mockPublisher; - const { rerender } = render(); - rerender(); + const { rerender } = render(); + rerender(); sessionContext.unreadCount = 4; - rerender(); + rerender(); expect(screen.queryAllByTestId('chat-button-unread-count')[0]).toHaveTextContent('4'); }); @@ -339,7 +311,7 @@ describe('MeetingRoom', () => { publisherContext.isVideoEnabled = false; publisherContext.quality = 'poor'; - render(); + render(); const connectionAlert = screen.queryByText( 'Please check your connectivity. Your video may be disabled to improve the user experience' @@ -350,7 +322,7 @@ describe('MeetingRoom', () => { it('should be displayed when publishing video', () => { publisherContext.isVideoEnabled = true; publisherContext.quality = 'poor'; - render(); + render(); const connectionAlert = screen.getByText( 'Please check your connectivity. Your video may be disabled to improve the user experience' @@ -361,7 +333,7 @@ describe('MeetingRoom', () => { it('should be hidden when user stops publishing video', () => { publisherContext.isVideoEnabled = true; publisherContext.quality = 'poor'; - const { rerender } = render(); + const { rerender } = render(); const connectionAlert = screen.queryByText( 'Please check your connectivity. Your video may be disabled to improve the user experience' @@ -369,7 +341,7 @@ describe('MeetingRoom', () => { expect(connectionAlert).toBeInTheDocument(); publisherContext.isVideoEnabled = false; - rerender(); + rerender(); expect(connectionAlert).not.toBeInTheDocument(); }); }); @@ -381,7 +353,7 @@ describe('MeetingRoom', () => { "We're having trouble connecting you with others in the meeting room. Please check your network and try again.", }; publisherContext.publishingError = publishingBlockedError; - render(); + render(); expect(mockedNavigate).toHaveBeenCalledOnce(); expect(mockedNavigate).toHaveBeenCalledWith('/goodbye', { @@ -394,3 +366,16 @@ describe('MeetingRoom', () => { }); }); }); + +function render(ui: ReactElement) { + const { AppConfigWrapper } = makeAppConfigProviderWrapper(); + + const composeWrapper = composeProviders( + AppConfigWrapper, + UserProvider, + SessionProvider, + PublisherProvider + ); + + return renderBase(ui, { wrapper: composeWrapper }); +} diff --git a/frontend/src/pages/WaitingRoom/WaitingRoom.spec.tsx b/frontend/src/pages/WaitingRoom/WaitingRoom.spec.tsx index d91bde26..54f5d3c8 100644 --- a/frontend/src/pages/WaitingRoom/WaitingRoom.spec.tsx +++ b/frontend/src/pages/WaitingRoom/WaitingRoom.spec.tsx @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi, Mock, beforeAll, afterAll } from 'vitest'; -import { act, render, screen, waitFor } from '@testing-library/react'; -import { ReactNode } from 'react'; +import { act, render as renderBase, screen } from '@testing-library/react'; +import { ReactElement, ReactNode } from 'react'; import { Publisher } from '@vonage/client-sdk-video'; import EventEmitter from 'events'; import userEvent from '@testing-library/user-event'; @@ -18,6 +18,7 @@ import usePermissions from '@hooks/usePermissions'; import { DEVICE_ACCESS_STATUS } from '@utils/constants'; import waitUntilPlaying from '@utils/waitUntilPlaying'; import { BackgroundPublisherContextType } from '@Context/BackgroundPublisherProvider'; +import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers'; import WaitingRoom from './WaitingRoom'; const mockedNavigate = vi.fn(); @@ -125,23 +126,35 @@ describe('WaitingRoom', () => { }); it('should display a video loading element on entering', () => { - render(); + render(, { + appConfigOptions: { + value: { + isAppConfigLoaded: false, + videoSettings: { + allowCameraControl: true, + }, + }, + }, + }); + const videoLoadingElement = screen.getByTestId('VideoLoading'); expect(videoLoadingElement).toBeVisible(); }); it('should eventually display a preview publisher', async () => { - const { rerender } = render(); - act(() => { - // After the preview publisher initializes. - previewPublisherContext.publisher = mockPublisher; - previewPublisherContext.publisherVideoElement = mockPublisherVideoElement; - previewPublisherContext.isVideoEnabled = true; + // After the preview publisher initializes. + previewPublisherContext.publisher = mockPublisher; + previewPublisherContext.publisherVideoElement = mockPublisherVideoElement; + previewPublisherContext.isVideoEnabled = true; + + const { rerender, container } = render(); + + await act(() => { + rerender(); }); - rerender(); - const previewPublisher = screen.getByTitle('publisher-preview'); - await waitFor(() => expect(previewPublisher).toBeVisible()); + expect(container.querySelector('[data-video-container]')).toBeVisible(); + expect(screen.getByTitle('publisher-preview')).toBeVisible(); }); it('should call destroyPublisher when navigating away from waiting room', async () => { @@ -197,3 +210,14 @@ function getLocationMock() { return { locationBackUp: location, locationMock }; } + +function render( + ui: ReactElement, + options?: { + appConfigOptions?: AppConfigProviderWrapperOptions; + } +) { + const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions); + + return renderBase(ui, { wrapper: AppConfigWrapper }); +} diff --git a/frontend/src/test/providers/index.ts b/frontend/src/test/providers/index.ts new file mode 100644 index 00000000..6b1e80f6 --- /dev/null +++ b/frontend/src/test/providers/index.ts @@ -0,0 +1,26 @@ +export { + default as makeAppConfigProviderWrapper, + type AppConfigProviderWrapperOptions, +} from './makeAppConfigProviderWrapper'; + +export { + default as makeSessionProviderWrapper, + type SessionProviderWrapperOptions, +} from './makeSessionProviderWrapper'; + +export { + default as makeUserProviderWrapper, + type UserProviderWrapperOptions, +} from './makeUserProviderWrapper'; + +/** + * TODO: We still need to create provider wrappers for the following contexts: + * + * AudioOutputProvider, + * BackgroundPublisherProvider, + * PreviewPublisherProvider, + * PublisherProvider, + * RoomProvider + * + * Right now we are mocking all those context which downgrades the quality of our tests. + */ diff --git a/frontend/src/test/providers/makeAppConfigProviderWrapper.ts b/frontend/src/test/providers/makeAppConfigProviderWrapper.ts new file mode 100644 index 00000000..30467d95 --- /dev/null +++ b/frontend/src/test/providers/makeAppConfigProviderWrapper.ts @@ -0,0 +1,36 @@ +import { DeepPartial } from '@app-types/index'; +import appConfig, { type AppConfig } from '@Context/AppConfig'; +import defaultAppConfig from '@Context/AppConfig/helpers/defaultAppConfig'; +import mergeAppConfigs from '@Context/AppConfig/helpers/mergeAppConfigs'; + +export type AppConfigProviderWrapperOptions = { + value?: DeepPartial; +}; + +/** + * Creates wrapper for the AppConfigProvider context. + * Allows overriding context values via options and accessing the context value. + * @param {object} appConfigOptions - The wrapper options. + * @returns {object} The AppConfigProvider wrapper and context getter. + */ +function makeAppConfigProviderWrapper(appConfigOptions?: AppConfigProviderWrapperOptions) { + const initialState = mergeAppConfigs({ + previous: defaultAppConfig, + updates: { + /** + * This flag marks the app config as loaded to prevent fetching it during tests. + */ + isAppConfigLoaded: true, + ...appConfigOptions?.value, + }, + }); + + const { wrapper: AppConfigWrapper, context: appConfigContext } = + appConfig.Provider.makeProviderWrapper({ + value: initialState, + }); + + return { AppConfigWrapper, appConfigContext }; +} + +export default makeAppConfigProviderWrapper; diff --git a/frontend/src/test/providers/makeGenericProviderWrapper.tsx b/frontend/src/test/providers/makeGenericProviderWrapper.tsx new file mode 100644 index 00000000..803c04e0 --- /dev/null +++ b/frontend/src/test/providers/makeGenericProviderWrapper.tsx @@ -0,0 +1,85 @@ +/* eslint-disable no-underscore-dangle */ +/* eslint-disable jsdoc/no-undefined-types */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React, { PropsWithChildren, useContext } from 'react'; + +export type ContextInterceptorCallback> = ( + value: React.ContextType +) => void; + +export type ContextCreatedCallback> = ( + value: React.ContextType +) => void; + +export type GenericWrapperOptions< + TProvider extends React.FunctionComponent>, + TContext extends React.Context, +> = { + __interceptor?: ContextInterceptorCallback; + __onCreated?: ContextCreatedCallback; +} & Omit[0], 'children'>; + +/** + * Creates a generic provider wrapper for testing context providers. + * The wrapper includes an interceptor to capture context values. + * @param {React.FunctionComponent} ContextProvider - The context provider component. + * @param {React.Context} context - The context to capture values from. + * @param {object} [options] - The wrapper options. + * @param {ContextInterceptorCallback} [options.__interceptor] - Callback called on each render with the current context value. + * @param {ContextCreatedCallback} [options.__onCreated] - Callback called once when the context is first created. + * @param {...any} [options.props] - Any additional props are passed through to the ContextProvider. + * @returns {[React.FunctionComponent, { current: any | undefined }]} The provider wrapper and context value getter. + */ +function makeGenericProviderWrapper< + TProvider extends React.FunctionComponent>, + TContext extends React.Context, +>( + ContextProvider: TProvider, + context: TContext, + options?: GenericWrapperOptions +) { + type ContextValue = React.ContextType; + + /** + * Get the last context value captured by the Interceptor. + * @returns {ContextValue | null} The last context value or null if not yet captured. + */ + const contextResult = { + current: undefined as ContextValue, + }; + + let onCreated = options?.__onCreated ?? null; + + const Interceptor = () => { + const contextValue = useContext(context); + + contextResult.current = contextValue; + + onCreated?.(contextValue); + onCreated = null; + + options?.__interceptor?.(contextValue); + + return null; + }; + + /** + * Wrapper component for the context provider. + * Contains an interceptor to capture context values. + * @param {object} props - The component props. + * @param {React.ReactNode} props.children - The child components. + * @returns {React.ReactElement} The provider wrapper component. + */ + const ProviderWrapper = ({ children }: PropsWithChildren) => { + return ( + [0])}> + + {children} + + ); + }; + + return [ProviderWrapper, contextResult] as const; +} + +export default makeGenericProviderWrapper; diff --git a/frontend/src/test/providers/makeSessionProviderWrapper.ts b/frontend/src/test/providers/makeSessionProviderWrapper.ts new file mode 100644 index 00000000..5b46a110 --- /dev/null +++ b/frontend/src/test/providers/makeSessionProviderWrapper.ts @@ -0,0 +1,57 @@ +import SessionProvider, { SessionContext } from '@Context/SessionProvider/session'; +import composeProviders from '@utils/composeProviders'; +import makeGenericProviderWrapper, { GenericWrapperOptions } from './makeGenericProviderWrapper'; +import makeUserProviderWrapper, { UserProviderWrapperOptions } from './makeUserProviderWrapper'; +import makeAppConfigProviderWrapper, { + AppConfigProviderWrapperOptions, +} from './makeAppConfigProviderWrapper'; + +export type SessionProviderWrapperOptions = GenericWrapperOptions< + typeof SessionProvider, + typeof SessionContext +> & { + appConfigOptions?: AppConfigProviderWrapperOptions; + userOptions?: UserProviderWrapperOptions; +}; + +/** + * Creates wrapper for the SessionProvider context. + * The wrapper includes: + * - AppConfigProvider: you can override its options via appConfigOptions + * - UserProvider: you can override its options via userOptions + * - SessionProvider: you can override its options via the rest of the options + * @param {object} options - The wrapper options. + * @param {AppConfigProviderWrapperOptions} [options.appConfigOptions] - Options for the AppConfigProvider wrapper. + * @param {UserProviderWrapperOptions} [options.userOptions] - Options for the UserProvider wrapper. + * @returns {object} The SessionProvider wrapper and context getters. + */ +function makeSessionProviderWrapper({ + appConfigOptions, + userOptions, + ...sessionOptions +}: SessionProviderWrapperOptions = {}) { + const { AppConfigWrapper, appConfigContext } = makeAppConfigProviderWrapper(appConfigOptions); + + const { UserProviderWrapper, userContext } = makeUserProviderWrapper(userOptions); + + const [SessionProviderWrapper, sessionContext] = makeGenericProviderWrapper( + SessionProvider, + SessionContext, + sessionOptions + ); + + const composeWrapper = composeProviders( + AppConfigWrapper, + UserProviderWrapper, + SessionProviderWrapper + ); + + return { + SessionProviderWrapper: composeWrapper, + sessionContext, + userContext, + appConfigContext, + }; +} + +export default makeSessionProviderWrapper; diff --git a/frontend/src/test/providers/makeUserProviderWrapper.ts b/frontend/src/test/providers/makeUserProviderWrapper.ts new file mode 100644 index 00000000..71ead467 --- /dev/null +++ b/frontend/src/test/providers/makeUserProviderWrapper.ts @@ -0,0 +1,25 @@ +import UserProvider, { UserContext } from '@Context/user'; +import makeGenericProviderWrapper, { GenericWrapperOptions } from './makeGenericProviderWrapper'; + +export type UserProviderWrapperOptions = GenericWrapperOptions< + typeof UserProvider, + typeof UserContext +>; + +/** + * Creates wrapper for the UserProvider context. + * Allows overriding context values via options and accessing the context value. + * @param {object} options - The wrapper options. + * @returns {object} The UserProvider wrapper and context getter. + */ +function makeUserProviderWrapper(options?: UserProviderWrapperOptions) { + const [UserProviderWrapper, userContext] = makeGenericProviderWrapper( + UserProvider, + UserContext, + options + ); + + return { UserProviderWrapper, userContext }; +} + +export default makeUserProviderWrapper; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7736072a..ac456c1e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1 +1,5 @@ export * from './room'; + +export type DeepPartial = { + [K in keyof T]?: T[K] extends object ? DeepPartial : T[K]; +}; diff --git a/frontend/src/utils/composeProviders.tsx b/frontend/src/utils/composeProviders.tsx new file mode 100644 index 00000000..9f1ec9a4 --- /dev/null +++ b/frontend/src/utils/composeProviders.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +type ProviderProps = { children: React.ReactNode }; +type ProviderComponent = React.ComponentType; + +/** + * Composes multiple context providers into a single provider component. + * @param {...ProviderComponent} providers - The provider components to compose. + * @returns {ProviderComponent} - A single provider component that nests the given providers. + */ +function composeProviders(...providers: ProviderComponent[]): ProviderComponent { + return ({ children }) => { + return providers.reduceRight((acc, Provider) => {acc}, children); + }; +} + +export default composeProviders; diff --git a/package.json b/package.json index 84c69839..84f53ece 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "dev": "yarn && concurrently 'yarn dev:frontend' 'yarn dev:backend'", "docs": "yarn workspace frontend docs", "docs:watch": "yarn workspace frontend docs:watch", - "lint": "ESLINT_USE_FLAT_CONFIG=false yarn eslint . --ext .ts,.tsx", + "lint": "ESLINT_USE_FLAT_CONFIG=false yarn eslint . --ext .ts,.tsx --max-warnings 0", "lint:filenames": "./scripts/lintFileNames.sh", "lint:fix": "prettier --log-level warn --write \"**/*.{ts,tsx,js,json,md,yml,yaml}\" && yarn lint --fix", "prepare": "husky install", diff --git a/yarn.lock b/yarn.lock index d24cb2e8..0c727792 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3552,6 +3552,11 @@ cjs-module-lexer@^1.0.0: resolved "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.3.1.tgz" integrity sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q== +classnames@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b" + integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow== + clear-module@^4.1.2: version "4.1.2" resolved "https://registry.npmjs.org/clear-module/-/clear-module-4.1.2.tgz" @@ -6288,6 +6293,11 @@ json-stable-stringify-without-jsonify@^1.0.1: resolved "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz" integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== +json-storage-formatter@^2.0.9: + version "2.0.9" + resolved "https://registry.yarnpkg.com/json-storage-formatter/-/json-storage-formatter-2.0.9.tgz#f86a7fd2375c370e40c30be630a097ae3a93f3d8" + integrity sha512-8UsiveogvIJp+Bf1h5yTKPsLbt5tgQKmuh7tFru9Z5js2BjrJMHsdqO+KOWJrvaaj0D1TexMLGg1/q0yDrwgQw== + json-stringify-safe@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" @@ -7667,6 +7677,18 @@ react-dom@^19.1.0: dependencies: scheduler "^0.26.0" +react-global-state-hooks@^14.0.2: + version "14.0.2" + resolved "https://registry.yarnpkg.com/react-global-state-hooks/-/react-global-state-hooks-14.0.2.tgz#0a64749f386a962b44466637a756d1312c2ef4d3" + integrity sha512-GPbj3f/NKOb8MaqdVvtQf1R6cvOznxDyC1OHLBQf7nefVpvPrfvFFDkx83wsT5bS2VsUQQ7qMWcpEMmaSdMcpA== + dependencies: + react-hooks-global-states "^14.0.2" + +react-hooks-global-states@^14.0.2: + version "14.0.2" + resolved "https://registry.yarnpkg.com/react-hooks-global-states/-/react-hooks-global-states-14.0.2.tgz#7c87acbe97300664e6924ef851dc222f73516445" + integrity sha512-6o4NfaweJ5ryLUUmQWZKnIwHaoQWs6AtRT2DCZZ3eYiX3Dco2uZkAlJBfDyYFY9VbBznRetcrMXXCeBJYTVaAA== + react-i18next@^15.6.1: version "15.6.1" resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-15.6.1.tgz#a2747bed7768faef28fa28de32ff3811b2459c20"