Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f92ccef
ref(dynamic-sampling): Migrate projectSampling to new TanStack form s…
JonasBa Feb 25, 2026
6d9c263
fix(dynamic-sampling): Align projectSampling form with new scraps API
priscilawebdev Mar 26, 2026
dc8b6c0
ref(dynamic-sampling): Use shared Zod validation for project rates
priscilawebdev Mar 26, 2026
ad4592b
fix(dynamic-sampling): Initialize form with project rates
priscilawebdev Mar 26, 2026
ab426cf
fix(dynamic-sampling): Add spacing between rate input and previous label
priscilawebdev Mar 26, 2026
284d171
ref(dynamic-sampling): Remove manual getProjectRateErrors
priscilawebdev Mar 26, 2026
a69e5cb
fix(dynamic-sampling): Show validation error on failed submit
priscilawebdev Mar 26, 2026
8efcdc5
fix(dynamic-sampling): Show per-row errors after failed submit
priscilawebdev Mar 26, 2026
122f41e
ref(dynamic-sampling): Reuse sampleRateField for per-row validation
priscilawebdev Mar 26, 2026
369a758
ref(dynamic-sampling): Use individual AppField per project rate
priscilawebdev Mar 26, 2026
c458487
ref(dynamic-sampling): Remove form prop from ProjectsTable
priscilawebdev Mar 26, 2026
48964e9
ref(dynamic-sampling): Replace type hack with explicit interface
priscilawebdev Mar 26, 2026
9e6c294
ref(dynamic-sampling): Decouple ProjectsEditTable from form system
priscilawebdev Mar 26, 2026
0865dc6
ref(dynamic-sampling): Remove canSubmit from submit buttons
priscilawebdev Mar 26, 2026
a4381d4
test(dynamic-sampling): Add tests for ProjectSampling
priscilawebdev Mar 26, 2026
88802e9
Merge branch 'master' into jb/forms/dynamic-sampling-project-sampling
priscilawebdev Mar 27, 2026
d3206e7
ref(dynamic-sampling): Add aria-label to project rate input
priscilawebdev Mar 27, 2026
bdf02c6
perf(dynamic-sampling): Batch bulk org rate updates
priscilawebdev Mar 27, 2026
87b64b9
fix(dynamic-sampling): Show 0% instead of blank estimated org rate
priscilawebdev Mar 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -80,8 +82,8 @@ export function OrganizationSampling() {

return (
<form.AppForm form={form}>
<form.Subscribe selector={s => ({isDirty: s.isDirty, canSubmit: s.canSubmit})}>
{({isDirty, canSubmit}) => (
<form.Subscribe selector={s => ({isDirty: s.isDirty})}>
{({isDirty}) => (
<Fragment>
<OnRouteLeave
message={UNSAVED_CHANGES_MESSAGE}
Expand Down Expand Up @@ -121,10 +123,7 @@ export function OrganizationSampling() {
'You do not have permission to update these settings.'
)}
>
<form.SubmitButton
disabled={!hasAccess || !canSubmit}
formNoValidate
>
<form.SubmitButton disabled={!hasAccess} formNoValidate>
{t('Apply Changes')}
</form.SubmitButton>
</Tooltip>
Expand Down
182 changes: 110 additions & 72 deletions static/app/views/settings/dynamicSampling/projectSampling.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {Fragment, useMemo, useState} from 'react';
import {Fragment, useCallback, 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 {
Expand All @@ -12,13 +14,13 @@ 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';
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,
Expand All @@ -28,11 +30,14 @@ 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?'
);

const projectSamplingSchema = z.object({
projectRates: z.record(z.string(), sampleRateField),
});

export function ProjectSampling() {
const hasAccess = useHasDynamicSamplingWriteAccess();
const [period, setPeriod] = useState<ProjectionSamplePeriod>('24h');
Expand All @@ -54,37 +59,48 @@ 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<Record<string, string>>(projectRates);

const form = useScrapsForm({
...defaultFormOptions,
defaultValues: {
projectRates,
},
validators: {
onDynamic: projectSamplingSchema,
},
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.'));
},
});
};
}
},
});

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});
setSavedProjectRates(projectRates);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectRates]);
Comment thread
sentry[bot] marked this conversation as resolved.

const initialTargetRate = useMemo(() => {
const sampleRates = sampleRatesQuery.data ?? [];
Expand All @@ -105,52 +121,74 @@ export function ProjectSampling() {
);
}, [sampleRatesQuery.data, sampleCountsQuery.data]);

const isFormActionDisabled =
!hasAccess ||
sampleRatesQuery.isPending ||
updateSamplingProjectRates.isPending ||
!formState.hasChanged;

return (
<FormProvider formState={formState}>
<OnRouteLeave
message={UNSAVED_CHANGES_MESSAGE}
when={locationChange =>
locationChange.currentLocation.pathname !==
locationChange.nextLocation.pathname && formState.hasChanged
}
/>
<Flex justify="between" marginBottom="lg">
<ProjectionPeriodControl period={period} onChange={setPeriod} />
<SamplingModeSwitch initialTargetRate={initialTargetRate} />
</Flex>
{sampleCountsQuery.isError ? (
<LoadingError onRetry={sampleCountsQuery.refetch} />
) : (
<ProjectsEditTable
period={period}
editMode={editMode}
onEditModeChange={setEditMode}
isLoading={sampleRatesQuery.isPending || sampleCountsQuery.isPending}
sampleCounts={sampleCountsQuery.data}
actions={
<form.AppForm form={form}>
<form.Subscribe
selector={s => ({
isDirty: s.isDirty,
currentProjectRates: s.values.projectRates,
fieldMeta: s.fieldMeta,
})}
>
{({isDirty, currentProjectRates, fieldMeta}) => {
const projectErrors: Record<string, string | undefined> = {};
for (const id of Object.keys(currentProjectRates)) {
const error = fieldMeta[`projectRates.${id}`]?.errors?.[0]?.message;
if (error) {
projectErrors[id] = error;
}
}

return (
<Fragment>
<Button disabled={isFormActionDisabled} onClick={handleReset}>
{t('Reset')}
</Button>
<Button
priority="primary"
disabled={isFormActionDisabled || !formState.isValid}
onClick={handleSubmit}
>
{t('Apply Changes')}
</Button>
<OnRouteLeave
message={UNSAVED_CHANGES_MESSAGE}
when={locationChange =>
locationChange.currentLocation.pathname !==
locationChange.nextLocation.pathname && isDirty
}
/>
<Flex justify="between" marginBottom="lg">
<ProjectionPeriodControl period={period} onChange={setPeriod} />
<SamplingModeSwitch initialTargetRate={initialTargetRate} />
</Flex>
{sampleCountsQuery.isError ? (
<LoadingError onRetry={sampleCountsQuery.refetch} />
) : (
<ProjectsEditTable
period={period}
editMode={editMode}
onEditModeChange={setEditMode}
onProjectRateChange={handleProjectRateChange}
projectRates={currentProjectRates}
projectErrors={projectErrors}
isLoading={sampleRatesQuery.isPending || sampleCountsQuery.isPending}
Comment thread
sentry[bot] marked this conversation as resolved.
sampleCounts={sampleCountsQuery.data}
savedProjectRates={savedProjectRates}
actions={
<Fragment>
<Button
disabled={!isDirty || updateSamplingProjectRates.isPending}
onClick={() => {
form.reset();
setEditMode('single');
}}
>
{t('Reset')}
</Button>
<form.SubmitButton disabled={!hasAccess} formNoValidate>
{t('Apply Changes')}
</form.SubmitButton>
</Fragment>
}
/>
)}
<FormActions />
</Fragment>
}
/>
)}
<FormActions />
</FormProvider>
);
}}
</form.Subscribe>
</form.AppForm>
);
}

Expand Down
Loading
Loading