From f92ccef41b826f2f005ef0e233c4b12c566e5ae0 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Wed, 25 Feb 2026 12:18:36 -0800 Subject: [PATCH 01/18] ref(dynamic-sampling): Migrate projectSampling to new TanStack form system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the custom formContext-based form in projectSampling with useScrapsForm from @sentry/scraps/form, completing the migration started for organizationSampling. - Delete projectSamplingForm.tsx and formContext.tsx — now dead code - Rewrite projectSampling.tsx using useScrapsForm with a z.record schema. A useEffect mirrors the old enableReInitialize behaviour, resetting the form whenever the server data changes. Per-project validity is checked inline in the AppField render prop (same logic as getProjectRateErrors) to keep the Apply Changes button disabled when any rate is invalid. - Rewrite projectsEditTable.tsx to receive projectRates, savedProjectRates, and onProjectRatesChange as explicit props instead of pulling from the form context via useFormField. Per-project validation errors are now computed locally via getProjectRateErrors, which keeps the validation logic co-located with the table that displays it. Co-Authored-By: Claude --- .../dynamicSampling/projectSampling.tsx | 184 ++++++++------ .../dynamicSampling/projectsEditTable.tsx | 77 ++++-- .../dynamicSampling/utils/formContext.tsx | 235 ------------------ .../utils/projectSamplingForm.tsx | 35 --- 4 files changed, 162 insertions(+), 369 deletions(-) delete mode 100644 static/app/views/settings/dynamicSampling/utils/formContext.tsx delete mode 100644 static/app/views/settings/dynamicSampling/utils/projectSamplingForm.tsx diff --git a/static/app/views/settings/dynamicSampling/projectSampling.tsx b/static/app/views/settings/dynamicSampling/projectSampling.tsx index 43f868a83f6350..5fe0eaaf3cb803 100644 --- a/static/app/views/settings/dynamicSampling/projectSampling.tsx +++ b/static/app/views/settings/dynamicSampling/projectSampling.tsx @@ -1,7 +1,9 @@ -import {Fragment, useMemo, useState} from 'react'; +import {Fragment, useEffect, useMemo, useState} from 'react'; import styled from '@emotion/styled'; +import {z} from 'zod'; import {Button} from '@sentry/scraps/button'; +import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form'; import {Flex} from '@sentry/scraps/layout'; import { @@ -18,7 +20,6 @@ import {SamplingModeSwitch} from 'sentry/views/settings/dynamicSampling/sampling import {mapArrayToObject} from 'sentry/views/settings/dynamicSampling/utils'; import {useHasDynamicSamplingWriteAccess} from 'sentry/views/settings/dynamicSampling/utils/access'; import {parsePercent} from 'sentry/views/settings/dynamicSampling/utils/parsePercent'; -import {projectSamplingForm} from 'sentry/views/settings/dynamicSampling/utils/projectSamplingForm'; import { useProjectSampleCounts, type ProjectionSamplePeriod, @@ -28,11 +29,16 @@ import { useUpdateSamplingProjectRates, } from 'sentry/views/settings/dynamicSampling/utils/useSamplingProjectRates'; -const {useFormState, FormProvider} = projectSamplingForm; const UNSAVED_CHANGES_MESSAGE = t( 'You have unsaved changes, are you sure you want to leave?' ); +// Zod schema for type correctness. Per-project validation errors are computed +// in projectsEditTable via getProjectRateErrors. +const schema = z.object({ + projectRates: z.record(z.string(), z.string()), +}); + export function ProjectSampling() { const hasAccess = useHasDynamicSamplingWriteAccess(); const [period, setPeriod] = useState('24h'); @@ -54,37 +60,40 @@ export function ProjectSampling() { [sampleRatesQuery.data] ); - const initialValues = useMemo(() => ({projectRates}), [projectRates]); - - const formState = useFormState({ - initialValues, - enableReInitialize: true, - }); - - const handleReset = () => { - formState.reset(); - setEditMode('single'); - }; - - const handleSubmit = () => { - const ratesArray = Object.entries(formState.fields.projectRates.value).map( - ([id, rate]) => ({ + const [savedProjectRates, setSavedProjectRates] = useState>({}); + + const form = useScrapsForm({ + ...defaultFormOptions, + defaultValues: { + projectRates: {} as Record, + }, + validators: { + onDynamic: schema, + }, + onSubmit: async ({value, formApi}) => { + const ratesArray = Object.entries(value.projectRates).map(([id, rate]) => ({ id: Number(id), sampleRate: parsePercent(rate), - }) - ); - addLoadingMessage(t('Saving changes...')); - updateSamplingProjectRates.mutate(ratesArray, { - onSuccess: () => { - formState.save(); + })); + addLoadingMessage(t('Saving changes...')); + try { + await updateSamplingProjectRates.mutateAsync(ratesArray); + setSavedProjectRates(value.projectRates); setEditMode('single'); + formApi.reset(value); addSuccessMessage(t('Changes applied')); - }, - onError: () => { + } catch { addErrorMessage(t('Unable to save changes. Please try again.')); - }, - }); - }; + } + }, + }); + + // Mirror enableReInitialize: reset the form whenever the server data changes + useEffect(() => { + form.reset({projectRates}); + setSavedProjectRates(projectRates); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectRates]); const initialTargetRate = useMemo(() => { const sampleRates = sampleRatesQuery.data ?? []; @@ -105,52 +114,83 @@ export function ProjectSampling() { ); }, [sampleRatesQuery.data, sampleCountsQuery.data]); - const isFormActionDisabled = - !hasAccess || - sampleRatesQuery.isPending || - updateSamplingProjectRates.isPending || - !formState.hasChanged; - return ( - - - locationChange.currentLocation.pathname !== - locationChange.nextLocation.pathname && formState.hasChanged - } - /> - - - - - {sampleCountsQuery.isError ? ( - - ) : ( - + + s.isDirty}> + {isDirty => ( - - + + locationChange.currentLocation.pathname !== + locationChange.nextLocation.pathname && isDirty + } + /> + + + + + {sampleCountsQuery.isError ? ( + + ) : ( + + {field => { + const hasProjectRateErrors = + field.state.value && + Object.values(field.state.value).some(rate => { + if (!rate) return true; + const n = Number(rate); + return isNaN(n) || n < 0 || n > 100; + }); + return ( + + + + + } + /> + ); + }} + + )} + - } - /> - )} - - + )} + + + ); } diff --git a/static/app/views/settings/dynamicSampling/projectsEditTable.tsx b/static/app/views/settings/dynamicSampling/projectsEditTable.tsx index d52da4a1ca2106..265a7e116d5cea 100644 --- a/static/app/views/settings/dynamicSampling/projectsEditTable.tsx +++ b/static/app/views/settings/dynamicSampling/projectsEditTable.tsx @@ -13,7 +13,6 @@ import {SamplingBreakdown} from 'sentry/views/settings/dynamicSampling/samplingB import {mapArrayToObject} from 'sentry/views/settings/dynamicSampling/utils'; import {formatPercent} from 'sentry/views/settings/dynamicSampling/utils/formatPercent'; import {parsePercent} from 'sentry/views/settings/dynamicSampling/utils/parsePercent'; -import {projectSamplingForm} from 'sentry/views/settings/dynamicSampling/utils/projectSamplingForm'; import {scaleSampleRates} from 'sentry/views/settings/dynamicSampling/utils/scaleSampleRates'; import type { ProjectionSamplePeriod, @@ -25,13 +24,33 @@ interface Props { editMode: 'single' | 'bulk'; isLoading: boolean; onEditModeChange: (mode: 'single' | 'bulk') => void; + onProjectRatesChange: ( + updater: + | Record + | ((prev: Record) => Record) + ) => void; period: ProjectionSamplePeriod; + projectRates: Record; sampleCounts: ProjectSampleCount[]; + savedProjectRates: Record; } -const {useFormField} = projectSamplingForm; const EMPTY_ARRAY: any = []; +function getProjectRateErrors(rates: Record): Record { + const errors: Record = {}; + for (const [projectId, rate] of Object.entries(rates)) { + if (rate === '') { + errors[projectId] = t('This field is required'); + } else if (isNaN(Number(rate))) { + errors[projectId] = t('Please enter a valid number'); + } else if (Number(rate) < 0 || Number(rate) > 100) { + errors[projectId] = t('Must be between 0% and 100%'); + } + } + return errors; +} + export function ProjectsEditTable({ actions, isLoading: isLoadingProp, @@ -39,14 +58,21 @@ export function ProjectsEditTable({ editMode, period, onEditModeChange, + projectRates, + savedProjectRates, + onProjectRatesChange, }: Props) { const {projects, fetching} = useProjects(); - const {value, initialValue, error, onChange} = useFormField('projectRates'); const [isBulkEditEnabled, setIsBulkEditEnabled] = useState(false); const [orgRate, setOrgRate] = useState(''); const projectRateSnapshotRef = useRef>({}); + const projectRateErrors = useMemo( + () => getProjectRateErrors(projectRates), + [projectRates] + ); + const dataByProjectId = useMemo( () => mapArrayToObject({ @@ -59,22 +85,22 @@ export function ProjectsEditTable({ const handleProjectChange = useCallback( (projectId: string, newRate: string) => { - onChange(prev => ({ + onProjectRatesChange(prev => ({ ...prev, [projectId]: newRate, })); onEditModeChange('single'); }, - [onChange, onEditModeChange] + [onProjectRatesChange, onEditModeChange] ); const handleOrgChange = useCallback( (newRate: string) => { - // Editing the org rate will transition the logic to bulk edit mode + // Editing the org rate will transition the logic to bulk edit mode. // On the first edit, we need to snapshot the current project rates as scaling baseline - // to avoid rounding errors when scaling the sample rates up and down + // to avoid rounding errors when scaling the sample rates up and down. if (editMode === 'single') { - projectRateSnapshotRef.current = value; + projectRateSnapshotRef.current = projectRates; } const cappedOrgRate = parsePercent(newRate, 1); @@ -84,7 +110,7 @@ export function ProjectsEditTable({ sampleRate: rate ? parsePercent(rate) : 0, count: dataByProjectId[projectId]?.count ?? 0, })) - // We do not wan't to bulk edit inactive projects as they have no effect on the outcome + // We do not want to bulk edit inactive projects as they have no effect on the outcome .filter(item => item.count !== 0); const {scaledItems} = scaleSampleRates({ @@ -98,15 +124,11 @@ export function ProjectsEditTable({ valueSelector: item => formatPercent(item.sampleRate), }); - // Update the form state (project values) with the new sample rates - onChange(prev => { - return {...prev, ...newProjectValues}; - }); - + onProjectRatesChange(prev => ({...prev, ...newProjectValues})); setOrgRate(newRate); onEditModeChange('bulk'); }, - [dataByProjectId, editMode, onChange, onEditModeChange, value] + [dataByProjectId, editMode, onProjectRatesChange, onEditModeChange, projectRates] ); const handleBulkEditChange = useCallback((newIsActive: boolean) => { @@ -129,12 +151,12 @@ export function ProjectsEditTable({ ownCount: item?.ownCount || 0, subProjects: item?.subProjects ?? EMPTY_ARRAY, project, - initialSampleRate: initialValue[project.id]!, - sampleRate: value[project.id]!, - error: error?.[project.id], + initialSampleRate: savedProjectRates[project.id]!, + sampleRate: projectRates[project.id]!, + error: projectRateErrors[project.id], }; }), - [dataByProjectId, error, initialValue, projects, value] + [dataByProjectId, projectRateErrors, savedProjectRates, projects, projectRates] ); const totalSpanCount = useMemo( @@ -142,35 +164,36 @@ export function ProjectsEditTable({ [items] ); - // In bulk edit mode, we display the org rate from the input state - // In single edit mode, we display the estimated org rate based on the current sample rates + // In bulk edit mode, we display the org rate from the input state. + // In single edit mode, we display the estimated org rate based on the current sample rates. const displayedOrgRate = useMemo(() => { if (editMode === 'bulk') { return orgRate; } const totalSampledSpans = items.reduce( - (acc, item) => acc + item.count * parsePercent(value[item.project.id], 1), + (acc, item) => acc + item.count * parsePercent(projectRates[item.project.id], 1), 0 ); return formatPercent(totalSampledSpans / totalSpanCount); - }, [editMode, items, orgRate, totalSpanCount, value]); + }, [editMode, items, orgRate, totalSpanCount, projectRates]); const initialOrgRate = useMemo(() => { const totalSampledSpans = items.reduce( - (acc, item) => acc + item.count * parsePercent(initialValue[item.project.id], 1), + (acc, item) => + acc + item.count * parsePercent(savedProjectRates[item.project.id], 1), 0 ); return formatPercent(totalSampledSpans / totalSpanCount); - }, [initialValue, items, totalSpanCount]); + }, [savedProjectRates, items, totalSpanCount]); const breakdownSampleRates = useMemo( () => mapArrayToObject({ - array: Object.entries(value), + array: Object.entries(projectRates), keySelector: ([projectId, _]) => projectId, valueSelector: ([_, rate]) => parsePercent(rate), }), - [value] + [projectRates] ); const isLoading = fetching || isLoadingProp; diff --git a/static/app/views/settings/dynamicSampling/utils/formContext.tsx b/static/app/views/settings/dynamicSampling/utils/formContext.tsx deleted file mode 100644 index 4f6010e7b107ab..00000000000000 --- a/static/app/views/settings/dynamicSampling/utils/formContext.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from 'react'; -import isEqual from 'lodash/isEqual'; - -interface FormState< - FormFields extends PlainValue, - FieldErrors extends Record, -> { - /** - * State for each field in the form. - */ - fields: { - [K in keyof FormFields]: { - hasChanged: boolean; - initialValue: FormFields[K]; - onChange: (value: React.SetStateAction) => void; - value: FormFields[K]; - error?: FieldErrors[K]; - }; - }; - /** - * Whether the form has changed from the initial values. - */ - hasChanged: boolean; - /** - * Whether the form is valid. - * A form is valid if all fields pass validation. - */ - isValid: boolean; - /** - * Resets the form state to the initial values. - */ - reset: () => void; - /** - * Saves the form state by setting the initial values to the current values. - */ - save: () => void; -} - -type PlainValue = AtomicValue | PlainArray | PlainObject; -interface PlainObject { - [key: string]: PlainValue; -} -interface PlainArray extends Array {} -type AtomicValue = string | number | boolean | null | undefined; - -type FormValidators< - FormFields extends Record, - FieldErrors extends Record, -> = { - [K in keyof FormFields]?: (value: FormFields[K]) => FieldErrors[K] | undefined; -}; - -type InitialValues> = { - [K in keyof FormFields]: FormFields[K]; -}; - -type FormStateConfig< - FormFields extends Record, - FieldErrors extends Record, -> = { - /** - * The initial values for the form fields. - */ - initialValues: InitialValues; - /** - * Whether to re-initialize the form state when the initial values change. - */ - enableReInitialize?: boolean; - /** - * Validator functions for the form fields. - */ - validators?: FormValidators; -}; - -/** - * Creates a form state object with fields and validation for a given set of form fields. - */ -const useFormState = < - FormFields extends Record, - FieldErrors extends Record, ->( - config: FormStateConfig -): FormState => { - const [initialValues, setInitialValues] = useState(config.initialValues); - const [validators] = useState(config.validators); - const [values, setValues] = useState(initialValues); - const [errors, setErrors] = useState<{[K in keyof FormFields]?: FieldErrors[K]}>({}); - - useEffect(() => { - if (config.enableReInitialize) { - // eslint-disable-next-line react-you-might-not-need-an-effect/no-derived-state - setInitialValues(config.initialValues); - // eslint-disable-next-line react-you-might-not-need-an-effect/no-derived-state - setValues(config.initialValues); - setErrors({}); - } - }, [config.enableReInitialize, config.initialValues]); - - const setValue = useCallback( - (name: K, value: React.SetStateAction) => { - setValues(old => ({ - ...old, - [name]: typeof value === 'function' ? value(old[name]) : value, - })); - }, - [] - ); - - const setError = useCallback( - (name: K, error: string | undefined) => { - setErrors(old => ({...old, [name]: error})); - }, - [] - ); - - /** - * Validates a field by running the field's validator function. - */ - const validateField = useCallback( - (name: K, value: FormFields[K]) => { - const validator = validators?.[name]; - return validator?.(value); - }, - [validators] - ); - - const handleFieldChange = useCallback( - (name: K, value: React.SetStateAction) => { - setValue(name, old => { - const newValue = typeof value === 'function' ? value(old) : value; - const error = validateField(name, newValue); - setError(name, error); - return newValue; - }); - }, - [setError, setValue, validateField] - ); - - const changeHandlers = useMemo(() => { - const result: { - [K in keyof FormFields]: (value: React.SetStateAction) => void; - } = {} as any; - - for (const name in initialValues) { - result[name] = (value: React.SetStateAction) => - handleFieldChange(name, value); - } - - return result; - }, [handleFieldChange, initialValues]); - - const fields = useMemo(() => { - const result: FormState['fields'] = {} as any; - - for (const name in initialValues) { - result[name] = { - value: values[name], - onChange: changeHandlers[name], - error: errors[name], - hasChanged: values[name] !== initialValues[name], - initialValue: initialValues[name], - }; - } - - return result; - }, [changeHandlers, errors, initialValues, values]); - - return { - fields, - isValid: Object.values(errors).every(error => !error), - hasChanged: Object.entries(values).some( - ([name, value]) => !isEqual(value, initialValues[name]) - ), - save: () => { - setInitialValues(values); - }, - reset: () => { - setValues(initialValues); - setErrors({}); - }, - }; -}; - -/** - * Creates a form context and hooks for a form with a given set of fields to enable type-safe form handling. - */ -export const createForm = < - FormFields extends Record, - FieldErrors extends Record = Record< - keyof FormFields, - string | undefined - >, ->({ - validators, -}: { - validators?: FormValidators; -}) => { - const FormContext = createContext | undefined>( - undefined - ); - - function FormProvider({ - children, - formState, - }: { - children: React.ReactNode; - formState: FormState; - }) { - return {children}; - } - - const useFormField = (name: K) => { - const formState = useContext(FormContext); - if (!formState) { - throw new Error('useFormField must be used within a FormProvider'); - } - - return formState.fields[name]; - }; - - return { - useFormState: ( - config: Omit, 'validators'> - ) => useFormState({...config, validators}), - FormProvider, - useFormField, - }; -}; diff --git a/static/app/views/settings/dynamicSampling/utils/projectSamplingForm.tsx b/static/app/views/settings/dynamicSampling/utils/projectSamplingForm.tsx deleted file mode 100644 index 46b4b8d2911f67..00000000000000 --- a/static/app/views/settings/dynamicSampling/utils/projectSamplingForm.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import {t} from 'sentry/locale'; -import {createForm} from 'sentry/views/settings/dynamicSampling/utils/formContext'; - -type FormFields = { - projectRates: Record; -}; - -type FormErrors = { - projectRates: Record; -}; - -export const projectSamplingForm = createForm({ - validators: { - projectRates: value => { - const errors: Record = {}; - - Object.entries(value).forEach(([projectId, rate]) => { - if (rate === '') { - errors[projectId] = t('This field is required'); - } - - const numericRate = Number(rate); - if (isNaN(numericRate)) { - errors[projectId] = t('Please enter a valid number'); - } - - if (numericRate < 0 || numericRate > 100) { - errors[projectId] = t('Must be between 0% and 100%'); - } - }); - - return Object.keys(errors).length === 0 ? undefined : errors; - }, - }, -}); From 6d9c263b9b7e8c60ed968bfc781e816f2f993a01 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Thu, 26 Mar 2026 09:51:39 +0100 Subject: [PATCH 02/18] fix(dynamic-sampling): Align projectSampling form with new scraps API Use AppForm form prop, SubmitButton, canSubmit state, and remove FormWrapper to match the pattern established in organizationSampling. Co-Authored-By: Claude Opus 4.6 --- .../dynamicSampling/projectSampling.tsx | 147 +++++++++--------- 1 file changed, 72 insertions(+), 75 deletions(-) diff --git a/static/app/views/settings/dynamicSampling/projectSampling.tsx b/static/app/views/settings/dynamicSampling/projectSampling.tsx index 5fe0eaaf3cb803..88fd74c34f47c1 100644 --- a/static/app/views/settings/dynamicSampling/projectSampling.tsx +++ b/static/app/views/settings/dynamicSampling/projectSampling.tsx @@ -115,81 +115,78 @@ export function ProjectSampling() { }, [sampleRatesQuery.data, sampleCountsQuery.data]); return ( - - - s.isDirty}> - {isDirty => ( - - - locationChange.currentLocation.pathname !== - locationChange.nextLocation.pathname && isDirty - } - /> - - - - - {sampleCountsQuery.isError ? ( - - ) : ( - - {field => { - const hasProjectRateErrors = - field.state.value && - Object.values(field.state.value).some(rate => { - if (!rate) return true; - const n = Number(rate); - return isNaN(n) || n < 0 || n > 100; - }); - return ( - - - - - } - /> - ); - }} - - )} - - - )} - - + + ({isDirty: s.isDirty, canSubmit: s.canSubmit})}> + {({isDirty, canSubmit}) => ( + + + locationChange.currentLocation.pathname !== + locationChange.nextLocation.pathname && isDirty + } + /> + + + + + {sampleCountsQuery.isError ? ( + + ) : ( + + {field => { + const hasProjectRateErrors = + field.state.value && + Object.values(field.state.value).some(rate => { + if (!rate) return true; + const n = Number(rate); + return isNaN(n) || n < 0 || n > 100; + }); + return ( + + + + {t('Apply Changes')} + + + } + /> + ); + }} + + )} + + + )} + ); } From dc8b6c05a1a0f135f742f2473c1c84a0bf51d85b Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Thu, 26 Mar 2026 10:15:37 +0100 Subject: [PATCH 03/18] ref(dynamic-sampling): Use shared Zod validation for project rates Extract sampleRateField from organizationSampling and reuse it in projectSamplingSchema via z.record(). This removes the duplicate manual validation (hasProjectRateErrors, getProjectRateErrors) and lets the form system handle submit-blocking through canSubmit. Co-Authored-By: Claude Opus 4.6 --- .../dynamicSampling/organizationSampling.tsx | 24 ++--- .../dynamicSampling/projectSampling.tsx | 87 ++++++++----------- 2 files changed, 48 insertions(+), 63 deletions(-) diff --git a/static/app/views/settings/dynamicSampling/organizationSampling.tsx b/static/app/views/settings/dynamicSampling/organizationSampling.tsx index 5ad9d9c05b6143..e2fefd23fd0df6 100644 --- a/static/app/views/settings/dynamicSampling/organizationSampling.tsx +++ b/static/app/views/settings/dynamicSampling/organizationSampling.tsx @@ -26,18 +26,20 @@ const UNSAVED_CHANGES_MESSAGE = t( 'You have unsaved changes, are you sure you want to leave?' ); +export const sampleRateField = z + .string() + .min(1, t('Please enter a valid number')) + .refine(val => !isNaN(Number(val)), {message: t('Please enter a valid number')}) + .refine( + val => { + const n = Number(val); + return n >= 0 && n <= 100; + }, + {message: t('Must be between 0% and 100%')} + ); + export const targetSampleRateSchema = z.object({ - targetSampleRate: z - .string() - .min(1, t('Please enter a valid number')) - .refine(val => !isNaN(Number(val)), {message: t('Please enter a valid number')}) - .refine( - val => { - const n = Number(val); - return n >= 0 && n <= 100; - }, - {message: t('Must be between 0% and 100%')} - ), + targetSampleRate: sampleRateField, }); export function OrganizationSampling() { diff --git a/static/app/views/settings/dynamicSampling/projectSampling.tsx b/static/app/views/settings/dynamicSampling/projectSampling.tsx index 88fd74c34f47c1..79ed164ee51bc6 100644 --- a/static/app/views/settings/dynamicSampling/projectSampling.tsx +++ b/static/app/views/settings/dynamicSampling/projectSampling.tsx @@ -14,6 +14,7 @@ import { import {LoadingError} from 'sentry/components/loadingError'; import {t} from 'sentry/locale'; import {OnRouteLeave} from 'sentry/utils/reactRouter6Compat/onRouteLeave'; +import {sampleRateField} from 'sentry/views/settings/dynamicSampling/organizationSampling'; import {ProjectionPeriodControl} from 'sentry/views/settings/dynamicSampling/projectionPeriodControl'; import {ProjectsEditTable} from 'sentry/views/settings/dynamicSampling/projectsEditTable'; import {SamplingModeSwitch} from 'sentry/views/settings/dynamicSampling/samplingModeSwitch'; @@ -33,10 +34,8 @@ const UNSAVED_CHANGES_MESSAGE = t( 'You have unsaved changes, are you sure you want to leave?' ); -// Zod schema for type correctness. Per-project validation errors are computed -// in projectsEditTable via getProjectRateErrors. -const schema = z.object({ - projectRates: z.record(z.string(), z.string()), +const projectSamplingSchema = z.object({ + projectRates: z.record(z.string(), sampleRateField), }); export function ProjectSampling() { @@ -68,7 +67,7 @@ export function ProjectSampling() { projectRates: {} as Record, }, validators: { - onDynamic: schema, + onDynamic: projectSamplingSchema, }, onSubmit: async ({value, formApi}) => { const ratesArray = Object.entries(value.projectRates).map(([id, rate]) => ({ @@ -134,53 +133,37 @@ export function ProjectSampling() { ) : ( - {field => { - const hasProjectRateErrors = - field.state.value && - Object.values(field.state.value).some(rate => { - if (!rate) return true; - const n = Number(rate); - return isNaN(n) || n < 0 || n > 100; - }); - return ( - - - - {t('Apply Changes')} - - - } - /> - ); - }} + {field => ( + + + + {t('Apply Changes')} + + + } + /> + )} )} From ad4592b1765a862e7b00e8767df80456f711fc99 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Thu, 26 Mar 2026 10:37:02 +0100 Subject: [PATCH 04/18] fix(dynamic-sampling): Initialize form with project rates Use projectRates as defaultValues instead of an empty object so fields show their current values immediately rather than rendering empty on first paint. Co-Authored-By: Claude Opus 4.6 --- .../app/views/settings/dynamicSampling/projectSampling.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/static/app/views/settings/dynamicSampling/projectSampling.tsx b/static/app/views/settings/dynamicSampling/projectSampling.tsx index 79ed164ee51bc6..a7973ddbbca5eb 100644 --- a/static/app/views/settings/dynamicSampling/projectSampling.tsx +++ b/static/app/views/settings/dynamicSampling/projectSampling.tsx @@ -59,12 +59,13 @@ export function ProjectSampling() { [sampleRatesQuery.data] ); - const [savedProjectRates, setSavedProjectRates] = useState>({}); + const [savedProjectRates, setSavedProjectRates] = + useState>(projectRates); const form = useScrapsForm({ ...defaultFormOptions, defaultValues: { - projectRates: {} as Record, + projectRates, }, validators: { onDynamic: projectSamplingSchema, From ab426cfb7e0d10c9e8b11631198f609e8a0be06d Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Thu, 26 Mar 2026 10:39:11 +0100 Subject: [PATCH 05/18] fix(dynamic-sampling): Add spacing between rate input and previous label Co-Authored-By: Claude Opus 4.6 --- static/app/views/settings/dynamicSampling/projectsTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/views/settings/dynamicSampling/projectsTable.tsx b/static/app/views/settings/dynamicSampling/projectsTable.tsx index 511fe9c37a8a97..a91aa31318d4b9 100644 --- a/static/app/views/settings/dynamicSampling/projectsTable.tsx +++ b/static/app/views/settings/dynamicSampling/projectsTable.tsx @@ -385,7 +385,7 @@ const TableRow = memo(function TableRow({ )} - + Date: Thu, 26 Mar 2026 10:49:17 +0100 Subject: [PATCH 06/18] ref(dynamic-sampling): Remove manual getProjectRateErrors Zod schema now handles per-value validation through the form system. Remove the redundant manual error computation that was running on every render regardless of submission state. Co-Authored-By: Claude Opus 4.6 --- .../dynamicSampling/projectsEditTable.tsx | 22 +------------------ 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/static/app/views/settings/dynamicSampling/projectsEditTable.tsx b/static/app/views/settings/dynamicSampling/projectsEditTable.tsx index 265a7e116d5cea..4523c759543bf4 100644 --- a/static/app/views/settings/dynamicSampling/projectsEditTable.tsx +++ b/static/app/views/settings/dynamicSampling/projectsEditTable.tsx @@ -37,20 +37,6 @@ interface Props { const EMPTY_ARRAY: any = []; -function getProjectRateErrors(rates: Record): Record { - const errors: Record = {}; - for (const [projectId, rate] of Object.entries(rates)) { - if (rate === '') { - errors[projectId] = t('This field is required'); - } else if (isNaN(Number(rate))) { - errors[projectId] = t('Please enter a valid number'); - } else if (Number(rate) < 0 || Number(rate) > 100) { - errors[projectId] = t('Must be between 0% and 100%'); - } - } - return errors; -} - export function ProjectsEditTable({ actions, isLoading: isLoadingProp, @@ -68,11 +54,6 @@ export function ProjectsEditTable({ const projectRateSnapshotRef = useRef>({}); - const projectRateErrors = useMemo( - () => getProjectRateErrors(projectRates), - [projectRates] - ); - const dataByProjectId = useMemo( () => mapArrayToObject({ @@ -153,10 +134,9 @@ export function ProjectsEditTable({ project, initialSampleRate: savedProjectRates[project.id]!, sampleRate: projectRates[project.id]!, - error: projectRateErrors[project.id], }; }), - [dataByProjectId, projectRateErrors, savedProjectRates, projects, projectRates] + [dataByProjectId, savedProjectRates, projects, projectRates] ); const totalSpanCount = useMemo( From a69e5cb0ff201cf64b3322b0a9e266b7c44bc4e5 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Thu, 26 Mar 2026 10:56:57 +0100 Subject: [PATCH 07/18] fix(dynamic-sampling): Show validation error on failed submit Pass the field error from the form to the table footer so users see feedback when Zod validation blocks submission. Co-Authored-By: Claude Opus 4.6 --- .../settings/dynamicSampling/projectSampling.tsx | 1 + .../settings/dynamicSampling/projectsEditTable.tsx | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/static/app/views/settings/dynamicSampling/projectSampling.tsx b/static/app/views/settings/dynamicSampling/projectSampling.tsx index a7973ddbbca5eb..cf303d38658848 100644 --- a/static/app/views/settings/dynamicSampling/projectSampling.tsx +++ b/static/app/views/settings/dynamicSampling/projectSampling.tsx @@ -144,6 +144,7 @@ export function ProjectSampling() { projectRates={field.state.value} savedProjectRates={savedProjectRates} onProjectRatesChange={field.handleChange} + fieldError={field.state.meta.errors[0]?.message} actions={ - - {t('Apply Changes')} - - - } - /> - )} - + + + + {t('Apply Changes')} + + + } + /> )} diff --git a/static/app/views/settings/dynamicSampling/projectsEditTable.tsx b/static/app/views/settings/dynamicSampling/projectsEditTable.tsx index 8a73a86861c4a8..254a32f1d09784 100644 --- a/static/app/views/settings/dynamicSampling/projectsEditTable.tsx +++ b/static/app/views/settings/dynamicSampling/projectsEditTable.tsx @@ -3,12 +3,14 @@ import {Fragment, useCallback, useMemo, useRef, useState} from 'react'; import {css} from '@emotion/react'; import styled from '@emotion/styled'; +import {useStore} from '@sentry/scraps/form'; + import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {Panel} from 'sentry/components/panels/panel'; import {t} from 'sentry/locale'; import {useProjects} from 'sentry/utils/useProjects'; import {OrganizationSampleRateInput} from 'sentry/views/settings/dynamicSampling/organizationSampleRateInput'; -import {sampleRateField} from 'sentry/views/settings/dynamicSampling/organizationSampling'; +import type {ProjectSamplingForm} from 'sentry/views/settings/dynamicSampling/projectSampling'; import {ProjectsTable} from 'sentry/views/settings/dynamicSampling/projectsTable'; import {SamplingBreakdown} from 'sentry/views/settings/dynamicSampling/samplingBreakdown'; import {mapArrayToObject} from 'sentry/views/settings/dynamicSampling/utils'; @@ -23,30 +25,16 @@ import type { interface Props { actions: React.ReactNode; editMode: 'single' | 'bulk'; + form: ProjectSamplingForm; isLoading: boolean; onEditModeChange: (mode: 'single' | 'bulk') => void; - onProjectRatesChange: ( - updater: - | Record - | ((prev: Record) => Record) - ) => void; period: ProjectionSamplePeriod; - projectRates: Record; sampleCounts: ProjectSampleCount[]; savedProjectRates: Record; - showErrors?: boolean; } const EMPTY_ARRAY: any = []; -function getProjectRateError(rate: string): string | undefined { - const result = sampleRateField.safeParse(rate); - if (result.success) { - return undefined; - } - return result.error.issues[0]?.message; -} - export function ProjectsEditTable({ actions, isLoading: isLoadingProp, @@ -54,10 +42,8 @@ export function ProjectsEditTable({ editMode, period, onEditModeChange, - projectRates, + form, savedProjectRates, - onProjectRatesChange, - showErrors, }: Props) { const {projects, fetching} = useProjects(); const [isBulkEditEnabled, setIsBulkEditEnabled] = useState(false); @@ -65,6 +51,8 @@ export function ProjectsEditTable({ const projectRateSnapshotRef = useRef>({}); + const projectRates = useStore(form.baseStore, s => s.values.projectRates); + const dataByProjectId = useMemo( () => mapArrayToObject({ @@ -77,13 +65,10 @@ export function ProjectsEditTable({ const handleProjectChange = useCallback( (projectId: string, newRate: string) => { - onProjectRatesChange(prev => ({ - ...prev, - [projectId]: newRate, - })); + form.setFieldValue(`projectRates.${projectId}`, newRate); onEditModeChange('single'); }, - [onProjectRatesChange, onEditModeChange] + [form, onEditModeChange] ); const handleOrgChange = useCallback( @@ -116,11 +101,13 @@ export function ProjectsEditTable({ valueSelector: item => formatPercent(item.sampleRate), }); - onProjectRatesChange(prev => ({...prev, ...newProjectValues})); + for (const [projectId, rate] of Object.entries(newProjectValues)) { + form.setFieldValue(`projectRates.${projectId}`, rate); + } setOrgRate(newRate); onEditModeChange('bulk'); }, - [dataByProjectId, editMode, onProjectRatesChange, onEditModeChange, projectRates] + [dataByProjectId, editMode, form, onEditModeChange, projectRates] ); const handleBulkEditChange = useCallback((newIsActive: boolean) => { @@ -145,12 +132,9 @@ export function ProjectsEditTable({ project, initialSampleRate: savedProjectRates[project.id]!, sampleRate: projectRates[project.id]!, - error: showErrors - ? getProjectRateError(projectRates[project.id] ?? '') - : undefined, }; }), - [dataByProjectId, showErrors, savedProjectRates, projects, projectRates] + [dataByProjectId, savedProjectRates, projects, projectRates] ); const totalSpanCount = useMemo( @@ -227,6 +211,7 @@ export function ProjectsEditTable({ period={period} isLoading={isLoading} items={items} + form={form} />
{actions}
diff --git a/static/app/views/settings/dynamicSampling/projectsTable.tsx b/static/app/views/settings/dynamicSampling/projectsTable.tsx index a91aa31318d4b9..3aa73a5e330608 100644 --- a/static/app/views/settings/dynamicSampling/projectsTable.tsx +++ b/static/app/views/settings/dynamicSampling/projectsTable.tsx @@ -19,6 +19,7 @@ import {formatAbbreviatedNumber} from 'sentry/utils/formatters'; import {oxfordizeArray} from 'sentry/utils/oxfordizeArray'; import {useOrganization} from 'sentry/utils/useOrganization'; import {PercentInput} from 'sentry/views/settings/dynamicSampling/percentInput'; +import type {ProjectSamplingForm} from 'sentry/views/settings/dynamicSampling/projectSampling'; import {useHasDynamicSamplingWriteAccess} from 'sentry/views/settings/dynamicSampling/utils/access'; import {parsePercent} from 'sentry/views/settings/dynamicSampling/utils/parsePercent'; import type { @@ -49,6 +50,7 @@ interface Props { period: ProjectionSamplePeriod; rateHeader: React.ReactNode; canEdit?: boolean; + form?: ProjectSamplingForm; inputTooltip?: string; onChange?: (projectId: string, value: string) => void; } @@ -65,6 +67,7 @@ export function ProjectsTable({ period, isLoading, emptyMessage, + form, }: Props) { const hasAccess = useHasDynamicSamplingWriteAccess(); const [tableSort, setTableSort] = useState<'asc' | 'desc'>('desc'); @@ -167,14 +170,31 @@ export function ProjectsTable({ transform: `translateY(${virtualRow.start}px)`, }} > - + {form ? ( + + {field => ( + + )} + + ) : ( + + )} ); })} From c4584875bea611c3dd989e5ce55491a2b9084878 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Thu, 26 Mar 2026 11:25:34 +0100 Subject: [PATCH 11/18] ref(dynamic-sampling): Remove form prop from ProjectsTable Move form awareness out of ProjectsTable into ProjectsEditTable. Read per-field errors from form.state.fieldMeta and pass them via the existing items array. ProjectsTable is now a pure display component again. Co-Authored-By: Claude Opus 4.6 --- .../dynamicSampling/projectsEditTable.tsx | 5 +-- .../dynamicSampling/projectsTable.tsx | 36 +++++-------------- 2 files changed, 11 insertions(+), 30 deletions(-) diff --git a/static/app/views/settings/dynamicSampling/projectsEditTable.tsx b/static/app/views/settings/dynamicSampling/projectsEditTable.tsx index 254a32f1d09784..2e6c0650728c85 100644 --- a/static/app/views/settings/dynamicSampling/projectsEditTable.tsx +++ b/static/app/views/settings/dynamicSampling/projectsEditTable.tsx @@ -52,6 +52,7 @@ export function ProjectsEditTable({ const projectRateSnapshotRef = useRef>({}); const projectRates = useStore(form.baseStore, s => s.values.projectRates); + const fieldMeta = form.state.fieldMeta; const dataByProjectId = useMemo( () => @@ -132,9 +133,10 @@ export function ProjectsEditTable({ project, initialSampleRate: savedProjectRates[project.id]!, sampleRate: projectRates[project.id]!, + error: fieldMeta[`projectRates.${project.id}`]?.errors?.[0]?.message, }; }), - [dataByProjectId, savedProjectRates, projects, projectRates] + [dataByProjectId, fieldMeta, savedProjectRates, projects, projectRates] ); const totalSpanCount = useMemo( @@ -211,7 +213,6 @@ export function ProjectsEditTable({ period={period} isLoading={isLoading} items={items} - form={form} />
{actions}
diff --git a/static/app/views/settings/dynamicSampling/projectsTable.tsx b/static/app/views/settings/dynamicSampling/projectsTable.tsx index 3aa73a5e330608..a91aa31318d4b9 100644 --- a/static/app/views/settings/dynamicSampling/projectsTable.tsx +++ b/static/app/views/settings/dynamicSampling/projectsTable.tsx @@ -19,7 +19,6 @@ import {formatAbbreviatedNumber} from 'sentry/utils/formatters'; import {oxfordizeArray} from 'sentry/utils/oxfordizeArray'; import {useOrganization} from 'sentry/utils/useOrganization'; import {PercentInput} from 'sentry/views/settings/dynamicSampling/percentInput'; -import type {ProjectSamplingForm} from 'sentry/views/settings/dynamicSampling/projectSampling'; import {useHasDynamicSamplingWriteAccess} from 'sentry/views/settings/dynamicSampling/utils/access'; import {parsePercent} from 'sentry/views/settings/dynamicSampling/utils/parsePercent'; import type { @@ -50,7 +49,6 @@ interface Props { period: ProjectionSamplePeriod; rateHeader: React.ReactNode; canEdit?: boolean; - form?: ProjectSamplingForm; inputTooltip?: string; onChange?: (projectId: string, value: string) => void; } @@ -67,7 +65,6 @@ export function ProjectsTable({ period, isLoading, emptyMessage, - form, }: Props) { const hasAccess = useHasDynamicSamplingWriteAccess(); const [tableSort, setTableSort] = useState<'asc' | 'desc'>('desc'); @@ -170,31 +167,14 @@ export function ProjectsTable({ transform: `translateY(${virtualRow.start}px)`, }} > - {form ? ( - - {field => ( - - )} - - ) : ( - - )} + ); })} From 48964e9d6cbdb3014d27c4a22ecb636581b57083 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Thu, 26 Mar 2026 11:35:07 +0100 Subject: [PATCH 12/18] ref(dynamic-sampling): Replace type hack with explicit interface Remove the _formTypeHelper function and define a clean ProjectSamplingForm interface in projectsEditTable with only the methods it actually uses from the form. Co-Authored-By: Claude Opus 4.6 --- .../settings/dynamicSampling/projectSampling.tsx | 13 ------------- .../dynamicSampling/projectsEditTable.tsx | 15 ++++++++++----- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/static/app/views/settings/dynamicSampling/projectSampling.tsx b/static/app/views/settings/dynamicSampling/projectSampling.tsx index 18e8e516ee2300..442e0faf75d182 100644 --- a/static/app/views/settings/dynamicSampling/projectSampling.tsx +++ b/static/app/views/settings/dynamicSampling/projectSampling.tsx @@ -38,19 +38,6 @@ const projectSamplingSchema = z.object({ projectRates: z.record(z.string(), sampleRateField), }); -// Helper to extract the form type from the component — avoids deeply generic TanStack types. -// Consumers should not construct this type directly; it's inferred from useScrapsForm. -function _formTypeHelper() { - // This function is never called — it exists only for type inference. - // eslint-disable-next-line react-hooks/rules-of-hooks - return useScrapsForm({ - ...defaultFormOptions, - defaultValues: {projectRates: {} as Record}, - validators: {onDynamic: projectSamplingSchema}, - }); -} -export type ProjectSamplingForm = ReturnType; - export function ProjectSampling() { const hasAccess = useHasDynamicSamplingWriteAccess(); const [period, setPeriod] = useState('24h'); diff --git a/static/app/views/settings/dynamicSampling/projectsEditTable.tsx b/static/app/views/settings/dynamicSampling/projectsEditTable.tsx index 2e6c0650728c85..232aaa54fc4c8c 100644 --- a/static/app/views/settings/dynamicSampling/projectsEditTable.tsx +++ b/static/app/views/settings/dynamicSampling/projectsEditTable.tsx @@ -3,14 +3,11 @@ import {Fragment, useCallback, useMemo, useRef, useState} from 'react'; import {css} from '@emotion/react'; import styled from '@emotion/styled'; -import {useStore} from '@sentry/scraps/form'; - import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {Panel} from 'sentry/components/panels/panel'; import {t} from 'sentry/locale'; import {useProjects} from 'sentry/utils/useProjects'; import {OrganizationSampleRateInput} from 'sentry/views/settings/dynamicSampling/organizationSampleRateInput'; -import type {ProjectSamplingForm} from 'sentry/views/settings/dynamicSampling/projectSampling'; import {ProjectsTable} from 'sentry/views/settings/dynamicSampling/projectsTable'; import {SamplingBreakdown} from 'sentry/views/settings/dynamicSampling/samplingBreakdown'; import {mapArrayToObject} from 'sentry/views/settings/dynamicSampling/utils'; @@ -22,6 +19,14 @@ import type { ProjectSampleCount, } from 'sentry/views/settings/dynamicSampling/utils/useProjectSampleCounts'; +interface ProjectSamplingForm { + setFieldValue: (name: `projectRates.${string}`, value: string) => void; + state: { + fieldMeta: Partial | undefined}>>; + values: {projectRates: Record}; + }; +} + interface Props { actions: React.ReactNode; editMode: 'single' | 'bulk'; @@ -51,8 +56,8 @@ export function ProjectsEditTable({ const projectRateSnapshotRef = useRef>({}); - const projectRates = useStore(form.baseStore, s => s.values.projectRates); - const fieldMeta = form.state.fieldMeta; + const {projectRates} = form.state.values; + const {fieldMeta} = form.state; const dataByProjectId = useMemo( () => From 9e6c294cca96d1ee2f598b5bd5da697cfceaa0d8 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Thu, 26 Mar 2026 12:00:10 +0100 Subject: [PATCH 13/18] ref(dynamic-sampling): Decouple ProjectsEditTable from form system Replace the form prop with plain data props (projectRates, projectErrors, onProjectRateChange) so the edit table is a pure presentational component with no form system imports or any types. Co-Authored-By: Claude Opus 4.6 --- .../dynamicSampling/projectSampling.tsx | 119 +++++++++++------- .../dynamicSampling/projectsEditTable.tsx | 33 ++--- 2 files changed, 87 insertions(+), 65 deletions(-) diff --git a/static/app/views/settings/dynamicSampling/projectSampling.tsx b/static/app/views/settings/dynamicSampling/projectSampling.tsx index 442e0faf75d182..7562ef3a5b2e41 100644 --- a/static/app/views/settings/dynamicSampling/projectSampling.tsx +++ b/static/app/views/settings/dynamicSampling/projectSampling.tsx @@ -1,4 +1,4 @@ -import {Fragment, useEffect, useMemo, useState} from 'react'; +import {Fragment, useCallback, useEffect, useMemo, useState} from 'react'; import styled from '@emotion/styled'; import {z} from 'zod'; @@ -88,6 +88,13 @@ export function ProjectSampling() { }, }); + const handleProjectRateChange = useCallback( + (projectId: string, rate: string) => { + form.setFieldValue(`projectRates.${projectId}`, rate); + }, + [form] + ); + // Mirror enableReInitialize: reset the form whenever the server data changes useEffect(() => { form.reset({projectRates}); @@ -116,52 +123,74 @@ export function ProjectSampling() { return ( - ({isDirty: s.isDirty, canSubmit: s.canSubmit})}> - {({isDirty, canSubmit}) => ( - - - locationChange.currentLocation.pathname !== - locationChange.nextLocation.pathname && isDirty - } - /> - - - - - {sampleCountsQuery.isError ? ( - - ) : ( - - - - {t('Apply Changes')} - - + ({ + isDirty: s.isDirty, + canSubmit: s.canSubmit, + currentProjectRates: s.values.projectRates, + fieldMeta: s.fieldMeta, + })} + > + {({isDirty, canSubmit, currentProjectRates, fieldMeta}) => { + const projectErrors: Record = {}; + for (const id of Object.keys(currentProjectRates)) { + const error = fieldMeta[`projectRates.${id}`]?.errors?.[0]?.message; + if (error) { + projectErrors[id] = error; + } + } + + return ( + + + locationChange.currentLocation.pathname !== + locationChange.nextLocation.pathname && isDirty } /> - )} - - - )} + + + + + {sampleCountsQuery.isError ? ( + + ) : ( + + + + {t('Apply Changes')} + + + } + /> + )} + + + ); + }} ); diff --git a/static/app/views/settings/dynamicSampling/projectsEditTable.tsx b/static/app/views/settings/dynamicSampling/projectsEditTable.tsx index 232aaa54fc4c8c..41ca0aea4a1e11 100644 --- a/static/app/views/settings/dynamicSampling/projectsEditTable.tsx +++ b/static/app/views/settings/dynamicSampling/projectsEditTable.tsx @@ -19,26 +19,20 @@ import type { ProjectSampleCount, } from 'sentry/views/settings/dynamicSampling/utils/useProjectSampleCounts'; -interface ProjectSamplingForm { - setFieldValue: (name: `projectRates.${string}`, value: string) => void; - state: { - fieldMeta: Partial | undefined}>>; - values: {projectRates: Record}; - }; -} - interface Props { actions: React.ReactNode; editMode: 'single' | 'bulk'; - form: ProjectSamplingForm; isLoading: boolean; onEditModeChange: (mode: 'single' | 'bulk') => void; + onProjectRateChange: (projectId: string, rate: string) => void; period: ProjectionSamplePeriod; + projectErrors: Record; + projectRates: Record; sampleCounts: ProjectSampleCount[]; savedProjectRates: Record; } -const EMPTY_ARRAY: any = []; +const EMPTY_ARRAY: never[] = []; export function ProjectsEditTable({ actions, @@ -47,7 +41,9 @@ export function ProjectsEditTable({ editMode, period, onEditModeChange, - form, + onProjectRateChange, + projectRates, + projectErrors, savedProjectRates, }: Props) { const {projects, fetching} = useProjects(); @@ -56,9 +52,6 @@ export function ProjectsEditTable({ const projectRateSnapshotRef = useRef>({}); - const {projectRates} = form.state.values; - const {fieldMeta} = form.state; - const dataByProjectId = useMemo( () => mapArrayToObject({ @@ -71,10 +64,10 @@ export function ProjectsEditTable({ const handleProjectChange = useCallback( (projectId: string, newRate: string) => { - form.setFieldValue(`projectRates.${projectId}`, newRate); + onProjectRateChange(projectId, newRate); onEditModeChange('single'); }, - [form, onEditModeChange] + [onProjectRateChange, onEditModeChange] ); const handleOrgChange = useCallback( @@ -108,12 +101,12 @@ export function ProjectsEditTable({ }); for (const [projectId, rate] of Object.entries(newProjectValues)) { - form.setFieldValue(`projectRates.${projectId}`, rate); + onProjectRateChange(projectId, rate); } setOrgRate(newRate); onEditModeChange('bulk'); }, - [dataByProjectId, editMode, form, onEditModeChange, projectRates] + [dataByProjectId, editMode, onProjectRateChange, onEditModeChange, projectRates] ); const handleBulkEditChange = useCallback((newIsActive: boolean) => { @@ -138,10 +131,10 @@ export function ProjectsEditTable({ project, initialSampleRate: savedProjectRates[project.id]!, sampleRate: projectRates[project.id]!, - error: fieldMeta[`projectRates.${project.id}`]?.errors?.[0]?.message, + error: projectErrors[project.id], }; }), - [dataByProjectId, fieldMeta, savedProjectRates, projects, projectRates] + [dataByProjectId, projectErrors, savedProjectRates, projects, projectRates] ); const totalSpanCount = useMemo( From 0865dc64d3f12fc9f5c5de20247003fb86f964f8 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Thu, 26 Mar 2026 12:08:59 +0100 Subject: [PATCH 14/18] ref(dynamic-sampling): Remove canSubmit from submit buttons The submit button should always be clickable so users can trigger validation and see errors. Only disable based on access permissions. Co-Authored-By: Claude Opus 4.6 --- .../settings/dynamicSampling/organizationSampling.tsx | 9 +++------ .../views/settings/dynamicSampling/projectSampling.tsx | 8 ++------ 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/static/app/views/settings/dynamicSampling/organizationSampling.tsx b/static/app/views/settings/dynamicSampling/organizationSampling.tsx index e2fefd23fd0df6..162ce9a206f22c 100644 --- a/static/app/views/settings/dynamicSampling/organizationSampling.tsx +++ b/static/app/views/settings/dynamicSampling/organizationSampling.tsx @@ -82,8 +82,8 @@ export function OrganizationSampling() { return ( - ({isDirty: s.isDirty, canSubmit: s.canSubmit})}> - {({isDirty, canSubmit}) => ( + ({isDirty: s.isDirty})}> + {({isDirty}) => ( - + {t('Apply Changes')}
diff --git a/static/app/views/settings/dynamicSampling/projectSampling.tsx b/static/app/views/settings/dynamicSampling/projectSampling.tsx index 7562ef3a5b2e41..d686b64213e83b 100644 --- a/static/app/views/settings/dynamicSampling/projectSampling.tsx +++ b/static/app/views/settings/dynamicSampling/projectSampling.tsx @@ -126,12 +126,11 @@ export function ProjectSampling() { ({ isDirty: s.isDirty, - canSubmit: s.canSubmit, currentProjectRates: s.values.projectRates, fieldMeta: s.fieldMeta, })} > - {({isDirty, canSubmit, currentProjectRates, fieldMeta}) => { + {({isDirty, currentProjectRates, fieldMeta}) => { const projectErrors: Record = {}; for (const id of Object.keys(currentProjectRates)) { const error = fieldMeta[`projectRates.${id}`]?.errors?.[0]?.message; @@ -177,10 +176,7 @@ export function ProjectSampling() { > {t('Reset')} - + {t('Apply Changes')} From a4381d4aec223a2aaf26f8b45da2218ec8a9f356 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Thu, 26 Mar 2026 12:16:08 +0100 Subject: [PATCH 15/18] test(dynamic-sampling): Add tests for ProjectSampling Cover initial render, edit/reset flow, validation on submit, API payload, post-save state, error handling, and access control. Co-Authored-By: Claude Opus 4.6 --- .../dynamicSampling/projectSampling.spec.tsx | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 static/app/views/settings/dynamicSampling/projectSampling.spec.tsx diff --git a/static/app/views/settings/dynamicSampling/projectSampling.spec.tsx b/static/app/views/settings/dynamicSampling/projectSampling.spec.tsx new file mode 100644 index 00000000000000..e61eab933e368c --- /dev/null +++ b/static/app/views/settings/dynamicSampling/projectSampling.spec.tsx @@ -0,0 +1,191 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; + +import {act, render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; + +import {ProjectsStore} from 'sentry/stores/projectsStore'; + +import {ProjectSampling} from './projectSampling'; + +jest.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: jest.fn(({count}: {count: number}) => ({ + getVirtualItems: jest.fn(() => + Array.from({length: count}, (_, index) => ({ + key: index, + index, + start: index * 63, + size: 63, + })) + ), + getTotalSize: jest.fn(() => count * 63), + measure: jest.fn(), + })), +})); + +describe('ProjectSampling', () => { + const project = ProjectFixture({id: '1', slug: 'project-slug'}); + const organization = OrganizationFixture({ + slug: 'org-slug', + access: ['org:write'], + samplingMode: 'project', + }); + + beforeEach(() => { + MockApiClient.clearMockResponses(); + act(() => ProjectsStore.loadInitialData([project])); + + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/sampling/project-root-counts/', + body: { + data: [ + [ + { + by: {project: 'project-slug', target_project_id: '1'}, + totals: 1000, + series: [], + }, + ], + ], + end: '', + intervals: [], + start: '', + }, + }); + + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/sampling/project-rates/', + body: [{id: 1, sampleRate: 0.5}], + }); + }); + + function getProjectRateInput() { + // The first spinbutton is the org rate, the second is the project rate + const inputs = screen.getAllByRole('spinbutton'); + return inputs[inputs.length - 1]!; + } + + async function waitForProjectRateInput() { + // Wait for the project table to render — the ProjectBadge component + // renders a link with this accessible name + await screen.findByRole('link', {name: 'View Project Details'}); + return getProjectRateInput(); + } + + it('renders project rate inputs with initial values', async () => { + // The input briefly transitions from uncontrolled to controlled as form + // state initializes with the fetched project rates. + jest.spyOn(console, 'error').mockImplementation(); + + render(, {organization}); + + const input = await waitForProjectRateInput(); + expect(input).toHaveValue(50); + }); + + it('enables Reset button after changing a project rate', async () => { + render(, {organization}); + + const input = await waitForProjectRateInput(); + expect(screen.getByRole('button', {name: 'Reset'})).toBeDisabled(); + + await userEvent.clear(input); + await userEvent.type(input, '30'); + + expect(screen.getByRole('button', {name: 'Reset'})).toBeEnabled(); + }); + + it('resets the input back to the saved value when Reset is clicked', async () => { + render(, {organization}); + + const input = await waitForProjectRateInput(); + await userEvent.clear(input); + await userEvent.type(input, '30'); + + await userEvent.click(screen.getByRole('button', {name: 'Reset'})); + + expect(input).toHaveValue(50); + }); + + it('shows validation error for empty value on submit', async () => { + render(, {organization}); + + const input = await waitForProjectRateInput(); + await userEvent.clear(input); + await userEvent.click(screen.getByRole('button', {name: 'Apply Changes'})); + + expect(await screen.findByText('Please enter a valid number')).toBeInTheDocument(); + }); + + it('calls the API with the correct payload on save', async () => { + const putMock = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/sampling/project-rates/', + method: 'PUT', + body: [{id: 1, sampleRate: 0.3}], + }); + + render(, {organization}); + + const input = await waitForProjectRateInput(); + await userEvent.clear(input); + await userEvent.type(input, '30'); + await userEvent.click(screen.getByRole('button', {name: 'Apply Changes'})); + + await waitFor(() => { + expect(putMock).toHaveBeenCalledWith( + '/organizations/org-slug/sampling/project-rates/', + expect.objectContaining({data: [{id: 1, sampleRate: 0.3}]}) + ); + }); + }); + + it('resets form to clean state after a successful save', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/sampling/project-rates/', + method: 'PUT', + body: [{id: 1, sampleRate: 0.3}], + }); + + render(, {organization}); + + const input = await waitForProjectRateInput(); + await userEvent.clear(input); + await userEvent.type(input, '30'); + await userEvent.click(screen.getByRole('button', {name: 'Apply Changes'})); + + await waitFor(() => + expect(screen.getByRole('button', {name: 'Reset'})).toBeDisabled() + ); + }); + + it('keeps form dirty after an API error', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/sampling/project-rates/', + method: 'PUT', + statusCode: 500, + body: {detail: 'Internal Server Error'}, + }); + + render(, {organization}); + + const input = await waitForProjectRateInput(); + await userEvent.clear(input); + await userEvent.type(input, '30'); + await userEvent.click(screen.getByRole('button', {name: 'Apply Changes'})); + + await waitFor(() => + expect(screen.getByRole('button', {name: 'Reset'})).toBeEnabled() + ); + }); + + it('disables Apply Changes for users without org:write access', async () => { + const orgWithoutAccess = OrganizationFixture({ + access: [], + samplingMode: 'project', + }); + + render(, {organization: orgWithoutAccess}); + + await waitForProjectRateInput(); + expect(screen.getByRole('button', {name: 'Apply Changes'})).toBeDisabled(); + }); +}); From d3206e78ec8cecb1b5bfdb864d829d3c4d07f1ed Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Fri, 27 Mar 2026 09:13:58 +0100 Subject: [PATCH 16/18] ref(dynamic-sampling): Add aria-label to project rate input Add an accessible label to each project's sample rate input so screen readers can identify which project the input belongs to. Update the test helper to query by this label instead of relying on positional spinbutton indexing. Co-Authored-By: Claude Opus 4.6 --- .../dynamicSampling/projectSampling.spec.tsx | 13 +++---------- .../settings/dynamicSampling/projectsTable.tsx | 1 + 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/static/app/views/settings/dynamicSampling/projectSampling.spec.tsx b/static/app/views/settings/dynamicSampling/projectSampling.spec.tsx index e61eab933e368c..f796672f0e6f36 100644 --- a/static/app/views/settings/dynamicSampling/projectSampling.spec.tsx +++ b/static/app/views/settings/dynamicSampling/projectSampling.spec.tsx @@ -58,17 +58,10 @@ describe('ProjectSampling', () => { }); }); - function getProjectRateInput() { - // The first spinbutton is the org rate, the second is the project rate - const inputs = screen.getAllByRole('spinbutton'); - return inputs[inputs.length - 1]!; - } - async function waitForProjectRateInput() { - // Wait for the project table to render — the ProjectBadge component - // renders a link with this accessible name - await screen.findByRole('link', {name: 'View Project Details'}); - return getProjectRateInput(); + return screen.findByRole('spinbutton', { + name: 'Sample rate for project-slug', + }); } it('renders project rate inputs with initial values', async () => { diff --git a/static/app/views/settings/dynamicSampling/projectsTable.tsx b/static/app/views/settings/dynamicSampling/projectsTable.tsx index a91aa31318d4b9..b1af658e978f82 100644 --- a/static/app/views/settings/dynamicSampling/projectsTable.tsx +++ b/static/app/views/settings/dynamicSampling/projectsTable.tsx @@ -394,6 +394,7 @@ const TableRow = memo(function TableRow({ onChange={handleChange} size="sm" value={sampleRate} + aria-label={t('Sample rate for %s', project.slug)} />
From bdf02c672b7f9b3c2f2b4bda0abcc66dcdeb7e74 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Fri, 27 Mar 2026 09:40:54 +0100 Subject: [PATCH 17/18] perf(dynamic-sampling): Batch bulk org rate updates The bulk org rate edit was calling setFieldValue in a loop for each project, triggering N separate validations. Use a single atomic update for all project rates instead, matching the old behavior. Co-Authored-By: Claude Opus 4.6 --- .../dynamicSampling/projectSampling.spec.tsx | 38 +++++++++++++++++++ .../dynamicSampling/projectSampling.tsx | 8 ++++ .../dynamicSampling/projectsEditTable.tsx | 8 ++-- 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/static/app/views/settings/dynamicSampling/projectSampling.spec.tsx b/static/app/views/settings/dynamicSampling/projectSampling.spec.tsx index f796672f0e6f36..a4cb90fab07e88 100644 --- a/static/app/views/settings/dynamicSampling/projectSampling.spec.tsx +++ b/static/app/views/settings/dynamicSampling/projectSampling.spec.tsx @@ -170,6 +170,44 @@ describe('ProjectSampling', () => { ); }); + it('updates project rates atomically via bulk org rate edit', async () => { + const putMock = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/sampling/project-rates/', + method: 'PUT', + body: [{id: 1, sampleRate: 0.8}], + }); + + render(, {organization}); + + await waitForProjectRateInput(); + + // Activate bulk edit mode + await userEvent.click( + screen.getByRole('button', {name: 'Proportionally scale project rates'}) + ); + + // Type a new org rate — this should update all project rates in one atomic call + const orgRateInput = screen.getAllByRole('spinbutton')[0]!; + await userEvent.clear(orgRateInput); + await userEvent.type(orgRateInput, '80'); + + // The project rate should have been scaled + const projectInput = screen.getByRole('spinbutton', { + name: 'Sample rate for project-slug', + }); + expect(projectInput).toHaveValue(80); + + // Submit and verify the API call + await userEvent.click(screen.getByRole('button', {name: 'Apply Changes'})); + + await waitFor(() => { + expect(putMock).toHaveBeenCalledWith( + '/organizations/org-slug/sampling/project-rates/', + expect.objectContaining({data: [{id: 1, sampleRate: 0.8}]}) + ); + }); + }); + it('disables Apply Changes for users without org:write access', async () => { const orgWithoutAccess = OrganizationFixture({ access: [], diff --git a/static/app/views/settings/dynamicSampling/projectSampling.tsx b/static/app/views/settings/dynamicSampling/projectSampling.tsx index d686b64213e83b..e360db91c6f413 100644 --- a/static/app/views/settings/dynamicSampling/projectSampling.tsx +++ b/static/app/views/settings/dynamicSampling/projectSampling.tsx @@ -95,6 +95,13 @@ export function ProjectSampling() { [form] ); + const handleBulkProjectRateChange = useCallback( + (updates: Record) => { + form.setFieldValue('projectRates', prev => ({...prev, ...updates})); + }, + [form] + ); + // Mirror enableReInitialize: reset the form whenever the server data changes useEffect(() => { form.reset({projectRates}); @@ -160,6 +167,7 @@ export function ProjectSampling() { editMode={editMode} onEditModeChange={setEditMode} onProjectRateChange={handleProjectRateChange} + onBulkProjectRateChange={handleBulkProjectRateChange} projectRates={currentProjectRates} projectErrors={projectErrors} isLoading={sampleRatesQuery.isPending || sampleCountsQuery.isPending} diff --git a/static/app/views/settings/dynamicSampling/projectsEditTable.tsx b/static/app/views/settings/dynamicSampling/projectsEditTable.tsx index 41ca0aea4a1e11..613b14a2884069 100644 --- a/static/app/views/settings/dynamicSampling/projectsEditTable.tsx +++ b/static/app/views/settings/dynamicSampling/projectsEditTable.tsx @@ -23,6 +23,7 @@ interface Props { actions: React.ReactNode; editMode: 'single' | 'bulk'; isLoading: boolean; + onBulkProjectRateChange: (updates: Record) => void; onEditModeChange: (mode: 'single' | 'bulk') => void; onProjectRateChange: (projectId: string, rate: string) => void; period: ProjectionSamplePeriod; @@ -41,6 +42,7 @@ export function ProjectsEditTable({ editMode, period, onEditModeChange, + onBulkProjectRateChange, onProjectRateChange, projectRates, projectErrors, @@ -100,13 +102,11 @@ export function ProjectsEditTable({ valueSelector: item => formatPercent(item.sampleRate), }); - for (const [projectId, rate] of Object.entries(newProjectValues)) { - onProjectRateChange(projectId, rate); - } + onBulkProjectRateChange(newProjectValues); setOrgRate(newRate); onEditModeChange('bulk'); }, - [dataByProjectId, editMode, onProjectRateChange, onEditModeChange, projectRates] + [dataByProjectId, editMode, onBulkProjectRateChange, onEditModeChange, projectRates] ); const handleBulkEditChange = useCallback((newIsActive: boolean) => { From 87b64b9cd5291b4cd7bafbafe454a07cfff4204c Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 30 Mar 2026 12:28:52 +0200 Subject: [PATCH 18/18] fix(dynamic-sampling): Show 0% instead of blank estimated org rate When there are no span counts for the selected period, the division by zero produces NaN which renders as a blank number input. Default to 0% when totalSpanCount is zero. Co-Authored-By: Claude Opus 4.6 --- .../views/settings/dynamicSampling/projectsEditTable.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/static/app/views/settings/dynamicSampling/projectsEditTable.tsx b/static/app/views/settings/dynamicSampling/projectsEditTable.tsx index 613b14a2884069..2812017093cb1b 100644 --- a/static/app/views/settings/dynamicSampling/projectsEditTable.tsx +++ b/static/app/views/settings/dynamicSampling/projectsEditTable.tsx @@ -152,6 +152,9 @@ export function ProjectsEditTable({ (acc, item) => acc + item.count * parsePercent(projectRates[item.project.id], 1), 0 ); + if (totalSpanCount === 0) { + return formatPercent(0); + } return formatPercent(totalSampledSpans / totalSpanCount); }, [editMode, items, orgRate, totalSpanCount, projectRates]); @@ -161,6 +164,9 @@ export function ProjectsEditTable({ acc + item.count * parsePercent(savedProjectRates[item.project.id], 1), 0 ); + if (totalSpanCount === 0) { + return formatPercent(0); + } return formatPercent(totalSampledSpans / totalSpanCount); }, [savedProjectRates, items, totalSpanCount]);