From f1af0a6e8c4acf1f8dd1c80cec53cd547db6913f Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 3 Aug 2025 10:41:49 +0100 Subject: [PATCH 01/49] .prettierrc: remove hardcoded babel as parser setting to allow typescript parser on ts files --- .prettierrc | 1 - 1 file changed, 1 deletion(-) 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, From 8a0f302076acdab6bf8786bc84dd993644b45dde Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 3 Aug 2025 10:45:29 +0100 Subject: [PATCH 02/49] SkipLink: lint correctly to add ; to end of type definition lines --- client/components/SkipLink.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/components/SkipLink.tsx b/client/components/SkipLink.tsx index d70af6a999..c5b7b15e57 100644 --- a/client/components/SkipLink.tsx +++ b/client/components/SkipLink.tsx @@ -3,8 +3,8 @@ import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; type SkipLinkProps = { - targetId: string, - text: string + targetId: string; + text: string; }; const SkipLink = ({ targetId, text }: SkipLinkProps) => { From 1cb8f5b0a71c7e3a9028f0eef1752c46d92a958f Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 3 Aug 2025 11:00:39 +0100 Subject: [PATCH 03/49] RouterTab: unit test --- client/common/RouterTab.test.tsx | 33 ++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 client/common/RouterTab.test.tsx diff --git a/client/common/RouterTab.test.tsx b/client/common/RouterTab.test.tsx new file mode 100644 index 0000000000..298f91a5f3 --- /dev/null +++ b/client/common/RouterTab.test.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor, history } from '../test-utils'; +import Tab from './RouterTab'; + +const mockPath = '/projects'; +const mockLinkText = 'Projects'; + +describe('Tab', () => { + function rerender() { + return render({mockLinkText}); + } + + it('renders a react-router NavLink with correct text and path', async () => { + rerender(); + + const linkElement = screen.getByText(mockLinkText); + expect(linkElement).toBeInTheDocument(); + expect(linkElement.getAttribute('href')).toBe(mockPath); + + fireEvent.click(linkElement); + await waitFor(() => expect(history.location.pathname).toEqual('/projects')); + }); + + it('includes the dashboard-header class names', () => { + const { container } = rerender(); + + const listItem = container.querySelector('li'); + const link = container.querySelector('a'); + + expect(listItem).toHaveClass('dashboard-header__tab'); + expect(link).toHaveClass('dashboard-header__tab__title'); + }); +}); From d3762079fae4060276678bd87d11038cf218b62f Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 3 Aug 2025 11:02:10 +0100 Subject: [PATCH 04/49] RouterTab: update to tsx --no-verify --- client/common/{RouterTab.jsx => RouterTab.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename client/common/{RouterTab.jsx => RouterTab.tsx} (100%) diff --git a/client/common/RouterTab.jsx b/client/common/RouterTab.tsx similarity index 100% rename from client/common/RouterTab.jsx rename to client/common/RouterTab.tsx From 29e1e6d164800f64146ba8c8120862e6a0b42770 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 3 Aug 2025 11:08:44 +0100 Subject: [PATCH 05/49] RouterTab: add typescript & install @types/react-router-dom --- client/common/RouterTab.tsx | 14 ++++----- package-lock.json | 58 +++++++++++++++++++++++++++++++++++++ package.json | 1 + 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/client/common/RouterTab.tsx b/client/common/RouterTab.tsx index d08c839855..cd20455f49 100644 --- a/client/common/RouterTab.tsx +++ b/client/common/RouterTab.tsx @@ -1,11 +1,14 @@ -import PropTypes from 'prop-types'; -import React from 'react'; +import React, { ReactNode } from 'react'; import { NavLink } from 'react-router-dom'; +export type TabProps = { + children: ReactNode; + to: string; +}; /** * Wraps the react-router `NavLink` with dashboard-header__tab styling. */ -const Tab = ({ children, to }) => ( +const Tab = ({ children, to }: TabProps) => (
  • (
  • ); -Tab.propTypes = { - children: PropTypes.string.isRequired, - to: PropTypes.string.isRequired -}; - export default Tab; diff --git a/package-lock.json b/package-lock.json index aa67ab3b9d..f45f72ed0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -158,6 +158,7 @@ "@types/node": "^16.18.126", "@types/react": "^16.14.0", "@types/react-dom": "^16.9.25", + "@types/react-router-dom": "^5.3.3", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "babel-core": "^7.0.0-bridge.0", @@ -14131,6 +14132,13 @@ "@types/unist": "*" } }, + "node_modules/@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/hoist-non-react-statics": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", @@ -14368,6 +14376,29 @@ "redux": "^4.0.0" } }, + "node_modules/@types/react-router": { + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, "node_modules/@types/react/node_modules/csstype": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.8.tgz", @@ -50465,6 +50496,12 @@ "@types/unist": "*" } }, + "@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "dev": true + }, "@types/hoist-non-react-statics": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", @@ -50706,6 +50743,27 @@ "redux": "^4.0.0" } }, + "@types/react-router": { + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "dev": true, + "requires": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "dev": true, + "requires": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, "@types/redux-devtools-themes": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/redux-devtools-themes/-/redux-devtools-themes-1.0.0.tgz", diff --git a/package.json b/package.json index ccf2842f22..e2a61fe2f3 100644 --- a/package.json +++ b/package.json @@ -130,6 +130,7 @@ "@types/node": "^16.18.126", "@types/react": "^16.14.0", "@types/react-dom": "^16.9.25", + "@types/react-router-dom": "^5.3.3", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "babel-core": "^7.0.0-bridge.0", From 1efe93de1b2d9b0fe71f3ab64da459cbc500df0b Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 3 Aug 2025 11:14:11 +0100 Subject: [PATCH 06/49] Button: update to tsx --no-verify --- client/common/{Button.jsx => Button.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename client/common/{Button.jsx => Button.tsx} (100%) diff --git a/client/common/Button.jsx b/client/common/Button.tsx similarity index 100% rename from client/common/Button.jsx rename to client/common/Button.tsx From 60426387cd1669399592cb9f029866474f099147 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 3 Aug 2025 11:21:57 +0100 Subject: [PATCH 07/49] Button.tsx: migrate to typescript, add unit test, add @types/styled-components, remove react/require-default-props rule --- .eslintrc | 3 +- client/common/Button.test.tsx | 91 ++++++++++++++++++ client/common/Button.tsx | 168 ++++++++++++++++++---------------- package-lock.json | 39 ++++++++ package.json | 1 + 5 files changed, 220 insertions(+), 82 deletions(-) create mode 100644 client/common/Button.test.tsx diff --git a/.eslintrc b/.eslintrc index 0c9597ce98..0b800e2da2 100644 --- a/.eslintrc +++ b/.eslintrc @@ -131,7 +131,8 @@ "rules": { "no-use-before-define": "off", "import/no-extraneous-dependencies": "off", - "no-unused-vars": "off" + "no-unused-vars": "off", + "react/require-default-props": "off" } }, { diff --git a/client/common/Button.test.tsx b/client/common/Button.test.tsx new file mode 100644 index 0000000000..863f2d89b2 --- /dev/null +++ b/client/common/Button.test.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { render, screen, fireEvent } from '../test-utils'; +import Button from './Button'; + +const MockIcon = (props: React.SVGProps) => ( + +); + +describe('Button', () => { + // Tag + it('renders as an anchor when href is provided', () => { + render(); + const anchor = screen.getByRole('link'); + expect(anchor.tagName.toLowerCase()).toBe('a'); + expect(anchor).toHaveAttribute('href', 'https://example.com'); + }); + + it('renders as a React Router when `to` is provided', () => { + render(); + const link = screen.getByRole('link'); + expect(link.tagName.toLowerCase()).toBe('a'); // Link renders as + expect(link).toHaveAttribute('href', '/dashboard'); + }); + + it('renders as a ); + const el = screen.getByRole('button'); + expect(el.tagName.toLowerCase()).toBe('button'); + expect(el).toHaveAttribute('type', 'button'); + }); + + // Children & Icons + it('renders children', () => { + render(); + expect(screen.getByText('Click Me')).toBeInTheDocument(); + }); + + it('renders an iconBefore and button text', () => { + render( + + ); + expect(screen.getByLabelText('iconbefore')).toBeInTheDocument(); + expect(screen.getByRole('button')).toHaveTextContent( + 'This has a before icon' + ); + }); + + it('renders with iconAfter', () => { + render( + + ); + expect(screen.getByLabelText('iconafter')).toBeInTheDocument(); + expect(screen.getByRole('button')).toHaveTextContent( + 'This has an after icon' + ); + }); + + it('renders only the icon if iconOnly', () => { + render( + + ); + expect(screen.getByLabelText('iconafter')).toBeInTheDocument(); + expect(screen.getByRole('button')).not.toHaveTextContent( + 'This has an after icon' + ); + }); + + // HTML attributes + it('calls onClick handler when clicked', () => { + const handleClick = jest.fn(); + render(); + fireEvent.click(screen.getByText('Click')); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('renders disabled state', () => { + render(); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('uses aria-label when provided', () => { + render( ); export const SubmitButton = () => ( - ); @@ -59,7 +59,7 @@ export const ButtonWithIconAfter = () => ( ); export const InlineButtonWithIconAfter = () => ( - ); @@ -68,6 +68,6 @@ export const InlineIconOnlyButton = () => ( )} diff --git a/client/modules/IDE/components/NewFolderForm.jsx b/client/modules/IDE/components/NewFolderForm.jsx index a5afd60ff8..381520c47e 100644 --- a/client/modules/IDE/components/NewFolderForm.jsx +++ b/client/modules/IDE/components/NewFolderForm.jsx @@ -2,7 +2,7 @@ import React, { useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Form, Field } from 'react-final-form'; import { useDispatch } from 'react-redux'; -import { Button } from '../../../common/Button'; +import { Button, ButtonTypes } from '../../../common/Button'; import { handleCreateFolder } from '../actions/files'; function NewFolderForm() { @@ -54,7 +54,7 @@ function NewFolderForm() { {() => ( - )} diff --git a/client/modules/User/components/APIKeyForm.jsx b/client/modules/User/components/APIKeyForm.jsx index 379f5e19d4..fda5945f87 100644 --- a/client/modules/User/components/APIKeyForm.jsx +++ b/client/modules/User/components/APIKeyForm.jsx @@ -2,7 +2,7 @@ import PropTypes from 'prop-types'; import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; -import { Button } from '../../../common/Button'; +import { Button, ButtonTypes } from '../../../common/Button'; import { PlusIcon } from '../../../common/icons'; import CopyableInput from '../../IDE/components/CopyableInput'; import { createApiKey, removeApiKey } from '../actions'; @@ -78,7 +78,7 @@ const APIKeyForm = () => { disabled={keyLabel === ''} iconBefore={} label="Create new key" - type="submit" + type={ButtonTypes.SUBMIT} > {t('APIKeyForm.CreateTokenSubmit')} diff --git a/client/modules/User/components/AccountForm.jsx b/client/modules/User/components/AccountForm.jsx index a4905e081f..4ef40e4298 100644 --- a/client/modules/User/components/AccountForm.jsx +++ b/client/modules/User/components/AccountForm.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { Form, Field } from 'react-final-form'; import { useSelector, useDispatch } from 'react-redux'; import { useTranslation } from 'react-i18next'; -import { Button } from '../../../common/Button'; +import { Button, ButtonTypes } from '../../../common/Button'; import { validateSettings } from '../../../utils/reduxFormUtils'; import { updateSettings, initiateVerification } from '../actions'; import { apiClient } from '../../../utils/apiClient'; @@ -175,7 +175,7 @@ function AccountForm() { )} )} - diff --git a/client/modules/User/components/CollectionCreate.jsx b/client/modules/User/components/CollectionCreate.jsx index 8b89d3d7db..3b2ccdefed 100644 --- a/client/modules/User/components/CollectionCreate.jsx +++ b/client/modules/User/components/CollectionCreate.jsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { generateCollectionName } from '../../../utils/generateRandomName'; -import { Button } from '../../../common/Button'; +import { Button, ButtonTypes } from '../../../common/Button'; import { createCollection } from '../../IDE/actions/collections'; const CollectionCreate = () => { @@ -74,7 +74,7 @@ const CollectionCreate = () => { rows="6" />

    - diff --git a/client/modules/User/components/CookieConsent.jsx b/client/modules/User/components/CookieConsent.jsx index 18811c84a5..9cfff74ac6 100644 --- a/client/modules/User/components/CookieConsent.jsx +++ b/client/modules/User/components/CookieConsent.jsx @@ -10,7 +10,7 @@ import PropTypes from 'prop-types'; import { getConfig } from '../../../utils/getConfig'; import { setUserCookieConsent } from '../actions'; import { remSize, prop, device } from '../../../theme'; -import { Button } from '../../../common/Button'; +import { Button, ButtonKinds } from '../../../common/Button'; const CookieConsentContainer = styled.div` position: fixed; @@ -177,10 +177,7 @@ function CookieConsent({ hide }) { /> - diff --git a/client/modules/User/components/NewPasswordForm.jsx b/client/modules/User/components/NewPasswordForm.jsx index 2404ae4cd1..feca326c77 100644 --- a/client/modules/User/components/NewPasswordForm.jsx +++ b/client/modules/User/components/NewPasswordForm.jsx @@ -5,7 +5,7 @@ import { useDispatch } from 'react-redux'; import { useTranslation } from 'react-i18next'; import { validateNewPassword } from '../../../utils/reduxFormUtils'; import { updatePassword } from '../actions'; -import { Button } from '../../../common/Button'; +import { Button, ButtonTypes } from '../../../common/Button'; function NewPasswordForm(props) { const { resetPasswordToken } = props; @@ -64,7 +64,10 @@ function NewPasswordForm(props) {

    )} - diff --git a/client/modules/User/components/ResetPasswordForm.jsx b/client/modules/User/components/ResetPasswordForm.jsx index 6f7a45b1ba..fe3752fdf4 100644 --- a/client/modules/User/components/ResetPasswordForm.jsx +++ b/client/modules/User/components/ResetPasswordForm.jsx @@ -4,7 +4,7 @@ import { Form, Field } from 'react-final-form'; import { useDispatch, useSelector } from 'react-redux'; import { validateResetPassword } from '../../../utils/reduxFormUtils'; import { initiateResetPassword } from '../actions'; -import { Button } from '../../../common/Button'; +import { Button, ButtonTypes } from '../../../common/Button'; function ResetPasswordForm(props) { const { t } = useTranslation(); @@ -45,7 +45,7 @@ function ResetPasswordForm(props) { )}