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/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.test.tsx b/client/components/SkipLink.test.tsx index ba6d78517d..692a08a616 100644 --- a/client/components/SkipLink.test.tsx +++ b/client/components/SkipLink.test.tsx @@ -2,7 +2,7 @@ import { render, screen, fireEvent } from '@testing-library/react'; import React from 'react'; import { useTranslation } from 'react-i18next'; import '@testing-library/jest-dom'; -import SkipLink from './SkipLink'; +import { SkipLink } from './SkipLink'; jest.mock('react-i18next', () => ({ useTranslation: () => ({ diff --git a/client/components/SkipLink.tsx b/client/components/SkipLink.tsx index d70af6a999..28b02b8b5c 100644 --- a/client/components/SkipLink.tsx +++ b/client/components/SkipLink.tsx @@ -2,12 +2,12 @@ import React, { useState } from 'react'; import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; -type SkipLinkProps = { - targetId: string, - text: string -}; +interface SkipLinkProps { + targetId: string; + text: string; +} -const SkipLink = ({ targetId, text }: SkipLinkProps) => { +export const SkipLink = ({ targetId, text }: SkipLinkProps) => { const [focus, setFocus] = useState(false); const { t } = useTranslation(); const handleFocus = () => { @@ -30,5 +30,3 @@ const SkipLink = ({ targetId, text }: SkipLinkProps) => { ); }; - -export default SkipLink; 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/index.jsx b/client/index.jsx index 27befe8421..0c1ca04082 100644 --- a/client/index.jsx +++ b/client/index.jsx @@ -9,7 +9,7 @@ import Routing from './routes'; import ThemeProvider from './modules/App/components/ThemeProvider'; import Loader from './modules/App/components/loader'; import './i18n'; -import SkipLink from './components/SkipLink'; +import { SkipLink } from './components/SkipLink'; require('./styles/main.scss'); 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 cdd30cf6ae..5b3aaf7743 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 { @@ -308,6 +308,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..8654309769 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` 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 b50cf63e4d..62eff65c03 100644 --- a/client/modules/IDE/components/SketchListRowBase.jsx +++ b/client/modules/IDE/components/SketchListRowBase.jsx @@ -8,14 +8,14 @@ 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'; import VisibilityDropdown from '../../User/components/VisibilityDropdown'; const ROOT_URL = getConfig('API_URL'); 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..2b6ac16720 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); diff --git a/client/modules/Preview/previewIndex.jsx b/client/modules/Preview/previewIndex.jsx index fe4b5dc153..12c14e3b79 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` 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} { @@ -44,7 +44,7 @@ const CollectionItemRow = ({ collection, item, isOwner }) => { }`} > {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/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..2a05ef7a3d --- /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'); + +/** + * 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..48739d16ce --- /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 interface 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..6f8cdf8ada --- /dev/null +++ b/client/utils/evaluateExpression.ts @@ -0,0 +1,44 @@ +interface 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..c69e28aa5c --- /dev/null +++ b/client/utils/getConfig.test.ts @@ -0,0 +1,81 @@ +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', () => { + expect(() => getConfig('CONFIG_TEST_KEY_NAME')).not.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', () => { + expect(() => getConfig('CONFIG_TEST_KEY_NAME')).not.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..7410fc06cb --- /dev/null +++ b/client/utils/getConfig.ts @@ -0,0 +1,63 @@ +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; +} + +const DEFAULT_GET_CONFIG_OPTIONS: GetConfigOptions = { + warn: !isTestEnvironment, + nullishString: false +}; + +/** + * Returns a string config value from environment variables. + * Logs a warning if the value is missing, if `warn` is true in options + * + * @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. + */ +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 } = { + ...DEFAULT_GET_CONFIG_OPTIONS, + ...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`; + + // warn or continue if no value found: + 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..bb50cafa15 --- /dev/null +++ b/client/utils/parseStringToType.ts @@ -0,0 +1,57 @@ +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, or returns the original boolean. + * - Returns `false` for nullish input if `nullishBool` is true. + * - Returns `undefined` otherwise for nullish or unrecognized input. + */ +export function parseBoolean( + input?: string | boolean, + nullishBool = false +): boolean | undefined { + // Handle nullish + if (input == null || input === '') { + if (!isTestEnvironment) { + console.warn('parseBoolean: got nullish input'); + } + return nullishBool ? false : undefined; + } + + // Handle actual boolean + if (typeof input === 'boolean') { + return input; + } + + // Handle string case + const lower = input.toLowerCase(); + if (lower === 'true') return true; + if (lower === 'false') return false; + + console.warn(`parseBoolean: expected 'true' or 'false', got "${input}"`); + 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; +}