From 8891cee95f425cba7a8e68fd3f1693a90eb65182 Mon Sep 17 00:00:00 2001 From: Danny Budzinski Date: Mon, 28 Nov 2022 15:31:58 +0100 Subject: [PATCH] fix: build improvements, code cleanup, small improvements, test fixes * fix: add code splitting * fix: differentiate dev/prod/test/etc modes from development/production builds * chore: run e2e tests with production build * fix: ensure all compile builds are done as production builds (only dev server runs with development builds) * chore: add test id helper * chore: replace all relative paths with tsconfig paths * chore: improve button, dialog, textfield * chore: cleanup dependencies * chore: delete unused Settings page * fix: fix demo translation * fix: make payment tests more robust * fix: fix IP override for tests --- .commitlintrc.js | 1 + .env.development => .env.dev | 0 .github/PULL_REQUEST_TEMPLATE.md | 5 +- .github/workflows/codeceptjs.yml | 4 +- package.json | 9 ++- src/components/Button/Button.module.scss | 12 ++++ src/components/Button/Button.tsx | 34 ++++++---- .../ChooseOfferForm/ChooseOfferForm.tsx | 4 +- .../ConfirmationDialog/ConfirmationDialog.tsx | 16 ++++- .../DemoConfigDialog/DemoConfigDialog.tsx | 2 +- .../DemoConfigDialog.test.tsx.snap | 1 - .../EditPasswordForm/EditPasswordForm.tsx | 4 +- src/components/EpgChannel/EpgChannelItem.tsx | 3 +- .../EpgProgramItem/EpgProgramItem.tsx | 4 +- .../ForgotPasswordForm/ForgotPasswordForm.tsx | 4 +- src/components/LoginForm/LoginForm.tsx | 4 +- src/components/Modal/Modal.tsx | 4 +- .../PersonalDetailsForm.tsx | 4 +- src/components/Player/Player.tsx | 4 +- .../RegistrationForm/RegistrationForm.tsx | 4 +- src/components/Root/Root.tsx | 6 +- src/components/Spinner/Spinner.module.scss | 9 ++- src/components/TextField/TextField.test.tsx | 2 +- src/components/TextField/TextField.tsx | 67 ++++++++----------- .../__snapshots__/TextField.test.tsx.snap | 1 - src/components/VideoDetails/VideoDetails.tsx | 3 +- .../VideoDetailsInline/VideoDetailsInline.tsx | 3 +- src/components/VideoLayout/VideoLayout.tsx | 7 +- src/components/VideoList/VideoList.tsx | 3 +- .../AccountModal/forms/Checkout.tsx | 2 +- src/containers/Layout/Layout.tsx | 4 +- src/containers/ShelfList/ShelfList.tsx | 3 +- src/i18n/locales/en_US/demo.json | 2 +- src/i18n/locales/en_US/error.json | 4 +- src/pages/Settings/Settings.module.scss | 2 - src/pages/Settings/Settings.test.tsx | 12 ---- src/pages/Settings/Settings.tsx | 17 ----- .../__snapshots__/Settings.test.tsx.snap | 9 --- src/services/account.service.ts | 14 ++-- src/services/checkout.service.ts | 11 ++- src/stores/ConfigStore.ts | 4 -- src/stores/UIStore.ts | 4 +- src/utils/common.ts | 20 ++++-- test-e2e/tests/account_test.ts | 6 +- test-e2e/tests/inline_layout_test.ts | 6 +- test-e2e/tests/login/account_test.ts | 4 +- test-e2e/tests/login/home_test.ts | 4 +- test-e2e/tests/payments/coupons_test.ts | 4 +- test-e2e/tests/payments/subscription_test.ts | 14 ++-- test-e2e/tests/register_test.ts | 12 ++-- test-e2e/tests/video_detail_test.ts | 12 ++-- .../tests/watch_history/logged_in_test.ts | 4 +- test-e2e/utils/constants.ts | 3 + test-e2e/utils/payments.ts | 45 +++++++++---- test-e2e/utils/steps_file.ts | 15 +++-- test/constants.ts | 2 + types/account.d.ts | 8 ++- types/checkout.d.ts | 4 +- vite.config.ts | 41 +++++++++++- yarn.lock | 58 +--------------- 60 files changed, 288 insertions(+), 286 deletions(-) rename .env.development => .env.dev (100%) delete mode 100644 src/pages/Settings/Settings.module.scss delete mode 100644 src/pages/Settings/Settings.test.tsx delete mode 100644 src/pages/Settings/Settings.tsx delete mode 100644 src/pages/Settings/__snapshots__/Settings.test.tsx.snap diff --git a/.commitlintrc.js b/.commitlintrc.js index 4c6763978..88ea9e0f4 100644 --- a/.commitlintrc.js +++ b/.commitlintrc.js @@ -27,6 +27,7 @@ module.exports = { 'inlineplayer', 'config', 'epg', + 'tests', ], ], }, diff --git a/.env.development b/.env.dev similarity index 100% rename from .env.development rename to .env.dev diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 627dc045b..730866ad9 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -10,11 +10,8 @@ This PR solves # . According to our definition of done, I have completed the following steps: -- [ ] User stories met -- [ ] Storybook stories +- [ ] Acceptance criteria met - [ ] Unit tests added -- [ ] Linting passing -- [ ] Unit tests passing - [ ] Docs updated (including config and env variables) - [ ] Translations added - [ ] UX tested diff --git a/.github/workflows/codeceptjs.yml b/.github/workflows/codeceptjs.yml index c45131f1a..07b136286 100644 --- a/.github/workflows/codeceptjs.yml +++ b/.github/workflows/codeceptjs.yml @@ -22,8 +22,8 @@ jobs: run: | yarn npm install wait-on -g - - name: Start dev server - run: yarn start --mode test & + - name: Start preview server + run: yarn start:test & - name: Run tests run: wait-on -v -t 30000 -c ./scripts/waitOnConfig.js http-get://localhost:8080 && yarn codecept:${{ matrix.config }} env: diff --git a/package.json b/package.json index e217488ec..c87117453 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "2.7.0", "main": "index.js", "repository": "https://github.com/jwplayer/ott-web-app.git", - "author": "Robin van Zanten", + "author": "JW Player", "private": true, "engines": { "node": ">=16.0.0" @@ -11,6 +11,7 @@ "scripts": { "prepare": "husky install", "start": "vite", + "start:test": "vite build --mode test && vite preview --port 8080", "build": "vite build", "test": "TZ=UTC vitest run", "test-watch": "TZ=UTC vitest", @@ -38,17 +39,14 @@ "deploy:github": "node ./scripts/deploy-github.js" }, "dependencies": { - "allure-commandline": "^2.17.2", "classnames": "^2.3.1", "date-fns": "^2.28.0", "dompurify": "^2.3.8", - "history": "^4.10.1", "i18next": "^20.3.1", "i18next-browser-languagedetector": "^6.1.1", "jwt-decode": "^3.1.2", "lodash.merge": "^4.6.2", "marked": "^4.1.1", - "npm-run-all": "^4.1.5", "planby": "^0.3.0", "react": "^17.0.2", "react-dom": "^17.0.2", @@ -78,11 +76,11 @@ "@types/react-dom": "^17.0.9", "@types/react-helmet": "^6.1.2", "@types/react-infinite-scroller": "^1.2.3", - "@types/react-router-dom": "^5.3.3", "@typescript-eslint/eslint-plugin": "^5.17.0", "@typescript-eslint/parser": "^5.17.0", "@vitejs/plugin-react": "^1.0.7", "@vitest/coverage-c8": "^0.23.4", + "allure-commandline": "^2.17.2", "codeceptjs": "3.3.0", "confusing-browser-globals": "^1.0.10", "depcheck": "^1.4.3", @@ -97,6 +95,7 @@ "jsdom": "^19.0.0", "lint-staged": "^10.5.4", "luxon": "^3.1.0", + "npm-run-all": "^4.1.5", "playwright": "^1.25.2", "postcss": "^8.3.5", "postcss-import": "^14.0.2", diff --git a/src/components/Button/Button.module.scss b/src/components/Button/Button.module.scss index d3769aa5d..b5ace6bf3 100644 --- a/src/components/Button/Button.module.scss +++ b/src/components/Button/Button.module.scss @@ -132,3 +132,15 @@ $large-button-height: 40px; } } } + +.hidden { + visibility: hidden; +} + +.centerAbsolute { + position: absolute; + right: 0; + left: 0; + margin: auto; + transform: translate(-5px, -5px); +} diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 7d8588d7f..b40327c50 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -4,6 +4,8 @@ import { NavLink } from 'react-router-dom'; import styles from './Button.module.scss'; +import Spinner from '#src/components/Spinner/Spinner'; + type Color = 'default' | 'primary'; type Variant = 'contained' | 'outlined' | 'text'; @@ -24,6 +26,7 @@ type Props = { className?: string; type?: 'button' | 'submit' | 'reset'; disabled?: boolean; + busy?: boolean; id?: string; } & React.AriaAttributes; @@ -37,6 +40,7 @@ const Button: React.FC = ({ variant = 'outlined', size = 'medium', disabled, + busy, type, to, onClick, @@ -52,20 +56,26 @@ const Button: React.FC = ({ [styles.disabled]: disabled, }); - const icon = startIcon ?
{startIcon}
: null; - const span = {label}; - - return to ? ( - buttonClassName(isActive)} to={to} {...rest} end> - {icon} - {span} + const content = ( + <> + {startIcon &&
{startIcon}
} + {{label}} {children} -
- ) : ( + {busy && } + + ); + + if (to) { + return ( + buttonClassName(isActive)} to={to} {...rest} end> + {content} + + ); + } + + return ( ); }; diff --git a/src/components/ChooseOfferForm/ChooseOfferForm.tsx b/src/components/ChooseOfferForm/ChooseOfferForm.tsx index 4eeef1585..558698c13 100644 --- a/src/components/ChooseOfferForm/ChooseOfferForm.tsx +++ b/src/components/ChooseOfferForm/ChooseOfferForm.tsx @@ -12,7 +12,7 @@ import CheckCircle from '#src/icons/CheckCircle'; import type { Offer } from '#types/checkout'; import { getOfferPrice, isSVODOffer } from '#src/utils/subscription'; import type { FormErrors } from '#types/form'; -import { IS_DEV_BUILD } from '#src/utils/common'; +import { testId } from '#src/utils/common'; import type { ChooseOfferFormData, OfferType } from '#types/account'; import { useConfigStore } from '#src/stores/ConfigStore'; @@ -139,7 +139,7 @@ const ChooseOfferForm: React.FC = ({ }; return ( -
+ {onBackButtonClickHandler ? : null}

{t('choose_offer.title')}

{t('choose_offer.watch_this_on_platform', { siteName })}

diff --git a/src/components/ConfirmationDialog/ConfirmationDialog.tsx b/src/components/ConfirmationDialog/ConfirmationDialog.tsx index 1f1701902..b8f803c4c 100644 --- a/src/components/ConfirmationDialog/ConfirmationDialog.tsx +++ b/src/components/ConfirmationDialog/ConfirmationDialog.tsx @@ -12,17 +12,27 @@ type Props = { body: string; onConfirm: () => void; onClose: () => void; + busy?: boolean; }; -const ConfirmationDialog: React.FC = ({ open, title, body, onConfirm, onClose }: Props) => { +const ConfirmationDialog: React.FC = ({ open, title, body, onConfirm, onClose, busy }: Props) => { const { t } = useTranslation('common'); return (

{title}

{body}

-
); }; diff --git a/src/components/DemoConfigDialog/DemoConfigDialog.tsx b/src/components/DemoConfigDialog/DemoConfigDialog.tsx index eac6894b0..7d2b5d00e 100644 --- a/src/components/DemoConfigDialog/DemoConfigDialog.tsx +++ b/src/components/DemoConfigDialog/DemoConfigDialog.tsx @@ -28,7 +28,7 @@ const DemoConfigDialog = ({ configLocation = getConfigLocation() }: Props) => { if (configLocation) { return (
-
{t('currently_previewing_config', { configLocation })}
+
{t('currently_previewing_config', { configSource: configLocation })}
{t('click_to_unselect_config')}
); diff --git a/src/components/DemoConfigDialog/__snapshots__/DemoConfigDialog.test.tsx.snap b/src/components/DemoConfigDialog/__snapshots__/DemoConfigDialog.test.tsx.snap index 886fe9110..d3e607983 100644 --- a/src/components/DemoConfigDialog/__snapshots__/DemoConfigDialog.test.tsx.snap +++ b/src/components/DemoConfigDialog/__snapshots__/DemoConfigDialog.test.tsx.snap @@ -42,7 +42,6 @@ exports[` > renders and matches snapshot 1`] = ` placeholder="please_enter_config_id" required="" type="text" - value="" /> diff --git a/src/components/EditPasswordForm/EditPasswordForm.tsx b/src/components/EditPasswordForm/EditPasswordForm.tsx index fd65442ec..35e0dbf85 100644 --- a/src/components/EditPasswordForm/EditPasswordForm.tsx +++ b/src/components/EditPasswordForm/EditPasswordForm.tsx @@ -11,7 +11,7 @@ import Visibility from '#src/icons/Visibility'; import VisibilityOff from '#src/icons/VisibilityOff'; import PasswordStrength from '#components/PasswordStrength/PasswordStrength'; import LoadingOverlay from '#components/LoadingOverlay/LoadingOverlay'; -import { IS_DEV_BUILD } from '#src/utils/common'; +import { testId } from '#src/utils/common'; import useToggle from '#src/hooks/useToggle'; import type { EditPasswordFormData } from '#types/account'; import type { FormErrors } from '#types/form'; @@ -31,7 +31,7 @@ const EditPasswordForm: React.FC = ({ onSubmit, onChange, onBlur, value, const [viewPassword, toggleViewPassword] = useToggle(); return ( - +

{t('reset.password_reset')}

{errors.form ? {errors.form} : null} = ({ channel, channelItemWidth, sidebarWi className={classNames(styles.epgChannel, { [styles.active]: isActive })} style={{ width: channelItemWidth }} onClick={() => onClick && onClick(channel)} - data-testid={uuid} + data-testid={testId(uuid)} > Logo diff --git a/src/components/EpgProgramItem/EpgProgramItem.tsx b/src/components/EpgProgramItem/EpgProgramItem.tsx index 9566bc321..28efc53d9 100644 --- a/src/components/EpgProgramItem/EpgProgramItem.tsx +++ b/src/components/EpgProgramItem/EpgProgramItem.tsx @@ -5,6 +5,8 @@ import { useTranslation } from 'react-i18next'; import styles from './EpgProgramItem.module.scss'; +import { testId } from '#src/utils/common'; + type Props = { program: Program; onClick?: (program: Program) => void; @@ -45,7 +47,7 @@ const ProgramItem: React.VFC = ({ program, onClick, isActive, compact, di [styles.disabled]: disabled, })} style={{ width: styles.width }} - data-testid={program.data.id} + data-testid={testId(program.data.id)} > {showImage && Preview} {showLiveTagInImage &&
{t('live')}
} diff --git a/src/components/ForgotPasswordForm/ForgotPasswordForm.tsx b/src/components/ForgotPasswordForm/ForgotPasswordForm.tsx index f7bb53171..53f44ced3 100644 --- a/src/components/ForgotPasswordForm/ForgotPasswordForm.tsx +++ b/src/components/ForgotPasswordForm/ForgotPasswordForm.tsx @@ -6,7 +6,7 @@ import styles from './ForgotPasswordForm.module.scss'; import Button from '#components/Button/Button'; import TextField from '#components/TextField/TextField'; import FormFeedback from '#components/FormFeedback/FormFeedback'; -import { IS_DEV_BUILD } from '#src/utils/common'; +import { testId } from '#src/utils/common'; import type { ForgotPasswordFormData } from '#types/account'; import type { FormErrors } from '#types/form'; @@ -24,7 +24,7 @@ const ForgotPasswordForm: React.FC = ({ onSubmit, onChange, value, errors const { t } = useTranslation('account'); return ( - +

{t('reset.forgot_password')}

{errors.form ? {errors.form} : null}

{t('reset.forgot_text')}

diff --git a/src/components/LoginForm/LoginForm.tsx b/src/components/LoginForm/LoginForm.tsx index f53edd227..57d932ddc 100644 --- a/src/components/LoginForm/LoginForm.tsx +++ b/src/components/LoginForm/LoginForm.tsx @@ -12,7 +12,7 @@ import Visibility from '#src/icons/Visibility'; import VisibilityOff from '#src/icons/VisibilityOff'; import FormFeedback from '#components/FormFeedback/FormFeedback'; import LoadingOverlay from '#components/LoadingOverlay/LoadingOverlay'; -import { IS_DEV_BUILD } from '#src/utils/common'; +import { testId } from '#src/utils/common'; import useToggle from '#src/hooks/useToggle'; import { addQueryParam } from '#src/utils/location'; import type { FormErrors } from '#types/form'; @@ -34,7 +34,7 @@ const LoginForm: React.FC = ({ onSubmit, onChange, values, errors, submit const location = useLocation(); return ( - +

{t('login.sign_in')}

{errors.form ? {errors.form} : null} = ({ open, onClose, children, AnimationComponent = return ReactDOM.createPortal( setVisible(false)}>
-
+
{children} diff --git a/src/components/PersonalDetailsForm/PersonalDetailsForm.tsx b/src/components/PersonalDetailsForm/PersonalDetailsForm.tsx index 95ceac58f..03cb8cd3b 100644 --- a/src/components/PersonalDetailsForm/PersonalDetailsForm.tsx +++ b/src/components/PersonalDetailsForm/PersonalDetailsForm.tsx @@ -11,7 +11,7 @@ import Radio from '#components/Radio/Radio'; import DateField from '#components/DateField/DateField'; import LoadingOverlay from '#components/LoadingOverlay/LoadingOverlay'; import FormFeedback from '#components/FormFeedback/FormFeedback'; -import { IS_DEV_BUILD } from '#src/utils/common'; +import { testId } from '#src/utils/common'; import type { FormErrors } from '#types/form'; import type { PersonalDetailsFormData, CleengCaptureField, CleengCaptureQuestionField } from '#types/account'; @@ -67,7 +67,7 @@ const PersonalDetailsForm: React.FC = ({ }; return ( - +

{t('personal_details.title')}

{errors.form ? {errors.form} : null} {fields.firstNameLastName?.enabled ? ( diff --git a/src/components/Player/Player.tsx b/src/components/Player/Player.tsx index e2b0cbfe7..48011de33 100644 --- a/src/components/Player/Player.tsx +++ b/src/components/Player/Player.tsx @@ -8,7 +8,7 @@ import type { JWPlayer } from '#types/jwplayer'; import type { PlaylistItem } from '#types/playlist'; import useEventCallback from '#src/hooks/useEventCallback'; import useOttAnalytics from '#src/hooks/useOttAnalytics'; -import { logDev } from '#src/utils/common'; +import { logDev, testId } from '#src/utils/common'; type Props = { playerId: string; @@ -193,7 +193,7 @@ const Player: React.FC = ({ }, [detachEvents]); return ( -
+
); diff --git a/src/components/RegistrationForm/RegistrationForm.tsx b/src/components/RegistrationForm/RegistrationForm.tsx index 057501180..6f5830d4a 100644 --- a/src/components/RegistrationForm/RegistrationForm.tsx +++ b/src/components/RegistrationForm/RegistrationForm.tsx @@ -15,7 +15,7 @@ import Checkbox from '#components/Checkbox/Checkbox'; import FormFeedback from '#components/FormFeedback/FormFeedback'; import LoadingOverlay from '#components/LoadingOverlay/LoadingOverlay'; import Link from '#components/Link/Link'; -import { IS_DEV_BUILD } from '#src/utils/common'; +import { testId } from '#src/utils/common'; import useToggle from '#src/hooks/useToggle'; import { addQueryParam } from '#src/utils/location'; import type { FormErrors } from '#types/form'; @@ -76,7 +76,7 @@ const RegistrationForm: React.FC = ({ } return ( - +

{t('registration.sign_up')}

{errors.form ? {errors.form} : null} = ({ error }) => { return ( <> {error ? ( - -

{IS_DEV_BUILD ? error.stack : t('generic_error_description', 'Try refreshing this page or come back later.')}

+ +

{IS_DEVELOPMENT_BUILD ? error.stack : t('generic_error_description', 'Try refreshing this page or come back later.')}

) : ( <> diff --git a/src/components/Spinner/Spinner.module.scss b/src/components/Spinner/Spinner.module.scss index 5985916e6..45f95c4ca 100644 --- a/src/components/Spinner/Spinner.module.scss +++ b/src/components/Spinner/Spinner.module.scss @@ -19,8 +19,15 @@ border-radius: 50%; animation: buffer 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; } + .small { - transform: scale(0.6); + width: 20px; + height: 20px; +} + +.small div { + width: variables.$base-spacing; + height: variables.$base-spacing; } .buffer div:nth-child(1) { diff --git a/src/components/TextField/TextField.test.tsx b/src/components/TextField/TextField.test.tsx index 830ea42c8..7fd937bef 100644 --- a/src/components/TextField/TextField.test.tsx +++ b/src/components/TextField/TextField.test.tsx @@ -11,7 +11,7 @@ describe('', () => { }); test('renders and matches multiline snapshot', () => { - const { container } = render(); + const { container } = render(); expect(container).toMatchSnapshot(); }); diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index c4cff2241..9fb7fa13e 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -1,90 +1,79 @@ -import React from 'react'; +import React, { RefObject } from 'react'; import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; import styles from './TextField.module.scss'; -import HelperText from '#components/HelperText/HelperText'; -import { IS_DEV_BUILD } from '#src/utils/common'; +import HelperText from '#src/components/HelperText/HelperText'; +import { testId as getTestId } from '#src/utils/common'; import useOpaqueId from '#src/hooks/useOpaqueId'; +type InputProps = Omit, HTMLInputElement>, 'id' | 'ref' | 'className'>; +type TextAreaProps = Omit, HTMLTextAreaElement>, 'id' | 'ref' | 'className'>; + +type InputOrTextAreaProps = + | ({ multiline?: false; inputRef?: RefObject; textAreaRef?: never } & InputProps) + | ({ multiline: true; inputRef?: never; textAreaRef?: RefObject } & TextAreaProps); + type Props = { className?: string; label?: string; - placeholder?: string; - name?: string; - value?: string; - type?: 'text' | 'email' | 'password' | 'search' | 'number' | 'date'; - onChange?: React.ChangeEventHandler; - onFocus?: React.FocusEventHandler; - onBlur?: React.FocusEventHandler; helperText?: React.ReactNode; leftControl?: React.ReactNode; rightControl?: React.ReactNode; error?: boolean; - disabled?: boolean; - required?: boolean; - readOnly?: boolean; - multiline?: boolean; - rows?: number; editing?: boolean; testId?: string; -}; +} & InputOrTextAreaProps; const TextField: React.FC = ({ className, label, error, helperText, - multiline, leftControl, rightControl, - type = 'text', - rows = 3, editing = true, - value, testId, - ...rest + inputRef, + textAreaRef, + ...inputProps }: Props) => { - const id = useOpaqueId('text-field', rest.name); + const id = useOpaqueId('text-field', inputProps.name); const { t } = useTranslation('common'); - const InputComponent = multiline ? 'textarea' : 'input'; + + const isInputOrTextArea = (item: unknown): item is InputOrTextAreaProps => !!item && typeof item === 'object' && 'multiline' in item; + const isTextArea = (item: unknown): item is TextAreaProps => isInputOrTextArea(item) && !!item.multiline; + const textFieldClassName = classNames( styles.textField, { [styles.error]: error, - [styles.disabled]: rest.disabled, + [styles.disabled]: inputProps.disabled, [styles.leftControl]: !!leftControl, [styles.rightControl]: !!rightControl, }, className, ); - const inputProps: Partial = { - id, - type, - value, - ...rest, - }; - - if (multiline) { - inputProps.rows = rows; - } - return ( -
+
{editing ? (
{leftControl ?
{leftControl}
: null} - + {isTextArea(inputProps) ? ( +