Skip to content

Commit a197f03

Browse files
JonasBaclaude
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 3006dd5 commit a197f03

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 {
@@ -19,7 +21,6 @@ import {SamplingModeSwitch} from 'sentry/views/settings/dynamicSampling/sampling
1921
import {mapArrayToObject} from 'sentry/views/settings/dynamicSampling/utils';
2022
import {useHasDynamicSamplingWriteAccess} from 'sentry/views/settings/dynamicSampling/utils/access';
2123
import {parsePercent} from 'sentry/views/settings/dynamicSampling/utils/parsePercent';
22-
import {projectSamplingForm} from 'sentry/views/settings/dynamicSampling/utils/projectSamplingForm';
2324
import {
2425
useProjectSampleCounts,
2526
type ProjectionSamplePeriod,
@@ -29,11 +30,16 @@ import {
2930
useUpdateSamplingProjectRates,
3031
} from 'sentry/views/settings/dynamicSampling/utils/useSamplingProjectRates';
3132

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

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

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

9099
const initialTargetRate = useMemo(() => {
91100
const sampleRates = sampleRatesQuery.data ?? [];
@@ -106,52 +115,83 @@ export function ProjectSampling() {
106115
);
107116
}, [sampleRatesQuery.data, sampleCountsQuery.data]);
108117

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

0 commit comments

Comments
 (0)