diff --git a/.storybook/preview.js b/.storybook/preview.js index 9260b91c98..2f611743f2 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -2,14 +2,14 @@ import React from 'react'; import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router'; -import configureStore from '../client/store'; +import { setupStore } from '../client/store'; import '../client/i18n-test'; -import '../client/styles/storybook.css' +import '../client/styles/storybook.css'; import { withThemeProvider, themeToolbarItem } from './decorator-theme'; const initialState = window.__INITIAL_STATE__; -const store = configureStore(initialState); +const store = setupStore(initialState); export const decorators = [ (Story) => ( diff --git a/client/custom.d.ts b/client/custom.d.ts index 216fa99ad4..729f9e03de 100644 --- a/client/custom.d.ts +++ b/client/custom.d.ts @@ -4,5 +4,18 @@ declare module '*.svg' { const ReactComponent: React.FunctionComponent< React.SVGProps & { title?: string } >; + // eslint-disable-next-line import/no-default-export export default ReactComponent; } + +// Extend window for Redux DevTools +interface Window { + __REDUX_DEVTOOLS_EXTENSION__?: () => any; +} + +// Extend NodeModule for hot reloading +interface NodeModule { + hot?: { + accept(path?: string, callback?: () => void): void; + }; +} diff --git a/client/index.integration.test.jsx b/client/index.integration.test.jsx index 66819b1536..f27971b7b9 100644 --- a/client/index.integration.test.jsx +++ b/client/index.integration.test.jsx @@ -4,13 +4,13 @@ import React from 'react'; import Routing from './routes'; import { reduxRender, act, waitFor, screen, within } from './test-utils'; -import configureStore from './store'; +import { setupStore } from './store'; import * as Actions from './modules/User/actions'; import { userResponse } from './testData/testServerResponses'; // setup for the app const initialState = window.__INITIAL_STATE__; -const store = configureStore(initialState); +const store = setupStore(initialState); // need to mock this file or it'll throw ERRCONNECTED jest.mock('./i18n'); diff --git a/client/index.jsx b/client/index.jsx index c4c53c1186..4b79e95f12 100644 --- a/client/index.jsx +++ b/client/index.jsx @@ -5,7 +5,7 @@ import { Router } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import browserHistory from './browserHistory'; -import configureStore from './store'; +import { setupStore } from './store'; import Routing from './routes'; import ThemeProvider from './modules/App/components/ThemeProvider'; import Loader from './modules/App/components/loader'; @@ -19,7 +19,7 @@ require('./images/p5js-square-logo.png'); const initialState = window.__INITIAL_STATE__; -const store = configureStore(initialState); +const store = setupStore(initialState); const DONATE_LOGO_IMAGE_URL = 'https://donorbox.org/images/white_logo.svg'; diff --git a/client/modules/IDE/actions/preferences.js b/client/modules/IDE/actions/preferences.ts similarity index 62% rename from client/modules/IDE/actions/preferences.js rename to client/modules/IDE/actions/preferences.ts index f6e71504ee..ccb5ef6a63 100644 --- a/client/modules/IDE/actions/preferences.js +++ b/client/modules/IDE/actions/preferences.ts @@ -1,8 +1,30 @@ import i18next from 'i18next'; +import { UpdatePreferencesRequestBody } from '../../../../common/types'; import { apiClient } from '../../../utils/apiClient'; import * as ActionTypes from '../../../constants'; +import type { + UpdatePreferencesDispatch, + SetPreferencesTabValue, + SetFontSizeValue, + GetRootState, + SetLineNumbersValue, + SetAutocloseBracketsQuotesValue, + SetAutocompleteHinterValue, + SetAutosaveValue, + SetLinewrapValue, + SetLintWarningValue, + SetTextOutputValue, + SetAllAccessibleOutputValue, + SetAutorefreshValue, + SetGridOutputValue, + SetLanguageValue, + SetThemeValue +} from './preferences.types'; -function updatePreferences(formParams, dispatch) { +function updatePreferences( + formParams: UpdatePreferencesRequestBody, + dispatch: UpdatePreferencesDispatch +) { apiClient .put('/preferences', formParams) .then(() => {}) @@ -14,15 +36,15 @@ function updatePreferences(formParams, dispatch) { }); } -export function setPreferencesTab(value) { +export function setPreferencesTab(value: SetPreferencesTabValue) { return { type: ActionTypes.SET_PREFERENCES_TAB, value }; } -export function setFontSize(value) { - return (dispatch, getState) => { +export function setFontSize(value: SetFontSizeValue) { + return (dispatch: UpdatePreferencesDispatch, getState: GetRootState) => { // eslint-disable-line dispatch({ type: ActionTypes.SET_FONT_SIZE, @@ -40,8 +62,8 @@ export function setFontSize(value) { }; } -export function setLineNumbers(value) { - return (dispatch, getState) => { +export function setLineNumbers(value: SetLineNumbersValue) { + return (dispatch: UpdatePreferencesDispatch, getState: GetRootState) => { dispatch({ type: ActionTypes.SET_LINE_NUMBERS, value @@ -58,8 +80,10 @@ export function setLineNumbers(value) { }; } -export function setAutocloseBracketsQuotes(value) { - return (dispatch, getState) => { +export function setAutocloseBracketsQuotes( + value: SetAutocloseBracketsQuotesValue +) { + return (dispatch: UpdatePreferencesDispatch, getState: GetRootState) => { dispatch({ type: ActionTypes.SET_AUTOCLOSE_BRACKETS_QUOTES, value @@ -76,8 +100,8 @@ export function setAutocloseBracketsQuotes(value) { }; } -export function setAutocompleteHinter(value) { - return (dispatch, getState) => { +export function setAutocompleteHinter(value: SetAutocompleteHinterValue) { + return (dispatch: UpdatePreferencesDispatch, getState: GetRootState) => { dispatch({ type: ActionTypes.SET_AUTOCOMPLETE_HINTER, value @@ -94,8 +118,8 @@ export function setAutocompleteHinter(value) { }; } -export function setAutosave(value) { - return (dispatch, getState) => { +export function setAutosave(value: SetAutosaveValue) { + return (dispatch: UpdatePreferencesDispatch, getState: GetRootState) => { dispatch({ type: ActionTypes.SET_AUTOSAVE, value @@ -112,8 +136,8 @@ export function setAutosave(value) { }; } -export function setLinewrap(value) { - return (dispatch, getState) => { +export function setLinewrap(value: SetLinewrapValue) { + return (dispatch: UpdatePreferencesDispatch, getState: GetRootState) => { dispatch({ type: ActionTypes.SET_LINEWRAP, value @@ -130,8 +154,8 @@ export function setLinewrap(value) { }; } -export function setLintWarning(value) { - return (dispatch, getState) => { +export function setLintWarning(value: SetLintWarningValue) { + return (dispatch: UpdatePreferencesDispatch, getState: GetRootState) => { dispatch({ type: ActionTypes.SET_LINT_WARNING, value @@ -148,8 +172,8 @@ export function setLintWarning(value) { }; } -export function setTextOutput(value) { - return (dispatch, getState) => { +export function setTextOutput(value: SetTextOutputValue) { + return (dispatch: UpdatePreferencesDispatch, getState: GetRootState) => { dispatch({ type: ActionTypes.SET_TEXT_OUTPUT, value @@ -166,8 +190,8 @@ export function setTextOutput(value) { }; } -export function setGridOutput(value) { - return (dispatch, getState) => { +export function setGridOutput(value: SetGridOutputValue) { + return (dispatch: UpdatePreferencesDispatch, getState: GetRootState) => { dispatch({ type: ActionTypes.SET_GRID_OUTPUT, value @@ -184,12 +208,8 @@ export function setGridOutput(value) { }; } -export function setTheme(value) { - // return { - // type: ActionTypes.SET_THEME, - // value - // }; - return (dispatch, getState) => { +export function setTheme(value: SetThemeValue) { + return (dispatch: UpdatePreferencesDispatch, getState: GetRootState) => { dispatch({ type: ActionTypes.SET_THEME, value @@ -206,12 +226,8 @@ export function setTheme(value) { }; } -export function setAutorefresh(value) { - // return { - // type: ActionTypes.SET_AUTOREFRESH, - // value - // }; - return (dispatch, getState) => { +export function setAutorefresh(value: SetAutorefreshValue) { + return (dispatch: UpdatePreferencesDispatch, getState: GetRootState) => { dispatch({ type: ActionTypes.SET_AUTOREFRESH, value @@ -228,15 +244,18 @@ export function setAutorefresh(value) { }; } -export function setAllAccessibleOutput(value) { - return (dispatch) => { +export function setAllAccessibleOutput(value: SetAllAccessibleOutputValue) { + return (dispatch: UpdatePreferencesDispatch, getState: GetRootState) => { dispatch(setTextOutput(value)); dispatch(setGridOutput(value)); }; } -export function setLanguage(value, { persistPreference = true } = {}) { - return (dispatch, getState) => { +export function setLanguage( + value: SetLanguageValue, + { persistPreference = true } = {} +) { + return (dispatch: UpdatePreferencesDispatch, getState: GetRootState) => { i18next.changeLanguage(value); dispatch({ type: ActionTypes.SET_LANGUAGE, diff --git a/client/modules/IDE/actions/preferences.types.ts b/client/modules/IDE/actions/preferences.types.ts new file mode 100644 index 0000000000..e54d28d3cd --- /dev/null +++ b/client/modules/IDE/actions/preferences.types.ts @@ -0,0 +1,116 @@ +import * as ActionTypes from '../../../constants'; +import type { PreferencesState } from '../reducers/preferences'; +import type { RootState } from '../../../reducers'; + +// Value Definitions: +export type SetPreferencesTabValue = PreferencesState['tabIndex']; +export type SetFontSizeValue = PreferencesState['fontSize']; +export type SetLineNumbersValue = PreferencesState['lineNumbers']; +export type SetAutocloseBracketsQuotesValue = PreferencesState['autocloseBracketsQuotes']; +export type SetAutocompleteHinterValue = PreferencesState['autocompleteHinter']; +export type SetAutosaveValue = PreferencesState['autosave']; +export type SetLinewrapValue = PreferencesState['linewrap']; +export type SetLintWarningValue = PreferencesState['lintWarning']; +export type SetTextOutputValue = PreferencesState['textOutput']; +export type SetGridOutputValue = PreferencesState['gridOutput']; +export type SetThemeValue = PreferencesState['theme']; +export type SetAutorefreshValue = PreferencesState['autorefresh']; +export type SetLanguageValue = PreferencesState['language']; +export type SetAllAccessibleOutputValue = + | SetTextOutputValue + | SetGridOutputValue; + +// Action Definitions: +export type OpenPreferencesAction = { + type: typeof ActionTypes.OPEN_PREFERENCES; +}; +export type SetPreferencesAction = { + type: typeof ActionTypes.SET_PREFERENCES; + preferences: PreferencesState; +}; +export type SetErrorAction = { + type: typeof ActionTypes.ERROR; + error: unknown; +}; + +export type SetPreferencesTabAction = { + type: typeof ActionTypes.SET_PREFERENCES_TAB; + value: SetPreferencesTabValue; +}; +export type SetFontSizeAction = { + type: typeof ActionTypes.SET_FONT_SIZE; + value: SetFontSizeValue; +}; +export type SetLineNumbersAction = { + type: typeof ActionTypes.SET_LINE_NUMBERS; + value: SetLineNumbersValue; +}; +export type SetAutocloseBracketsQuotesAction = { + type: typeof ActionTypes.SET_AUTOCLOSE_BRACKETS_QUOTES; + value: SetAutocloseBracketsQuotesValue; +}; +export type SetAutocompleteHinterAction = { + type: typeof ActionTypes.SET_AUTOCOMPLETE_HINTER; + value: SetAutocompleteHinterValue; +}; +export type SetAutosaveAction = { + type: typeof ActionTypes.SET_AUTOSAVE; + value: SetAutosaveValue; +}; +export type SetLinewrapAction = { + type: typeof ActionTypes.SET_LINEWRAP; + value: SetLinewrapValue; +}; +export type SetLintWarningAction = { + type: typeof ActionTypes.SET_LINT_WARNING; + value: SetLintWarningValue; +}; +export type SetTextOutputAction = { + type: typeof ActionTypes.SET_TEXT_OUTPUT; + value: SetTextOutputValue; +}; +export type SetGridOutputAction = { + type: typeof ActionTypes.SET_GRID_OUTPUT; + value: SetGridOutputValue; +}; +export type SetThemeAction = { + type: typeof ActionTypes.SET_THEME; + value: SetThemeValue; +}; +export type SetAutorefreshAction = { + type: typeof ActionTypes.SET_AUTOREFRESH; + value: SetAutorefreshValue; +}; +export type SetLanguageAction = { + type: typeof ActionTypes.SET_LANGUAGE; + language: SetLanguageValue; +}; + +export type PreferencesAction = + | OpenPreferencesAction + | SetPreferencesAction + | SetErrorAction + | SetPreferencesTabAction + | SetFontSizeAction + | SetLineNumbersAction + | SetAutocloseBracketsQuotesAction + | SetAutocompleteHinterAction + | SetAutosaveAction + | SetLinewrapAction + | SetLintWarningAction + | SetTextOutputAction + | SetGridOutputAction + | SetThemeAction + | SetAutorefreshAction + | SetLanguageAction; + +export type UpdatePreferencesDispatch = ( + action: PreferencesAction | PreferencesThunk +) => void; + +export type PreferencesThunk = ( + dispatch: UpdatePreferencesDispatch, + getState: GetRootState +) => void; + +export type GetRootState = () => RootState; diff --git a/client/modules/IDE/reducers/preferences.js b/client/modules/IDE/reducers/preferences.ts similarity index 80% rename from client/modules/IDE/reducers/preferences.js rename to client/modules/IDE/reducers/preferences.ts index d6323c4fd2..f890bb2ccb 100644 --- a/client/modules/IDE/reducers/preferences.js +++ b/client/modules/IDE/reducers/preferences.ts @@ -1,7 +1,17 @@ +import { + UserPreferences as Preferences, + AppThemeOptions +} from '../../../../common/types'; import * as ActionTypes from '../../../constants'; import i18n from '../../../i18n'; +import type { PreferencesAction } from '../actions/preferences.types'; -export const initialState = { +export interface PreferencesState + extends Omit { + tabIndex: number; +} + +export const initialState: PreferencesState = { tabIndex: 0, fontSize: 18, autosave: true, @@ -10,14 +20,17 @@ export const initialState = { lintWarning: false, textOutput: false, gridOutput: false, - theme: 'light', + theme: AppThemeOptions.LIGHT, autorefresh: false, language: i18n.language, autocloseBracketsQuotes: true, autocompleteHinter: false }; -const preferences = (state = initialState, action) => { +export const preferences = ( + state: PreferencesState = initialState, + action: PreferencesAction +) => { switch (action.type) { case ActionTypes.OPEN_PREFERENCES: return Object.assign({}, state, { tabIndex: 0 }); @@ -57,5 +70,3 @@ const preferences = (state = initialState, action) => { return state; } }; - -export default preferences; diff --git a/client/persistState.js b/client/persistState.ts similarity index 68% rename from client/persistState.js rename to client/persistState.ts index 457dba016b..ceff31435e 100644 --- a/client/persistState.js +++ b/client/persistState.ts @@ -1,3 +1,4 @@ +import type { RootState } from './reducers'; /* Saves and loads a snapshot of the Redux store state to session storage @@ -5,7 +6,7 @@ const key = 'p5js-editor'; const storage = sessionStorage; -export const saveState = (state) => { +export const saveState = (state: RootState) => { try { storage.setItem(key, JSON.stringify(state)); } catch (error) { @@ -15,7 +16,9 @@ export const saveState = (state) => { export const loadState = () => { try { - return JSON.parse(storage.getItem(key)); + const stored = storage.getItem(key); + if (!stored) return null; // handle null before parsing + return JSON.parse(stored) as RootState; } catch (error) { console.warn('Failed to retrieve initialize state from storage:', error); return null; diff --git a/client/reducers.js b/client/reducers.ts similarity index 82% rename from client/reducers.js rename to client/reducers.ts index f61d2585d6..2c65e555ca 100644 --- a/client/reducers.js +++ b/client/reducers.ts @@ -1,7 +1,7 @@ import { combineReducers } from 'redux'; import files from './modules/IDE/reducers/files'; import ide from './modules/IDE/reducers/ide'; -import preferences from './modules/IDE/reducers/preferences'; +import { preferences } from './modules/IDE/reducers/preferences'; import project from './modules/IDE/reducers/project'; import editorAccessibility from './modules/IDE/reducers/editorAccessibility'; import user from './modules/User/reducers'; @@ -31,4 +31,8 @@ const rootReducer = combineReducers({ collections }); +// Type for entire redux state +export type RootState = ReturnType; + +// eslint-disable-next-line import/no-default-export export default rootReducer; diff --git a/client/store.js b/client/store.ts similarity index 93% rename from client/store.js rename to client/store.ts index e74248f010..4ff0e566fd 100644 --- a/client/store.js +++ b/client/store.ts @@ -2,6 +2,7 @@ import { configureStore } from '@reduxjs/toolkit'; import listenerMiddleware from './middleware'; import DevTools from './modules/App/components/DevTools'; import rootReducer from './reducers'; +import type { RootState } from './reducers'; import { clearState, loadState } from './persistState'; import { getConfig } from './utils/getConfig'; @@ -15,7 +16,7 @@ export function showReduxDevTools() { ); } -export default function setupStore(initialState) { +export function setupStore(initialState: RootState) { const savedState = loadState(); clearState(); diff --git a/client/storeInstance.js b/client/storeInstance.js deleted file mode 100644 index bd92360c2e..0000000000 --- a/client/storeInstance.js +++ /dev/null @@ -1,6 +0,0 @@ -import setupStore from './store'; - -const initialState = window.__INITIAL_STATE__; -const store = setupStore(initialState); - -export default store; diff --git a/client/test-utils.js b/client/test-utils.js index 9b7c8aab31..4e0aca0e79 100644 --- a/client/test-utils.js +++ b/client/test-utils.js @@ -23,7 +23,7 @@ import { Context as ResponsiveContext } from 'react-responsive'; import i18n from './i18n-test'; import ThemeProvider from './modules/App/components/ThemeProvider'; -import configureStore from './store'; +import { setupStore } from './store'; import theme, { Theme } from './theme'; export const history = createMemoryHistory(); @@ -95,7 +95,7 @@ Providers.propTypes = { */ function reduxRender( ui, - { initialState, store = configureStore(initialState), ...renderOptions } = {} + { initialState, store = setupStore(initialState), ...renderOptions } = {} ) { function Wrapper({ children }) { return ( diff --git a/client/testData/testReduxStore.js b/client/testData/testReduxStore.ts similarity index 96% rename from client/testData/testReduxStore.js rename to client/testData/testReduxStore.ts index f9f5d01925..33223ae950 100644 --- a/client/testData/testReduxStore.js +++ b/client/testData/testReduxStore.ts @@ -1,5 +1,6 @@ import { initialState as initialFilesState } from '../modules/IDE/reducers/files'; import { initialState as initialPrefState } from '../modules/IDE/reducers/preferences'; +import { RootState } from '../reducers'; const mockProjects = [ { @@ -22,7 +23,7 @@ const mockProjects = [ } ]; -const initialTestState = { +const initialTestState: RootState = { ide: { isPlaying: false, isAccessibleOutputPlaying: false, diff --git a/common/types/index.ts b/common/types/index.ts new file mode 100644 index 0000000000..b45c30b26e --- /dev/null +++ b/common/types/index.ts @@ -0,0 +1,33 @@ +// This file declares shared types between the client & server +// Types should be defined in their own portions of the codebase and exported here. + +// SERVER SHARED TYPES: +export type { + SanitisedApiKey, + IApiKey as ApiKey, + ApiKeyResponseOrError, + ApiKeyResponse, + CreateApiKeyRequestBody, + RemoveApiKeyRequestParams +} from '../../server/types/apiKey'; + +export * from '../../server/types/email'; + +export type { Error, GenericResponseBody } from '../../server/types/express'; + +export type { + User, + PublicUser, + PublicUserOrError, + PublicUserOrErrorOrGeneric, + UpdateSettingsRequestBody, + UnlinkThirdPartyResponseBody, + ResetPasswordInitiateRequestBody, + ResetOrUpdatePasswordRequestParams, + UpdatePasswordRequestBody, + CreateUserRequestBody, + DuplicateUserCheckQuery, + VerifyEmailQuery +} from '../../server/types/user'; + +export * from '../../server/types/userPreferences';