Skip to content

Commit 369a758

Browse files
ref(dynamic-sampling): Use individual AppField per project rate
Replace the single projectRates record field with per-project AppField instances. Each row now gets its own field via form.AppField name={projectRates.${projectId}}, so the form system handles per-field validation, error display, and canSubmit natively. Removes the showErrors/getProjectRateError workaround. Bulk edit uses form.setFieldValue per project instead of updating the record. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 122f41e commit 369a758

File tree

3 files changed

+81
-72
lines changed

3 files changed

+81
-72
lines changed

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

Lines changed: 38 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,19 @@ const projectSamplingSchema = z.object({
3838
projectRates: z.record(z.string(), sampleRateField),
3939
});
4040

41+
// Helper to extract the form type from the component — avoids deeply generic TanStack types.
42+
// Consumers should not construct this type directly; it's inferred from useScrapsForm.
43+
function _formTypeHelper() {
44+
// This function is never called — it exists only for type inference.
45+
// eslint-disable-next-line react-hooks/rules-of-hooks
46+
return useScrapsForm({
47+
...defaultFormOptions,
48+
defaultValues: {projectRates: {} as Record<string, string>},
49+
validators: {onDynamic: projectSamplingSchema},
50+
});
51+
}
52+
export type ProjectSamplingForm = ReturnType<typeof _formTypeHelper>;
53+
4154
export function ProjectSampling() {
4255
const hasAccess = useHasDynamicSamplingWriteAccess();
4356
const [period, setPeriod] = useState<ProjectionSamplePeriod>('24h');
@@ -133,40 +146,31 @@ export function ProjectSampling() {
133146
{sampleCountsQuery.isError ? (
134147
<LoadingError onRetry={sampleCountsQuery.refetch} />
135148
) : (
136-
<form.AppField name="projectRates">
137-
{field => (
138-
<ProjectsEditTable
139-
period={period}
140-
editMode={editMode}
141-
onEditModeChange={setEditMode}
142-
isLoading={sampleRatesQuery.isPending || sampleCountsQuery.isPending}
143-
sampleCounts={sampleCountsQuery.data}
144-
projectRates={field.state.value}
145-
savedProjectRates={savedProjectRates}
146-
onProjectRatesChange={field.handleChange}
147-
showErrors={!field.state.meta.isValid}
148-
actions={
149-
<Fragment>
150-
<Button
151-
disabled={!isDirty || updateSamplingProjectRates.isPending}
152-
onClick={() => {
153-
form.reset();
154-
setEditMode('single');
155-
}}
156-
>
157-
{t('Reset')}
158-
</Button>
159-
<form.SubmitButton
160-
disabled={!hasAccess || !canSubmit}
161-
formNoValidate
162-
>
163-
{t('Apply Changes')}
164-
</form.SubmitButton>
165-
</Fragment>
166-
}
167-
/>
168-
)}
169-
</form.AppField>
149+
<ProjectsEditTable
150+
form={form}
151+
period={period}
152+
editMode={editMode}
153+
onEditModeChange={setEditMode}
154+
isLoading={sampleRatesQuery.isPending || sampleCountsQuery.isPending}
155+
sampleCounts={sampleCountsQuery.data}
156+
savedProjectRates={savedProjectRates}
157+
actions={
158+
<Fragment>
159+
<Button
160+
disabled={!isDirty || updateSamplingProjectRates.isPending}
161+
onClick={() => {
162+
form.reset();
163+
setEditMode('single');
164+
}}
165+
>
166+
{t('Reset')}
167+
</Button>
168+
<form.SubmitButton disabled={!hasAccess || !canSubmit} formNoValidate>
169+
{t('Apply Changes')}
170+
</form.SubmitButton>
171+
</Fragment>
172+
}
173+
/>
170174
)}
171175
<FormActions />
172176
</Fragment>

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

Lines changed: 15 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import {Fragment, useCallback, useMemo, useRef, useState} from 'react';
33
import {css} from '@emotion/react';
44
import styled from '@emotion/styled';
55

6+
import {useStore} from '@sentry/scraps/form';
7+
68
import {LoadingIndicator} from 'sentry/components/loadingIndicator';
79
import {Panel} from 'sentry/components/panels/panel';
810
import {t} from 'sentry/locale';
911
import {useProjects} from 'sentry/utils/useProjects';
1012
import {OrganizationSampleRateInput} from 'sentry/views/settings/dynamicSampling/organizationSampleRateInput';
11-
import {sampleRateField} from 'sentry/views/settings/dynamicSampling/organizationSampling';
13+
import type {ProjectSamplingForm} from 'sentry/views/settings/dynamicSampling/projectSampling';
1214
import {ProjectsTable} from 'sentry/views/settings/dynamicSampling/projectsTable';
1315
import {SamplingBreakdown} from 'sentry/views/settings/dynamicSampling/samplingBreakdown';
1416
import {mapArrayToObject} from 'sentry/views/settings/dynamicSampling/utils';
@@ -23,48 +25,34 @@ import type {
2325
interface Props {
2426
actions: React.ReactNode;
2527
editMode: 'single' | 'bulk';
28+
form: ProjectSamplingForm;
2629
isLoading: boolean;
2730
onEditModeChange: (mode: 'single' | 'bulk') => void;
28-
onProjectRatesChange: (
29-
updater:
30-
| Record<string, string>
31-
| ((prev: Record<string, string>) => Record<string, string>)
32-
) => void;
3331
period: ProjectionSamplePeriod;
34-
projectRates: Record<string, string>;
3532
sampleCounts: ProjectSampleCount[];
3633
savedProjectRates: Record<string, string>;
37-
showErrors?: boolean;
3834
}
3935

4036
const EMPTY_ARRAY: any = [];
4137

42-
function getProjectRateError(rate: string): string | undefined {
43-
const result = sampleRateField.safeParse(rate);
44-
if (result.success) {
45-
return undefined;
46-
}
47-
return result.error.issues[0]?.message;
48-
}
49-
5038
export function ProjectsEditTable({
5139
actions,
5240
isLoading: isLoadingProp,
5341
sampleCounts,
5442
editMode,
5543
period,
5644
onEditModeChange,
57-
projectRates,
45+
form,
5846
savedProjectRates,
59-
onProjectRatesChange,
60-
showErrors,
6147
}: Props) {
6248
const {projects, fetching} = useProjects();
6349
const [isBulkEditEnabled, setIsBulkEditEnabled] = useState(false);
6450
const [orgRate, setOrgRate] = useState<string>('');
6551

6652
const projectRateSnapshotRef = useRef<Record<string, string>>({});
6753

54+
const projectRates = useStore(form.baseStore, s => s.values.projectRates);
55+
6856
const dataByProjectId = useMemo(
6957
() =>
7058
mapArrayToObject({
@@ -77,13 +65,10 @@ export function ProjectsEditTable({
7765

7866
const handleProjectChange = useCallback(
7967
(projectId: string, newRate: string) => {
80-
onProjectRatesChange(prev => ({
81-
...prev,
82-
[projectId]: newRate,
83-
}));
68+
form.setFieldValue(`projectRates.${projectId}`, newRate);
8469
onEditModeChange('single');
8570
},
86-
[onProjectRatesChange, onEditModeChange]
71+
[form, onEditModeChange]
8772
);
8873

8974
const handleOrgChange = useCallback(
@@ -116,11 +101,13 @@ export function ProjectsEditTable({
116101
valueSelector: item => formatPercent(item.sampleRate),
117102
});
118103

119-
onProjectRatesChange(prev => ({...prev, ...newProjectValues}));
104+
for (const [projectId, rate] of Object.entries(newProjectValues)) {
105+
form.setFieldValue(`projectRates.${projectId}`, rate);
106+
}
120107
setOrgRate(newRate);
121108
onEditModeChange('bulk');
122109
},
123-
[dataByProjectId, editMode, onProjectRatesChange, onEditModeChange, projectRates]
110+
[dataByProjectId, editMode, form, onEditModeChange, projectRates]
124111
);
125112

126113
const handleBulkEditChange = useCallback((newIsActive: boolean) => {
@@ -145,12 +132,9 @@ export function ProjectsEditTable({
145132
project,
146133
initialSampleRate: savedProjectRates[project.id]!,
147134
sampleRate: projectRates[project.id]!,
148-
error: showErrors
149-
? getProjectRateError(projectRates[project.id] ?? '')
150-
: undefined,
151135
};
152136
}),
153-
[dataByProjectId, showErrors, savedProjectRates, projects, projectRates]
137+
[dataByProjectId, savedProjectRates, projects, projectRates]
154138
);
155139

156140
const totalSpanCount = useMemo(
@@ -227,6 +211,7 @@ export function ProjectsEditTable({
227211
period={period}
228212
isLoading={isLoading}
229213
items={items}
214+
form={form}
230215
/>
231216
<Footer>{actions}</Footer>
232217
</Fragment>

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

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {formatAbbreviatedNumber} from 'sentry/utils/formatters';
1919
import {oxfordizeArray} from 'sentry/utils/oxfordizeArray';
2020
import {useOrganization} from 'sentry/utils/useOrganization';
2121
import {PercentInput} from 'sentry/views/settings/dynamicSampling/percentInput';
22+
import type {ProjectSamplingForm} from 'sentry/views/settings/dynamicSampling/projectSampling';
2223
import {useHasDynamicSamplingWriteAccess} from 'sentry/views/settings/dynamicSampling/utils/access';
2324
import {parsePercent} from 'sentry/views/settings/dynamicSampling/utils/parsePercent';
2425
import type {
@@ -49,6 +50,7 @@ interface Props {
4950
period: ProjectionSamplePeriod;
5051
rateHeader: React.ReactNode;
5152
canEdit?: boolean;
53+
form?: ProjectSamplingForm;
5254
inputTooltip?: string;
5355
onChange?: (projectId: string, value: string) => void;
5456
}
@@ -65,6 +67,7 @@ export function ProjectsTable({
6567
period,
6668
isLoading,
6769
emptyMessage,
70+
form,
6871
}: Props) {
6972
const hasAccess = useHasDynamicSamplingWriteAccess();
7073
const [tableSort, setTableSort] = useState<'asc' | 'desc'>('desc');
@@ -167,14 +170,31 @@ export function ProjectsTable({
167170
transform: `translateY(${virtualRow.start}px)`,
168171
}}
169172
>
170-
<TableRow
171-
canEdit={canEdit}
172-
onChange={onChange}
173-
inputTooltip={inputTooltip}
174-
toggleExpanded={handleToggleItemExpanded}
175-
hasAccess={hasAccess}
176-
{...item}
177-
/>
173+
{form ? (
174+
<form.AppField name={`projectRates.${item.project.id}`}>
175+
{field => (
176+
<TableRow
177+
canEdit={canEdit}
178+
onChange={onChange}
179+
inputTooltip={inputTooltip}
180+
toggleExpanded={handleToggleItemExpanded}
181+
hasAccess={hasAccess}
182+
{...item}
183+
sampleRate={field.state.value}
184+
error={field.state.meta.errors[0]?.message}
185+
/>
186+
)}
187+
</form.AppField>
188+
) : (
189+
<TableRow
190+
canEdit={canEdit}
191+
onChange={onChange}
192+
inputTooltip={inputTooltip}
193+
toggleExpanded={handleToggleItemExpanded}
194+
hasAccess={hasAccess}
195+
{...item}
196+
/>
197+
)}
178198
</div>
179199
);
180200
})}

0 commit comments

Comments
 (0)