1
1
import { Callout } from '@app-builder/components' ;
2
2
import { ExternalLink } from '@app-builder/components/ExternalLink' ;
3
- import { FormErrorOrDescription } from '@app-builder/components/Form/FormErrorOrDescription' ;
4
- import { FormField } from '@app-builder/components/Form/FormField' ;
5
- import { FormInput } from '@app-builder/components/Form/FormInput' ;
6
- import { FormLabel } from '@app-builder/components/Form/FormLabel' ;
7
- import { FormSelect } from '@app-builder/components/Form/FormSelect' ;
8
- import { FormTextArea } from '@app-builder/components/Form/FormTextArea' ;
3
+ import { FormErrorOrDescription } from '@app-builder/components/Form/Tanstack/FormErrorOrDescription' ;
4
+ import { FormInput } from '@app-builder/components/Form/Tanstack/FormInput' ;
5
+ import { FormLabel } from '@app-builder/components/Form/Tanstack/FormLabel' ;
9
6
import { setToastMessage } from '@app-builder/components/MarbleToaster' ;
10
7
import { LoadingIcon } from '@app-builder/components/Spinner' ;
11
- import { adaptDateTimeFieldCodes } from '@app-builder/models/duration' ;
8
+ import { adaptDateTimeFieldCodes , type DurationUnit } from '@app-builder/models/duration' ;
12
9
import { isStatusConflictHttpError } from '@app-builder/models/http-errors' ;
13
10
import { ruleSnoozesDocHref } from '@app-builder/services/documentation-href' ;
14
11
import { serverServices } from '@app-builder/services/init.server' ;
15
12
import { useFormatLanguage } from '@app-builder/utils/format' ;
16
13
import { getRoute } from '@app-builder/utils/routes' ;
17
- import { FormProvider , getFormProps , getInputProps , useForm } from '@conform-to/react' ;
18
- import { getZodConstraint , parseWithZod } from '@conform-to/zod' ;
19
14
import { type ActionFunctionArgs , json } from '@remix-run/node' ;
20
15
import { useFetcher } from '@remix-run/react' ;
21
- import * as React from 'react' ;
16
+ import { useForm } from '@tanstack/react-form' ;
17
+ import { useEffect , useMemo , useState } from 'react' ;
22
18
import { Trans , useTranslation } from 'react-i18next' ;
23
19
import { Temporal } from 'temporal-polyfill' ;
24
- import { Button , ModalV2 } from 'ui-design-system' ;
20
+ import { Button , ModalV2 , Select , TextArea } from 'ui-design-system' ;
25
21
import { z } from 'zod' ;
26
22
27
23
const durationUnitOptions = [ 'days' , 'weeks' , 'hours' ] as const ;
@@ -34,26 +30,37 @@ const addRuleSnoozeFormSchema = z.object({
34
30
durationUnit : z . enum ( durationUnitOptions ) ,
35
31
} ) ;
36
32
33
+ type AddRuleSnoozeForm = z . infer < typeof addRuleSnoozeFormSchema > ;
34
+
37
35
export async function action ( { request } : ActionFunctionArgs ) {
38
36
const {
39
37
authService,
40
38
i18nextService : { getFixedT } ,
41
39
toastSessionService : { getSession, commitSession } ,
42
40
} = serverServices ;
43
- const { decision } = await authService . isAuthenticated ( request , {
44
- failureRedirect : getRoute ( '/sign-in' ) ,
45
- } ) ;
46
41
47
- const formData = await request . formData ( ) ;
48
- const submission = parseWithZod ( formData , {
49
- schema : addRuleSnoozeFormSchema ,
50
- } ) ;
42
+ const [ t , session , rawData , { decision } ] = await Promise . all ( [
43
+ getFixedT ( request , [ 'common' , 'cases' ] ) ,
44
+ getSession ( request ) ,
45
+ request . json ( ) ,
46
+ authService . isAuthenticated ( request , {
47
+ failureRedirect : getRoute ( '/sign-in' ) ,
48
+ } ) ,
49
+ ] ) ;
51
50
52
- if ( submission . status !== 'success' ) {
53
- return json ( submission . reply ( ) ) ;
51
+ const { data, success, error } = addRuleSnoozeFormSchema . safeParse ( rawData ) ;
52
+
53
+ if ( ! success ) {
54
+ return json (
55
+ { status : 'error' , errors : error . flatten ( ) } ,
56
+ {
57
+ headers : { 'Set-Cookie' : await commitSession ( session ) } ,
58
+ } ,
59
+ ) ;
54
60
}
55
61
56
- const { decisionId, ruleId, comment, durationUnit, durationValue } = submission . value ;
62
+ const { decisionId, ruleId, comment, durationUnit, durationValue } = data ;
63
+
57
64
const duration = Temporal . Duration . from ( {
58
65
[ durationUnit ] : durationValue ,
59
66
} ) ;
@@ -63,13 +70,18 @@ export async function action({ request }: ActionFunctionArgs) {
63
70
relativeTo : Temporal . Now . plainDateTime ( 'gregory' ) ,
64
71
} ) >= 0
65
72
) {
66
- const t = await getFixedT ( request , [ 'cases' ] ) ;
67
73
return json (
68
- submission . reply ( {
69
- fieldErrors : {
70
- durationValue : [ t ( 'cases:case_detail.add_rule_snooze.errors.max_duration' ) ] ,
71
- } ,
72
- } ) ,
74
+ {
75
+ status : 'error' ,
76
+ errors : [
77
+ {
78
+ durationValue : [ t ( 'cases:case_detail.add_rule_snooze.errors.max_duration' ) ] ,
79
+ } ,
80
+ ] ,
81
+ } ,
82
+ {
83
+ headers : { 'Set-Cookie' : await commitSession ( session ) } ,
84
+ } ,
73
85
) ;
74
86
}
75
87
@@ -80,26 +92,21 @@ export async function action({ request }: ActionFunctionArgs) {
80
92
comment,
81
93
} ) ;
82
94
83
- return json ( submission . reply ( ) ) ;
95
+ return { status : 'success' , errors : [ ] } ;
84
96
} catch ( error ) {
85
- const session = await getSession ( request ) ;
86
- const t = await getFixedT ( request , [ 'common' , 'cases' ] ) ;
87
-
88
- let message : string ;
89
- if ( isStatusConflictHttpError ( error ) ) {
90
- message = t ( 'cases:case_detail.add_rule_snooze.errors.duplicate_rule_snooze' ) ;
91
- } else {
92
- message = t ( 'common:errors.unknown' ) ;
93
- }
94
-
95
97
setToastMessage ( session , {
96
98
type : 'error' ,
97
- message,
99
+ message : isStatusConflictHttpError ( error )
100
+ ? t ( 'cases:case_detail.add_rule_snooze.errors.duplicate_rule_snooze' )
101
+ : t ( 'common:errors.unknown' ) ,
98
102
} ) ;
99
103
100
- return json ( submission . reply ( { formErrors : [ message ] } ) , {
101
- headers : { 'Set-Cookie' : await commitSession ( session ) } ,
102
- } ) ;
104
+ return json (
105
+ { status : 'error' , errors : [ ] } ,
106
+ {
107
+ headers : { 'Set-Cookie' : await commitSession ( session ) } ,
108
+ } ,
109
+ ) ;
103
110
}
104
111
}
105
112
@@ -112,7 +119,7 @@ export function AddRuleSnooze({
112
119
decisionId : string ;
113
120
ruleId : string ;
114
121
} ) {
115
- const [ open , setOpen ] = React . useState ( false ) ;
122
+ const [ open , setOpen ] = useState ( false ) ;
116
123
117
124
return (
118
125
< ModalV2 . Root open = { open } setOpen = { setOpen } >
@@ -135,121 +142,147 @@ function AddRuleSnoozeContent({
135
142
} ) {
136
143
const { t } = useTranslation ( [ 'common' , 'cases' ] ) ;
137
144
const language = useFormatLanguage ( ) ;
138
- const dateTimeFieldNames = React . useMemo (
145
+ const fetcher = useFetcher < typeof action > ( ) ;
146
+ const dateTimeFieldNames = useMemo (
139
147
( ) =>
140
148
new Intl . DisplayNames ( language , {
141
149
type : 'dateTimeField' ,
142
150
} ) ,
143
151
[ language ] ,
144
152
) ;
145
153
146
- const fetcher = useFetcher < typeof action > ( ) ;
147
- React . useEffect ( ( ) => {
154
+ useEffect ( ( ) => {
148
155
if ( fetcher ?. data ?. status === 'success' ) {
149
156
setOpen ( false ) ;
150
157
}
151
158
} , [ setOpen , fetcher ?. data ?. status ] ) ;
152
159
153
- const [ form , fields ] = useForm ( {
154
- shouldRevalidate : 'onInput' ,
155
- defaultValue : {
160
+ const form = useForm < AddRuleSnoozeForm > ( {
161
+ defaultValues : {
156
162
decisionId,
157
163
ruleId,
158
164
durationValue : 1 ,
159
165
durationUnit : 'days' ,
160
166
} ,
161
- lastResult : fetcher . data ,
162
- constraint : getZodConstraint ( addRuleSnoozeFormSchema ) ,
163
- onValidate ( { formData } ) {
164
- return parseWithZod ( formData , {
165
- schema : addRuleSnoozeFormSchema ,
166
- } ) ;
167
+ onSubmit : ( { value, formApi } ) => {
168
+ if ( formApi . state . isValid ) {
169
+ fetcher . submit ( value , {
170
+ method : 'POST' ,
171
+ action : getRoute ( '/ressources/cases/add-rule-snooze' ) ,
172
+ encType : 'application/json' ,
173
+ } ) ;
174
+ }
175
+ } ,
176
+ validators : {
177
+ onChangeAsync : addRuleSnoozeFormSchema ,
178
+ onBlurAsync : addRuleSnoozeFormSchema ,
179
+ onSubmitAsync : addRuleSnoozeFormSchema ,
167
180
} ,
168
181
} ) ;
169
182
170
183
return (
171
- < FormProvider context = { form . context } >
172
- < fetcher . Form
173
- method = "post"
174
- action = { getRoute ( '/ressources/cases/add-rule-snooze' ) }
175
- { ...getFormProps ( form ) }
176
- >
177
- < ModalV2 . Title > { t ( 'cases:case_detail.add_rule_snooze.title' ) } </ ModalV2 . Title >
178
- < div className = "flex flex-col gap-6 p-6" >
179
- < ModalV2 . Description render = { < Callout variant = "outlined" /> } >
180
- < p className = "whitespace-pre text-wrap" >
181
- < Trans
182
- t = { t }
183
- i18nKey = "cases:case_detail.add_rule_snooze.callout"
184
- components = { {
185
- DocLink : < ExternalLink href = { ruleSnoozesDocHref } /> ,
186
- } }
187
- />
188
- </ p >
189
- </ ModalV2 . Description >
190
- < input
191
- { ...getInputProps ( fields . decisionId , {
192
- type : 'hidden' ,
193
- } ) }
194
- />
195
- < input
196
- { ...getInputProps ( fields . ruleId , {
197
- type : 'hidden' ,
198
- } ) }
199
- />
200
-
201
- < FormField
202
- name = { fields . comment . name }
203
- className = "row-span-full grid grid-rows-subgrid gap-2"
204
- >
205
- < FormLabel > { t ( 'cases:case_detail.add_rule_snooze.comment.label' ) } </ FormLabel >
206
- < FormTextArea
207
- className = "w-full"
208
- placeholder = { t ( 'cases:case_detail.add_rule_snooze.comment.placeholder' ) }
184
+ < form
185
+ onSubmit = { ( e ) => {
186
+ e . preventDefault ( ) ;
187
+ e . stopPropagation ( ) ;
188
+ form . handleSubmit ( ) ;
189
+ } }
190
+ >
191
+ < ModalV2 . Title > { t ( 'cases:case_detail.add_rule_snooze.title' ) } </ ModalV2 . Title >
192
+ < div className = "flex flex-col gap-6 p-6" >
193
+ < ModalV2 . Description render = { < Callout variant = "outlined" /> } >
194
+ < p className = "whitespace-pre text-wrap" >
195
+ < Trans
196
+ t = { t }
197
+ i18nKey = "cases:case_detail.add_rule_snooze.callout"
198
+ components = { {
199
+ DocLink : < ExternalLink href = { ruleSnoozesDocHref } /> ,
200
+ } }
209
201
/>
210
- </ FormField >
211
-
212
- < div className = "grid w-full grid-cols-2 grid-rows-[repeat(3,_max-content)] gap-2" >
213
- < FormField
214
- name = { fields . durationValue . name }
215
- className = "row-span-full grid grid-rows-subgrid gap-2"
216
- >
217
- < FormLabel > { t ( 'cases:case_detail.add_rule_snooze.duration_value' ) } </ FormLabel >
218
- < FormInput type = "number" className = "w-full" />
219
- < FormErrorOrDescription />
220
- </ FormField >
221
-
222
- < FormField
223
- name = { fields . durationUnit . name }
224
- className = "row-span-full grid grid-rows-subgrid gap-2"
225
- >
226
- < FormLabel > { t ( 'cases:case_detail.add_rule_snooze.duration_unit' ) } </ FormLabel >
227
- < FormSelect . Default className = "h-10 w-full" options = { durationUnitOptions } >
228
- { durationUnitOptions . map ( ( unit ) => (
229
- < FormSelect . DefaultItem key = { unit } value = { unit } >
230
- { dateTimeFieldNames . of ( adaptDateTimeFieldCodes ( unit ) ) }
231
- </ FormSelect . DefaultItem >
232
- ) ) }
233
- </ FormSelect . Default >
234
- < FormErrorOrDescription />
235
- </ FormField >
236
- </ div >
237
-
238
- < div className = "flex flex-1 flex-row gap-2" >
239
- < ModalV2 . Close render = { < Button className = "flex-1" variant = "secondary" /> } >
240
- { t ( 'common:cancel' ) }
241
- </ ModalV2 . Close >
242
- < Button className = "flex-1" variant = "primary" type = "submit" name = "update" >
243
- < LoadingIcon
244
- icon = "snooze"
245
- className = "size-5"
246
- loading = { fetcher . state === 'submitting' }
202
+ </ p >
203
+ </ ModalV2 . Description >
204
+
205
+ < form . Field name = "comment" >
206
+ { ( field ) => (
207
+ < div className = "row-span-full grid grid-rows-subgrid gap-2" >
208
+ < FormLabel name = { field . name } >
209
+ { t ( 'cases:case_detail.add_rule_snooze.comment.label' ) }
210
+ </ FormLabel >
211
+ < TextArea
212
+ className = "w-full"
213
+ defaultValue = { field . state . value }
214
+ onChange = { ( e ) => field . handleChange ( e . currentTarget . value ) }
215
+ name = { field . name }
216
+ onBlur = { field . handleBlur }
217
+ borderColor = { field . state . meta . errors . length === 0 ? 'greyfigma-90' : 'redfigma-47' }
218
+ placeholder = { t ( 'cases:case_detail.add_rule_snooze.comment.placeholder' ) }
247
219
/>
248
- { t ( 'cases:case_detail.add_rule_snooze.snooze_this_value' ) }
249
- </ Button >
250
- </ div >
220
+ </ div >
221
+ ) }
222
+ </ form . Field >
223
+
224
+ < div className = "grid w-full grid-cols-2 grid-rows-[repeat(3,_max-content)] gap-2" >
225
+ < form . Field name = "durationValue" >
226
+ { ( field ) => (
227
+ < div className = "row-span-full grid grid-rows-subgrid gap-2" >
228
+ < FormLabel name = { field . name } >
229
+ { t ( 'cases:case_detail.add_rule_snooze.duration_value' ) }
230
+ </ FormLabel >
231
+ < FormInput
232
+ type = "number"
233
+ name = { field . name }
234
+ value = { field . state . value }
235
+ onChange = { ( e ) => field . handleChange ( + e . currentTarget . value ) }
236
+ onBlur = { field . handleBlur }
237
+ valid = { field . state . meta . errors . length === 0 }
238
+ className = "w-full"
239
+ />
240
+ < FormErrorOrDescription errors = { field . state . meta . errors } />
241
+ </ div >
242
+ ) }
243
+ </ form . Field >
244
+
245
+ < form . Field name = "durationUnit" >
246
+ { ( field ) => (
247
+ < div className = "row-span-full grid grid-rows-subgrid gap-2" >
248
+ < FormLabel name = { field . name } >
249
+ { t ( 'cases:case_detail.add_rule_snooze.duration_unit' ) }
250
+ </ FormLabel >
251
+ < Select . Default
252
+ className = "h-10 w-full"
253
+ defaultValue = { field . state . value }
254
+ onValueChange = { ( unit ) =>
255
+ field . handleChange (
256
+ unit as Exclude < DurationUnit , 'seconds' | 'years' | 'minutes' | 'months' > ,
257
+ )
258
+ }
259
+ >
260
+ { durationUnitOptions . map ( ( unit ) => (
261
+ < Select . DefaultItem key = { unit } value = { unit } >
262
+ { dateTimeFieldNames . of ( adaptDateTimeFieldCodes ( unit ) ) }
263
+ </ Select . DefaultItem >
264
+ ) ) }
265
+ </ Select . Default >
266
+ < FormErrorOrDescription />
267
+ </ div >
268
+ ) }
269
+ </ form . Field >
270
+ </ div >
271
+
272
+ < div className = "flex flex-1 flex-row gap-2" >
273
+ < ModalV2 . Close render = { < Button className = "flex-1" variant = "secondary" /> } >
274
+ { t ( 'common:cancel' ) }
275
+ </ ModalV2 . Close >
276
+ < Button className = "flex-1" variant = "primary" type = "submit" name = "update" >
277
+ < LoadingIcon
278
+ icon = "snooze"
279
+ className = "size-5"
280
+ loading = { fetcher . state === 'submitting' }
281
+ />
282
+ { t ( 'cases:case_detail.add_rule_snooze.snooze_this_value' ) }
283
+ </ Button >
251
284
</ div >
252
- </ fetcher . Form >
253
- </ FormProvider >
285
+ </ div >
286
+ </ form >
254
287
) ;
255
288
}
0 commit comments