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 {
@@ -19,7 +21,6 @@ import {SamplingModeSwitch} from 'sentry/views/settings/dynamicSampling/sampling
1921import { mapArrayToObject } from 'sentry/views/settings/dynamicSampling/utils' ;
2022import { useHasDynamicSamplingWriteAccess } from 'sentry/views/settings/dynamicSampling/utils/access' ;
2123import { parsePercent } from 'sentry/views/settings/dynamicSampling/utils/parsePercent' ;
22- import { projectSamplingForm } from 'sentry/views/settings/dynamicSampling/utils/projectSamplingForm' ;
2324import {
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 ;
3333const 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+
3743export 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