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.

Commit
d38fd4327431deef9a0fb3e1e41389d51656f37e
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
ARCHITECTURE.org
index 5c726c90..184c0303 100644..100644
@@ -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
index 17a0e05b..c6cd2a5e 100644..100644
@@ -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
index 8a0662e7..836e9d95 100644..100644
@@ -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
index 7dd2b912..88d72e2a 100644..100644
@@ -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
index 8be9a26a..835b7157 100644..100644
@@ -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 () ->