[OCaml] High Intensity Training Online
feat edit username and password in a modal
The profile page now shows the username value and a change button rather than a bare input. Each edit form moves into its own modal that the button opens. A generic dialog opener drives them: a button carrying data-hito-dialog-open names the dialog id, and a close control dismisses it. Both dialogs stay reachable without script and on a validation re-render: the server marks the dialog open when its form carries an error, so the message shows and the field keeps its value. The change still posts through POST-redirect-GET.
Changed files
ARCHITECTURE.org
@@ -426,6 +426,7 @@
426
426
| =data-hito-workout-*= | the same, for current-workout slots and forms |
427
427
| =data-hito-workout-status= | an =aria-live= region for announcements |
428
428
| =data-hito-toast= | a transient notice the client dismisses |
429
Added:
| =data-hito-dialog-open= | a button that opens the modal with that id |
429
430
430
431
Behaviour: the client fetches the same URL the link or form would have
431
432
used, extracts the marked region from the response, and swaps
lib/web/assets/hito.css
@@ -757,6 +757,54 @@
757
757
}
758
758
.profile-form { margin-top: 0.5rem; }
759
759
760
Added:
/* A profile value row: the field name, its current value, and the button that
761
Added:
opens the edit modal. */
762
Added:
.profile-value {
763
Added:
display: flex;
764
Added:
flex-wrap: wrap;
765
Added:
align-items: baseline;
766
Added:
gap: 0.5rem 1rem;
767
Added:
margin-top: 0.5rem;
768
Added:
}
769
Added:
.profile-value-label { color: var(--muted-ink); }
770
Added:
.profile-value-text {
771
Added:
font: 600 var(--font-size-lead)/1.2 var(--sans);
772
Added:
margin-right: auto;
773
Added:
}
774
Added:
.profile-edit {
775
Added:
border: 1px solid var(--rule-strong);
776
Added:
border-radius: var(--radius);
777
Added:
background: transparent;
778
Added:
color: var(--ink);
779
Added:
padding: 0.4rem 0.8rem;
780
Added:
cursor: pointer;
781
Added:
font: 600 var(--font-size-base)/1 var(--sans);
782
Added:
}
783
Added:
.profile-edit:hover { background: var(--brass-wash); }
784
Added:
785
Added:
/* The profile edit modal reuses the confirm modal's raised-paper look and
786
Added:
entrance animation, so username and password edits open like every other
787
Added:
modal. */
788
Added:
.profile-dialog {
789
Added:
width: min(100%, 26rem);
790
Added:
margin: auto;
791
Added:
border: 1px solid var(--rule-strong);
792
Added:
border-top: 6px solid var(--oxblood);
793
Added:
border-radius: var(--radius);
794
Added:
background: var(--paper-raised);
795
Added:
box-shadow: 0 0.6rem 2rem rgba(26, 26, 22, 0.25);
796
Added:
padding: clamp(1.25rem, 3vw, 1.75rem);
797
Added:
color: var(--ink);
798
Added:
}
799
Added:
.profile-dialog[open] { animation: modal-enter 180ms ease both; }
800
Added:
.profile-dialog::backdrop { background: rgba(26, 26, 22, 0.45); }
801
Added:
.profile-dialog h3 { margin-top: 0; }
802
Added:
.profile-dialog-actions {
803
Added:
display: flex;
804
Added:
gap: 0.75rem;
805
Added:
margin-top: 1rem;
806
Added:
}
807
Added:
760
808
.done {
761
809
color: var(--evergreen);
762
810
font: 700 0.85rem/1.4 var(--mono);
lib/web/pages.ml
@@ -1801,82 +1801,175 @@
1801
1801
([ tag "h1" [] [ txt "Profile" ] ]
1802
1802
@ notice_block
1803
1803
@ [
1804
Added:
(* Username: show the current value and a button that opens the edit
1805
Added:
modal. The modal holds the rename form. A no-script client, or an
1806
Added:
error re-render, opens the dialog through the [open] attribute so the
1807
Added:
form stays reachable and its inline error shows. *)
1804
1808
tag "section"
1805
1809
[ class_ "profile-section" ]
1806
Removed:
([
1807
Removed:
tag "h2" [] [ txt "Username" ];
1808
Removed:
tag "form"
1809
Removed:
[
1810
Removed:
action Routes.profile_username;
1811
Removed:
post_form;
1812
Removed:
class_ "profile-form";
1813
Removed:
Dream_html.attr "data-hito-app-form";
1810
Added:
[
1811
Added:
tag "h2" [] [ txt "Username" ];
1812
Added:
tag "div"
1813
Added:
[ class_ "profile-value" ]
1814
Added:
[
1815
Added:
tag "span" [ class_ "profile-value-label" ] [ txt "Username" ];
1816
Added:
tag "span"
1817
Added:
[ class_ "profile-value-text" ]
1818
Added:
[
1819
Added:
txt "%s"
1820
Added:
(Trainee.username_to_string trainee.Trainee.username);
1821
Added:
];
1822
Added:
tag "button"
1823
Added:
[
1824
Added:
type_ "button";
1825
Added:
class_ "profile-edit";
1826
Added:
Dream_html.string_attr "data-hito-dialog-open"
1827
Added:
"username-dialog";
1828
Added:
]
1829
Added:
[ txt "Change username" ];
1830
Added:
];
1831
Added:
tag "dialog"
1832
Added:
([
1833
Added:
Dream_html.string_attr "id" "username-dialog";
1834
Added:
class_ "profile-dialog";
1814
1835
]
1815
Removed:
[
1816
Removed:
Dream_html.csrf_tag request;
1817
Removed:
tag "div"
1818
Removed:
[ class_ "field" ]
1819
Removed:
[
1820
Removed:
tag "label"
1821
Removed:
[ Dream_html.string_attr "for" "username" ]
1822
Removed:
[ txt "Username" ];
1823
Removed:
void "input"
1836
Added:
@
1837
Added:
if Option.is_some username_error then [ Dream_html.attr "open" ]
1838
Added:
else [])
1839
Added:
[
1840
Added:
tag "div"
1841
Added:
[ class_ "profile-dialog-content" ]
1842
Added:
([
1843
Added:
tag "h3" [] [ txt "Change username" ];
1844
Added:
tag "form"
1824
1845
[
1825
Removed:
type_ "text";
1826
Removed:
name "username";
1827
Removed:
Dream_html.string_attr "id" "username";
1828
Removed:
Dream_html.string_attr "value" "%s"
1829
Removed:
(Trainee.username_to_string trainee.Trainee.username);
1830
Removed:
required;
1846
Added:
action Routes.profile_username;
1847
Added:
post_form;
1848
Added:
class_ "profile-form";
1849
Added:
Dream_html.attr "data-hito-app-form";
1850
Added:
]
1851
Added:
[
1852
Added:
Dream_html.csrf_tag request;
1853
Added:
tag "div"
1854
Added:
[ class_ "field" ]
1855
Added:
[
1856
Added:
tag "label"
1857
Added:
[ Dream_html.string_attr "for" "username" ]
1858
Added:
[ txt "Username" ];
1859
Added:
void "input"
1860
Added:
[
1861
Added:
type_ "text";
1862
Added:
name "username";
1863
Added:
Dream_html.string_attr "id" "username";
1864
Added:
Dream_html.string_attr "value" "%s"
1865
Added:
(Trainee.username_to_string
1866
Added:
trainee.Trainee.username);
1867
Added:
required;
1868
Added:
];
1869
Added:
];
1870
Added:
tag "div"
1871
Added:
[ class_ "profile-dialog-actions" ]
1872
Added:
[
1873
Added:
tag "button"
1874
Added:
[
1875
Added:
type_ "button";
1876
Added:
class_ "secondary";
1877
Added:
Dream_html.attr "data-hito-dialog-close";
1878
Added:
]
1879
Added:
[ txt "Cancel" ];
1880
Added:
void "input"
1881
Added:
[ type_ "submit"; value "Change username" ];
1882
Added:
];
1831
1883
];
1832
Removed:
];
1833
Removed:
void "input" [ type_ "submit"; value "Change username" ];
1834
Removed:
];
1835
Removed:
]
1836
Removed:
@ error_block username_error);
1884
Added:
]
1885
Added:
@ error_block username_error);
1886
Added:
];
1887
Added:
];
1888
Added:
(* Password: a button opens the change-password modal. *)
1837
1889
tag "section"
1838
1890
[ class_ "profile-section" ]
1839
Removed:
([
1840
Removed:
tag "h2" [] [ txt "Password" ];
1841
Removed:
tag "form"
1842
Removed:
[
1843
Removed:
action Routes.profile_password;
1844
Removed:
post_form;
1845
Removed:
class_ "profile-form";
1846
Removed:
Dream_html.attr "data-hito-app-form";
1891
Added:
[
1892
Added:
tag "h2" [] [ txt "Password" ];
1893
Added:
tag "div"
1894
Added:
[ class_ "profile-value" ]
1895
Added:
[
1896
Added:
tag "span" [ class_ "profile-value-label" ] [ txt "Password" ];
1897
Added:
tag "span" [ class_ "profile-value-text" ] [ txt "••••••••" ];
1898
Added:
tag "button"
1899
Added:
[
1900
Added:
type_ "button";
1901
Added:
class_ "profile-edit";
1902
Added:
Dream_html.string_attr "data-hito-dialog-open"
1903
Added:
"password-dialog";
1904
Added:
]
1905
Added:
[ txt "Change password" ];
1906
Added:
];
1907
Added:
tag "dialog"
1908
Added:
([
1909
Added:
Dream_html.string_attr "id" "password-dialog";
1910
Added:
class_ "profile-dialog";
1847
1911
]
1848
Removed:
[
1849
Removed:
Dream_html.csrf_tag request;
1850
Removed:
tag "div"
1851
Removed:
[ class_ "field" ]
1852
Removed:
[
1853
Removed:
tag "label"
1854
Removed:
[ Dream_html.string_attr "for" "current" ]
1855
Removed:
[ txt "Current password" ];
1856
Removed:
void "input"
1912
Added:
@
1913
Added:
if Option.is_some password_error then [ Dream_html.attr "open" ]
1914
Added:
else [])
1915
Added:
[
1916
Added:
tag "div"
1917
Added:
[ class_ "profile-dialog-content" ]
1918
Added:
([
1919
Added:
tag "h3" [] [ txt "Change password" ];
1920
Added:
tag "form"
1857
1921
[
1858
Removed:
type_ "password";
1859
Removed:
name "current";
1860
Removed:
Dream_html.string_attr "id" "current";
1861
Removed:
required;
1862
Removed:
];
1863
Removed:
];
1864
Removed:
tag "div"
1865
Removed:
[ class_ "field" ]
1866
Removed:
[
1867
Removed:
tag "label"
1868
Removed:
[ Dream_html.string_attr "for" "next" ]
1869
Removed:
[ txt "New password" ];
1870
Removed:
void "input"
1922
Added:
action Routes.profile_password;
1923
Added:
post_form;
1924
Added:
class_ "profile-form";
1925
Added:
Dream_html.attr "data-hito-app-form";
1926
Added:
]
1871
1927
[
1872
Removed:
type_ "password";
1873
Removed:
name "next";
1874
Removed:
Dream_html.string_attr "id" "next";
1875
Removed:
required;
1928
Added:
Dream_html.csrf_tag request;
1929
Added:
tag "div"
1930
Added:
[ class_ "field" ]
1931
Added:
[
1932
Added:
tag "label"
1933
Added:
[ Dream_html.string_attr "for" "current" ]
1934
Added:
[ txt "Current password" ];
1935
Added:
void "input"
1936
Added:
[
1937
Added:
type_ "password";
1938
Added:
name "current";
1939
Added:
Dream_html.string_attr "id" "current";
1940
Added:
required;
1941
Added:
];
1942
Added:
];
1943
Added:
tag "div"
1944
Added:
[ class_ "field" ]
1945
Added:
[
1946
Added:
tag "label"
1947
Added:
[ Dream_html.string_attr "for" "next" ]
1948
Added:
[ txt "New password" ];
1949
Added:
void "input"
1950
Added:
[
1951
Added:
type_ "password";
1952
Added:
name "next";
1953
Added:
Dream_html.string_attr "id" "next";
1954
Added:
required;
1955
Added:
];
1956
Added:
];
1957
Added:
tag "div"
1958
Added:
[ class_ "profile-dialog-actions" ]
1959
Added:
[
1960
Added:
tag "button"
1961
Added:
[
1962
Added:
type_ "button";
1963
Added:
class_ "secondary";
1964
Added:
Dream_html.attr "data-hito-dialog-close";
1965
Added:
]
1966
Added:
[ txt "Cancel" ];
1967
Added:
void "input"
1968
Added:
[ type_ "submit"; value "Change password" ];
1969
Added:
];
1876
1970
];
1877
Removed:
];
1878
Removed:
void "input" [ type_ "submit"; value "Change password" ];
1879
Removed:
];
1880
Removed:
]
1881
Removed:
@ error_block password_error);
1971
Added:
]
1972
Added:
@ error_block password_error);
1973
Added:
];
1974
Added:
];
1882
1975
])
lib/web/workout_client.ml
@@ -121,6 +121,41 @@
121
121
| None -> ()
122
122
| Some modal -> ignore (Js.Unsafe.meth_call modal "close" [||])
123
123
124
Added:
(* A generic modal keyed by id: [data-hito-dialog-open="id"] opens the dialog
125
Added:
with that id, and [data-hito-dialog-close] closes its nearest dialog. Used by
126
Added:
the profile page so username and password edits open in a modal, while a
127
Added:
no-script client still shows the same server-rendered form. *)
128
Added:
let open_dialog id =
129
Added:
match query_one document (Printf.sprintf "dialog#%s" id) with
130
Added:
| None -> ()
131
Added:
| Some modal ->
132
Added:
modal##removeAttribute (Js.string "open");
133
Added:
ignore (Js.Unsafe.meth_call modal "showModal" [||])
134
Added:
135
Added:
let dialog_click event =
136
Added:
let target = Dom_html.eventTarget event in
137
Added:
match closest "[data-hito-dialog-open]" target with
138
Added:
| Some trigger -> (
139
Added:
match
140
Added:
Js.Opt.to_option
141
Added:
(trigger##getAttribute (Js.string "data-hito-dialog-open"))
142
Added:
with
143
Added:
| None -> Js._true
144
Added:
| Some id ->
145
Added:
Dom.preventDefault event;
146
Added:
open_dialog (Js.to_string id);
147
Added:
Js._false)
148
Added:
| None -> (
149
Added:
match closest "[data-hito-dialog-close]" target with
150
Added:
| None -> Js._true
151
Added:
| Some close -> (
152
Added:
match closest "dialog" close with
153
Added:
| None -> Js._true
154
Added:
| Some modal ->
155
Added:
Dom.preventDefault event;
156
Added:
ignore (Js.Unsafe.meth_call modal "close" [||]);
157
Added:
Js._false))
158
Added:
124
159
let open_marked_feedback_modal () =
125
160
match feedback_modal () with
126
161
| Some modal when Js.to_bool (modal##hasAttribute (Js.string "open")) ->
@@ -410,6 +445,10 @@
410
445
|> ignore;
411
446
Dom_html.addEventListener document Dom_html.Event.click
412
447
(Dom_html.handler feedback_click)
448
Added:
Js._false
449
Added:
|> ignore;
450
Added:
Dom_html.addEventListener document Dom_html.Event.click
451
Added:
(Dom_html.handler dialog_click)
413
452
Js._false
414
453
|> ignore;
415
454
Dom_html.addEventListener document Dom_html.Event.change
test/test_web.ml
@@ -1724,7 +1724,22 @@
1724
1724
(contains ~substring:"value=\"lifter\"" page);
1725
1725
Alcotest.(check bool)
1726
1726
"asks for the current password" true
1727
Removed:
(contains ~substring:"name=\"current\"" page) );
1727
Added:
(contains ~substring:"name=\"current\"" page);
1728
Added:
Alcotest.(check bool)
1729
Added:
"shows the username value, not a bare field" true
1730
Added:
(contains ~substring:"profile-value-text" page);
1731
Added:
Alcotest.(check bool)
1732
Added:
"offers a change-username button that opens a dialog" true
1733
Added:
(contains ~substring:"data-hito-dialog-open=\"username-dialog\""
1734
Added:
page);
1735
Added:
Alcotest.(check bool)
1736
Added:
"offers a change-password button that opens a dialog" true
1737
Added:
(contains ~substring:"data-hito-dialog-open=\"password-dialog\""
1738
Added:
page);
1739
Added:
Alcotest.(check bool)
1740
Added:
"wraps each edit form in a dialog" true
1741
Added:
(contains ~substring:"<dialog id=\"username-dialog\"" page
1742
Added:
&& contains ~substring:"<dialog id=\"password-dialog\"" page) );
1728
1743
( "changing the username updates the profile and the navigation",
1729
1744
`Quick,
1730
1745
fun () ->
@@ -1766,7 +1781,11 @@
1766
1781
"renders an invalid response" 400 (status refused);
1767
1782
Alcotest.(check bool)
1768
1783
"explains the name is taken" true
1769
Removed:
(contains ~substring:"already registered" (body refused)) );
1784
Added:
(contains ~substring:"already registered" (body refused));
1785
Added:
Alcotest.(check bool)
1786
Added:
"opens the username dialog to show the error" true
1787
Added:
(contains ~substring:"class=\"profile-dialog\" open"
1788
Added:
(body refused)) );
1770
1789
( "changing the password requires the current one",
1771
1790
`Quick,
1772
1791
fun () ->