diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dbdca8e9..ae1faba44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,14 @@ way to update this template, but currently, we follow a pattern: ## Upcoming version 2025-XX-XX +- [change] This updates Sharetribe Web Template to use React v18 (v18.3.1). Some highlights: + + - Several dependency libraries have been updated. + - Hydration is much more strict now. First render on client-side must match the server-side + render. + + [#523](https://github.com/sharetribe/web-template/pull/523) + - [add] Add currently available translations for DE. [#529](https://github.com/sharetribe/web-template/pull/529) - [fix] a link inside the inquiry message was invisible for the sender of the inquiry. diff --git a/package.json b/package.json index 82c330ef5..a3b23d8e9 100644 --- a/package.json +++ b/package.json @@ -4,30 +4,28 @@ "private": true, "license": "Apache-2.0", "dependencies": { - "@babel/runtime": "^7.17.9", "@loadable/component": "^5.16.4", "@loadable/server": "^5.16.5", - "@mapbox/polyline": "^1.1.1", + "@mapbox/polyline": "^1.2.1", "@sentry/browser": "^8.26.0", "@sentry/node": "^8.26.0", "autosize": "^5.0.1", "basic-auth": "^2.0.1", - "body-parser": "^1.20.2", + "body-parser": "^1.20.3", "classnames": "^2.5.1", - "compression": "^1.7.4", - "cookie-parser": "^1.4.6", - "core-js": "^3.22.5", + "compression": "^1.7.5", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "decimal.js": "^10.4.3", "dotenv": "^10.0.0", "dotenv-expand": "^5.1.0", - "express": "^4.19.2", + "express": "^4.21.1", "express-enforces-ssl": "^1.1.0", - "final-form": "4.20.7", - "final-form-arrays": "3.0.2", + "final-form": "4.20.10", + "final-form-arrays": "3.1.0", "full-icu": "^1.4.0", - "helmet": "^7.1.0", - "jose": "5.2.0", + "helmet": "^8.0.0", + "jose": "5.9.6", "lodash": "^4.17.21", "mapbox-gl-multitouch": "^1.0.3", "moment": "^2.30.1", @@ -36,47 +34,45 @@ "passport-facebook": "^3.0.0", "passport-google-oauth": "^2.0.0", "patch-package": "^8.0.0", - "path-to-regexp": "^6.2.1", + "path-to-regexp": "^8.2.0", "postinstall-postinstall": "^2.1.0", "prop-types": "^15.8.1", - "query-string": "^7.1.1", - "raf": "^3.4.0", - "react": "^17.0.2", - "react-dom": "^17.0.2", + "query-string": "^7.1.3", + "react": "^18.3.1", + "react-dom": "^18.3.1", "react-final-form": "6.5.9", - "react-final-form-arrays": "3.1.3", - "react-helmet-async": "^1.3.0", - "react-image-gallery": "1.2.8", - "react-intl": "^5.25.1", + "react-final-form-arrays": "3.1.4", + "react-helmet-async": "^2.0.5", + "react-image-gallery": "1.3.0", + "react-intl": "6.8.4", "react-moment-proptypes": "^1.8.1", - "react-redux": "^7.2.8", - "react-router-dom": "^5.3.2", - "react-with-direction": "^1.4.0", - "redux": "^4.2.0", - "redux-thunk": "^2.4.1", + "react-redux": "^8.1.2", + "react-router-dom": "^5.3.4", + "redux": "^4.2.1", + "redux-thunk": "^2.4.2", "rehype-react": "^6.2.1", "rehype-sanitize": "^4.0.0", "remark-parse": "^9.0.0", "remark-rehype": "^8.1.0", "seedrandom": "^3.0.5", "sharetribe-flex-sdk": "^1.21.1", - "sharetribe-scripts": "6.0.2", - "sitemap": "^7.1.1", + "sharetribe-scripts": "7.0.0", + "sitemap": "^8.0.0", "smoothscroll-polyfill": "^0.4.0", "source-map-support": "^0.5.21", - "unified": "^9.2.2", - "url": "^0.11.0" + "unified": "^9.2.2" }, "devDependencies": { + "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^5.16.2", - "@testing-library/react": "^12.1.2", + "@testing-library/react": "^16.0.1", "@testing-library/user-event": "^13.5.0", - "bfj": "^7.0.2", + "bfj": "^9.1.1", "chalk": "^v4.1.2", "concurrently": "^8.2.2", "cross-env": "^7.0.3", - "inquirer": "^8.2.4", - "nodemon": "^3.1.4", + "inquirer": "^8.2.6", + "nodemon": "^3.1.7", "prettier": "^1.18.2" }, "resolutions": { diff --git a/patches/final-form+4.20.7.patch b/patches/final-form+4.20.10.patch similarity index 75% rename from patches/final-form+4.20.7.patch rename to patches/final-form+4.20.10.patch index 3cdb1fd9e..207037656 100644 --- a/patches/final-form+4.20.7.patch +++ b/patches/final-form+4.20.10.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/final-form/dist/final-form.es.js b/node_modules/final-form/dist/final-form.es.js -index a1fa14f..72011ef 100644 +index e31d7f3..0757644 100644 --- a/node_modules/final-form/dist/final-form.es.js +++ b/node_modules/final-form/dist/final-form.es.js -@@ -1,6 +1,17 @@ +@@ -1,6 +1,16 @@ -import _extends from '@babel/runtime/helpers/esm/extends'; import _objectWithoutPropertiesLoose from '@babel/runtime/helpers/esm/objectWithoutPropertiesLoose'; @@ -10,14 +10,13 @@ index a1fa14f..72011ef 100644 + for (var i = 1; i < arguments.length; i++) { + var source = arguments[i]; + for (var key in source) { -+ if (Object.prototype.hasOwnProperty.call(source, key)) { -+ target[key] = source[key]; -+ } -+ } ++ if (Object.prototype.hasOwnProperty.call(source, key)) { ++ target[key] = source[key]; ++ } ++ } + } + return target; -+}; -+ ++} // + var charCodeOfDot = ".".charCodeAt(0); - var reEscapeChar = /\\(\\)?/g; diff --git a/server/csp.js b/server/csp.js index b476d4748..0b47abeb8 100644 --- a/server/csp.js +++ b/server/csp.js @@ -159,7 +159,7 @@ exports.csp = (reportUri, reportOnly) => { // https://github.com/helmetjs/helmet/blob/bdb09348c17c78698b0c94f0f6cc6b3968cd43f9/middlewares/content-security-policy/index.ts#L51 const directives = Object.assign({ reportUri: [reportUri] }, defaultDirectives, customDirectives); - if (!reportOnly) { + if (!reportOnly && !dev) { directives.upgradeInsecureRequests = []; } diff --git a/server/dataLoader.js b/server/dataLoader.js index b02c314f7..b2c507e08 100644 --- a/server/dataLoader.js +++ b/server/dataLoader.js @@ -1,5 +1,6 @@ -const url = require('url'); +const { URL } = require('node:url'); const log = require('./log'); +const { getRootURL } = require('./api-util/rootURL'); const PREVENT_DATA_LOADING_IN_SSR = process.env.PREVENT_DATA_LOADING_IN_SSR === 'true'; @@ -19,7 +20,7 @@ exports.loadData = function(requestUrl, sdk, appInfo) { mergeConfig, fetchAppAssets, } = appInfo; - const { pathname, query } = url.parse(requestUrl); + const { pathname, search } = new URL(`${getRootURL()}${requestUrl}`); let translations = {}; let hostedConfig = {}; @@ -43,7 +44,7 @@ exports.loadData = function(requestUrl, sdk, appInfo) { return matchedRoutes.reduce((calls, match) => { const { route, params } = match; if (typeof route.loadData === 'function' && !route.auth) { - calls.push(store.dispatch(route.loadData(params, query, config))); + calls.push(store.dispatch(route.loadData(params, search, config))); } return calls; }, []); diff --git a/server/index.js b/server/index.js index be46cb1bf..f9ede88cb 100644 --- a/server/index.js +++ b/server/index.js @@ -192,7 +192,7 @@ const noCacheHeaders = { 'Cache-control': 'no-cache, no-store, must-revalidate', }; -app.get('*', (req, res) => { +app.get('*', async (req, res) => { if (req.url.startsWith('/static/')) { // The express.static middleware only handles static resources // that it finds, otherwise passes them through. However, we don't @@ -226,8 +226,9 @@ app.get('*', (req, res) => { .loadData(req.url, sdk, appInfo) .then(data => { const cspNonce = cspEnabled ? res.locals.cspNonce : null; - const html = renderer.render(req.url, context, data, renderApp, webExtractor, cspNonce); - + return renderer.render(req.url, context, data, renderApp, webExtractor, cspNonce); + }) + .then(html => { if (dev) { const debugData = { url: req.url, diff --git a/server/renderer.js b/server/renderer.js index 14a6b2852..8fa31ce4c 100644 --- a/server/renderer.js +++ b/server/renderer.js @@ -97,44 +97,44 @@ exports.render = function(requestUrl, context, data, renderApp, webExtractor, no const collectWebChunks = webExtractor.collectChunks.bind(webExtractor); // Render the app with given route, preloaded state, hosted translations. - const { head, body } = renderApp( + return renderApp( requestUrl, context, preloadedState, translations, hostedConfig, collectWebChunks - ); + ).then(({ head, body }) => { + // Preloaded state needs to be passed for client side too. + // For security reasons we ensure that preloaded state is considered as a string + // by replacing '<' character with its unicode equivalent. + // http://redux.js.org/docs/recipes/ServerRendering.html#security-considerations + const serializedState = JSON.stringify(preloadedState, replacer).replace(/window.__PRELOADED_STATE__ = ${JSON.stringify( + serializedState + )}; + `; + // Add nonce to server-side rendered script tags + const nonceParamMaybe = nonce ? { nonce } : {}; - // At this point the serializedState is a string, the second - // JSON.stringify wraps it within double quotes and escapes the - // contents properly so the value can be injected in the script tag - // as a string. - const nonceMaybe = nonce ? `nonce="${nonce}"` : ''; - const preloadedStateScript = ` - - `; - // Add nonce to server-side rendered script tags - const nonceParamMaybe = nonce ? { nonce } : {}; - - return template({ - htmlAttributes: head.htmlAttributes.toString(), - title: head.title.toString(), - link: head.link.toString(), - meta: head.meta.toString(), - script: head.script.toString(), - preloadedStateScript, - ssrStyles: webExtractor.getStyleTags(), - ssrLinks: webExtractor.getLinkTags(), - ssrScripts: webExtractor.getScriptTags(nonceParamMaybe), - body, + return template({ + htmlAttributes: head.htmlAttributes.toString(), + title: head.title.toString(), + link: head.link.toString(), + meta: head.meta.toString(), + script: head.script.toString(), + preloadedStateScript, + ssrStyles: webExtractor.getStyleTags(), + ssrLinks: webExtractor.getLinkTags(), + ssrScripts: webExtractor.getScriptTags(nonceParamMaybe), + body, + }); }); }; diff --git a/src/app.js b/src/app.js index deca4eca4..7e972e7b6 100644 --- a/src/app.js +++ b/src/app.js @@ -1,6 +1,5 @@ import React from 'react'; import { any, string } from 'prop-types'; -import ReactDOMServer from 'react-dom/server'; import { HelmetProvider } from 'react-helmet-async'; import { BrowserRouter, StaticRouter } from 'react-router-dom'; @@ -340,7 +339,12 @@ export const renderApp = ( hostedConfig={hostedConfig} /> ); - const body = ReactDOMServer.renderToString(WithChunks); - const { helmet: head } = helmetContext; - return { head, body }; + + // Let's keep react-dom/server out of the main code-chunk. + return import('react-dom/server').then(mod => { + const { default: ReactDOMServer } = mod; + const body = ReactDOMServer.renderToString(WithChunks); + const { helmet: head } = helmetContext; + return { head, body }; + }); }; diff --git a/src/app.test.js b/src/app.test.js index 1ad14efc8..086e1819a 100644 --- a/src/app.test.js +++ b/src/app.test.js @@ -1,5 +1,5 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; +import React, { act } from 'react'; +import ReactDOMClient from 'react-dom/client'; import { getHostedConfiguration } from './util/testHelpers'; import { ClientApp } from './app'; import configureStore from './store'; @@ -15,7 +15,7 @@ afterAll(() => { }); describe('Application - JSDOM environment', () => { - it('renders the LandingPage without crashing', () => { + it('renders the LandingPage without crashing', async () => { window.google = { maps: {} }; // LandingPage gets rendered and it calls hostedAsset > fetchPageAssets > sdk.assetByVersion @@ -32,7 +32,11 @@ describe('Application - JSDOM environment', () => { const fakeSdk = { assetByVersion: resolvePageAssetCall, assetByAlias: resolvePageAssetCall }; const store = configureStore({}, fakeSdk); const div = document.createElement('div'); - ReactDOM.render(, div); + const root = ReactDOMClient.createRoot(div); + + await act(async () => { + root.render(); + }); delete window.google; }); }); diff --git a/src/components/AspectRatioWrapper/AspectRatioWrapper.js b/src/components/AspectRatioWrapper/AspectRatioWrapper.js index 22bc15b7c..6e72e8db1 100644 --- a/src/components/AspectRatioWrapper/AspectRatioWrapper.js +++ b/src/components/AspectRatioWrapper/AspectRatioWrapper.js @@ -1,9 +1,20 @@ import React from 'react'; -import { node, number, string } from 'prop-types'; import classNames from 'classnames'; import css from './AspectRatioWrapper.module.css'; +/** + * Container that maintains a given aspect ratio, which should be given through width and heigh props + * + * @component + * @param {Object} props + * @param {string?} props.className add more style rules in addition to components own css.root + * @param {string?} props.rootClassName overwrite components own css.root + * @param {number} props.width + * @param {number} props.height + * @param {ReactNode} props.children + * @returns {JSX.Element} container element that maintains given aspect ratio + */ const AspectRatioWrapper = props => { const { children, className, rootClassName, width, height, ...rest } = props; const classes = classNames(rootClassName || css.root, className); @@ -20,18 +31,4 @@ const AspectRatioWrapper = props => { ); }; -AspectRatioWrapper.defaultProps = { - className: null, - rootClassName: null, - children: null, -}; - -AspectRatioWrapper.propTypes = { - className: string, - rootClassName: string, - width: number.isRequired, - height: number.isRequired, - children: node, -}; - export default AspectRatioWrapper; diff --git a/src/components/Avatar/Avatar.js b/src/components/Avatar/Avatar.js index a6768a692..f5c45aa48 100644 --- a/src/components/Avatar/Avatar.js +++ b/src/components/Avatar/Avatar.js @@ -1,8 +1,6 @@ import React from 'react'; -import { string, oneOfType, bool } from 'prop-types'; -import { injectIntl, intlShape } from '../../util/reactIntl'; +import { useIntl } from '../../util/reactIntl'; import classNames from 'classnames'; -import { propTypes } from '../../util/types'; import { ensureUser, ensureCurrentUser, @@ -34,15 +32,27 @@ const AVATAR_IMAGE_VARIANTS = [ 'square-small2x', ]; -export const AvatarComponent = props => { +/** + * Menu for mobile layout (opens through hamburger icon) + * + * @component + * @param {Object} props + * @param {string?} props.className add more style rules in addition to components own css.root + * @param {string?} props.rootClassName overwrite components own css.root + * @param {Object?} props.user API entity + * @param {string} props.renderSizes + * @param {boolean} props.disableProfileLink + * @returns {JSX.Element} search icon + */ +export const Avatar = props => { + const intl = useIntl(); const { rootClassName, className, initialsClassName, user, - renderSizes, + renderSizes = AVATAR_SIZES, disableProfileLink, - intl, } = props; const classes = classNames(rootClassName || css.root, className); @@ -126,28 +136,6 @@ export const AvatarComponent = props => { } }; -AvatarComponent.defaultProps = { - className: null, - rootClassName: null, - user: null, - renderSizes: AVATAR_SIZES, - disableProfileLink: false, -}; - -AvatarComponent.propTypes = { - rootClassName: string, - className: string, - user: oneOfType([propTypes.user, propTypes.currentUser]), - - renderSizes: string, - disableProfileLink: bool, - - // from injectIntl - intl: intlShape.isRequired, -}; - -const Avatar = injectIntl(AvatarComponent); - export default Avatar; export const AvatarSmall = props => ( diff --git a/src/components/Button/Button.js b/src/components/Button/Button.js index 1bbdad4b3..3c3a5c87e 100644 --- a/src/components/Button/Button.js +++ b/src/components/Button/Button.js @@ -1,5 +1,4 @@ import React, { useEffect, useState } from 'react'; -import { bool, node, string } from 'prop-types'; import classNames from 'classnames'; import { useRouteConfiguration } from '../../context/routeConfigurationContext'; @@ -100,6 +99,22 @@ const ButtonWithPagePreload = props => { return ; }; +/** + * Topbar containing logo, main search and navigation links. + * + * @component + * @param {Object} props + * @param {string?} props.className add more style rules in addition to components own css.root + * @param {string?} props.rootClassName overwrite components own css.root + * @param {string?} props.spinnerClassName overwrite components own css.spinner + * @param {string?} props.checkmarkClassName overwrite components own css.checkmark + * @param {boolean} props.inProgress + * @param {boolean} props.ready + * @param {boolean} props.disabled + * @param {string?} props.enforcePagePreloadFor + * @param {ReactNode?} props.children + * @returns {JSX.Element} topbar component + */ const Button = props => { const { enforcePagePreloadFor, ...restProps } = props; return enforcePagePreloadFor ? ( @@ -109,32 +124,6 @@ const Button = props => { ); }; -Button.defaultProps = { - rootClassName: null, - className: null, - spinnerClassName: null, - checkmarkClassName: null, - inProgress: false, - ready: false, - disabled: false, - enforcePagePreloadFor: null, - children: null, -}; - -Button.propTypes = { - rootClassName: string, - className: string, - spinnerClassName: string, - checkmarkClassName: string, - - inProgress: bool, - ready: bool, - disabled: bool, - enforcePagePreloadFor: string, - - children: node, -}; - export default Button; export const PrimaryButton = props => { diff --git a/src/components/DatePicker/FieldDateRangeController/FieldDateRangeController.js b/src/components/DatePicker/FieldDateRangeController/FieldDateRangeController.js index 35fbd7562..462f96c3e 100644 --- a/src/components/DatePicker/FieldDateRangeController/FieldDateRangeController.js +++ b/src/components/DatePicker/FieldDateRangeController/FieldDateRangeController.js @@ -7,7 +7,6 @@ */ import React from 'react'; -import { bool, func, object, string, number } from 'prop-types'; import { Field } from 'react-final-form'; import classNames from 'classnames'; @@ -73,25 +72,19 @@ const FieldDateRangeControllerComponent = props => { ); }; -FieldDateRangeControllerComponent.defaultProps = { - className: null, - rootClassName: null, - useMobileMargins: false, - minimumNights: 1, -}; - -FieldDateRangeControllerComponent.propTypes = { - className: string, - rootClassName: string, - minimumNights: number, - useMobileMargins: bool, - input: object.isRequired, - meta: object.isRequired, - - isOutsideRange: func.isRequired, - firstDayOfWeek: number.isRequired, -}; - +/** + * A component that provides a date range picker for Final Forms. + * + * @component + * @param {Object} props + * @param {string} [props.className] - Custom class that extends the default class for the root element + * @param {string} [props.rootClassName] - Custom class that overrides the default class for the root element + * @param {number} [props.minimumNights] - The minimum number of nights for the date range + * @param {boolean} [props.useMobileMargins] - Whether to use mobile margins + * @param {Function} [props.isOutsideRange] - The function to check if a day is outside the range + * @param {number} [props.firstDayOfWeek] - The first day of the week (0-6, default to value set in configuration) + * @returns {JSX.Element} FieldDateRangeController component + */ const FieldDateRangeController = props => { const config = useConfiguration(); const { isOutsideRange, firstDayOfWeek, ...rest } = props; diff --git a/src/components/DatePicker/FieldDateRangePicker/FieldDateRangePicker.js b/src/components/DatePicker/FieldDateRangePicker/FieldDateRangePicker.js index b6345938e..ad798954f 100644 --- a/src/components/DatePicker/FieldDateRangePicker/FieldDateRangePicker.js +++ b/src/components/DatePicker/FieldDateRangePicker/FieldDateRangePicker.js @@ -7,7 +7,6 @@ */ import React from 'react'; -import { bool, func, object, string, number } from 'prop-types'; import { Field } from 'react-final-form'; import classNames from 'classnames'; @@ -101,40 +100,23 @@ const FieldDateRangePickerComponent = props => { ); }; -FieldDateRangePickerComponent.defaultProps = { - className: null, - rootClassName: null, - inputClassName: null, - popupClassName: null, - useMobileMargins: false, - endDateId: null, - endDateLabel: null, - endDatePlaceholderText: null, - startDateId: null, - startDateLabel: null, - startDatePlaceholderText: null, -}; - -FieldDateRangePickerComponent.propTypes = { - className: string, - rootClassName: string, - inputClassName: string, - popupClassName: string, - isDaily: bool.isRequired, - useMobileMargins: bool, - endDateId: string, - endDateLabel: string, - endDatePlaceholderText: string, - startDateId: string, - startDateLabel: string, - startDatePlaceholderText: string, - input: object.isRequired, - meta: object.isRequired, - - isOutsideRange: func.isRequired, - firstDayOfWeek: number.isRequired, -}; - +/** + * A component that provides a date range picker for Final Forms. + * + * @component + * @param {Object} props + * @param {string} [props.className] - Custom class that extends the default class for the root element + * @param {string} [props.rootClassName] - Custom class that overrides the default class for the root element + * @param {boolean} [props.isDaily] - Whether the date range is daily + * @param {boolean} [props.useMobileMargins] - Whether to use mobile margins (allowing the input to expand to the full width of the screen) + * @param {string} [props.endDateId] - The ID of the end date input + * @param {string} [props.endDateLabel] - The label of the end date input + * @param {string} [props.startDateId] - The ID of the start date input + * @param {string} [props.startDateLabel] - The label of the start date input + * @param {Function} [props.isOutsideRange] - The function to check if a day is outside the range + * @param {number} [props.firstDayOfWeek] - The first day of the week (0-6, default to value set in configuration) + * @returns {JSX.Element} FieldDateRangePicker component + */ const FieldDateRangePicker = props => { const config = useConfiguration(); const { isOutsideRange, firstDayOfWeek, ...rest } = props; diff --git a/src/components/DatePicker/FieldSingleDatePicker/FieldSingleDatePicker.js b/src/components/DatePicker/FieldSingleDatePicker/FieldSingleDatePicker.js index d33cfe8b8..d918bf974 100644 --- a/src/components/DatePicker/FieldSingleDatePicker/FieldSingleDatePicker.js +++ b/src/components/DatePicker/FieldSingleDatePicker/FieldSingleDatePicker.js @@ -6,7 +6,6 @@ */ import React from 'react'; -import { bool, func, object, string } from 'prop-types'; import { Field } from 'react-final-form'; import classNames from 'classnames'; @@ -87,36 +86,29 @@ const FieldSingleDatePickerComponent = props => { ); }; -FieldSingleDatePickerComponent.defaultProps = { - className: null, - rootClassName: null, - inputClassName: null, - popupClassName: null, - id: null, - label: null, - useMobileMargins: false, - showErrorMessage: true, - showLabelAsDisabled: false, - placeholderText: null, - onChange: null, -}; - -FieldSingleDatePickerComponent.propTypes = { - className: string, - rootClassName: string, - inputClassName: string, - popupClassName: string, - id: string, - label: string, - useMobileMargins: bool, - showErrorMessage: bool, - showLabelAsDisabled: bool, - placeholderText: string, - input: object.isRequired, - meta: object.isRequired, - onChange: func, -}; - +/** + * A component that provides a single date picker for Final Forms. + * + * NOTE: On mobile screens, this puts the input on read-only mode. + * Trying to enter date string (ISO formatted or US) on mobile browsers is more confusing that just tapping a date. + * + * @component + * @param {Object} props + * @param {string} [props.className] - Custom class that extends the default class for the root element + * @param {string} [props.rootClassName] - Custom class that overrides the default class for the root element + * @param {string} [props.inputClassName] - Custom class that extends the default class for the container of the input element + * @param {string} [props.popupClassName] - Custom class that over the default class for the popup element css.poup + * @param {string} [props.id] - The ID of the input + * @param {string} [props.label] - The label of the input + * @param {boolean} [props.showLabelAsDisabled] - Whether to show the label as disabled + * @param {string} [props.placeholderText] - The placeholder text of the input + * @param {boolean} [props.useMobileMargins] - Whether to use mobile margins (allowing the input to expand to the full width of the screen) + * @param {Function} [props.isOutsideRange] - The function to check if a day is outside the range + * @param {Function} [props.isDayBlocked] - The function to check if a day is blocked + * @param {Function} [props.onChange] - The function to handle the change of the input + * @param {number} [props.firstDayOfWeek] - The first day of the week (0-6, default to value set in configuration) + * @returns {JSX.Element} FieldSingleDatePicker component + */ const FieldSingleDatePicker = props => { const config = useConfiguration(); const { isOutsideRange, firstDayOfWeek, ...rest } = props; diff --git a/src/components/ExternalLink/ExternalLink.js b/src/components/ExternalLink/ExternalLink.js index fa91ab341..d66c46d51 100644 --- a/src/components/ExternalLink/ExternalLink.js +++ b/src/components/ExternalLink/ExternalLink.js @@ -1,10 +1,18 @@ import React from 'react'; -import { node, string } from 'prop-types'; -// External link that opens in a new tab/window, ensuring that the -// opened page doesn't have access to the current page. -// -// See: https://mathiasbynens.github.io/rel-noopener/ +/** + * External link that opens in a new tab/window, ensuring that the + * opened page doesn't have access to the current page. + * See: https://mathiasbynens.github.io/rel-noopener/ + * + * @component + * @param {Object} props + * @param {string?} props.className add more style rules in addition to components own css.root + * @param {string?} props.rootClassName overwrite components own css.root + * @param {string} props.target attribute for element + * @param {ReactNode} props.children + * @returns {JSX.Element} External link + */ const ExternalLink = props => { const { children, target, ...rest } = props; const targetProp = target || '_blank'; @@ -19,8 +27,4 @@ const ExternalLink = props => { ); }; -ExternalLink.defaultProps = { children: null, target: '_blank' }; - -ExternalLink.propTypes = { children: node, target: string }; - export default ExternalLink; diff --git a/src/components/FieldBoolean/FieldBoolean.js b/src/components/FieldBoolean/FieldBoolean.js index 72253e1d9..21528abba 100644 --- a/src/components/FieldBoolean/FieldBoolean.js +++ b/src/components/FieldBoolean/FieldBoolean.js @@ -1,9 +1,10 @@ import React from 'react'; -import { injectIntl, intlShape } from '../../util/reactIntl'; +import { useIntl } from '../../util/reactIntl'; import { FieldSelect } from '../../components'; const FieldBoolean = props => { - const { placeholder, intl, ...rest } = props; + const intl = useIntl(); + const { placeholder, ...rest } = props; const trueLabel = intl.formatMessage({ id: 'FieldBoolean.yes', }); @@ -38,8 +39,4 @@ const FieldBoolean = props => { ); }; -FieldBoolean.propTypes = { - intl: intlShape.isRequired, -}; - -export default injectIntl(FieldBoolean); +export default FieldBoolean; diff --git a/src/components/FieldCheckbox/FieldCheckbox.js b/src/components/FieldCheckbox/FieldCheckbox.js index 039ec198d..8361a3796 100644 --- a/src/components/FieldCheckbox/FieldCheckbox.js +++ b/src/components/FieldCheckbox/FieldCheckbox.js @@ -1,10 +1,19 @@ import React from 'react'; -import { node, string } from 'prop-types'; import classNames from 'classnames'; import { Field } from 'react-final-form'; import css from './FieldCheckbox.module.css'; +/** + * IconCheckbox + * + * @component + * @param {Object} props + * @param {string?} props.className add more style rules in addition to components own css.root + * @param {string?} props.checkedClassName overwrite components own css.checked + * @param {string?} props.boxClassName overwrite components own css.box + * @returns {JSX.Element} checkbox svg that places the native checkbox + */ const IconCheckbox = props => { const { className, checkedClassName, boxClassName } = props; return ( @@ -30,11 +39,22 @@ const IconCheckbox = props => { ); }; -IconCheckbox.defaultProps = { className: null, checkedClassName: null, boxClassName: null }; - -IconCheckbox.propTypes = { className: string, checkedClassName: string, boxClassName: string }; - -const FieldCheckboxComponent = props => { +/** + * Final Form Field containing checkbox input + * + * @component + * @param {Object} props + * @param {string?} props.className add more style rules in addition to components own css.root + * @param {string?} props.rootClassName overwrite components own css.root + * @param {string?} props.svgClassName is passed to checkbox svg as className + * @param {string?} props.textClassName overwrite components own css.textRoot given to label + * @param {string} props.id givent to input + * @param {string} props.name Name groups several checkboxes to an array of selected values + * @param {string} props.value Checkbox needs a value that is passed forward when user checks the checkbox + * @param {ReactNode} props.label + * @returns {JSX.Element} Final Form Field containing checkbox input + */ +const FieldCheckbox = props => { const { rootClassName, className, @@ -104,29 +124,4 @@ const FieldCheckboxComponent = props => { ); }; -FieldCheckboxComponent.defaultProps = { - className: null, - rootClassName: null, - svgClassName: null, - textClassName: null, - label: null, -}; - -FieldCheckboxComponent.propTypes = { - className: string, - rootClassName: string, - svgClassName: string, - textClassName: string, - - // Id is needed to connect the label with input. - id: string.isRequired, - label: node, - - // Name groups several checkboxes to an array of selected values - name: string.isRequired, - - // Checkbox needs a value that is passed forward when user checks the checkbox - value: string.isRequired, -}; - -export default FieldCheckboxComponent; +export default FieldCheckbox; diff --git a/src/components/FieldCheckboxGroup/FieldCheckboxGroup.js b/src/components/FieldCheckboxGroup/FieldCheckboxGroup.js index 6570c1d43..6f1cf76cb 100644 --- a/src/components/FieldCheckboxGroup/FieldCheckboxGroup.js +++ b/src/components/FieldCheckboxGroup/FieldCheckboxGroup.js @@ -8,7 +8,6 @@ */ import React from 'react'; -import { arrayOf, bool, node, shape, string } from 'prop-types'; import classNames from 'classnames'; import { FieldArray } from 'react-final-form-arrays'; import { FieldCheckbox, ValidationError } from '../../components'; @@ -57,33 +56,35 @@ const FieldCheckboxRenderer = props => { ); }; -FieldCheckboxRenderer.defaultProps = { - rootClassName: null, - className: null, - label: null, - twoColumns: false, -}; +// Note: name and component are required fields for FieldArray. +// Component-prop we define in this file, name needs to be passed in -FieldCheckboxRenderer.propTypes = { - rootClassName: string, - className: string, - id: string.isRequired, - label: node, - options: arrayOf( - shape({ - key: string.isRequired, - label: node.isRequired, - }) - ).isRequired, - twoColumns: bool, -}; +/** + * @typedef {Object} CheckboxGroupOption + * @property {string} key + * @property {string} label + */ +/** + * Final Form Field containing checkbox group. + * Renders a group of checkboxes that can be used to select + * multiple values from a set of options. + * + * The corresponding component when rendering the selected + * values is PropertyGroup. + * + * @component + * @param {Object} props + * @param {string} props.name this is required for FieldArray (Final Form component) + * @param {string?} props.className add more style rules in addition to components own css.root + * @param {string?} props.rootClassName overwrite components own css.root + * @param {string?} props.optionLabelClassName given to each option + * @param {string} props.id givent to input + * @param {ReactNode} props.label the label for the checkbox group + * @param {Array} props.options E.g. [{ key, label }] + * @param {boolean} props.twoColumns + * @returns {JSX.Element} Final Form Field containing multiple checkbox inputs + */ const FieldCheckboxGroup = props => ; -// Name and component are required fields for FieldArray. -// Component-prop we define in this file, name needs to be passed in -FieldCheckboxGroup.propTypes = { - name: string.isRequired, -}; - export default FieldCheckboxGroup; diff --git a/src/components/FieldCurrencyInput/FieldCurrencyInput.js b/src/components/FieldCurrencyInput/FieldCurrencyInput.js index 952415fdf..44beb5967 100644 --- a/src/components/FieldCurrencyInput/FieldCurrencyInput.js +++ b/src/components/FieldCurrencyInput/FieldCurrencyInput.js @@ -215,28 +215,6 @@ class CurrencyInputComponent extends Component { } } -CurrencyInputComponent.defaultProps = { - className: null, - defaultValue: null, - input: null, - placeholder: null, -}; - -CurrencyInputComponent.propTypes = { - className: string, - currencyConfig: propTypes.currencyConfig.isRequired, - defaultValue: number, - intl: intlShape.isRequired, - input: shape({ - value: oneOfType([string, propTypes.money]), - onBlur: func, - onChange: func.isRequired, - onFocus: func, - }).isRequired, - - placeholder: string, -}; - export const CurrencyInput = injectIntl(CurrencyInputComponent); const FieldCurrencyInputComponent = props => { @@ -268,29 +246,31 @@ const FieldCurrencyInputComponent = props => { ); }; -FieldCurrencyInputComponent.defaultProps = { - rootClassName: null, - className: null, - id: null, - label: null, - hideErrorMessage: false, -}; - -FieldCurrencyInputComponent.propTypes = { - rootClassName: string, - className: string, - - // Label is optional, but if it is given, an id is also required so - // the label can reference the input in the `for` attribute - id: string, - label: string, - hideErrorMessage: bool, - - // Generated by final-form's Field component - input: object.isRequired, - meta: object.isRequired, -}; - +/** + * Final Form Field containing currency input. + * CurrencyInput renders an input field that format it's value according to currency formatting rules + * onFocus: renders given value in unformatted manner: "9999,99" + * onBlur: formats the given input: "9 999,99 €" + * + * @component + * @param {Object} props + * @param {string?} props.className add more style rules in addition to components own css.root + * @param {string?} props.rootClassName overwrite components own css.root + * @param {string} props.name name for the input attribute + * @param {string} props.id given to input + * @param {ReactNode} props.label + * @param {Object} props.currencyConfig + * @param {'currency'} props.currencyConfig.style + * @param {string} props.currencyConfig.currency E.g. 'USD' + * @param {'symbol'} props.currencyConfig.currencyDisplay + * @param {boolean} props.currencyConfig.useGrouping E.g true + * @param {number} props.currencyConfig.minimumFractionDigits E.g 2 + * @param {number} props.currencyConfig.maximumFractionDigits E.g 2 + * @param {boolean} props.hideErrorMessage + * @param {number?} props.defaultValue + * @param {string?} props.placeholder + * @returns {JSX.Element} Final Form Field containing currency input + */ const FieldCurrencyInput = props => { return ; }; diff --git a/src/components/FieldRadioButton/FieldRadioButton.js b/src/components/FieldRadioButton/FieldRadioButton.js index 88819f5ce..23fd1863c 100644 --- a/src/components/FieldRadioButton/FieldRadioButton.js +++ b/src/components/FieldRadioButton/FieldRadioButton.js @@ -1,17 +1,25 @@ import React from 'react'; -import { node, string } from 'prop-types'; import classNames from 'classnames'; import { Field } from 'react-final-form'; import css from './FieldRadioButton.module.css'; +/** + * IconRadioButton + * + * @component + * @param {Object} props + * @param {string?} props.checkedClassName overwrite components own css.checkedStyle + * @param {boolean?} props.showAsRequired adds attention color for the icon if not selected + * @returns {JSX.Element} checkbox svg that places the native radio button + */ const IconRadioButton = props => { - const { checkedClassName } = props; + const { className, checkedClassName, showAsRequired } = props; return (
- + { ); }; -IconRadioButton.defaultProps = { className: null }; - -IconRadioButton.propTypes = { className: string }; - -const FieldRadioButtonComponent = props => { +/** + * Final Form Field containing radio button input + * + * @component + * @param {Object} props + * @param {string?} props.className add more style rules in addition to components own css.root + * @param {string?} props.rootClassName overwrite components own css.root + * @param {string?} props.svgClassName is passed to radio button svg as className + * @param {string?} props.checkedClassName overwrite components own css.checkedStyle given to icon + * @param {string} props.id Id is needed to connect the label with input. + * @param {string} props.name Name groups several RadioButtons to be alternative values for this "key" + * @param {string} props.value RadioButton needs a value that is passed forward when user checks the RadioButton + * @param {ReactNode} props.label + * @param {boolean?} props.showAsRequired adds attention color for the icon if not selected + * @returns {JSX.Element} Final Form Field containing radio button input + */ +const FieldRadioButton = props => { const { rootClassName, className, @@ -77,29 +97,4 @@ const FieldRadioButtonComponent = props => { ); }; -FieldRadioButtonComponent.defaultProps = { - className: null, - rootClassName: null, - svgClassName: null, - checkedClassName: null, - label: null, -}; - -FieldRadioButtonComponent.propTypes = { - className: string, - rootClassName: string, - svgClassName: string, - checkedClassName: string, - - // Id is needed to connect the label with input. - id: string.isRequired, - label: node, - - // Name groups several RadioButtones to an array of selected values - name: string.isRequired, - - // RadioButton needs a value that is passed forward when user checks the RadioButton - value: string.isRequired, -}; - -export default FieldRadioButtonComponent; +export default FieldRadioButton; diff --git a/src/components/FieldReviewRating/FieldReviewRating.js b/src/components/FieldReviewRating/FieldReviewRating.js index 3942d4980..cdc440d55 100644 --- a/src/components/FieldReviewRating/FieldReviewRating.js +++ b/src/components/FieldReviewRating/FieldReviewRating.js @@ -1,6 +1,5 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { intlShape, injectIntl } from '../../util/reactIntl'; +import React from 'react'; +import { useIntl } from '../../util/reactIntl'; import { Field } from 'react-final-form'; import classNames from 'classnames'; import { IconReviewStar, ValidationError } from '../../components'; @@ -83,37 +82,22 @@ const FieldReviewRatingComponent = props => { ); }; -FieldReviewRatingComponent.defaultProps = { - rootClassName: null, - className: null, - customErrorText: null, - label: null, -}; - -const { string, shape, func, object } = PropTypes; - -FieldReviewRatingComponent.propTypes = { - rootClassName: string, - className: string, - id: string.isRequired, - label: string, - - // Error message that can be manually passed to input field, - // overrides default validation message - customErrorText: string, - - // Generated by final-form's Field component - input: shape({ - onChange: func.isRequired, - }).isRequired, - meta: object.isRequired, - - // from injectIntl - intl: intlShape.isRequired, -}; - +/** + * Final Form Field containing review rating 'stars' + * + * @component + * @param {Object} props + * @param {string?} props.className add more style rules in addition to components own css.root + * @param {string?} props.rootClassName overwrite components own css.root + * @param {string} props.name Name of the input in Final Form + * @param {string} props.id + * @param {ReactNode} props.label + * @param {string} props.customErrorText Error message that can be manually passed to input field, overrides default validation message + * @returns {JSX.Element} Final Form Field containing review rating input + */ const FieldReviewRating = props => { - return ; + const intl = useIntl(); + return ; }; -export default injectIntl(FieldReviewRating); +export default FieldReviewRating; diff --git a/src/components/FieldSelect/FieldSelect.js b/src/components/FieldSelect/FieldSelect.js index 032e6cf7f..84747e951 100644 --- a/src/components/FieldSelect/FieldSelect.js +++ b/src/components/FieldSelect/FieldSelect.js @@ -1,5 +1,4 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { Field } from 'react-final-form'; import classNames from 'classnames'; import { ValidationError } from '../../components'; @@ -53,34 +52,20 @@ const FieldSelectComponent = props => { ); }; -FieldSelectComponent.defaultProps = { - rootClassName: null, - className: null, - selectClassName: null, - id: null, - label: null, - children: null, -}; - -const { string, object, node } = PropTypes; - -FieldSelectComponent.propTypes = { - rootClassName: string, - className: string, - selectClassName: string, - - // Label is optional, but if it is given, an id is also required so - // the label can reference the input in the `for` attribute - id: string, - label: string, - - // Generated by final-form's Field component - input: object.isRequired, - meta: object.isRequired, - - children: node, -}; - +/** + * Final Form Field wrapping
- {labelInfo} - - {hideErrorMessage ? null : } -
- ); - } -} - -LocationAutocompleteInputComponent.defaultProps = { - rootClassName: null, - labelClassName: null, - type: null, - label: null, -}; - -LocationAutocompleteInputComponent.propTypes = { - rootClassName: string, - labelClassName: string, - input: shape({ - onChange: func.isRequired, - name: string.isRequired, - }).isRequired, - label: string, - meta: object.isRequired, +/** + * LocationAutocompleteInput component. + * + * @component + * @param {Object} props + * @param {string?} props.className add more style rules in addition to components own css.root + * @param {string?} props.rootClassName overwrite components own css.root + * @param {string?} props.labelClassName + * @param {string?} props.label + * @param {boolean} props.hideErrorMessage + * @param {Object} props.input + * @param {string} props.input.name + * @param {Function} props.input.onChange + * @param {Object} props.meta + * @returns {JSX.Element} arrow head icon + */ +const LocationAutocompleteInputComponent = props => { + /* eslint-disable no-unused-vars */ + const { rootClassName, labelClassName, hideErrorMessage, ...restProps } = props; + const { input, label, meta, valueFromForm, ...otherProps } = restProps; + /* eslint-enable no-unused-vars */ + + const value = typeof valueFromForm !== 'undefined' ? valueFromForm : input.value; + const locationAutocompleteProps = { label, meta, ...otherProps, input: { ...input, value } }; + const labelInfo = label ? ( + + ) : null; + + return ( +
+ {labelInfo} + + {hideErrorMessage ? null : } +
+ ); }; export default LocationAutocompleteInputImpl; diff --git a/src/components/LocationAutocompleteInput/LocationAutocompleteInputImpl.js b/src/components/LocationAutocompleteInput/LocationAutocompleteInputImpl.js index f0abcb4e3..90ad8cd83 100644 --- a/src/components/LocationAutocompleteInput/LocationAutocompleteInputImpl.js +++ b/src/components/LocationAutocompleteInput/LocationAutocompleteInputImpl.js @@ -1,22 +1,9 @@ import React, { Component } from 'react'; -import { - any, - arrayOf, - bool, - func, - number, - shape, - string, - oneOfType, - object, - node, -} from 'prop-types'; import classNames from 'classnames'; import debounce from 'lodash/debounce'; import { useConfiguration } from '../../context/configurationContext'; import { FormattedMessage } from '../../util/reactIntl'; -import { propTypes } from '../../util/types'; import { IconSpinner } from '../../components'; @@ -127,25 +114,6 @@ const LocationPredictionsList = props => { ); }; -LocationPredictionsList.defaultProps = { - rootClassName: null, - className: null, - highlightedIndex: null, -}; - -LocationPredictionsList.propTypes = { - rootClassName: string, - className: string, - children: node, - predictions: arrayOf(object).isRequired, - currentLocationId: string.isRequired, - geocoder: object.isRequired, - highlightedIndex: number, - onSelectStart: func.isRequired, - onSelectMove: func.isRequired, - onSelectEnd: func.isRequired, -}; - // Get the current value with defaults from the given // LocationAutocompleteInput props. const currentValue = props => { @@ -154,21 +122,6 @@ const currentValue = props => { return { search, predictions, selectedPlace }; }; -/* - Location auto completion input component - - This component can work as the `component` prop to Final Form's - component. It takes a custom input value shape, and - controls the onChange callback that is called with the input value. - - The component works by listening to the underlying input component - and calling a Geocoder implementation for predictions. When the - predictions arrive, those are passed to Final Form in the onChange - callback. - - See the LocationAutocompleteInput.example.js file for a usage - example within a form. -*/ class LocationAutocompleteInputImplementation extends Component { constructor(props) { super(props); @@ -228,7 +181,7 @@ class LocationAutocompleteInputImplementation extends Component { currentPredictions() { const { search, predictions: fetchedPredictions } = currentValue(this.props); - const { useDefaultPredictions, config } = this.props; + const { useDefaultPredictions = true, config } = this.props; const hasFetchedPredictions = fetchedPredictions && fetchedPredictions.length > 0; const showDefaultPredictions = !search && !hasFetchedPredictions && useDefaultPredictions; const geocoderVariant = getGeocoderVariant(config.maps.mapProvider); @@ -485,7 +438,7 @@ class LocationAutocompleteInputImplementation extends Component { predictionsClassName, predictionsAttributionClassName, validClassName, - placeholder, + placeholder = '', input, meta, inputRef, @@ -515,6 +468,21 @@ class LocationAutocompleteInputImplementation extends Component { const renderPredictions = this.state.inputHasFocus; const geocoderVariant = getGeocoderVariant(config.maps.mapProvider); const GeocoderAttribution = geocoderVariant.GeocoderAttribution; + // The first ref option in this optional chain is about callback ref, + // which was used in previous version of this Template. + const refMaybe = + typeof inputRef === 'function' + ? { + ref: node => { + this.input = node; + if (inputRef) { + inputRef(node); + } + }, + } + : inputRef + ? { ref: inputRef } + : {}; return (
@@ -538,12 +506,7 @@ class LocationAutocompleteInputImplementation extends Component { onBlur={this.handleOnBlur} onChange={this.onChange} onKeyDown={this.onKeyDown} - ref={node => { - this.input = node; - if (inputRef) { - inputRef(node); - } - }} + {...refMaybe} title={search} data-testid="location-search" /> @@ -567,59 +530,62 @@ class LocationAutocompleteInputImplementation extends Component { } } +/** + * @typedef {Object} SearchData + * @property {string} search + * @property {Object} predictions + * @property {Object} selectedPlace + */ + +/** + * @typedef {Object} SearchData + * @property {Object} current + */ + +/** + * Location auto completion input component + * + * This component can work as the `component` prop to Final Form's + * component. It takes a custom input value shape, and + * controls the onChange callback that is called with the input value. + * + * The component works by listening to the underlying input component + * and calling a Geocoder implementation for predictions. When the + * predictions arrive, those are passed to Final Form in the onChange + * callback. + * + * See the LocationAutocompleteInput.example.js file for a usage + * example within a form. + * + * @component + * @param {Object} props + * @param {string?} props.className add more style rules in addition to components own css.root + * @param {string?} props.rootClassName overwrite components own css.root + * @param {string?} props.iconClassName + * @param {string?} props.inputClassName + * @param {string?} props.predictionsClassName + * @param {string?} props.predictionsAttributionClassName + * @param {string?} props.validClassName + * @param {boolean} props.autoFocus + * @param {boolean} props.closeOnBlur + * @param {string?} props.placeholder + * @param {boolean} props.useDefaultPredictions + * @param {Object} props.input + * @param {string} props.input.name + * @param {string|SearchData} props.input.value + * @param {Function} props.input.onChange + * @param {Function} props.input.onFocus + * @param {Function} props.input.onBlur + * @param {Object} props.meta + * @param {boolean} props.meta.valid + * @param {boolean} props.meta.touched + * @param {Function | RefHook} props.inputRef + * @returns {JSX.Element} LocationAutocompleteInputImpl component + */ const LocationAutocompleteInputImpl = props => { const config = useConfiguration(); return ; }; -LocationAutocompleteInputImpl.defaultProps = { - autoFocus: false, - closeOnBlur: true, - rootClassName: null, - className: null, - iconClassName: null, - inputClassName: null, - predictionsClassName: null, - predictionsAttributionClassName: null, - validClassName: null, - placeholder: '', - useDefaultPredictions: true, - meta: null, - inputRef: null, -}; - -LocationAutocompleteInputImpl.propTypes = { - autoFocus: bool, - rootClassName: string, - className: string, - closeOnBlur: bool, - iconClassName: string, - inputClassName: string, - predictionsClassName: string, - predictionsAttributionClassName: string, - validClassName: string, - placeholder: string, - useDefaultPredictions: bool, - input: shape({ - name: string.isRequired, - value: oneOfType([ - shape({ - search: string, - predictions: any, - selectedPlace: propTypes.place, - }), - string, - ]), - onChange: func.isRequired, - onFocus: func.isRequired, - onBlur: func.isRequired, - }).isRequired, - meta: shape({ - valid: bool.isRequired, - touched: bool.isRequired, - }), - inputRef: func, -}; - export default LocationAutocompleteInputImpl; diff --git a/src/components/Logo/LinkedLogo.js b/src/components/Logo/LinkedLogo.js index 0fb9eb968..3b3390196 100644 --- a/src/components/Logo/LinkedLogo.js +++ b/src/components/Logo/LinkedLogo.js @@ -1,18 +1,32 @@ import React from 'react'; -import { oneOf, shape, string } from 'prop-types'; import classNames from 'classnames'; import { ExternalLink, Logo, NamedLink } from '../../components'; import css from './LinkedLogo.module.css'; +/** + * This component returns a clickable logo. + * + * @component + * @param {Object} props + * @param {string?} props.className add more style rules in addition to components own css.root + * @param {string?} props.rootClassName overwrite components own css.root + * @param {string?} props.logoClassName andd more style rules in addtion to css.logo + * @param {string?} props.logoImageClassName overwrite components own css.root + * @param {('desktop' | 'mobile')} props.layout + * @param {Object} props.linkToExternalSite + * @param {string} props.linkToExternalSite.href + * @param {string?} props.alt alt text for logo image + * @returns {JSX.Element} linked logo component + */ const LinkedLogo = props => { const { className, rootClassName, logoClassName, logoImageClassName, - layout, + layout = 'desktop', linkToExternalSite, alt, ...rest @@ -40,24 +54,4 @@ const LinkedLogo = props => { ); }; -LinkedLogo.defaultProps = { - className: null, - rootClassName: null, - logoClassName: null, - logoImageClassName: null, - layout: 'desktop', - linkToExternalSite: null, -}; - -LinkedLogo.propTypes = { - className: string, - rootClassName: string, - logoClassName: string, - logoImageClassName: string, - layout: oneOf(['desktop', 'mobile']), - linkToExternalSite: shape({ - href: string.isRequired, - }), -}; - export default LinkedLogo; diff --git a/src/components/Logo/Logo.js b/src/components/Logo/Logo.js index 302cf13d6..06ad8e4f7 100644 --- a/src/components/Logo/Logo.js +++ b/src/components/Logo/Logo.js @@ -1,5 +1,4 @@ import React from 'react'; -import { oneOf, string } from 'prop-types'; import classNames from 'classnames'; import { useConfiguration } from '../../context/configurationContext'; @@ -110,14 +109,27 @@ export const LogoComponent = props => { ); }; +/** + * This component returns a logo + * + * @component + * @param {Object} props + * @param {string?} props.className add more style rules in addition to components own css.root + * @param {string?} props.rootClassName overwrite components own css.root + * @param {('desktop' | 'mobile')} props.layout + * @param {string?} props.alt alt text for logo image + * @returns {JSX.Element} logo component + */ const Logo = props => { const config = useConfiguration(); + const { layout = 'desktop', ...rest } = props; // NOTE: logo images are set in hosted branding.json asset or src/config/brandingConfig.js const { logoImageDesktop, logoImageMobile, logoSettings } = config.branding; return ( { ); }; -Logo.defaultProps = { - className: null, - layout: 'desktop', -}; - -Logo.propTypes = { - className: string, - layout: oneOf(['desktop', 'mobile']), -}; - export default Logo; diff --git a/src/components/Map/DynamicGoogleMap.js b/src/components/Map/DynamicGoogleMap.js index 0b0f67138..043ea9846 100644 --- a/src/components/Map/DynamicGoogleMap.js +++ b/src/components/Map/DynamicGoogleMap.js @@ -1,9 +1,20 @@ import React, { Component } from 'react'; -import { number, object, shape, string } from 'prop-types'; import { circlePolyline } from '../../util/maps'; /** - * DynamicGoogleMap uses Google Maps API. + * Map that uses Google Maps and is fully dynamic (zoom, pan, etc.). + * + * @component + * @param {Object} props + * @param {string?} props.containerClassName add style rules for the root container + * @param {string?} props.mapClassName add style rules for the map div + * @param {string?} props.address + * @param {Object} props.center LatLng + * @param {number} props.center.lat latitude + * @param {number} props.center.lng longitude + * @param {number} props.zoom + * @param {Object} props.mapsConfig + * @returns {JSX.Element} dynamic version of Google Maps */ class DynamicGoogleMap extends Component { constructor(props) { @@ -94,19 +105,4 @@ class DynamicGoogleMap extends Component { } } -DynamicGoogleMap.defaultProps = { - address: '', - center: null, -}; - -DynamicGoogleMap.propTypes = { - address: string, - center: shape({ - lat: number.isRequired, - lng: number.isRequired, - }).isRequired, - zoom: number.isRequired, - mapsConfig: object.isRequired, -}; - export default DynamicGoogleMap; diff --git a/src/components/Map/DynamicMapboxMap.js b/src/components/Map/DynamicMapboxMap.js index 4c53b4c5a..dbce69528 100644 --- a/src/components/Map/DynamicMapboxMap.js +++ b/src/components/Map/DynamicMapboxMap.js @@ -1,5 +1,4 @@ import React, { Component } from 'react'; -import { string, shape, number, object } from 'prop-types'; // This MultiTouch lib is used for 2-finger panning. // which prevents user to experience map-scroll trap, while scrolling the page. // https://github.com/mapbox/mapbox-gl-js/issues/2618 @@ -39,6 +38,21 @@ const generateFuzzyLayerId = () => { return uniqueId('fuzzy_layer_'); }; +/** + * Map that uses Mapbox and is fully dynamic (zoom, pan, etc.). + * + * @component + * @param {Object} props + * @param {string?} props.containerClassName add style rules for the root container + * @param {string?} props.mapClassName add style rules for the map div + * @param {string?} props.address + * @param {Object} props.center LatLng + * @param {number} props.center.lat latitude + * @param {number} props.center.lng longitude + * @param {number} props.zoom + * @param {Object} props.mapsConfig + * @returns {JSX.Element} dynamic version of Mapbox + */ class DynamicMapboxMap extends Component { constructor(props) { super(props); @@ -140,19 +154,4 @@ class DynamicMapboxMap extends Component { } } -DynamicMapboxMap.defaultProps = { - address: '', - center: null, -}; - -DynamicMapboxMap.propTypes = { - address: string, // not used - center: shape({ - lat: number.isRequired, - lng: number.isRequired, - }).isRequired, - zoom: number.isRequired, - mapsConfig: object.isRequired, -}; - export default DynamicMapboxMap; diff --git a/src/components/Map/Map.js b/src/components/Map/Map.js index 30b4cdcad..7a8d5bdaf 100644 --- a/src/components/Map/Map.js +++ b/src/components/Map/Map.js @@ -2,14 +2,32 @@ import React from 'react'; import classNames from 'classnames'; import { useConfiguration } from '../../context/configurationContext'; -import { bool, number, object, string } from 'prop-types'; -import { propTypes } from '../../util/types'; import { getMapProviderApiAccess } from '../../util/maps'; import * as mapboxMap from './MapboxMap'; import * as googleMapsMap from './GoogleMap'; import css from './Map.module.css'; +/** + * Map component that uses StaticMap or DynamicMap from the configured map provider: Mapbox or Google Maps + * + * @component + * @param {Object} props + * @param {string?} props.className add more style rules in addition to component's own css.root + * @param {string?} props.rootClassName overwrite components own css.root + * @param {string?} props.mapRootClassName add style rules for the root container + * @param {string?} props.address + * @param {Object} props.center LatLng + * @param {number} props.center.lat latitude + * @param {number} props.center.lng longitude + * @param {Object} props.obfuscatedCenter LatLng + * @param {number} props.obfuscatedCenter.lat latitude + * @param {number} props.obfuscatedCenter.lng longitude + * @param {number} props.zoom + * @param {Object} props.mapsConfig + * @param {boolean} props.useStaticMap + * @returns {JSX.Element} Map component + */ export const Map = props => { const config = useConfiguration(); const { @@ -70,26 +88,4 @@ export const Map = props => { ); }; -Map.defaultProps = { - className: null, - rootClassName: null, - mapRootClassName: null, - address: '', - zoom: null, - mapsConfig: null, - useStaticMap: false, -}; - -Map.propTypes = { - className: string, - rootClassName: string, - mapRootClassName: string, - address: string, - center: propTypes.latlng, - obfuscatedCenter: propTypes.latlng, - zoom: number, - mapsConfig: object, - useStaticMap: bool, -}; - export default Map; diff --git a/src/components/Map/StaticGoogleMap.js b/src/components/Map/StaticGoogleMap.js index 1723393bc..39d453aef 100644 --- a/src/components/Map/StaticGoogleMap.js +++ b/src/components/Map/StaticGoogleMap.js @@ -1,5 +1,4 @@ import React, { Component } from 'react'; -import { number, object, shape, string } from 'prop-types'; import pick from 'lodash/pick'; import isEqual from 'lodash/isEqual'; import polyline from '@mapbox/polyline'; @@ -64,6 +63,25 @@ const drawFuzzyCircle = (mapsConfig, center) => { return polylineGraphicTokens.join('|'); }; +/** + * Static version of Google Maps + * Note: Google supports max 640px wide static map tile. + * + * @component + * @param {Object} props + * @param {string?} props.className add more style rules in addition to component's own css.root + * @param {string?} props.rootClassName overwrite components own css.root + * @param {string?} props.address + * @param {Object} props.center LatLng + * @param {number} props.center.lat latitude + * @param {number} props.center.lng longitude + * @param {number} props.zoom zoom level + * @param {Object} props.mapsConfig + * @param {Object} props.dimensions + * @param {number} props.dimensions.width + * @param {number} props.dimensions.height + * @returns {JSX.Element} static version of Google Maps + */ class StaticGoogleMap extends Component { shouldComponentUpdate(nextProps, prevState) { // Do not draw the map unless center, zoom or dimensions change @@ -104,29 +122,4 @@ class StaticGoogleMap extends Component { } } -StaticGoogleMap.defaultProps = { - className: null, - rootClassName: null, - address: '', - center: null, -}; - -StaticGoogleMap.propTypes = { - className: string, - rootClassName: string, - address: string, - center: shape({ - lat: number.isRequired, - lng: number.isRequired, - }).isRequired, - zoom: number.isRequired, - mapsConfig: object.isRequired, - - // from withDimensions - dimensions: shape({ - width: number.isRequired, - height: number.isRequired, - }).isRequired, -}; - export default lazyLoadWithDimensions(StaticGoogleMap, { maxWidth: '640px' }); diff --git a/src/components/Map/StaticMapboxMap.js b/src/components/Map/StaticMapboxMap.js index e67d05181..f998bdad7 100644 --- a/src/components/Map/StaticMapboxMap.js +++ b/src/components/Map/StaticMapboxMap.js @@ -1,5 +1,4 @@ import React from 'react'; -import { string, shape, number, object } from 'prop-types'; import polyline from '@mapbox/polyline'; import { lazyLoadWithDimensions } from '../../util/uiHelpers'; @@ -34,6 +33,25 @@ const mapOverlay = (center, mapsConfig) => { return markerOverlay(center); }; +/** + * Static version of Mapbox + * Note: Google supports max 640px wide static map tile. It's enforced with Mapbox too. + * + * @component + * @param {Object} props + * @param {string?} props.className add more style rules in addition to component's own css.root + * @param {string?} props.rootClassName overwrite components own css.root + * @param {string?} props.address + * @param {Object} props.center LatLng + * @param {number} props.center.lat latitude + * @param {number} props.center.lng longitude + * @param {number} props.zoom zoom level + * @param {Object} props.mapsConfig + * @param {Object} props.dimensions + * @param {number} props.dimensions.width + * @param {number} props.dimensions.height + * @returns {JSX.Element} static version of Mapbox + */ const StaticMapboxMap = props => { const { address, center, zoom, mapsConfig, dimensions } = props; const { width, height } = dimensions; @@ -54,25 +72,4 @@ const StaticMapboxMap = props => { return {address}; }; -StaticMapboxMap.defaultProps = { - address: '', - center: null, -}; - -StaticMapboxMap.propTypes = { - address: string, - center: shape({ - lat: number.isRequired, - lng: number.isRequired, - }).isRequired, - zoom: number.isRequired, - mapsConfig: object.isRequired, - - // from withDimensions - dimensions: shape({ - width: number.isRequired, - height: number.isRequired, - }).isRequired, -}; - export default lazyLoadWithDimensions(StaticMapboxMap, { maxWidth: '640px' }); diff --git a/src/components/Menu/Menu.js b/src/components/Menu/Menu.js index f68226156..8dbe185a7 100644 --- a/src/components/Menu/Menu.js +++ b/src/components/Menu/Menu.js @@ -1,23 +1,4 @@ -/** - * Menu is component that shows extra content when it is clicked. - * Clicking it toggles visibility of MenuContent. - * - * Example: - * - * - * Open menu - * - * - * - * - * - * - * - * - */ - import React, { Component } from 'react'; -import { bool, func, node, number, string } from 'prop-types'; import classNames from 'classnames'; import { MenuContent, MenuLabel } from '../../components'; @@ -33,13 +14,42 @@ const isControlledMenu = (isOpenProp, onToggleActiveProp) => { return isOpenProp !== null && onToggleActiveProp !== null; }; +/** + * Menu is component that shows extra content when it is clicked. + * Clicking it toggles visibility of MenuContent. + * + * @example + * + * + * Open menu + * + * + * + * + * + * + * + * + * @component + * @param {Object} props + * @param {string?} props.className add more style rules in addition to components own css.root + * @param {string?} props.rootClassName overwrite components own css.root + * @param {ReactNode} props.children + * @param {boolean} props.isOpen + * @param {('left' | 'right')} props.contentPosition + * @param {number?} props.contentPlacementOffset + * @param {boolean} props.useArrow + * @param {boolean} props.preferScreenWidthOnMobile + * @param {Function?} props.onToggleActive + * @returns {JSX.Element} menu component + */ class Menu extends Component { constructor(props) { super(props); this.state = { isOpen: false, ready: false }; - const { isOpen, onToggleActive } = props; + const { isOpen = null, onToggleActive = null } = props; const isIndependentMenu = isOpen === null && onToggleActive === null; if (!(isIndependentMenu || isControlledMenu(isOpen, onToggleActive))) { throw new Error( @@ -68,7 +78,7 @@ class Menu extends Component { // FocusEvent is fired faster than the link elements native click handler // gets its own event. Therefore, we need to check the origin of this FocusEvent. if (!this.menu.contains(event.relatedTarget)) { - const { isOpen, onToggleActive } = this.props; + const { isOpen = null, onToggleActive = null } = this.props; if (isControlledMenu(isOpen, onToggleActive)) { onToggleActive(false); @@ -87,7 +97,7 @@ class Menu extends Component { toggleOpen(enforcedState) { // If state is handled outside of Menu component, we call a passed in onToggleActive func - const { isOpen, onToggleActive } = this.props; + const { isOpen = null, onToggleActive = null } = this.props; if (isControlledMenu(isOpen, onToggleActive)) { const isMenuOpen = enforcedState != null ? enforcedState : !isOpen; onToggleActive(isMenuOpen); @@ -110,7 +120,7 @@ class Menu extends Component { const menuWidth = this.menu.offsetWidth; const contentWidthBiggerThanLabel = this.menuContent.offsetWidth - menuWidth; const usePositionLeftFromLabel = contentPosition === CONTENT_TO_LEFT; - const contentPlacementOffset = this.props.contentPlacementOffset; + const contentPlacementOffset = this.props.contentPlacementOffset ?? CONTENT_PLACEMENT_OFFSET; const mobileMaxWidth = this.props.mobileMaxWidth || MAX_MOBILE_SCREEN_WIDTH; if (this.props.preferScreenWidthOnMobile && windowWidth <= mobileMaxWidth) { @@ -138,7 +148,7 @@ class Menu extends Component { positionStyleForArrow(isPositionedRight) { if (this.menu) { const menuWidth = this.menu.offsetWidth; - const contentPlacementOffset = this.props.contentPlacementOffset; + const contentPlacementOffset = this.props.contentPlacementOffset ?? CONTENT_PLACEMENT_OFFSET; return isPositionedRight ? Math.floor(menuWidth / 2) - contentPlacementOffset : Math.floor(menuWidth / 2); @@ -152,8 +162,10 @@ class Menu extends Component { } return React.Children.map(this.props.children, child => { - const { isOpen: isOpenProp, onToggleActive } = this.props; - const isOpen = isControlledMenu(isOpenProp, onToggleActive) ? isOpenProp : this.state.isOpen; + const { isOpen: isOpenProp, onToggleActive = null } = this.props; + const isOpen = isControlledMenu(isOpenProp || null, onToggleActive) + ? isOpenProp + : this.state.isOpen; if (child.type === MenuLabel) { // MenuLabel needs toggleOpen function @@ -165,7 +177,7 @@ class Menu extends Component { } else if (child.type === MenuContent) { // MenuContent needs some styling data (width, arrowPosition, and isOpen info) // We pass those directly so that component user doesn't need to worry about those. - const { contentPosition, useArrow } = this.props; + const { contentPosition = CONTENT_TO_RIGHT, useArrow } = this.props; const positionStyles = this.positionStyleForMenuContent(contentPosition); const arrowPosition = useArrow ? this.positionStyleForArrow(positionStyles.right != null) @@ -206,27 +218,4 @@ class Menu extends Component { } } -Menu.defaultProps = { - className: null, - rootClassName: '', - contentPlacementOffset: CONTENT_PLACEMENT_OFFSET, - contentPosition: CONTENT_TO_RIGHT, - isOpen: null, - onToggleActive: null, - useArrow: true, - preferScreenWidthOnMobile: false, -}; - -Menu.propTypes = { - children: node.isRequired, - className: string, - rootClassName: string, - contentPosition: string, - contentPlacementOffset: number, - useArrow: bool, - isOpen: bool, - onToggleActive: func, - preferScreenWidthOnMobile: bool, -}; - export default Menu; diff --git a/src/components/MenuContent/MenuContent.js b/src/components/MenuContent/MenuContent.js index 5e26c5296..321b1b1e8 100644 --- a/src/components/MenuContent/MenuContent.js +++ b/src/components/MenuContent/MenuContent.js @@ -1,14 +1,25 @@ -/** - * MenuContent is a immediate child of Menu component sibling to MenuLabel. - * Clicking MenuLabel toggles visibility of MenuContent. - */ import React from 'react'; -import PropTypes from 'prop-types'; import classNames from 'classnames'; import { MenuItem } from '../../components'; import css from './MenuContent.module.css'; +/** + * MenuContent is a immediate child of Menu component sibling to MenuLabel. + * Clicking MenuLabel toggles visibility of MenuContent. + * + * @component + * @param {Object} props + * @param {string?} props.className add more style rules in addition to components own css.root + * @param {string?} props.rootClassName overwrite components own css.root + * @param {string?} props.contentClassName overwrite components own css.content, which is given to