Skip to content

Commit f92ccef

Browse files
JonasBaclaude
authored andcommitted
ref(dynamic-sampling): Migrate projectSampling to new TanStack form system
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 <noreply@anthropic.com>
1 parent b59560c commit f92ccef

File tree

4 files changed

+162
-369
lines changed

4 files changed

+162
-369
lines changed

static/app/views/settings/dynamicSampling/projectSampling.tsx

Lines changed: 112 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import {Fragment, useMemo, useState} from 'react';
1+
import {Fragment, useEffect, useMemo, useState} from 'react';
22
import styled from '@emotion/styled';
3+
import {z} from 'zod';
34

45
import {Button} from '@sentry/scraps/button';
6+
import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form';
57
import {Flex} from '@sentry/scraps/layout';
68

79
import {
@@ -18,7 +20,6 @@ import {SamplingModeSwitch} from 'sentry/views/settings/dynamicSampling/sampling
1820
import {mapArrayToObject} from 'sentry/views/settings/dynamicSampling/utils';
1921
import {useHasDynamicSamplingWriteAccess} from 'sentry/views/settings/dynamicSampling/utils/access';
2022
import {parsePercent} from 'sentry/views/settings/dynamicSampling/utils/parsePercent';
21-
import {projectSamplingForm} from 'sentry/views/settings/dynamicSampling/utils/projectSamplingForm';
2223
import {
2324
useProjectSampleCounts,
2425
type ProjectionSamplePeriod,
@@ -28,11 +29,16 @@ import {
2829
useUpdateSamplingProjectRates,
2930
} from 'sentry/views/settings/dynamicSampling/utils/useSamplingProjectRates';
3031

31-
const {useFormState, FormProvider} = projectSamplingForm;
3232
const UNSAVED_CHANGES_MESSAGE = t(
3333
'You have unsaved changes, are you sure you want to leave?'
3434
);
3535

36+
// Zod schema for type correctness. Per-project validation errors are computed
37+
// in projectsEditTable via getProjectRateErrors.
38+
const schema = z.object({
39+
projectRates: z.record(z.string(), z.string()),
40+
});
41+
3642
export function ProjectSampling() {
3743
const hasAccess = useHasDynamicSamplingWriteAccess();
3844
const [period, setPeriod] = useState<ProjectionSamplePeriod>('24h');
@@ -54,37 +60,40 @@ export function ProjectSampling() {
5460
[sampleRatesQuery.data]
5561
);
5662

57-
const initialValues = useMemo(() => ({projectRates}), [projectRates]);
58-
59-
const formState = useFormState({
60-
initialValues,
61-
enableReInitialize: true,
62-
});
63-
64-
const handleReset = () => {
65-
formState.reset();
66-
setEditMode('single');
67-
};
68-
69-
const handleSubmit = () => {
70-
const ratesArray = Object.entries(formState.fields.projectRates.value).map(
71-
([id, rate]) => ({
63+
const [savedProjectRates, setSavedProjectRates] = useState<Record<string, string>>({});
64+
65+
const form = useScrapsForm({
66+
...defaultFormOptions,
67+
defaultValues: {
68+
projectRates: {} as Record<string, string>,
69+
},
70+
validators: {
71+
onDynamic: schema,
72+
},
73+
onSubmit: async ({value, formApi}) => {
74+
const ratesArray = Object.entries(value.projectRates).map(([id, rate]) => ({
7275
id: Number(id),
7376
sampleRate: parsePercent(rate),
74-
})
75-
);
76-
addLoadingMessage(t('Saving changes...'));
77-
updateSamplingProjectRates.mutate(ratesArray, {
78-
onSuccess: () => {
79-
formState.save();
77+
}));
78+
addLoadingMessage(t('Saving changes...'));
79+
try {
80+
await updateSamplingProjectRates.mutateAsync(ratesArray);
81+
setSavedProjectRates(value.projectRates);
8082
setEditMode('single');
83+
formApi.reset(value);
8184
addSuccessMessage(t('Changes applied'));
82-
},
83-
onError: () => {
85+
} catch {
8486
addErrorMessage(t('Unable to save changes. Please try again.'));
85-
},
86-
});
87-
};
87+
}
88+
},
89+
});
90+
91+
// Mirror enableReInitialize: reset the form whenever the server data changes
92+
useEffect(() => {
93+
form.reset({projectRates});
94+
setSavedProjectRates(projectRates);
95+
// eslint-disable-next-line react-hooks/exhaustive-deps
96+
}, [projectRates]);
8897

8998
const initialTargetRate = useMemo(() => {
9099
const sampleRates = sampleRatesQuery.data ?? [];
@@ -105,52 +114,83 @@ export function ProjectSampling() {
105114
);
106115
}, [sampleRatesQuery.data, sampleCountsQuery.data]);
107116

108-
const isFormActionDisabled =
109-
!hasAccess ||
110-
sampleRatesQuery.isPending ||
111-
updateSamplingProjectRates.isPending ||
112-
!formState.hasChanged;
113-
114117
return (
115-
<FormProvider formState={formState}>
116-
<OnRouteLeave
117-
message={UNSAVED_CHANGES_MESSAGE}
118-
when={locationChange =>
119-
locationChange.currentLocation.pathname !==
120-
locationChange.nextLocation.pathname && formState.hasChanged
121-
}
122-
/>
123-
<Flex justify="between" marginBottom="lg">
124-
<ProjectionPeriodControl period={period} onChange={setPeriod} />
125-
<SamplingModeSwitch initialTargetRate={initialTargetRate} />
126-
</Flex>
127-
{sampleCountsQuery.isError ? (
128-
<LoadingError onRetry={sampleCountsQuery.refetch} />
129-
) : (
130-
<ProjectsEditTable
131-
period={period}
132-
editMode={editMode}
133-
onEditModeChange={setEditMode}
134-
isLoading={sampleRatesQuery.isPending || sampleCountsQuery.isPending}
135-
sampleCounts={sampleCountsQuery.data}
136-
actions={
118+
<form.AppForm>
119+
<form.FormWrapper>
120+
<form.Subscribe selector={s => s.isDirty}>
121+
{isDirty => (
137122
<Fragment>
138-
<Button disabled={isFormActionDisabled} onClick={handleReset}>
139-
{t('Reset')}
140-
</Button>
141-
<Button
142-
priority="primary"
143-
disabled={isFormActionDisabled || !formState.isValid}
144-
onClick={handleSubmit}
145-
>
146-
{t('Apply Changes')}
147-
</Button>
123+
<OnRouteLeave
124+
message={UNSAVED_CHANGES_MESSAGE}
125+
when={locationChange =>
126+
locationChange.currentLocation.pathname !==
127+
locationChange.nextLocation.pathname && isDirty
128+
}
129+
/>
130+
<Flex justify="between" marginBottom="lg">
131+
<ProjectionPeriodControl period={period} onChange={setPeriod} />
132+
<SamplingModeSwitch initialTargetRate={initialTargetRate} />
133+
</Flex>
134+
{sampleCountsQuery.isError ? (
135+
<LoadingError onRetry={sampleCountsQuery.refetch} />
136+
) : (
137+
<form.AppField name="projectRates">
138+
{field => {
139+
const hasProjectRateErrors =
140+
field.state.value &&
141+
Object.values(field.state.value).some(rate => {
142+
if (!rate) return true;
143+
const n = Number(rate);
144+
return isNaN(n) || n < 0 || n > 100;
145+
});
146+
return (
147+
<ProjectsEditTable
148+
period={period}
149+
editMode={editMode}
150+
onEditModeChange={setEditMode}
151+
isLoading={
152+
sampleRatesQuery.isPending || sampleCountsQuery.isPending
153+
}
154+
sampleCounts={sampleCountsQuery.data}
155+
projectRates={field.state.value}
156+
savedProjectRates={savedProjectRates}
157+
onProjectRatesChange={field.handleChange}
158+
actions={
159+
<Fragment>
160+
<Button
161+
disabled={!isDirty || updateSamplingProjectRates.isPending}
162+
onClick={() => {
163+
form.reset();
164+
setEditMode('single');
165+
}}
166+
>
167+
{t('Reset')}
168+
</Button>
169+
<Button
170+
priority="primary"
171+
type="submit"
172+
disabled={
173+
!hasAccess ||
174+
!isDirty ||
175+
!!hasProjectRateErrors ||
176+
updateSamplingProjectRates.isPending
177+
}
178+
>
179+
{t('Apply Changes')}
180+
</Button>
181+
</Fragment>
182+
}
183+
/>
184+
);
185+
}}
186+
</form.AppField>
187+
)}
188+
<FormActions />
148189
</Fragment>
149-
}
150-
/>
151-
)}
152-
<FormActions />
153-
</FormProvider>
190+
)}
191+
</form.Subscribe>
192+
</form.FormWrapper>
193+
</form.AppForm>
154194
);
155195
}
156196

0 commit comments

Comments
 (0)