diff --git a/package-lock.json b/package-lock.json index 82df511..5821b81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vizzly-testing/cli", - "version": "0.29.2", + "version": "0.29.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vizzly-testing/cli", - "version": "0.29.2", + "version": "0.29.3", "license": "MIT", "dependencies": { "@vizzly-testing/honeydiff": "^0.10.0", @@ -37,6 +37,7 @@ "@vitejs/plugin-react": "^5.0.3", "@vizzly-testing/observatory": "^0.3.3", "autoprefixer": "^10.4.21", + "babel-plugin-react-compiler": "^1.0.0", "babel-plugin-transform-remove-console": "^6.9.4", "postcss": "^8.5.6", "react": "^19.1.1", @@ -3792,6 +3793,16 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/babel-plugin-react-compiler": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", + "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.0" + } + }, "node_modules/babel-plugin-transform-remove-console": { "version": "6.9.4", "resolved": "https://registry.npmjs.org/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.9.4.tgz", diff --git a/package.json b/package.json index 09d9f30..7b8bc2d 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,7 @@ "@vitejs/plugin-react": "^5.0.3", "@vizzly-testing/observatory": "^0.3.3", "autoprefixer": "^10.4.21", + "babel-plugin-react-compiler": "^1.0.0", "babel-plugin-transform-remove-console": "^6.9.4", "postcss": "^8.5.6", "react": "^19.1.1", diff --git a/src/reporter/src/components/comparison/screenshot-list.jsx b/src/reporter/src/components/comparison/screenshot-list.jsx index 064c3df..6435df5 100644 --- a/src/reporter/src/components/comparison/screenshot-list.jsx +++ b/src/reporter/src/components/comparison/screenshot-list.jsx @@ -10,6 +10,10 @@ import { useMemo } from 'react'; import { Badge, Button } from '../design-system/index.js'; import SmartImage from '../ui/smart-image.jsx'; +function getComparisonId(comparison) { + return comparison.id || comparison.signature || comparison.name; +} + /** * Group comparisons by name and calculate aggregate stats */ @@ -366,6 +370,8 @@ export default function ScreenshotList({ onSelectComparison, onAcceptComparison, onRejectComparison, + onAcceptGroup, + onRejectGroup, loadingStates = {}, }) { let groups = useMemo(() => { @@ -382,16 +388,30 @@ export default function ScreenshotList({ } let handleAcceptGroup = group => { + let ids = group.comparisons.map(getComparisonId).filter(Boolean); + + if (onAcceptGroup) { + onAcceptGroup(ids); + return; + } + if (!onAcceptComparison) return; - for (let comp of group.comparisons) { - onAcceptComparison(comp.id || comp.signature || comp.name); + for (let id of ids) { + onAcceptComparison(id); } }; let handleRejectGroup = group => { + let ids = group.comparisons.map(getComparisonId).filter(Boolean); + + if (onRejectGroup) { + onRejectGroup(ids); + return; + } + if (!onRejectComparison) return; - for (let comp of group.comparisons) { - onRejectComparison(comp.id || comp.signature || comp.name); + for (let id of ids) { + onRejectComparison(id); } }; diff --git a/src/reporter/src/components/views/builds-view.jsx b/src/reporter/src/components/views/builds-view.jsx index dd48b4e..bc9e317 100644 --- a/src/reporter/src/components/views/builds-view.jsx +++ b/src/reporter/src/components/views/builds-view.jsx @@ -393,15 +393,14 @@ export default function BuildsView() { // Use TanStack Query for data const { data: authData, isLoading: authLoading } = useAuthStatus(); + const authenticated = authData?.authenticated; const { data: projectsData, isLoading: projectsLoading, refetch, - } = useProjects(); + } = useProjects({ enabled: authenticated === true }); const downloadMutation = useDownloadBaselines(); - const authenticated = authData?.authenticated; - // Group projects by organization const projectsByOrg = useMemo(() => { const projects = projectsData?.projects || []; diff --git a/src/reporter/src/components/views/comparisons-view.jsx b/src/reporter/src/components/views/comparisons-view.jsx index 6143068..c2d43f0 100644 --- a/src/reporter/src/components/views/comparisons-view.jsx +++ b/src/reporter/src/components/views/comparisons-view.jsx @@ -9,8 +9,8 @@ import { useCallback, useState } from 'react'; import { useLocation } from 'wouter'; import { useAcceptAllBaselines, - useAcceptBaseline, - useRejectBaseline, + useAcceptBaselinesBatch, + useRejectBaselinesBatch, useReportData, } from '../../hooks/queries/use-tdd-queries.js'; import useComparisonFilters from '../../hooks/use-comparison-filters.js'; @@ -23,6 +23,18 @@ import DashboardFilters from '../dashboard/dashboard-filters.jsx'; import { Button, Card, CardBody, EmptyState } from '../design-system/index.js'; import { useToast } from '../ui/toast.jsx'; +function asIdList(value) { + if (Array.isArray(value)) { + return value.filter(Boolean); + } + + if (value) { + return [value]; + } + + return []; +} + /** * Action banner for accepting changes */ @@ -122,8 +134,8 @@ export default function ComparisonsView() { // Use TanStack Query for data let { data: reportData, isLoading, refetch } = useReportData(); let acceptAllMutation = useAcceptAllBaselines(); - let acceptMutation = useAcceptBaseline(); - let rejectMutation = useRejectBaseline(); + let acceptBatchMutation = useAcceptBaselinesBatch(); + let rejectBatchMutation = useRejectBaselinesBatch(); let { filteredComparisons, @@ -152,38 +164,93 @@ export default function ComparisonsView() { [setLocation] ); - // Accept a single comparison - let handleAcceptComparison = useCallback( - id => { - setLoadingStates(prev => ({ ...prev, [id]: 'accepting' })); - acceptMutation.mutate(id, { + let updateLoadingStateForIds = useCallback((ids, state) => { + setLoadingStates(prev => { + let next = { ...prev }; + + for (let id of ids) { + if (!id) continue; + + if (state) { + next[id] = state; + } else { + delete next[id]; + } + } + + return next; + }); + }, []); + + let handleAcceptGroup = useCallback( + ids => { + if (!ids || ids.length === 0) return; + + updateLoadingStateForIds(ids, 'accepting'); + acceptBatchMutation.mutate(ids, { onSuccess: () => { - setLoadingStates(prev => ({ ...prev, [id]: 'accepted' })); + updateLoadingStateForIds(ids, 'accepted'); }, onError: err => { - setLoadingStates(prev => ({ ...prev, [id]: undefined })); + let succeededIds = asIdList(err?.succeededIds); + let failedIds = asIdList(err?.failedIds); + + if (succeededIds.length > 0) { + updateLoadingStateForIds(succeededIds, 'accepted'); + } + + if (failedIds.length > 0) { + updateLoadingStateForIds(failedIds, null); + } else { + updateLoadingStateForIds(ids, null); + } + addToast(`Failed to accept: ${err.message}`, 'error'); }, }); }, - [acceptMutation, addToast] + [acceptBatchMutation, addToast, updateLoadingStateForIds] ); - // Reject a single comparison - let handleRejectComparison = useCallback( - id => { - setLoadingStates(prev => ({ ...prev, [id]: 'rejecting' })); - rejectMutation.mutate(id, { + let handleRejectGroup = useCallback( + ids => { + if (!ids || ids.length === 0) return; + + updateLoadingStateForIds(ids, 'rejecting'); + rejectBatchMutation.mutate(ids, { onSuccess: () => { - setLoadingStates(prev => ({ ...prev, [id]: 'rejected' })); + updateLoadingStateForIds(ids, 'rejected'); }, onError: err => { - setLoadingStates(prev => ({ ...prev, [id]: undefined })); + let succeededIds = asIdList(err?.succeededIds); + let failedIds = asIdList(err?.failedIds); + + if (succeededIds.length > 0) { + updateLoadingStateForIds(succeededIds, 'rejected'); + } + + if (failedIds.length > 0) { + updateLoadingStateForIds(failedIds, null); + } else { + updateLoadingStateForIds(ids, null); + } + addToast(`Failed to reject: ${err.message}`, 'error'); }, }); }, - [rejectMutation, addToast] + [rejectBatchMutation, addToast, updateLoadingStateForIds] + ); + + // Single-item handlers for fallback paths + let handleAcceptComparison = useCallback( + id => handleAcceptGroup(id ? [id] : []), + [handleAcceptGroup] + ); + + let handleRejectComparison = useCallback( + id => handleRejectGroup(id ? [id] : []), + [handleRejectGroup] ); let handleAcceptAll = useCallback(async () => { @@ -227,7 +294,6 @@ export default function ComparisonsView() { let newCount = reportData?.comparisons?.filter(c => isNewComparisonStatus(c.status)) .length || 0; - let _totalToAccept = failedCount + newCount; if (hasNoComparisons) { return ( @@ -302,6 +368,8 @@ export default function ComparisonsView() { onSelectComparison={handleSelectComparison} onAcceptComparison={handleAcceptComparison} onRejectComparison={handleRejectComparison} + onAcceptGroup={handleAcceptGroup} + onRejectGroup={handleRejectGroup} loadingStates={loadingStates} /> )} diff --git a/src/reporter/src/components/views/settings-view.jsx b/src/reporter/src/components/views/settings-view.jsx index a3b1da1..92a1399 100644 --- a/src/reporter/src/components/views/settings-view.jsx +++ b/src/reporter/src/components/views/settings-view.jsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useConfig, useUpdateProjectConfig, @@ -18,15 +18,54 @@ import { useToast } from '../ui/toast.jsx'; function getInitialFormData(config) { return { - threshold: config?.comparison?.threshold ?? 2.0, - port: config?.server?.port ?? 47392, - timeout: config?.server?.timeout ?? 30000, + threshold: String(config?.comparison?.threshold ?? 2.0), + port: String(config?.server?.port ?? 47392), + timeout: String(config?.server?.timeout ?? 30000), buildName: config?.build?.name ?? 'Build {timestamp}', environment: config?.build?.environment ?? 'test', openReport: config?.tdd?.openReport ?? false, }; } +function parseNumberInput(value) { + if (value === null || value === undefined) return null; + if (String(value).trim() === '') return null; + + let parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function parseIntegerInput(value) { + let parsed = parseNumberInput(value); + return Number.isInteger(parsed) ? parsed : null; +} + +function getValidationErrors(formData) { + let errors = { + threshold: null, + port: null, + timeout: null, + }; + + let threshold = parseNumberInput(formData.threshold); + if (threshold === null || threshold < 0) { + errors.threshold = 'Threshold must be a number greater than or equal to 0.'; + } + + let port = parseIntegerInput(formData.port); + if (port === null || port < 1 || port > 65535) { + errors.port = 'Port must be a whole number between 1 and 65535.'; + } + + let timeout = parseIntegerInput(formData.timeout); + if (timeout === null || timeout < 0) { + errors.timeout = + 'Timeout must be a whole number greater than or equal to 0.'; + } + + return errors; +} + function SourceBadge({ source }) { let variants = { default: 'default', @@ -70,6 +109,11 @@ function SettingsForm({ config, sources, onSave, isSaving }) { let initialFormData = getInitialFormData(config); let [formData, setFormData] = useState(initialFormData); let [hasChanges, setHasChanges] = useState(false); + let validationErrors = useMemo( + () => getValidationErrors(formData), + [formData] + ); + let hasValidationErrors = Object.values(validationErrors).some(Boolean); let handleFieldChange = useCallback((name, value) => { setFormData(prev => ({ ...prev, [name]: value })); @@ -82,13 +126,21 @@ function SettingsForm({ config, sources, onSave, isSaving }) { }, [config]); let handleSave = useCallback(() => { + let threshold = parseNumberInput(formData.threshold); + let port = parseIntegerInput(formData.port); + let timeout = parseIntegerInput(formData.timeout); + + if (threshold === null || port === null || timeout === null) { + return; + } + let updates = { comparison: { - threshold: formData.threshold, + threshold, }, server: { - port: formData.port, - timeout: formData.timeout, + port, + timeout, }, build: { name: formData.buildName, @@ -117,13 +169,16 @@ function SettingsForm({ config, sources, onSave, isSaving }) { label="Threshold" type="number" value={formData.threshold} - onChange={e => - handleFieldChange('threshold', parseFloat(e.target.value)) - } + onChange={e => handleFieldChange('threshold', e.target.value)} hint="CIEDE2000 Delta E. 0 = exact, 2 = recommended" step="0.1" min="0" /> + {validationErrors.threshold && ( +
+ {validationErrors.threshold} +
+ )} @@ -140,20 +195,26 @@ function SettingsForm({ config, sources, onSave, isSaving }) { label="Port" type="number" value={formData.port} - onChange={e => - handleFieldChange('port', parseInt(e.target.value, 10)) - } + onChange={e => handleFieldChange('port', e.target.value)} hint="Default: 47392" /> + {validationErrors.port && ( ++ {validationErrors.port} +
+ )} - handleFieldChange('timeout', parseInt(e.target.value, 10)) - } + onChange={e => handleFieldChange('timeout', e.target.value)} hint="Request timeout in milliseconds" /> + {validationErrors.timeout && ( ++ {validationErrors.timeout} +
+ )} @@ -217,7 +278,11 @@ function SettingsForm({ config, sources, onSave, isSaving }) {