diff --git a/static/app/views/settings/dynamicSampling/organizationSampling.tsx b/static/app/views/settings/dynamicSampling/organizationSampling.tsx
index 5ad9d9c05b6143..162ce9a206f22c 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() {
@@ -80,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.spec.tsx b/static/app/views/settings/dynamicSampling/projectSampling.spec.tsx
new file mode 100644
index 00000000000000..a4cb90fab07e88
--- /dev/null
+++ b/static/app/views/settings/dynamicSampling/projectSampling.spec.tsx
@@ -0,0 +1,222 @@
+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}],
+ });
+ });
+
+ async function waitForProjectRateInput() {
+ return screen.findByRole('spinbutton', {
+ name: 'Sample rate for project-slug',
+ });
+ }
+
+ 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('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: [],
+ samplingMode: 'project',
+ });
+
+ render(, {organization: orgWithoutAccess});
+
+ await waitForProjectRateInput();
+ expect(screen.getByRole('button', {name: 'Apply Changes'})).toBeDisabled();
+ });
+});
diff --git a/static/app/views/settings/dynamicSampling/projectSampling.tsx b/static/app/views/settings/dynamicSampling/projectSampling.tsx
index 43f868a83f6350..e360db91c6f413 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, 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 {
@@ -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,
@@ -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('24h');
@@ -54,37 +59,55 @@ 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>(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]
+ );
+
+ 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});
+ setSavedProjectRates(projectRates);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [projectRates]);
const initialTargetRate = useMemo(() => {
const sampleRates = sampleRatesQuery.data ?? [];
@@ -105,52 +128,75 @@ 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 ? (
-
- ) : (
-
+ ({
+ isDirty: s.isDirty,
+ currentProjectRates: s.values.projectRates,
+ fieldMeta: s.fieldMeta,
+ })}
+ >
+ {({isDirty, 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 d52da4a1ca2106..2812017093cb1b 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,
@@ -24,13 +23,17 @@ 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;
+ projectErrors: Record;
+ projectRates: Record;
sampleCounts: ProjectSampleCount[];
+ savedProjectRates: Record;
}
-const {useFormField} = projectSamplingForm;
-const EMPTY_ARRAY: any = [];
+const EMPTY_ARRAY: never[] = [];
export function ProjectsEditTable({
actions,
@@ -39,9 +42,13 @@ export function ProjectsEditTable({
editMode,
period,
onEditModeChange,
+ onBulkProjectRateChange,
+ onProjectRateChange,
+ projectRates,
+ projectErrors,
+ savedProjectRates,
}: Props) {
const {projects, fetching} = useProjects();
- const {value, initialValue, error, onChange} = useFormField('projectRates');
const [isBulkEditEnabled, setIsBulkEditEnabled] = useState(false);
const [orgRate, setOrgRate] = useState('');
@@ -59,22 +66,19 @@ export function ProjectsEditTable({
const handleProjectChange = useCallback(
(projectId: string, newRate: string) => {
- onChange(prev => ({
- ...prev,
- [projectId]: newRate,
- }));
+ onProjectRateChange(projectId, newRate);
onEditModeChange('single');
},
- [onChange, onEditModeChange]
+ [onProjectRateChange, 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 +88,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 +102,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};
- });
-
+ onBulkProjectRateChange(newProjectValues);
setOrgRate(newRate);
onEditModeChange('bulk');
},
- [dataByProjectId, editMode, onChange, onEditModeChange, value]
+ [dataByProjectId, editMode, onBulkProjectRateChange, onEditModeChange, projectRates]
);
const handleBulkEditChange = useCallback((newIsActive: boolean) => {
@@ -129,12 +129,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: projectErrors[project.id],
};
}),
- [dataByProjectId, error, initialValue, projects, value]
+ [dataByProjectId, projectErrors, savedProjectRates, projects, projectRates]
);
const totalSpanCount = useMemo(
@@ -142,35 +142,42 @@ 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
);
+ if (totalSpanCount === 0) {
+ return formatPercent(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
);
+ if (totalSpanCount === 0) {
+ return formatPercent(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/projectsTable.tsx b/static/app/views/settings/dynamicSampling/projectsTable.tsx
index 511fe9c37a8a97..b1af658e978f82 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({
)}
-
+
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;
- },
- },
-});