Add TypeScript types to the redux state (#2394)

Adds some TypeScript types to the global redux state that's in `src/store.ts`. I've only added types for a few parts of the state but already it's caught quite a few bugs in the code, which I've tried to fix in this PR.
This commit is contained in:
Braden MacDonald
2025-08-22 09:00:19 -07:00
committed by GitHub
parent 0c88fd6da9
commit 641fc589a4
22 changed files with 158 additions and 98 deletions

View File

@@ -43,7 +43,7 @@ export const COURSE_CREATOR_STATES = {
granted: 'granted',
denied: 'denied',
disallowedForThisSite: 'disallowed_for_this_site',
};
} as const;
export const DECODED_ROUTES = {
COURSE_UNIT: [

View File

@@ -3,7 +3,7 @@
* @readonly
* @enum {string}
*/
export const RequestStatus = /** @type {const} */ ({
export const RequestStatus = {
IN_PROGRESS: 'in-progress',
SUCCESSFUL: 'successful',
FAILED: 'failed',
@@ -13,7 +13,8 @@ export const RequestStatus = /** @type {const} */ ({
PARTIAL: 'partial',
PARTIAL_FAILURE: 'partial failure',
NOT_FOUND: 'not-found',
});
} as const;
export type RequestStatusType = (typeof RequestStatus)[keyof typeof RequestStatus];
export const RequestFailureStatuses = [
RequestStatus.FAILED,
@@ -25,39 +26,37 @@ export const RequestFailureStatuses = [
/**
* Team sizes enum
* @enum
* @type {{MIN: number, MAX: number, DEFAULT: number}}
*/
export const TeamSizes = /** @type {const} */ ({
export const TeamSizes = {
DEFAULT: 5,
MIN: 1,
MAX: 500,
});
} as const;
/**
* Group types enum
* @enum
* @type {{PRIVATE_MANAGED: string, PUBLIC_MANAGED: string, OPEN: string}}
*/
export const GroupTypes = /** @type {const} */ ({
export const GroupTypes = {
OPEN: 'open',
PUBLIC_MANAGED: 'public_managed',
PRIVATE_MANAGED: 'private_managed',
OPEN_MANAGED: 'open_managed',
});
} as const;
export const DivisionSchemes = /** @type {const} */ ({
export const DivisionSchemes = {
NONE: 'none',
COHORT: 'cohort',
});
} as const;
export const VisibilityTypes = /** @type {const} */ ({
export const VisibilityTypes = {
GATED: 'gated',
LIVE: 'live',
STAFF_ONLY: 'staff_only',
HIDE_AFTER_DUE: 'hide_after_due',
UNSCHEDULED: 'unscheduled',
NEEDS_ATTENTION: 'needs_attention',
});
} as const;
export const TOTAL_LENGTH_KEY = 'total-length';

View File

@@ -1,14 +1,15 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
import { type RequestStatusType } from './constants';
export const LOADED = 'LOADED';
const slice = createSlice({
name: 'courseDetail',
initialState: {
courseId: null,
status: null,
canChangeProvider: null,
courseId: null as string | null,
status: null as RequestStatusType | null,
canChangeProviders: null as null | boolean,
},
reducers: {
updateStatus: (state, { payload }) => {

View File

@@ -20,7 +20,7 @@ export function fetchCourseDetail(courseId) {
canChangeProviders: getAuthenticatedUser().administrator || new Date(courseDetail.start) > new Date(),
}));
} catch (error) {
if (error.response && error.response.status === 404) {
if ((error as any).response && (error as any).response.status === 404) {
dispatch(updateStatus({ courseId, status: RequestStatus.NOT_FOUND }));
} else {
dispatch(updateStatus({ courseId, status: RequestStatus.FAILED }));

View File

@@ -1,29 +1,12 @@
import React from 'react';
import { render, screen, initializeMocks } from '@src/testUtils';
import { selectors } from '@src/editors/data/redux';
import AnswerOption from './AnswerOption';
import * as hooks from './hooks';
import { selectors } from '../../../../../data/redux';
const { problem } = selectors;
const initialState = {
problem: {
problemType: 'multiplechoiceresponse', // No problem type selected by default
// ... other problem-related state
},
app: {
images: {}, // No images loaded by default; use {} if it's an object keyed by IDs, or [] if it's a list
isLibrary: false, // Default to false; not in library context initially
learningContextId: 'course+org+run', // No context ID by default
blockId: 'block-id', // No block ID initially
// ... other app-related state
},
// ... any other top-level state slices
};
export default initialState;
jest.mock('../../../../../data/redux', () => ({
jest.mock('@src/editors/data/redux', () => ({
__esModule: true,
default: jest.fn(),
selectors: {
@@ -44,7 +27,7 @@ jest.mock('../../../../../data/redux', () => ({
},
}));
jest.mock('../../../../../sharedComponents/ExpandableTextArea', () => 'ExpandableTextArea');
jest.mock('@src/editors/sharedComponents/ExpandableTextArea', () => 'ExpandableTextArea');
describe('AnswerOption', () => {
const answerWithOnlyFeedback = {
@@ -86,7 +69,7 @@ describe('AnswerOption', () => {
isFeedbackVisible: false,
toggleFeedback: jest.fn(),
});
initializeMocks({ initialState });
initializeMocks();
});
test('renders correct option with feedback', () => {

View File

@@ -3,9 +3,9 @@ import React from 'react';
import { ProblemTypeKeys } from '@src/editors/data/constants/problem';
import {
render, screen, fireEvent, initializeMocks,
} from '../../../../../../testUtils';
} from '@src/testUtils';
import { actions } from '@src/editors/data/redux';
import AnswersContainer from './AnswersContainer';
import { actions } from '../../../../../data/redux';
const { useAnswerContainer } = require('./hooks');
@@ -15,12 +15,6 @@ const answers = [
{ id: 'a2', isAnswerRange: false },
];
const initialState = {
problem: {
answers,
},
};
// Mock actions module
jest.mock('../../../../../data/redux', () => ({
__esModule: true,
@@ -57,7 +51,7 @@ describe('AnswersContainer', () => {
};
beforeEach(() => {
initializeMocks({ initialState });
initializeMocks();
jest.clearAllMocks();
});

View File

@@ -1,3 +1,4 @@
// @ts-check
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';

View File

@@ -1,4 +0,0 @@
export const getGroupConfigurationsData = (state) => state.groupConfigurations.groupConfigurations;
export const getLoadingStatus = (state) => state.groupConfigurations.loadingStatus;
export const getSavingStatus = (state) => state.groupConfigurations.savingStatus;
export const getErrorMessage = (state) => state.groupConfigurations.errorMessage;

View File

@@ -0,0 +1,8 @@
import { DeprecatedReduxState } from '@src/store';
export const getGroupConfigurationsData = (state: DeprecatedReduxState) => (
state.groupConfigurations.groupConfigurations
);
export const getLoadingStatus = (state: DeprecatedReduxState) => state.groupConfigurations.loadingStatus;
export const getSavingStatus = (state: DeprecatedReduxState) => state.groupConfigurations.savingStatus;
export const getErrorMessage = (state: DeprecatedReduxState) => state.groupConfigurations.errorMessage;

View File

@@ -63,6 +63,7 @@ describe('groupConfigurations slice', () => {
it('should delete an experiment configuration with deleteExperimentConfigurationSuccess', () => {
const initialStateWithExperiment = {
savingStatus: '',
errorMessage: '',
loadingStatus: RequestStatus.IN_PROGRESS,
groupConfigurations: {
allGroupConfigurations: [],

View File

@@ -9,7 +9,7 @@ const slice = createSlice({
savingStatus: '',
errorMessage: '',
loadingStatus: RequestStatus.IN_PROGRESS,
groupConfigurations: {},
groupConfigurations: {} as Record<string, any>,
},
reducers: {
fetchGroupConfigurations: (state, { payload }) => {

View File

@@ -1,10 +1,10 @@
import { RequestStatus } from '../../data/constants';
import { NOTIFICATION_MESSAGES } from '../../constants';
import { RequestStatus } from '@src/data/constants';
import { NOTIFICATION_MESSAGES } from '@src/constants';
import {
hideProcessingNotification,
showProcessingNotification,
} from '../../generic/processing-notification/data/slice';
import { handleResponseErrors } from '../../generic/saving-error-alert';
} from '@src/generic/processing-notification/data/slice';
import { handleResponseErrors } from '@src/generic/saving-error-alert';
import {
getGroupConfigurations,
createContentGroup,
@@ -33,7 +33,7 @@ export function fetchGroupConfigurationsQuery(courseId) {
dispatch(fetchGroupConfigurations({ groupConfigurations }));
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
if (error.response && error.response.status === 403) {
if ((error as any).response && (error as any).response.status === 403) {
dispatch(updateLoadingStatus({ status: RequestStatus.DENIED }));
} else {
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED }));

View File

@@ -1,4 +1,4 @@
import { configureStore } from '@reduxjs/toolkit';
import { configureStore, Reducer } from '@reduxjs/toolkit';
// FIXME: because the 'live' plugin is using Redux, we have to hard-code a reference to it here.
// If this app + the plugin were using React-query, there'd be no issues.
@@ -30,8 +30,43 @@ import { reducer as textbooksReducer } from './textbooks/data/slice';
import { reducer as certificatesReducer } from './certificates/data/slice';
import { reducer as groupConfigurationsReducer } from './group-configurations/data/slice';
export default function initializeStore(preloadedState = undefined) {
return configureStore({
type InferState<ReducerType> = ReducerType extends Reducer<infer T> ? T : never;
/**
* @deprecated The global Redux state for Authoring MFE, excluding editors.
* TODO: refactor each part to use React Context and React Query instead.
*/
export interface DeprecatedReduxState {
courseDetail: InferState<typeof courseDetailReducer>;
customPages: Record<string, any>;
discussions: Record<string, any>;
assets: Record<string, any>;
pagesAndResources: Record<string, any>;
scheduleAndDetails: Record<string, any>;
advancedSettings: Record<string, any>;
studioHome: InferState<typeof studioHomeReducer>;
models: Record<string, any>;
live: Record<string, any>;
courseTeam: Record<string, any>;
courseUpdates: Record<string, any>;
processingNotification: Record<string, any>;
helpUrls: Record<string, any>;
courseExport: Record<string, any>;
courseOptimizer: Record<string, any>;
generic: Record<string, any>;
courseImport: Record<string, any>;
videos: Record<string, any>;
courseOutline: Record<string, any>;
courseUnit: Record<string, any>;
courseChecklist: Record<string, any>;
accessibilityPage: Record<string, any>;
certificates: Record<string, any>;
groupConfigurations: InferState<typeof groupConfigurationsReducer>;
textbooks: Record<string, any>;
}
export default function initializeStore(preloadedState: Partial<DeprecatedReduxState> | undefined = undefined) {
return configureStore<DeprecatedReduxState>({
reducer: {
courseDetail: courseDetailReducer,
customPages: customPagesReducer,

View File

@@ -150,7 +150,7 @@ const StudioHome = () => {
<TabsSection
showNewCourseContainer={showNewCourseContainer}
onClickNewCourse={() => setShowNewCourseContainer(true)}
isShowProcessing={isShowProcessing && !isFiltered}
isShowProcessing={Boolean(isShowProcessing) && !isFiltered}
librariesV1Enabled={librariesV1Enabled}
librariesV2Enabled={librariesV2Enabled}
/>

View File

@@ -68,7 +68,6 @@ export default {
rerunCreatorStatus: true,
showNewLibraryButton: true,
showNewLibraryV2Button: true,
splitStudioHome: false,
studioName: 'Studio',
studioShortName: 'Studio',
studioRequestEmail: 'request@email.com',

View File

@@ -1,4 +0,0 @@
export const getStudioHomeData = state => state.studioHome.studioHomeData;
export const getLoadingStatuses = (state) => state.studioHome.loadingStatuses;
export const getSavingStatuses = (state) => state.studioHome.savingStatuses;
export const getStudioHomeCoursesParams = (state) => state.studioHome.studioHomeCoursesRequestParams;

View File

@@ -0,0 +1,8 @@
import { type DeprecatedReduxState } from '@src/store';
export const getStudioHomeData = (state: DeprecatedReduxState) => state.studioHome.studioHomeData;
export const getLoadingStatuses = (state: DeprecatedReduxState) => state.studioHome.loadingStatuses;
export const getSavingStatuses = (state: DeprecatedReduxState) => state.studioHome.savingStatuses;
export const getStudioHomeCoursesParams = (state: DeprecatedReduxState) => (
state.studioHome.studioHomeCoursesRequestParams
);

View File

@@ -1,9 +1,20 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
import { type COURSE_CREATOR_STATES } from '@src/constants';
import { RequestStatus } from '../../data/constants';
import { RequestStatus, type RequestStatusType } from '@src/data/constants';
export const studioHomeCoursesRequestParamsDefault = {
export interface Params {
currentPage: number;
search?: string;
order?: string;
archivedOnly?: boolean;
activeOnly?: boolean;
isFiltered?: boolean;
cleanFilters?: boolean;
}
export const studioHomeCoursesRequestParamsDefault: Params = {
currentPage: 1,
search: '',
order: 'display_name',
@@ -17,16 +28,42 @@ const slice = createSlice({
name: 'studioHome',
initialState: {
loadingStatuses: {
studioHomeLoadingStatus: RequestStatus.IN_PROGRESS,
courseNotificationLoadingStatus: RequestStatus.IN_PROGRESS,
courseLoadingStatus: RequestStatus.IN_PROGRESS,
libraryLoadingStatus: RequestStatus.IN_PROGRESS,
studioHomeLoadingStatus: RequestStatus.IN_PROGRESS as RequestStatusType,
courseNotificationLoadingStatus: RequestStatus.IN_PROGRESS as RequestStatusType,
courseLoadingStatus: RequestStatus.IN_PROGRESS as RequestStatusType,
libraryLoadingStatus: RequestStatus.IN_PROGRESS as RequestStatusType,
},
savingStatuses: {
courseCreatorSavingStatus: '',
deleteNotificationSavingStatus: '',
courseCreatorSavingStatus: '' as RequestStatusType | '',
deleteNotificationSavingStatus: '' as RequestStatusType | '',
},
studioHomeData: {} as {
allowCourseReruns?: boolean;
allowToCreateNewOrg?: boolean;
canCreateOrganizations?: boolean; // TODO: redundant with 'allowToCreateNewOrg' ???
allowedOrganizations?: string[];
allowedOrganizationsForLibraries?: string[];
courseCreatorStatus?: (typeof COURSE_CREATOR_STATES)[keyof typeof COURSE_CREATOR_STATES];
coursesCount?: any;
courses?: any;
archivedCourses?: any;
inProcessCourseActions?: any;
numPages?: any;
optimizationEnabled?: boolean;
libraries?: any;
librariesV1Enabled?: boolean;
librariesV2Enabled?: boolean;
platformName?: string;
rerunCreatorStatus?: boolean;
requestCourseCreatorUrl?: string;
showNewLibraryButton?: boolean;
showNewLibraryV2Button?: boolean;
studioRequestEmail?: string;
studioName?: string;
studioShortName?: string;
techSupportEmail?: string;
userIsActive?: boolean;
},
studioHomeData: {},
studioHomeCoursesRequestParams: studioHomeCoursesRequestParamsDefault,
},
reducers: {

View File

@@ -1,4 +1,5 @@
import { RequestStatus } from '../../data/constants';
import { type DeprecatedReduxState } from '@src/store';
import { RequestStatus } from '@src/data/constants';
export const courseId = 'course';
@@ -19,10 +20,9 @@ export const initialState = {
currentPage: 1,
},
},
};
} satisfies Partial<DeprecatedReduxState>;
export const generateGetStudioHomeDataApiResponse = () => ({
activeTab: 'courses',
export const generateGetStudioHomeDataApiResponse = (): DeprecatedReduxState['studioHome']['studioHomeData'] => ({
allowCourseReruns: true,
allowedOrganizations: ['edx', 'org'],
archivedCourses: [],
@@ -38,7 +38,6 @@ export const generateGetStudioHomeDataApiResponse = () => ({
rerunCreatorStatus: true,
showNewLibraryButton: true,
showNewLibraryV2Button: true,
splitStudioHome: false,
studioName: 'Studio',
studioShortName: 'Studio',
studioRequestEmail: 'request@email.com',

View File

@@ -5,12 +5,15 @@ import {
screen,
} from '@src/testUtils';
import { COURSE_CREATOR_STATES } from '@src/constants';
import { type DeprecatedReduxState } from '@src/store';
import studioHomeMock from '@src/studio-home/__mocks__/studioHomeMock';
import { initialState } from '../../factories/mockApiResponses';
import CoursesTab from '.';
import { studioHomeCoursesRequestParamsDefault } from '../../data/slice';
type StudioHomeState = DeprecatedReduxState['studioHome'];
const onClickNewCourse = jest.fn();
const isShowProcessing = false;
const isLoading = false;
@@ -19,9 +22,9 @@ const numPages = 1;
const coursesCount = studioHomeMock.courses.length;
const showNewCourseContainer = true;
const renderComponent = (overrideProps = {}, studioHomeState = {}) => {
const renderComponent = (overrideProps = {}, studioHomeState: Partial<StudioHomeState> = {}) => {
// Generate a custom initial state based on studioHomeCoursesRequestParams
const customInitialState: any = { // TODO: remove 'any' once our redux state has proper types
const customInitialState: Partial<DeprecatedReduxState> = {
...initialState,
studioHome: {
...initialState.studioHome,
@@ -118,7 +121,7 @@ describe('<CoursesTab />', () => {
it('should reset filters when in pressed the button to clean them', () => {
const props = { isLoading: false, coursesDataItems: [] };
const customStoreData = { studioHomeCoursesRequestParams: { isFiltered: true } };
const customStoreData = { studioHomeCoursesRequestParams: { currentPage: 1, isFiltered: true } };
const { store } = renderComponent(props, customStoreData);
const cleanFiltersButton = screen.getByRole('button', { name: /clear filters/i });
expect(cleanFiltersButton).toBeInTheDocument();

View File

@@ -11,18 +11,18 @@ import {
} from '@openedx/paragon';
import { Error } from '@openedx/paragon/icons';
import { COURSE_CREATOR_STATES } from '../../../constants';
import { getStudioHomeData, getStudioHomeCoursesParams } from '../../data/selectors';
import { resetStudioHomeCoursesCustomParams, updateStudioHomeCoursesCustomParams } from '../../data/slice';
import { fetchStudioHomeData } from '../../data/thunks';
import CardItem from '../../card-item';
import CollapsibleStateWithAction from '../../collapsible-state-with-action';
import ContactAdministrator from './contact-administrator';
import CoursesFilters from './courses-filters';
import ProcessingCourses from '../../processing-courses';
import { LoadingSpinner } from '../../../generic/Loading';
import AlertMessage from '../../../generic/alert-message';
import { COURSE_CREATOR_STATES } from '@src/constants';
import { getStudioHomeData, getStudioHomeCoursesParams } from '@src/studio-home/data/selectors';
import { resetStudioHomeCoursesCustomParams, updateStudioHomeCoursesCustomParams } from '@src/studio-home/data/slice';
import { fetchStudioHomeData } from '@src/studio-home/data/thunks';
import CardItem from '@src/studio-home/card-item';
import CollapsibleStateWithAction from '@src/studio-home/collapsible-state-with-action';
import ProcessingCourses from '@src/studio-home/processing-courses';
import { LoadingSpinner } from '@src/generic/Loading';
import AlertMessage from '@src/generic/alert-message';
import messages from '../messages';
import CoursesFilters from './courses-filters';
import ContactAdministrator from './contact-administrator';
import './index.scss';
interface Props {
@@ -69,7 +69,7 @@ const CoursesTab: React.FC<Props> = ({
COURSE_CREATOR_STATES.denied,
COURSE_CREATOR_STATES.pending,
COURSE_CREATOR_STATES.unrequested,
].includes(courseCreatorStatus);
].includes(courseCreatorStatus as any);
const locationValue = location.search ?? '';
const handlePageSelected = (page) => {
@@ -191,7 +191,7 @@ const CoursesTab: React.FC<Props> = ({
)}
{showCollapsible && (
<CollapsibleStateWithAction
state={courseCreatorStatus}
state={courseCreatorStatus!}
className="mt-3"
/>
)}

View File

@@ -24,7 +24,7 @@ import {
} from 'react-router-dom';
import { ToastContext, type ToastContextData } from './generic/toast-context';
import initializeReduxStore from './store';
import initializeReduxStore, { type DeprecatedReduxState } from './store';
import { getApiWaffleFlagsUrl } from './data/api';
/** @deprecated Use React Query and/or regular React Context instead of redux */
@@ -157,7 +157,7 @@ const defaultUser = {
*/
export function initializeMocks({ user = defaultUser, initialState = undefined }: {
user?: { userId: number, username: string },
initialState?: Record<string, any>, // TODO: proper typing for our redux state
initialState?: Partial<DeprecatedReduxState>
} = {}) {
initializeMockApp({
authenticatedUser: user,