1- import { Fragment , useMemo , useState } from 'react' ;
1+ import { Fragment , useEffect , useMemo , useState } from 'react' ;
22import styled from '@emotion/styled' ;
3+ import { z } from 'zod' ;
34
45import { Button } from '@sentry/scraps/button' ;
6+ import { defaultFormOptions , useScrapsForm } from '@sentry/scraps/form' ;
57import { Flex } from '@sentry/scraps/layout' ;
68
79import {
@@ -18,7 +20,6 @@ import {SamplingModeSwitch} from 'sentry/views/settings/dynamicSampling/sampling
1820import { mapArrayToObject } from 'sentry/views/settings/dynamicSampling/utils' ;
1921import { useHasDynamicSamplingWriteAccess } from 'sentry/views/settings/dynamicSampling/utils/access' ;
2022import { parsePercent } from 'sentry/views/settings/dynamicSampling/utils/parsePercent' ;
21- import { projectSamplingForm } from 'sentry/views/settings/dynamicSampling/utils/projectSamplingForm' ;
2223import {
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 ;
3232const 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+
3642export 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