diff --git a/src/UnisonShare/AddProjectWebhookModal.elm b/src/UnisonShare/AddProjectWebhookModal.elm new file mode 100644 index 00000000..a885c442 --- /dev/null +++ b/src/UnisonShare/AddProjectWebhookModal.elm @@ -0,0 +1,312 @@ +module UnisonShare.AddProjectWebhookModal exposing (..) + +import Html exposing (Html, div) +import Html.Attributes exposing (class) +import Http +import Json.Decode as Decode +import Lib.HttpApi as 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.Api as ShareApi +import UnisonShare.AppContext exposing (AppContext) +import UnisonShare.NotificationTopicType exposing (NotificationTopicType(..)) +import UnisonShare.Project.ProjectRef exposing (ProjectRef) +import UnisonShare.ProjectWebhook as ProjectWebhook exposing (ProjectWebhook, ProjectWebhookForm, ProjectWebhookTopics(..)) +import UnisonShare.User exposing (UserSummaryWithId) +import Url + + +type alias Form = + { url : String + , topics : ProjectWebhookTopics + } + + +type Validation + = NotChecked + | Valid + | InvalidUrlAndSelection + | InvalidUrl + | InvalidSelection + + +type Model + = Edit Validation Form + | Saving Form + | Failure Http.Error Form + | Success ProjectWebhook + + +init : Model +init = + Edit NotChecked { url = "", topics = AllTopics } + + + +-- UPDATE + + +type Msg + = CloseModal + | UpdateUrl String + | SetProjectWebhookTopics ProjectWebhookTopics + | ToggleTopic NotificationTopicType + | AddWebhook + | AddWebhookFinished (HttpResult ProjectWebhook) + + +type OutMsg + = NoOutMsg + | RequestCloseModal + | AddedWebhook ProjectWebhook + + +update : AppContext -> ProjectRef -> Msg -> Model -> ( Model, Cmd Msg, OutMsg ) +update appContext projectRef msg model = + case ( msg, model ) of + ( UpdateUrl url, Edit v f ) -> + ( Edit v { f | url = url }, Cmd.none, NoOutMsg ) + + ( SetProjectWebhookTopics topics, Edit v f ) -> + ( Edit v { f | topics = topics }, Cmd.none, NoOutMsg ) + + ( ToggleTopic topicType, Edit v f ) -> + let + topics = + case f.topics of + AllTopics -> + SelectedTopics [ topicType ] + + SelectedTopics evts -> + if List.member topicType evts then + SelectedTopics (ListE.remove topicType evts) + + else + SelectedTopics (evts ++ [ topicType ]) + in + ( Edit v { f | topics = topics }, Cmd.none, NoOutMsg ) + + ( AddWebhook, Edit _ f ) -> + let + hasTopics = + case f.topics of + AllTopics -> + True + + 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 ) + + ( 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 ) + + _ -> + ( model, Cmd.none, NoOutMsg ) + + + +-- 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 + + +viewUser : UserSummaryWithId -> Html msg +viewUser user = + ProfileSnippet.profileSnippet user |> ProfileSnippet.view + + +divider : Html msg +divider = + Divider.divider |> Divider.small |> Divider.view + + +viewtopicselection : List NotificationTopicType -> Html Msg +viewtopicselection selected = + let + isSelected topic = + List.member topic selected + + checkbox title topic = + CheckboxField.field title (ToggleTopic topic) (isSelected topic) + |> CheckboxField.view + + contributiontopics = + [ checkbox "Contribution created" ProjectContributionCreated + , checkbox "Contribution updated" ProjectContributionUpdated + , checkbox "Contribution comment" ProjectContributionComment + ] + + tickettopics = + [ checkbox "Ticket created" ProjectTicketCreated + , checkbox "Ticket updated" ProjectTicketUpdated + , checkbox "Ticket comment" ProjectTicketComment + ] + + topicselectionCheckboxes = + div [ class "topic-selection_groups" ] + [ div [] + [ div [ class "checkboxes" ] + [ checkbox "Branch updated" ProjectBranchUpdated + , checkbox "Release created" ProjectReleaseCreated + ] + ] + , div [] + [ div [ class "checkboxes" ] contributiontopics + ] + , div [] + [ div [ class "checkboxes" ] tickettopics + ] + ] + in + 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 +view model = + let + modal_ c = + Modal.content c + |> Modal.modal "add-project-webhook-modal" CloseModal + |> Modal.withHeader "Add Webhook" + + modal = + case model of + Edit validation form -> + viewEditModal validation modal_ form + + Saving f -> + viewEditModal NotChecked modal_ f + |> Modal.withLeftSideFooter [ StatusBanner.working "Adding Webhook..." ] + |> Modal.withDimOverlay True + + Failure _ f -> + viewEditModal NotChecked modal_ f + |> Modal.withLeftSideFooter [ StatusBanner.bad "Failed to add Webhook" ] + + Success webhook -> + let + form = + { url = Url.toString webhook.url, topics = webhook.topics } + in + 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..d204edc4 --- /dev/null +++ b/src/UnisonShare/ErrorDetails.elm @@ -0,0 +1,27 @@ +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 + + + +{- details [] + [ summary [] [ text "Error Details" ] + , pre [] [ text (Util.httpErrorToString error) ] + ] +-} 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 3b3504a7..210952c5 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,17 +23,23 @@ 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.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 as ProjectWebhook exposing (ProjectWebhook) import UnisonShare.Session as Session exposing (Session) import UnisonShare.User as User exposing (UserSummary) +import Url @@ -59,6 +65,7 @@ type alias DeleteProject = type Modal = NoModal | AddCollaboratorModal AddProjectCollaboratorModal.Model + | AddWebhookModal AddProjectWebhookModal.Model type ProjectOwner @@ -68,6 +75,7 @@ type ProjectOwner type alias Model = { collaborators : WebData (List ProjectCollaborator) + , webhooks : WebData (List ProjectWebhook) , owner : WebData ProjectOwner , modal : Modal , form : Form @@ -82,6 +90,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 +106,7 @@ project pages. init : AppContext -> ProjectRef -> ( Model, Cmd Msg ) init appContext projectRef = ( { collaborators = Loading + , webhooks = Success [] , owner = Loading , modal = NoModal , form = NoChanges @@ -104,6 +114,7 @@ init appContext projectRef = , Cmd.batch [ fetchOwner appContext (ProjectRef.handle projectRef) , fetchCollaborators appContext projectRef + , fetchWebhooks appContext projectRef ] ) @@ -115,6 +126,7 @@ init appContext projectRef = type Msg = FetchCollaboratorsFinished (WebData (List ProjectCollaborator)) | FetchOwnerFinished (WebData ProjectOwner) + | FetchProjectWebhooksFinished (WebData (List ProjectWebhook)) | UpdateVisibility ProjectVisibility | DiscardChanges | SaveChanges @@ -122,10 +134,14 @@ type Msg | ClearAfterSave | ShowDeleteProjectModal | ShowAddCollaboratorModal + | ShowAddWebhookModal | CloseModal | RemoveCollaborator ProjectCollaborator + | RemoveWebhook ProjectWebhook | RemoveCollaboratorFinished (HttpResult ()) + | RemoveWebhookFinished (HttpResult ()) | AddProjectCollaboratorModalMsg AddProjectCollaboratorModal.Msg + | AddProjectWebhookModalMsg AddProjectWebhookModal.Msg type OutMsg @@ -149,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 ) @@ -184,6 +203,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 ) @@ -195,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 ) -> @@ -228,22 +258,70 @@ 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 1000 CloseModal ] + , None + ) + + _ -> + ( model, Cmd.none, None ) + _ -> ( 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 @@ -283,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 @@ -290,7 +384,7 @@ updateProjectSettings appContext projectRef changes = 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 @@ -319,15 +413,17 @@ viewLoadingPage = |> PageLayout.withSubduedBackground -viewCollaborators : Model -> Html Msg -viewCollaborators model = +viewCollaborators : Session -> Model -> Html Msg +viewCollaborators session 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,48 +440,150 @@ 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" ] - , 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" ] + 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 , 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 "Collaborators" ], addButton ] , collabs ] |> Card.asContained |> Card.view -viewPageContent : ProjectDetails -> Model -> PageContent Msg -viewPageContent project model = +viewWebhook : ProjectWebhook -> Html Msg +viewWebhook webhook = + let + topicIcon 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 + + 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.chain, text (Url.toString webhook.url) ] + , div [ class "webhook_events" ] viewTopics + ] + , div + [ class "webhook_actions" ] + [ viewAction (RemoveWebhook webhook) Icon.trash + ] + ] + + +viewWebhooks : Session -> Model -> Html Msg +viewWebhooks session model = + let + divider = + Divider.divider |> Divider.small |> Divider.view + + addButton = + Button.iconThenLabel ShowAddWebhookModal Icon.plus "Add a webhook" + |> Button.small + |> Button.view + + content = + case model.webhooks of + Success webhooks -> + 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 "Could not load webhooks." + , ErrorDetails.view session e + ] + + _ -> + [ 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 + , Placeholder.text |> Placeholder.withLength Placeholder.Large |> Placeholder.view + ] + ] + in + Card.card + content + |> Card.asContained + |> Card.view + + +viewPageContent : Session -> ProjectDetails -> Model -> PageContent Msg +viewPageContent session project model = let activeVisiblityValue = case model.form of @@ -454,16 +652,18 @@ viewPageContent 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" ] , message_ - , div [ class "form" ] - [ overlay - , RadioField.view projectVisibilityField - ] + , div [ class "form" ] [ overlay, RadioField.view projectVisibilityField ] ] |> Card.asContained |> Card.view @@ -529,14 +729,17 @@ viewPageContent project model = collaborators = if Project.isPublic project || project.isPremiumProject then - viewCollaborators model + viewCollaborators session model else UI.nothing + + webhooks = + viewWebhooks session model in PageContent.oneColumn [ div [ class "settings-content", class stateClass ] - (collaborators :: formAndActions) + (collaborators :: webhooks :: formAndActions) ] |> pageTitle_ @@ -550,11 +753,14 @@ view session project model = AddCollaboratorModal m -> Just (Html.map AddProjectCollaboratorModalMsg (AddProjectCollaboratorModal.view m)) + AddWebhookModal m -> + Just (Html.map AddProjectWebhookModalMsg (AddProjectWebhookModal.view m)) + _ -> 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 new file mode 100644 index 00000000..517d691e --- /dev/null +++ b/src/UnisonShare/ProjectWebhook.elm @@ -0,0 +1,63 @@ +module UnisonShare.ProjectWebhook exposing (..) + +import Json.Decode as Decode +import Json.Decode.Pipeline exposing (required) +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 ProjectWebhookTopics + = AllTopics + | SelectedTopics (List NotificationTopicType) + + +type alias ProjectWebhookForm = + { url : Url + , 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 "notificationSubscriptionId" Decode.string + |> required "uri" DecodeH.url + |> required "topics" decodeTopics 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..7169a4e2 --- /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); + + & .topic-selection_groups { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 1.5rem; + } + + & .topic-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..efb97b8f 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); @@ -55,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; @@ -70,17 +77,68 @@ 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); } -.project-settings-page .collaborators_loading { +.project-settings-page .list_loading { display: flex; flex-direction: column; 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; 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,