From c5cfd0fd9065dee039ae2cd08474ff3e6b79e0d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8jberg?= Date: Mon, 4 Aug 2025 10:09:51 -0400 Subject: [PATCH 1/4] Add webhook configuration UI to project settings The notification system supports webhooks. Add a way to configure then for each project on the project settings page. ! This does not yet add API integration --- elm-git.json | 2 +- src/UnisonShare/AddProjectWebhookModal.elm | 247 ++++++++++++++++++ src/UnisonShare/Page/ProjectSettingsPage.elm | 162 ++++++++++-- src/UnisonShare/ProjectWebhook.elm | 19 ++ src/css/unison-share.css | 1 + .../add-project-webhook-modal.css | 23 ++ .../page/project-settings-page.css | 58 ++++ 7 files changed, 495 insertions(+), 17 deletions(-) create mode 100644 src/UnisonShare/AddProjectWebhookModal.elm create mode 100644 src/UnisonShare/ProjectWebhook.elm create mode 100644 src/css/unison-share/add-project-webhook-modal.css diff --git a/elm-git.json b/elm-git.json index e8c182d8..795b301b 100644 --- a/elm-git.json +++ b/elm-git.json @@ -5,4 +5,4 @@ }, "indirect": {} } -} \ No newline at end of file +} diff --git a/src/UnisonShare/AddProjectWebhookModal.elm b/src/UnisonShare/AddProjectWebhookModal.elm new file mode 100644 index 00000000..a7362d0d --- /dev/null +++ b/src/UnisonShare/AddProjectWebhookModal.elm @@ -0,0 +1,247 @@ +module UnisonShare.AddProjectWebhookModal exposing (..) + +import Html exposing (Html, div) +import Html.Attributes exposing (class) +import Http +import Lib.HttpApi exposing (HttpResult) +import List.Extra as ListE +import List.Nonempty as NEL +import UI +import UI.Button as Button +import UI.Divider as Divider +import UI.Form.CheckboxField as CheckboxField +import UI.Form.RadioField as RadioField +import UI.Form.TextField as TextField +import UI.Icon as Icon +import UI.Modal as Modal +import UI.ProfileSnippet as ProfileSnippet +import UI.StatusBanner as StatusBanner +import UnisonShare.AppContext exposing (AppContext) +import UnisonShare.Link as Link +import UnisonShare.Project.ProjectRef exposing (ProjectRef) +import UnisonShare.ProjectWebhook exposing (ProjectWebhook) +import UnisonShare.User exposing (UserSummaryWithId) + + +type NotificationEventType + = ProjectContributionCreated + | ProjectContributionUpdated + | ProjectContributionComment + | ProjectTicketCreated + | ProjectTicketUpdated + | ProjectTicketComment + | ProjectBranchUpdated + | ProjectReleaseCreated + + +type WebhookEvents + = AllEvents + | SpecificEvents (List NotificationEventType) + + +type alias Form = + { url : String + , events : WebhookEvents + , isActive : Bool + } + + +type Model + = Edit Form + | Saving Form + | Failure Http.Error Form + | Success ProjectWebhook + + +init : Model +init = + Edit { url = "", events = AllEvents, isActive = True } + + + +-- UPDATE + + +type Msg + = CloseModal + | UpdateUrl String + | SetWebhookEvents WebhookEvents + | ToggleEvent NotificationEventType + | ToggleIsActive + | AddWebhook + | AddWebhookFinished (HttpResult ()) + + +type OutMsg + = NoOutMsg + | RequestCloseModal + | AddedWebhook ProjectWebhook + + +update : AppContext -> ProjectRef -> Msg -> Model -> ( Model, Cmd Msg, OutMsg ) +update _ _ msg model = + case ( msg, model ) of + ( UpdateUrl url, Edit f ) -> + ( Edit { f | url = url }, Cmd.none, NoOutMsg ) + + ( SetWebhookEvents events, Edit f ) -> + ( Edit { f | events = events }, Cmd.none, NoOutMsg ) + + ( ToggleEvent eventType, Edit f ) -> + let + events = + case f.events of + AllEvents -> + SpecificEvents [ eventType ] + + SpecificEvents evts -> + if List.member eventType evts then + SpecificEvents (ListE.remove eventType evts) + + else + SpecificEvents (evts ++ [ eventType ]) + in + ( Edit { f | events = events }, Cmd.none, NoOutMsg ) + + ( ToggleIsActive, Edit f ) -> + ( Edit { f | isActive = not f.isActive }, Cmd.none, NoOutMsg ) + + ( AddWebhook, _ ) -> + ( model, Cmd.none, NoOutMsg ) + + ( AddWebhookFinished _, _ ) -> + ( model, Cmd.none, NoOutMsg ) + + ( CloseModal, _ ) -> + ( model, Cmd.none, RequestCloseModal ) + + _ -> + ( model, Cmd.none, NoOutMsg ) + + + +-- EFFECTS +-- VIEW + + +viewUser : UserSummaryWithId -> Html msg +viewUser user = + ProfileSnippet.profileSnippet user |> ProfileSnippet.view + + +divider : Html msg +divider = + Divider.divider |> Divider.small |> Divider.view + + +viewEventSelection : List NotificationEventType -> Html Msg +viewEventSelection selected = + let + isSelected event = + List.member event selected + + checkbox title event = + CheckboxField.field title (ToggleEvent event) (isSelected event) + |> CheckboxField.view + + contributionEvents = + [ checkbox "Contribution created" ProjectContributionCreated + , checkbox "Contribution updated" ProjectContributionUpdated + , checkbox "Contribution comment" ProjectContributionComment + ] + + ticketEvents = + [ checkbox "Ticket created" ProjectTicketCreated + , checkbox "Ticket updated" ProjectTicketUpdated + , checkbox "Ticket comment" ProjectTicketComment + ] + + eventSelectionCheckboxes = + div [ class "event-selection_groups" ] + [ div [] + [ div [ class "checkboxes" ] + [ checkbox "Branch updated" ProjectBranchUpdated + , checkbox "Release created" ProjectReleaseCreated + ] + ] + , div [] + [ div [ class "checkboxes" ] contributionEvents + ] + , div [] + [ div [ class "checkboxes" ] ticketEvents + ] + ] + in + div [ class "event-selection" ] [ divider, eventSelectionCheckboxes ] + + +view : Model -> Html Msg +view model = + let + modal_ c = + Modal.content c + |> Modal.modal "add-project-webhook-modal" CloseModal + |> Modal.withHeader "Add Webhook" + + modal = + case model of + Edit form -> + let + specificEventsOption = + case form.events of + AllEvents -> + SpecificEvents [] + + _ -> + form.events + + options = + NEL.singleton (RadioField.option "Select specific events" "The webhook is only called on selected events" specificEventsOption) + |> NEL.cons (RadioField.option "All events" "The webhook is called on all project events (including future additions)" AllEvents) + + eventSelection = + case form.events of + AllEvents -> + UI.nothing + + SpecificEvents selected -> + viewEventSelection selected + in + modal_ + (div [] + [ TextField.field UpdateUrl "Webhook URL" form.url + |> TextField.withIcon Icon.wireframeGlobe + |> TextField.withHelpText "This URL will be called when the selected events are triggered." + |> TextField.view + , divider + , RadioField.field "Events" SetWebhookEvents options form.events |> RadioField.view + , eventSelection + , divider + , CheckboxField.field "Active" ToggleIsActive form.isActive + |> CheckboxField.withHelpText "Actively call the Webhook URL when selected events are triggered." + |> CheckboxField.view + ] + ) + |> Modal.withActions + [ Button.button CloseModal "Cancel" + |> Button.subdued + , Button.button AddWebhook "Add Webhook" + |> Button.emphasized + ] + |> Modal.withLeftSideFooter + [ Button.iconThenLabel_ Link.docs Icon.docs "Webhook request format docs" + |> Button.small + |> Button.outlined + |> Button.view + ] + + Saving _ -> + modal_ (StatusBanner.working "Adding Webhook...") + + Failure _ _ -> + modal_ (StatusBanner.bad "Failed to add Webhook") + + Success _ -> + modal_ (StatusBanner.good "Successfully added Webhook") + in + Modal.view modal diff --git a/src/UnisonShare/Page/ProjectSettingsPage.elm b/src/UnisonShare/Page/ProjectSettingsPage.elm index 3b3504a7..2bc37771 100644 --- a/src/UnisonShare/Page/ProjectSettingsPage.elm +++ b/src/UnisonShare/Page/ProjectSettingsPage.elm @@ -1,6 +1,6 @@ module UnisonShare.Page.ProjectSettingsPage exposing (..) -import Html exposing (Html, div, footer, h2, text) +import Html exposing (Html, div, footer, h2, header, strong, text) import Html.Attributes exposing (class) import Http exposing (Error) import Json.Decode as Decode @@ -23,7 +23,9 @@ import UI.PageTitle as PageTitle import UI.Placeholder as Placeholder import UI.ProfileSnippet as ProfileSnippet import UI.StatusBanner as StatusBanner +import UI.Tag as Tag import UnisonShare.AddProjectCollaboratorModal as AddProjectCollaboratorModal +import UnisonShare.AddProjectWebhookModal as AddProjectWebhookModal import UnisonShare.Api as ShareApi import UnisonShare.AppContext exposing (AppContext) import UnisonShare.Org as Org exposing (OrgSummary) @@ -32,6 +34,7 @@ import UnisonShare.Project as Project exposing (ProjectDetails, ProjectVisibilit import UnisonShare.Project.ProjectRef as ProjectRef exposing (ProjectRef) import UnisonShare.ProjectCollaborator as ProjectCollaborator exposing (ProjectCollaborator) import UnisonShare.ProjectRole as ProjectRole +import UnisonShare.ProjectWebhook exposing (ProjectWebhook) import UnisonShare.Session as Session exposing (Session) import UnisonShare.User as User exposing (UserSummary) @@ -59,6 +62,7 @@ type alias DeleteProject = type Modal = NoModal | AddCollaboratorModal AddProjectCollaboratorModal.Model + | AddWebhookModal AddProjectWebhookModal.Model type ProjectOwner @@ -68,6 +72,7 @@ type ProjectOwner type alias Model = { collaborators : WebData (List ProjectCollaborator) + , webhooks : WebData (List ProjectWebhook) , owner : WebData ProjectOwner , modal : Modal , form : Form @@ -82,6 +87,7 @@ switching between project subpages when the project data is already fetched. preInit : Model preInit = { collaborators = NotAsked + , webhooks = Success [] , owner = NotAsked , modal = NoModal , form = NoChanges @@ -97,6 +103,7 @@ project pages. init : AppContext -> ProjectRef -> ( Model, Cmd Msg ) init appContext projectRef = ( { collaborators = Loading + , webhooks = Success [] , owner = Loading , modal = NoModal , form = NoChanges @@ -122,10 +129,12 @@ type Msg | ClearAfterSave | ShowDeleteProjectModal | ShowAddCollaboratorModal + | ShowAddWebhookModal | CloseModal | RemoveCollaborator ProjectCollaborator | RemoveCollaboratorFinished (HttpResult ()) | AddProjectCollaboratorModalMsg AddProjectCollaboratorModal.Msg + | AddProjectWebhookModalMsg AddProjectWebhookModal.Msg type OutMsg @@ -184,6 +193,9 @@ update appContext project msg model = ( ShowAddCollaboratorModal, _ ) -> ( { model | modal = AddCollaboratorModal AddProjectCollaboratorModal.init }, Cmd.none, None ) + ( ShowAddWebhookModal, _ ) -> + ( { model | modal = AddWebhookModal AddProjectWebhookModal.init }, Cmd.none, None ) + ( CloseModal, _ ) -> ( { model | modal = NoModal }, Cmd.none, None ) @@ -228,6 +240,43 @@ update appContext project msg model = _ -> ( model, Cmd.none, None ) + ( AddProjectWebhookModalMsg webhookMsg, _ ) -> + case ( model.modal, model.webhooks ) of + ( AddWebhookModal m, Success currentWebhooks ) -> + let + ( modal, cmd, out ) = + AddProjectWebhookModal.update + appContext + project.ref + webhookMsg + m + in + case out of + AddProjectWebhookModal.NoOutMsg -> + ( { model | modal = AddWebhookModal modal } + , Cmd.map AddProjectWebhookModalMsg cmd + , None + ) + + AddProjectWebhookModal.RequestCloseModal -> + ( { model | modal = NoModal } + , Cmd.map AddProjectWebhookModalMsg cmd + , None + ) + + AddProjectWebhookModal.AddedWebhook webhook -> + let + webhooks = + Success (webhook :: currentWebhooks) + in + ( { model | modal = AddWebhookModal modal, webhooks = webhooks } + , Cmd.batch [ Cmd.map AddProjectWebhookModalMsg cmd, Util.delayMsg 1500 CloseModal ] + , None + ) + + _ -> + ( model, Cmd.none, None ) + _ -> ( model, Cmd.none, None ) @@ -322,12 +371,14 @@ viewLoadingPage = viewCollaborators : Model -> Html Msg viewCollaborators model = let - collabs = + ( collabs, addButton ) = case model.collaborators of Success collaborators -> let - addButton = + addButton_ = Button.iconThenLabel ShowAddCollaboratorModal Icon.plus "Add a collaborator" + |> Button.small + |> Button.view viewCollaborator collab = div [ class "collaborator" ] @@ -344,46 +395,122 @@ viewCollaborators model = content = if List.isEmpty collaborators then - div [ class "collaborators_empty-state" ] + ( div [ class "collaborators_empty-state" ] [ div [ class "collaborators_empty-state_text" ] [ Icon.view Icon.userGroup, text "You haven't invited any collaborators yet" ] - , Button.view addButton ] + , addButton_ + ) else - div [ class "collaborators" ] - [ addButton |> Button.small |> Button.view - , Divider.divider + ( div [ class "collaborators" ] + [ Divider.divider |> Divider.withoutMargin |> Divider.small |> Divider.view , div [ class "collaborators_list" ] (List.map viewCollaborator collaborators) ] + , addButton_ + ) in content Failure _ -> - div [ class "collaborators_error" ] + ( div [ class "collaborators_error" ] [ StatusBanner.bad "Could not load collaborators" ] + , UI.nothing + ) _ -> - div [ class "collaborators_loading" ] + ( div [ class "collaborators_loading" ] [ Placeholder.text |> Placeholder.withLength Placeholder.Small |> Placeholder.view , Placeholder.text |> Placeholder.withLength Placeholder.Medium |> Placeholder.view , Placeholder.text |> Placeholder.withLength Placeholder.Huge |> Placeholder.view , Placeholder.text |> Placeholder.withLength Placeholder.Large |> Placeholder.view ] + , UI.nothing + ) in Card.card - [ h2 [] [ text "Project Collaborators" ] + [ header [ class "project-settings_card_header" ] [ h2 [] [ text "Project Collaborators" ], addButton ] , collabs ] |> Card.asContained |> Card.view +type alias Webhook = + { events : List String + , url : String + } + + +viewWebhook : Webhook -> Html Msg +viewWebhook webhook = + let + eventIcon event = + if String.startsWith "Branch" event then + Icon.branch + + else if String.startsWith "Contribution" event then + Icon.merge + + else if String.startsWith "Ticket" event then + Icon.bug + + else + Icon.bolt + + viewEventTag event = + Tag.tag event |> Tag.withIcon (eventIcon event) |> Tag.view + + viewAction msg icon = + Button.icon msg icon + |> Button.small + |> Button.subdued + |> Button.view + in + div [ class "webhook" ] + [ div + [ class "webhook_details" ] + [ strong [ class "webhook_url" ] [ Icon.view Icon.wireframeGlobe, text webhook.url ] + , div [ class "webhook_events" ] (List.map viewEventTag webhook.events) + ] + , div + [ class "webhook_actions" ] + [ viewAction ShowAddCollaboratorModal Icon.writingPad + , viewAction ShowAddCollaboratorModal Icon.trash + ] + ] + + +viewWebhooks : Model -> Html Msg +viewWebhooks _ = + let + divider = + Divider.divider |> Divider.small |> Divider.view + + addButton = + Button.iconThenLabel ShowAddWebhookModal Icon.plus "Add a webhook" + |> Button.small + |> Button.view + + webhooks = + [ viewWebhook { events = [ "Branch updated", "Contribution created" ], url = "https://example.com" } + , viewWebhook { events = [ "Contribution created" ], url = "https://example.com" } + , viewWebhook { events = [ "Ticket updated" ], url = "https://example.com" } + ] + in + Card.card + [ header [ class "project-settings_card_header" ] [ h2 [] [ text "Webhooks" ], addButton ] + , div [ class "webhooks" ] (List.intersperse divider webhooks) + ] + |> Card.asContained + |> Card.view + + viewPageContent : ProjectDetails -> Model -> PageContent Msg viewPageContent project model = let @@ -460,10 +587,7 @@ viewPageContent project model = Card.card [ h2 [] [ text "Project Visibility" ] , message_ - , div [ class "form" ] - [ overlay - , RadioField.view projectVisibilityField - ] + , div [ class "form" ] [ overlay, RadioField.view projectVisibilityField ] ] |> Card.asContained |> Card.view @@ -533,10 +657,13 @@ viewPageContent project model = else UI.nothing + + webhooks = + viewWebhooks model in PageContent.oneColumn [ div [ class "settings-content", class stateClass ] - (collaborators :: formAndActions) + (collaborators :: webhooks :: formAndActions) ] |> pageTitle_ @@ -550,6 +677,9 @@ view session project model = AddCollaboratorModal m -> Just (Html.map AddProjectCollaboratorModalMsg (AddProjectCollaboratorModal.view m)) + AddWebhookModal m -> + Just (Html.map AddProjectWebhookModalMsg (AddProjectWebhookModal.view m)) + _ -> Nothing in diff --git a/src/UnisonShare/ProjectWebhook.elm b/src/UnisonShare/ProjectWebhook.elm new file mode 100644 index 00000000..03b72f03 --- /dev/null +++ b/src/UnisonShare/ProjectWebhook.elm @@ -0,0 +1,19 @@ +module UnisonShare.ProjectWebhook exposing (..) + +import Json.Decode as Decode +import Json.Decode.Pipeline exposing (required) +import Lib.Decode.Helpers as DecodeH +import Url exposing (Url) + + +type alias ProjectWebhook = + { url : Url + , events : List String + } + + +decode : Decode.Decoder ProjectWebhook +decode = + Decode.succeed ProjectWebhook + |> required "url" DecodeH.url + |> required "events" (Decode.list Decode.string) diff --git a/src/css/unison-share.css b/src/css/unison-share.css index 55500a09..81b44577 100644 --- a/src/css/unison-share.css +++ b/src/css/unison-share.css @@ -9,6 +9,7 @@ @import "./unison-share/use-project-modal.css"; @import "./unison-share/publish-project-release-modal.css"; @import "./unison-share/add-project-collaborator-modal.css"; +@import "./unison-share/add-project-webhook-modal.css"; @import "./unison-share/add-org-member-modal.css"; @import "./unison-share/project-contribution-form-modal.css"; @import "./unison-share/project-ticket-form-modal.css"; diff --git a/src/css/unison-share/add-project-webhook-modal.css b/src/css/unison-share/add-project-webhook-modal.css new file mode 100644 index 00000000..84200bb5 --- /dev/null +++ b/src/css/unison-share/add-project-webhook-modal.css @@ -0,0 +1,23 @@ +#add-project-webhook-modal { + --c-add-project-webhook-modal_width: 38rem; + width: var(--c-add-project-webhook-modal_width); + + & .event-selection_groups { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 1.5rem; + } + + & .event-selection .form-field .label { + font-weight: normal; + } + + & .form-field.radio-field { + flex-direction: row; + + & .radio-field_option { + width: calc(50% - 0.125rem); + } + } +} diff --git a/src/css/unison-share/page/project-settings-page.css b/src/css/unison-share/page/project-settings-page.css index 6c673695..2dcfd913 100644 --- a/src/css/unison-share/page/project-settings-page.css +++ b/src/css/unison-share/page/project-settings-page.css @@ -9,6 +9,13 @@ position: relative; } +.project-settings-page .settings-content .card .project-settings_card_header { + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; +} + .project-settings-page .settings-content .disabled-overlay { background: var(--u-color_container); border-radius: var(--border-radius-base); @@ -81,6 +88,57 @@ gap: 0.5rem; } +.project-settings-page .webhooks { + display: flex; + width: 100%; + flex-direction: column; + gap: 0.5rem; + font-size: var(--font-size-medium); + color: var(--u-color_text); + + & .webhook { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + width: 100%; + } + + & .webhook_url { + display: flex; + flex-direction: row; + align-items: center; + line-height: 1; + gap: 0.25rem; + + & .icon { + color: var(--u-color_icon_subdued); + } + } + + & .webhook_details { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + & .webhook_details .webhook_events { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + gap: 0.125rem; + margin-left: 1rem; + } + + & .webhook_actions { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.125rem; + } +} + .project-settings-page .page-content .actions { display: flex; flex-direction: row; From b8ae0e0616280b88bb4b5155ca09a75b9dba7bcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8jberg?= Date: Wed, 8 Oct 2025 16:32:21 -0400 Subject: [PATCH 2/4] Connect the Webhook configuration UI with API * Hook up the AddWebhookModal and the ProjectSettings page to the new webhook endpoints allowing users to add and remove webhooks. * Also add validation feedback for URL input and topic selection. * Rename "event" to "topic" to better match the backend naming convention --- elm-git.json | 2 +- src/UnisonShare/AddProjectWebhookModal.elm | 295 +++++++++++------- src/UnisonShare/Api.elm | 50 +++ src/UnisonShare/ErrorCard.elm | 19 +- src/UnisonShare/ErrorDetails.elm | 19 ++ src/UnisonShare/NotificationTopicType.elm | 89 ++++++ src/UnisonShare/Page/ProjectPage.elm | 6 +- src/UnisonShare/Page/ProjectSettingsPage.elm | 125 ++++++-- src/UnisonShare/ProjectWebhook.elm | 54 +++- .../add-project-webhook-modal.css | 4 +- 10 files changed, 492 insertions(+), 171 deletions(-) create mode 100644 src/UnisonShare/ErrorDetails.elm create mode 100644 src/UnisonShare/NotificationTopicType.elm diff --git a/elm-git.json b/elm-git.json index 795b301b..e8c182d8 100644 --- a/elm-git.json +++ b/elm-git.json @@ -5,4 +5,4 @@ }, "indirect": {} } -} +} \ No newline at end of file diff --git a/src/UnisonShare/AddProjectWebhookModal.elm b/src/UnisonShare/AddProjectWebhookModal.elm index a7362d0d..a885c442 100644 --- a/src/UnisonShare/AddProjectWebhookModal.elm +++ b/src/UnisonShare/AddProjectWebhookModal.elm @@ -3,7 +3,8 @@ module UnisonShare.AddProjectWebhookModal exposing (..) import Html exposing (Html, div) import Html.Attributes exposing (class) import Http -import Lib.HttpApi exposing (HttpResult) +import Json.Decode as Decode +import Lib.HttpApi as HttpApi exposing (HttpResult) import List.Extra as ListE import List.Nonempty as NEL import UI @@ -16,38 +17,31 @@ import UI.Icon as Icon import UI.Modal as Modal import UI.ProfileSnippet as ProfileSnippet import UI.StatusBanner as StatusBanner +import UnisonShare.Api as ShareApi import UnisonShare.AppContext exposing (AppContext) -import UnisonShare.Link as Link +import UnisonShare.NotificationTopicType exposing (NotificationTopicType(..)) import UnisonShare.Project.ProjectRef exposing (ProjectRef) -import UnisonShare.ProjectWebhook exposing (ProjectWebhook) +import UnisonShare.ProjectWebhook as ProjectWebhook exposing (ProjectWebhook, ProjectWebhookForm, ProjectWebhookTopics(..)) import UnisonShare.User exposing (UserSummaryWithId) - - -type NotificationEventType - = ProjectContributionCreated - | ProjectContributionUpdated - | ProjectContributionComment - | ProjectTicketCreated - | ProjectTicketUpdated - | ProjectTicketComment - | ProjectBranchUpdated - | ProjectReleaseCreated - - -type WebhookEvents - = AllEvents - | SpecificEvents (List NotificationEventType) +import Url type alias Form = { url : String - , events : WebhookEvents - , isActive : Bool + , topics : ProjectWebhookTopics } +type Validation + = NotChecked + | Valid + | InvalidUrlAndSelection + | InvalidUrl + | InvalidSelection + + type Model - = Edit Form + = Edit Validation Form | Saving Form | Failure Http.Error Form | Success ProjectWebhook @@ -55,7 +49,7 @@ type Model init : Model init = - Edit { url = "", events = AllEvents, isActive = True } + Edit NotChecked { url = "", topics = AllTopics } @@ -65,11 +59,10 @@ init = type Msg = CloseModal | UpdateUrl String - | SetWebhookEvents WebhookEvents - | ToggleEvent NotificationEventType - | ToggleIsActive + | SetProjectWebhookTopics ProjectWebhookTopics + | ToggleTopic NotificationTopicType | AddWebhook - | AddWebhookFinished (HttpResult ()) + | AddWebhookFinished (HttpResult ProjectWebhook) type OutMsg @@ -79,38 +72,60 @@ type OutMsg update : AppContext -> ProjectRef -> Msg -> Model -> ( Model, Cmd Msg, OutMsg ) -update _ _ msg model = +update appContext projectRef msg model = case ( msg, model ) of - ( UpdateUrl url, Edit f ) -> - ( Edit { f | url = url }, Cmd.none, NoOutMsg ) + ( UpdateUrl url, Edit v f ) -> + ( Edit v { f | url = url }, Cmd.none, NoOutMsg ) - ( SetWebhookEvents events, Edit f ) -> - ( Edit { f | events = events }, Cmd.none, NoOutMsg ) + ( SetProjectWebhookTopics topics, Edit v f ) -> + ( Edit v { f | topics = topics }, Cmd.none, NoOutMsg ) - ( ToggleEvent eventType, Edit f ) -> + ( ToggleTopic topicType, Edit v f ) -> let - events = - case f.events of - AllEvents -> - SpecificEvents [ eventType ] + topics = + case f.topics of + AllTopics -> + SelectedTopics [ topicType ] - SpecificEvents evts -> - if List.member eventType evts then - SpecificEvents (ListE.remove eventType evts) + SelectedTopics evts -> + if List.member topicType evts then + SelectedTopics (ListE.remove topicType evts) else - SpecificEvents (evts ++ [ eventType ]) + SelectedTopics (evts ++ [ topicType ]) in - ( Edit { f | events = events }, Cmd.none, NoOutMsg ) + ( Edit v { f | topics = topics }, Cmd.none, NoOutMsg ) - ( ToggleIsActive, Edit f ) -> - ( Edit { f | isActive = not f.isActive }, Cmd.none, NoOutMsg ) + ( AddWebhook, Edit _ f ) -> + let + hasTopics = + case f.topics of + AllTopics -> + True - ( AddWebhook, _ ) -> - ( model, Cmd.none, NoOutMsg ) + SelectedTopics topics -> + not (List.isEmpty topics) + in + case ( Url.fromString f.url, hasTopics ) of + ( Just url, True ) -> + ( Saving f, addWebhook appContext projectRef { url = url, topics = f.topics }, NoOutMsg ) - ( AddWebhookFinished _, _ ) -> - ( model, Cmd.none, NoOutMsg ) + ( Just _, False ) -> + ( Edit InvalidSelection f, Cmd.none, NoOutMsg ) + + ( Nothing, True ) -> + ( Edit InvalidUrl f, Cmd.none, NoOutMsg ) + + ( Nothing, False ) -> + ( Edit InvalidUrlAndSelection f, Cmd.none, NoOutMsg ) + + ( AddWebhookFinished res, Saving f ) -> + case res of + Ok webhook -> + ( Success webhook, Cmd.none, AddedWebhook webhook ) + + Err e -> + ( Failure e f, Cmd.none, NoOutMsg ) ( CloseModal, _ ) -> ( model, Cmd.none, RequestCloseModal ) @@ -121,6 +136,17 @@ update _ _ msg model = -- EFFECTS + + +addWebhook : AppContext -> ProjectRef -> ProjectWebhookForm -> Cmd Msg +addWebhook appContext projectRef webhook = + ShareApi.createProjectWebhook projectRef webhook + |> HttpApi.toRequest (Decode.field "webhook" ProjectWebhook.decode) + AddWebhookFinished + |> HttpApi.perform appContext.api + + + -- VIEW @@ -134,30 +160,30 @@ divider = Divider.divider |> Divider.small |> Divider.view -viewEventSelection : List NotificationEventType -> Html Msg -viewEventSelection selected = +viewtopicselection : List NotificationTopicType -> Html Msg +viewtopicselection selected = let - isSelected event = - List.member event selected + isSelected topic = + List.member topic selected - checkbox title event = - CheckboxField.field title (ToggleEvent event) (isSelected event) + checkbox title topic = + CheckboxField.field title (ToggleTopic topic) (isSelected topic) |> CheckboxField.view - contributionEvents = + contributiontopics = [ checkbox "Contribution created" ProjectContributionCreated , checkbox "Contribution updated" ProjectContributionUpdated , checkbox "Contribution comment" ProjectContributionComment ] - ticketEvents = + tickettopics = [ checkbox "Ticket created" ProjectTicketCreated , checkbox "Ticket updated" ProjectTicketUpdated , checkbox "Ticket comment" ProjectTicketComment ] - eventSelectionCheckboxes = - div [ class "event-selection_groups" ] + topicselectionCheckboxes = + div [ class "topic-selection_groups" ] [ div [] [ div [ class "checkboxes" ] [ checkbox "Branch updated" ProjectBranchUpdated @@ -165,14 +191,91 @@ viewEventSelection selected = ] ] , div [] - [ div [ class "checkboxes" ] contributionEvents + [ div [ class "checkboxes" ] contributiontopics ] , div [] - [ div [ class "checkboxes" ] ticketEvents + [ div [ class "checkboxes" ] tickettopics ] ] in - div [ class "event-selection" ] [ divider, eventSelectionCheckboxes ] + div [ class "topic-selection" ] [ divider, topicselectionCheckboxes ] + + +viewEditModal : Validation -> (Html Msg -> Modal.Modal Msg) -> Form -> Modal.Modal Msg +viewEditModal validation toModal form = + let + specifictopicsOption = + case form.topics of + AllTopics -> + SelectedTopics [] + + _ -> + form.topics + + options = + NEL.singleton (RadioField.option "Select specific topics" "The webhook is only called on selected topics" specifictopicsOption) + |> NEL.cons (RadioField.option "All topics" "The webhook is called on all project topics (including future additions)" AllTopics) + + topicselection = + case form.topics of + AllTopics -> + UI.nothing + + SelectedTopics selected -> + viewtopicselection selected + + withInvalidIndicator t = + if validation == InvalidUrlAndSelection || validation == InvalidUrl then + TextField.markAsInvalid t + + else + t + + withInvalidBanner m = + if validation == InvalidUrlAndSelection then + Modal.withLeftSideFooter + [ StatusBanner.bad "Please a URL and select topics." ] + m + + else if validation == InvalidSelection then + Modal.withLeftSideFooter + [ StatusBanner.bad "Please select topics." ] + m + + else + m + in + toModal + (div [] + [ TextField.field UpdateUrl "Webhook URL" form.url + |> TextField.withIcon Icon.wireframeGlobe + |> TextField.withPlaceholder "https://example.com" + |> TextField.withHelpText "Provide the *full* URL that will be called when the selected topics are triggered." + |> withInvalidIndicator + |> TextField.view + , divider + , RadioField.field "topics" SetProjectWebhookTopics options form.topics |> RadioField.view + , topicselection + ] + ) + |> withInvalidBanner + |> Modal.withActions + [ Button.button CloseModal "Cancel" + |> Button.subdued + , Button.button AddWebhook "Add Webhook" + |> Button.emphasized + ] + + + +{- + |> Modal.withLeftSideFooter + [ Button.iconThenLabel_ Link.docs Icon.docs "Webhook request format docs" + |> Button.small + |> Button.outlined + |> Button.view + ] +-} view : Model -> Html Msg @@ -185,63 +288,25 @@ view model = modal = case model of - Edit form -> - let - specificEventsOption = - case form.events of - AllEvents -> - SpecificEvents [] - - _ -> - form.events + Edit validation form -> + viewEditModal validation modal_ form - options = - NEL.singleton (RadioField.option "Select specific events" "The webhook is only called on selected events" specificEventsOption) - |> NEL.cons (RadioField.option "All events" "The webhook is called on all project events (including future additions)" AllEvents) + Saving f -> + viewEditModal NotChecked modal_ f + |> Modal.withLeftSideFooter [ StatusBanner.working "Adding Webhook..." ] + |> Modal.withDimOverlay True - eventSelection = - case form.events of - AllEvents -> - UI.nothing + Failure _ f -> + viewEditModal NotChecked modal_ f + |> Modal.withLeftSideFooter [ StatusBanner.bad "Failed to add Webhook" ] - SpecificEvents selected -> - viewEventSelection selected + Success webhook -> + let + form = + { url = Url.toString webhook.url, topics = webhook.topics } in - modal_ - (div [] - [ TextField.field UpdateUrl "Webhook URL" form.url - |> TextField.withIcon Icon.wireframeGlobe - |> TextField.withHelpText "This URL will be called when the selected events are triggered." - |> TextField.view - , divider - , RadioField.field "Events" SetWebhookEvents options form.events |> RadioField.view - , eventSelection - , divider - , CheckboxField.field "Active" ToggleIsActive form.isActive - |> CheckboxField.withHelpText "Actively call the Webhook URL when selected events are triggered." - |> CheckboxField.view - ] - ) - |> Modal.withActions - [ Button.button CloseModal "Cancel" - |> Button.subdued - , Button.button AddWebhook "Add Webhook" - |> Button.emphasized - ] - |> Modal.withLeftSideFooter - [ Button.iconThenLabel_ Link.docs Icon.docs "Webhook request format docs" - |> Button.small - |> Button.outlined - |> Button.view - ] - - Saving _ -> - modal_ (StatusBanner.working "Adding Webhook...") - - Failure _ _ -> - modal_ (StatusBanner.bad "Failed to add Webhook") - - Success _ -> - modal_ (StatusBanner.good "Successfully added Webhook") + viewEditModal NotChecked modal_ form + |> Modal.withLeftSideFooter [ StatusBanner.good "Successfully added Webhook" ] + |> Modal.withDimOverlay True in Modal.view modal diff --git a/src/UnisonShare/Api.elm b/src/UnisonShare/Api.elm index 57516d1c..378d4df4 100644 --- a/src/UnisonShare/Api.elm +++ b/src/UnisonShare/Api.elm @@ -32,10 +32,12 @@ import UnisonShare.Project as Project exposing (ProjectVisibility) import UnisonShare.Project.ProjectRef as ProjectRef exposing (ProjectRef) import UnisonShare.ProjectCollaborator exposing (ProjectCollaborator) import UnisonShare.ProjectRole as ProjectRole +import UnisonShare.ProjectWebhook as ProjectWebhook exposing (ProjectWebhook, ProjectWebhookForm) import UnisonShare.Ticket.TicketRef as TicketRef exposing (TicketRef) import UnisonShare.Ticket.TicketStatus as TicketStatus exposing (TicketStatus) import UnisonShare.Timeline.CommentId as CommentId exposing (CommentId) import UnisonShare.Tour as Tour exposing (Tour) +import Url import Url.Builder exposing (QueryParameter, int, string) @@ -515,6 +517,54 @@ deleteProjectRoleAssignment projectRef collaborator = +-- PROJECT WEBHOOKS + + +projectWebhooks : ProjectRef -> Endpoint +projectWebhooks projectRef = + let + ( handle, slug ) = + ProjectRef.toApiStringParts projectRef + in + GET + { path = [ "users", handle, "projects", slug, "webhooks" ] + , queryParams = [] + } + + +createProjectWebhook : ProjectRef -> ProjectWebhookForm -> Endpoint +createProjectWebhook projectRef webhook = + let + ( handle, slug ) = + ProjectRef.toApiStringParts projectRef + + body = + Encode.object + [ ( "uri", Encode.string (Url.toString webhook.url) ) + , ( "topics", ProjectWebhook.encodeTopics webhook.topics ) + ] + in + POST + { path = [ "users", handle, "projects", slug, "webhooks" ] + , queryParams = [] + , body = Http.jsonBody body + } + + +deleteProjectWebhook : ProjectRef -> ProjectWebhook -> Endpoint +deleteProjectWebhook projectRef webhook = + let + ( handle, slug ) = + ProjectRef.toApiStringParts projectRef + in + DELETE + { path = [ "users", handle, "projects", slug, "webhooks", webhook.id ] + , queryParams = [] + , body = Http.emptyBody + } + + + -- PROJECT CONTRIBUTIONS diff --git a/src/UnisonShare/ErrorCard.elm b/src/UnisonShare/ErrorCard.elm index 75effcf5..99e302a2 100644 --- a/src/UnisonShare/ErrorCard.elm +++ b/src/UnisonShare/ErrorCard.elm @@ -1,29 +1,18 @@ module UnisonShare.ErrorCard exposing (..) -import Html exposing (Html, details, div, summary, text) +import Html exposing (Html) import Http -import Lib.Util as Util -import UI import UI.Card as Card exposing (Card) import UI.StatusBanner as StatusBanner -import UnisonShare.Session as Session exposing (Session) +import UnisonShare.ErrorDetails as ErrorDetails +import UnisonShare.Session exposing (Session) card : Session -> Http.Error -> String -> String -> Card msg card session error entityName className = - let - errorDetails = - if Session.isSuperAdmin session then - details [] [ summary [] [ text "Error Details" ], div [] [ text (Util.httpErrorToString error) ] ] - - else - UI.nothing - - -- details [] [ summary [] [ text "Error Details" ], div [] [ text (Util.httpErrorToString error) ] ] - in Card.card [ StatusBanner.bad ("Something broke on our end and we couldn't show the " ++ entityName ++ ". Please try again.") - , errorDetails + , ErrorDetails.view session error ] |> Card.withClassName className |> Card.asContained diff --git a/src/UnisonShare/ErrorDetails.elm b/src/UnisonShare/ErrorDetails.elm new file mode 100644 index 00000000..e8a3aba6 --- /dev/null +++ b/src/UnisonShare/ErrorDetails.elm @@ -0,0 +1,19 @@ +module UnisonShare.ErrorDetails exposing (..) + +import Html exposing (Html, details, pre, summary, text) +import Http +import Lib.Util as Util +import UI +import UnisonShare.Session as Session exposing (Session) + + +view : Session -> Http.Error -> Html msg +view session error = + if Session.isSuperAdmin session then + details [] + [ summary [] [ text "Error Details" ] + , pre [] [ text (Util.httpErrorToString error) ] + ] + + else + UI.nothing diff --git a/src/UnisonShare/NotificationTopicType.elm b/src/UnisonShare/NotificationTopicType.elm new file mode 100644 index 00000000..b28eea56 --- /dev/null +++ b/src/UnisonShare/NotificationTopicType.elm @@ -0,0 +1,89 @@ +module UnisonShare.NotificationTopicType exposing (..) + +import Json.Decode as Decode +import Json.Decode.Extra exposing (when) + + +type NotificationTopicType + = ProjectContributionCreated + | ProjectContributionUpdated + | ProjectContributionComment + | ProjectTicketCreated + | ProjectTicketUpdated + | ProjectTicketComment + | ProjectBranchUpdated + | ProjectReleaseCreated + + +toApiString : NotificationTopicType -> String +toApiString topic = + case topic of + ProjectContributionCreated -> + "project:contribution:created" + + ProjectContributionUpdated -> + "project:contribution:updated" + + ProjectContributionComment -> + "project:contribution:comment" + + ProjectTicketCreated -> + "project:ticket:created" + + ProjectTicketUpdated -> + "project:ticket:updated" + + ProjectTicketComment -> + "project:ticket:comment" + + ProjectBranchUpdated -> + "project:branch:updated" + + ProjectReleaseCreated -> + "project:release:created" + + +toString : NotificationTopicType -> String +toString topic = + case topic of + ProjectContributionCreated -> + "ProjectContributionCreated" + + ProjectContributionUpdated -> + "ProjectContributionUpdated" + + ProjectContributionComment -> + "ProjectContributionComment" + + ProjectTicketCreated -> + "ProjectTicketCreated" + + ProjectTicketUpdated -> + "ProjectTicketUpdated" + + ProjectTicketComment -> + "ProjectTicketComment" + + ProjectBranchUpdated -> + "ProjectBranchUpdated" + + ProjectReleaseCreated -> + "ProjectReleaseCreated" + + + +-- DECODE + + +decode : Decode.Decoder NotificationTopicType +decode = + Decode.oneOf + [ when Decode.string ((==) "project:ticket:comment") (Decode.succeed ProjectTicketComment) + , when Decode.string ((==) "project:ticket:created") (Decode.succeed ProjectTicketCreated) + , when Decode.string ((==) "project:ticket:updated") (Decode.succeed ProjectTicketUpdated) + , when Decode.string ((==) "project:contribution:comment") (Decode.succeed ProjectContributionComment) + , when Decode.string ((==) "project:contribution:created") (Decode.succeed ProjectContributionCreated) + , when Decode.string ((==) "project:contribution:updated") (Decode.succeed ProjectContributionUpdated) + , when Decode.string ((==) "project:branch:updated") (Decode.succeed ProjectBranchUpdated) + , when Decode.string ((==) "project:release:created") (Decode.succeed ProjectReleaseCreated) + ] diff --git a/src/UnisonShare/Page/ProjectPage.elm b/src/UnisonShare/Page/ProjectPage.elm index 0f6aeed0..d22c4489 100644 --- a/src/UnisonShare/Page/ProjectPage.elm +++ b/src/UnisonShare/Page/ProjectPage.elm @@ -292,11 +292,15 @@ update appContext projectRef route msg model = ( fetchingOwner, fetchingOwnerCmd ) = ProjectSettingsPage.fetchProjectOwner appContext projectRef fetchingCollabs + + ( fetchingWebhooks, fetchingWebhooksCmd ) = + ProjectSettingsPage.fetchProjectWebhooks appContext projectRef fetchingOwner in - ( { modelWithProject | subPage = Settings fetchingOwner } + ( { modelWithProject | subPage = Settings fetchingWebhooks } , Cmd.batch [ Cmd.map ProjectSettingsPageMsg fetchingCollabsCmd , Cmd.map ProjectSettingsPageMsg fetchingOwnerCmd + , Cmd.map ProjectSettingsPageMsg fetchingWebhooksCmd ] ) diff --git a/src/UnisonShare/Page/ProjectSettingsPage.elm b/src/UnisonShare/Page/ProjectSettingsPage.elm index 2bc37771..227c5357 100644 --- a/src/UnisonShare/Page/ProjectSettingsPage.elm +++ b/src/UnisonShare/Page/ProjectSettingsPage.elm @@ -28,15 +28,18 @@ import UnisonShare.AddProjectCollaboratorModal as AddProjectCollaboratorModal import UnisonShare.AddProjectWebhookModal as AddProjectWebhookModal import UnisonShare.Api as ShareApi import UnisonShare.AppContext exposing (AppContext) +import UnisonShare.ErrorDetails as ErrorDetails +import UnisonShare.NotificationTopicType as NotificationTopicType import UnisonShare.Org as Org exposing (OrgSummary) import UnisonShare.PageFooter as PageFooter import UnisonShare.Project as Project exposing (ProjectDetails, ProjectVisibility(..)) import UnisonShare.Project.ProjectRef as ProjectRef exposing (ProjectRef) import UnisonShare.ProjectCollaborator as ProjectCollaborator exposing (ProjectCollaborator) import UnisonShare.ProjectRole as ProjectRole -import UnisonShare.ProjectWebhook exposing (ProjectWebhook) +import UnisonShare.ProjectWebhook as ProjectWebhook exposing (ProjectWebhook) import UnisonShare.Session as Session exposing (Session) import UnisonShare.User as User exposing (UserSummary) +import Url @@ -111,6 +114,7 @@ init appContext projectRef = , Cmd.batch [ fetchOwner appContext (ProjectRef.handle projectRef) , fetchCollaborators appContext projectRef + , fetchWebhooks appContext projectRef ] ) @@ -122,6 +126,7 @@ init appContext projectRef = type Msg = FetchCollaboratorsFinished (WebData (List ProjectCollaborator)) | FetchOwnerFinished (WebData ProjectOwner) + | FetchProjectWebhooksFinished (WebData (List ProjectWebhook)) | UpdateVisibility ProjectVisibility | DiscardChanges | SaveChanges @@ -132,7 +137,9 @@ type Msg | ShowAddWebhookModal | CloseModal | RemoveCollaborator ProjectCollaborator + | RemoveWebhook ProjectWebhook | RemoveCollaboratorFinished (HttpResult ()) + | RemoveWebhookFinished (HttpResult ()) | AddProjectCollaboratorModalMsg AddProjectCollaboratorModal.Msg | AddProjectWebhookModalMsg AddProjectWebhookModal.Msg @@ -158,6 +165,9 @@ update appContext project msg model = ( FetchOwnerFinished owner, _ ) -> ( { model | owner = owner }, Cmd.none, None ) + ( FetchProjectWebhooksFinished webhooks, _ ) -> + ( { model | webhooks = webhooks }, Cmd.none, None ) + ( UpdateVisibility newVisibility, WithChanges c ) -> if newVisibility /= project.visibility then ( { model | form = WithChanges { c | visibility = newVisibility } }, Cmd.none, None ) @@ -207,6 +217,14 @@ update appContext project msg model = in ( { model | collaborators = collaborators }, removeCollaborator appContext project.ref collab, None ) + ( RemoveWebhook webhook, _ ) -> + let + webhooks = + model.webhooks + |> RemoteData.map (List.filter (\w -> w /= webhook)) + in + ( { model | webhooks = webhooks }, removeWebhook appContext project.ref webhook, None ) + ( AddProjectCollaboratorModalMsg collabMsg, _ ) -> case ( model.modal, model.collaborators ) of ( AddCollaboratorModal m, Success currentCollabs ) -> @@ -270,7 +288,7 @@ update appContext project msg model = Success (webhook :: currentWebhooks) in ( { model | modal = AddWebhookModal modal, webhooks = webhooks } - , Cmd.batch [ Cmd.map AddProjectWebhookModalMsg cmd, Util.delayMsg 1500 CloseModal ] + , Cmd.batch [ Cmd.map AddProjectWebhookModalMsg cmd, Util.delayMsg 1000 CloseModal ] , None ) @@ -281,18 +299,29 @@ update appContext project msg model = ( model, Cmd.none, None ) + +-- EFFECTS + + +{-| Used by parent, ProjectPage +-} fetchProjectCollaborators : AppContext -> ProjectRef -> Model -> ( Model, Cmd Msg ) fetchProjectCollaborators appContext projectRef model = ( { model | collaborators = Loading }, fetchCollaborators appContext projectRef ) +{-| Used by parent, ProjectPage +-} fetchProjectOwner : AppContext -> ProjectRef -> Model -> ( Model, Cmd Msg ) fetchProjectOwner appContext projectRef model = ( { model | owner = Loading }, fetchOwner appContext (ProjectRef.handle projectRef) ) - --- EFFECTS +{-| Used by parent, ProjectPage +-} +fetchProjectWebhooks : AppContext -> ProjectRef -> Model -> ( Model, Cmd Msg ) +fetchProjectWebhooks appContext projectRef model = + ( { model | webhooks = Loading }, fetchWebhooks appContext projectRef ) fetchCollaborators : AppContext -> ProjectRef -> Cmd Msg @@ -332,6 +361,22 @@ updateProjectSettings appContext projectRef changes = |> HttpApi.perform appContext.api +fetchWebhooks : AppContext -> ProjectRef -> Cmd Msg +fetchWebhooks appContext projectRef = + ShareApi.projectWebhooks projectRef + |> HttpApi.toRequest + (Decode.field "webhooks" (Decode.list ProjectWebhook.decode)) + (RemoteData.fromResult >> FetchProjectWebhooksFinished) + |> HttpApi.perform appContext.api + + +removeWebhook : AppContext -> ProjectRef -> ProjectWebhook -> Cmd Msg +removeWebhook appContext projectRef webhook = + ShareApi.deleteProjectWebhook projectRef webhook + |> HttpApi.toRequestWithEmptyResponse RemoveWebhookFinished + |> HttpApi.perform appContext.api + + -- VIEW @@ -441,16 +486,10 @@ viewCollaborators model = |> Card.view -type alias Webhook = - { events : List String - , url : String - } - - -viewWebhook : Webhook -> Html Msg +viewWebhook : ProjectWebhook -> Html Msg viewWebhook webhook = let - eventIcon event = + topicIcon event = if String.startsWith "Branch" event then Icon.branch @@ -463,31 +502,40 @@ viewWebhook webhook = else Icon.bolt - viewEventTag event = - Tag.tag event |> Tag.withIcon (eventIcon event) |> Tag.view + viewTopicTag topic = + Tag.tag topic |> Tag.withIcon (topicIcon topic) |> Tag.view viewAction msg icon = Button.icon msg icon |> Button.small |> Button.subdued |> Button.view + + viewTopics = + case webhook.topics of + ProjectWebhook.AllTopics -> + [ viewTopicTag "All" ] + + ProjectWebhook.SelectedTopics topics -> + List.map + (NotificationTopicType.toString >> viewTopicTag) + topics in div [ class "webhook" ] [ div [ class "webhook_details" ] - [ strong [ class "webhook_url" ] [ Icon.view Icon.wireframeGlobe, text webhook.url ] - , div [ class "webhook_events" ] (List.map viewEventTag webhook.events) + [ strong [ class "webhook_url" ] [ Icon.view Icon.wireframeGlobe, text (Url.toString webhook.url) ] + , div [ class "webhook_events" ] viewTopics ] , div [ class "webhook_actions" ] - [ viewAction ShowAddCollaboratorModal Icon.writingPad - , viewAction ShowAddCollaboratorModal Icon.trash + [ viewAction (RemoveWebhook webhook) Icon.trash ] ] -viewWebhooks : Model -> Html Msg -viewWebhooks _ = +viewWebhooks : Session -> Model -> Html Msg +viewWebhooks session model = let divider = Divider.divider |> Divider.small |> Divider.view @@ -497,22 +545,35 @@ viewWebhooks _ = |> Button.small |> Button.view - webhooks = - [ viewWebhook { events = [ "Branch updated", "Contribution created" ], url = "https://example.com" } - , viewWebhook { events = [ "Contribution created" ], url = "https://example.com" } - , viewWebhook { events = [ "Ticket updated" ], url = "https://example.com" } - ] + content = + case model.webhooks of + Success webhooks -> + [ header [ class "project-settings_card_header" ] [ h2 [] [ text "Webhooks" ], addButton ] + , div [ class "webhooks" ] (webhooks |> List.map viewWebhook |> List.intersperse divider) + ] + + Failure e -> + [ StatusBanner.bad "An unexpected error occurred, please try again." + , ErrorDetails.view session e + ] + + _ -> + [ div [ class "webhooks_loading" ] + [ Placeholder.text |> Placeholder.withLength Placeholder.Small |> Placeholder.view + , Placeholder.text |> Placeholder.withLength Placeholder.Medium |> Placeholder.view + , Placeholder.text |> Placeholder.withLength Placeholder.Huge |> Placeholder.view + , Placeholder.text |> Placeholder.withLength Placeholder.Large |> Placeholder.view + ] + ] in Card.card - [ header [ class "project-settings_card_header" ] [ h2 [] [ text "Webhooks" ], addButton ] - , div [ class "webhooks" ] (List.intersperse divider webhooks) - ] + content |> Card.asContained |> Card.view -viewPageContent : ProjectDetails -> Model -> PageContent Msg -viewPageContent project model = +viewPageContent : Session -> ProjectDetails -> Model -> PageContent Msg +viewPageContent session project model = let activeVisiblityValue = case model.form of @@ -659,7 +720,7 @@ viewPageContent project model = UI.nothing webhooks = - viewWebhooks model + viewWebhooks session model in PageContent.oneColumn [ div [ class "settings-content", class stateClass ] @@ -684,7 +745,7 @@ view session project model = Nothing in ( PageLayout.centeredNarrowLayout - (viewPageContent project model) + (viewPageContent session project model) PageFooter.pageFooter |> PageLayout.withSubduedBackground , modal diff --git a/src/UnisonShare/ProjectWebhook.elm b/src/UnisonShare/ProjectWebhook.elm index 03b72f03..517d691e 100644 --- a/src/UnisonShare/ProjectWebhook.elm +++ b/src/UnisonShare/ProjectWebhook.elm @@ -2,18 +2,62 @@ module UnisonShare.ProjectWebhook exposing (..) import Json.Decode as Decode import Json.Decode.Pipeline exposing (required) -import Lib.Decode.Helpers as DecodeH +import Json.Encode as Encode +import Lib.Decode.Helpers as DecodeH exposing (whenFieldIs) +import UnisonShare.NotificationTopicType as NotificationTopicType exposing (NotificationTopicType) import Url exposing (Url) -type alias ProjectWebhook = +type ProjectWebhookTopics + = AllTopics + | SelectedTopics (List NotificationTopicType) + + +type alias ProjectWebhookForm = { url : Url - , events : List String + , topics : ProjectWebhookTopics + } + + +type alias ProjectWebhook = + { id : String + , url : Url + , topics : ProjectWebhookTopics } + +-- ENCODE + + +encodeTopics : ProjectWebhookTopics -> Encode.Value +encodeTopics topic = + case topic of + AllTopics -> + Encode.object [ ( "type", Encode.string "all" ) ] + + SelectedTopics selectedTopics -> + Encode.object + [ ( "type", Encode.string "selected" ) + , ( "topics" + , Encode.list + (NotificationTopicType.toApiString >> Encode.string) + selectedTopics + ) + ] + + +decodeTopics : Decode.Decoder ProjectWebhookTopics +decodeTopics = + Decode.oneOf + [ whenFieldIs "type" "all" (Decode.succeed AllTopics) + , whenFieldIs "type" "selected" (Decode.map SelectedTopics (Decode.field "topics" (Decode.list NotificationTopicType.decode))) + ] + + decode : Decode.Decoder ProjectWebhook decode = Decode.succeed ProjectWebhook - |> required "url" DecodeH.url - |> required "events" (Decode.list Decode.string) + |> required "notificationSubscriptionId" Decode.string + |> required "uri" DecodeH.url + |> required "topics" decodeTopics diff --git a/src/css/unison-share/add-project-webhook-modal.css b/src/css/unison-share/add-project-webhook-modal.css index 84200bb5..7169a4e2 100644 --- a/src/css/unison-share/add-project-webhook-modal.css +++ b/src/css/unison-share/add-project-webhook-modal.css @@ -2,14 +2,14 @@ --c-add-project-webhook-modal_width: 38rem; width: var(--c-add-project-webhook-modal_width); - & .event-selection_groups { + & .topic-selection_groups { display: flex; flex-direction: row; flex-wrap: wrap; gap: 1.5rem; } - & .event-selection .form-field .label { + & .topic-selection .form-field .label { font-weight: normal; } From 872e208d05d2ef95fc502e53c9d7d5046b74e3e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8jberg?= Date: Thu, 9 Oct 2025 08:59:41 -0400 Subject: [PATCH 3/4] Add webhooks empty state --- src/UnisonShare/Page/ProjectSettingsPage.elm | 21 +++++++++++++------ .../page/project-settings-page.css | 6 +++--- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/UnisonShare/Page/ProjectSettingsPage.elm b/src/UnisonShare/Page/ProjectSettingsPage.elm index 227c5357..9b0c58fc 100644 --- a/src/UnisonShare/Page/ProjectSettingsPage.elm +++ b/src/UnisonShare/Page/ProjectSettingsPage.elm @@ -440,8 +440,8 @@ viewCollaborators model = content = if List.isEmpty collaborators then - ( div [ class "collaborators_empty-state" ] - [ div [ class "collaborators_empty-state_text" ] + ( div [ class "list_empty-state" ] + [ div [ class "list_empty-state_text" ] [ Icon.view Icon.userGroup, text "You haven't invited any collaborators yet" ] ] , addButton_ @@ -524,7 +524,7 @@ viewWebhook webhook = div [ class "webhook" ] [ div [ class "webhook_details" ] - [ strong [ class "webhook_url" ] [ Icon.view Icon.wireframeGlobe, text (Url.toString webhook.url) ] + [ strong [ class "webhook_url" ] [ Icon.view Icon.chain, text (Url.toString webhook.url) ] , div [ class "webhook_events" ] viewTopics ] , div @@ -548,9 +548,18 @@ viewWebhooks session model = content = case model.webhooks of Success webhooks -> - [ header [ class "project-settings_card_header" ] [ h2 [] [ text "Webhooks" ], addButton ] - , div [ class "webhooks" ] (webhooks |> List.map viewWebhook |> List.intersperse divider) - ] + if List.isEmpty webhooks then + [ header [ class "project-settings_card_header" ] [ h2 [] [ text "Webhooks" ], addButton ] + , div [ class "list_empty-state" ] + [ div [ class "list_empty-state_text" ] + [ Icon.view Icon.wireframeGlobe, text "You haven't set up any webhooks yet" ] + ] + ] + + else + [ header [ class "project-settings_card_header" ] [ h2 [] [ text "Webhooks" ], addButton ] + , div [ class "webhooks" ] (webhooks |> List.map viewWebhook |> List.intersperse divider) + ] Failure e -> [ StatusBanner.bad "An unexpected error occurred, please try again." diff --git a/src/css/unison-share/page/project-settings-page.css b/src/css/unison-share/page/project-settings-page.css index 2dcfd913..c4426a49 100644 --- a/src/css/unison-share/page/project-settings-page.css +++ b/src/css/unison-share/page/project-settings-page.css @@ -62,13 +62,13 @@ color: var(--u-color_text_subdued); } -.project-settings-page .collaborators_empty-state { +.project-settings-page .list_empty-state { display: flex; flex-direction: column; gap: 1rem; } -.project-settings-page .collaborators_empty-state_text { +.project-settings-page .list_empty-state_text { color: var(--u-color_text_subdued); display: flex; flex-direction: row; @@ -77,7 +77,7 @@ font-size: var(--font-size-base); } -.project-settings-page .collaborators_empty-state_text .icon { +.project-settings-page .list_empty-state_text .icon { font-size: var(--font-size-base); color: var(--u-color_icon_subdued); } From 6300046892bfc9deee92e0eb0eeb1485ebdd227d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8jberg?= Date: Thu, 9 Oct 2025 10:36:24 -0400 Subject: [PATCH 4/4] Add settings page test coverage --- src/UnisonShare/ErrorDetails.elm | 8 +++ src/UnisonShare/Page/ProjectSettingsPage.elm | 28 +++++---- .../page/project-settings-page.css | 2 +- tests/e2e/ProjectSettingsPage.spec.ts | 58 +++++++++++++++++++ tests/e2e/TestHelpers/Api.ts | 51 ++++++++++++++++ tests/e2e/TestHelpers/Data.ts | 22 +++++++ 6 files changed, 157 insertions(+), 12 deletions(-) create mode 100644 tests/e2e/ProjectSettingsPage.spec.ts diff --git a/src/UnisonShare/ErrorDetails.elm b/src/UnisonShare/ErrorDetails.elm index e8a3aba6..d204edc4 100644 --- a/src/UnisonShare/ErrorDetails.elm +++ b/src/UnisonShare/ErrorDetails.elm @@ -17,3 +17,11 @@ view session error = else UI.nothing + + + +{- details [] + [ summary [] [ text "Error Details" ] + , pre [] [ text (Util.httpErrorToString error) ] + ] +-} diff --git a/src/UnisonShare/Page/ProjectSettingsPage.elm b/src/UnisonShare/Page/ProjectSettingsPage.elm index 9b0c58fc..210952c5 100644 --- a/src/UnisonShare/Page/ProjectSettingsPage.elm +++ b/src/UnisonShare/Page/ProjectSettingsPage.elm @@ -384,7 +384,7 @@ removeWebhook appContext projectRef webhook = pageTitle : PageTitle.PageTitle msg pageTitle = PageTitle.title "Project Settings" - |> PageTitle.withDescription "Manage your project visibility and settings." + |> PageTitle.withDescription "Manage your project. Collaborators, webhooks, and visibility." viewLoadingPage : PageLayout msg @@ -413,8 +413,8 @@ viewLoadingPage = |> PageLayout.withSubduedBackground -viewCollaborators : Model -> Html Msg -viewCollaborators model = +viewCollaborators : Session -> Model -> Html Msg +viewCollaborators session model = let ( collabs, addButton ) = case model.collaborators of @@ -461,15 +461,16 @@ viewCollaborators model = in content - Failure _ -> + Failure e -> ( div [ class "collaborators_error" ] [ StatusBanner.bad "Could not load collaborators" + , ErrorDetails.view session e ] , UI.nothing ) _ -> - ( div [ class "collaborators_loading" ] + ( div [ class "list_loading" ] [ Placeholder.text |> Placeholder.withLength Placeholder.Small |> Placeholder.view , Placeholder.text |> Placeholder.withLength Placeholder.Medium |> Placeholder.view , Placeholder.text |> Placeholder.withLength Placeholder.Huge |> Placeholder.view @@ -479,7 +480,7 @@ viewCollaborators model = ) in Card.card - [ header [ class "project-settings_card_header" ] [ h2 [] [ text "Project Collaborators" ], addButton ] + [ header [ class "project-settings_card_header" ] [ h2 [] [ text "Collaborators" ], addButton ] , collabs ] |> Card.asContained @@ -562,12 +563,12 @@ viewWebhooks session model = ] Failure e -> - [ StatusBanner.bad "An unexpected error occurred, please try again." + [ StatusBanner.bad "Could not load webhooks." , ErrorDetails.view session e ] _ -> - [ div [ class "webhooks_loading" ] + [ div [ class "list_loading" ] [ Placeholder.text |> Placeholder.withLength Placeholder.Small |> Placeholder.view , Placeholder.text |> Placeholder.withLength Placeholder.Medium |> Placeholder.view , Placeholder.text |> Placeholder.withLength Placeholder.Huge |> Placeholder.view @@ -651,8 +652,13 @@ viewPageContent session project model = , StatusBanner.info "Changing visibility is not supported for public organizations." ) - Failure _ -> - ( overlay_, StatusBanner.bad "An unexpected error occurred, please try again." ) + Failure e -> + ( overlay_ + , div [] + [ StatusBanner.bad "An unexpected error occurred, please try again." + , ErrorDetails.view session e + ] + ) in Card.card [ h2 [] [ text "Project Visibility" ] @@ -723,7 +729,7 @@ viewPageContent session project model = collaborators = if Project.isPublic project || project.isPremiumProject then - viewCollaborators model + viewCollaborators session model else UI.nothing diff --git a/src/css/unison-share/page/project-settings-page.css b/src/css/unison-share/page/project-settings-page.css index c4426a49..efb97b8f 100644 --- a/src/css/unison-share/page/project-settings-page.css +++ b/src/css/unison-share/page/project-settings-page.css @@ -82,7 +82,7 @@ color: var(--u-color_icon_subdued); } -.project-settings-page .collaborators_loading { +.project-settings-page .list_loading { display: flex; flex-direction: column; gap: 0.5rem; diff --git a/tests/e2e/ProjectSettingsPage.spec.ts b/tests/e2e/ProjectSettingsPage.spec.ts new file mode 100644 index 00000000..430bf9ea --- /dev/null +++ b/tests/e2e/ProjectSettingsPage.spec.ts @@ -0,0 +1,58 @@ +import { test, expect } from "@playwright/test"; +import { button } from "./TestHelpers/Page"; +import * as API from "./TestHelpers/Api"; + +const projectRef = "@alice/html"; + +test.beforeEach(async ({ page }) => { + await API.getWebsiteFeed(page); +}); + +test.describe("without being signed in", () => { + test.beforeEach(async ({ page }) => { + await API.getAccount(page, "NOT_SIGNED_IN"); + await page.goto(`http://localhost:1234/${projectRef}/settings`); + }); + + test("can *NOT* see the project settings page", async ({ page }) => { + await expect(page.getByText("Project Settings")).not.toBeVisible(); + }); +}); + +test.describe("with the project:manage permission", () => { + test.beforeEach(async ({ page }) => { + await API.getAccount(page, "@alice"); + await API.getUserProfile(page, "@alice"); + await API.getProjectRoleAssignments(page, projectRef); + await API.getProjectWebhooks(page, projectRef); + await API.getProject(page, projectRef, { + visibility: "public", + permissions: ["project:manage"], + }); + await page.goto(`http://localhost:1234/${projectRef}/settings`); + }); + + test("The user can see the project settings page", async ({ page }) => { + await expect(page.getByText("Project Settings")).toBeVisible(); + }); + + test("The user can see the the collaborators section", async ({ page }) => { + await expect(page.locator(".collaborator")).toHaveCount(3); + }); + + test("The user can see the the visibility section", async ({ page }) => { + await expect(page.getByText("Project Visibility")).toBeVisible(); + }); + + test.describe("webhooks", () => { + test("The user can see the webhooks section", async ({ page }) => { + await expect(page.locator(".webhook")).toHaveCount(3); + await expect(button(page, "Add a webhook")).toBeVisible(); + }); + + test("The user can see the add webhook modal", async ({ page }) => { + await button(page, "Add a webhook").click(); + await expect(page.locator(".modal")).toBeVisible(); + }); + }); +}); diff --git a/tests/e2e/TestHelpers/Api.ts b/tests/e2e/TestHelpers/Api.ts index aa2f5780..7bf5a7fc 100644 --- a/tests/e2e/TestHelpers/Api.ts +++ b/tests/e2e/TestHelpers/Api.ts @@ -5,6 +5,8 @@ import { project, type ContributionDiffConfig, type Notification, + projectRoleAssignment, + projectWebhook, contributionTimeline, contributionDiff, ticket, @@ -537,6 +539,53 @@ async function patchProjectTicket( }); } +// -- /users/:handle/:project-ref/roles + +async function getProjectRoleAssignments( + page: Page, + projectRef: string, + resp?: { status: number }, +) { + const [handle, projectSlug] = projectRef.split("/"); + + return get(page, { + ...{ + url: `/users/${handle.replace("@", "")}/projects/${projectSlug}/roles`, + status: 200, + data: { + active: true, + role_assignments: [ + projectRoleAssignment(), + projectRoleAssignment(), + projectRoleAssignment(), + ], + }, + }, + ...(resp || {}), + }); +} + +// -- /users/:handle/:project-ref/webhooks +// +async function getProjectWebhooks( + page: Page, + projectRef: string, + resp?: { status: number }, +) { + const [handle, projectSlug] = projectRef.split("/"); + + return get(page, { + ...{ + url: `/users/${handle.replace("@", "")}/projects/${projectSlug}/webhooks`, + status: 200, + data: { + webhooks: [projectWebhook(), projectWebhook(), projectWebhook()], + }, + }, + ...(resp || {}), + }); +} + // /users/:handle/notifications type NotificationsHubData = { @@ -677,6 +726,8 @@ export { getProject, getProject_, getProjectReadme, + getProjectRoleAssignments, + getProjectWebhooks, getProjectDependencies, getProjectContribution, getProjectContribution_, diff --git a/tests/e2e/TestHelpers/Data.ts b/tests/e2e/TestHelpers/Data.ts index 1d0d38c5..b69ab9f8 100644 --- a/tests/e2e/TestHelpers/Data.ts +++ b/tests/e2e/TestHelpers/Data.ts @@ -727,6 +727,26 @@ function userSearchMatch() { }; } +function projectRoleAssignment() { + return { + roles: ["project_contributor"], + subject: { + data: user(), + kind: "user", + }, + }; +} + +function projectWebhook() { + return { + notificationSubscriptionId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + uri: faker.internet.url(), + topics: { type: "all" }, + }; +} + export { projectRef, project, @@ -741,6 +761,8 @@ export { contribution, contributionTimeline, contributionStatusChangeEvent, + projectRoleAssignment, + projectWebhook, contributionDiff, notification, notificationEvent,