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' ;
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' ;
6
+ import { setToastMessage } from '@app-builder/components/MarbleToaster' ;
8
7
import { scenarioObjectDocHref } from '@app-builder/services/documentation-href' ;
9
8
import { serverServices } from '@app-builder/services/init.server' ;
10
9
import { getRoute } from '@app-builder/utils/routes' ;
11
10
import { fromUUID } from '@app-builder/utils/short-uuid' ;
12
11
import * as Ariakit from '@ariakit/react' ;
13
- import { FormProvider , getFormProps , useForm } from '@conform-to/react' ;
14
- import { getZodConstraint , parseWithZod } from '@conform-to/zod' ;
15
12
import { type ActionFunctionArgs , json , type LoaderFunctionArgs , redirect } from '@remix-run/node' ;
16
13
import { useFetcher } from '@remix-run/react' ;
14
+ import { useForm } from '@tanstack/react-form' ;
17
15
import { type Namespace } from 'i18next' ;
18
16
import * as React from 'react' ;
19
17
import { Trans , useTranslation } from 'react-i18next' ;
20
18
import { useHydrated } from 'remix-utils/use-hydrated' ;
21
- import { Button , ModalV2 } from 'ui-design-system' ;
19
+ import { Button , ModalV2 , Select } from 'ui-design-system' ;
22
20
import { Icon } from 'ui-icons' ;
23
21
import { z } from 'zod' ;
24
22
@@ -31,47 +29,67 @@ export async function loader({ request }: LoaderFunctionArgs) {
31
29
const { dataModelRepository } = await authService . isAuthenticated ( request , {
32
30
failureRedirect : getRoute ( '/sign-in' ) ,
33
31
} ) ;
34
- const dataModel = await dataModelRepository . getDataModel ( ) ;
35
32
36
- return json ( {
37
- dataModel,
38
- } ) ;
33
+ return { dataModel : await dataModelRepository . getDataModel ( ) } ;
39
34
}
40
35
41
36
const createScenarioFormSchema = z . object ( {
42
37
name : z . string ( ) . min ( 1 ) ,
43
- description : z . string ( ) . nullable ( ) . default ( null ) ,
38
+ description : z . string ( ) ,
44
39
triggerObjectType : z . string ( ) . min ( 1 ) ,
45
40
} ) ;
46
41
42
+ type CreateScenarioForm = z . infer < typeof createScenarioFormSchema > ;
43
+
47
44
export async function action ( { request } : ActionFunctionArgs ) {
48
- const { authService } = serverServices ;
49
- const { scenario } = await authService . isAuthenticated ( request , {
50
- failureRedirect : getRoute ( '/sign-in' ) ,
51
- } ) ;
45
+ const {
46
+ authService,
47
+ toastSessionService : { getSession , commitSession } ,
48
+ } = serverServices ;
52
49
53
- const formData = await request . formData ( ) ;
54
- const submission = parseWithZod ( formData , {
55
- schema : createScenarioFormSchema ,
56
- } ) ;
50
+ const [ session , rawData , { scenario } ] = await Promise . all ( [
51
+ getSession ( request ) ,
52
+ request . json ( ) ,
53
+ authService . isAuthenticated ( request , {
54
+ failureRedirect : getRoute ( '/sign-in' ) ,
55
+ } ) ,
56
+ ] ) ;
57
+
58
+ const { data, success, error } = createScenarioFormSchema . safeParse ( rawData ) ;
57
59
58
- if ( submission . status !== 'success' ) {
59
- return json ( submission . reply ( ) ) ;
60
+ if ( ! success ) {
61
+ return json (
62
+ { status : 'error' , errors : error . flatten ( ) } ,
63
+ {
64
+ headers : { 'Set-Cookie' : await commitSession ( session ) } ,
65
+ } ,
66
+ ) ;
60
67
}
61
68
62
69
try {
63
- const createdScenario = await scenario . createScenario ( submission . value ) ;
70
+ const createdScenario = await scenario . createScenario ( data ) ;
64
71
const scenarioIteration = await scenario . createScenarioIteration ( {
65
72
scenarioId : createdScenario . id ,
66
73
} ) ;
74
+
67
75
return redirect (
68
76
getRoute ( '/scenarios/:scenarioId/i/:iterationId' , {
69
77
scenarioId : fromUUID ( createdScenario . id ) ,
70
78
iterationId : fromUUID ( scenarioIteration . id ) ,
71
79
} ) ,
72
80
) ;
73
81
} catch ( error ) {
74
- return json ( submission . reply ( ) ) ;
82
+ setToastMessage ( session , {
83
+ type : 'error' ,
84
+ messageKey : 'common:errors.unknown' ,
85
+ } ) ;
86
+
87
+ return json (
88
+ { status : 'error' , errors : [ ] } ,
89
+ {
90
+ headers : { 'Set-Cookie' : await commitSession ( session ) } ,
91
+ } ,
92
+ ) ;
75
93
}
76
94
}
77
95
@@ -90,8 +108,8 @@ export function CreateScenario({ children }: { children: React.ReactElement }) {
90
108
function CreateScenarioContent ( ) {
91
109
const { t, i18n } = useTranslation ( handle . i18n ) ;
92
110
const dataModelFetcher = useFetcher < typeof loader > ( ) ;
93
-
94
111
const { load : loadDataModel } = dataModelFetcher ;
112
+
95
113
React . useEffect ( ( ) => {
96
114
loadDataModel ( getRoute ( '/ressources/scenarios/create' ) ) ;
97
115
} , [ loadDataModel ] ) ;
@@ -103,109 +121,138 @@ function CreateScenarioContent() {
103
121
104
122
const createScenarioFetcher = useFetcher < typeof action > ( ) ;
105
123
106
- const [ form , fields ] = useForm ( {
107
- shouldRevalidate : 'onInput' ,
108
- lastResult : createScenarioFetcher . data ,
109
- constraint : getZodConstraint ( createScenarioFormSchema ) ,
110
- onValidate ( { formData } ) {
111
- return parseWithZod ( formData , {
112
- schema : createScenarioFormSchema ,
113
- } ) ;
124
+ const form = useForm < CreateScenarioForm > ( {
125
+ defaultValues : { name : '' , description : '' , triggerObjectType : '' } ,
126
+ onSubmit : ( { value, formApi } ) => {
127
+ if ( formApi . state . isValid ) {
128
+ createScenarioFetcher . submit ( value , {
129
+ method : 'PATCH' ,
130
+ action : getRoute ( '/ressources/scenarios/create' ) ,
131
+ encType : 'application/json' ,
132
+ } ) ;
133
+ }
134
+ } ,
135
+ validators : {
136
+ onChangeAsync : createScenarioFormSchema ,
137
+ onBlurAsync : createScenarioFormSchema ,
138
+ onSubmitAsync : createScenarioFormSchema ,
114
139
} ,
115
140
} ) ;
116
141
117
142
return (
118
- < FormProvider context = { form . context } >
119
- < createScenarioFetcher . Form
120
- method = "POST"
121
- action = { getRoute ( '/ressources/scenarios/create' ) }
122
- { ...getFormProps ( form ) }
123
- >
124
- < ModalV2 . Title > { t ( 'scenarios:create_scenario.title' ) } </ ModalV2 . Title >
125
- < div className = "flex flex-col gap-6 p-6" >
126
- < ModalV2 . Description render = { < Callout variant = "outlined" /> } >
127
- < p className = "whitespace-pre text-wrap" >
128
- < Trans
129
- t = { t }
130
- i18nKey = "scenarios:create_scenario.callout"
131
- components = { {
132
- DocLink : < ExternalLink href = { scenarioObjectDocHref } /> ,
133
- } }
134
- />
135
- </ p >
136
- </ ModalV2 . Description >
137
- < div className = "flex flex-1 flex-col gap-4" >
138
- < FormField name = { fields . name . name } className = "group flex w-full flex-col gap-2" >
139
- < FormLabel > { t ( 'scenarios:create_scenario.name' ) } </ FormLabel >
140
- < FormInput
141
- type = "text"
142
- placeholder = { t ( 'scenarios:create_scenario.name_placeholder' ) }
143
- />
144
- < FormErrorOrDescription />
145
- </ FormField >
146
- < FormField name = { fields . description . name } className = "group flex w-full flex-col gap-2" >
147
- < FormLabel > { t ( 'scenarios:create_scenario.description' ) } </ FormLabel >
148
- < FormInput
149
- type = "text"
150
- placeholder = { t ( 'scenarios:create_scenario.description_placeholder' ) }
151
- />
152
- < FormErrorOrDescription />
153
- </ FormField >
154
- < FormField
155
- name = { fields . triggerObjectType . name }
156
- className = "group flex w-full flex-col gap-2"
157
- >
158
- < FormLabel className = "flex flex-row items-center gap-1" >
159
- { t ( 'scenarios:create_scenario.trigger_object_title' ) }
160
- < Ariakit . HovercardProvider
161
- showTimeout = { 0 }
162
- hideTimeout = { 0 }
163
- placement = { i18n . dir ( ) === 'ltr' ? 'right' : 'left' }
164
- >
165
- < Ariakit . HovercardAnchor
166
- tabIndex = { - 1 }
167
- className = "text-grey-80 hover:text-grey-50 cursor-pointer transition-colors"
168
- >
169
- < Icon icon = "tip" className = "size-5" />
170
- </ Ariakit . HovercardAnchor >
171
- < Ariakit . Hovercard
172
- portal
173
- gutter = { 4 }
174
- className = "bg-grey-100 border-grey-90 flex w-fit max-w-80 rounded border p-2 shadow-md"
143
+ < form
144
+ onSubmit = { ( e ) => {
145
+ e . preventDefault ( ) ;
146
+ e . stopPropagation ( ) ;
147
+ form . handleSubmit ( ) ;
148
+ } }
149
+ >
150
+ < ModalV2 . Title > { t ( 'scenarios:create_scenario.title' ) } </ ModalV2 . Title >
151
+ < div className = "flex flex-col gap-6 p-6" >
152
+ < ModalV2 . Description render = { < Callout variant = "outlined" /> } >
153
+ < p className = "whitespace-pre text-wrap" >
154
+ < Trans
155
+ t = { t }
156
+ i18nKey = "scenarios:create_scenario.callout"
157
+ components = { {
158
+ DocLink : < ExternalLink href = { scenarioObjectDocHref } /> ,
159
+ } }
160
+ />
161
+ </ p >
162
+ </ ModalV2 . Description >
163
+ < div className = "flex flex-1 flex-col gap-4" >
164
+ < form . Field name = "name" >
165
+ { ( field ) => (
166
+ < div className = "group flex w-full flex-col gap-2" >
167
+ < FormLabel name = { field . name } > { t ( 'scenarios:create_scenario.name' ) } </ FormLabel >
168
+ < FormInput
169
+ type = "text"
170
+ name = { field . name }
171
+ defaultValue = { field . state . value }
172
+ onChange = { ( e ) => field . handleChange ( e . currentTarget . value ) }
173
+ onBlur = { field . handleBlur }
174
+ valid = { field . state . meta . errors . length === 0 }
175
+ placeholder = { t ( 'scenarios:create_scenario.name_placeholder' ) }
176
+ />
177
+ < FormErrorOrDescription errors = { field . state . meta . errors } />
178
+ </ div >
179
+ ) }
180
+ </ form . Field >
181
+ < form . Field name = "description" >
182
+ { ( field ) => (
183
+ < div className = "group flex w-full flex-col gap-2" >
184
+ < FormLabel name = { field . name } >
185
+ { t ( 'scenarios:create_scenario.description' ) }
186
+ </ FormLabel >
187
+ < FormInput
188
+ type = "text"
189
+ name = { field . name }
190
+ defaultValue = { field . state . value }
191
+ onChange = { ( e ) => field . handleChange ( e . currentTarget . value ) }
192
+ onBlur = { field . handleBlur }
193
+ valid = { field . state . meta . errors . length === 0 }
194
+ placeholder = { t ( 'scenarios:create_scenario.description_placeholder' ) }
195
+ />
196
+ < FormErrorOrDescription errors = { field . state . meta . errors } />
197
+ </ div >
198
+ ) }
199
+ </ form . Field >
200
+ < form . Field name = "triggerObjectType" >
201
+ { ( field ) => (
202
+ < div className = "group flex w-full flex-col gap-2" >
203
+ < FormLabel name = { field . name } className = "flex flex-row items-center gap-1" >
204
+ { t ( 'scenarios:create_scenario.trigger_object_title' ) }
205
+ < Ariakit . HovercardProvider
206
+ showTimeout = { 0 }
207
+ hideTimeout = { 0 }
208
+ placement = { i18n . dir ( ) === 'ltr' ? 'right' : 'left' }
175
209
>
176
- { t ( 'scenarios:trigger_object.description' ) }
177
- </ Ariakit . Hovercard >
178
- </ Ariakit . HovercardProvider >
179
- </ FormLabel >
180
- < FormSelect . Default
181
- placeholder = { t ( 'scenarios:create_scenario.trigger_object_placeholder' ) }
182
- options = { dataModel }
183
- >
184
- { dataModelFetcher . state === 'loading' ? < p > { t ( 'common:loading' ) } </ p > : null }
185
- { dataModel . map ( ( tableName ) => {
186
- return (
187
- < FormSelect . DefaultItem key = { tableName } value = { tableName } >
188
- { tableName }
189
- </ FormSelect . DefaultItem >
190
- ) ;
191
- } ) }
192
- { dataModelFetcher . state === 'idle' && dataModel . length === 0 ? (
193
- < p > { t ( 'scenarios:create_scenario.no_trigger_object' ) } </ p >
194
- ) : null }
195
- </ FormSelect . Default >
196
- < FormErrorOrDescription />
197
- </ FormField >
198
- </ div >
199
- < div className = "flex flex-1 flex-row gap-2" >
200
- < ModalV2 . Close render = { < Button className = "flex-1" variant = "secondary" /> } >
201
- { t ( 'common:cancel' ) }
202
- </ ModalV2 . Close >
203
- < Button className = "flex-1" variant = "primary" type = "submit" >
204
- { t ( 'common:save' ) }
205
- </ Button >
206
- </ div >
210
+ < Ariakit . HovercardAnchor
211
+ tabIndex = { - 1 }
212
+ className = "text-grey-80 hover:text-grey-50 cursor-pointer transition-colors"
213
+ >
214
+ < Icon icon = "tip" className = "size-5" />
215
+ </ Ariakit . HovercardAnchor >
216
+ < Ariakit . Hovercard
217
+ portal
218
+ gutter = { 4 }
219
+ className = "bg-grey-100 border-grey-90 flex w-fit max-w-80 rounded border p-2 shadow-md"
220
+ >
221
+ { t ( 'scenarios:trigger_object.description' ) }
222
+ </ Ariakit . Hovercard >
223
+ </ Ariakit . HovercardProvider >
224
+ </ FormLabel >
225
+ < Select . Default
226
+ placeholder = { t ( 'scenarios:create_scenario.trigger_object_placeholder' ) }
227
+ defaultValue = { field . state . value }
228
+ onValueChange = { field . handleChange }
229
+ >
230
+ { dataModelFetcher . state === 'loading' ? < p > { t ( 'common:loading' ) } </ p > : null }
231
+ { dataModel . map ( ( tableName ) => {
232
+ return (
233
+ < Select . DefaultItem key = { tableName } value = { tableName } >
234
+ { tableName }
235
+ </ Select . DefaultItem >
236
+ ) ;
237
+ } ) }
238
+ { dataModelFetcher . state === 'idle' && dataModel . length === 0 ? (
239
+ < p > { t ( 'scenarios:create_scenario.no_trigger_object' ) } </ p >
240
+ ) : null }
241
+ </ Select . Default >
242
+ < FormErrorOrDescription />
243
+ </ div >
244
+ ) }
245
+ </ form . Field >
246
+ </ div >
247
+ < div className = "flex flex-1 flex-row gap-2" >
248
+ < ModalV2 . Close render = { < Button className = "flex-1" variant = "secondary" /> } >
249
+ { t ( 'common:cancel' ) }
250
+ </ ModalV2 . Close >
251
+ < Button className = "flex-1" variant = "primary" type = "submit" >
252
+ { t ( 'common:save' ) }
253
+ </ Button >
207
254
</ div >
208
- </ createScenarioFetcher . Form >
209
- </ FormProvider >
255
+ </ div >
256
+ </ form >
210
257
) ;
211
258
}
0 commit comments