From 0402b5517e56b3dec8b2f70c4dc3fcff6feea9f6 Mon Sep 17 00:00:00 2001 From: Nicolas Perriault Date: Thu, 17 Oct 2024 14:56:28 +0200 Subject: [PATCH] feat: add object explorer pages. (#803) Add explorer pages for browsing available examples and processes. --- .env.sample | 1 + README.md | 1 + index.js | 1 + src/Data/Dataset.elm | 54 ++++++++++++- src/Data/Object/Process.elm | 9 ++- src/Data/Session.elm | 1 + src/Main.elm | 2 + src/Page/Explore.elm | 114 ++++++++++++++++++++++++--- src/Page/Explore/ObjectExamples.elm | 52 ++++++++++++ src/Page/Explore/ObjectProcesses.elm | 55 +++++++++++++ src/Page/Object.elm | 61 ++++++++------ src/Views/Format.elm | 14 +++- src/Views/Page.elm | 7 +- 13 files changed, 331 insertions(+), 41 deletions(-) create mode 100644 src/Page/Explore/ObjectExamples.elm create mode 100644 src/Page/Explore/ObjectProcesses.elm diff --git a/.env.sample b/.env.sample index da250d0cb..8a983ce25 100644 --- a/.env.sample +++ b/.env.sample @@ -8,6 +8,7 @@ ENCRYPTION_KEY=please-change-this-with-32-chars EMAIL_HOST_PASSWORD=please-change-this EMAIL_HOST_USER=please-change-this ENABLE_FOOD_SECTION=True +ENABLE_OBJECT_SECTION=True MATOMO_HOST=stats.beta.gouv.fr MATOMO_SITE_ID=57 MATOMO_TOKEN=xxx diff --git a/README.md b/README.md index c6f6a127c..41589fef4 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Les variables d'environnement suivantes doivent être définies : - `EMAIL_HOST_USER`: l'utilisateur du compte SMTP - `EMAIL_HOST_PASSWORD` : le mot de passe du compte SMTP pour envoyer les mail liés à l'authentification - `ENABLE_FOOD_SECTION` : affichage ou non de la section expérimentale dédiée à l'alimentaire (valeur `True` ou `False`, par défault `False`) +- `ENABLE_OBJECT_SECTION` : affichage ou non de la section expérimentale dédiée aux objets génériques (valeur `True` ou `False`, par défault `False`) - `MATOMO_HOST`: le domaine de l'instance Matomo permettant le suivi d'audience du produit (typiquement `stats.beta.gouv.fr`). - `MATOMO_SITE_ID`: l'identifiant du site Ecobalyse sur l'instance Matomo permettant le suivi d'audience du produit. - `MATOMO_TOKEN`: le token Matomo permettant le suivi d'audience du produit. diff --git a/index.js b/index.js index 274c6e86f..317e74650 100644 --- a/index.js +++ b/index.js @@ -38,6 +38,7 @@ const app = Elm.Main.init({ flags: { clientUrl: location.origin + location.pathname, enableFoodSection: process.env.ENABLE_FOOD_SECTION === "True", + enableObjectSection: process.env.ENABLE_OBJECT_SECTION === "True", rawStore: localStorage[storeKey] || "null", matomo: { host: process.env.MATOMO_HOST || "", diff --git a/src/Data/Dataset.elm b/src/Data/Dataset.elm index b92690e01..969f4861c 100644 --- a/src/Data/Dataset.elm +++ b/src/Data/Dataset.elm @@ -14,6 +14,7 @@ import Data.Country as Country import Data.Food.Ingredient as Ingredient import Data.Food.Process as FoodProcess import Data.Impact.Definition as Definition +import Data.Object.Process as ObjectProcess import Data.Scope as Scope exposing (Scope) import Data.Textile.Material as Material import Data.Textile.Process as Process @@ -33,6 +34,8 @@ type Dataset | FoodIngredients (Maybe Ingredient.Id) | FoodProcesses (Maybe FoodProcess.Identifier) | Impacts (Maybe Definition.Trigram) + | ObjectExamples (Maybe Uuid) + | ObjectProcesses (Maybe ObjectProcess.Id) | TextileExamples (Maybe Uuid) | TextileMaterials (Maybe Material.Id) | TextileProcesses (Maybe Process.Uuid) @@ -52,7 +55,8 @@ datasets scope = Scope.Object -> [ Impacts Nothing - , Countries Nothing + , ObjectExamples Nothing + , ObjectProcesses Nothing ] Scope.Textile -> @@ -86,6 +90,12 @@ fromSlug string = "materials" -> TextileMaterials Nothing + "object-examples" -> + ObjectExamples Nothing + + "object-processes" -> + ObjectProcesses Nothing + "processes" -> TextileProcesses Nothing @@ -114,6 +124,12 @@ isDetailed dataset = Impacts (Just _) -> True + ObjectExamples (Just _) -> + True + + ObjectProcesses (Just _) -> + True + TextileExamples (Just _) -> True @@ -158,6 +174,12 @@ reset dataset = Impacts _ -> Impacts Nothing + ObjectExamples _ -> + ObjectExamples Nothing + + ObjectProcesses _ -> + ObjectProcesses Nothing + TextileExamples _ -> TextileExamples Nothing @@ -192,6 +214,12 @@ same a b = ( TextileExamples _, TextileExamples _ ) -> True + ( ObjectExamples _, ObjectExamples _ ) -> + True + + ( ObjectProcesses _, ObjectProcesses _ ) -> + True + ( TextileMaterials _, TextileMaterials _ ) -> True @@ -223,6 +251,12 @@ setIdFromString idString dataset = Impacts _ -> Impacts (Definition.toTrigram idString |> Result.toMaybe) + ObjectExamples _ -> + ObjectExamples (Uuid.fromString idString) + + ObjectProcesses _ -> + ObjectProcesses (ObjectProcess.idFromString idString) + TextileExamples _ -> TextileExamples (Uuid.fromString idString) @@ -259,6 +293,12 @@ strings dataset = Impacts _ -> { label = "Impacts", slug = "impacts" } + ObjectExamples _ -> + { label = "Exemples", slug = "object-examples" } + + ObjectProcesses _ -> + { label = "Procédés", slug = "object-processes" } + TextileExamples _ -> { label = "Exemples", slug = "textile-examples" } @@ -305,6 +345,18 @@ toRoutePath dataset = Impacts Nothing -> [ slug dataset ] + ObjectExamples (Just id) -> + [ slug dataset, Uuid.toString id ] + + ObjectExamples Nothing -> + [ slug dataset ] + + ObjectProcesses (Just id) -> + [ slug dataset, ObjectProcess.idToString id ] + + ObjectProcesses Nothing -> + [ slug dataset ] + TextileExamples (Just id) -> [ slug dataset, Uuid.toString id ] diff --git a/src/Data/Object/Process.elm b/src/Data/Object/Process.elm index b00291a56..3938ebf0c 100644 --- a/src/Data/Object/Process.elm +++ b/src/Data/Object/Process.elm @@ -1,11 +1,13 @@ module Data.Object.Process exposing - ( Id + ( Id(..) , Process , decodeId , decodeList , encode , encodeId , findById + , idFromString + , idToString ) import Data.Impact as Impact exposing (Impacts) @@ -81,6 +83,11 @@ findById processes id = |> Result.fromMaybe ("Procédé introuvable par id : " ++ idToString id) +idFromString : String -> Maybe Id +idFromString = + Uuid.fromString >> Maybe.map Id + + idToString : Id -> String idToString (Id uuid) = Uuid.toString uuid diff --git a/src/Data/Session.elm b/src/Data/Session.elm index 23ff7db57..c3f6129ae 100644 --- a/src/Data/Session.elm +++ b/src/Data/Session.elm @@ -52,6 +52,7 @@ type alias Session = , currentVersion : Version , db : Db , enableFoodSection : Bool + , enableObjectSection : Bool , matomo : { host : String, siteId : String } , navKey : Nav.Key , notifications : List Notification diff --git a/src/Main.elm b/src/Main.elm index 280ab6cc2..ef08161ad 100644 --- a/src/Main.elm +++ b/src/Main.elm @@ -37,6 +37,7 @@ import Views.Page as Page type alias Flags = { clientUrl : String , enableFoodSection : Bool + , enableObjectSection : Bool , matomo : { host : String, siteId : String } , rawStore : String } @@ -145,6 +146,7 @@ setupSession navKey flags db = , currentVersion = Request.Version.Unknown , db = db , enableFoodSection = flags.enableFoodSection + , enableObjectSection = flags.enableObjectSection , matomo = flags.matomo , navKey = navKey , notifications = [] diff --git a/src/Page/Explore.elm b/src/Page/Explore.elm index c7b8b1558..c6303a732 100644 --- a/src/Page/Explore.elm +++ b/src/Page/Explore.elm @@ -22,6 +22,9 @@ import Data.Food.Recipe as Recipe import Data.Impact as Impact import Data.Impact.Definition as Definition exposing (Definition, Definitions) import Data.Key as Key +import Data.Object.Process as ObjectProcess +import Data.Object.Query as ObjectQuery +import Data.Object.Simulator as ObjectSimulator import Data.Scope as Scope exposing (Scope) import Data.Session exposing (Session) import Data.Textile.Material as Material exposing (Material) @@ -39,6 +42,8 @@ import Page.Explore.FoodExamples as FoodExamples import Page.Explore.FoodIngredients as FoodIngredients import Page.Explore.FoodProcesses as FoodProcesses import Page.Explore.Impacts as ExploreImpacts +import Page.Explore.ObjectExamples as ObjectExamples +import Page.Explore.ObjectProcesses as ObjectProcesses import Page.Explore.Table as Table import Page.Explore.TextileExamples as TextileExamples import Page.Explore.TextileMaterials as TextileMaterials @@ -87,6 +92,12 @@ init scope dataset session = Dataset.Impacts _ -> "Code" + Dataset.ObjectExamples _ -> + "Coût Environnemental" + + Dataset.ObjectProcesses _ -> + "Identifiant" + Dataset.TextileExamples _ -> "Coût Environnemental" @@ -148,9 +159,8 @@ update session msg model = Scope.Food -> Dataset.FoodExamples Nothing - -- FIXME: object examples explorer page Scope.Object -> - Dataset.TextileExamples Nothing + Dataset.ObjectExamples Nothing Scope.Textile -> Dataset.TextileExamples Nothing @@ -185,10 +195,14 @@ datasetsMenuView { scope, dataset } = scopesMenuView : Session -> Model -> Html Msg -scopesMenuView { enableFoodSection } model = - [ Scope.Food, Scope.Textile ] +scopesMenuView { enableFoodSection, enableObjectSection } model = + [ ( Scope.Food, enableFoodSection ) + , ( Scope.Object, enableObjectSection ) + , ( Scope.Textile, True ) + ] + |> List.filter Tuple.second |> List.map - (\scope -> + (\( scope, _ ) -> label [] [ input [ class "form-check-input ms-1 ms-sm-3 me-1" @@ -202,13 +216,7 @@ scopesMenuView { enableFoodSection } model = ] ) |> (::) (strong [ class "d-block d-sm-inline" ] [ text "Secteur d'activité" ]) - |> nav - (if enableFoodSection then - [] - - else - [ class "d-none" ] - ) + |> nav [] detailsModal : Html Msg -> Html Msg @@ -401,6 +409,74 @@ foodProcessesExplorer { food } tableConfig tableState maybeId = ] +objectExamplesExplorer : + Db + -> Table.Config ( Example ObjectQuery.Query, { score : Float } ) Msg + -> SortableTable.State + -> Maybe Uuid + -> List (Html Msg) +objectExamplesExplorer db tableConfig tableState maybeId = + let + scoredExamples = + db.object.examples + |> List.map (\example -> ( example, { score = getObjectScore db example } )) + |> List.sortBy (Tuple.first >> .name) + + max = + { maxScore = + scoredExamples + |> List.map (Tuple.second >> .score) + |> List.maximum + |> Maybe.withDefault 0 + } + in + [ scoredExamples + |> List.filter (Tuple.first >> .query >> (/=) ObjectQuery.default) + |> List.sortBy (Tuple.first >> .name) + |> Table.viewList OpenDetail tableConfig tableState Scope.Object (ObjectExamples.table max) + , case maybeId of + Just id -> + detailsModal + (case Example.findByUuid id db.object.examples of + Err error -> + alert error + + Ok example -> + ( example, { score = getObjectScore db example } ) + |> Table.viewDetails Scope.Object (ObjectExamples.table max) + ) + + Nothing -> + text "" + ] + + +objectProcessesExplorer : + Db + -> Table.Config ObjectProcess.Process Msg + -> SortableTable.State + -> Maybe ObjectProcess.Id + -> List (Html Msg) +objectProcessesExplorer { object } tableConfig tableState maybeId = + [ object.processes + |> Table.viewList OpenDetail tableConfig tableState Scope.Object ObjectProcesses.table + , case maybeId of + Just id -> + detailsModal + (case ObjectProcess.findById object.processes id of + Err error -> + alert error + + Ok process -> + process + |> Table.viewDetails Scope.Object ObjectProcesses.table + ) + + Nothing -> + text "" + ] + + textileExamplesExplorer : Db -> Table.Config ( Example TextileQuery.Query, { score : Float, per100g : Float } ) Msg @@ -566,6 +642,14 @@ getFoodScorePer100g db = >> Result.withDefault 0 +getObjectScore : Db -> Example ObjectQuery.Query -> Float +getObjectScore db = + .query + >> ObjectSimulator.compute db + >> Result.map (ObjectSimulator.extractImpacts >> Impact.getImpact Definition.Ecs >> Unit.impactToFloat) + >> Result.withDefault 0 + + getTextileScore : Db -> Example TextileQuery.Query -> Float getTextileScore db = .query @@ -619,6 +703,12 @@ explore { db } { scope, dataset, tableState } = Dataset.Impacts maybeTrigram -> impactsExplorer db.definitions tableConfig tableState scope maybeTrigram + Dataset.ObjectExamples maybeId -> + objectExamplesExplorer db tableConfig tableState maybeId + + Dataset.ObjectProcesses maybeId -> + objectProcessesExplorer db tableConfig tableState maybeId + Dataset.TextileExamples maybeId -> textileExamplesExplorer db tableConfig tableState maybeId diff --git a/src/Page/Explore/ObjectExamples.elm b/src/Page/Explore/ObjectExamples.elm new file mode 100644 index 000000000..08b183d37 --- /dev/null +++ b/src/Page/Explore/ObjectExamples.elm @@ -0,0 +1,52 @@ +module Page.Explore.ObjectExamples exposing (table) + +import Data.Dataset as Dataset +import Data.Example exposing (Example) +import Data.Object.Query exposing (Query) +import Data.Scope exposing (Scope) +import Data.Uuid as Uuid +import Html exposing (..) +import Html.Attributes exposing (..) +import Page.Explore.Common as Common +import Page.Explore.Table as Table exposing (Table) +import Route +import Views.Icon as Icon + + +table : + { maxScore : Float } + -> { detailed : Bool, scope : Scope } + -> Table ( Example Query, { score : Float } ) String msg +table { maxScore } { detailed, scope } = + { filename = "examples" + , toId = Tuple.first >> .id >> Uuid.toString + , toRoute = Tuple.first >> .id >> Just >> Dataset.ObjectExamples >> Route.Explore scope + , legend = [] + , columns = + [ { label = "Nom" + , toValue = Table.StringValue (Tuple.first >> .name) + , toCell = Tuple.first >> .name >> text + } + , { label = "Catégorie" + , toValue = Table.StringValue (Tuple.first >> .category) + , toCell = Tuple.first >> .category >> text + } + , { label = "Coût Environnemental" + , toValue = Table.FloatValue (Tuple.second >> .score) + , toCell = + \( _, { score } ) -> + Common.impactBarGraph detailed maxScore score + } + , { label = "" + , toValue = Table.NoValue + , toCell = + \( { id, name }, _ ) -> + a + [ class "btn btn-light btn-sm w-100" + , Route.href <| Route.ObjectSimulatorExample id + , title <| "Charger " ++ name + ] + [ Icon.search ] + } + ] + } diff --git a/src/Page/Explore/ObjectProcesses.elm b/src/Page/Explore/ObjectProcesses.elm new file mode 100644 index 000000000..6f1275ed5 --- /dev/null +++ b/src/Page/Explore/ObjectProcesses.elm @@ -0,0 +1,55 @@ +module Page.Explore.ObjectProcesses exposing (table) + +import Data.Dataset as Dataset +import Data.Object.Process as ObjectProcess +import Data.Scope exposing (Scope) +import Html exposing (..) +import Page.Explore.Table as Table exposing (Table) +import Route +import Views.Format as Format + + +table : { detailed : Bool, scope : Scope } -> Table ObjectProcess.Process String msg +table { detailed, scope } = + { filename = "processes" + , toId = .id >> ObjectProcess.idToString + , toRoute = .id >> Just >> Dataset.ObjectProcesses >> Route.Explore scope + , legend = [] + , columns = + [ { label = "Identifiant" + , toValue = Table.StringValue <| .id >> ObjectProcess.idToString + , toCell = + \process -> + if detailed then + code [] [ text (ObjectProcess.idToString process.id) ] + + else + a [ Route.href (Route.Explore scope (Dataset.ObjectProcesses (Just process.id))) ] + [ code [] [ text (ObjectProcess.idToString process.id) ] ] + } + , { label = "Nom" + , toValue = Table.StringValue .displayName + , toCell = .displayName >> text + } + , { label = "Nom technique" + , toValue = Table.StringValue .name + , toCell = .name >> text + } + , { label = "Source" + , toValue = Table.StringValue .source + , toCell = .source >> text + } + , { label = "Unité" + , toValue = Table.StringValue .unit + , toCell = .unit >> text + } + , { label = "Densité" + , toValue = Table.FloatValue .density + , toCell = Format.density + } + , { label = "Commentaire" + , toValue = Table.StringValue .comment + , toCell = .comment >> text + } + ] + } diff --git a/src/Page/Object.elm b/src/Page/Object.elm index a7142fbad..1a68f6b81 100644 --- a/src/Page/Object.elm +++ b/src/Page/Object.elm @@ -40,6 +40,7 @@ import Views.Example as ExampleView import Views.Format as Format import Views.Icon as Icon import Views.ImpactTabs as ImpactTabs +import Views.Link as Link import Views.Modal as ModalView import Views.Sidebar as SidebarView @@ -384,11 +385,9 @@ simulatorView session model = } } ] - , div [ class "card shadow-sm mb-3" ] - [ session.queries.object - |> itemListView session.db model.impact model.results - |> div [ class "d-flex flex-column bg-white" ] - ] + , session.queries.object + |> itemListView session.db model.impact model.results + |> div [ class "card shadow-sm mb-3" ] ] , div [ class "col-lg-4 bg-white" ] [ SidebarView.view @@ -455,7 +454,16 @@ addItemButton db query = itemListView : Db -> Definition -> Results -> Query -> List (Html Msg) itemListView db selectedImpact results query = [ div [ class "card-header d-flex align-items-center justify-content-between" ] - [ h2 [ class "h5 mb-0" ] [ text "Éléments" ] ] + [ h2 [ class "h5 mb-0" ] + [ text "Éléments" + , Link.smallPillExternal + [ Route.href (Route.Explore Scope.Object (Dataset.ObjectProcesses Nothing)) + , title "Explorer" + , attribute "aria-label" "Explorer" + ] + [ Icon.search ] + ] + ] , if List.isEmpty query.items then div [ class "card-body" ] [ text "Aucun élément." ] @@ -474,12 +482,12 @@ itemListView db selectedImpact results query = [ table [ class "table mb-0" ] [ thead [] [ tr [ class "fs-7 text-muted" ] - [ th [] [ text "Quantité" ] - , th [] [ text "Procédé" ] - , th [] [ text "Densité" ] - , th [] [ text "Masse" ] - , th [] [ text "Impact" ] - , th [] [] + [ th [ class "ps-3", scope "col" ] [ text "Quantité" ] + , th [ scope "col" ] [ text "Procédé" ] + , th [ scope "col" ] [ text "Densité" ] + , th [ scope "col" ] [ text "Masse" ] + , th [ scope "col" ] [ text "Impact" ] + , th [ scope "col" ] [] ] ] , Simulator.extractItems results @@ -494,7 +502,7 @@ itemListView db selectedImpact results query = itemView : Definition -> ( Query.Amount, Process ) -> Results -> Html Msg itemView selectedImpact ( amount, process ) itemResults = tr [] - [ td [class "align-middle"] + [ td [ class "ps-3 align-middle" ] [ div [ class "input-group", style "min-width" "180px" ] [ input [ type_ "number" @@ -514,31 +522,36 @@ itemView selectedImpact ( amount, process ) itemResults = \str -> case String.toFloat str of Just float -> - UpdateItem { amount = Query.amount float, processId = process.id } + UpdateItem + { amount = Query.amount float + , processId = process.id + } Nothing -> NoOp ] [] - , span [ class "input-group-text justify-content-center fs-8", style "width" "38px" ] + , span + [ class "input-group-text justify-content-center fs-8" + , style "width" "38px" + ] [ text process.unit ] ] ] , td [ class "align-middle text-truncate w-100" ] [ text process.displayName ] , td [ class "align-middle text-end" ] - [ if process.unit /= "kg" then - process.density |> Format.formatRichFloat 0 ("kg/" ++ process.unit) - - else - text "" - ] + [ Format.density process ] , td [ class "text-end align-middle text-nowrap" ] [ Format.kg <| Simulator.extractMass itemResults ] , td [ class "text-end align-middle text-nowrap" ] - [ Format.formatImpact selectedImpact <| Simulator.extractImpacts itemResults ] - , td [ class "align-middle text-nowrap" ] - [ button [ class "btn btn-outline-secondary", onClick (RemoveItem process.id) ] [ Icon.trash ] ] + [ Simulator.extractImpacts itemResults + |> Format.formatImpact selectedImpact + ] + , td [ class "pe-3 align-middle text-nowrap" ] + [ button [ class "btn btn-outline-secondary", onClick (RemoveItem process.id) ] + [ Icon.trash ] + ] ] diff --git a/src/Views/Format.elm b/src/Views/Format.elm index 5955d71d0..c0bf31929 100644 --- a/src/Views/Format.elm +++ b/src/Views/Format.elm @@ -1,6 +1,7 @@ module Views.Format exposing ( complement , days + , density , formatFloat , formatImpact , formatImpactFloat @@ -198,8 +199,8 @@ surfaceMass = threadDensity : Unit.ThreadDensity -> Html msg -threadDensity (Unit.ThreadDensity density) = - density |> formatRichFloat 0 "#/cm" +threadDensity (Unit.ThreadDensity density_) = + density_ |> formatRichFloat 0 "#/cm" picking : Unit.PickPerMeter -> Html msg @@ -239,3 +240,12 @@ hours = minutes : Duration -> Html msg minutes = Duration.inMinutes >> formatRichFloat 0 "min" + + +density : { a | density : Float, unit : String } -> Html msg +density process = + if process.unit /= "kg" then + formatRichFloat 0 ("kg/" ++ process.unit) process.density + + else + text "N/A" diff --git a/src/Views/Page.elm b/src/Views/Page.elm index 5977f2fbe..537f67eed 100644 --- a/src/Views/Page.elm +++ b/src/Views/Page.elm @@ -133,13 +133,18 @@ newVersionAlert { session, reloadPage } = mainMenuLinks : Session -> List MenuLink -mainMenuLinks { enableFoodSection } = +mainMenuLinks { enableFoodSection, enableObjectSection } = List.filterMap identity [ Just <| Internal "Accueil" Route.Home Home , Just <| Internal "Textile" Route.TextileSimulatorHome TextileSimulator , if enableFoodSection then Just <| Internal "Alimentaire" Route.FoodBuilderHome FoodBuilder + else + Nothing + , if enableObjectSection then + Just <| Internal "Objets" Route.ObjectSimulatorHome Object + else Nothing , Just <| Internal "Explorateur" (Route.Explore Scope.Textile (Dataset.TextileExamples Nothing)) Explore