diff --git a/elm-git.json b/elm-git.json index 447073fa..abac6f93 100644 --- a/elm-git.json +++ b/elm-git.json @@ -1,7 +1,7 @@ { "git-dependencies": { "direct": { - "https://github.com/unisonweb/ui-core": "aedb50adb6e1ecbba981c1bea6c074cfdf1fffae" + "https://github.com/unisonweb/ui-core": "caf8eaffac685566d964c1d3e4b63deea8e7462f" }, "indirect": {} } 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 dd7e6f31..77119d68 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;