diff --git a/.eslintrc b/.eslintrc index 0c9597ce98..e9539865dd 100644 --- a/.eslintrc +++ b/.eslintrc @@ -29,6 +29,7 @@ "tsx": "never" } ], + "import/prefer-default-export": "off", "react/jsx-filename-extension": [1, { "extensions": [".jsx", ".tsx"] }], "comma-dangle": 0, // not sure why airbnb turned this on. gross! "default-param-last": 0, @@ -41,6 +42,7 @@ "no-restricted-exports": 1, "no-underscore-dangle": 0, "no-useless-catch": 2, + "no-plusplus": "off", "prefer-object-spread": 0, "max-len": [1, 120, 2, {"ignoreComments": true, "ignoreTemplateLiterals": true}], "max-classes-per-file": 0, @@ -131,7 +133,9 @@ "rules": { "no-use-before-define": "off", "import/no-extraneous-dependencies": "off", - "no-unused-vars": "off" + "no-unused-vars": "off", + "import/no-default-export": "warn", + "no-underscore-dangle": "warn", } }, { diff --git a/.prettierrc b/.prettierrc index e2da8bb25a..df6b0841b0 100644 --- a/.prettierrc +++ b/.prettierrc @@ -5,7 +5,6 @@ "insertPragma": false, "jsxBracketSameLine": false, "jsxSingleQuote": false, - "parser": "babel", "printWidth": 80, "proseWrap": "never", "requirePragma": false, diff --git a/README.md b/README.md index 5ac8968c49..2eaffbddde 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,13 @@ The p5.js Editor is a collaborative project created by many individuals, mostly 3. [All Contributors list on the p5.js repository](https://github.com/processing/p5.js?tab=readme-ov-file#contributors) - Explore the All Contributors list to see the wide range of contributions by our amazing community! +> **TypeScript Migration:** +> As of July 2025, we are working on migrating the repo to TypeScript as part of the **[p5.js Web Editor pr05 Grant](https://github.com/processing/pr05-grant/wiki/2025-pr05-Program-Page)**. +> This migration will occur in two phases: +> 1. **Grant Work (July – October 31, 2025)** – Setting up TypeScript configuration, tooling, and starting partial migration. Contributions will be **closed** during this period. +> 2. **Open Contribution (After October 31, 2025)** – TypeScript migration tasks will **open** to all contributors, with guidelines and tutorials available. +> +> For full details, see [TypeScript Migration Plan](./contributor_docs/typescript-migration.md). ## Acknowledgements 🙏 diff --git a/client/common/useKeyDownHandlers.js b/client/common/useKeyDownHandlers.js index 7259574e82..f47fbc7c8a 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-${ diff --git a/client/components/SkipLink.tsx b/client/components/SkipLink.tsx index d70af6a999..c5b7b15e57 100644 --- a/client/components/SkipLink.tsx +++ b/client/components/SkipLink.tsx @@ -3,8 +3,8 @@ import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; type SkipLinkProps = { - targetId: string, - text: string + targetId: string; + text: string; }; const SkipLink = ({ targetId, text }: SkipLinkProps) => { diff --git a/client/i18n.js b/client/i18n.js index 25ce8f19d9..f37c9ed53f 100644 --- a/client/i18n.js +++ b/client/i18n.js @@ -21,7 +21,7 @@ import { enIN } from 'date-fns/locale'; -import getPreferredLanguage from './utils/language-utils'; +import { getPreferredLanguage } from './utils/language-utils'; const fallbackLng = ['en-US']; diff --git a/client/modules/IDE/actions/assets.js b/client/modules/IDE/actions/assets.js index ecd16fb7c6..0aa9085da3 100644 --- a/client/modules/IDE/actions/assets.js +++ b/client/modules/IDE/actions/assets.js @@ -1,4 +1,4 @@ -import apiClient from '../../../utils/apiClient'; +import { apiClient } from '../../../utils/apiClient'; import * as ActionTypes from '../../../constants'; import { startLoader, stopLoader } from '../reducers/loading'; import { assetsActions } from '../reducers/assets'; diff --git a/client/modules/IDE/actions/collections.js b/client/modules/IDE/actions/collections.js index 32790e681e..68a42f500d 100644 --- a/client/modules/IDE/actions/collections.js +++ b/client/modules/IDE/actions/collections.js @@ -1,5 +1,5 @@ import browserHistory from '../../../browserHistory'; -import apiClient from '../../../utils/apiClient'; +import { apiClient } from '../../../utils/apiClient'; import * as ActionTypes from '../../../constants'; import { startLoader, stopLoader } from '../reducers/loading'; import { setToastText, showToast } from './toast'; diff --git a/client/modules/IDE/actions/files.js b/client/modules/IDE/actions/files.js index 84b76b6f41..3d406bcff6 100644 --- a/client/modules/IDE/actions/files.js +++ b/client/modules/IDE/actions/files.js @@ -1,6 +1,6 @@ import objectID from 'bson-objectid'; import blobUtil from 'blob-util'; -import apiClient from '../../../utils/apiClient'; +import { apiClient } from '../../../utils/apiClient'; import * as ActionTypes from '../../../constants'; import { setUnsavedChanges, diff --git a/client/modules/IDE/actions/preferences.js b/client/modules/IDE/actions/preferences.js index ebaefd1625..f6e71504ee 100644 --- a/client/modules/IDE/actions/preferences.js +++ b/client/modules/IDE/actions/preferences.js @@ -1,5 +1,5 @@ import i18next from 'i18next'; -import apiClient from '../../../utils/apiClient'; +import { apiClient } from '../../../utils/apiClient'; import * as ActionTypes from '../../../constants'; function updatePreferences(formParams, dispatch) { diff --git a/client/modules/IDE/actions/project.js b/client/modules/IDE/actions/project.js index 1d8943336b..fcdd9f9366 100644 --- a/client/modules/IDE/actions/project.js +++ b/client/modules/IDE/actions/project.js @@ -2,8 +2,8 @@ import objectID from 'bson-objectid'; import each from 'async/each'; import { isEqual } from 'lodash'; import browserHistory from '../../../browserHistory'; -import apiClient from '../../../utils/apiClient'; -import getConfig from '../../../utils/getConfig'; +import { apiClient } from '../../../utils/apiClient'; +import { getConfig } from '../../../utils/getConfig'; import * as ActionTypes from '../../../constants'; import { showToast, setToastText } from './toast'; import { @@ -15,9 +15,11 @@ import { } from './ide'; import { clearState, saveState } from '../../../persistState'; -const ROOT_URL = getConfig('API_URL'); -const S3_BUCKET_URL_BASE = getConfig('S3_BUCKET_URL_BASE'); -const S3_BUCKET = getConfig('S3_BUCKET'); +const ROOT_URL = getConfig('API_URL', { throwErrorIfNotFound: true }); +const S3_BUCKET_URL_BASE = getConfig('S3_BUCKET_URL_BASE', { + throwErrorIfNotFound: true +}); +const S3_BUCKET = getConfig('S3_BUCKET', { throwErrorIfNotFound: true }); export function setProject(project) { return { @@ -307,6 +309,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/projects.js b/client/modules/IDE/actions/projects.js index 34ca2a35bf..06c50cbbfa 100644 --- a/client/modules/IDE/actions/projects.js +++ b/client/modules/IDE/actions/projects.js @@ -1,4 +1,4 @@ -import apiClient from '../../../utils/apiClient'; +import { apiClient } from '../../../utils/apiClient'; import * as ActionTypes from '../../../constants'; import { startLoader, stopLoader } from '../reducers/loading'; diff --git a/client/modules/IDE/actions/uploader.js b/client/modules/IDE/actions/uploader.js index e2831df75f..8b79ef688b 100644 --- a/client/modules/IDE/actions/uploader.js +++ b/client/modules/IDE/actions/uploader.js @@ -1,13 +1,21 @@ import { TEXT_FILE_REGEX } from '../../../../server/utils/fileUtils'; -import apiClient from '../../../utils/apiClient'; -import getConfig from '../../../utils/getConfig'; +import { apiClient } from '../../../utils/apiClient'; +import { getConfig } from '../../../utils/getConfig'; +import { isTestEnvironment } from '../../../utils/checkTestEnv'; import { handleCreateFile } from './files'; +const s3BucketUrlBase = getConfig('S3_BUCKET_URL_BASE'); +const awsRegion = getConfig('AWS_REGION'); +const s3Bucket = getConfig('S3_BUCKET'); + +if (!isTestEnvironment && !s3BucketUrlBase && !(awsRegion && s3Bucket)) { + throw new Error(`S3 bucket address not configured. + Configure either S3_BUCKET_URL_BASE or both AWS_REGION & S3_BUCKET in env vars`); +} + export const s3BucketHttps = - getConfig('S3_BUCKET_URL_BASE') || - `https://s3-${getConfig('AWS_REGION')}.amazonaws.com/${getConfig( - 'S3_BUCKET' - )}/`; + s3BucketUrlBase || `https://s3-${awsRegion}.amazonaws.com/${s3Bucket}/`; + 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..6e0a7d3266 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'; -import getConfig from '../../../utils/getConfig'; - -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/CollectionList/CollectionListRow.jsx b/client/modules/IDE/components/CollectionList/CollectionListRow.jsx index a2096fc788..35b0ae6b09 100644 --- a/client/modules/IDE/components/CollectionList/CollectionListRow.jsx +++ b/client/modules/IDE/components/CollectionList/CollectionListRow.jsx @@ -11,7 +11,7 @@ import * as ProjectActions from '../../actions/project'; import * as CollectionsActions from '../../actions/collections'; import * as IdeActions from '../../actions/ide'; import * as ToastActions from '../../actions/toast'; -import dates from '../../../../utils/formatDate'; +import { formatDateToString } from '../../../../utils/formatDate'; import { remSize, prop } from '../../../../theme'; const SketchsTableRow = styled.tr` @@ -93,7 +93,7 @@ const SketchlistDropdownColumn = styled.td` } `; const formatDateCell = (date, mobile = false) => - dates.format(date, { showTime: !mobile }); + formatDateToString(date, { showTime: !mobile }); const CollectionListRowBase = (props) => { const [renameOpen, setRenameOpen] = useState(false); diff --git a/client/modules/IDE/components/Header/Nav.jsx b/client/modules/IDE/components/Header/Nav.jsx index 151e2d8212..f8f19cdb9f 100644 --- a/client/modules/IDE/components/Header/Nav.jsx +++ b/client/modules/IDE/components/Header/Nav.jsx @@ -7,7 +7,8 @@ import { useTranslation } from 'react-i18next'; import MenubarSubmenu from '../../../../components/Menubar/MenubarSubmenu'; import MenubarItem from '../../../../components/Menubar/MenubarItem'; import { availableLanguages, languageKeyToLabel } from '../../../../i18n'; -import getConfig from '../../../../utils/getConfig'; +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/PreviewFrame.jsx b/client/modules/IDE/components/PreviewFrame.jsx index 47bd38a1df..c3e6c2dbf3 100644 --- a/client/modules/IDE/components/PreviewFrame.jsx +++ b/client/modules/IDE/components/PreviewFrame.jsx @@ -1,7 +1,7 @@ import React, { useRef, useEffect } from 'react'; import PropTypes from 'prop-types'; import styled from 'styled-components'; -import getConfig from '../../../utils/getConfig'; +import { getConfig } from '../../../utils/getConfig'; import { registerFrame } from '../../../utils/dispatcher'; const Frame = styled.iframe` @@ -13,7 +13,7 @@ const Frame = styled.iframe` function PreviewFrame({ fullView, isOverlayVisible }) { const iframe = useRef(); - const previewUrl = getConfig('PREVIEW_URL'); + const previewUrl = getConfig('PREVIEW_URL', { throwErrorIfNotFound: true }); useEffect(() => { const unsubscribe = registerFrame(iframe.current.contentWindow, previewUrl); return () => { diff --git a/client/modules/IDE/components/ShareModal.jsx b/client/modules/IDE/components/ShareModal.jsx index ec7f61fbe8..5e77af0a6a 100644 --- a/client/modules/IDE/components/ShareModal.jsx +++ b/client/modules/IDE/components/ShareModal.jsx @@ -2,7 +2,6 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import CopyableInput from './CopyableInput'; -// import getConfig from '../../../utils/getConfig'; const ShareModal = () => { const { t } = useTranslation(); @@ -15,7 +14,6 @@ const ShareModal = () => { ); const hostname = window.location.origin; - // const previewUrl = getConfig('PREVIEW_URL'); return ( {projectName} diff --git a/client/modules/IDE/components/SketchListRowBase.jsx b/client/modules/IDE/components/SketchListRowBase.jsx index 08dc185885..a3ff465d71 100644 --- a/client/modules/IDE/components/SketchListRowBase.jsx +++ b/client/modules/IDE/components/SketchListRowBase.jsx @@ -8,13 +8,13 @@ import * as ProjectActions from '../actions/project'; import * as IdeActions from '../actions/ide'; import TableDropdown from '../../../components/Dropdown/TableDropdown'; import MenuItem from '../../../components/Dropdown/MenuItem'; -import dates from '../../../utils/formatDate'; -import getConfig from '../../../utils/getConfig'; +import { formatDateToString } from '../../../utils/formatDate'; +import { getConfig } from '../../../utils/getConfig'; -const ROOT_URL = getConfig('API_URL'); +const ROOT_URL = getConfig('API_URL', { throwErrorIfNotFound: true }); const formatDateCell = (date, mobile = false) => - dates.format(date, { showTime: !mobile }); + formatDateToString(date, { showTime: !mobile }); const SketchListRowBase = ({ sketch, diff --git a/client/modules/IDE/components/Timer.jsx b/client/modules/IDE/components/Timer.jsx index e203835c18..2502e9f33c 100644 --- a/client/modules/IDE/components/Timer.jsx +++ b/client/modules/IDE/components/Timer.jsx @@ -3,7 +3,7 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; -import dates from '../../../utils/formatDate'; +import { distanceInWordsToNow } from '../../../utils/formatDate'; import useInterval from '../hooks/useInterval'; import { getIsUserOwner } from '../selectors/users'; @@ -17,16 +17,14 @@ const Timer = () => { // Update immediately upon saving. useEffect(() => { - setTimeAgo( - projectSavedTime ? dates.distanceInWordsToNow(projectSavedTime) : '' - ); + setTimeAgo(projectSavedTime ? distanceInWordsToNow(projectSavedTime) : ''); }, [projectSavedTime]); // Update every 10 seconds. useInterval( () => setTimeAgo( - projectSavedTime ? dates.distanceInWordsToNow(projectSavedTime) : '' + projectSavedTime ? distanceInWordsToNow(projectSavedTime) : '' ), 10000 ); diff --git a/client/modules/IDE/components/UploadFileModal.jsx b/client/modules/IDE/components/UploadFileModal.jsx index f1e0b90fef..72fc8cabc5 100644 --- a/client/modules/IDE/components/UploadFileModal.jsx +++ b/client/modules/IDE/components/UploadFileModal.jsx @@ -3,13 +3,14 @@ import { useDispatch, useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import prettyBytes from 'pretty-bytes'; -import getConfig from '../../../utils/getConfig'; +import { getConfig } from '../../../utils/getConfig'; 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..14f81fd114 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 { 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..82975bd9f7 100644 --- a/client/modules/Preview/EmbedFrame.jsx +++ b/client/modules/Preview/EmbedFrame.jsx @@ -6,7 +6,7 @@ import loopProtect from 'loop-protect'; import { JSHINT } from 'jshint'; import decomment from 'decomment'; import { resolvePathToFile } from '../../../server/utils/filePath'; -import getConfig from '../../utils/getConfig'; +import { getConfig } from '../../utils/getConfig'; import { MEDIA_FILE_QUOTED_REGEX, STRING_REGEX, @@ -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', { + throwErrorIfNotFound: true + })}'; `; addLoopProtect(sketchDoc); sketchDoc.head.prepend(consoleErrorsScript); diff --git a/client/modules/Preview/previewIndex.jsx b/client/modules/Preview/previewIndex.jsx index fe4b5dc153..4229811d3a 100644 --- a/client/modules/Preview/previewIndex.jsx +++ b/client/modules/Preview/previewIndex.jsx @@ -9,7 +9,7 @@ import { } from '../../utils/dispatcher'; import { filesReducer, setFiles } from './filesReducer'; import EmbedFrame from './EmbedFrame'; -import getConfig from '../../utils/getConfig'; +import { getConfig } from '../../utils/getConfig'; import { initialState } from '../IDE/reducers/files'; const GlobalStyle = createGlobalStyle` @@ -24,7 +24,10 @@ const App = () => { const [basePath, setBasePath] = useState(''); const [textOutput, setTextOutput] = useState(false); const [gridOutput, setGridOutput] = useState(false); - registerFrame(window.parent, getConfig('EDITOR_URL')); + registerFrame( + window.parent, + getConfig('EDITOR_URL', { throwErrorIfNotFound: true }) + ); function handleMessageEvent(message) { const { type, payload } = message; diff --git a/client/modules/User/actions.js b/client/modules/User/actions.js index db367d388d..20e6729b0d 100644 --- a/client/modules/User/actions.js +++ b/client/modules/User/actions.js @@ -1,7 +1,7 @@ import { FORM_ERROR } from 'final-form'; import * as ActionTypes from '../../constants'; import browserHistory from '../../browserHistory'; -import apiClient from '../../utils/apiClient'; +import { apiClient } from '../../utils/apiClient'; import { showErrorModal, justOpenedProject } from '../IDE/actions/ide'; import { setLanguage } from '../IDE/actions/preferences'; import { showToast, setToastText } from '../IDE/actions/toast'; diff --git a/client/modules/User/components/APIKeyList.jsx b/client/modules/User/components/APIKeyList.jsx index c7fdf15f4a..17cd857354 100644 --- a/client/modules/User/components/APIKeyList.jsx +++ b/client/modules/User/components/APIKeyList.jsx @@ -5,7 +5,10 @@ import { useTranslation } from 'react-i18next'; import { APIKeyPropType } from './APIKeyForm'; -import dates from '../../../utils/formatDate'; +import { + distanceInWordsToNow, + formatDateToString +} from '../../../utils/formatDate'; import TrashCanIcon from '../../../images/trash-can.svg'; function APIKeyList({ apiKeys, onRemove }) { @@ -23,13 +26,13 @@ function APIKeyList({ apiKeys, onRemove }) { {orderBy(apiKeys, ['createdAt'], ['desc']).map((key) => { const lastUsed = key.lastUsedAt - ? dates.distanceInWordsToNow(new Date(key.lastUsedAt)) + ? distanceInWordsToNow(new Date(key.lastUsedAt)) : t('APIKeyList.Never'); return ( {key.label} - {dates.format(key.createdAt)} + {formatDateToString(key.createdAt)} {lastUsed} { @@ -40,7 +40,7 @@ const CollectionItemRow = ({ collection, item, isOwner }) => { className={`sketches-table__row ${projectIsDeleted ? 'is-deleted' : ''}`} > {name} - {dates.format(item.createdAt)} + {formatDateToString(item.createdAt)} {sketchOwnerUsername} {isOwner && ( diff --git a/client/modules/User/components/CookieConsent.jsx b/client/modules/User/components/CookieConsent.jsx index 37b2e0618f..0817e10c70 100644 --- a/client/modules/User/components/CookieConsent.jsx +++ b/client/modules/User/components/CookieConsent.jsx @@ -7,7 +7,7 @@ import { Transition } from 'react-transition-group'; import { Link } from 'react-router-dom'; import { Trans, useTranslation } from 'react-i18next'; import PropTypes from 'prop-types'; -import getConfig from '../../../utils/getConfig'; +import { getConfig } from '../../../utils/getConfig'; import { setUserCookieConsent } from '../actions'; import { remSize, prop, device } from '../../../theme'; import Button from '../../../common/Button'; @@ -77,6 +77,8 @@ const CookieConsentButtons = styled.div` } `; +const GOOGLE_ANALYTICS_ID = getConfig('GA_MEASUREMENT_ID'); + function CookieConsent({ hide }) { const user = useSelector((state) => state.user); const [cookieConsent, setBrowserCookieConsent] = useState('none'); @@ -133,15 +135,15 @@ function CookieConsent({ hide }) { initializeCookieConsent(); } - if (getConfig('GA_MEASUREMENT_ID')) { + if (GOOGLE_ANALYTICS_ID) { if (p5CookieConsent === 'essential') { - ReactGA.initialize(getConfig('GA_MEASUREMENT_ID'), { + ReactGA.initialize(GOOGLE_ANALYTICS_ID, { gaOptions: { storage: 'none' } }); } else { - ReactGA.initialize(getConfig('GA_MEASUREMENT_ID')); + ReactGA.initialize(GOOGLE_ANALYTICS_ID); } ReactGA.pageview(window.location.pathname + window.location.search); } diff --git a/client/modules/User/components/SignupForm.jsx b/client/modules/User/components/SignupForm.jsx index d3e6129461..26fb6fa97d 100644 --- a/client/modules/User/components/SignupForm.jsx +++ b/client/modules/User/components/SignupForm.jsx @@ -6,7 +6,7 @@ import { AiOutlineEye, AiOutlineEyeInvisible } from 'react-icons/ai'; import { validateSignup } from '../../../utils/reduxFormUtils'; import { validateAndSignUpUser } from '../actions'; import Button from '../../../common/Button'; -import apiClient from '../../../utils/apiClient'; +import { apiClient } from '../../../utils/apiClient'; import useSyncFormTranslations from '../../../common/useSyncFormTranslations'; const timeoutRef = { current: null }; diff --git a/client/store.js b/client/store.js index 7de458086d..e74248f010 100644 --- a/client/store.js +++ b/client/store.js @@ -3,7 +3,7 @@ import listenerMiddleware from './middleware'; import DevTools from './modules/App/components/DevTools'; import rootReducer from './reducers'; import { clearState, loadState } from './persistState'; -import getConfig from './utils/getConfig'; +import { getConfig } from './utils/getConfig'; // Enable DevTools only when rendering on client and during development. // Display the dock monitor only if no browser extension is found. diff --git a/client/styles/components/_console.scss b/client/styles/components/_console.scss index 6580673952..4e17fe1cea 100644 --- a/client/styles/components/_console.scss +++ b/client/styles/components/_console.scss @@ -57,6 +57,13 @@ & path { fill: getThemifyVariable('secondary-text-color'); } + &:hover { + & g, + & polygon, + & path { + fill: getThemifyVariable('logo-color'); + } + } } .preview-console--collapsed & { display: none; @@ -72,6 +79,13 @@ & path { fill: getThemifyVariable('secondary-text-color'); } + &:hover { + & g, + & polygon, + & path { + fill: getThemifyVariable('logo-color'); + } + } } display: none; .preview-console--collapsed & { diff --git a/client/utils/apiClient.js b/client/utils/apiClient.js deleted file mode 100644 index a8347674a0..0000000000 --- a/client/utils/apiClient.js +++ /dev/null @@ -1,17 +0,0 @@ -import axios from 'axios'; - -import getConfig from './getConfig'; - -const ROOT_URL = getConfig('API_URL'); - -/** - * Configures an Axios instance with the correct API URL - */ -function createClientInstance() { - return axios.create({ - baseURL: ROOT_URL, - withCredentials: true - }); -} - -export default createClientInstance(); diff --git a/client/utils/apiClient.ts b/client/utils/apiClient.ts new file mode 100644 index 0000000000..7ff5971a05 --- /dev/null +++ b/client/utils/apiClient.ts @@ -0,0 +1,17 @@ +import axios, { AxiosInstance } from 'axios'; +import { getConfig } from './getConfig'; + +const ROOT_URL = getConfig('API_URL', { throwErrorIfNotFound: true }); + +/** + * Configures an Axios instance with the correct API URL + */ +function createClientInstance(): AxiosInstance { + return axios.create({ + baseURL: ROOT_URL, + withCredentials: true + }); +} + +/** Axios instance configured with the API_URL as the ROOT URL */ +export const apiClient = createClientInstance(); diff --git a/client/utils/checkTestEnv.ts b/client/utils/checkTestEnv.ts new file mode 100644 index 0000000000..4a56a076bb --- /dev/null +++ b/client/utils/checkTestEnv.ts @@ -0,0 +1,3 @@ +import { getEnvVar } from './getConfig'; + +export const isTestEnvironment = getEnvVar('NODE_ENV') === 'test'; 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.js b/client/utils/consoleUtils.ts similarity index 77% rename from client/utils/consoleUtils.js rename to client/utils/consoleUtils.ts index 1f15fc2b6b..3cf156972a 100644 --- a/client/utils/consoleUtils.js +++ b/client/utils/consoleUtils.ts @@ -1,7 +1,14 @@ export const startTag = '@fs-'; -export const getAllScriptOffsets = (htmlFile) => { - 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; diff --git a/client/utils/device.js b/client/utils/device.js deleted file mode 100644 index 040b16b7d4..0000000000 --- a/client/utils/device.js +++ /dev/null @@ -1 +0,0 @@ -export const isMac = () => navigator.userAgent.toLowerCase().indexOf('mac') !== -1; // eslint-disable-line diff --git a/client/utils/device.test.ts b/client/utils/device.test.ts new file mode 100644 index 0000000000..b748bb47f1 --- /dev/null +++ b/client/utils/device.test.ts @@ -0,0 +1,45 @@ +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); + }); + + 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 new file mode 100644 index 0000000000..bdbce93f76 --- /dev/null +++ b/client/utils/device.ts @@ -0,0 +1,8 @@ +/** + * 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 { + return navigator?.userAgent?.toLowerCase().includes('mac') ?? false; +} diff --git a/client/utils/dispatcher.js b/client/utils/dispatcher.js deleted file mode 100644 index 49393121ac..0000000000 --- a/client/utils/dispatcher.js +++ /dev/null @@ -1,69 +0,0 @@ -// Inspired by -// https://github.com/codesandbox/codesandbox-client/blob/master/packages/codesandbox-api/src/dispatcher/index.ts - -const frames = {}; -let frameIndex = 1; -let listener = null; - -export const MessageTypes = { - START: 'START', - STOP: 'STOP', - FILES: 'FILES', - SKETCH: 'SKETCH', - REGISTER: 'REGISTER', - EXECUTE: 'EXECUTE' -}; - -export function registerFrame(newFrame, newOrigin) { - const frameId = frameIndex; - frameIndex += 1; - frames[frameId] = { frame: newFrame, origin: newOrigin }; - return () => { - delete frames[frameId]; - }; -} - -function notifyListener(message) { - if (listener) listener(message); -} - -function notifyFrames(message) { - const rawMessage = JSON.parse(JSON.stringify(message)); - Object.keys(frames).forEach((frameId) => { - const { frame, origin } = frames[frameId]; - if (frame && frame.postMessage) { - frame.postMessage(rawMessage, origin); - } - }); -} - -export function dispatchMessage(message) { - if (!message) return; - - // maybe one day i will understand why in the codesandbox - // code they leave notifyListeners in here? - // notifyListener(message); - notifyFrames(message); -} - -/** - * Call callback to remove listener - */ -export function listen(callback) { - listener = callback; - return () => { - listener = null; - }; -} - -function eventListener(e) { - const { data } = e; - - // should also store origin of parent? idk - // if (data && e.origin === origin) { - if (data) { - notifyListener(data); - } -} - -window.addEventListener('message', eventListener); diff --git a/client/utils/dispatcher.test.ts b/client/utils/dispatcher.test.ts new file mode 100644 index 0000000000..48572653c4 --- /dev/null +++ b/client/utils/dispatcher.test.ts @@ -0,0 +1,92 @@ +import { + registerFrame, + dispatchMessage, + listen, + MessageTypes +} from './dispatcher'; + +describe('dispatcher', () => { + let mockFrame: Window; + let origin: string; + let removeFrame: () => void; + + beforeEach(() => { + origin = 'https://example.com'; + mockFrame = ({ postMessage: jest.fn() } as unknown) as Window; + }); + + 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() } as unknown) as Window; + const frame2 = ({ postMessage: jest.fn() } as unknown) as Window; + + 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); + }); + }); +}); diff --git a/client/utils/dispatcher.ts b/client/utils/dispatcher.ts new file mode 100644 index 0000000000..f5628ab3b0 --- /dev/null +++ b/client/utils/dispatcher.ts @@ -0,0 +1,91 @@ +// Inspired by +// https://github.com/codesandbox/codesandbox-client/blob/master/packages/codesandbox-api/src/dispatcher/index.ts + +const frames: { + [key: number]: { frame: Window | null; origin: string }; +} = {}; +let frameIndex = 1; + +/* eslint-disable no-shadow */ +/** Codesandbox dispatcher message types: */ +export enum MessageTypes { + START = 'START', + STOP = 'STOP', + FILES = 'FILES', + SKETCH = 'SKETCH', + REGISTER = 'REGISTER', + EXECUTE = 'EXECUTE' +} +/* eslint-enable no-shadow */ + +/** + * Codesandbox dispatcher message + * - type: 'START', 'STOP' etc + * - payload: additional data for that message type + */ +export type Message = { + type: MessageTypes; + payload?: any; +}; + +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 +): () => void { + const frameId = frameIndex; + frameIndex += 1; + frames[frameId] = { frame: newFrame, origin: newOrigin ?? '' }; + return () => { + delete frames[frameId]; + }; +} + +function notifyListener(message: Message): void { + if (listener) listener(message); +} + +function notifyFrames(message: Message) { + const rawMessage = JSON.parse(JSON.stringify(message)); + Object.values(frames).forEach((frameObj) => { + const { frame, origin } = frameObj; + if (frame && frame.postMessage) { + frame.postMessage(rawMessage, origin); + } + }); +} + +/** + * Sends a message to all registered frames. + * @param message - The message to dispatch. + */ +export function dispatchMessage(message: Message | undefined | null): void { + if (!message) return; + notifyFrames(message); +} + +/** + * Call callback to remove listener + */ +export function listen(callback: (message: Message) => void): () => void { + listener = callback; + return () => { + listener = null; + }; +} + +function eventListener(e: MessageEvent) { + const { data } = e; + if (data) { + notifyListener(data); + } +} + +window.addEventListener('message', eventListener); diff --git a/client/utils/evaluateExpression.js b/client/utils/evaluateExpression.js deleted file mode 100644 index 6e277d5d0f..0000000000 --- a/client/utils/evaluateExpression.js +++ /dev/null @@ -1,29 +0,0 @@ -function __makeEvaluateExpression(evalInClosure) { - return (expr) => - evalInClosure(` - ${expr}`); -} - -function evaluateExpression() { - return __makeEvaluateExpression((expr) => { - let newExpr = expr; - let result = null; - let error = false; - try { - try { - const wrapped = `(${expr})`; - const validate = new Function(wrapped); // eslint-disable-line - newExpr = wrapped; // eslint-disable-line - } catch (e) { - // We shouldn't wrap the expression - } - result = (0, eval)(newExpr); // eslint-disable-line - } catch (e) { - result = `${e.name}: ${e.message}`; - error = true; - } - return { result, error }; - }); -} - -export default evaluateExpression(); diff --git a/client/utils/evaluateExpression.test.ts b/client/utils/evaluateExpression.test.ts new file mode 100644 index 0000000000..9c41e9a61f --- /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 +}); diff --git a/client/utils/evaluateExpression.ts b/client/utils/evaluateExpression.ts new file mode 100644 index 0000000000..ec14e410bf --- /dev/null +++ b/client/utils/evaluateExpression.ts @@ -0,0 +1,44 @@ +type EvalResult = { + result: unknown; + error: boolean; +}; + +type EvalInClosureFn = (expr: string) => EvalResult; + +function makeEvaluateExpression(evalInClosure: EvalInClosureFn) { + return (expr: string) => + evalInClosure(` + ${expr}`); +} + +export function evaluateExpressionWrapper(): (expr: string) => EvalResult { + return makeEvaluateExpression( + (expr: string): EvalResult => { + let newExpr = expr; + let result = null; + let error = false; + try { + try { + const wrapped = `(${expr})`; + // eslint-disable-next-line no-new-func + const validate = new Function(wrapped); + newExpr = wrapped; + } catch (e) { + // We shouldn't wrap the expression + } + // eslint-disable-next-line no-eval + result = (0, eval)(newExpr); + } catch (e) { + if (e instanceof Error) { + result = `${e.name}: ${e.message}`; + } else { + result = String(e); + } + error = true; + } + return { result, error }; + } + ); +} + +export const evaluateExpression = evaluateExpressionWrapper(); diff --git a/client/utils/formatDate.js b/client/utils/formatDate.js deleted file mode 100644 index 73379e0211..0000000000 --- a/client/utils/formatDate.js +++ /dev/null @@ -1,57 +0,0 @@ -import formatDistanceToNow from 'date-fns/formatDistanceToNow'; -import differenceInMilliseconds from 'date-fns/differenceInMilliseconds'; -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) { - const date = maybeDate instanceof Date ? maybeDate : parseISO(maybeDate); - - if (isValid(date)) { - return date; - } - - return null; -} - -export default { - distanceInWordsToNow(date) { - 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 ''; - }, - format(date, { showTime = true } = {}) { - const parsed = parse(date); - const formatType = showTime ? 'PPpp' : 'PP'; - - if (parsed) { - return format(parsed, formatType, { locale: currentDateLocale() }); - } - - return ''; - } -}; diff --git a/client/utils/formatDate.test.ts b/client/utils/formatDate.test.ts new file mode 100644 index 0000000000..3c98507d77 --- /dev/null +++ b/client/utils/formatDate.test.ts @@ -0,0 +1,79 @@ +import i18next from 'i18next'; +import { distanceInWordsToNow, formatDateToString } from './formatDate'; + +jest.mock('i18next', () => ({ + t: jest.fn((key: string) => key.split('.')[1]) +})); + +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); + + const result = 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); + + const result = 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); + + jest.mock('i18next', () => ({ + t: jest.fn((key: string, { timeAgo }) => `${key}: ${timeAgo}`) + })); + + const result = distanceInWordsToNow(oldDate); + expect(i18next.t).toHaveBeenCalledWith( + 'formatDate.Ago', + expect.any(Object) + ); + expect(result).toContain('Ago'); + }); + + it('returns empty string for invalid date', () => { + const result = 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 = formatDateToString(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 = formatDateToString(date, { showTime: false }); + + expect(formatted).not.toMatch(/(\d{1,2}:\d{2})/); // Contains time + }); + + it('returns empty string for invalid date', () => { + const formatted = formatDateToString('invalid date'); + expect(formatted).toBe(''); + }); + }); +}); diff --git a/client/utils/formatDate.ts b/client/utils/formatDate.ts new file mode 100644 index 0000000000..85802d8153 --- /dev/null +++ b/client/utils/formatDate.ts @@ -0,0 +1,66 @@ +import formatDistanceToNow from 'date-fns/formatDistanceToNow'; +import differenceInMilliseconds from 'date-fns/differenceInMilliseconds'; +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'; + +/** + * 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)) { + return date; + } + + return null; +} + +/** + * 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 + */ +export function distanceInWordsToNow(date: Date | string) { + const parsed = parse(date); + + 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 }); +} + +/** + * 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 + */ +export function formatDateToString( + date: Date | string, + { showTime = true } = {} +): string { + const parsed = parse(date); + if (!parsed) return ''; + + const formatType = showTime ? 'PPpp' : 'PP'; + return format(parsed, formatType, { locale: currentDateLocale() }); +} diff --git a/client/utils/getConfig.js b/client/utils/getConfig.js deleted file mode 100644 index 594af535f7..0000000000 --- a/client/utils/getConfig.js +++ /dev/null @@ -1,25 +0,0 @@ -function isTestEnvironment() { - // eslint-disable-next-line no-use-before-define - return getConfig('NODE_ENV', { warn: false }) === 'test'; -} - -/** - * Returns config item from environment - */ -function getConfig(key, options = { warn: !isTestEnvironment() }) { - if (key == null) { - throw new Error('"key" must be provided to getConfig()'); - } - - const env = - (typeof global !== 'undefined' ? global : window)?.process?.env || {}; - const value = env[key]; - - if (value == null && options?.warn !== false) { - console.warn(`getConfig("${key}") returned null`); - } - - return value; -} - -export default getConfig; diff --git a/client/utils/getConfig.test.js b/client/utils/getConfig.test.js deleted file mode 100644 index 05659caeda..0000000000 --- a/client/utils/getConfig.test.js +++ /dev/null @@ -1,28 +0,0 @@ -import getConfig from './getConfig'; - -describe('utils/getConfig()', () => { - beforeEach(() => { - delete global.process.env.CONFIG_TEST_KEY_NAME; - 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'; - - expect(getConfig('CONFIG_TEST_KEY_NAME')).toBe('editor.p5js.org'); - }); - - it('fetches from window.process', () => { - window.process.env.CONFIG_TEST_KEY_NAME = 'editor.p5js.org'; - - expect(getConfig('CONFIG_TEST_KEY_NAME')).toBe('editor.p5js.org'); - }); - - it('warns but does not throw if no value found', () => { - expect(() => getConfig('CONFIG_TEST_KEY_NAME')).not.toThrow(); - }); -}); diff --git a/client/utils/getConfig.test.ts b/client/utils/getConfig.test.ts new file mode 100644 index 0000000000..85b63a2f5f --- /dev/null +++ b/client/utils/getConfig.test.ts @@ -0,0 +1,97 @@ +import { getConfig } from './getConfig'; + +const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + +jest.mock('./checkTestEnv', () => ({ + isTestEnvironment: false +})); + +describe('utils/getConfig()', () => { + beforeEach(() => { + delete global.process.env.CONFIG_TEST_KEY_NAME; + delete window.process.env.CONFIG_TEST_KEY_NAME; + + consoleWarnSpy.mockClear(); + }); + + afterAll(() => { + consoleWarnSpy.mockRestore(); + }); + + // 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'; + + expect(getConfig('CONFIG_TEST_KEY_NAME')).toBe('editor.p5js.org'); + }); + + it('fetches from window.process', () => { + window.process.env.CONFIG_TEST_KEY_NAME = 'editor.p5js.org'; + + expect(getConfig('CONFIG_TEST_KEY_NAME')).toBe('editor.p5js.org'); + }); + + // check returns unhappy path + describe('and when the key does not exist in the env file', () => { + it('warns but does not throw if throwErrorIfNotFound is false (default)', () => { + expect(() => getConfig('CONFIG_TEST_KEY_NAME')).not.toThrow(); + }); + + it('throws an error if throwErrorIfNotFound is true', () => { + expect(() => + getConfig('CONFIG_TEST_KEY_NAME', { + throwErrorIfNotFound: true + }) + ).toThrow(); + }); + + it('returns undefined by default', () => { + 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(''); + }); + }); + + describe('and when the key exists in the env file but the value is empty', () => { + beforeEach(() => { + global.process.env.CONFIG_TEST_KEY_NAME = ''; + }); + + it('warns but does not throw if throwErrorIfNotFound is false (default)', () => { + expect(() => getConfig('CONFIG_TEST_KEY_NAME')).not.toThrow(); + }); + + it('throws an error if throwErrorIfNotFound is true', () => { + expect(() => + getConfig('CONFIG_TEST_KEY_NAME', { + throwErrorIfNotFound: true + }) + ).toThrow(); + }); + + it('returns undefined by default', () => { + 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(''); + }); + }); +}); diff --git a/client/utils/getConfig.ts b/client/utils/getConfig.ts new file mode 100644 index 0000000000..45b63600da --- /dev/null +++ b/client/utils/getConfig.ts @@ -0,0 +1,69 @@ +import { isTestEnvironment } from './checkTestEnv'; + +/** + * Function to retrieve env vars, with no error handling. + * @returns String value of env variable or undefined if not found. + */ +export function getEnvVar(key: string): string | undefined { + const configSource = global ?? window; + const env = configSource?.process?.env ?? {}; + + return env[key]; +} + +interface GetConfigOptions { + warn?: boolean; + nullishString?: boolean; + throwErrorIfNotFound?: boolean; +} + +const defaultGetConfigOptions: GetConfigOptions = { + warn: !isTestEnvironment, + nullishString: false, + throwErrorIfNotFound: false +}; + +/** + * Returns a string config value from environment variables. + * Logs a warning or throws an error if the value is missing, if `warn` and `throwErrorIfNotFound` are true in options + * + * @param key - The environment variable key to fetch. + * @param options - Optional settings: + * - `throwErrorIfNotFound`: whether to throw an error if the value is missing (default to `false`). + * - `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. + */ +export function getConfig( + key: string, + options: GetConfigOptions = {} +): string | undefined { + if (!key) { + throw new Error('"key" must be provided to getConfig()'); + } + + // override default options with param options + const { warn, nullishString, throwErrorIfNotFound } = { + ...defaultGetConfigOptions, + ...options + }; + + const value = getEnvVar(key); + + // value == null when the key is not present in the env file + // value === '' when the key is present but is empty (eg. TEST_CONFIG_VALUE=) + if (value == null || value === '') { + const notFoundMessage = `getConfig("${key}") returned null or undefined`; + + // error, warn or continue if no value found: + if (throwErrorIfNotFound && !isTestEnvironment) { + throw new Error(notFoundMessage); + } + if (warn) { + console.warn(notFoundMessage); + } + return nullishString ? '' : undefined; + } + + return value; +} diff --git a/client/utils/language-utils.js b/client/utils/language-utils.js deleted file mode 100644 index b173a3137f..0000000000 --- a/client/utils/language-utils.js +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Utility functions for language detection and handling - */ - -function getPreferredLanguage(supportedLanguages = [], defaultLanguage = 'en') { - if (typeof navigator === 'undefined') { - return defaultLanguage; - } - - const normalizeLanguage = (langCode) => 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; - - return hasLanguageOnlyMatch; - }); - - if (matchedLang) { - const normalizedMatchedLang = normalizeLanguage(matchedLang); - const exactMatchIndex = normalizedSupported.findIndex( - (lang) => lang === normalizedMatchedLang - ); - - if (exactMatchIndex !== -1) { - return supportedLanguages[exactMatchIndex]; - } - - const languageOnly = normalizedMatchedLang.split('-')[0]; - const languageOnlyMatchIndex = normalizedSupported.findIndex( - (lang) => lang === languageOnly || lang.startsWith(`${languageOnly}-`) - ); - - if (languageOnlyMatchIndex !== -1) { - return supportedLanguages[languageOnlyMatchIndex]; - } - } - } - - 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]; - } - } - - return defaultLanguage; -} - -export default getPreferredLanguage; diff --git a/client/utils/language-utils.test.ts b/client/utils/language-utils.test.ts new file mode 100644 index 0000000000..d84a702b62 --- /dev/null +++ b/client/utils/language-utils.test.ts @@ -0,0 +1,75 @@ +import { getPreferredLanguage } from './language-utils'; + +describe('getPreferredLanguage', () => { + const originalNavigator = window.navigator; + + afterEach(() => { + window.navigator = originalNavigator; + }); + + const mockNavigator = (language: string, languages: string[] = []) => { + window.navigator = { + ...originalNavigator, + language, + languages + }; + }; + + describe('when navigator is undefined', () => { + it('returns the default language', () => { + const oldNavigator = window.navigator; + + // @ts-expect-error TS2790: The operand of a 'delete' operator must be optional + delete window.navigator; + + const result = getPreferredLanguage(['en', 'fr'], 'en'); + expect(result).toBe('en'); + + window.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'); + }); + }); +}); diff --git a/client/utils/language-utils.ts b/client/utils/language-utils.ts new file mode 100644 index 0000000000..5765c4db6a --- /dev/null +++ b/client/utils/language-utils.ts @@ -0,0 +1,53 @@ +/** + * Utility functions for language detection and handling + */ + +export function getPreferredLanguage( + supportedLanguages: string[] = [], + defaultLanguage: string = 'en' +): string | undefined { + const { navigator } = window; + + if (!navigator) { + return defaultLanguage; + } + + const normalizeLanguage = (langCode: string) => langCode.toLowerCase().trim(); + const normalizedSupported = supportedLanguages.map(normalizeLanguage); + + /** + * 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); + + const exactMatchIndex = normalizedSupported.indexOf(normalizedLang); + 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]; + + // eslint-disable-next-line consistent-return + return undefined; + } + + // 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 match = findMatch(navigator.language); + if (match) return match; + } + + return defaultLanguage; +} diff --git a/client/utils/metaKey.js b/client/utils/metaKey.js deleted file mode 100644 index cca6d3986b..0000000000 --- a/client/utils/metaKey.js +++ /dev/null @@ -1,11 +0,0 @@ -const metaKey = (() => { - if (navigator != null && navigator.platform != null) { - return /^MAC/i.test(navigator.platform) ? 'Cmd' : 'Ctrl'; - } - - return 'Ctrl'; -})(); - -const metaKeyName = metaKey === 'Cmd' ? '⌘' : 'Ctrl'; - -export { metaKey, metaKeyName }; diff --git a/client/utils/metaKey.ts b/client/utils/metaKey.ts new file mode 100644 index 0000000000..d4ee293dc3 --- /dev/null +++ b/client/utils/metaKey.ts @@ -0,0 +1,17 @@ +import { isMac } from './device'; + +/** + * A string representing the meta key name used in keyboard shortcuts. + * - `'Cmd'` on macOS + * - `'Ctrl'` on other platforms + */ +const metaKey: string = isMac() ? '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 }; diff --git a/client/utils/parseStringToType.test.ts b/client/utils/parseStringToType.test.ts new file mode 100644 index 0000000000..61794cc98d --- /dev/null +++ b/client/utils/parseStringToType.test.ts @@ -0,0 +1,104 @@ +import { parseNumber, parseBoolean } from './parseStringToType'; + +jest.mock('./checkTestEnv', () => ({ + isTestEnvironment: false +})); + +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 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 nullish input'); + }); + + it('returns 0 if input is an empty string and nullishNumber is true', () => { + const warnSpy = jest.spyOn(console, 'warn'); + expect(parseNumber('', true)).toBe(0); + expect(warnSpy).toHaveBeenCalledWith('parseNumber: got nullish 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 nullish input'); + }); + + it('returns undefined and warns if input is an empty string and nullishNumber is false', () => { + const warnSpy = jest.spyOn(console, 'warn'); + expect(parseNumber('', false)).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith('parseNumber: got nullish input'); + }); + + it('returns undefined and warns if parsing fails', () => { + const warnSpy = jest.spyOn(console, 'warn'); + const input = 'abc'; + expect(parseNumber(input)).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + `parseNumber: 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 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 nullish input'); + }); + + it('returns false if input is empty string and nullishBool is true', () => { + const warnSpy = jest.spyOn(console, 'warn'); + expect(parseBoolean('', true)).toBe(false); + expect(warnSpy).toHaveBeenCalledWith('parseBoolean: got nullish 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 nullish input'); + }); + + it('returns undefined and warns if input is empty string and nullishBool is false', () => { + const warnSpy = jest.spyOn(console, 'warn'); + expect(parseBoolean('', false)).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith('parseBoolean: got nullish input'); + }); + + it('returns undefined and warns if parsing fails', () => { + const warnSpy = jest.spyOn(console, 'warn'); + const input = 'yes'; + 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 new file mode 100644 index 0000000000..7c627a3354 --- /dev/null +++ b/client/utils/parseStringToType.ts @@ -0,0 +1,50 @@ +import { isTestEnvironment } from './checkTestEnv'; +/* eslint-disable consistent-return */ +/** + * 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) { + if (!isTestEnvironment) { + console.warn(`parseNumber: got nullish input`); + } + return nullishNumber ? 0 : undefined; + } + + const num = Number(str); + if (Number.isNaN(num)) { + console.warn(`parseNumber: expected a number, got ${str}`); + return undefined; + } + + return num; +} + +/** + * 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) { + if (!isTestEnvironment) { + console.warn('parseBoolean: got nullish input'); + } + return nullishBool ? false : undefined; + } + + const lower = str.toLowerCase(); + if (lower === 'true') return true; + if (lower === 'false') return false; + + console.warn(`parseBoolean: expected 'true' or 'false', got "${str}"`); + return undefined; +} diff --git a/client/utils/previewEntry.js b/client/utils/previewEntry.js index a361c00476..4292e5a83b 100644 --- a/client/utils/previewEntry.js +++ b/client/utils/previewEntry.js @@ -1,7 +1,7 @@ import loopProtect from 'loop-protect'; import { Hook, Decode, Encode } from 'console-feed'; import StackTrace from 'stacktrace-js'; -import evaluateExpression from './evaluateExpression'; +import { evaluateExpression } from './evaluateExpression'; // should postMessage user the dispatcher? does the parent window need to // be registered as a frame? or a just a listener? diff --git a/client/utils/reduxFormUtils.js b/client/utils/reduxFormUtils.js deleted file mode 100644 index 085e37cae7..0000000000 --- a/client/utils/reduxFormUtils.js +++ /dev/null @@ -1,118 +0,0 @@ -/* 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; -/* eslint-enable */ - -function validateNameEmail(formProps, errors) { - if (!formProps.username) { - errors.username = i18n.t('ReduxFormUtils.errorEmptyUsername'); - } else if (!formProps.username.match(/^.{1,20}$/)) { - errors.username = i18n.t('ReduxFormUtils.errorLongUsername'); - } else if (!formProps.username.match(/^[a-zA-Z0-9._-]{1,20}$/)) { - 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) - ) { - errors.email = i18n.t('ReduxFormUtils.errorInvalidEmail'); - } -} - -export function validateSettings(formProps) { - const errors = {}; - - validateNameEmail(formProps, errors); - - if (formProps.currentPassword && !formProps.newPassword) { - errors.newPassword = i18n.t('ReduxFormUtils.errorNewPassword'); - } - if (formProps.newPassword && formProps.newPassword.length < 6) { - errors.newPassword = i18n.t('ReduxFormUtils.errorShortPassword'); - } - if ( - formProps.newPassword && - formProps.currentPassword === formProps.newPassword - ) { - errors.newPassword = i18n.t('ReduxFormUtils.errorNewPasswordRepeat'); - } - return errors; -} - -export function validateLogin(formProps) { - const errors = {}; - if (!formProps.email && !formProps.username) { - errors.email = i18n.t('ReduxFormUtils.errorEmptyEmailorUserName'); - } - if (!formProps.password) { - errors.password = i18n.t('ReduxFormUtils.errorEmptyPassword'); - } - 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 function validateNewPassword(formProps) { - const errors = {}; - validatePasswords(formProps, errors); - return errors; -} - -export function validateSignup(formProps) { - const errors = {}; - - validateNameEmail(formProps, errors); - validatePasswords(formProps, errors); - - return errors; -} -export function validateResetPassword(formProps) { - const errors = {}; - if (!formProps.email) { - errors.email = i18n.t('ReduxFormUtils.errorEmptyEmail'); - } else if ( - // eslint-disable-next-line max-len - !formProps.email.match(EMAIL_REGEX) - ) { - errors.email = i18n.t('ReduxFormUtils.errorInvalidEmail'); - } - return errors; -} 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({}); + }); + }); +}); diff --git a/client/utils/reduxFormUtils.ts b/client/utils/reduxFormUtils.ts new file mode 100644 index 0000000000..8ef0a0259e --- /dev/null +++ b/client/utils/reduxFormUtils.ts @@ -0,0 +1,152 @@ +import i18n from 'i18next'; + +// eslint-disable-next-line max-len +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}$/; + +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: FormErrors +) { + if (!formProps.username) { + errors.username = i18n.t('ReduxFormUtils.errorEmptyUsername'); + } else if (formProps.username.length > 20) { + errors.username = i18n.t('ReduxFormUtils.errorLongUsername'); + } else if (!formProps.username.match(USERNAME_REGEX)) { + errors.username = i18n.t('ReduxFormUtils.errorValidUsername'); + } + + if (!formProps.email) { + errors.email = i18n.t('ReduxFormUtils.errorEmptyEmail'); + } else if (!formProps.email.match(EMAIL_REGEX)) { + errors.email = i18n.t('ReduxFormUtils.errorInvalidEmail'); + } +} + +/** Processes form & mutates errors to add any `password` and `confirmPassword` errors */ +function validatePasswords( + formProps: Partial, + errors: FormErrors +) { + 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; + +/** Validation for the Account Form */ +export function validateSettings( + formProps: Partial +): Partial { + const errors: Partial = {}; + + validateUsernameEmail(formProps, errors); + + if (formProps.currentPassword && !formProps.newPassword) { + errors.newPassword = i18n.t('ReduxFormUtils.errorNewPassword'); + } + if (formProps.newPassword && formProps.newPassword.length < 6) { + errors.newPassword = i18n.t('ReduxFormUtils.errorShortPassword'); + } + if ( + formProps.newPassword && + formProps.currentPassword === formProps.newPassword + ) { + errors.newPassword = i18n.t('ReduxFormUtils.errorNewPasswordRepeat'); + } + return errors; +} + +// Login form: +export type LoginForm = UsernameAndEmail & Password; + +/** Validation for the Login Form */ +export function validateLogin( + formProps: Partial +): Partial { + const errors: Partial = {}; + if (!formProps.email && !formProps.username) { + errors.email = i18n.t('ReduxFormUtils.errorEmptyEmailorUserName'); + } + if (!formProps.password) { + errors.password = i18n.t('ReduxFormUtils.errorEmptyPassword'); + } + return errors; +} + +export type NewPasswordForm = PasswordsConfirm; + +/** Validation for the New Password Form */ +export function validateNewPassword( + formProps: Partial +): Partial { + const errors = {}; + validatePasswords(formProps, errors); + return errors; +} + +// Signup Form: +export type SignupForm = UsernameAndEmail & PasswordsConfirm; + +/** Validation for the Signup Form */ +export function validateSignup( + formProps: Partial +): Partial { + const errors = {}; + + validateUsernameEmail(formProps, errors); + validatePasswords(formProps, errors); + + return errors; +} + +// Reset Password Form: +export type ResetPasswordForm = Email; + +/** Validation for the Reset Password Form */ +export function validateResetPassword( + formProps: Partial +): Partial { + const errors: Partial = {}; + if (!formProps.email) { + errors.email = i18n.t('ReduxFormUtils.errorEmptyEmail'); + } else if (!formProps.email.match(EMAIL_REGEX)) { + errors.email = i18n.t('ReduxFormUtils.errorInvalidEmail'); + } + return errors; +} diff --git a/contributor_docs/README.md b/contributor_docs/README.md index 6df4147666..e33bfa20ee 100644 --- a/contributor_docs/README.md +++ b/contributor_docs/README.md @@ -1,4 +1,4 @@ -# Contributor Documentatation +# Contributor Documentation This folder contains guides for contributing to the p5.js Web Editor. You don't need to know everything to get started—explore at your own pace! To begin, we highly recommend starting with the [Contribution Guide](https://github.com/processing/p5.js-web-editor/blob/develop/.github/CONTRIBUTING.md)! These guides aren't exhaustive, and do not cover all the possible ways you can contribute to a project. If you have an idea for how you'd like to help and don't see a guide for it here, you're welcome to add it to the "Documents to Create" list below by opening an issue! @@ -15,6 +15,7 @@ These guides aren't exhaustive, and do not cover all the possible ways you can c * [Translations Guidelines](translations.md) - Guidelines for translating the p5.js editor. * [Deployment](deployment.md) - A guide to production deployment, and all platforms that are being used. * [Release](./release.md) - A guide to creating a production release. +* [Typescript Migration](./typescript_migration.md) - About the Typescript Migration project. ## Documents to Create diff --git a/contributor_docs/s3_configuration.md b/contributor_docs/s3_configuration.md index 9b2ecb621a..be5972a953 100644 --- a/contributor_docs/s3_configuration.md +++ b/contributor_docs/s3_configuration.md @@ -1,5 +1,5 @@ # S3 Bucket Configuration -1. [Create an S3 bucket](https://docs.aws.amazon.com/AmazonS3/latest/userguide/create-bucket-overview.html), with any name +1. [Create an S3 bucket](https://docs.aws.amazon.com/AmazonS3/latest/userguide/create-bucket-overview.html), with any name. 2. Navigate to the S3 bucket permissions and add the following CORS policy. This is for development only, as it allows CORS from any origin. ``` [ diff --git a/contributor_docs/typescript_migration.md b/contributor_docs/typescript_migration.md new file mode 100644 index 0000000000..e1d481871f --- /dev/null +++ b/contributor_docs/typescript_migration.md @@ -0,0 +1,22 @@ +# TypeScript Migration + +This repository is undergoing a TypeScript migration as part of the **2025 pr05 Grant: Incremental Typescript Migration of the p5.js Web Editor**, running **July – October 31, 2025**. Details on the [pr05 grant](https://github.com/processing/pr05-grant/wiki/2025-pr05-Program-Page) and [migration project](https://github.com/processing/pr05-grant/wiki/2025-pr05-Project-List#incremental-typescript-migration-for-the-p5js-editor). + +## Phase 1: Grant Work (July – October 31, 2025) + +During this period, the grant team will: + +- Configure and enable TypeScript across the project (`tsconfig`, linting, type checking, etc). +- Set up linting, testing, and other related developer tooling for TS. +- Begin migrating selected JavaScript files to TypeScript. +- Prepare documentation and workflow so migration tasks can become **good first issues** for new contributors. + +> **Note:** While this initial setup and partial migration is in progress, contributions to the migration itself will **not** be open to the public. + +## Phase 2: Open Contribution (After October 31, 2025) + +When the grant period ends: + +- The migration effort will open up to all contributors. +- This section will include **guidelines, tutorials, and examples** for migrating files from JavaScript to TypeScript in this project. +- Migration tasks will be clearly labeled in the issue tracker (e.g., `good first issue`, `typescript migration`). \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index aa67ab3b9d..cbd718d09f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "p5.js-web-editor", - "version": "2.17.1", + "version": "2.17.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "p5.js-web-editor", - "version": "2.17.1", + "version": "2.17.2", "license": "LGPL-2.1", "dependencies": { "@auth0/s3": "^1.0.0", diff --git a/package.json b/package.json index ccf2842f22..2456718a61 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "p5.js-web-editor", - "version": "2.17.1", + "version": "2.17.2", "description": "The web editor for p5.js.", "scripts": { "clean": "rimraf dist",