objective categorical abstract machine language personal data server
65
fork

Configure Feed

Select the types of activity you want to include in your feed.

Handle multi-step dashboard form state management client-side

futurGH ee4c770c 73ea9a51

+216 -82
+3 -1
frontend/src/components/Button.mlx
··· 32 32 disabled:text-mist-80" 33 33 34 34 let[@react.component] make ?id ?name ?(kind = `Primary) ?(type_ = "button") 35 - ?formMethod ?onClick ?value ?(className = "") ?ref ~children () = 35 + ?formMethod ?onClick ?value ?disabled ?(className = "") ?ref 36 + ~children () = 36 37 <button 37 38 ?id 38 39 ?name ··· 40 41 ?formMethod 41 42 ?onClick 42 43 ?value 44 + ?disabled 43 45 className=(classes kind ^ " " ^ className) 44 46 ?ref> 45 47 children
+5 -3
frontend/src/components/Input.mlx
··· 5 5 6 6 let[@react.component] make ?id ~name ?(className = "") ?(type_ = "text") ?label 7 7 ?(sr_only = false) ?value ?placeholder ?autoComplete ?(required = false) 8 - ?(disabled = false) ?trailing ?(show_optional_indicator = true) () = 8 + ?(disabled = false) ?trailing ?(showIndicator = true) ?onChange () 9 + = 9 10 let id = Option.value id ~default:name in 10 11 let placeholder = if label <> None && sr_only then label else placeholder in 11 12 let input = ··· 18 19 required 19 20 disabled 20 21 ?value 22 + ?onChange 21 23 className="block min-w-0 grow text-mist-100 placeholder:text-mist-80 \ 22 24 placeholder:font-medium focus-visible:outline-none" 23 25 /> ··· 30 32 ^ if sr_only then " sr-only" else "" )> 31 33 <label htmlFor=id className="text-mist-100 mb-1"> 32 34 (string label) 33 - ( if required then 35 + ( if required && showIndicator then 34 36 <span className="text-phoenix-100">(string req_marker)</span> 35 37 else null ) 36 38 </label> 37 - ( if required || not show_optional_indicator then null 39 + ( if required || not showIndicator then null 38 40 else <span className="text-mist-80">(string "optional")</span> ) 39 41 </div> 40 42 | None ->
+208 -78
frontend/src/templates/AccountPage.mlx
··· 45 45 let deleteModalOpen, setDeleteModalOpen = 46 46 useState (fun () -> delete_pending) 47 47 in 48 + let emailPending, setEmailPending = 49 + useState (fun () -> email_change_pending) 50 + in 51 + let emailPendingAddress, setEmailPendingAddress = 52 + useState (fun () -> pending_email) 53 + in 54 + let emailErrorState, setEmailErrorState = useState (fun () -> email_error) in 55 + let emailLoading, setEmailLoading = useState (fun () -> false) in 56 + let newEmailInput, setNewEmailInput = useState (fun () -> "") in 57 + let emailTokenInput, setEmailTokenInput = useState (fun () -> "") in 58 + let deletePendingState, setDeletePendingState = 59 + useState (fun () -> delete_pending) 60 + in 61 + let deleteErrorState, setDeleteErrorState = 62 + useState (fun () -> delete_error) 63 + in 64 + let deleteLoading, setDeleteLoading = useState (fun () -> false) in 65 + let deleteTokenInput, setDeleteTokenInput = useState (fun () -> "") in 48 66 <div className="w-auto h-auto px-4 sm:px-0 flex flex-col md:flex-row gap-12"> 49 67 <AccountSidebar 50 68 current_user logged_in_users active_page=AccountSidebar.Account ··· 93 111 type_="email" 94 112 label="Email" 95 113 placeholder=email 96 - show_optional_indicator=false 114 + required=true 115 + showIndicator=false 97 116 disabled=true 98 117 trailing=( 99 118 <button ··· 107 126 [%browser_only 108 127 (fun () -> 109 128 let module Aria = ReactAria in 129 + let submitEmailForm action fields = 130 + setEmailLoading (fun _ -> true) ; 131 + setEmailErrorState (fun _ -> None) ; 132 + let body = 133 + Fetch.BodyInit.make 134 + (Webapi.Url.URLSearchParams.makeWithArray 135 + (Array.append 136 + [|("dream.csrf", csrf_token); ("action", action)|] 137 + fields ) 138 + |> Webapi.Url.URLSearchParams.toString ) 139 + in 140 + let _ = 141 + Fetch.fetchWithInit "/account" 142 + (Fetch.RequestInit.make ~method_:Post ~body 143 + ~headers: 144 + (Fetch.HeadersInit.makeWithArray 145 + [|("Content-Type", "application/x-www-form-urlencoded")|] ) 146 + () ) 147 + |> Js.Promise.then_ (fun response -> 148 + setEmailLoading (fun _ -> false) ; 149 + if Fetch.Response.ok response then 150 + match action with 151 + | "request_email_change" -> 152 + let new_email = 153 + Array.find_opt 154 + (fun (k, _) -> k = "new_email") 155 + fields 156 + |> Option.map snd 157 + in 158 + setEmailPending (fun _ -> true) ; 159 + setEmailPendingAddress (fun _ -> new_email) ; 160 + Js.Promise.resolve () 161 + | "confirm_email_change" -> 162 + setEmailModalOpen (fun _ -> false) ; 163 + setEmailPending (fun _ -> false) ; 164 + setEmailPendingAddress (fun _ -> None) ; 165 + Webapi.Dom.( 166 + location |> Location.reload) ; 167 + Js.Promise.resolve () 168 + | "cancel_email_change" -> 169 + setEmailPending (fun _ -> false) ; 170 + setEmailPendingAddress (fun _ -> None) ; 171 + setEmailModalOpen (fun _ -> false) ; 172 + Js.Promise.resolve () 173 + | _ -> 174 + Js.Promise.resolve () 175 + else ( 176 + setEmailErrorState (fun _ -> 177 + Some "An error occurred. Please try again." ) ; 178 + Js.Promise.resolve () ) ) 179 + |> Js.Promise.catch (fun _ -> 180 + setEmailLoading (fun _ -> false) ; 181 + setEmailErrorState (fun _ -> 182 + Some "An error occurred. Please try again." ) ; 183 + Js.Promise.resolve () ) 184 + in 185 + () 186 + in 110 187 <Aria.DialogTrigger defaultOpen=email_change_pending> 111 188 <Input 112 189 name="email" 113 190 type_="email" 114 191 label="Email" 115 192 placeholder=email 116 - show_optional_indicator=false 193 + showIndicator=false 117 194 disabled=true 118 195 trailing=( 119 196 <Aria.Pressable> ··· 130 207 <Aria.ModalOverlay 131 208 className="fixed inset-0 z-50 bg-feather-100/80 \ 132 209 flex items-center justify-center" 133 - isDismissable=(not email_change_pending) 210 + isDismissable=(not emailPending) 134 211 isOpen=emailModalOpen 135 212 onOpenChange=(fun o -> setEmailModalOpen (fun _ -> o)) 136 213 > ··· 143 220 className="text-lg font-serif text-mana-200 mb-2"> 144 221 (string "change email") 145 222 </Aria.Heading> 146 - ( if email_change_pending then 147 - <form className="flex flex-col gap-y-3"> 148 - <input 149 - type_="hidden" 150 - name="dream.csrf" 151 - value=csrf_token 152 - /> 223 + ( if emailPending then 224 + <form 225 + className="flex flex-col gap-y-3" 226 + onSubmit=(fun e -> 227 + Event.Form.preventDefault e ; 228 + submitEmailForm "confirm_email_change" 229 + [|("token", emailTokenInput)|] )> 153 230 <p className="text-mist-100"> 154 231 (string 155 232 ( "Enter the verification code sent to " 156 - ^ Option.value pending_email 233 + ^ Option.value emailPendingAddress 157 234 ~default:"your new email" 158 235 ^ "." ) ) 159 236 </p> ··· 161 238 name="token" 162 239 label="Verification code" 163 240 placeholder="eml-..." 164 - show_optional_indicator=false 241 + required=true 242 + showIndicator=false 243 + value=emailTokenInput 244 + onChange=(fun e -> 245 + setEmailTokenInput (fun _ -> 246 + (Event.Form.target e)##value ) ) 165 247 /> 166 - ( match email_error with 248 + ( match emailErrorState with 167 249 | Some err -> 168 250 <span 169 251 className="inline-flex items-center \ ··· 175 257 null ) 176 258 <div className="flex flex-row gap-x-3 mt-2"> 177 259 <Button 178 - type_="submit" 179 - formMethod="post" 180 - name="action" 181 - value="confirm_email_change"> 182 - (string "verify") 260 + type_="submit" 261 + disabled=emailLoading> 262 + (string (if emailLoading then "verifying..." else "verify")) 183 263 </Button> 184 264 <Button 185 - kind=`Secondary 186 - type_="submit" 187 - formMethod="post" 188 - name="action" 189 - value="cancel_email_change"> 190 - (string "cancel") 265 + kind=`Secondary 266 + type_="button" 267 + disabled=emailLoading 268 + onClick=(fun _ -> 269 + submitEmailForm "cancel_email_change" [||])> 270 + (string "cancel") 191 271 </Button> 192 272 </div> 193 273 </form> 194 274 else 195 - <form className="flex flex-col gap-y-3"> 196 - <input 197 - type_="hidden" 198 - name="dream.csrf" 199 - value=csrf_token 200 - /> 275 + <form 276 + className="flex flex-col gap-y-3" 277 + onSubmit=(fun e -> 278 + Event.Form.preventDefault e ; 279 + submitEmailForm "request_email_change" 280 + [|("new_email", newEmailInput)|] )> 201 281 <Input 202 282 name="new_email" 203 283 label="New email address" 204 284 type_="email" 205 - show_optional_indicator=false 285 + required=true 286 + showIndicator=false 287 + value=newEmailInput 288 + onChange=(fun e -> 289 + setNewEmailInput (fun _ -> 290 + (Event.Form.target e)##value ) ) 206 291 /> 207 - ( match email_error with 292 + ( match emailErrorState with 208 293 | Some err -> 209 294 <span 210 295 className="inline-flex items-center \ ··· 216 301 null ) 217 302 <div className="flex flex-row gap-x-3 mt-2"> 218 303 <Button 219 - type_="submit" 220 - formMethod="post" 221 - name="action" 222 - value="request_email_change"> 223 - (string "send code") 304 + type_="submit" 305 + disabled=emailLoading> 306 + (string (if emailLoading then "sending..." else "send code")) 224 307 </Button> 225 308 <Button kind=`Tertiary 226 - className="text-mist-100 hover:text-mana-100" 227 - onClick=(fun _ -> setEmailModalOpen (fun _ -> false))> 228 - (string "cancel") 309 + className="text-mist-100 hover:text-mana-100" 310 + onClick=(fun _ -> setEmailModalOpen (fun _ -> false))> 311 + (string "cancel") 229 312 </Button> 230 313 </div> 231 314 </form> ) ··· 238 321 name="handle" 239 322 label="Handle" 240 323 placeholder=handle 241 - show_optional_indicator=false 324 + showIndicator=false 242 325 /> 243 326 <Input 244 327 name="password" ··· 246 329 label="Password" 247 330 placeholder="********" 248 331 autoComplete="current-password" 249 - show_optional_indicator=false 332 + showIndicator=false 250 333 /> 251 334 ( match error with 252 335 | Some error -> ··· 322 405 [%browser_only 323 406 (fun () -> 324 407 let module Aria = ReactAria in 408 + let submitDeleteForm action fields = 409 + setDeleteLoading (fun _ -> true) ; 410 + setDeleteErrorState (fun _ -> None) ; 411 + let body = 412 + Fetch.BodyInit.make 413 + (Webapi.Url.URLSearchParams.makeWithArray 414 + (Array.append 415 + [|("dream.csrf", csrf_token); ("action", action)|] 416 + fields ) 417 + |> Webapi.Url.URLSearchParams.toString ) 418 + in 419 + let _ = 420 + Fetch.fetchWithInit "/account" 421 + (Fetch.RequestInit.make ~method_:Post ~body 422 + ~headers: 423 + (Fetch.HeadersInit.makeWithArray 424 + [|("Content-Type", "application/x-www-form-urlencoded")|] ) 425 + () ) 426 + |> Js.Promise.then_ (fun response -> 427 + setDeleteLoading (fun _ -> false) ; 428 + if Fetch.Response.ok response then 429 + match action with 430 + | "request_delete" -> 431 + setDeletePendingState (fun _ -> true) ; 432 + Js.Promise.resolve () 433 + | "confirm_delete" -> 434 + Webapi.Dom.( 435 + Window.setLocation window "/account/login") ; 436 + Js.Promise.resolve () 437 + | "cancel_delete" -> 438 + setDeletePendingState (fun _ -> false) ; 439 + setDeleteModalOpen (fun _ -> false) ; 440 + Js.Promise.resolve () 441 + | _ -> 442 + Js.Promise.resolve () 443 + else ( 444 + setDeleteErrorState (fun _ -> 445 + Some "An error occurred. Please try again." ) ; 446 + Js.Promise.resolve () ) ) 447 + |> Js.Promise.catch (fun _ -> 448 + setDeleteLoading (fun _ -> false) ; 449 + setDeleteErrorState (fun _ -> 450 + Some "An error occurred. Please try again." ) ; 451 + Js.Promise.resolve () ) 452 + in 453 + () 454 + in 325 455 <Aria.DialogTrigger defaultOpen=delete_pending> 326 456 <Aria.Pressable> 327 457 <Button kind=`Danger className="flex-1" onClick=(fun _ -> setDeleteModalOpen (fun _ -> true))> ··· 331 461 <Aria.ModalOverlay 332 462 className="fixed inset-0 z-50 bg-feather-100/80 \ 333 463 flex items-center justify-center" 334 - isDismissable=(not delete_pending) 464 + isDismissable=(not deletePendingState) 335 465 isOpen=deleteModalOpen onOpenChange=(fun o -> setDeleteModalOpen (fun _ -> o))> 336 466 <Aria.Modal 337 467 className="bg-feather-100 border border-mist-40 rounded-xl \ ··· 342 472 className="text-lg font-serif text-mana-200 mb-2"> 343 473 (string "delete account") 344 474 </Aria.Heading> 345 - ( if delete_pending then 346 - <form className="flex flex-col gap-y-3"> 347 - <input 348 - type_="hidden" 349 - name="dream.csrf" 350 - value=csrf_token 351 - /> 475 + ( if deletePendingState then 476 + <form 477 + className="flex flex-col gap-y-3" 478 + onSubmit=(fun e -> 479 + Event.Form.preventDefault e ; 480 + submitDeleteForm "confirm_delete" 481 + [|("token", deleteTokenInput)|] )> 352 482 <p className="text-mist-100 text-sm"> 353 483 (string 354 484 "Enter the confirmation code sent to your email." ) ··· 357 487 name="token" 358 488 label="Confirmation code" 359 489 placeholder="del-..." 360 - show_optional_indicator=false 490 + required=true 491 + showIndicator=false 492 + value=deleteTokenInput 493 + onChange=(fun e -> 494 + setDeleteTokenInput (fun _ -> 495 + (Event.Form.target e)##value ) ) 361 496 /> 362 - ( match delete_error with 497 + ( match deleteErrorState with 363 498 | Some err -> 364 499 <span 365 500 className="inline-flex items-center \ ··· 371 506 null ) 372 507 <div className="flex flex-row gap-x-3 mt-2"> 373 508 <Button 374 - kind=`Danger 375 - type_="submit" 376 - formMethod="post" 377 - name="action" 378 - value="confirm_delete"> 379 - (string "confirm") 509 + kind=`Danger 510 + type_="submit" 511 + disabled=deleteLoading> 512 + (string (if deleteLoading then "deleting..." else "confirm")) 380 513 </Button> 381 514 <Button 382 - kind=`Secondary 383 - type_="submit" 384 - formMethod="post" 385 - name="action" 386 - value="cancel_delete"> 515 + kind=`Secondary 516 + type_="button" 517 + disabled=deleteLoading 518 + onClick=(fun _ -> 519 + submitDeleteForm "cancel_delete" [||])> 387 520 (string "cancel") 388 521 </Button> 389 522 </div> 390 523 </form> 391 524 else 392 - <form className="flex flex-col gap-y-3"> 393 - <input 394 - type_="hidden" 395 - name="dream.csrf" 396 - value=csrf_token 397 - /> 525 + <form 526 + className="flex flex-col gap-y-3" 527 + onSubmit=(fun e -> 528 + Event.Form.preventDefault e ; 529 + submitDeleteForm "request_delete" [||] )> 398 530 <p className="text-mist-100"> 399 531 (string 400 532 "This action is irreversible. A confirmation \ ··· 402 534 </p> 403 535 <div className="flex flex-row gap-x-3 mt-2"> 404 536 <Button 405 - kind=`Danger 406 - type_="submit" 407 - formMethod="post" 408 - name="action" 409 - value="request_delete"> 410 - (string "send code") 537 + kind=`Danger 538 + type_="submit" 539 + disabled=deleteLoading> 540 + (string (if deleteLoading then "sending..." else "send code")) 411 541 </Button> 412 542 <Button kind=`Tertiary 413 - onClick=(fun _ -> setDeleteModalOpen (fun _ -> false)) 414 - className="text-mist-100 hover:text-mana-100"> 415 - (string "cancel") 543 + onClick=(fun _ -> setDeleteModalOpen (fun _ -> false)) 544 + className="text-mist-100 hover:text-mana-100"> 545 + (string "cancel") 416 546 </Button> 417 547 </div> 418 548 </form> )