Skip to content

Commit 57d7614

Browse files
VIDSOL-211-adding granular config context
- using granular context for app configuration... avoid re-renders and isolate logic - adding reusable provider wrappers for unit test - removing a bunch of mocks in our unit test... mocking our own code downgrades the quality of the test
1 parent a0d05c8 commit 57d7614

File tree

84 files changed

+1847
-1159
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

84 files changed

+1847
-1159
lines changed

frontend/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,18 @@
3737
"autolinker": "^4.0.0",
3838
"autoprefixer": "^10.4.19",
3939
"axios": "^1.12.0",
40+
"classnames": "^2.5.1",
4041
"events": "^3.3.0",
4142
"i18next": "^25.3.2",
4243
"i18next-browser-languagedetector": "^8.2.0",
44+
"json-storage-formatter": "^2.0.9",
4345
"lodash": "^4.17.21",
4446
"opentok-layout-js": "^5.4.0",
4547
"opentok-solutions-logging": "^1.1.5",
4648
"postcss": "^8.4.38",
4749
"react": "^19.1.0",
4850
"react-dom": "^19.1.0",
51+
"react-global-state-hooks": "^10.3.0",
4952
"react-i18next": "^15.6.1",
5053
"react-router-dom": "^6.11.0",
5154
"resize-observer-polyfill": "^1.5.1",

frontend/src/App.spec.tsx

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,31 +10,9 @@ vi.mock('./pages/UnsupportedBrowserPage', () => ({
1010
default: () => <div>Unsupported Browser</div>,
1111
}));
1212

13-
// Mock context providers and wrappers
14-
vi.mock('./Context/PreviewPublisherProvider', () => ({
15-
__esModule: true,
16-
PreviewPublisherProvider: ({ children }: PropsWithChildren) => children,
17-
default: ({ children }: PropsWithChildren) => children,
18-
}));
19-
vi.mock('./Context/PublisherProvider', () => ({
20-
__esModule: true,
21-
PublisherProvider: ({ children }: PropsWithChildren) => children,
22-
default: ({ children }: PropsWithChildren) => children,
23-
}));
24-
vi.mock('./Context/SessionProvider/session', () => ({
25-
default: ({ children }: PropsWithChildren) => children,
26-
}));
2713
vi.mock('./components/RedirectToWaitingRoom', () => ({
2814
default: ({ children }: PropsWithChildren) => children,
2915
}));
30-
vi.mock('./Context/RoomContext', () => ({
31-
default: ({ children }: PropsWithChildren) => children,
32-
}));
33-
vi.mock('./Context/ConfigProvider', () => ({
34-
__esModule: true,
35-
ConfigContextProvider: ({ children }: PropsWithChildren) => children,
36-
default: ({ children }: PropsWithChildren) => children,
37-
}));
3816

3917
describe('App routing', () => {
4018
it('renders LandingPage on unknown route', () => {
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { act, renderHook as renderHookBase, waitFor } from '@testing-library/react';
3+
import useAppConfigContext, { AppConfig, AppConfigProvider } from '@Context/AppConfig';
4+
import defaultAppConfig from './helpers/defaultAppConfig';
5+
6+
describe('AppConfigContext', () => {
7+
it('returns the default config when no config.json is loaded', async () => {
8+
const { result } = renderHook(() => useAppConfigContext()[0]);
9+
10+
await waitFor(() => {
11+
expect(result.current).toEqual(expect.objectContaining(defaultAppConfig));
12+
});
13+
});
14+
15+
it('merges config.json values if loaded (mocked fetch)', async () => {
16+
expect.assertions(2);
17+
18+
// All values in this config should override the defaultConfig
19+
const mockConfig: AppConfig = {
20+
isAppConfigLoaded: true,
21+
videoSettings: {
22+
allowCameraControl: false,
23+
defaultResolution: '640x480',
24+
allowVideoOnJoin: false,
25+
allowBackgroundEffects: false,
26+
},
27+
audioSettings: {
28+
allowAdvancedNoiseSuppression: false,
29+
allowAudioOnJoin: false,
30+
allowMicrophoneControl: false,
31+
},
32+
waitingRoomSettings: {
33+
allowDeviceSelection: false,
34+
},
35+
meetingRoomSettings: {
36+
allowArchiving: false,
37+
allowCaptions: false,
38+
allowChat: false,
39+
allowDeviceSelection: false,
40+
allowEmojis: false,
41+
allowScreenShare: false,
42+
defaultLayoutMode: 'grid',
43+
showParticipantList: false,
44+
},
45+
};
46+
47+
vi.spyOn(global, 'fetch').mockResolvedValue({
48+
json: async () => mockConfig,
49+
headers: {
50+
get: () => 'application/json',
51+
},
52+
} as unknown as Response);
53+
54+
const { result } = renderHook(() => useAppConfigContext());
55+
let [appConfig, { loadAppConfig }] = result.current;
56+
57+
expect(appConfig).not.toEqual(mockConfig);
58+
59+
await loadAppConfig();
60+
61+
[appConfig, { loadAppConfig }] = result.current;
62+
63+
expect(appConfig).toEqual({
64+
...mockConfig,
65+
isAppConfigLoaded: true,
66+
});
67+
});
68+
69+
it('falls back to defaultConfig if fetch fails', async () => {
70+
expect.assertions(4);
71+
72+
const mockFetchError = new Error('mocking a failure to fetch');
73+
74+
vi.spyOn(global, 'fetch').mockRejectedValue(mockFetchError as unknown as Response);
75+
76+
const { result, rerender } = renderHook(() => useAppConfigContext());
77+
let [appConfig, { loadAppConfig }] = result.current;
78+
79+
expect(appConfig.isAppConfigLoaded).toBe(false);
80+
expect(loadAppConfig()).rejects.toThrow('mocking a failure to fetch');
81+
82+
await act(() => {
83+
rerender();
84+
});
85+
86+
[appConfig, { loadAppConfig }] = result.current;
87+
88+
expect(appConfig.isAppConfigLoaded).toBe(true);
89+
90+
expect(appConfig).toEqual({
91+
...defaultAppConfig,
92+
isAppConfigLoaded: true,
93+
});
94+
});
95+
96+
it('falls back to defaultConfig if no config.json is found', async () => {
97+
expect.assertions(4);
98+
99+
vi.spyOn(global, 'fetch').mockResolvedValue({
100+
ok: false,
101+
status: 404,
102+
statusText: 'Not Found',
103+
headers: {
104+
get: () => 'text/html',
105+
},
106+
} as unknown as Response);
107+
108+
const { result, rerender } = renderHook(() => useAppConfigContext());
109+
let [appConfig, { loadAppConfig }] = result.current;
110+
111+
expect(appConfig).toEqual(defaultAppConfig);
112+
113+
expect(loadAppConfig()).rejects.toThrow('No valid JSON found, using default config');
114+
115+
await act(() => {
116+
rerender();
117+
});
118+
119+
[appConfig, { loadAppConfig }] = result.current;
120+
121+
expect(appConfig.isAppConfigLoaded).toBe(true);
122+
123+
expect(appConfig).toEqual({
124+
...defaultAppConfig,
125+
isAppConfigLoaded: true,
126+
});
127+
});
128+
});
129+
130+
function renderHook<Result, Props>(render: (initialProps: Props) => Result) {
131+
return renderHookBase(render, { wrapper: AppConfigProvider });
132+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import createContext, { type InferContextType } from 'react-global-state-hooks/createContext';
2+
import updateAppConfig from './actions/updateAppConfig';
3+
import loadAppConfig from './actions/loadAppConfig';
4+
import initialValue from './helpers/defaultAppConfig';
5+
6+
const [useHook, provider, context] = createContext(initialValue, {
7+
actions: {
8+
updateAppConfig,
9+
loadAppConfig,
10+
},
11+
});
12+
13+
const [useControls] = useHook.stateControls();
14+
15+
/**
16+
* The AppConfig context instance.
17+
*/
18+
export const AppConfigContext = context;
19+
20+
/**
21+
* Provider component that makes the config available across the app.
22+
* @param props - The component props.
23+
* @param props.value - (optional) Initial value override for the AppConfig state.
24+
* @param props.onCreated - (optional) Callback invoked when the provider is created, receives the context as argument.
25+
* @returns The AppConfig provider component.
26+
*/
27+
export const AppConfigProvider = provider;
28+
29+
/**
30+
* Hook to access the AppConfig state with a selector.
31+
* @param {Function} selector - (optional) Function to select a part of the AppConfig state.
32+
* @param {unknown[]} [dependencies] - Optional dependencies array to control re-selection.
33+
* @returns {[Selection, AppConfigActions]} If selector is provided, returns [selected state, actions], otherwise returns [entire state, actions].
34+
*/
35+
export const useAppConfigContext = useHook;
36+
37+
/**
38+
* Hook to access non-reactive AppConfig state and actions.
39+
* Useful for handlers that don't need to re-render on state changes.
40+
* @returns [getter, actions] The AppConfig state getter and actions.
41+
*/
42+
export const useAppConfigControls = useControls;
43+
44+
/**
45+
* The AppConfig context type.
46+
* Represents the shape of the context including state and actions.
47+
*/
48+
export type AppConfigContextType = InferContextType<typeof provider>;
49+
50+
/**
51+
* The AppConfig actions type.
52+
* Represents the available actions to modify or interact with the state.
53+
*/
54+
export type AppConfigActions = AppConfigContextType['actions'];
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { LayoutMode } from '@app-types/session';
2+
3+
export type VideoSettings = {
4+
allowBackgroundEffects: boolean;
5+
allowCameraControl: boolean;
6+
allowVideoOnJoin: boolean;
7+
defaultResolution:
8+
| '1920x1080'
9+
| '1280x960'
10+
| '1280x720'
11+
| '640x480'
12+
| '640x360'
13+
| '320x240'
14+
| '320x180';
15+
};
16+
17+
export type AudioSettings = {
18+
allowAdvancedNoiseSuppression: boolean;
19+
allowAudioOnJoin: boolean;
20+
allowMicrophoneControl: boolean;
21+
};
22+
23+
export type WaitingRoomSettings = {
24+
allowDeviceSelection: boolean;
25+
};
26+
27+
export type MeetingRoomSettings = {
28+
allowArchiving: boolean;
29+
allowCaptions: boolean;
30+
allowChat: boolean;
31+
allowDeviceSelection: boolean;
32+
allowEmojis: boolean;
33+
allowScreenShare: boolean;
34+
defaultLayoutMode: LayoutMode;
35+
showParticipantList: boolean;
36+
};
37+
38+
export type AppConfig = {
39+
isAppConfigLoaded: boolean;
40+
41+
videoSettings: VideoSettings;
42+
43+
audioSettings: AudioSettings;
44+
45+
waitingRoomSettings: WaitingRoomSettings;
46+
47+
meetingRoomSettings: MeetingRoomSettings;
48+
};
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { AppConfig } from '../AppConfigContext.types';
2+
3+
export type AppConfigActions = import('../AppConfigContext').AppConfigActions;
4+
export type AppConfigContext = import('../AppConfigContext').AppConfigContextType;
5+
6+
/**
7+
* Loads the application configuration from public/config.json file.
8+
* If the fetch fails or the content is invalid, it falls back to the default configuration.
9+
* Finally, it marks the app config as loaded.
10+
* @returns {Function} The thunk action to load the app config.
11+
*/
12+
function loadAppConfig(this: AppConfigActions) {
13+
return async (_: AppConfigContext) => {
14+
try {
15+
const response = await fetch('/config.json', {
16+
cache: 'no-cache',
17+
});
18+
19+
const contentType = response.headers.get('content-type');
20+
if (!contentType || !contentType.includes('application/json')) {
21+
throw new Error('No valid JSON found, using default config');
22+
}
23+
24+
const json: Partial<AppConfig> = await response.json();
25+
26+
this.updateAppConfig(json);
27+
} finally {
28+
this.updateAppConfig({
29+
isAppConfigLoaded: true,
30+
});
31+
}
32+
};
33+
}
34+
35+
export default loadAppConfig;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { DeepPartial } from '@app-types/index';
2+
import type { AppConfig } from '../AppConfigContext.types';
3+
import mergeAppConfigs from '../helpers/mergeAppConfigs';
4+
5+
export type AppConfigActions = import('../AppConfigContext').AppConfigActions;
6+
export type AppConfigContext = import('../AppConfigContext').AppConfigContextType;
7+
8+
/**
9+
* Partially updates the app config state
10+
* @param {DeepPartial<AppConfig>} updates - Partial updates to apply to the app config state.
11+
* @returns {Function} A function that updates the app config state.
12+
*/
13+
function updateAppConfig(this: AppConfigActions, updates: DeepPartial<AppConfig>) {
14+
return ({ setState }: AppConfigContext) => {
15+
setState((previous) => mergeAppConfigs({ previous, updates }));
16+
};
17+
}
18+
19+
export default updateAppConfig;
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { AppConfig } from '../AppConfigContext.types';
2+
3+
const defaultAppConfig: AppConfig = {
4+
isAppConfigLoaded: false,
5+
6+
videoSettings: {
7+
allowBackgroundEffects: true,
8+
allowCameraControl: true,
9+
allowVideoOnJoin: true,
10+
defaultResolution: '1280x720',
11+
},
12+
13+
audioSettings: {
14+
allowAdvancedNoiseSuppression: true,
15+
allowAudioOnJoin: true,
16+
allowMicrophoneControl: true,
17+
},
18+
19+
waitingRoomSettings: {
20+
allowDeviceSelection: true,
21+
},
22+
23+
meetingRoomSettings: {
24+
allowArchiving: true,
25+
allowCaptions: true,
26+
allowChat: true,
27+
allowDeviceSelection: true,
28+
allowEmojis: true,
29+
allowScreenShare: true,
30+
defaultLayoutMode: 'active-speaker',
31+
showParticipantList: true,
32+
},
33+
};
34+
35+
export default defaultAppConfig;

0 commit comments

Comments
 (0)