diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 135ec93c33..4471e921f9 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -218,6 +218,21 @@ jobs: working-directory: app run: yarn install --frozen-lockfile + - name: Lint frontend + if: steps.check.outputs.should_test == 'true' + working-directory: app + run: yarn lint + + - name: Check frontend formatting + if: steps.check.outputs.should_test == 'true' + working-directory: app + run: yarn fm:check + + - name: Type-check frontend (app + tests) + if: steps.check.outputs.should_test == 'true' + working-directory: app + run: yarn type-check + - name: Run frontend tests with coverage if: steps.check.outputs.should_test == 'true' working-directory: app diff --git a/app/.prettierignore b/app/.prettierignore new file mode 100644 index 0000000000..d17a2d4203 --- /dev/null +++ b/app/.prettierignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +coverage/ +yarn.lock +# Hand-tuned inline critical CSS + meta tags — keep formatting as-is +index.html diff --git a/app/cloudbuild.yaml b/app/cloudbuild.yaml index 684d364759..d749fb7219 100644 --- a/app/cloudbuild.yaml +++ b/app/cloudbuild.yaml @@ -1,74 +1,74 @@ # Cloud Build configuration for anyplot Frontend substitutions: - _REGION: "europe-west4" - _SERVICE_NAME: "anyplot-app" - _VITE_API_URL: "https://api.anyplot.ai" + _REGION: 'europe-west4' + _SERVICE_NAME: 'anyplot-app' + _VITE_API_URL: 'https://api.anyplot.ai' # Only DebugPage uses this — same-origin via the Cloudflare Worker on # anyplot.ai/api/*, so the CF Access cookie on anyplot.ai is sent with # fetch (host-only cookies cannot cross subdomains). Other pages keep # using VITE_API_URL directly because their endpoints are public and # don't need the CF Access cookie. - _VITE_DEBUG_API_URL: "/api" + _VITE_DEBUG_API_URL: '/api' steps: # Build the container image - - name: "gcr.io/cloud-builders/docker" + - name: 'gcr.io/cloud-builders/docker' args: [ - "build", - "-t", - "europe-west4-docker.pkg.dev/$PROJECT_ID/anyplot/${_SERVICE_NAME}:$BUILD_ID", - "-t", - "europe-west4-docker.pkg.dev/$PROJECT_ID/anyplot/${_SERVICE_NAME}:latest", - "--build-arg", - "VITE_API_URL=${_VITE_API_URL}", - "--build-arg", - "VITE_DEBUG_API_URL=${_VITE_DEBUG_API_URL}", - "-f", - "app/Dockerfile", - "app", + 'build', + '-t', + 'europe-west4-docker.pkg.dev/$PROJECT_ID/anyplot/${_SERVICE_NAME}:$BUILD_ID', + '-t', + 'europe-west4-docker.pkg.dev/$PROJECT_ID/anyplot/${_SERVICE_NAME}:latest', + '--build-arg', + 'VITE_API_URL=${_VITE_API_URL}', + '--build-arg', + 'VITE_DEBUG_API_URL=${_VITE_DEBUG_API_URL}', + '-f', + 'app/Dockerfile', + 'app', ] # Push the container image to Artifact Registry - - name: "gcr.io/cloud-builders/docker" - args: ["push", "--all-tags", "europe-west4-docker.pkg.dev/$PROJECT_ID/anyplot/${_SERVICE_NAME}"] + - name: 'gcr.io/cloud-builders/docker' + args: ['push', '--all-tags', 'europe-west4-docker.pkg.dev/$PROJECT_ID/anyplot/${_SERVICE_NAME}'] # Deploy container image to Cloud Run - - name: "gcr.io/google.com/cloudsdktool/cloud-sdk" + - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk' entrypoint: gcloud args: - - "run" - - "deploy" - - "${_SERVICE_NAME}" - - "--image" - - "europe-west4-docker.pkg.dev/$PROJECT_ID/anyplot/${_SERVICE_NAME}:$BUILD_ID" - - "--region" - - "${_REGION}" - - "--platform" - - "managed" - - "--allow-unauthenticated" - - "--memory" - - "512Mi" - - "--cpu" - - "1" - - "--timeout" - - "60" - - "--min-instances" - - "1" - - "--max-instances" - - "3" - - "--port" - - "8080" - - "--execution-environment" - - "gen2" - - "--cpu-throttling" - - "--concurrency" - - "15" + - 'run' + - 'deploy' + - '${_SERVICE_NAME}' + - '--image' + - 'europe-west4-docker.pkg.dev/$PROJECT_ID/anyplot/${_SERVICE_NAME}:$BUILD_ID' + - '--region' + - '${_REGION}' + - '--platform' + - 'managed' + - '--allow-unauthenticated' + - '--memory' + - '512Mi' + - '--cpu' + - '1' + - '--timeout' + - '60' + - '--min-instances' + - '1' + - '--max-instances' + - '3' + - '--port' + - '8080' + - '--execution-environment' + - 'gen2' + - '--cpu-throttling' + - '--concurrency' + - '15' # Store images in Artifact Registry images: - - "europe-west4-docker.pkg.dev/$PROJECT_ID/anyplot/${_SERVICE_NAME}:$BUILD_ID" - - "europe-west4-docker.pkg.dev/$PROJECT_ID/anyplot/${_SERVICE_NAME}:latest" + - 'europe-west4-docker.pkg.dev/$PROJECT_ID/anyplot/${_SERVICE_NAME}:$BUILD_ID' + - 'europe-west4-docker.pkg.dev/$PROJECT_ID/anyplot/${_SERVICE_NAME}:latest' options: logging: CLOUD_LOGGING_ONLY diff --git a/app/eslint.config.js b/app/eslint.config.js index 866ad99bcb..876fcf162b 100644 --- a/app/eslint.config.js +++ b/app/eslint.config.js @@ -1,8 +1,12 @@ import js from '@eslint/js'; import tseslint from '@typescript-eslint/eslint-plugin'; import tsparser from '@typescript-eslint/parser'; +import prettierConfig from 'eslint-config-prettier'; +import perfectionist from 'eslint-plugin-perfectionist'; import reactHooks from 'eslint-plugin-react-hooks'; import reactRefresh from 'eslint-plugin-react-refresh'; +import unusedImports from 'eslint-plugin-unused-imports'; +import globals from 'globals'; export default [ js.configs.recommended, @@ -17,80 +21,14 @@ export default [ jsx: true, }, }, - globals: { - // Browser globals - window: 'readonly', - document: 'readonly', - navigator: 'readonly', - console: 'readonly', - setTimeout: 'readonly', - clearTimeout: 'readonly', - setInterval: 'readonly', - clearInterval: 'readonly', - fetch: 'readonly', - URL: 'readonly', - URLSearchParams: 'readonly', - localStorage: 'readonly', - sessionStorage: 'readonly', - Storage: 'readonly', - history: 'readonly', - location: 'readonly', - requestAnimationFrame: 'readonly', - cancelAnimationFrame: 'readonly', - performance: 'readonly', - crypto: 'readonly', - // DOM types - HTMLElement: 'readonly', - HTMLDivElement: 'readonly', - HTMLInputElement: 'readonly', - HTMLSelectElement: 'readonly', - HTMLButtonElement: 'readonly', - HTMLAnchorElement: 'readonly', - HTMLImageElement: 'readonly', - HTMLCanvasElement: 'readonly', - HTMLIFrameElement: 'readonly', - Element: 'readonly', - Node: 'readonly', - NodeList: 'readonly', - // Events - MouseEvent: 'readonly', - KeyboardEvent: 'readonly', - TouchEvent: 'readonly', - ClipboardEvent: 'readonly', - Event: 'readonly', - MessageEvent: 'readonly', - MediaQueryListEvent: 'readonly', - // APIs - AbortController: 'readonly', - AbortSignal: 'readonly', - RequestInit: 'readonly', - Response: 'readonly', - ResizeObserver: 'readonly', - IntersectionObserver: 'readonly', - MutationObserver: 'readonly', - Blob: 'readonly', - File: 'readonly', - FileReader: 'readonly', - // Idle / animation callback APIs - requestIdleCallback: 'readonly', - cancelIdleCallback: 'readonly', - IdleRequestCallback: 'readonly', - IdleDeadline: 'readonly', - IdleRequestOptions: 'readonly', - IdleCallbackHandle: 'readonly', - FrameRequestCallback: 'readonly', - // IntersectionObserver type aliases - IntersectionObserverCallback: 'readonly', - IntersectionObserverInit: 'readonly', - IntersectionObserverEntry: 'readonly', - // React (for JSX runtime) - React: 'readonly', - }, + globals: globals.browser, }, plugins: { '@typescript-eslint': tseslint, 'react-hooks': reactHooks, 'react-refresh': reactRefresh, + perfectionist, + 'unused-imports': unusedImports, }, rules: { ...tseslint.configs.recommended.rules, @@ -98,29 +36,39 @@ export default [ 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 'no-unused-vars': 'off', + // TypeScript reports undefined identifiers itself (incl. DOM lib types); + // with no-undef active every DOM global would need hand-listing here. + 'no-undef': 'off', // Allow ref updates during render (common pattern for keeping refs in sync) 'react-hooks/refs': 'off', + 'unused-imports/no-unused-imports': 'error', + 'perfectionist/sort-imports': [ + 'error', + { + type: 'natural', + newlinesBetween: 1, + internalPattern: ['^src/'], + groups: [ + 'side-effect-style', + 'side-effect', + 'react', + ['builtin', 'external'], + 'mui', + 'internal', + ['parent', 'sibling', 'index'], + 'unknown', + ], + customGroups: [ + { groupName: 'react', elementNamePattern: ['^react$', '^react-dom'] }, + { groupName: 'mui', elementNamePattern: ['^@mui/'] }, + ], + }, + ], + 'perfectionist/sort-named-imports': ['error', { type: 'natural' }], }, }, { - files: ['src/**/*.test.{ts,tsx}'], - languageOptions: { - globals: { - // Vitest globals - describe: 'readonly', - it: 'readonly', - expect: 'readonly', - vi: 'readonly', - beforeEach: 'readonly', - afterEach: 'readonly', - beforeAll: 'readonly', - afterAll: 'readonly', - globalThis: 'readonly', - global: 'readonly', - }, - }, - }, - { - ignores: ['dist/**', 'node_modules/**', '*.config.js'], + ignores: ['dist/**', 'node_modules/**', 'coverage/**', '*.config.js', '*.config.mjs'], }, + prettierConfig, ]; diff --git a/app/package.json b/app/package.json index 5cb8d87e07..0b5c09025e 100644 --- a/app/package.json +++ b/app/package.json @@ -9,7 +9,11 @@ "build": "tsc && vite build", "preview": "vite preview", "lint": "eslint src", - "type-check": "tsc --noEmit", + "lint:fix": "eslint src --fix", + "fm:check": "prettier --check .", + "fm:fix": "prettier --write .", + "fix:all": "yarn lint:fix && yarn fm:fix", + "type-check": "tsc --noEmit && tsc -p tsconfig.test.json --noEmit", "test": "vitest run", "test:watch": "vitest" }, @@ -46,11 +50,17 @@ "@vitejs/plugin-react-swc": "^4.3.1", "@vitest/coverage-v8": "^4.1.8", "eslint": "^10.4.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-perfectionist": "^5.9.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", + "eslint-plugin-unused-imports": "^4.4.1", + "globals": "^17.6.0", "jsdom": "^29.1.1", + "prettier": "^3.8.4", "typescript": "^6.0.3", "vite": "^8.0.16", + "vite-plugin-checker": "^0.14.1", "vite-plugin-compression2": "^2.5.3", "vitest": "^4.1.8" } diff --git a/app/prettier.config.mjs b/app/prettier.config.mjs new file mode 100644 index 0000000000..057cbca8c3 --- /dev/null +++ b/app/prettier.config.mjs @@ -0,0 +1,13 @@ +/** + * Prettier configuration for the anyplot frontend. + * Values match the predominant existing style to minimize reformat churn. + */ +export default { + printWidth: 100, + tabWidth: 2, + singleQuote: true, + semi: true, + trailingComma: 'es5', + arrowParens: 'avoid', + endOfLine: 'lf', +}; diff --git a/app/src/analytics/reportWebVitals.test.ts b/app/src/analytics/reportWebVitals.test.ts index abd49c0e21..5c17818d68 100644 --- a/app/src/analytics/reportWebVitals.test.ts +++ b/app/src/analytics/reportWebVitals.test.ts @@ -1,15 +1,20 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { reportWebVitals } from './reportWebVitals'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + import { setAnalyticsAmbientProps } from '../hooks/useAnalytics'; +import { reportWebVitals } from './reportWebVitals'; // Single hoisted mock (vi.mock dedupes by module path — last call wins, so // keeping one shared mock avoids cross-test interference). vi.mock('web-vitals', () => ({ - onLCP: (cb: (m: { value: number; rating: string }) => void) => cb({ value: 2500, rating: 'good' }), - onCLS: (cb: (m: { value: number; rating: string }) => void) => cb({ value: 0.15, rating: 'needs-improvement' }), + onLCP: (cb: (m: { value: number; rating: string }) => void) => + cb({ value: 2500, rating: 'good' }), + onCLS: (cb: (m: { value: number; rating: string }) => void) => + cb({ value: 0.15, rating: 'needs-improvement' }), onINP: (cb: (m: { value: number; rating: string }) => void) => cb({ value: 200, rating: 'good' }), - onFCP: (cb: (m: { value: number; rating: string }) => void) => cb({ value: 1200, rating: 'good' }), - onTTFB: (cb: (m: { value: number; rating: string }) => void) => cb({ value: 400, rating: 'good' }), + onFCP: (cb: (m: { value: number; rating: string }) => void) => + cb({ value: 1200, rating: 'good' }), + onTTFB: (cb: (m: { value: number; rating: string }) => void) => + cb({ value: 400, rating: 'good' }), })); describe('reportWebVitals', () => { diff --git a/app/src/analytics/reportWebVitals.ts b/app/src/analytics/reportWebVitals.ts index ce7e3402e5..809b0dc4af 100644 --- a/app/src/analytics/reportWebVitals.ts +++ b/app/src/analytics/reportWebVitals.ts @@ -6,63 +6,61 @@ import { getAnalyticsAmbientProps } from '../hooks/useAnalytics'; * Only runs in production (anyplot.ai), dynamically imported for zero dev cost. */ export function reportWebVitals() { - if ( - typeof window === 'undefined' || - window.location.hostname !== 'anyplot.ai' - ) { + if (typeof window === 'undefined' || window.location.hostname !== 'anyplot.ai') { return; } - import('web-vitals').then(({ onLCP, onCLS, onINP, onFCP, onTTFB }) => { - onLCP((metric) => { - window.plausible?.('LCP', { - props: { - ...getAnalyticsAmbientProps(), - value: String(Math.round(metric.value / 100) * 100), - rating: metric.rating, - }, + import('web-vitals') + .then(({ onLCP, onCLS, onINP, onFCP, onTTFB }) => { + onLCP(metric => { + window.plausible?.('LCP', { + props: { + ...getAnalyticsAmbientProps(), + value: String(Math.round(metric.value / 100) * 100), + rating: metric.rating, + }, + }); }); - }); - onCLS((metric) => { - window.plausible?.('CLS', { - props: { - ...getAnalyticsAmbientProps(), - value: String(Math.round(metric.value * 100) / 100), - rating: metric.rating, - }, + onCLS(metric => { + window.plausible?.('CLS', { + props: { + ...getAnalyticsAmbientProps(), + value: String(Math.round(metric.value * 100) / 100), + rating: metric.rating, + }, + }); }); - }); - onINP((metric) => { - window.plausible?.('INP', { - props: { - ...getAnalyticsAmbientProps(), - value: String(Math.round(metric.value / 50) * 50), - rating: metric.rating, - }, + onINP(metric => { + window.plausible?.('INP', { + props: { + ...getAnalyticsAmbientProps(), + value: String(Math.round(metric.value / 50) * 50), + rating: metric.rating, + }, + }); }); - }); - onFCP((metric) => { - window.plausible?.('FCP', { - props: { - ...getAnalyticsAmbientProps(), - value: String(Math.round(metric.value / 100) * 100), - rating: metric.rating, - }, + onFCP(metric => { + window.plausible?.('FCP', { + props: { + ...getAnalyticsAmbientProps(), + value: String(Math.round(metric.value / 100) * 100), + rating: metric.rating, + }, + }); }); - }); - onTTFB((metric) => { - window.plausible?.('TTFB', { - props: { - ...getAnalyticsAmbientProps(), - value: String(Math.round(metric.value / 100) * 100), - rating: metric.rating, - }, + onTTFB(metric => { + window.plausible?.('TTFB', { + props: { + ...getAnalyticsAmbientProps(), + value: String(Math.round(metric.value / 100) * 100), + rating: metric.rating, + }, + }); }); - }); - }) - .catch(() => {}); + }) + .catch(() => {}); } diff --git a/app/src/components/BareLayout.tsx b/app/src/components/BareLayout.tsx index dc8f08b3a1..b1845f2f3e 100644 --- a/app/src/components/BareLayout.tsx +++ b/app/src/components/BareLayout.tsx @@ -1,4 +1,5 @@ import { Outlet } from 'react-router-dom'; + import Box from '@mui/material/Box'; /** diff --git a/app/src/components/CodeHighlighter.test.tsx b/app/src/components/CodeHighlighter.test.tsx index 810261ad74..e59deee2aa 100644 --- a/app/src/components/CodeHighlighter.test.tsx +++ b/app/src/components/CodeHighlighter.test.tsx @@ -1,4 +1,5 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; + import { render, screen } from '../test-utils'; vi.mock('react-syntax-highlighter/dist/esm/prism-light', () => { @@ -62,34 +63,22 @@ describe('CodeHighlighter', () => { it('defaults to python when no language prop given', () => { render(); - expect(screen.getByTestId('syntax-highlighter')).toHaveAttribute( - 'data-language', - 'python' - ); + expect(screen.getByTestId('syntax-highlighter')).toHaveAttribute('data-language', 'python'); }); it('uses r grammar when language is "r"', () => { - render(); - expect(screen.getByTestId('syntax-highlighter')).toHaveAttribute( - 'data-language', - 'r' - ); + render(); + expect(screen.getByTestId('syntax-highlighter')).toHaveAttribute('data-language', 'r'); }); it('uses julia grammar when language is "julia"', () => { - render(); - expect(screen.getByTestId('syntax-highlighter')).toHaveAttribute( - 'data-language', - 'julia' - ); + render(); + expect(screen.getByTestId('syntax-highlighter')).toHaveAttribute('data-language', 'julia'); }); it('falls back to plain text for unknown languages', () => { render(); - expect(screen.getByTestId('syntax-highlighter')).toHaveAttribute( - 'data-language', - 'text' - ); + expect(screen.getByTestId('syntax-highlighter')).toHaveAttribute('data-language', 'text'); }); it('uses the tsx grammar for the muix library (React TSX) even though its language is javascript', () => { @@ -100,17 +89,11 @@ describe('CodeHighlighter', () => { library="muix" /> ); - expect(screen.getByTestId('syntax-highlighter')).toHaveAttribute( - 'data-language', - 'tsx' - ); + expect(screen.getByTestId('syntax-highlighter')).toHaveAttribute('data-language', 'tsx'); }); it('uses the language grammar when the library has no grammar override', () => { render(); - expect(screen.getByTestId('syntax-highlighter')).toHaveAttribute( - 'data-language', - 'javascript' - ); + expect(screen.getByTestId('syntax-highlighter')).toHaveAttribute('data-language', 'javascript'); }); }); diff --git a/app/src/components/CodeHighlighter.tsx b/app/src/components/CodeHighlighter.tsx index be6fa911bc..1b6ccd08f0 100644 --- a/app/src/components/CodeHighlighter.tsx +++ b/app/src/components/CodeHighlighter.tsx @@ -1,9 +1,10 @@ -import SyntaxHighlighter from 'react-syntax-highlighter/dist/esm/prism-light'; +import javascript from 'react-syntax-highlighter/dist/esm/languages/prism/javascript'; +import julia from 'react-syntax-highlighter/dist/esm/languages/prism/julia'; import python from 'react-syntax-highlighter/dist/esm/languages/prism/python'; import r from 'react-syntax-highlighter/dist/esm/languages/prism/r'; -import julia from 'react-syntax-highlighter/dist/esm/languages/prism/julia'; -import javascript from 'react-syntax-highlighter/dist/esm/languages/prism/javascript'; import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx'; +import SyntaxHighlighter from 'react-syntax-highlighter/dist/esm/prism-light'; + import { typography } from '../theme'; SyntaxHighlighter.registerLanguage('python', python); @@ -78,7 +79,11 @@ interface CodeHighlighterProps { library?: string; } -export default function CodeHighlighter({ code, language = 'python', library }: CodeHighlighterProps) { +export default function CodeHighlighter({ + code, + language = 'python', + library, +}: CodeHighlighterProps) { const prismLanguage = (library ? LIBRARY_GRAMMAR_OVERRIDE[library.toLowerCase()] : undefined) ?? PRISM_LANGUAGE[language.toLowerCase()] ?? diff --git a/app/src/components/CodeShowcase.tsx b/app/src/components/CodeShowcase.tsx index d839a42777..c9c35fd42e 100644 --- a/app/src/components/CodeShowcase.tsx +++ b/app/src/components/CodeShowcase.tsx @@ -1,118 +1,167 @@ import Box from '@mui/material/Box'; -import { SectionHeader } from './SectionHeader'; + import { typography } from '../theme'; +import { SectionHeader } from './SectionHeader'; export function CodeShowcase() { return ( One import.} + title={ + <> + One import. + + } /> - + {/* Left: description */} - - Same palette,
every library. + + Same palette, +
+ every library.
- - every example in the catalogue uses the same imprint palette. switch libraries - without losing your color grammar — a gentoo penguin is always blue, - whether you draw it in matplotlib or plotly. + + every example in the catalogue uses the same imprint palette. switch libraries without + losing your color grammar — a gentoo penguin is + always blue, whether you draw it in matplotlib or plotly. - - validated against deuteranopia, protanopia and tritanopia using the Machado et al. (2009) simulation model. + + validated against deuteranopia, protanopia and tritanopia using the Machado et al. + (2009) simulation model. {/* Right: code block — terminal showcase, intentionally dark in both themes so the macOS-style window dots and drop shadow stay coherent. */} - + {'# pick any library. the palette travels with you.\n'} - import{' anyplot '} - as{' ap\n\n'} + + import + + {' anyplot '} + + as + + {' ap\n\n'} {'data = ap.'} - load + + load + {'('} - "penguins" + + "penguins" + {')\n\n'} {'# matplotlib\n'} {'ap.'} - mpl + + mpl + {'.'} - scatter + + scatter + {'(data, x='} - "bill" + + "bill" + {', y='} - "flipper" + + "flipper" + {',\n hue='} - "species" + + "species" + {')\n\n'} {'# plotly — same colors, interactive\n'} {'ap.'} - plotly + + plotly + {'.'} - scatter + + scatter + {'(data, x='} - "bill" + + "bill" + {', y='} - "flipper" + + "flipper" + {',\n hue='} - "species" + + "species" + {')'} diff --git a/app/src/components/ErrorBoundary.test.tsx b/app/src/components/ErrorBoundary.test.tsx index 59e27a5061..079f40a4b0 100644 --- a/app/src/components/ErrorBoundary.test.tsx +++ b/app/src/components/ErrorBoundary.test.tsx @@ -1,11 +1,13 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen } from '../test-utils'; +import { ReactNode } from 'react'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '../test-utils'; // Must import after test-utils to get jest-dom matchers import { ErrorBoundary } from './ErrorBoundary'; // Component that throws on render -function ThrowingComponent({ message }: { message: string }) { +function ThrowingComponent({ message }: { message: string }): ReactNode { throw new Error(message); } diff --git a/app/src/components/ErrorBoundary.tsx b/app/src/components/ErrorBoundary.tsx index 2710ddcc96..b220fb158d 100644 --- a/app/src/components/ErrorBoundary.tsx +++ b/app/src/components/ErrorBoundary.tsx @@ -1,12 +1,13 @@ import { Component, ReactNode } from 'react'; -import Box from '@mui/material/Box'; + +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import HomeIcon from '@mui/icons-material/Home'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import ReplayIcon from '@mui/icons-material/Replay'; import Alert from '@mui/material/Alert'; +import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Typography from '@mui/material/Typography'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import ReplayIcon from '@mui/icons-material/Replay'; -import HomeIcon from '@mui/icons-material/Home'; -import ContentCopyIcon from '@mui/icons-material/ContentCopy'; interface Props { children: ReactNode; @@ -67,7 +68,7 @@ export class ErrorBoundary extends Component { }; handleToggleDetails = (): void => { - this.setState((s) => ({ showDetails: !s.showDetails })); + this.setState(s => ({ showDetails: !s.showDetails })); }; buildDetails = (): string => { diff --git a/app/src/components/FeedbackWidget.test.tsx b/app/src/components/FeedbackWidget.test.tsx index 7004790dec..a0c33cc0a5 100644 --- a/app/src/components/FeedbackWidget.test.tsx +++ b/app/src/components/FeedbackWidget.test.tsx @@ -1,4 +1,5 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + import { render, screen, userEvent, waitFor } from '../test-utils'; import { FeedbackWidget } from './FeedbackWidget'; @@ -32,7 +33,10 @@ describe('FeedbackWidget', () => { it('submits a reaction-only entry when 👍 is clicked in the mini-stack', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response(JSON.stringify({ status: 'ok' }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + new Response(JSON.stringify({ status: 'ok' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) ); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime.bind(vi) }); @@ -78,7 +82,10 @@ describe('FeedbackWidget', () => { it('POSTs full-form fields to /feedback and shows a thank-you on success', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response(JSON.stringify({ status: 'ok' }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + new Response(JSON.stringify({ status: 'ok' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) ); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime.bind(vi) }); @@ -157,7 +164,10 @@ describe('FeedbackWidget', () => { it('full-dialog submit sends the chosen reaction in the payload', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response(JSON.stringify({ status: 'ok' }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + new Response(JSON.stringify({ status: 'ok' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) ); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime.bind(vi) }); diff --git a/app/src/components/FeedbackWidget.tsx b/app/src/components/FeedbackWidget.tsx index c7a16b8ece..61d66f812b 100644 --- a/app/src/components/FeedbackWidget.tsx +++ b/app/src/components/FeedbackWidget.tsx @@ -1,4 +1,10 @@ import { useEffect, useRef, useState } from 'react'; + +import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutlineOutlined'; +import CloseIcon from '@mui/icons-material/Close'; +import ForumIcon from '@mui/icons-material/ForumOutlined'; +import ThumbDownIcon from '@mui/icons-material/ThumbDownOutlined'; +import ThumbUpIcon from '@mui/icons-material/ThumbUpOutlined'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import ClickAwayListener from '@mui/material/ClickAwayListener'; @@ -9,11 +15,7 @@ import TextField from '@mui/material/TextField'; import ToggleButton from '@mui/material/ToggleButton'; import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; import Tooltip from '@mui/material/Tooltip'; -import ForumIcon from '@mui/icons-material/ForumOutlined'; -import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutlineOutlined'; -import ThumbUpIcon from '@mui/icons-material/ThumbUpOutlined'; -import ThumbDownIcon from '@mui/icons-material/ThumbDownOutlined'; -import CloseIcon from '@mui/icons-material/Close'; + import { API_URL } from '../constants'; import { useAnalytics } from '../hooks'; import { useLocalStorage } from '../hooks/useLocalStorage'; @@ -58,7 +60,7 @@ function newSessionId(): string { if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') { const bytes = new Uint8Array(16); crypto.getRandomValues(bytes); - return `s-${Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')}`; + return `s-${Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')}`; } // Browser without Web Crypto support (e.g. very old, or insecure context). The // session id is an opaque correlation handle, not a credential — a coarse @@ -135,7 +137,9 @@ export function FeedbackWidget() { // so the footer's top edge runs exactly through the FAB centre. const FAB_CENTER_FROM_BOTTOM_XS = 32; const liftTransform = - lift > FAB_CENTER_FROM_BOTTOM_XS ? `translateY(-${lift - FAB_CENTER_FROM_BOTTOM_XS}px)` : 'none'; + lift > FAB_CENTER_FROM_BOTTOM_XS + ? `translateY(-${lift - FAB_CENTER_FROM_BOTTOM_XS}px)` + : 'none'; const [sessionId, setSessionId] = useLocalStorage(SESSION_KEY, ''); @@ -198,7 +202,11 @@ export function FeedbackWidget() { if (mode === 'quick') setMode('closed'); }; - const buildPayload = (overrides: { message: string | null; reaction: Reaction | null; contact: string | null }) => ({ + const buildPayload = (overrides: { + message: string | null; + reaction: Reaction | null; + contact: string | null; + }) => ({ message: overrides.message, reaction: overrides.reaction, contact: overrides.contact, @@ -426,15 +434,20 @@ export function FeedbackWidget() { 🙏 Thanks! - - We read every note. - + We read every note. ) : ( - + Quick feedback - + @@ -447,8 +460,10 @@ export function FeedbackWidget() { fullWidth placeholder="Bug, idea, typo, anything…" value={message} - onChange={(e) => setMessage(e.target.value)} - slotProps={{ htmlInput: { maxLength: MAX_MESSAGE_LENGTH, 'aria-label': 'Feedback message' } }} + onChange={e => setMessage(e.target.value)} + slotProps={{ + htmlInput: { maxLength: MAX_MESSAGE_LENGTH, 'aria-label': 'Feedback message' }, + }} disabled={submitting} sx={{ mb: 1.5 }} /> @@ -461,8 +476,13 @@ export function FeedbackWidget() { aria-label="Reaction" sx={{ display: 'flex', flexWrap: 'wrap', mb: 1.5 }} > - {REACTIONS.map((r) => ( - + {REACTIONS.map(r => ( + {r.glyph} ))} @@ -473,7 +493,7 @@ export function FeedbackWidget() { size="small" placeholder="Name or email (optional)" value={contact} - onChange={(e) => setContact(e.target.value)} + onChange={e => setContact(e.target.value)} slotProps={{ htmlInput: { maxLength: 255, 'aria-label': 'Contact (optional)' } }} disabled={submitting} sx={{ mb: 1 }} @@ -494,14 +514,23 @@ export function FeedbackWidget() { {/* Honeypot — real users never see this, bots will fill it and trip the server-side guard. */} -