diff --git a/assets/css/app.css b/assets/css/app.css index 49707a05..e45ef3de 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -504,3 +504,11 @@ post-editor.dragging-over > * { .tilt-2deg { transform: rotate(2deg); } + +.trans-border-b-grey { + box-shadow: 0 1px 0 0 rgba(0,0,0,0.12); +} + +.translate-y-1 { + transform: translateY(1px); +} diff --git a/assets/elm/src/DigestSettings.elm b/assets/elm/src/DigestSettings.elm new file mode 100644 index 00000000..232bdc5f --- /dev/null +++ b/assets/elm/src/DigestSettings.elm @@ -0,0 +1,54 @@ +module DigestSettings exposing (DigestSettings, decoder, fragment, isEnabled, toggle) + +import GraphQL exposing (Fragment) +import Json.Decode as Decode exposing (Decoder) + + +type DigestSettings + = DigestSettings Data + + +type alias Data = + { isEnabled : Bool + } + + + +-- PROPERTIES + + +isEnabled : DigestSettings -> Bool +isEnabled (DigestSettings data) = + data.isEnabled + + +toggle : DigestSettings -> DigestSettings +toggle (DigestSettings data) = + DigestSettings { data | isEnabled = not data.isEnabled } + + + +-- GRAPHQL + + +fragment : Fragment +fragment = + let + queryBody = + """ + fragment DigestSettingsFields on DigestSettings { + isEnabled + } + """ + in + GraphQL.toFragment queryBody [] + + + +-- DECODERS + + +decoder : Decoder DigestSettings +decoder = + Decode.map DigestSettings <| + Decode.map Data (Decode.field "isEnabled" Decode.bool) diff --git a/assets/elm/src/Mutation/UpdateDigestSettings.elm b/assets/elm/src/Mutation/UpdateDigestSettings.elm new file mode 100644 index 00000000..5e67b227 --- /dev/null +++ b/assets/elm/src/Mutation/UpdateDigestSettings.elm @@ -0,0 +1,85 @@ +module Mutation.UpdateDigestSettings exposing (Response(..), request, variables) + +import DigestSettings exposing (DigestSettings) +import GraphQL exposing (Document) +import Id exposing (Id) +import Json.Decode as Decode exposing (Decoder) +import Json.Encode as Encode +import Session exposing (Session) +import Task exposing (Task) +import User exposing (User) +import ValidationError exposing (ValidationError) +import ValidationFields + + +type Response + = Success DigestSettings + | Invalid (List ValidationError) + + +document : Document +document = + GraphQL.toDocument + """ + mutation UpdateDigestSettings( + $spaceId: ID!, + $isEnabled: Boolean + ) { + updateDigestSettings( + spaceId: $spaceId, + isEnabled: $isEnabled + ) { + ...ValidationFields + digestSettings { + ...DigestSettingsFields + } + } + } + """ + [ User.fragment + , DigestSettings.fragment + , ValidationFields.fragment + ] + + +variables : Id -> Bool -> Maybe Encode.Value +variables spaceId isEnabled = + Just <| + Encode.object + [ ( "spaceId", Id.encoder spaceId ) + , ( "isEnabled", Encode.bool isEnabled ) + ] + + +successDecoder : Decoder Response +successDecoder = + Decode.map Success <| + Decode.at [ "data", "updateDigestSettings", "digestSettings" ] DigestSettings.decoder + + +failureDecoder : Decoder Response +failureDecoder = + Decode.map Invalid <| + Decode.at [ "data", "updateDigestSettings", "errors" ] (Decode.list ValidationError.decoder) + + +decoder : Decoder Response +decoder = + let + conditionalDecoder : Bool -> Decoder Response + conditionalDecoder success = + case success of + True -> + successDecoder + + False -> + failureDecoder + in + Decode.at [ "data", "updateDigestSettings", "success" ] Decode.bool + |> Decode.andThen conditionalDecoder + + +request : Id -> Bool -> Session -> Task Session.Error ( Session, Response ) +request spaceId isEnabled session = + Session.request session <| + GraphQL.request document (variables spaceId isEnabled) decoder diff --git a/assets/elm/src/Page/Inbox.elm b/assets/elm/src/Page/Inbox.elm index e000e9f7..ce11301a 100644 --- a/assets/elm/src/Page/Inbox.elm +++ b/assets/elm/src/Page/Inbox.elm @@ -431,7 +431,7 @@ resolvedView repo maybeCurrentRoute pushStatus spaceUsers model data = maybeCurrentRoute [ div [ class "mx-auto max-w-90 leading-normal" ] [ div [ class "sticky pin-t mb-3 pt-4 bg-white z-50" ] - [ div [ class "border-b" ] + [ div [ class "trans-border-b-grey" ] [ div [ class "flex items-center" ] [ h2 [ class "flex-no-shrink font-extrabold text-2xl" ] [ text "Inbox" ] , controlsView model data diff --git a/assets/elm/src/Page/Posts.elm b/assets/elm/src/Page/Posts.elm index a2a7fa97..63abde4d 100644 --- a/assets/elm/src/Page/Posts.elm +++ b/assets/elm/src/Page/Posts.elm @@ -314,7 +314,7 @@ resolvedView repo maybeCurrentRoute spaceUsers model data = data.bookmarks maybeCurrentRoute [ div [ class "mx-auto max-w-90 leading-normal" ] - [ div [ class "sticky pin-t border-b mb-3 pt-4 bg-white z-50" ] + [ div [ class "sticky pin-t trans-border-b-grey mb-3 pt-4 bg-white z-50" ] [ div [ class "flex items-center" ] [ h2 [ class "flex-no-shrink font-extrabold text-2xl" ] [ text "Activity" ] , controlsView model diff --git a/assets/elm/src/Page/SpaceSettings.elm b/assets/elm/src/Page/Settings.elm similarity index 53% rename from assets/elm/src/Page/SpaceSettings.elm rename to assets/elm/src/Page/Settings.elm index c1ef208b..3d86a835 100644 --- a/assets/elm/src/Page/SpaceSettings.elm +++ b/assets/elm/src/Page/Settings.elm @@ -1,6 +1,7 @@ -module Page.SpaceSettings exposing (Model, Msg(..), consumeEvent, init, setup, subscriptions, teardown, title, update, view) +module Page.Settings exposing (Model, Msg(..), consumeEvent, init, setup, subscriptions, teardown, title, update, view) import Avatar +import DigestSettings exposing (DigestSettings) import Event exposing (Event) import File exposing (File) import Globals exposing (Globals) @@ -11,11 +12,13 @@ import Html.Events exposing (onClick, onInput) import Id exposing (Id) import Json.Decode as Decode import ListHelpers exposing (insertUniqueBy, removeBy) +import Mutation.UpdateDigestSettings as UpdateDigestSettings import Mutation.UpdateSpace as UpdateSpace import Mutation.UpdateSpaceAvatar as UpdateSpaceAvatar -import Query.SetupInit as SetupInit +import Query.SettingsInit as SettingsInit import Repo exposing (Repo) import Route exposing (Route) +import Route.Settings exposing (Params) import Scroll import Session exposing (Session) import Space exposing (Space) @@ -23,6 +26,7 @@ import SpaceUser exposing (SpaceUser) import Task exposing (Task) import ValidationError exposing (ValidationError, errorView, errorsFor, errorsNotFor, isInvalid) import Vendor.Keys as Keys exposing (Modifier(..), enter, onKeydown, preventDefault) +import View.Helpers exposing (viewIf) import View.SpaceLayout @@ -31,12 +35,13 @@ import View.SpaceLayout type alias Model = - { spaceSlug : String + { params : Params , viewerId : Id , spaceId : Id , bookmarkIds : List Id , name : String , slug : String + , digestSettings : DigestSettings , avatarUrl : Maybe String , errors : List ValidationError , isSubmitting : Bool @@ -72,24 +77,25 @@ title = -- LIFECYCLE -init : String -> Globals -> Task Session.Error ( Globals, Model ) -init spaceSlug globals = +init : Params -> Globals -> Task Session.Error ( Globals, Model ) +init params globals = globals.session - |> SetupInit.request spaceSlug - |> Task.map (buildModel spaceSlug globals) + |> SettingsInit.request (Route.Settings.getSpaceSlug params) + |> Task.map (buildModel params globals) -buildModel : String -> Globals -> ( Session, SetupInit.Response ) -> ( Globals, Model ) -buildModel spaceSlug globals ( newSession, resp ) = +buildModel : Params -> Globals -> ( Session, SettingsInit.Response ) -> ( Globals, Model ) +buildModel params globals ( newSession, resp ) = let model = Model - spaceSlug + params resp.viewerId resp.spaceId resp.bookmarkIds (Space.name resp.space) (Space.slug resp.space) + resp.digestSettings (Space.avatarUrl resp.space) [] False @@ -116,14 +122,16 @@ teardown model = type Msg - = NameChanged String + = NoOp + | NameChanged String | SlugChanged String | Submit | Submitted (Result Session.Error ( Session, UpdateSpace.Response )) | AvatarSubmitted (Result Session.Error ( Session, UpdateSpaceAvatar.Response )) | AvatarSelected | FileReceived Decode.Value - | NoOp + | DigestToggled + | DigestSettingsUpdated (Result Session.Error ( Session, UpdateDigestSettings.Response )) update : Msg -> Globals -> Model -> ( ( Model, Cmd Msg ), Globals ) @@ -197,6 +205,29 @@ update msg globals model = -- TODO: handle unexpected exceptions noCmd globals { model | isSubmitting = False } + DigestToggled -> + let + cmd = + globals.session + |> UpdateDigestSettings.request model.spaceId (not (DigestSettings.isEnabled model.digestSettings)) + |> Task.attempt DigestSettingsUpdated + in + ( ( { model | digestSettings = DigestSettings.toggle model.digestSettings }, cmd ), globals ) + + DigestSettingsUpdated (Ok ( newSession, UpdateDigestSettings.Success newDigestSettings )) -> + ( ( { model | digestSettings = newDigestSettings, isSubmitting = False } + , Cmd.none + ) + , { globals | session = newSession } + ) + + DigestSettingsUpdated (Err Session.Expired) -> + redirectToLogin globals model + + DigestSettingsUpdated _ -> + -- TODO: handle unexpected exceptions + noCmd globals { model | isSubmitting = False } + NoOp -> noCmd globals model @@ -259,69 +290,114 @@ resolvedView maybeCurrentRoute model data = data.bookmarks maybeCurrentRoute [ div [ class "mx-auto max-w-md leading-normal p-8" ] - [ div [ class "pb-8" ] + [ div [ class "pb-4" ] [ nav [ class "text-xl font-extrabold text-dusty-blue-dark leading-tight" ] [ text <| Space.name data.space ] - , h1 [ class "font-extrabold text-3xl" ] [ text "Space Settings" ] + , h1 [ class "font-extrabold text-3xl" ] [ text "Settings" ] ] - , div [ class "flex" ] - [ div [ class "flex-1 mr-8" ] - [ div [ class "pb-6" ] - [ label [ for "name", class "input-label" ] [ text "Space Name" ] - , input - [ id "name" - , type_ "text" - , classList [ ( "input-field", True ), ( "input-field-error", isInvalid "name" model.errors ) ] - , name "name" - , placeholder "Acme, Co." - , value model.name - , onInput NameChanged - , onKeydown preventDefault [ ( [], enter, \event -> Submit ) ] - , disabled model.isSubmitting - ] - [] - , errorView "name" model.errors - ] - , div [ class "pb-6" ] - [ label [ for "slug", class "input-label" ] [ text "URL" ] - , div - [ classList - [ ( "input-field inline-flex leading-none items-baseline", True ) - , ( "input-field-error", isInvalid "slug" model.errors ) - ] - ] - [ label - [ for "slug" - , class "flex-none text-dusty-blue-darker select-none" - ] - [ text "level.app/" ] - , div [ class "flex-1" ] - [ input - [ id "slug" - , type_ "text" - , class "placeholder-blue w-full p-0 no-outline text-dusty-blue-darker" - , name "slug" - , placeholder "smith-co" - , value model.slug - , onInput SlugChanged - , onKeydown preventDefault [ ( [], enter, \event -> Submit ) ] - , disabled model.isSubmitting - ] - [] - ] - ] - , errorView "slug" model.errors - ] - , button - [ type_ "submit" - , class "btn btn-blue" - , onClick Submit + , div [ class "flex items-baseline mb-6 border-b" ] + [ filterTab "Preferences" Route.Settings.Preferences (Route.Settings.setSection Route.Settings.Preferences model.params) model.params + , viewIf (Space.canUpdate data.space) <| + filterTab "Space Settings" Route.Settings.Space (Route.Settings.setSection Route.Settings.Space model.params) model.params + ] + , viewIf (Route.Settings.getSection model.params == Route.Settings.Preferences) <| + preferencesView model data + , viewIf (Route.Settings.getSection model.params == Route.Settings.Space) <| + spaceSettingsView model data + ] + ] + + +preferencesView : Model -> Data -> Html Msg +preferencesView model data = + label [ class "control checkbox pb-6" ] + [ input + [ type_ "checkbox" + , class "checkbox" + , onClick DigestToggled + , checked (DigestSettings.isEnabled model.digestSettings) + , disabled model.isSubmitting + ] + [] + , span [ class "control-indicator" ] [] + , span [ class "select-none" ] [ text "Send me a daily digest at 4:00 pm" ] + ] + + +spaceSettingsView : Model -> Data -> Html Msg +spaceSettingsView model data = + div [] + [ div [ class "pb-6" ] + [ label [ for "name", class "input-label" ] [ text "Space Name" ] + , input + [ id "name" + , type_ "text" + , classList [ ( "input-field", True ), ( "input-field-error", isInvalid "name" model.errors ) ] + , name "name" + , placeholder "Acme, Co." + , value model.name + , onInput NameChanged + , onKeydown preventDefault [ ( [], enter, \event -> Submit ) ] + , disabled model.isSubmitting + ] + [] + , errorView "name" model.errors + ] + , div [ class "pb-6" ] + [ label [ for "slug", class "input-label" ] [ text "URL" ] + , div + [ classList + [ ( "input-field inline-flex leading-none items-baseline", True ) + , ( "input-field-error", isInvalid "slug" model.errors ) + ] + ] + [ label + [ for "slug" + , class "flex-none text-dusty-blue-darker select-none" + ] + [ text "level.app/" ] + , div [ class "flex-1" ] + [ input + [ id "slug" + , type_ "text" + , class "placeholder-blue w-full p-0 no-outline text-dusty-blue-darker" + , name "slug" + , placeholder "smith-co" + , value model.slug + , onInput SlugChanged + , onKeydown preventDefault [ ( [], enter, \event -> Submit ) ] , disabled model.isSubmitting ] - [ text "Save settings" ] - ] - , div [ class "flex-0" ] - [ Avatar.uploader "avatar" model.avatarUrl AvatarSelected + [] ] ] + , errorView "slug" model.errors + ] + , div [ class "pb-6" ] + [ label [ for "avatar", class "input-label" ] [ text "Logo" ] + , Avatar.uploader "avatar" model.avatarUrl AvatarSelected + ] + , button + [ type_ "submit" + , class "btn btn-blue" + , onClick Submit + , disabled model.isSubmitting + ] + [ text "Save settings" ] + ] + + +filterTab : String -> Route.Settings.Section -> Params -> Params -> Html Msg +filterTab label section linkParams currentParams = + let + isCurrent = + Route.Settings.getSection currentParams == section + in + a + [ Route.href (Route.Settings linkParams) + , classList + [ ( "block text-sm mr-4 py-2 border-b-3 border-transparent no-underline font-bold", True ) + , ( "text-dusty-blue", not isCurrent ) + , ( "border-turquoise text-dusty-blue-darker", isCurrent ) ] ] + [ text label ] diff --git a/assets/elm/src/Program/Main.elm b/assets/elm/src/Program/Main.elm index 7bf027e2..23f382f8 100644 --- a/assets/elm/src/Program/Main.elm +++ b/assets/elm/src/Program/Main.elm @@ -26,7 +26,7 @@ import Page.Posts import Page.Search import Page.Setup.CreateGroups import Page.Setup.InviteUsers -import Page.SpaceSettings +import Page.Settings import Page.SpaceUsers import Page.Spaces import Page.UserSettings @@ -202,7 +202,7 @@ type Msg | GroupPermissionsMsg Page.GroupPermissions.Msg | PostMsg Page.Post.Msg | UserSettingsMsg Page.UserSettings.Msg - | SpaceSettingsMsg Page.SpaceSettings.Msg + | SpaceSettingsMsg Page.Settings.Msg | SearchMsg Page.Search.Msg | SocketIn Decode.Value | PushManagerIn Decode.Value @@ -410,7 +410,7 @@ update msg model = ( SpaceSettingsMsg pageMsg, SpaceSettings pageModel ) -> pageModel - |> Page.SpaceSettings.update pageMsg globals + |> Page.Settings.update pageMsg globals |> updatePageWithGlobals SpaceSettings SpaceSettingsMsg model ( SearchMsg pageMsg, Search pageModel ) -> @@ -491,7 +491,7 @@ type Page | GroupPermissions Page.GroupPermissions.Model | Post Page.Post.Model | UserSettings Page.UserSettings.Model - | SpaceSettings Page.SpaceSettings.Model + | SpaceSettings Page.Settings.Model | Search Page.Search.Model @@ -508,7 +508,7 @@ type PageInit | GroupPermissionsInit (Result Session.Error ( Globals, Page.GroupPermissions.Model )) | PostInit String (Result Session.Error ( Globals, Page.Post.Model )) | UserSettingsInit (Result Session.Error ( Globals, Page.UserSettings.Model )) - | SpaceSettingsInit (Result Session.Error ( Globals, Page.SpaceSettings.Model )) + | SpaceSettingsInit (Result Session.Error ( Globals, Page.Settings.Model )) | SetupCreateGroupsInit (Result Session.Error ( Globals, Page.Setup.CreateGroups.Model )) | SetupInviteUsersInit (Result Session.Error ( Globals, Page.Setup.InviteUsers.Model )) | SearchInit (Result Session.Error ( Globals, Page.Search.Model )) @@ -602,9 +602,9 @@ navigateTo maybeRoute model = |> Page.Post.init spaceSlug postId |> transition model (PostInit postId) - Just (Route.SpaceSettings spaceSlug) -> + Just (Route.Settings spaceSlug) -> globals - |> Page.SpaceSettings.init spaceSlug + |> Page.Settings.init spaceSlug |> transition model SpaceSettingsInit Just Route.UserSettings -> @@ -652,7 +652,7 @@ pageTitle repo page = Page.Post.title pageModel SpaceSettings _ -> - Page.SpaceSettings.title + Page.Settings.title InviteUsers _ -> Page.InviteUsers.title @@ -807,7 +807,7 @@ setupPage pageInit model = ( model, Cmd.none ) SpaceSettingsInit (Ok result) -> - perform Page.SpaceSettings.setup SpaceSettings SpaceSettingsMsg model result + perform Page.Settings.setup SpaceSettings SpaceSettingsMsg model result SpaceSettingsInit (Err Session.Expired) -> ( model, Route.toLogin ) @@ -876,7 +876,7 @@ teardownPage page = Cmd.map UserSettingsMsg (Page.UserSettings.teardown pageModel) SpaceSettings pageModel -> - Cmd.map SpaceSettingsMsg (Page.SpaceSettings.teardown pageModel) + Cmd.map SpaceSettingsMsg (Page.Settings.teardown pageModel) Posts pageModel -> Cmd.map PostsMsg (Page.Posts.teardown pageModel) @@ -916,7 +916,7 @@ pageSubscription page = Sub.map UserSettingsMsg Page.UserSettings.subscriptions SpaceSettings _ -> - Sub.map SpaceSettingsMsg Page.SpaceSettings.subscriptions + Sub.map SpaceSettingsMsg Page.Settings.subscriptions Search _ -> Sub.map SearchMsg Page.Search.subscriptions @@ -970,8 +970,8 @@ routeFor page = UserSettings _ -> Just <| Route.UserSettings - SpaceSettings { spaceSlug } -> - Just <| Route.SpaceSettings spaceSlug + SpaceSettings { params } -> + Just <| Route.Settings params Search { params } -> Just <| Route.Search params @@ -1058,7 +1058,7 @@ pageView repo page pushStatus spaceUserLists = SpaceSettings pageModel -> pageModel - |> Page.SpaceSettings.view repo (routeFor page) + |> Page.Settings.view repo (routeFor page) |> Html.map SpaceSettingsMsg Search pageModel -> @@ -1272,7 +1272,7 @@ sendEventToPage globals event model = SpaceSettings pageModel -> pageModel - |> Page.SpaceSettings.consumeEvent event + |> Page.Settings.consumeEvent event |> updatePage SpaceSettings SpaceSettingsMsg model Search pageModel -> diff --git a/assets/elm/src/Query/SettingsInit.elm b/assets/elm/src/Query/SettingsInit.elm new file mode 100644 index 00000000..e33d2c8f --- /dev/null +++ b/assets/elm/src/Query/SettingsInit.elm @@ -0,0 +1,108 @@ +module Query.SettingsInit exposing (Response, request) + +import Connection exposing (Connection) +import DigestSettings exposing (DigestSettings) +import GraphQL exposing (Document) +import Group exposing (Group) +import Id exposing (Id) +import Json.Decode as Decode exposing (Decoder) +import Json.Decode.Pipeline as Pipeline +import Json.Encode as Encode +import Repo exposing (Repo) +import Session exposing (Session) +import Space exposing (Space) +import SpaceUser exposing (SpaceUser) +import Task exposing (Task) + + +type alias Response = + { viewerId : Id + , spaceId : Id + , bookmarkIds : List Id + , space : Space + , digestSettings : DigestSettings + , repo : Repo + } + + +type alias Data = + { viewer : SpaceUser + , space : Space + , bookmarks : List Group + , digestSettings : DigestSettings + } + + +document : Document +document = + GraphQL.toDocument + """ + query GroupInit( + $spaceSlug: String! + ) { + spaceUser(spaceSlug: $spaceSlug) { + ...SpaceUserFields + digestSettings { + ...DigestSettingsFields + } + space { + ...SpaceFields + } + bookmarks { + ...GroupFields + } + } + } + """ + [ Group.fragment + , SpaceUser.fragment + , Space.fragment + , DigestSettings.fragment + ] + + +variables : String -> Maybe Encode.Value +variables spaceSlug = + Just <| + Encode.object + [ ( "spaceSlug", Encode.string spaceSlug ) + ] + + +decoder : Decoder Data +decoder = + Decode.at [ "data", "spaceUser" ] <| + (Decode.succeed Data + |> Pipeline.custom SpaceUser.decoder + |> Pipeline.custom (Decode.field "space" Space.decoder) + |> Pipeline.custom (Decode.field "bookmarks" (Decode.list Group.decoder)) + |> Pipeline.custom (Decode.field "digestSettings" DigestSettings.decoder) + ) + + +buildResponse : ( Session, Data ) -> ( Session, Response ) +buildResponse ( session, data ) = + let + repo = + Repo.empty + |> Repo.setSpaceUser data.viewer + |> Repo.setSpace data.space + |> Repo.setGroups data.bookmarks + + resp = + Response + (SpaceUser.id data.viewer) + (Space.id data.space) + (List.map Group.id data.bookmarks) + data.space + data.digestSettings + repo + in + ( session, resp ) + + +request : String -> Session -> Task Session.Error ( Session, Response ) +request spaceSlug session = + GraphQL.request document (variables spaceSlug) decoder + |> Session.request session + |> Task.map buildResponse diff --git a/assets/elm/src/Route.elm b/assets/elm/src/Route.elm index 1741d7d8..1f5fc4fb 100644 --- a/assets/elm/src/Route.elm +++ b/assets/elm/src/Route.elm @@ -7,11 +7,12 @@ import Browser.Navigation as Nav import Html exposing (Attribute) import Html.Attributes as Attr import Route.Group +import Route.GroupPermissions import Route.Groups import Route.Inbox -import Route.GroupPermissions import Route.Posts import Route.Search +import Route.Settings import Route.SpaceUsers import Url exposing (Url) import Url.Builder as Builder exposing (absolute) @@ -38,7 +39,7 @@ type Route | GroupPermissions Route.GroupPermissions.Params | Post String String | UserSettings - | SpaceSettings String + | Settings Route.Settings.Params | Search Route.Search.Params @@ -60,7 +61,7 @@ parser = , Parser.map Group Route.Group.parser , Parser.map Post (Parser.string > s "posts" > Parser.string) , Parser.map UserSettings (s "user" > s "settings") - , Parser.map SpaceSettings (Parser.string > s "settings") + , Parser.map Settings Route.Settings.parser , Parser.map Search Route.Search.parser ] @@ -151,8 +152,8 @@ toString page = UserSettings -> absolute [ "user", "settings" ] [] - SpaceSettings slug -> - absolute [ slug, "settings" ] [] + Settings params -> + Route.Settings.toString params Search params -> Route.Search.toString params diff --git a/assets/elm/src/Route/Settings.elm b/assets/elm/src/Route/Settings.elm new file mode 100644 index 00000000..1baef72a --- /dev/null +++ b/assets/elm/src/Route/Settings.elm @@ -0,0 +1,112 @@ +module Route.Settings exposing + ( Params, Section(..) + , init, getSpaceSlug, getSection, setSection + , parser + , toString + ) + +{-| Route building and parsing for the space user directory. + + +# Types + +@docs Params, Section + + +# API + +@docs init, getSpaceSlug, getSection, setSection + + +# Parsing + +@docs parser + + +# Serialization + +@docs toString + +-} + +import Url.Builder as Builder exposing (QueryParameter, absolute) +import Url.Parser as Parser exposing ((>), (>), Parser, map, oneOf, s, string) +import Url.Parser.Query as Query + + +type Params + = Params Internal + + +type Section + = Preferences + | Space + + +type alias Internal = + { spaceSlug : String + , section : Section + } + + + +-- API + + +init : String -> Section -> Params +init spaceSlug section = + Params (Internal spaceSlug section) + + +getSpaceSlug : Params -> String +getSpaceSlug (Params internal) = + internal.spaceSlug + + +getSection : Params -> Section +getSection (Params internal) = + internal.section + + +setSection : Section -> Params -> Params +setSection newSection (Params internal) = + Params { internal | section = newSection } + + + +-- PARSING + + +parser : Parser (Params -> a) a +parser = + map Params <| + map Internal (string > s "settings" > map parseSection string) + + +parseSection : String -> Section +parseSection sectionSlug = + case sectionSlug of + "space" -> + Space + + _ -> + Preferences + + + +-- SERIALIZATION + + +toString : Params -> String +toString (Params internal) = + absolute [ internal.spaceSlug, "settings", serializeSection internal.section ] [] + + +serializeSection : Section -> String +serializeSection section = + case section of + Preferences -> + "preferences" + + Space -> + "space" diff --git a/assets/elm/src/View/SpaceLayout.elm b/assets/elm/src/View/SpaceLayout.elm index 88eb7db7..cdec0530 100644 --- a/assets/elm/src/View/SpaceLayout.elm +++ b/assets/elm/src/View/SpaceLayout.elm @@ -11,6 +11,7 @@ import Route.Group import Route.Groups import Route.Inbox import Route.Posts +import Route.Settings import Space exposing (Space) import SpaceUser exposing (SpaceUser) import User exposing (User) @@ -66,8 +67,7 @@ fullSidebar viewer space bookmarks maybeCurrentRoute = , groupLinks space bookmarks maybeCurrentRoute , ul [ class "mb-4 list-reset leading-semi-loose select-none" ] [ navLink space "Groups" (Just <| Route.Groups (Route.Groups.init (Space.slug space))) maybeCurrentRoute - , viewIf (Space.canUpdate space) <| - navLink space "Settings" (Just <| Route.SpaceSettings (Space.slug space)) maybeCurrentRoute + , navLink space "Settings" (Just <| Route.Settings (Route.Settings.init (Space.slug space) Route.Settings.Preferences)) maybeCurrentRoute ] ] , div [ class "absolute pin-b w-full" ] @@ -125,6 +125,9 @@ navLink space title maybeRoute maybeCurrentRoute = ( Just (Route.Posts params), Just (Route.Posts _) ) -> currentItem (Route.Posts params) + ( Just (Route.Settings params), Just (Route.Settings _) ) -> + currentItem (Route.Settings params) + ( Just (Route.Group params), Just (Route.Group currentParams) ) -> if Route.Group.hasSamePath params currentParams then currentItem (Route.Group params) diff --git a/lib/level/daily_digest.ex b/lib/level/daily_digest.ex index ed427751..2987b37c 100644 --- a/lib/level/daily_digest.ex +++ b/lib/level/daily_digest.ex @@ -35,6 +35,7 @@ defmodule Level.DailyDigest do inner_query = from su in Sendable, join: u in assoc(su, :user), + where: su.is_digest_enabled == true, select: %{ su | hour: fragment("EXTRACT(HOUR FROM ? AT TIME ZONE ?)", ^now, u.time_zone), diff --git a/lib/level/daily_digest/sendable.ex b/lib/level/daily_digest/sendable.ex index 838edacf..bcf25360 100644 --- a/lib/level/daily_digest/sendable.ex +++ b/lib/level/daily_digest/sendable.ex @@ -13,6 +13,8 @@ defmodule Level.DailyDigest.Sendable do field :digest_key, :string field :hour, :integer field :time_zone, :string + field :is_digest_enabled, :boolean + belongs_to :user, User end end diff --git a/lib/level/digests/builder.ex b/lib/level/digests/builder.ex index 42689dca..279905a8 100644 --- a/lib/level/digests/builder.ex +++ b/lib/level/digests/builder.ex @@ -96,22 +96,22 @@ defmodule Level.Digests.Builder do def inbox_section_summary(unread_count, 0) do unread_phrase = pluralize(unread_count, "unread post", "unread posts") - text = "You have #{unread_phrase} in your inbox. Here are a few of the top ones." - - html = - "You have #{unread_phrase} in your inbox. " <> - "Here are a few of the top ones." + text = "You have #{unread_phrase} in your inbox. Here are some highlights." + html = "You have #{unread_phrase} in your inbox. Here are some highlights." {text, html} end def inbox_section_summary(0, read_count) do read_phrase = pluralize(read_count, "post", "posts") - text = "You have #{read_phrase} in your inbox. Here are a few of the top ones." + + text = + "You have #{read_phrase} in your inbox. " <> + "We recommend dismissing posts from your inbox once you are finished with them." html = "You have #{read_phrase} in your inbox. " <> - "Here are a few of the top ones." + "We recommend dismissing posts from your inbox once you are finished with them." {text, html} end @@ -122,13 +122,11 @@ defmodule Level.Digests.Builder do plaintext = "You have #{unread_phrase} and " <> - "#{read_phrase} you have already seen in your inbox. " <> - "Here are a few of the top ones." + "#{read_phrase} you have already seen in your inbox. Here are some highlights." html = "You have #{unread_phrase} and " <> - "#{read_phrase} you have already seen in your inbox. " <> - "Here are a few of the top ones." + "#{read_phrase} you have already seen in your inbox. Here are some highlights." {plaintext, html} end diff --git a/lib/level/digests/compiler.ex b/lib/level/digests/compiler.ex index 37b80e36..358e9d55 100644 --- a/lib/level/digests/compiler.ex +++ b/lib/level/digests/compiler.ex @@ -18,6 +18,7 @@ defmodule Level.Digests.Compiler do id: digest.id, space_id: digest.space_id, space_name: space.name, + space_slug: space.slug, title: digest.title, subject: digest.subject, to_email: digest.to_email, diff --git a/lib/level/digests/digest.ex b/lib/level/digests/digest.ex index e164959e..22383552 100644 --- a/lib/level/digests/digest.ex +++ b/lib/level/digests/digest.ex @@ -8,6 +8,7 @@ defmodule Level.Digests.Digest do @enforce_keys [ :id, :space_id, + :space_slug, :space_name, :title, :subject, @@ -21,6 +22,7 @@ defmodule Level.Digests.Digest do defstruct [ :id, :space_id, + :space_slug, :space_name, :title, :subject, @@ -34,6 +36,7 @@ defmodule Level.Digests.Digest do @type t :: %__MODULE__{ id: String.t(), space_id: String.t(), + space_slug: String.t(), space_name: String.t(), title: String.t(), subject: String.t(), diff --git a/lib/level/mutations.ex b/lib/level/mutations.ex index ede8ce66..798ff87b 100644 --- a/lib/level/mutations.ex +++ b/lib/level/mutations.ex @@ -23,6 +23,15 @@ defmodule Level.Mutations do {:ok, %{success: boolean(), user: Users.User.t() | nil, errors: validation_errors()}} | {:error, String.t()} + @typedoc "The result of mutating digest settings" + @type digest_mutation_result :: + {:ok, + %{ + success: boolean(), + digest_settings: %{is_enabled: boolean()} | nil, + errors: validation_errors() + }} + @typedoc "The result of a space mutation" @type space_mutation_result :: {:ok, %{success: boolean(), space: Spaces.Space.t() | nil, errors: validation_errors()}} @@ -121,6 +130,42 @@ defmodule Level.Mutations do end end + @doc """ + Updates digest settings. + """ + @spec update_digest_settings(map(), info()) :: digest_mutation_result() + def update_digest_settings(%{space_id: space_id} = args, %{context: %{current_user: user}}) do + transformed_args = + case args do + %{is_enabled: is_enabled} = args -> + args + |> Map.delete(:is_enabled) + |> Map.put(:is_digest_enabled, is_enabled) + + args -> + args + end + + user + |> Spaces.get_space(space_id) + |> do_update_digest_settings(transformed_args) + end + + defp do_update_digest_settings({:ok, %{space_user: space_user}}, args) do + case Spaces.update_space_user(space_user, args) do + {:ok, space_user} -> + {:ok, + %{ + success: true, + digest_settings: %{is_enabled: space_user.is_digest_enabled}, + errors: [] + }} + + {:error, changeset} -> + {:ok, %{success: false, digest_settings: nil, errors: format_errors(changeset)}} + end + end + @doc """ Creates a new space. """ diff --git a/lib/level/schemas/space_user.ex b/lib/level/schemas/space_user.ex index c1831645..93bda87e 100644 --- a/lib/level/schemas/space_user.ex +++ b/lib/level/schemas/space_user.ex @@ -21,6 +21,7 @@ defmodule Level.Schemas.SpaceUser do schema "space_users" do field :state, :string, read_after_writes: true field :role, :string, read_after_writes: true + field :is_digest_enabled, :boolean, read_after_writes: true field :first_name, :string field :last_name, :string field :handle, :string @@ -48,7 +49,16 @@ defmodule Level.Schemas.SpaceUser do @doc false def create_changeset(struct, attrs \\ %{}) do struct - |> cast(attrs, [:user_id, :space_id, :role, :first_name, :last_name, :handle, :avatar]) + |> cast(attrs, [ + :user_id, + :space_id, + :role, + :first_name, + :last_name, + :handle, + :avatar, + :is_digest_enabled + ]) |> validate_required([:role, :first_name, :last_name, :handle]) |> Handles.validate_format(:handle) |> unique_constraint(:handle, @@ -60,7 +70,7 @@ defmodule Level.Schemas.SpaceUser do @doc false def update_changeset(struct, attrs \\ %{}) do struct - |> cast(attrs, [:role, :first_name, :last_name, :handle, :avatar]) + |> cast(attrs, [:role, :first_name, :last_name, :handle, :avatar, :is_digest_enabled]) |> validate_required([:role, :first_name, :last_name, :handle]) |> Handles.validate_format(:handle) |> unique_constraint(:handle, diff --git a/lib/level_web/schema.ex b/lib/level_web/schema.ex index e8edaedc..55b9ca74 100644 --- a/lib/level_web/schema.ex +++ b/lib/level_web/schema.ex @@ -98,6 +98,14 @@ defmodule LevelWeb.Schema do resolve &Level.Mutations.update_space_avatar/2 end + @desc "Updates a user's digest settings for a particular space." + field :update_digest_settings, type: :update_digest_settings_payload do + arg :space_id, non_null(:id) + arg :is_enabled, :boolean + + resolve &Level.Mutations.update_digest_settings/2 + end + @desc "Mark a space setup step as complete." field :complete_setup_step, type: :complete_setup_step_payload do arg :space_id, non_null(:id) diff --git a/lib/level_web/schema/mutations.ex b/lib/level_web/schema/mutations.ex index 2076ea9f..f81a77a9 100644 --- a/lib/level_web/schema/mutations.ex +++ b/lib/level_web/schema/mutations.ex @@ -82,6 +82,27 @@ defmodule LevelWeb.Schema.Mutations do interface :validatable end + @desc "The response to updating digest settings." + object :update_digest_settings_payload do + @desc """ + A boolean indicating if the mutation was successful. If true, the errors + list will be empty. Otherwise, errors may contain objects describing why + the mutation failed. + """ + field :success, non_null(:boolean) + + @desc "A list of validation errors." + field :errors, list_of(:error) + + @desc """ + The mutated object. If the mutation was not successful, + this field may be null. + """ + field :digest_settings, :digest_settings + + interface :validatable + end + @desc "The response to completing a setup step." object :complete_setup_step_payload do @desc """ diff --git a/lib/level_web/schema/objects.ex b/lib/level_web/schema/objects.ex index a8586d3f..8286553d 100644 --- a/lib/level_web/schema/objects.ex +++ b/lib/level_web/schema/objects.ex @@ -90,6 +90,16 @@ defmodule LevelWeb.Schema.Objects do field :last_name, non_null(:string) field :handle, non_null(:string) + field :digest_settings, :digest_settings do + resolve fn space_user, _, %{context: %{current_user: user}} -> + if space_user.user_id == user.id do + {:ok, %{is_enabled: space_user.is_digest_enabled}} + else + {:ok, nil} + end + end + end + @desc "A list of groups the user has bookmarked." field :bookmarks, list_of(:group) do resolve fn space_user, _args, %{context: %{current_user: user}} -> @@ -117,6 +127,11 @@ defmodule LevelWeb.Schema.Objects do field :fetched_at, non_null(:timestamp), resolve: fetch_time() end + @desc "Describes a user's digest sending preferences." + object :digest_settings do + field :is_enabled, non_null(:boolean) + end + @desc "A space bot represents a bot that has been installed in a particular space." object :space_bot do field :id, non_null(:id) diff --git a/lib/level_web/templates/digest/show.html.eex b/lib/level_web/templates/digest/show.html.eex index 079a3c52..3f3b0f69 100644 --- a/lib/level_web/templates/digest/show.html.eex +++ b/lib/level_web/templates/digest/show.html.eex @@ -2,8 +2,8 @@
- <%= @digest.space_name %> -<%= @digest.title %>+<%= @digest.space_name %> +<%= @digest.title %><%= digest_date(@digest) %> @ <%= digest_time(@digest) %> @@ -17,13 +17,14 @@ <%= render LevelWeb.DigestView, "_section.html", %{section: section, digest: @digest} %> <% end %> - +
|