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 %> - + diff --git a/lib/level_web/templates/layout/branded_email.html.eex b/lib/level_web/templates/layout/branded_email.html.eex index f0ced902..f9f6e952 100644 --- a/lib/level_web/templates/layout/branded_email.html.eex +++ b/lib/level_web/templates/layout/branded_email.html.eex @@ -190,6 +190,7 @@ .text-xl { font-size: 34px; + line-height: 1.2; } .text-lg { @@ -232,6 +233,10 @@ text-align: center; } + .no-underline { + text-decoration: none; + } + .font-bold { font-weight: 600; } diff --git a/priv/repo/migrations/20181115205414_add_is_digest_enabled_to_space_users.exs b/priv/repo/migrations/20181115205414_add_is_digest_enabled_to_space_users.exs new file mode 100644 index 00000000..65b34064 --- /dev/null +++ b/priv/repo/migrations/20181115205414_add_is_digest_enabled_to_space_users.exs @@ -0,0 +1,9 @@ +defmodule Level.Repo.Migrations.AddIsDigestEnabledToSpaceUsers do + use Ecto.Migration + + def change do + alter table(:space_users) do + add :is_digest_enabled, :boolean, null: false, default: true + end + end +end diff --git a/priv/repo/structure.sql b/priv/repo/structure.sql index 70d25a1b..c47a5747 100644 --- a/priv/repo/structure.sql +++ b/priv/repo/structure.sql @@ -714,7 +714,8 @@ CREATE TABLE public.space_users ( first_name text NOT NULL, last_name text NOT NULL, avatar text, - handle public.citext NOT NULL + handle public.citext NOT NULL, + is_digest_enabled boolean DEFAULT true NOT NULL ); @@ -1875,5 +1876,5 @@ ALTER TABLE ONLY public.user_mentions -- PostgreSQL database dump complete -- -INSERT INTO public."schema_migrations" (version) VALUES (20170527220454), (20170528000152), (20170619214118), (20180403181445), (20180404204544), (20180413214033), (20180509143149), (20180510211015), (20180515174533), (20180518203612), (20180531200436), (20180627000743), (20180627231041), (20180724162650), (20180725135511), (20180731205027), (20180803151120), (20180807173948), (20180809201313), (20180810141122), (20180903213417), (20180903215930), (20180903220826), (20180908173406), (20180918182427), (20181003182443), (20181005154158), (20181009210537), (20181010174443), (20181011172259), (20181012200233), (20181012223338), (20181014144651), (20181018210912), (20181019194025), (20181022151255), (20181023175556), (20181029191737), (20181029220713), (20181101221239), (20181103215151), (20181105181343), (20181105195328), (20181105203544), (20181112222730), (20181114164623); +INSERT INTO public."schema_migrations" (version) VALUES (20170527220454), (20170528000152), (20170619214118), (20180403181445), (20180404204544), (20180413214033), (20180509143149), (20180510211015), (20180515174533), (20180518203612), (20180531200436), (20180627000743), (20180627231041), (20180724162650), (20180725135511), (20180731205027), (20180803151120), (20180807173948), (20180809201313), (20180810141122), (20180903213417), (20180903215930), (20180903220826), (20180908173406), (20180918182427), (20181003182443), (20181005154158), (20181009210537), (20181010174443), (20181011172259), (20181012200233), (20181012223338), (20181014144651), (20181018210912), (20181019194025), (20181022151255), (20181023175556), (20181029191737), (20181029220713), (20181101221239), (20181103215151), (20181105181343), (20181105195328), (20181105203544), (20181112222730), (20181114164623), (20181115205414); diff --git a/test/level/daily_digest_test.exs b/test/level/daily_digest_test.exs index 96009acd..bab2e958 100644 --- a/test/level/daily_digest_test.exs +++ b/test/level/daily_digest_test.exs @@ -6,6 +6,7 @@ defmodule Level.DailyDigestTest do alias Level.Digests alias Level.Repo alias Level.Schemas.SpaceUser + alias Level.Spaces describe "sendable_query/1" do setup do @@ -48,5 +49,17 @@ defmodule Level.DailyDigestTest do query = DailyDigest.sendable_query(now, now.hour) assert [] = Repo.all(query) end + + test "does not include users with disabled digests", %{ + now: now + } do + {:ok, %{space_user: space_user}} = create_user_and_space(%{time_zone: "Etc/UTC"}) + + # Disable the digest + Spaces.update_space_user(space_user, %{is_digest_enabled: false}) + + query = DailyDigest.sendable_query(now, now.hour - 1) + assert [] = Repo.all(query) + end end end diff --git a/test/level/digests_test.exs b/test/level/digests_test.exs index 7bc7a3be..bb284ad5 100644 --- a/test/level/digests_test.exs +++ b/test/level/digests_test.exs @@ -104,6 +104,7 @@ defmodule Level.DigestsTest do id: "11111111-1111-1111-1111-111111111111", space_id: "11111111-1111-1111-1111-111111111111", space_name: "Level", + space_slug: "level", title: "Your Daily Digest", subject: "[Level] Your Daily Digest", to_email: "derrick@level.app", diff --git a/test/level/email_test.exs b/test/level/email_test.exs index 4a2d7ffb..4c88944d 100644 --- a/test/level/email_test.exs +++ b/test/level/email_test.exs @@ -28,6 +28,7 @@ defmodule Level.EmailTest do id: "11111111-1111-1111-1111-111111111111", space_id: "11111111-1111-1111-1111-111111111111", space_name: "CoffeeKit", + space_slug: "coffeekit", title: "Your Daily Digest", subject: "[CoffeeKit] Your Daily Digest", to_email: "derrick@level.app",

Click here to view this digest in the browser.
- Times are displayed in the <%= @digest.time_zone %> time zone. + Times are displayed in the <%= @digest.time_zone %> time zone.
+ ">Update my digest settings