From a52d3123365d84418682947ca312de3adf5a10fd Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 16:06:25 +0100 Subject: [PATCH 01/38] apiClient: update extension to ts --- client/utils/{apiClient.js => apiClient.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename client/utils/{apiClient.js => apiClient.ts} (100%) diff --git a/client/utils/apiClient.js b/client/utils/apiClient.ts similarity index 100% rename from client/utils/apiClient.js rename to client/utils/apiClient.ts From 2480bd17b78800be21bd8eddbb97f76fbc8a8563 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 16:08:07 +0100 Subject: [PATCH 02/38] apiClient: add axios instance type --- client/utils/apiClient.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/client/utils/apiClient.ts b/client/utils/apiClient.ts index a8347674a0..fa92c19bce 100644 --- a/client/utils/apiClient.ts +++ b/client/utils/apiClient.ts @@ -1,13 +1,12 @@ -import axios from 'axios'; - +import axios, { AxiosInstance } from 'axios'; import getConfig from './getConfig'; -const ROOT_URL = getConfig('API_URL'); +const ROOT_URL = getConfig('API_URL') ?? ''; /** * Configures an Axios instance with the correct API URL */ -function createClientInstance() { +function createClientInstance(): AxiosInstance { return axios.create({ baseURL: ROOT_URL, withCredentials: true From 87adc726b79a7e613d3fa2947fc6bf4e34b6cee5 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 16:10:32 +0100 Subject: [PATCH 03/38] device: update extension to ts --- client/utils/{device.js => device.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename client/utils/{device.js => device.ts} (100%) diff --git a/client/utils/device.js b/client/utils/device.ts similarity index 100% rename from client/utils/device.js rename to client/utils/device.ts From 9efdd25bee1b3768b8587bffd8b0057e3d028eaf Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 16:13:50 +0100 Subject: [PATCH 04/38] device: add test --- client/utils/device.test.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 client/utils/device.test.ts diff --git a/client/utils/device.test.ts b/client/utils/device.test.ts new file mode 100644 index 0000000000..fb6ad69256 --- /dev/null +++ b/client/utils/device.test.ts @@ -0,0 +1,29 @@ +import { isMac } from './device'; + +describe('isMac', () => { + const originalUserAgent = navigator.userAgent; + + afterEach(() => { + // Restore the original userAgent after each test + Object.defineProperty(navigator, 'userAgent', { + value: originalUserAgent, + configurable: true + }); + }); + + it('returns true when userAgent contains "Mac"', () => { + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', + configurable: true + }); + expect(isMac()).toBe(true); + }); + + it('returns false when userAgent does not contain "Mac"', () => { + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + configurable: true + }); + expect(isMac()).toBe(false); + }); +}); From 462524ca1896dcfc205c50b449fa611867e07309 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 16:28:06 +0100 Subject: [PATCH 05/38] eslint: remove prefer-default-export rule --- .eslintrc | 1 + 1 file changed, 1 insertion(+) diff --git a/.eslintrc b/.eslintrc index ca9c0acf70..56f70e7e16 100644 --- a/.eslintrc +++ b/.eslintrc @@ -26,6 +26,7 @@ "tsx": "never" } ], + "import/prefer-default-export": "off", "comma-dangle": 0, // not sure why airbnb turned this on. gross! "indent": 0, "no-console": 0, From 8010af79592dffabd8b8f6075ea0c5b3215901e8 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 16:28:42 +0100 Subject: [PATCH 06/38] device.ts: add edgecase test and refactor --- client/utils/device.test.ts | 16 ++++++++++++++++ client/utils/device.ts | 11 ++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/client/utils/device.test.ts b/client/utils/device.test.ts index fb6ad69256..b748bb47f1 100644 --- a/client/utils/device.test.ts +++ b/client/utils/device.test.ts @@ -26,4 +26,20 @@ describe('isMac', () => { }); expect(isMac()).toBe(false); }); + + it('returns false when navigator agent is null', () => { + Object.defineProperty(navigator, 'userAgent', { + value: null, + configurable: true + }); + expect(isMac()).toBe(false); + }); + + it('returns false when navigator agent is undefined', () => { + Object.defineProperty(navigator, 'userAgent', { + value: undefined, + configurable: true + }); + expect(isMac()).toBe(false); + }); }); diff --git a/client/utils/device.ts b/client/utils/device.ts index 040b16b7d4..db9ffe1980 100644 --- a/client/utils/device.ts +++ b/client/utils/device.ts @@ -1 +1,10 @@ -export const isMac = () => navigator.userAgent.toLowerCase().indexOf('mac') !== -1; // eslint-disable-line +/** + * Checks if the user's OS is macOS based on the user agent string. + * This is the preferred method over navigator.platform, which is now deprecated: + * - see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/platform + */ +export function isMac(): boolean { + return typeof navigator?.userAgent === 'string' + ? navigator.userAgent.toLowerCase().includes('mac') + : false; +} From cb9ddc58179ba12653028f9d137e2828a26eaf7e Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 16:32:33 +0100 Subject: [PATCH 07/38] metaKey.js: update extention to ts --- client/utils/{metaKey.js => metaKey.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename client/utils/{metaKey.js => metaKey.ts} (100%) diff --git a/client/utils/metaKey.js b/client/utils/metaKey.ts similarity index 100% rename from client/utils/metaKey.js rename to client/utils/metaKey.ts From f7282c24c200995af3aecfe340451cfc568e7a46 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 16:38:22 +0100 Subject: [PATCH 08/38] metaKey: update to use isMac() -- due to navigator.platform being deprecated --- client/utils/device.ts | 4 ++-- client/utils/metaKey.ts | 20 +++++++++++++------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/client/utils/device.ts b/client/utils/device.ts index db9ffe1980..aa0a5903e9 100644 --- a/client/utils/device.ts +++ b/client/utils/device.ts @@ -1,6 +1,6 @@ /** - * Checks if the user's OS is macOS based on the user agent string. - * This is the preferred method over navigator.platform, which is now deprecated: + * Checks if the user's OS is macOS based on the `navigator.userAgent` string. + * This is the preferred method over `navigator.platform`, which is now deprecated: * - see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/platform */ export function isMac(): boolean { diff --git a/client/utils/metaKey.ts b/client/utils/metaKey.ts index cca6d3986b..d4ee293dc3 100644 --- a/client/utils/metaKey.ts +++ b/client/utils/metaKey.ts @@ -1,11 +1,17 @@ -const metaKey = (() => { - if (navigator != null && navigator.platform != null) { - return /^MAC/i.test(navigator.platform) ? 'Cmd' : 'Ctrl'; - } +import { isMac } from './device'; - return 'Ctrl'; -})(); +/** + * A string representing the meta key name used in keyboard shortcuts. + * - `'Cmd'` on macOS + * - `'Ctrl'` on other platforms + */ +const metaKey: string = isMac() ? 'Cmd' : 'Ctrl'; -const metaKeyName = metaKey === 'Cmd' ? '⌘' : 'Ctrl'; +/** + * A user-friendly symbol or label representing the meta key for display purposes. + * - `'⌘'` on macOS + * - `'Ctrl'` on other platforms + */ +const metaKeyName: string = isMac() ? '⌘' : 'Ctrl'; export { metaKey, metaKeyName }; From 411eb4ac3d97654bfd49be862469d331ed50bd62 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 16:43:47 +0100 Subject: [PATCH 09/38] useKeyDownHandler: refactor to use isMac() --- client/common/useKeyDownHandlers.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/common/useKeyDownHandlers.js b/client/common/useKeyDownHandlers.js index dbe2ee06bb..a15ead8283 100644 --- a/client/common/useKeyDownHandlers.js +++ b/client/common/useKeyDownHandlers.js @@ -1,6 +1,7 @@ import { mapKeys } from 'lodash'; import PropTypes from 'prop-types'; import { useCallback, useEffect, useRef } from 'react'; +import { isMac } from '../utils/device'; /** * Attaches keydown handlers to the global document. @@ -30,8 +31,7 @@ export default function useKeyDownHandlers(keyHandlers) { */ const handleEvent = useCallback((e) => { if (!e.key) return; - const isMac = navigator.userAgent.toLowerCase().indexOf('mac') !== -1; - const isCtrl = isMac ? e.metaKey : e.ctrlKey; + const isCtrl = isMac() ? e.metaKey : e.ctrlKey; if (e.shiftKey && isCtrl) { handlers.current[ `ctrl-shift-${ From e7eaa2cbd2be75c360b980ad88c67807038f9532 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 16:48:00 +0100 Subject: [PATCH 10/38] language-utils.js: update ext to ts --- client/utils/{language-utils.js => language-utils.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename client/utils/{language-utils.js => language-utils.ts} (100%) diff --git a/client/utils/language-utils.js b/client/utils/language-utils.ts similarity index 100% rename from client/utils/language-utils.js rename to client/utils/language-utils.ts From 53962db02e5db8cab71c9e6797d6cc7d9263d3f6 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 16:49:34 +0100 Subject: [PATCH 11/38] language-utils.ts: add test --- client/utils/language-utils.test.ts | 75 +++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 client/utils/language-utils.test.ts diff --git a/client/utils/language-utils.test.ts b/client/utils/language-utils.test.ts new file mode 100644 index 0000000000..af7312c807 --- /dev/null +++ b/client/utils/language-utils.test.ts @@ -0,0 +1,75 @@ +import getPreferredLanguage from './language-utils'; + +describe('getPreferredLanguage', () => { + const originalNavigator = global.navigator; + + afterEach(() => { + global.navigator = originalNavigator; + }); + + const mockNavigator = (language: string, languages: string[] = []) => { + global.navigator = { + ...originalNavigator, + language, + languages + }; + }; + + describe('when navigator is undefined', () => { + it('returns the default language', () => { + const oldNavigator = global.navigator; + + // @ts-expect-error TS2790: The operand of a 'delete' operator must be optional + delete global.navigator; + + const result = getPreferredLanguage(['en', 'fr'], 'en'); + expect(result).toBe('en'); + + global.navigator = oldNavigator; + }); + }); + + describe('when navigator.languages has an exact match', () => { + it('returns the first matching language from the navigator.languages list', () => { + mockNavigator('en-US', ['en-GB', 'fr-FR', 'cz-CZ']); + const result = getPreferredLanguage(['fr-FR', 'es-SP', 'en-GB'], 'en'); + expect(result).toBe('en-GB'); + }); + }); + + describe('when navigator.languages has a partial match', () => { + it('returns the base language match', () => { + mockNavigator('en-US', ['en-GB', 'fr-FR', 'cz-CZ', 'es-SP']); + const result = getPreferredLanguage(['de', 'fr'], 'en'); + expect(result).toBe('fr'); + }); + }); + + describe('when only navigator.language is available', () => { + it('returns exact match if found', () => { + mockNavigator('fr-FR', []); + const result = getPreferredLanguage(['fr-FR', 'de'], 'en'); + expect(result).toBe('fr-FR'); + }); + + it('returns partial match if found', () => { + mockNavigator('de-DE', []); + const result = getPreferredLanguage(['de', 'fr'], 'en'); + expect(result).toBe('de'); + }); + + it('returns the default if no match is found', () => { + mockNavigator('es-MX', []); + const result = getPreferredLanguage(['fr', 'de'], 'en'); + expect(result).toBe('en'); + }); + }); + + describe('language normalization', () => { + it('handles casing and whitespace differences', () => { + mockNavigator(' EN-us ', [' EN ', ' FR ']); + const result = getPreferredLanguage(['fr', 'en'], 'de'); + expect(result).toBe('en'); + }); + }); +}); From 3efc1649c53127c8b5797e2eb2ce6a310ca0a913 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 16:51:57 +0100 Subject: [PATCH 12/38] language-utils: add types, passes typecheck --- client/utils/language-utils.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/client/utils/language-utils.ts b/client/utils/language-utils.ts index b173a3137f..5b5921a2e8 100644 --- a/client/utils/language-utils.ts +++ b/client/utils/language-utils.ts @@ -2,12 +2,15 @@ * Utility functions for language detection and handling */ -function getPreferredLanguage(supportedLanguages = [], defaultLanguage = 'en') { +function getPreferredLanguage( + supportedLanguages: string[] = [], + defaultLanguage: string = 'en' +) { if (typeof navigator === 'undefined') { return defaultLanguage; } - const normalizeLanguage = (langCode) => langCode.toLowerCase().trim(); + const normalizeLanguage = (langCode: string) => langCode.toLowerCase().trim(); const normalizedSupported = supportedLanguages.map(normalizeLanguage); From fa6d69f8aeace7a271763a6e7f23d5d84fe64cb4 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 16:57:32 +0100 Subject: [PATCH 13/38] language-utils: refactor for clarity & disable eslint noplusplus rule --- .eslintrc | 1 + client/utils/language-utils.ts | 80 +++++++++++----------------------- 2 files changed, 26 insertions(+), 55 deletions(-) diff --git a/.eslintrc b/.eslintrc index 56f70e7e16..4e80507a8c 100644 --- a/.eslintrc +++ b/.eslintrc @@ -33,6 +33,7 @@ "no-alert": 0, "no-underscore-dangle": 0, "no-useless-catch": 2, + "no-plusplus": "off", "max-len": [1, 120, 2, {"ignoreComments": true, "ignoreTemplateLiterals": true}], "quote-props": [1, "as-needed"], "no-unused-vars": [1, {"vars": "local", "args": "none"}], diff --git a/client/utils/language-utils.ts b/client/utils/language-utils.ts index 5b5921a2e8..d1186d14e5 100644 --- a/client/utils/language-utils.ts +++ b/client/utils/language-utils.ts @@ -5,76 +5,46 @@ function getPreferredLanguage( supportedLanguages: string[] = [], defaultLanguage: string = 'en' -) { +): string | undefined { if (typeof navigator === 'undefined') { return defaultLanguage; } const normalizeLanguage = (langCode: string) => langCode.toLowerCase().trim(); - const normalizedSupported = supportedLanguages.map(normalizeLanguage); - if (navigator.languages && navigator.languages.length) { - const matchedLang = navigator.languages.find((browserLang) => { - const normalizedBrowserLang = normalizeLanguage(browserLang); - - const hasExactMatch = - normalizedSupported.findIndex( - (lang) => lang === normalizedBrowserLang - ) !== -1; - - if (hasExactMatch) { - return true; - } - - const languageOnly = normalizedBrowserLang.split('-')[0]; - const hasLanguageOnlyMatch = - normalizedSupported.findIndex( - (lang) => lang === languageOnly || lang.startsWith(`${languageOnly}-`) - ) !== -1; + /** + * Attempts to find a match in normalizedSupported given a browser-provided language. + * Prioritizes exact match of both language and region (eg. 'en-GB'), falls back to base-language match (eg. 'en'). + */ + function findMatch(inputLang: string): string | undefined { + const normalizedLang = normalizeLanguage(inputLang); - return hasLanguageOnlyMatch; - }); + const exactMatchIndex = normalizedSupported.indexOf(normalizedLang); + if (exactMatchIndex !== -1) return supportedLanguages[exactMatchIndex]; - if (matchedLang) { - const normalizedMatchedLang = normalizeLanguage(matchedLang); - const exactMatchIndex = normalizedSupported.findIndex( - (lang) => lang === normalizedMatchedLang - ); - - if (exactMatchIndex !== -1) { - return supportedLanguages[exactMatchIndex]; - } + const baseLanguage = normalizedLang.split('-')[0]; + const partialMatchIndex = normalizedSupported.findIndex( + (lang) => lang === baseLanguage || lang.startsWith(`${baseLanguage}-`) + ); + if (partialMatchIndex !== -1) return supportedLanguages[partialMatchIndex]; - const languageOnly = normalizedMatchedLang.split('-')[0]; - const languageOnlyMatchIndex = normalizedSupported.findIndex( - (lang) => lang === languageOnly || lang.startsWith(`${languageOnly}-`) - ); + // eslint-disable-next-line consistent-return + return undefined; + } - if (languageOnlyMatchIndex !== -1) { - return supportedLanguages[languageOnlyMatchIndex]; - } + // Try navigator.languages list first + if (Array.isArray(navigator.languages)) { + for (let i = 0; i < navigator.languages.length; i++) { + const match = findMatch(navigator.languages[i]); + if (match) return match; } } + // Fallback to navigator.language if (navigator.language) { - const normalizedNavLang = normalizeLanguage(navigator.language); - const exactMatchIndex = normalizedSupported.findIndex( - (lang) => lang === normalizedNavLang - ); - - if (exactMatchIndex !== -1) { - return supportedLanguages[exactMatchIndex]; - } - - const languageOnly = normalizedNavLang.split('-')[0]; - const languageOnlyMatchIndex = normalizedSupported.findIndex( - (lang) => lang === languageOnly || lang.startsWith(`${languageOnly}-`) - ); - - if (languageOnlyMatchIndex !== -1) { - return supportedLanguages[languageOnlyMatchIndex]; - } + const match = findMatch(navigator.language); + if (match) return match; } return defaultLanguage; From 75b35a395cdaf5f2aa42a0f0787997abfd8b0c80 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 17:00:29 +0100 Subject: [PATCH 14/38] formatDate: update ext to ts --- client/utils/{formatDate.js => formatDate.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename client/utils/{formatDate.js => formatDate.ts} (100%) diff --git a/client/utils/formatDate.js b/client/utils/formatDate.ts similarity index 100% rename from client/utils/formatDate.js rename to client/utils/formatDate.ts From b7fb7a05e8444a698426c0537887956e4a4c8520 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 18:08:19 +0100 Subject: [PATCH 15/38] formatDate.js: add test --- client/utils/formatDate.test.js | 81 +++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 client/utils/formatDate.test.js diff --git a/client/utils/formatDate.test.js b/client/utils/formatDate.test.js new file mode 100644 index 0000000000..2f1a694a7b --- /dev/null +++ b/client/utils/formatDate.test.js @@ -0,0 +1,81 @@ +import i18next from 'i18next'; +import dateUtils from './formatDate'; + +jest.mock('i18next', () => ({ + t: jest.fn() +})); + +jest.mock('../i18n', () => ({ + // eslint-disable-next-line global-require + currentDateLocale: () => require('date-fns/locale').enUS +})); + +describe('dateUtils', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('distanceInWordsToNow', () => { + it('returns "JustNow" for dates within 10 seconds', () => { + const now = new Date(); + const recentDate = new Date(now.getTime() - 5000); + + i18next.t.mockReturnValue('JustNow'); + + const result = dateUtils.distanceInWordsToNow(recentDate); + expect(i18next.t).toHaveBeenCalledWith('formatDate.JustNow'); + expect(result).toBe('JustNow'); + }); + + it('returns "15Seconds" for dates ~15s ago', () => { + const now = new Date(); + const recentDate = new Date(now.getTime() - 15000); + + i18next.t.mockReturnValue('15Seconds'); + + const result = dateUtils.distanceInWordsToNow(recentDate); + expect(i18next.t).toHaveBeenCalledWith('formatDate.15Seconds'); + expect(result).toBe('15Seconds'); + }); + + it('returns formatted distance with "Ago" for dates over 46s', () => { + const now = new Date(); + const oldDate = new Date(now.getTime() - 60000); + + i18next.t.mockImplementation((key, { timeAgo }) => `${key}: ${timeAgo}`); + + const result = dateUtils.distanceInWordsToNow(oldDate); + expect(i18next.t).toHaveBeenCalledWith( + 'formatDate.Ago', + expect.any(Object) + ); + expect(result).toContain('Ago'); + }); + + it('returns empty string for invalid date', () => { + const result = dateUtils.distanceInWordsToNow('not a date'); + expect(result).toBe(''); + }); + }); + + describe('format', () => { + it('formats with time by default', () => { + const date = new Date('2025-07-16T12:34:56Z'); + const formatted = dateUtils.format(date); + + expect(formatted).toMatch(/(\d{1,2}:\d{2})/); // Contains time + }); + + it('formats without time when showTime is false', () => { + const date = new Date('2025-07-16T12:34:56Z'); + const formatted = dateUtils.format(date, { showTime: false }); + + expect(formatted).not.toMatch(/(\d{1,2}:\d{2})/); // Contains time + }); + + it('returns empty string for invalid date', () => { + const formatted = dateUtils.format('invalid date'); + expect(formatted).toBe(''); + }); + }); +}); From 2440d6fc19cf5d3ecf2cc883e9a934a85f847b25 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 18:10:38 +0100 Subject: [PATCH 16/38] formatDate.js: add types and refactor --- client/utils/formatDate.ts | 72 +++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 32 deletions(-) diff --git a/client/utils/formatDate.ts b/client/utils/formatDate.ts index 73379e0211..786759d338 100644 --- a/client/utils/formatDate.ts +++ b/client/utils/formatDate.ts @@ -4,10 +4,14 @@ import format from 'date-fns/format'; import isValid from 'date-fns/isValid'; import parseISO from 'date-fns/parseISO'; import i18next from 'i18next'; - import { currentDateLocale } from '../i18n'; -function parse(maybeDate) { +/** + * Parses input into a valid Date object, or returns null if invalid. + * @param date - Date or string to parse + * @returns Parsed Date or null + */ +function parse(maybeDate: Date | string) { const date = maybeDate instanceof Date ? maybeDate : parseISO(maybeDate); if (isValid(date)) { @@ -18,40 +22,44 @@ function parse(maybeDate) { } export default { - distanceInWordsToNow(date) { + /** + * Returns a human-friendly relative time string from now. + * For very recent dates, returns specific labels (e.g., 'JustNow'). + * @param date - Date or string to compare + * @returns Relative time string or empty string if invalid + */ + distanceInWordsToNow(date: Date | string) { const parsed = parse(date); - if (parsed) { - const now = new Date(); - const diffInMs = differenceInMilliseconds(now, parsed); - - if (Math.abs(diffInMs < 10000)) { - return i18next.t('formatDate.JustNow'); - } else if (diffInMs < 20000) { - return i18next.t('formatDate.15Seconds'); - } else if (diffInMs < 30000) { - return i18next.t('formatDate.25Seconds'); - } else if (diffInMs < 46000) { - return i18next.t('formatDate.35Seconds'); - } - - const timeAgo = formatDistanceToNow(parsed, { - includeSeconds: false, - locale: currentDateLocale() - }); - return i18next.t('formatDate.Ago', { timeAgo }); - } - - return ''; + if (!parsed) return ''; + + const diffInMs = Math.abs(differenceInMilliseconds(new Date(), parsed)); + + if (diffInMs < 10000) return i18next.t('formatDate.JustNow'); + if (diffInMs < 20000) return i18next.t('formatDate.15Seconds'); + if (diffInMs < 30000) return i18next.t('formatDate.25Seconds'); + if (diffInMs < 46000) return i18next.t('formatDate.35Seconds'); + + const timeAgo = formatDistanceToNow(parsed, { + includeSeconds: false, + locale: currentDateLocale() + }); + + return i18next.t('formatDate.Ago', { timeAgo }); }, - format(date, { showTime = true } = {}) { - const parsed = parse(date); - const formatType = showTime ? 'PPpp' : 'PP'; - if (parsed) { - return format(parsed, formatType, { locale: currentDateLocale() }); - } + /** + * Formats a date as a string. Includes time by default. + * @param date - Date or string to format + * @param options - Formatting options + * @param options.showTime - Whether to include time (default true) + * @returns Formatted date string or empty string if invalid + */ + format(date: Date | string, { showTime = true } = {}): string { + const parsed = parse(date); + if (!parsed) return ''; - return ''; + const formatType = showTime ? 'PPpp' : 'PP'; + return format(parsed, formatType, { locale: currentDateLocale() }); } }; From 0a9af470e48c7643c28a421b0a94e5927961dabe Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 18:15:08 +0100 Subject: [PATCH 17/38] consoleUtils: update extension to ts --- client/utils/{consoleUtils.js => consoleUtils.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename client/utils/{consoleUtils.js => consoleUtils.ts} (100%) diff --git a/client/utils/consoleUtils.js b/client/utils/consoleUtils.ts similarity index 100% rename from client/utils/consoleUtils.js rename to client/utils/consoleUtils.ts From 5302f54067de78e5e812132f4e92be2e822edda1 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 18:16:08 +0100 Subject: [PATCH 18/38] consoleUtils: add tests and bare minimum type --- client/utils/consoleUtils.test.ts | 85 +++++++++++++++++++++++++++++++ client/utils/consoleUtils.ts | 2 +- 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 client/utils/consoleUtils.test.ts diff --git a/client/utils/consoleUtils.test.ts b/client/utils/consoleUtils.test.ts new file mode 100644 index 0000000000..94b9352416 --- /dev/null +++ b/client/utils/consoleUtils.test.ts @@ -0,0 +1,85 @@ +import { getAllScriptOffsets, startTag } from './consoleUtils'; + +describe('getAllScriptOffsets', () => { + // not sure how the line offset calculations have been formulated + it('returns an empty array when no scripts are found', () => { + const html = '

No scripts here

'; + expect(getAllScriptOffsets(html)).toEqual([]); + }); + + it('detects a single external script with @fs- path', () => { + const html = ` + + + + + + `; + const result = getAllScriptOffsets(html); + expect(result.every(([offset, _]) => typeof offset === 'number')).toBe( + true + ); + expect(result.map(([_, name]) => name)).toEqual(['my-script']); + }); + + it('detects multiple external scripts with @fs- paths', () => { + const html = ` + + + `; + const result = getAllScriptOffsets(html); + expect(result.every(([offset, _]) => typeof offset === 'number')).toBe( + true + ); + expect(result.map(([_, name]) => name)).toEqual(['one', 'two']); + }); + + it('detects embedded scripts with crossorigin attribute', () => { + const html = ` + + + + + + `; + const result = getAllScriptOffsets(html); + expect(result.every(([offset, _]) => typeof offset === 'number')).toBe( + true + ); + expect(result.map(([_, name]) => name)).toEqual(['index.html']); + }); + + it('detects both @fs- scripts and embedded scripts together, ordering embedded scripts last', () => { + const html = ` + + + + `; + const result = getAllScriptOffsets(html); + expect(result.every(([offset, _]) => typeof offset === 'number')).toBe( + true + ); + expect(result.map(([_, name]) => name)).toEqual([ + 'abc', + 'xyz', + 'index.html' + ]); + }); + + it('handles scripts with varying whitespace and newlines', () => { + const html = ` + + + `; + const result = getAllScriptOffsets(html); + expect(result.every(([offset, _]) => typeof offset === 'number')).toBe( + true + ); + expect(result.map(([_, name]) => name)).toEqual([ + 'some-script', + 'index.html' + ]); + }); +}); diff --git a/client/utils/consoleUtils.ts b/client/utils/consoleUtils.ts index 1f15fc2b6b..743b291094 100644 --- a/client/utils/consoleUtils.ts +++ b/client/utils/consoleUtils.ts @@ -1,6 +1,6 @@ export const startTag = '@fs-'; -export const getAllScriptOffsets = (htmlFile) => { +export const getAllScriptOffsets = (htmlFile: string) => { const offs = []; const hijackConsoleErrorsScriptLength = 2; const embeddedJSStart = 'script crossorigin=""'; From 2638c328792379f39b29a88acbe20fdc76c6adbf Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 18:17:20 +0100 Subject: [PATCH 19/38] consoleUtils.ts: add jsdocs and return type --- client/utils/consoleUtils.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/client/utils/consoleUtils.ts b/client/utils/consoleUtils.ts index 743b291094..3cf156972a 100644 --- a/client/utils/consoleUtils.ts +++ b/client/utils/consoleUtils.ts @@ -1,7 +1,14 @@ export const startTag = '@fs-'; -export const getAllScriptOffsets = (htmlFile: string) => { - const offs = []; +export type ScriptOffset = [number, string]; + +/** + * Extracts line offsets and filenames for JS scripts embedded in an HTML string. + * @param htmlFile - Full HTML file content as a string + * @returns Array of [lineOffset, filename] pairs + */ +export const getAllScriptOffsets = (htmlFile: string): ScriptOffset[] => { + const offs: ScriptOffset[] = []; const hijackConsoleErrorsScriptLength = 2; const embeddedJSStart = 'script crossorigin=""'; let foundJSScript = true; From 723cac313a79d8f86bed61c3c9bdd1f9104e6207 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 18:22:59 +0100 Subject: [PATCH 20/38] dispatcher.js: update extention to ts --no-verify --- client/utils/{dispatcher.js => dispatcher.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename client/utils/{dispatcher.js => dispatcher.ts} (100%) diff --git a/client/utils/dispatcher.js b/client/utils/dispatcher.ts similarity index 100% rename from client/utils/dispatcher.js rename to client/utils/dispatcher.ts From e0cb6c7eaff6b47fcf4ea9b8692787b916c94e15 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 18:24:37 +0100 Subject: [PATCH 21/38] dispatcher: add unit test --- client/utils/dispatcher.test.ts | 96 +++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 client/utils/dispatcher.test.ts diff --git a/client/utils/dispatcher.test.ts b/client/utils/dispatcher.test.ts new file mode 100644 index 0000000000..a7ae10748a --- /dev/null +++ b/client/utils/dispatcher.test.ts @@ -0,0 +1,96 @@ +import { + registerFrame, + dispatchMessage, + listen, + MessageTypes +} from './dispatcher'; + +interface MessagePortLike { + postMessage: jest.Mock; +} + +describe('dispatcher', () => { + let mockFrame: MessagePortLike; + let origin: string; + let removeFrame: () => void; + + beforeEach(() => { + origin = 'https://example.com'; + mockFrame = { postMessage: jest.fn() }; + }); + + afterEach(() => { + if (removeFrame) removeFrame(); + jest.clearAllMocks(); + }); + + describe('registerFrame', () => { + it('registers and removes a frame', () => { + removeFrame = registerFrame(mockFrame, origin); + + // Should send message to this frame + dispatchMessage({ type: MessageTypes.START }); + + expect(mockFrame.postMessage).toHaveBeenCalledWith( + { type: MessageTypes.START }, + origin + ); + + // Remove and test no longer receives messages + removeFrame(); + dispatchMessage({ type: MessageTypes.STOP }); + + expect(mockFrame.postMessage).toHaveBeenCalledTimes(1); // still only one call + }); + }); + + describe('dispatchMessage', () => { + it('does nothing if message is falsy', () => { + expect(() => dispatchMessage(null)).not.toThrow(); + expect(() => dispatchMessage(undefined)).not.toThrow(); + }); + + it('sends a deep-copied message to all registered frames', () => { + const frame1 = { postMessage: jest.fn() }; + const frame2 = { postMessage: jest.fn() }; + + const remove1 = registerFrame(frame1, origin); + const remove2 = registerFrame(frame2, origin); + + const msg = { type: MessageTypes.EXECUTE, payload: { a: 1 } }; + dispatchMessage(msg); + + expect(frame1.postMessage).toHaveBeenCalledWith(msg, origin); + expect(frame2.postMessage).toHaveBeenCalledWith(msg, origin); + + remove1(); + remove2(); + }); + }); + + describe('listen', () => { + it('sets a listener that gets called when message is posted to window', () => { + const callback = jest.fn(); + const removeListener = listen(callback); + + const fakeEvent = new MessageEvent('message', { + data: { type: MessageTypes.SKETCH } + }); + + window.dispatchEvent(fakeEvent); + + expect(callback).toHaveBeenCalledWith({ type: MessageTypes.SKETCH }); + + removeListener(); + + // Dispatch again to verify it's removed + window.dispatchEvent( + new MessageEvent('message', { + data: { type: MessageTypes.STOP } + }) + ); + + expect(callback).toHaveBeenCalledTimes(1); + }); + }); +}); From 0aab54907038ce5ce1224f9aee6f72044b4ba959 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 18:52:00 +0100 Subject: [PATCH 22/38] dispatcher.ts: update with types --- client/utils/dispatcher.test.ts | 12 +++++------ client/utils/dispatcher.ts | 38 ++++++++++++++++++++++----------- 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/client/utils/dispatcher.test.ts b/client/utils/dispatcher.test.ts index a7ae10748a..d933077400 100644 --- a/client/utils/dispatcher.test.ts +++ b/client/utils/dispatcher.test.ts @@ -5,18 +5,16 @@ import { MessageTypes } from './dispatcher'; -interface MessagePortLike { - postMessage: jest.Mock; -} describe('dispatcher', () => { - let mockFrame: MessagePortLike; + let mockFrame: Window; let origin: string; let removeFrame: () => void; beforeEach(() => { origin = 'https://example.com'; - mockFrame = { postMessage: jest.fn() }; + // eslint-disable-next-line prettier/prettier + mockFrame = { postMessage: jest.fn() } as unknown as Window; }); afterEach(() => { @@ -51,8 +49,8 @@ describe('dispatcher', () => { }); it('sends a deep-copied message to all registered frames', () => { - const frame1 = { postMessage: jest.fn() }; - const frame2 = { postMessage: jest.fn() }; + const frame1 = { postMessage: jest.fn() } as unknown as Window; + const frame2 = { postMessage: jest.fn() } as unknown as Window; const remove1 = registerFrame(frame1, origin); const remove2 = registerFrame(frame2, origin); diff --git a/client/utils/dispatcher.ts b/client/utils/dispatcher.ts index 49393121ac..f7203ae37f 100644 --- a/client/utils/dispatcher.ts +++ b/client/utils/dispatcher.ts @@ -1,9 +1,10 @@ // Inspired by // https://github.com/codesandbox/codesandbox-client/blob/master/packages/codesandbox-api/src/dispatcher/index.ts -const frames = {}; +const frames: { + [key: number]: { frame: Window | null, origin: string } +} = {}; let frameIndex = 1; -let listener = null; export const MessageTypes = { START: 'START', @@ -11,33 +12,46 @@ export const MessageTypes = { FILES: 'FILES', SKETCH: 'SKETCH', REGISTER: 'REGISTER', - EXECUTE: 'EXECUTE' + EXECUTE: 'EXECUTE', + // eslint-disable-next-line prettier/prettier +} as const; + +export type MessageType = typeof MessageTypes[keyof typeof MessageTypes]; + +export type Message = { + type: MessageType, + payload?: any }; -export function registerFrame(newFrame, newOrigin) { +let listener: ((message: Message) => void) | null = null; + +export function registerFrame( + newFrame: Window | null, + newOrigin: string | null | undefined +): () => void { const frameId = frameIndex; frameIndex += 1; - frames[frameId] = { frame: newFrame, origin: newOrigin }; + frames[frameId] = { frame: newFrame, origin: newOrigin ?? '' }; return () => { delete frames[frameId]; }; } -function notifyListener(message) { +function notifyListener(message: Message): void { if (listener) listener(message); } -function notifyFrames(message) { +function notifyFrames(message: Message) { const rawMessage = JSON.parse(JSON.stringify(message)); - Object.keys(frames).forEach((frameId) => { - const { frame, origin } = frames[frameId]; + Object.values(frames).forEach((frameObj) => { + const { frame, origin } = frameObj if (frame && frame.postMessage) { frame.postMessage(rawMessage, origin); } }); } -export function dispatchMessage(message) { +export function dispatchMessage(message: Message | undefined | null): void { if (!message) return; // maybe one day i will understand why in the codesandbox @@ -49,14 +63,14 @@ export function dispatchMessage(message) { /** * Call callback to remove listener */ -export function listen(callback) { +export function listen(callback: (message: Message) => void): () => void { listener = callback; return () => { listener = null; }; } -function eventListener(e) { +function eventListener(e: MessageEvent) { const { data } = e; // should also store origin of parent? idk From 60f8a6e70160fddf63b57938edcc1779f04f160c Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 19:14:26 +0100 Subject: [PATCH 23/38] dispatcher.ts: add jsdocs --- client/utils/dispatcher.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/client/utils/dispatcher.ts b/client/utils/dispatcher.ts index f7203ae37f..ba619595dd 100644 --- a/client/utils/dispatcher.ts +++ b/client/utils/dispatcher.ts @@ -6,6 +6,7 @@ const frames: { } = {}; let frameIndex = 1; +/** Codesandbox dispatcher message types */ export const MessageTypes = { START: 'START', STOP: 'STOP', @@ -16,8 +17,14 @@ export const MessageTypes = { // eslint-disable-next-line prettier/prettier } as const; +/** Codesandbox dispatcher message types */ export type MessageType = typeof MessageTypes[keyof typeof MessageTypes]; +/** + * Codesandbox dispatcher message + * - type: 'START', 'STOP' etc + * - payload: additional data for that message type + */ export type Message = { type: MessageType, payload?: any @@ -25,6 +32,12 @@ export type Message = { let listener: ((message: Message) => void) | null = null; +/** + * Registers a frame to receive future dispatched messages. + * @param newFrame - The Window object of the frame to register. + * @param newOrigin - The expected origin to use when posting messages to this frame. If this is nullish, it will be registered as '' + * @returns A cleanup function that unregisters the frame. + */ export function registerFrame( newFrame: Window | null, newOrigin: string | null | undefined @@ -37,10 +50,12 @@ export function registerFrame( }; } +/** Notify the currently registered listener with a `message` */ function notifyListener(message: Message): void { if (listener) listener(message); } +/** Notify each registered frame with a `message` */ function notifyFrames(message: Message) { const rawMessage = JSON.parse(JSON.stringify(message)); Object.values(frames).forEach((frameObj) => { @@ -51,6 +66,10 @@ function notifyFrames(message: Message) { }); } +/** + * Sends a message to all registered frames. + * @param message - The message to dispatch. + */ export function dispatchMessage(message: Message | undefined | null): void { if (!message) return; @@ -70,6 +89,7 @@ export function listen(callback: (message: Message) => void): () => void { }; } +/** Forwards a `MessageEvent` to the registered event listener */ function eventListener(e: MessageEvent) { const { data } = e; From 04ca29a6975640238609b7806d15d15d72ff1ea6 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 19:20:22 +0100 Subject: [PATCH 24/38] remove jsdocs on internal functions to retain git history? --- client/utils/dispatcher.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/client/utils/dispatcher.ts b/client/utils/dispatcher.ts index ba619595dd..7648ee82d3 100644 --- a/client/utils/dispatcher.ts +++ b/client/utils/dispatcher.ts @@ -50,12 +50,10 @@ export function registerFrame( }; } -/** Notify the currently registered listener with a `message` */ function notifyListener(message: Message): void { if (listener) listener(message); } -/** Notify each registered frame with a `message` */ function notifyFrames(message: Message) { const rawMessage = JSON.parse(JSON.stringify(message)); Object.values(frames).forEach((frameObj) => { @@ -89,7 +87,6 @@ export function listen(callback: (message: Message) => void): () => void { }; } -/** Forwards a `MessageEvent` to the registered event listener */ function eventListener(e: MessageEvent) { const { data } = e; From e91000f2a4cb89dba74d3a07c46fee566bbb496f Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 19:25:59 +0100 Subject: [PATCH 25/38] evaluateExpression: update ext to ts --no-verify --- client/utils/{evaluateExpression.js => evaluateExpression.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename client/utils/{evaluateExpression.js => evaluateExpression.ts} (100%) diff --git a/client/utils/evaluateExpression.js b/client/utils/evaluateExpression.ts similarity index 100% rename from client/utils/evaluateExpression.js rename to client/utils/evaluateExpression.ts From 0713173c2024d0657830ab6ff0ad1984b4c45630 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 19:30:43 +0100 Subject: [PATCH 26/38] evaluateExpression: add unit test --- client/utils/evaluateExpression.test.ts | 36 +++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 client/utils/evaluateExpression.test.ts diff --git a/client/utils/evaluateExpression.test.ts b/client/utils/evaluateExpression.test.ts new file mode 100644 index 0000000000..b95ecfbd0a --- /dev/null +++ b/client/utils/evaluateExpression.test.ts @@ -0,0 +1,36 @@ +import evaluateExpression from './evaluateExpression'; + +describe('evaluateExpression', () => { + it('evaluates simple expressions correctly', () => { + const { result, error } = evaluateExpression('2 + 2'); + expect(error).toBe(false); + expect(result).toBe(4); + }); + + it('evaluates expressions with objects', () => { + const { result, error } = evaluateExpression('{ a: 1, b: 2 }.a + 1'); + expect(error).toBe(false); + expect(result).toBe(2); + }); + + it('returns an error object on invalid expression', () => { + const { result, error } = evaluateExpression('foo.bar('); + expect(error).toBe(true); + expect(result).toMatch(/SyntaxError|Unexpected token|Unexpected end/); + }); + + it('evaluates expressions that throw runtime errors', () => { + const { result, error } = evaluateExpression('null.foo'); + expect(error).toBe(true); + expect(result).toMatch(/TypeError|Cannot read property/); + }); + + it('handles expressions that are valid without parentheses', () => { + // e.g., function calls without wrapping + const { result, error } = evaluateExpression('Math.max(3, 5)'); + expect(error).toBe(false); + expect(result).toBe(5); + }); + + // not sure how else this is used in ./previewEntry +}); From 16d84e15552f59ff640fb8355213c5a5c38f929b Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 19:31:30 +0100 Subject: [PATCH 27/38] evaluateExpression: add tests --- client/utils/evaluateExpression.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/client/utils/evaluateExpression.ts b/client/utils/evaluateExpression.ts index 6e277d5d0f..a3e2c5f86e 100644 --- a/client/utils/evaluateExpression.ts +++ b/client/utils/evaluateExpression.ts @@ -1,11 +1,18 @@ -function __makeEvaluateExpression(evalInClosure) { - return (expr) => +type EvalResult = { + result: any, + error: boolean +}; + +type EvalInClosureFn = (expr: string) => EvalResult; + +function __makeEvaluateExpression(evalInClosure: EvalInClosureFn) { + return (expr: string) => evalInClosure(` ${expr}`); } -function evaluateExpression() { - return __makeEvaluateExpression((expr) => { +function evaluateExpression(): (expr: string) => EvalResult { + return __makeEvaluateExpression((expr: string): EvalResult => { let newExpr = expr; let result = null; let error = false; @@ -19,7 +26,11 @@ function evaluateExpression() { } result = (0, eval)(newExpr); // eslint-disable-line } catch (e) { - result = `${e.name}: ${e.message}`; + if (e instanceof Error) { + result = `${e.name}: ${e.message}`; + } else { + result = String(e); + } error = true; } return { result, error }; From 6957124524dd406c153a4ea60e7d08c623515a3d Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 19:46:25 +0100 Subject: [PATCH 28/38] reduxFormUtils: update ext to ts --no-verify --- client/utils/{reduxFormUtils.js => reduxFormUtils.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename client/utils/{reduxFormUtils.js => reduxFormUtils.ts} (100%) diff --git a/client/utils/reduxFormUtils.js b/client/utils/reduxFormUtils.ts similarity index 100% rename from client/utils/reduxFormUtils.js rename to client/utils/reduxFormUtils.ts From 97754db53af107e78e5bb80f599548428f05641c Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 20:21:14 +0100 Subject: [PATCH 29/38] reduxFormUtils: add unit test, no-verify --- client/utils/reduxFormUtils.test.ts | 172 ++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 client/utils/reduxFormUtils.test.ts diff --git a/client/utils/reduxFormUtils.test.ts b/client/utils/reduxFormUtils.test.ts new file mode 100644 index 0000000000..5f524437fe --- /dev/null +++ b/client/utils/reduxFormUtils.test.ts @@ -0,0 +1,172 @@ +import { + validateLogin, + validateSettings, + validateSignup, + validateNewPassword, + validateResetPassword +} from './reduxFormUtils'; + +jest.mock('i18next', () => ({ + t: (key: string) => `translated(${key})` +})); + +describe('reduxFormUtils', () => { + describe('validateLogin', () => { + it('returns errors when both username/email and password are missing', () => { + const result = validateLogin({}); + expect(result).toEqual({ + email: 'translated(ReduxFormUtils.errorEmptyEmailorUserName)', + password: 'translated(ReduxFormUtils.errorEmptyPassword)' + }); + }); + + it('returns no errors for valid login', () => { + const result = validateLogin({ + email: 'user@example.com', + password: 'password123' + }); + expect(result).toEqual({}); + }); + }); + + describe('validateSettings', () => { + it('returns errors for invalid username and email', () => { + const result = validateSettings({ + username: '!!!', + email: 'bademail', + currentPassword: '123456', + newPassword: '' + }); + expect(result).toMatchObject({ + username: 'translated(ReduxFormUtils.errorValidUsername)', + email: 'translated(ReduxFormUtils.errorInvalidEmail)', + newPassword: 'translated(ReduxFormUtils.errorNewPassword)' + }); + }); + + it('errors if newPassword is too short or same as currentPassword', () => { + const result = validateSettings({ + username: 'gooduser', + email: 'user@example.com', + currentPassword: 'short', + newPassword: 'short' + }); + expect(result.newPassword).toBe( + 'translated(ReduxFormUtils.errorNewPasswordRepeat)' + ); + }); + + it('errors if newPassword is too short', () => { + const result = validateSettings({ + username: 'gooduser', + email: 'user@example.com', + currentPassword: 'long enough', + newPassword: 'short' + }); + expect(result.newPassword).toBe( + 'translated(ReduxFormUtils.errorShortPassword)' + ); + }); + + it('errors if newPassword equals currentPassword', () => { + const result = validateSettings({ + username: 'user', + email: 'user@example.com', + currentPassword: 'abc123', + newPassword: 'abc123' + }); + expect(result.newPassword).toBe( + 'translated(ReduxFormUtils.errorNewPasswordRepeat)' + ); + }); + + it('returns no errors for valid data', () => { + const result = validateSettings({ + username: 'validuser', + email: 'user@example.com', + currentPassword: 'oldpass', + newPassword: 'newpass123' + }); + expect(result).toEqual({}); + }); + }); + + describe('validateSignup', () => { + it('returns errors for missing fields', () => { + const result = validateSignup({}); + expect(result).toMatchObject({ + username: 'translated(ReduxFormUtils.errorEmptyUsername)', + email: 'translated(ReduxFormUtils.errorEmptyEmail)', + password: 'translated(ReduxFormUtils.errorEmptyPassword)', + confirmPassword: 'translated(ReduxFormUtils.errorConfirmPassword)' + }); + }); + + it('returns error if password and confirmPassword don’t match', () => { + const result = validateSignup({ + username: 'newuser', + email: 'user@example.com', + password: 'pass123', + confirmPassword: 'different' + }); + expect(result.confirmPassword).toBe( + 'translated(ReduxFormUtils.errorPasswordMismatch)' + ); + }); + + it('returns no errors for valid signup', () => { + const result = validateSignup({ + username: 'user', + email: 'user@example.com', + password: 'securepass', + confirmPassword: 'securepass' + }); + expect(result).toEqual({}); + }); + }); + + describe('validateNewPassword', () => { + it('requires both password and confirmPassword', () => { + const result = validateNewPassword({}); + expect(result).toMatchObject({ + password: 'translated(ReduxFormUtils.errorEmptyPassword)', + confirmPassword: 'translated(ReduxFormUtils.errorConfirmPassword)' + }); + }); + + it('returns error if passwords do not match', () => { + const result = validateNewPassword({ + password: 'abc123', + confirmPassword: 'xyz456' + }); + expect(result.confirmPassword).toBe( + 'translated(ReduxFormUtils.errorPasswordMismatch)' + ); + }); + + it('returns no errors if passwords match and are long enough', () => { + const result = validateNewPassword({ + password: 'goodpass123', + confirmPassword: 'goodpass123' + }); + expect(result).toEqual({}); + }); + }); + + describe('validateResetPassword', () => { + it('returns error for missing email', () => { + const result = validateResetPassword({}); + expect(result.email).toBe('translated(ReduxFormUtils.errorEmptyEmail)'); + }); + + it('returns error for invalid email', () => { + const result = validateResetPassword({ email: 'bademail' }); + expect(result.email).toBe('translated(ReduxFormUtils.errorInvalidEmail)'); + }); + + it('returns no errors for valid email', () => { + const result = validateResetPassword({ email: 'test@example.com' }); + expect(result).toEqual({}); + }); + }); +}); From ed0c6f0b15fe09f0785d606b7923214c8570d8a1 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 21:32:42 +0100 Subject: [PATCH 30/38] reduxFormUtils: delete unused dom-onlyprops function --- client/utils/reduxFormUtils.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/client/utils/reduxFormUtils.ts b/client/utils/reduxFormUtils.ts index 085e37cae7..00014d18da 100644 --- a/client/utils/reduxFormUtils.ts +++ b/client/utils/reduxFormUtils.ts @@ -1,21 +1,5 @@ /* eslint-disable */ import i18n from 'i18next'; -export const domOnlyProps = ({ - initialValue, - autofill, - onUpdate, - valid, - invalid, - dirty, - pristine, - active, - touched, - visited, - autofilled, - error, - ...domProps -}) => domProps; -/* eslint-enable */ /* eslint-disable */ const EMAIL_REGEX = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/i; From 0b223df397411869703aee0d2941542812769985 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 21:33:15 +0100 Subject: [PATCH 31/38] reduxFormUtils: add types and jsdocs --- client/utils/reduxFormUtils.ts | 136 +++++++++++++++++++++++---------- 1 file changed, 95 insertions(+), 41 deletions(-) diff --git a/client/utils/reduxFormUtils.ts b/client/utils/reduxFormUtils.ts index 00014d18da..c87baa8b58 100644 --- a/client/utils/reduxFormUtils.ts +++ b/client/utils/reduxFormUtils.ts @@ -1,33 +1,83 @@ -/* eslint-disable */ import i18n from 'i18next'; /* eslint-disable */ const EMAIL_REGEX = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/i; +const USERNAME_REGEX = /^[a-zA-Z0-9._-]{1,20}$/ /* eslint-enable */ -function validateNameEmail(formProps, errors) { +type Email = { email: string }; +type Username = { username: string }; +type Password = { password: string }; +type ConfirmPassword = { confirmPassword: string }; +type CurrentPassword = { currentPassword: string }; +type NewPassword = { newPassword: string }; + +type UsernameAndEmail = Username & Email; +type PasswordsConfirm = Password & ConfirmPassword; + +/** Validation errors for site forms */ +export type FormErrors = Partial< + Email & Username & Password & ConfirmPassword & CurrentPassword & NewPassword +>; + +// === Internal helper functions: ===== + +/** Processes form & mutates errors to add any `username` & `email` errors */ +function validateUsernameEmail( + formProps: Partial, + errors: Partial +) { if (!formProps.username) { errors.username = i18n.t('ReduxFormUtils.errorEmptyUsername'); - } else if (!formProps.username.match(/^.{1,20}$/)) { + } else if (formProps.username.length > 20) { errors.username = i18n.t('ReduxFormUtils.errorLongUsername'); - } else if (!formProps.username.match(/^[a-zA-Z0-9._-]{1,20}$/)) { + } else if (!formProps.username.match(USERNAME_REGEX)) { errors.username = i18n.t('ReduxFormUtils.errorValidUsername'); } if (!formProps.email) { errors.email = i18n.t('ReduxFormUtils.errorEmptyEmail'); - } else if ( - // eslint-disable-next-line max-len - !formProps.email.match(EMAIL_REGEX) - ) { + } else if (!formProps.email.match(EMAIL_REGEX)) { errors.email = i18n.t('ReduxFormUtils.errorInvalidEmail'); } } -export function validateSettings(formProps) { - const errors = {}; +/** Processes form & mutates errors to add any `password` and `confirmPassword` errors */ +function validatePasswords( + formProps: Partial, + errors: Partial +) { + if (!formProps.password) { + errors.password = i18n.t('ReduxFormUtils.errorEmptyPassword'); + } + if (formProps.password && formProps.password.length < 6) { + errors.password = i18n.t('ReduxFormUtils.errorShortPassword'); + } + if (!formProps.confirmPassword) { + errors.confirmPassword = i18n.t('ReduxFormUtils.errorConfirmPassword'); + } + + if ( + formProps.password !== formProps.confirmPassword && + formProps.confirmPassword + ) { + errors.confirmPassword = i18n.t('ReduxFormUtils.errorPasswordMismatch'); + } +} + +// ====== PUBLIC: ======== + +// Account Form: +export type AccountForm = UsernameAndEmail & CurrentPassword & NewPassword; +export type AccountFormErrors = Partial; - validateNameEmail(formProps, errors); +/** Validation for the Account Form */ +export function validateSettings( + formProps: Partial +): AccountFormErrors { + const errors: AccountFormErrors = {}; + + validateUsernameEmail(formProps, errors); if (formProps.currentPassword && !formProps.newPassword) { errors.newPassword = i18n.t('ReduxFormUtils.errorNewPassword'); @@ -44,8 +94,13 @@ export function validateSettings(formProps) { return errors; } -export function validateLogin(formProps) { - const errors = {}; +// Login form: +export type LoginForm = UsernameAndEmail & Password; +export type LoginFormErrors = Partial; + +/** Validation for the Login Form */ +export function validateLogin(formProps: Partial): LoginFormErrors { + const errors: LoginFormErrors = {}; if (!formProps.email && !formProps.username) { errors.email = i18n.t('ReduxFormUtils.errorEmptyEmailorUserName'); } @@ -55,47 +110,46 @@ export function validateLogin(formProps) { return errors; } -function validatePasswords(formProps, errors) { - if (!formProps.password) { - errors.password = i18n.t('ReduxFormUtils.errorEmptyPassword'); - } - if (formProps.password && formProps.password.length < 6) { - errors.password = i18n.t('ReduxFormUtils.errorShortPassword'); - } - if (!formProps.confirmPassword) { - errors.confirmPassword = i18n.t('ReduxFormUtils.errorConfirmPassword'); - } - - if ( - formProps.password !== formProps.confirmPassword && - formProps.confirmPassword - ) { - errors.confirmPassword = i18n.t('ReduxFormUtils.errorPasswordMismatch'); - } -} +export type NewPasswordForm = PasswordsConfirm; +export type NewPasswordFormErrors = Partial; -export function validateNewPassword(formProps) { +/** Validation for the New Password Form */ +export function validateNewPassword( + formProps: Partial +): NewPasswordFormErrors { const errors = {}; validatePasswords(formProps, errors); return errors; } -export function validateSignup(formProps) { - const errors = {}; +// Signup Form: +export type SignupForm = UsernameAndEmail & PasswordsConfirm; +export type SignupFormErrors = Partial; + +/** Validation for the Signup Form */ +export function validateSignup( + formProps: Partial +): SignupFormErrors { + const errors: SignupFormErrors = {}; - validateNameEmail(formProps, errors); + validateUsernameEmail(formProps, errors); validatePasswords(formProps, errors); return errors; } -export function validateResetPassword(formProps) { - const errors = {}; + +// Reset Password Form: +export type ResetPasswordForm = Email; +export type ResetPasswordFormErrors = Partial; + +/** Validation for the Reset Password Form */ +export function validateResetPassword( + formProps: Partial +): ResetPasswordFormErrors { + const errors: ResetPasswordFormErrors = {}; if (!formProps.email) { errors.email = i18n.t('ReduxFormUtils.errorEmptyEmail'); - } else if ( - // eslint-disable-next-line max-len - !formProps.email.match(EMAIL_REGEX) - ) { + } else if (!formProps.email.match(EMAIL_REGEX)) { errors.email = i18n.t('ReduxFormUtils.errorInvalidEmail'); } return errors; From a52372d7b71d8f85d75a5afa97888936be8b530e Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 21:36:33 +0100 Subject: [PATCH 32/38] getConfig.js: change to ts, no-verify --- client/utils/{getConfig.js => getConfig.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename client/utils/{getConfig.js => getConfig.ts} (100%) diff --git a/client/utils/getConfig.js b/client/utils/getConfig.ts similarity index 100% rename from client/utils/getConfig.js rename to client/utils/getConfig.ts From de6fc1083856203f32ab3ac419b63f5ce44a8621 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 22:06:41 +0100 Subject: [PATCH 33/38] getConfig.ts: remove circular logic for env check and add types --- client/utils/getConfig.ts | 47 ++++++++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/client/utils/getConfig.ts b/client/utils/getConfig.ts index 594af535f7..ad7f57de43 100644 --- a/client/utils/getConfig.ts +++ b/client/utils/getConfig.ts @@ -1,22 +1,47 @@ -function isTestEnvironment() { - // eslint-disable-next-line no-use-before-define - return getConfig('NODE_ENV', { warn: false }) === 'test'; +/** + * Internal function to retrieve env vars, with no error handling. + * @returns String value of env variable or undefined if not found. + */ +function _getConfig(key: string): string | undefined { + const env: Record = + (typeof global !== 'undefined' ? global : window)?.process?.env || {}; + + return env[key]; } +type GetConfigOptions = { + warn?: boolean, + nullishString?: boolean +}; + /** - * Returns config item from environment + * Returns a string config value from environment variables. + * Logs a warning if the value is missing and `warn` is not explicitly disabled. + * + * @param key - The environment variable key to fetch. + * @param options - Optional settings: + * - `warn`: whether to warn if the value is missing (default `true` unless in test env). + * - `nullishString`: if true, returns `''` instead of `undefined` when missing. + * @returns String value of the env var, or `''` or `undefined` if missing. */ -function getConfig(key, options = { warn: !isTestEnvironment() }) { - if (key == null) { +function getConfig( + key: string, + options: GetConfigOptions = {} +): string | undefined { + if (!key) { throw new Error('"key" must be provided to getConfig()'); } + const isTestEnvironment = _getConfig('NODE_ENV') === 'test'; - const env = - (typeof global !== 'undefined' ? global : window)?.process?.env || {}; - const value = env[key]; + const { warn = !isTestEnvironment, nullishString = false } = options; + + const value = _getConfig(key); - if (value == null && options?.warn !== false) { - console.warn(`getConfig("${key}") returned null`); + if (value == null) { + if (warn) { + console.warn(`getConfig("${key}") returned null or undefined`); + } + return nullishString ? '' : undefined; } return value; From 8061e090432b1ae83a1b1bdac6c1cd9dcc342d14 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 22:10:48 +0100 Subject: [PATCH 34/38] migrate getConfig.test to ts --- client/utils/{getConfig.test.js => getConfig.test.ts} | 4 ---- 1 file changed, 4 deletions(-) rename client/utils/{getConfig.test.js => getConfig.test.ts} (84%) diff --git a/client/utils/getConfig.test.js b/client/utils/getConfig.test.ts similarity index 84% rename from client/utils/getConfig.test.js rename to client/utils/getConfig.test.ts index 05659caeda..4738415589 100644 --- a/client/utils/getConfig.test.js +++ b/client/utils/getConfig.test.ts @@ -6,10 +6,6 @@ describe('utils/getConfig()', () => { delete window.process.env.CONFIG_TEST_KEY_NAME; }); - it('throws if key is not defined', () => { - expect(() => getConfig(/* key is missing */)).toThrow(/must be provided/); - }); - it('fetches from global.process', () => { global.process.env.CONFIG_TEST_KEY_NAME = 'editor.p5js.org'; From 058d155c40c6b04da1cfc54606874e4f655c3f74 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 22:12:47 +0100 Subject: [PATCH 35/38] update tests for get config after typing --- client/utils/getConfig.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/client/utils/getConfig.test.ts b/client/utils/getConfig.test.ts index 4738415589..52e3da51db 100644 --- a/client/utils/getConfig.test.ts +++ b/client/utils/getConfig.test.ts @@ -6,6 +6,14 @@ describe('utils/getConfig()', () => { delete window.process.env.CONFIG_TEST_KEY_NAME; }); + // check for key + it('throws if key is empty string', () => { + expect(() => getConfig(/* key is empty string */ '')).toThrow( + /must be provided/ + ); + }); + + // check returns happy path it('fetches from global.process', () => { global.process.env.CONFIG_TEST_KEY_NAME = 'editor.p5js.org'; @@ -18,7 +26,19 @@ describe('utils/getConfig()', () => { expect(getConfig('CONFIG_TEST_KEY_NAME')).toBe('editor.p5js.org'); }); + // check returns unhappy path it('warns but does not throw if no value found', () => { expect(() => getConfig('CONFIG_TEST_KEY_NAME')).not.toThrow(); }); + + it('returns the expected nullish value when no value is found', () => { + const result = getConfig('CONFIG_TEST_KEY_NAME'); + expect(result).toBe(undefined); + expect(!result).toBe(true); + expect(`${result}`).toBe('undefined'); + }); + it('can be set to return an empty string as the nullish value', () => { + const result = getConfig('CONFIG_TEST_KEY_NAME', { nullishString: true }); + expect(`${result}`).toBe(''); + }); }); From 34f07358e1a3660ee62b1a4f74f92380acf38e18 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 22:20:16 +0100 Subject: [PATCH 36/38] add parseStringToType util --- client/utils/parseStringToType.test.ts | 48 ++++++++++++++++++++++++++ client/utils/parseStringToType.ts | 20 +++++++++++ 2 files changed, 68 insertions(+) create mode 100644 client/utils/parseStringToType.test.ts create mode 100644 client/utils/parseStringToType.ts diff --git a/client/utils/parseStringToType.test.ts b/client/utils/parseStringToType.test.ts new file mode 100644 index 0000000000..e12168125f --- /dev/null +++ b/client/utils/parseStringToType.test.ts @@ -0,0 +1,48 @@ +import { parseNumber, parseBoolean } from './parseStringToType'; + +describe('parseNumber', () => { + beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('parses a valid number string to number', () => { + expect(parseNumber('42')).toBe(42); + expect(parseNumber('3.14')).toBeCloseTo(3.14); + expect(parseNumber('0')).toBe(0); + }); + + it('returns undefined and warns if parsing fails', () => { + const warnSpy = jest.spyOn(console, 'warn'); + const input = 'abc'; + expect(parseNumber(input)).toBe(undefined); + expect(warnSpy).toHaveBeenCalledWith(`expected a number, got ${input}`); + }); +}); + +describe('parseBoolean', () => { + beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('parses "true" and "false" strings (case-insensitive) to booleans', () => { + expect(parseBoolean('true')).toBe(true); + expect(parseBoolean('TRUE')).toBe(true); + expect(parseBoolean('false')).toBe(false); + expect(parseBoolean('FALSE')).toBe(false); + }); + + it('returns undefined and warns if parsing fails and warn=true', () => { + const warnSpy = jest.spyOn(console, 'warn'); + const input = 'yes'; + expect(parseBoolean(input)).toBe(undefined); + expect(warnSpy).toHaveBeenCalledWith(`expected a boolean, got ${input}`); + }); +}); diff --git a/client/utils/parseStringToType.ts b/client/utils/parseStringToType.ts new file mode 100644 index 0000000000..e5191d9da7 --- /dev/null +++ b/client/utils/parseStringToType.ts @@ -0,0 +1,20 @@ +/* eslint-disable consistent-return */ +/** Parses a string into a number or undefined if parsing fails. */ +export function parseNumber(str: string): number | undefined { + const num = Number(str); + if (Number.isNaN(num)) { + console.warn(`expected a number, got ${str}`); + return undefined; + } + return num; +} + +/** Parses a case-insensitive string into a boolean or undefined if parsing fails. */ +export function parseBoolean(str: string): boolean | undefined { + const lower = str.toLowerCase(); + if (lower === 'true') return true; + if (lower === 'false') return false; + + console.warn(`expected a boolean, got ${str}`); + return undefined; +} From c6c7d40dd6cb1c29b3a98d30d9d2b1a45c489351 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 23:04:56 +0100 Subject: [PATCH 37/38] update parsers and update useages of getConfig --- client/modules/IDE/actions/project.js | 4 +- client/modules/IDE/actions/uploader.js | 8 ++-- client/modules/IDE/components/AssetSize.jsx | 4 +- client/modules/IDE/components/Header/Nav.jsx | 19 ++++++---- .../IDE/components/SketchListRowBase.jsx | 2 +- .../IDE/components/UploadFileModal.jsx | 3 +- client/modules/IDE/selectors/users.js | 3 +- client/modules/Preview/EmbedFrame.jsx | 10 +++-- client/utils/apiClient.ts | 2 +- client/utils/parseStringToType.test.ts | 38 ++++++++++++++++--- client/utils/parseStringToType.ts | 37 +++++++++++++++--- 11 files changed, 98 insertions(+), 32 deletions(-) diff --git a/client/modules/IDE/actions/project.js b/client/modules/IDE/actions/project.js index 1d8943336b..1818b5f015 100644 --- a/client/modules/IDE/actions/project.js +++ b/client/modules/IDE/actions/project.js @@ -15,7 +15,7 @@ import { } from './ide'; import { clearState, saveState } from '../../../persistState'; -const ROOT_URL = getConfig('API_URL'); +const ROOT_URL = getConfig('API_URL', { nullishString: true }); const S3_BUCKET_URL_BASE = getConfig('S3_BUCKET_URL_BASE'); const S3_BUCKET = getConfig('S3_BUCKET'); @@ -307,6 +307,8 @@ export function cloneProject(project) { (file, callback) => { if ( file.url && + S3_BUCKET && + S3_BUCKET_URL_BASE && (file.url.includes(S3_BUCKET_URL_BASE) || file.url.includes(S3_BUCKET)) ) { diff --git a/client/modules/IDE/actions/uploader.js b/client/modules/IDE/actions/uploader.js index e2831df75f..b47cf6760d 100644 --- a/client/modules/IDE/actions/uploader.js +++ b/client/modules/IDE/actions/uploader.js @@ -5,9 +5,11 @@ import { handleCreateFile } from './files'; export const s3BucketHttps = getConfig('S3_BUCKET_URL_BASE') || - `https://s3-${getConfig('AWS_REGION')}.amazonaws.com/${getConfig( - 'S3_BUCKET' - )}/`; + `https://s3-${getConfig('AWS_REGION', { + nullishString: true + })}.amazonaws.com/${getConfig('S3_BUCKET', { + nullishString: true + })}/`; const MAX_LOCAL_FILE_SIZE = 80000; // bytes, aka 80 KB function isS3Upload(file) { diff --git a/client/modules/IDE/components/AssetSize.jsx b/client/modules/IDE/components/AssetSize.jsx index 853e9d3ea4..c80425382c 100644 --- a/client/modules/IDE/components/AssetSize.jsx +++ b/client/modules/IDE/components/AssetSize.jsx @@ -1,10 +1,10 @@ import React from 'react'; import { useSelector } from 'react-redux'; import prettyBytes from 'pretty-bytes'; - import getConfig from '../../../utils/getConfig'; +import { parseNumber } from '../../../utils/parseStringToType'; -const limit = getConfig('UPLOAD_LIMIT') || 250000000; +const limit = parseNumber(getConfig('UPLOAD_LIMIT')) || 250000000; const MAX_SIZE_B = limit; const formatPercent = (percent) => { diff --git a/client/modules/IDE/components/Header/Nav.jsx b/client/modules/IDE/components/Header/Nav.jsx index 151e2d8212..05941ac2c7 100644 --- a/client/modules/IDE/components/Header/Nav.jsx +++ b/client/modules/IDE/components/Header/Nav.jsx @@ -8,6 +8,7 @@ import MenubarSubmenu from '../../../../components/Menubar/MenubarSubmenu'; import MenubarItem from '../../../../components/Menubar/MenubarItem'; import { availableLanguages, languageKeyToLabel } from '../../../../i18n'; import getConfig from '../../../../utils/getConfig'; +import { parseBoolean } from '../../../../utils/parseStringToType'; import { showToast } from '../../actions/toast'; import { setLanguage } from '../../actions/preferences'; import Menubar from '../../../../components/Menubar/Menubar'; @@ -80,8 +81,14 @@ LeftLayout.defaultProps = { layout: 'project' }; +const isLoginEnabled = parseBoolean(getConfig('LOGIN_ENABLED'), true); +const isUiCollectionsEnabled = parseBoolean( + getConfig('UI_COLLECTIONS_ENABLED'), + true +); +const isExamplesEnabled = parseBoolean(getConfig('EXAMPLES_ENABLED'), true); + const UserMenu = () => { - const isLoginEnabled = getConfig('LOGIN_ENABLED'); const isAuthenticated = useSelector(getAuthenticated); if (isLoginEnabled && isAuthenticated) { @@ -177,7 +184,7 @@ const ProjectMenu = () => { id="file-save" isDisabled={ !user.authenticated || - !getConfig('LOGIN_ENABLED') || + !isLoginEnabled || (project?.owner && !isUserOwner) } onClick={() => saveSketch(cmRef.current)} @@ -216,9 +223,7 @@ const ProjectMenu = () => { @@ -226,7 +231,7 @@ const ProjectMenu = () => { {t('Nav.File.Examples')} @@ -370,7 +375,7 @@ const AuthenticatedUserMenu = () => { {t('Nav.Auth.MyCollections')} diff --git a/client/modules/IDE/components/SketchListRowBase.jsx b/client/modules/IDE/components/SketchListRowBase.jsx index 08dc185885..7db71372db 100644 --- a/client/modules/IDE/components/SketchListRowBase.jsx +++ b/client/modules/IDE/components/SketchListRowBase.jsx @@ -11,7 +11,7 @@ import MenuItem from '../../../components/Dropdown/MenuItem'; import dates from '../../../utils/formatDate'; import getConfig from '../../../utils/getConfig'; -const ROOT_URL = getConfig('API_URL'); +const ROOT_URL = getConfig('API_URL', { nullishString: true }); const formatDateCell = (date, mobile = false) => dates.format(date, { showTime: !mobile }); diff --git a/client/modules/IDE/components/UploadFileModal.jsx b/client/modules/IDE/components/UploadFileModal.jsx index f1e0b90fef..0ff85ffce5 100644 --- a/client/modules/IDE/components/UploadFileModal.jsx +++ b/client/modules/IDE/components/UploadFileModal.jsx @@ -8,8 +8,9 @@ import { closeUploadFileModal } from '../actions/ide'; import FileUploader from './FileUploader'; import { getreachedTotalSizeLimit } from '../selectors/users'; import Modal from './Modal'; +import { parseNumber } from '../../../utils/parseStringToType'; -const limit = getConfig('UPLOAD_LIMIT') || 250000000; +const limit = parseNumber(getConfig('UPLOAD_LIMIT')) || 250000000; const limitText = prettyBytes(limit); const UploadFileModal = () => { diff --git a/client/modules/IDE/selectors/users.js b/client/modules/IDE/selectors/users.js index 266644c271..2eaa78dbbb 100644 --- a/client/modules/IDE/selectors/users.js +++ b/client/modules/IDE/selectors/users.js @@ -1,12 +1,13 @@ import { createSelector } from '@reduxjs/toolkit'; import getConfig from '../../../utils/getConfig'; +import { parseNumber } from '../../../utils/parseStringToType'; export const getAuthenticated = (state) => state.user.authenticated; const getTotalSize = (state) => state.user.totalSize; const getAssetsTotalSize = (state) => state.assets.totalSize; export const getSketchOwner = (state) => state.project.owner; const getUserId = (state) => state.user.id; -const limit = getConfig('UPLOAD_LIMIT') || 250000000; +const limit = parseNumber(getConfig('UPLOAD_LIMIT')) || 250000000; export const getCanUploadMedia = createSelector( getAuthenticated, diff --git a/client/modules/Preview/EmbedFrame.jsx b/client/modules/Preview/EmbedFrame.jsx index fa01604ab7..d3fe69158d 100644 --- a/client/modules/Preview/EmbedFrame.jsx +++ b/client/modules/Preview/EmbedFrame.jsx @@ -232,9 +232,9 @@ p5.prototype.registerMethod('afterSetup', p5.prototype.ensureAccessibleCanvas);` } const previewScripts = sketchDoc.createElement('script'); - previewScripts.src = `${window.location.origin}${getConfig( - 'PREVIEW_SCRIPTS_URL' - )}`; + previewScripts.src = `${window.location.origin}${ + (getConfig('PREVIEW_SCRIPTS_URL'), { nullishString: true }) + }`; previewScripts.setAttribute('crossorigin', ''); sketchDoc.head.appendChild(previewScripts); @@ -245,7 +245,9 @@ p5.prototype.registerMethod('afterSetup', p5.prototype.ensureAccessibleCanvas);` window.offs = ${JSON.stringify(scriptOffs)}; window.objectUrls = ${JSON.stringify(objectUrls)}; window.objectPaths = ${JSON.stringify(objectPaths)}; - window.editorOrigin = '${getConfig('EDITOR_URL')}'; + window.editorOrigin = '${ + (getConfig('EDITOR_URL'), { nullishString: true }) + }'; `; addLoopProtect(sketchDoc); sketchDoc.head.prepend(consoleErrorsScript); diff --git a/client/utils/apiClient.ts b/client/utils/apiClient.ts index fa92c19bce..a531d3d9f7 100644 --- a/client/utils/apiClient.ts +++ b/client/utils/apiClient.ts @@ -1,7 +1,7 @@ import axios, { AxiosInstance } from 'axios'; import getConfig from './getConfig'; -const ROOT_URL = getConfig('API_URL') ?? ''; +const ROOT_URL = getConfig('API_URL', { nullishString: true }); /** * Configures an Axios instance with the correct API URL diff --git a/client/utils/parseStringToType.test.ts b/client/utils/parseStringToType.test.ts index e12168125f..24121ae6c9 100644 --- a/client/utils/parseStringToType.test.ts +++ b/client/utils/parseStringToType.test.ts @@ -15,11 +15,25 @@ describe('parseNumber', () => { expect(parseNumber('0')).toBe(0); }); + it('returns 0 if input is undefined and nullishNumber is true', () => { + const warnSpy = jest.spyOn(console, 'warn'); + expect(parseNumber(undefined, true)).toBe(0); + expect(warnSpy).toHaveBeenCalledWith('parseNumber: got undefined input'); + }); + + it('returns undefined and warns if input is undefined and nullishNumber is false', () => { + const warnSpy = jest.spyOn(console, 'warn'); + expect(parseNumber(undefined, false)).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith('parseNumber: got undefined input'); + }); + it('returns undefined and warns if parsing fails', () => { const warnSpy = jest.spyOn(console, 'warn'); const input = 'abc'; - expect(parseNumber(input)).toBe(undefined); - expect(warnSpy).toHaveBeenCalledWith(`expected a number, got ${input}`); + expect(parseNumber(input)).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + `parseNumber: expected a number, got ${input}` + ); }); }); @@ -39,10 +53,24 @@ describe('parseBoolean', () => { expect(parseBoolean('FALSE')).toBe(false); }); - it('returns undefined and warns if parsing fails and warn=true', () => { + it('returns false if input is undefined and nullishBool is true', () => { + const warnSpy = jest.spyOn(console, 'warn'); + expect(parseBoolean(undefined, true)).toBe(false); + expect(warnSpy).toHaveBeenCalledWith('parseBoolean: got undefined input'); + }); + + it('returns undefined and warns if input is undefined and nullishBool is false', () => { + const warnSpy = jest.spyOn(console, 'warn'); + expect(parseBoolean(undefined, false)).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith('parseBoolean: got undefined input'); + }); + + it('returns undefined and warns if parsing fails', () => { const warnSpy = jest.spyOn(console, 'warn'); const input = 'yes'; - expect(parseBoolean(input)).toBe(undefined); - expect(warnSpy).toHaveBeenCalledWith(`expected a boolean, got ${input}`); + expect(parseBoolean(input)).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + `parseBoolean: expected 'true' or 'false', got "${input}"` + ); }); }); diff --git a/client/utils/parseStringToType.ts b/client/utils/parseStringToType.ts index e5191d9da7..da607afe26 100644 --- a/client/utils/parseStringToType.ts +++ b/client/utils/parseStringToType.ts @@ -1,20 +1,45 @@ /* eslint-disable consistent-return */ -/** Parses a string into a number or undefined if parsing fails. */ -export function parseNumber(str: string): number | undefined { +/** + * Parses a string into a number. + * - Returns `0` for nullish input if `nullishNumber` is true. + * - Returns `undefined` otherwise for nullish or unrecognized input. + */ +export function parseNumber( + str?: string, + nullishNumber = false +): number | undefined { + if (str == null) { + console.warn(`parseNumber: got undefined input`); + return nullishNumber ? 0 : undefined; + } + const num = Number(str); if (Number.isNaN(num)) { - console.warn(`expected a number, got ${str}`); + console.warn(`parseNumber: expected a number, got ${str}`); return undefined; } + return num; } -/** Parses a case-insensitive string into a boolean or undefined if parsing fails. */ -export function parseBoolean(str: string): boolean | undefined { +/** + * Parses a case-insensitive string into a boolean. + * - Returns `false` for nullish input if `nullishBool` is true. + * - Returns `undefined` otherwise for nullish or unrecognized input. + */ +export function parseBoolean( + str?: string, + nullishBool = false +): boolean | undefined { + if (str == null) { + console.warn('parseBoolean: got undefined input'); + return nullishBool ? false : undefined; + } + const lower = str.toLowerCase(); if (lower === 'true') return true; if (lower === 'false') return false; - console.warn(`expected a boolean, got ${str}`); + console.warn(`parseBoolean: expected 'true' or 'false', got "${str}"`); return undefined; } From 2362807016746d3b3798085437c2406db2d31757 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 26 Jul 2025 23:14:48 +0100 Subject: [PATCH 38/38] update formatDate to ts, fix type errors --- .../utils/{formatDate.test.js => formatDate.test.ts} | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) rename client/utils/{formatDate.test.js => formatDate.test.ts} (92%) diff --git a/client/utils/formatDate.test.js b/client/utils/formatDate.test.ts similarity index 92% rename from client/utils/formatDate.test.js rename to client/utils/formatDate.test.ts index 2f1a694a7b..1401bebb6f 100644 --- a/client/utils/formatDate.test.js +++ b/client/utils/formatDate.test.ts @@ -2,7 +2,7 @@ import i18next from 'i18next'; import dateUtils from './formatDate'; jest.mock('i18next', () => ({ - t: jest.fn() + t: jest.fn((key: string) => key.split('.')[1]) })); jest.mock('../i18n', () => ({ @@ -20,8 +20,6 @@ describe('dateUtils', () => { const now = new Date(); const recentDate = new Date(now.getTime() - 5000); - i18next.t.mockReturnValue('JustNow'); - const result = dateUtils.distanceInWordsToNow(recentDate); expect(i18next.t).toHaveBeenCalledWith('formatDate.JustNow'); expect(result).toBe('JustNow'); @@ -31,8 +29,6 @@ describe('dateUtils', () => { const now = new Date(); const recentDate = new Date(now.getTime() - 15000); - i18next.t.mockReturnValue('15Seconds'); - const result = dateUtils.distanceInWordsToNow(recentDate); expect(i18next.t).toHaveBeenCalledWith('formatDate.15Seconds'); expect(result).toBe('15Seconds'); @@ -42,7 +38,9 @@ describe('dateUtils', () => { const now = new Date(); const oldDate = new Date(now.getTime() - 60000); - i18next.t.mockImplementation((key, { timeAgo }) => `${key}: ${timeAgo}`); + jest.mock('i18next', () => ({ + t: jest.fn((key: string, { timeAgo }) => `${key}: ${timeAgo}`) + })); const result = dateUtils.distanceInWordsToNow(oldDate); expect(i18next.t).toHaveBeenCalledWith(