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; - }, - }, -});