[OCaml] High Intensity Training Online
refactor Cross the app-feedback boundary with a view model
The Clean Architecture rule is that data crossing a boundary is a plain structure, never an entity or a database row. The app-feedback page took Repository.app_feedback and formatted a Recovery timestamp inside the presenter. Introduce lib/web/view_model.ml with an App_feedback DTO of plain fields, map the repository record to it in the controller (formatting the timestamp there), and have Pages render only the DTO. The presenter no longer names Repository.app_feedback or any timestamp entity. Other pages migrate the same way in follow-up changes.
Changed files
lib/web/dune
@@ -28,7 +28,14 @@
28
28
(library
29
29
(name hito_web)
30
30
(public_name hito.web)
31
Removed:
(modules routes decode pages handlers stylesheet workout_client_asset)
31
Added:
(modules
32
Added:
routes
33
Added:
decode
34
Added:
view_model
35
Added:
pages
36
Added:
handlers
37
Added:
stylesheet
38
Added:
workout_client_asset)
32
39
(preprocess
33
40
(pps dream-html.ppx))
34
41
(libraries hito.core hito.app dream dream-html lwt unix))
lib/web/handlers.ml
@@ -730,13 +730,35 @@
730
730
| Some "submitted" -> `Submitted
731
731
| _ -> `Write
732
732
733
Added:
let time_to_string timestamp =
734
Added:
let tm =
735
Added:
Unix.gmtime
736
Added:
(float_of_int (Recovery.timestamp_to_unix_seconds timestamp))
737
Added:
in
738
Added:
Printf.sprintf "%04d-%02d-%02d %02d:%02d" (tm.Unix.tm_year + 1900)
739
Added:
(tm.Unix.tm_mon + 1) tm.Unix.tm_mday tm.Unix.tm_hour tm.Unix.tm_min
740
Added:
741
Added:
(* Map a repository record into the view model the presenter renders, so no
742
Added:
entity or record crosses the boundary into Pages. *)
743
Added:
let view_of (report : Repository.app_feedback) : View_model.App_feedback.t =
744
Added:
{
745
Added:
id = Repository.app_feedback_id_to_string report.Repository.feedback_id;
746
Added:
author = report.Repository.author;
747
Added:
contributions = report.Repository.contributions;
748
Added:
submitted_at = time_to_string report.Repository.submitted_at;
749
Added:
message = report.Repository.message;
750
Added:
upvotes = report.Repository.upvotes;
751
Added:
viewer_upvoted = report.Repository.viewer_upvoted;
752
Added:
viewer_owns = report.Repository.viewer_owns;
753
Added:
}
754
Added:
733
755
let show t trainee request =
734
756
Service.in_progress t.service trainee.Trainee.id >>= fun in_progress ->
735
757
Service.app_feedback t.service trainee.Trainee.id >>= fun reports ->
736
758
html
737
759
(Pages.app_feedback request
738
760
~logging:(Option.is_some in_progress)
739
Removed:
~trainee ~tab:(tab request) reports)
761
Added:
~trainee ~tab:(tab request) (List.map view_of reports))
740
762
741
763
let submit t trainee request =
742
764
decode_form Decode.app_feedback request >>= function
lib/web/pages.ml
@@ -1580,13 +1580,6 @@
1580
1580
(* The authenticated application feedback page. It keeps writing and reviewing
1581
1581
feedback in separate tabs, while both tabs remain plain links for no-script
1582
1582
use and progressive enhancement. *)
1583
Removed:
let app_feedback_time timestamp =
1584
Removed:
let tm =
1585
Removed:
Unix.gmtime (float_of_int (Recovery.timestamp_to_unix_seconds timestamp))
1586
Removed:
in
1587
Removed:
Printf.sprintf "%04d-%02d-%02d %02d:%02d" (tm.Unix.tm_year + 1900)
1588
Removed:
(tm.Unix.tm_mon + 1) tm.Unix.tm_mday tm.Unix.tm_hour tm.Unix.tm_min
1589
Removed:
1590
1583
let app_feedback_tab ~selected ~tab ~label =
1591
1584
let path =
1592
1585
match tab with
@@ -1641,26 +1634,24 @@
1641
1634
:: error_block
1642
1635
@ [ void "input" [ type_ "submit"; value "Submit feedback" ] ])
1643
1636
1644
Removed:
let app_feedback_vote_form request report =
1645
Removed:
if report.Repository.viewer_owns then
1637
Added:
let app_feedback_vote_form request (report : View_model.App_feedback.t) =
1638
Added:
if report.viewer_owns then
1646
1639
tag "p" [ class_ "app-feedback-own" ] [ txt "Your feedback" ]
1647
1640
else
1648
1641
let label =
1649
Removed:
if report.Repository.viewer_upvoted then "Upvoted"
1650
Removed:
else Printf.sprintf "Upvote (%d)" report.Repository.upvotes
1642
Added:
if report.viewer_upvoted then "Upvoted"
1643
Added:
else Printf.sprintf "Upvote (%d)" report.upvotes
1651
1644
in
1652
1645
let attrs =
1653
1646
[
1654
Removed:
action Routes.upvote_app_feedback
1655
Removed:
(Repository.app_feedback_id_to_string report.Repository.feedback_id);
1647
Added:
action Routes.upvote_app_feedback report.id;
1656
1648
post_form;
1657
1649
class_ "app-feedback-vote";
1658
1650
Dream_html.attr "data-hito-app-form";
1659
1651
]
1660
1652
in
1661
1653
let attrs =
1662
Removed:
if report.Repository.viewer_upvoted then
1663
Removed:
Dream_html.attr "disabled" :: attrs
1654
Added:
if report.viewer_upvoted then Dream_html.attr "disabled" :: attrs
1664
1655
else attrs
1665
1656
in
1666
1657
tag "form" attrs
@@ -1670,11 +1661,10 @@
1670
1661
[ type_ "submit"; Dream_html.string_attr "value" "%s" label ];
1671
1662
]
1672
1663
1673
Removed:
let app_feedback_edit_form request report =
1664
Added:
let app_feedback_edit_form request (report : View_model.App_feedback.t) =
1674
1665
tag "form"
1675
1666
[
1676
Removed:
action Routes.edit_app_feedback
1677
Removed:
(Repository.app_feedback_id_to_string report.Repository.feedback_id);
1667
Added:
action Routes.edit_app_feedback report.id;
1678
1668
post_form;
1679
1669
class_ "app-feedback-edit-form";
1680
1670
Dream_html.attr "data-hito-app-form";
@@ -1682,28 +1672,23 @@
1682
1672
[
1683
1673
Dream_html.csrf_tag request;
1684
1674
tag "label"
1685
Removed:
[
1686
Removed:
Dream_html.string_attr "for" "app-feedback-edit-%s"
1687
Removed:
(Repository.app_feedback_id_to_string report.Repository.feedback_id);
1688
Removed:
]
1675
Added:
[ Dream_html.string_attr "for" "app-feedback-edit-%s" report.id ]
1689
1676
[ txt "Edit feedback" ];
1690
1677
tag "textarea"
1691
1678
[
1692
1679
name "message";
1693
Removed:
Dream_html.string_attr "id" "app-feedback-edit-%s"
1694
Removed:
(Repository.app_feedback_id_to_string report.Repository.feedback_id);
1680
Added:
Dream_html.string_attr "id" "app-feedback-edit-%s" report.id;
1695
1681
Dream_html.string_attr "rows" "4";
1696
1682
required;
1697
1683
]
1698
Removed:
[ txt "%s" report.Repository.message ];
1684
Added:
[ txt "%s" report.message ];
1699
1685
void "input" [ type_ "submit"; value "Save edit" ];
1700
1686
]
1701
1687
1702
Removed:
let app_feedback_remove_form request report =
1688
Added:
let app_feedback_remove_form request (report : View_model.App_feedback.t) =
1703
1689
tag "form"
1704
1690
[
1705
Removed:
action Routes.remove_app_feedback
1706
Removed:
(Repository.app_feedback_id_to_string report.Repository.feedback_id);
1691
Added:
action Routes.remove_app_feedback report.id;
1707
1692
post_form;
1708
1693
class_ "app-feedback-remove-form";
1709
1694
Dream_html.attr "data-hito-app-form";
@@ -1713,8 +1698,8 @@
1713
1698
void "input" [ type_ "submit"; value "Remove feedback" ];
1714
1699
]
1715
1700
1716
Removed:
let app_feedback_actions request report =
1717
Removed:
if report.Repository.viewer_owns then
1701
Added:
let app_feedback_actions request (report : View_model.App_feedback.t) =
1702
Added:
if report.viewer_owns then
1718
1703
tag "div"
1719
1704
[ class_ "app-feedback-actions" ]
1720
1705
[
@@ -1747,7 +1732,7 @@
1747
1732
tag "ul"
1748
1733
[ class_ "app-feedback-list" ]
1749
1734
(List.map
1750
Removed:
(fun (report : Repository.app_feedback) ->
1735
Added:
(fun (report : View_model.App_feedback.t) ->
1751
1736
tag "li" []
1752
1737
[
1753
1738
tag "p"
@@ -1760,9 +1745,9 @@
1760
1745
[
1761
1746
class_ "app-feedback-time";
1762
1747
Dream_html.string_attr "datetime" "%s"
1763
Removed:
(app_feedback_time report.submitted_at);
1748
Added:
report.submitted_at;
1764
1749
]
1765
Removed:
[ txt "%s" (app_feedback_time report.submitted_at) ];
1750
Added:
[ txt "%s" report.submitted_at ];
1766
1751
tag "p"
1767
1752
[ class_ "app-feedback-message" ]
1768
1753
[ txt "%s" report.message ];
lib/web/pages.mli
@@ -104,7 +104,7 @@
104
104
trainee:Trainee.t ->
105
105
?tab:[ `Write | `Submitted ] ->
106
106
?error:string ->
107
Removed:
Repository.app_feedback list ->
107
Added:
View_model.App_feedback.t list ->
108
108
page
109
109
(** The authenticated application feedback page. One tab accepts a freeform
110
110
message; the other lists the trainee's submitted messages. *)
lib/web/view_model.ml
@@ -0,0 +1,25 @@
1
Added:
(** View models: the simple data structures the presenter renders.
2
Added:
3
Added:
The Clean Architecture rule for crossing a boundary is that the data passed
4
Added:
is a plain structure, never an entity or a database row. These DTOs are that
5
Added:
structure. A handler (the controller) maps a repository record or a core
6
Added:
entity into a view model, and the presenter ({!Pages}) renders the view
7
Added:
model without ever naming an inner-circle type.
8
Added:
9
Added:
Migrated so far: application feedback. Other pages still take entities
10
Added:
directly; they move here page by page. *)
11
Added:
12
Added:
module App_feedback = struct
13
Added:
type t = {
14
Added:
id : string; (** Opaque feedback identity, already stringified. *)
15
Added:
author : string;
16
Added:
contributions : int;
17
Added:
submitted_at : string; (** Pre-formatted "YYYY-MM-DD HH:MM" in UTC. *)
18
Added:
message : string;
19
Added:
upvotes : int;
20
Added:
viewer_upvoted : bool;
21
Added:
viewer_owns : bool;
22
Added:
}
23
Added:
(** One application-feedback entry, ready to render. Carries no timestamp
24
Added:
type, no opaque id, no repository record — only what the view shows. *)
25
Added:
end