diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b87d34f..507c9ba7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## 1.5.0 Betula Pendula - FOSDEM 2015 (unreleased) ### Features +- Taiga webhooks + + Created admin panel with webhook settings. - Not showing closed milestones by default in backlog view. - In kanban view an archived user story status doesn't show his content by default. - Now you can export and import projects between Taiga instances. diff --git a/app/coffee/modules/admin/third-parties.coffee b/app/coffee/modules/admin/third-parties.coffee index fc7abc4c..96eb6ff1 100644 --- a/app/coffee/modules/admin/third-parties.coffee +++ b/app/coffee/modules/admin/third-parties.coffee @@ -24,9 +24,239 @@ taiga = @.taiga mixOf = @.taiga.mixOf bindMethods = @.taiga.bindMethods debounce = @.taiga.debounce +timeout = @.taiga.timeout module = angular.module("taigaAdmin") +############################################################################# +## Webhooks +############################################################################# + +class WebhooksController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.FiltersMixin) + @.$inject = [ + "$scope", + "$tgRepo", + "$tgResources", + "$routeParams", + "$appTitle" + ] + + constructor: (@scope, @repo, @rs, @params, @appTitle) -> + bindMethods(@) + + @scope.sectionName = "Webhooks" #i18n + @scope.project = {} + + promise = @.loadInitialData() + + promise.then () => + @appTitle.set("Webhooks - " + @scope.project.name) + + promise.then null, @.onInitialDataError.bind(@) + + @scope.$on "webhooks:reload", @.loadWebhooks + + loadWebhooks: -> + return @rs.webhooks.list(@scope.projectId).then (webhooks) => + @scope.webhooks = webhooks + + loadProject: -> + return @rs.projects.get(@scope.projectId).then (project) => + @scope.project = project + @scope.$emit('project:loaded', project) + return project + + loadInitialData: -> + promise = @repo.resolve({pslug: @params.pslug}).then (data) => + @scope.projectId = data.project + return data + + return promise.then(=> @.loadProject()) + .then(=> @.loadWebhooks()) + +module.controller("WebhooksController", WebhooksController) + +############################################################################# +## Webhook Directive +############################################################################# + +WebhookDirective = ($rs, $repo, $confirm, $loading) -> + link = ($scope, $el, $attrs) -> + webhook = $scope.$eval($attrs.tgWebhook) + + updateLogs = () -> + $rs.webhooklogs.list(webhook.id).then (webhooklogs) => + webhooklogs = webhooklogs.reverse() + for log in webhooklogs + statusText = String(log.status) + log.validStatus = statusText.length==3 and statusText[0]="2" + log.prettySentData = JSON.stringify(log.request_data.data, undefined, 2) + log.prettySentHeaders = JSON.stringify(log.request_headers, undefined, 2) + log.prettyDate = moment(log.created).format("DD MMM YYYY [at] hh:mm:ss") + + webhook.logs = webhooklogs + updateShowHideHistoryText() + + updateShowHideHistoryText = () -> + textElement = $el.find(".toggle-history") + historyElement = textElement.parents(".single-webhook-wrapper").find(".webhooks-history") + if historyElement.hasClass("open") + textElement.text("(Hide history)") + else + textElement.text("(Show history)") + + showVisualizationMode = () -> + $el.find(".edition-mode").addClass("hidden") + $el.find(".visualization-mode").removeClass("hidden") + + showEditMode = () -> + $el.find(".visualization-mode").addClass("hidden") + $el.find(".edition-mode").removeClass("hidden") + + cancel = () -> + showVisualizationMode() + $scope.$apply -> + webhook.revert() + + save = debounce 2000, (target) -> + form = target.parents("form").checksley() + return if not form.validate() + + value = target.scope().value + promise = $repo.save(webhook) + promise.then => + showVisualizationMode() + + promise.then null, (data) -> + $confirm.notify("error") + form.setErrors(data) + + $el.on "click", ".test-webhook", () -> + $rs.webhooks.test(webhook.id).then => + updateLogs() + $el.find(".webhooks-history").addClass("open") + updateShowHideHistoryText() + + $el.on "click", ".edit-webhook", () -> + showEditMode() + + $el.on "click", ".cancel-existing", () -> + cancel() + + $el.on "click", ".edit-existing", (event) -> + event.preventDefault() + target = angular.element(event.currentTarget) + save(target) + + $el.on "keyup", ".edition-mode input", (event) -> + if event.keyCode == 13 + target = angular.element(event.currentTarget) + saveWebhook(target) + else if event.keyCode == 27 + target = angular.element(event.currentTarget) + cancel(target) + + $el.on "click", ".delete-webhook", () -> + title = "Delete webhook" #TODO: i18in + message = "Webhook '#{webhook.name}'" #TODO: i18in + + $confirm.askOnDelete(title, message).then (finish) => + onSucces = -> + finish() + $scope.$emit("webhooks:reload") + + onError = -> + finish(false) + $confirm.notify("error") + + $repo.remove(webhook).then(onSucces, onError) + + $el.on "click", ".toggle-history", (event) -> + target = angular.element(event.currentTarget) + if not webhook.logs? or webhook.logs.length == 0 + updateLogs().then -> + #Waiting for ng-repeat to finish + timeout 0, -> + $el.find(".webhooks-history").toggleClass("open") + updateShowHideHistoryText() + + else + $el.find(".webhooks-history").toggleClass("open") + $scope.$apply () -> + updateShowHideHistoryText() + + + $el.on "click", ".history-single", (event) -> + target = angular.element(event.currentTarget) + target.toggleClass("history-single-open") + target.siblings(".history-single-response").toggleClass("open") + + $el.on "click", ".resend-request", (event) -> + target = angular.element(event.currentTarget) + log = target.data("log") + $rs.webhooklogs.resend(log).then () => + updateLogs() + + return {link:link} + +module.directive("tgWebhook", ["$tgResources", "$tgRepo", "$tgConfirm", "$tgLoading", WebhookDirective]) + + +############################################################################# +## New webhook Directive +############################################################################# + +NewWebhookDirective = ($rs, $repo, $confirm, $loading) -> + link = ($scope, $el, $attrs) -> + webhook = $scope.$eval($attrs.tgWebhook) + formDOMNode = $el.find(".new-webhook-form") + addWebhookDOMNode = $el.find(".add-webhook") + initializeNewValue = -> + $scope.newValue = { + "name": "" + "url": "" + "key": "" + } + + initializeNewValue() + + $scope.$watch "webhooks", (webhooks) -> + if webhooks? + if webhooks.length == 0 + formDOMNode.removeClass("hidden") + addWebhookDOMNode.addClass("hidden") + formDOMNode.find("input")[0].focus() + else + formDOMNode.addClass("hidden") + addWebhookDOMNode.removeClass("hidden") + + formDOMNode.on "click", ".add-new", debounce 2000, (event) -> + event.preventDefault() + form = formDOMNode.checksley() + return if not form.validate() + + $scope.newValue.project = $scope.project.id + promise = $repo.create("webhooks", $scope.newValue) + promise.then => + $scope.$emit("webhooks:reload") + initializeNewValue() + + promise.then null, (data) -> + $confirm.notify("error") + form.setErrors(data) + + formDOMNode.on "click", ".cancel-new", (event) -> + $scope.$apply -> + initializeNewValue() + + addWebhookDOMNode.on "click", (event) -> + formDOMNode.removeClass("hidden") + formDOMNode.find("input")[0].focus() + + return {link:link} + +module.directive("tgNewWebhook", ["$tgResources", "$tgRepo", "$tgConfirm", "$tgLoading", NewWebhookDirective]) + ############################################################################# ## Github Controller diff --git a/app/coffee/modules/resources.coffee b/app/coffee/modules/resources.coffee index ac877edf..9f067e78 100644 --- a/app/coffee/modules/resources.coffee +++ b/app/coffee/modules/resources.coffee @@ -85,6 +85,10 @@ urls = { "priorities": "/priorities" "severities": "/severities" "project-modules": "/projects/%s/modules" + "webhooks": "/webhooks" + "webhooks-test": "/webhooks/%s/test" + "webhooklogs": "/webhooklogs" + "webhooklogs-resend": "/webhooklogs/%s/resend" # History "history/us": "/history/userstory" @@ -145,5 +149,7 @@ module.run([ "$tgHistoryResourcesProvider", "$tgKanbanResourcesProvider", "$tgModulesResourcesProvider", + "$tgWebhooksResourcesProvider", + "$tgWebhookLogsResourcesProvider", initResources ]) diff --git a/app/coffee/modules/resources/webhooklogs.coffee b/app/coffee/modules/resources/webhooklogs.coffee new file mode 100644 index 00000000..02d9eced --- /dev/null +++ b/app/coffee/modules/resources/webhooklogs.coffee @@ -0,0 +1,17 @@ +resourceProvider = ($repo, $urls, $http) -> + service = {} + + service.list = (webhookId) -> + params = {webhook: webhookId} + return $repo.queryMany("webhooklogs", params) + + service.resend = (webhooklogId) -> + url = $urls.resolve("webhooklogs-resend", webhooklogId) + return $http.post(url) + + return (instance) -> + instance.webhooklogs = service + + +module = angular.module("taigaResources") +module.factory("$tgWebhookLogsResourcesProvider", ["$tgRepo", "$tgUrls", "$tgHttp", resourceProvider]) diff --git a/app/coffee/modules/resources/webhooks.coffee b/app/coffee/modules/resources/webhooks.coffee new file mode 100644 index 00000000..06907c32 --- /dev/null +++ b/app/coffee/modules/resources/webhooks.coffee @@ -0,0 +1,17 @@ +resourceProvider = ($repo, $urls, $http) -> + service = {} + + service.list = (projectId) -> + params = {project: projectId} + return $repo.queryMany("webhooks", params) + + service.test = (webhookId) -> + url = $urls.resolve("webhooks-test", webhookId) + return $http.post(url) + + return (instance) -> + instance.webhooks = service + + +module = angular.module("taigaResources") +module.factory("$tgWebhooksResourcesProvider", ["$tgRepo", "$tgUrls", "$tgHttp", resourceProvider]) diff --git a/app/partials/views/modules/admin/admin-third-parties-webhooks.jade b/app/partials/views/modules/admin/admin-third-parties-webhooks.jade index 8a97ba98..9297fca6 100644 --- a/app/partials/views/modules/admin/admin-third-parties-webhooks.jade +++ b/app/partials/views/modules/admin/admin-third-parties-webhooks.jade @@ -2,19 +2,19 @@ block head title Taiga Your agile, free, and open source project management tool block content - div.wrapper.roles(tg-github-webhooks, ng-controller="GithubController as ctrl", + div.wrapper.roles(ng-controller="WebhooksController as ctrl", ng-init="section='admin'") sidebar.menu-secondary.sidebar(tg-admin-navigation="Webhooks") include ../admin-menu sidebar.menu-tertiary.sidebar(tg-admin-navigation="third-parties-webhooks") include ../admin-submenu-third-parties - section.main.admin-common.admin-webhooks + section.main.admin-common.admin-webhooks(tg-new-webhook) include ../../components/mainTitle p.admin-subtitle Webhooks notify external services about events in Taiga, like comments, user stories.... div.webhooks-options - a.button.button-green.add-webhook(href="",title="Add a New Webhook") Add Webhook + a.button.button-green.hidden.add-webhook(href="",title="Add a New Webhook") Add Webhook section.webhooks-table.basic-table div.table-header @@ -23,72 +23,77 @@ block content div.webhook-url URL div.webhook-options div.table-body - form.row - div.webhook-service - input(type="text", name="service-name", placeholder="Type the service name") + div.single-webhook-wrapper(tg-webhook="webhook", ng-repeat="webhook in webhooks") + div.edition-mode.hidden + form.row + fieldset.webhook-service + input(type="text", name="name", placeholder="Type the service name", data-required="true", ng-model="webhook.name") + div.webhook-url + div.webhook-url-inputs + fieldset + input(type="text", name="url", data-type="url", placeholder="Type the service payload url", data-required="true", ng-model="webhook.url") + fieldset + input(type="text", name="key", placeholder="Type the service secret key", data-required="true", ng-model="webhook.key") + div.webhook-options + a.edit-existing.icon.icon-floppy(href="", title="Save Webhook") + a.cancel-existing.icon.icon-delete(href="", title="Cancel Webhook") + + div.visualization-mode + div.row + div.webhook-service + span(ng-bind="webhook.name") + div.webhook-url + span(ng-bind="webhook.url") + a.show-history.toggle-history(href="", title="Toggle history", ng-show="webhook.logs_counter ") (Show history) + + div.webhook-options + div.webhook-options-wrapper + a.test-webhook.icon.icon-check-square(href="", title="Test Webhook") + a.edit-webhook.icon.icon-edit(href="", title="Edit Webhook") + a.delete-webhook.icon.icon-delete(href="", title="Delete Webhook") + + div.webhooks-history(ng-show="webhook.logs") + div.history-single-wrapper(ng-repeat="log in webhook.logs") + div.history-single + div + span.history-response-icon(ng-class="validStatus ? 'history-success' : 'history-error'") + span.history-date(ng-bind="log.prettyDate") + span.toggle-log.icon.icon-arrow-bottom + + div.history-single-response + div.history-single-request-header + span Request + a.resend-request(href="", title="Resend request", data-log="{{log.id}}") + span.icon.icon-reload + span Resend request + div.history-single-request-body + div.response-container + span.response-title Headers + textarea(name="headers", ng-bind="log.prettySentHeaders") + + div.response-container + span.response-title Payload + textarea(name="payload", ng-bind="log.prettySentData") + + div.history-single-response-header + span Response + div.history-single-response-body + div.response-container + textarea(name="response-data", ng-bind="log.response_data") + + form.new-webhook-form.row.hidden + fieldset.webhook-service + input(type="text", name="name", placeholder="Type the service name", data-required="true", ng-model="newValue.name") div.webhook-url div.webhook-url-inputs - input(type="text", name="service-sexret-key", placeholder="Type the service secret key") - input(type="text", name="service-payload-url", placeholder="Type the service payload url") + fieldset + input(type="text", name="url", data-type="url", placeholder="Type the service payload url", data-required="true", ng-model="newValue.url") + fieldset + input(type="text", name="key", placeholder="Type the service secret key", data-required="true", ng-model="newValue.key") div.webhook-options - a.icon.icon-floppy(href="", title="Save Webhook") - a.icon.icon-delete(href="", title="Cancel Webhook") - div.single-webhook-wrapper - div.row - div.webhook-service - span Github - div.webhook-url - span http://github.kjrw3543m/nwdlkw4m535/ffm - a(href="", title="Test history") (See test history) - div.webhook-options - div.webhook-options-wrapper - a.icon.icon-floppy(href="", title="Save Webhook") - a.icon.icon-edit(href="", title="Edit Webhook") - a.icon.icon-delete(href="", title="Delete Webhook") - div.single-webhook-wrapper - div.row - div.webhook-service - span Slack - div.webhook-url - span http://slack.kjrw3543m/nwdlkw4m535/ffm - a(href="", title="Test history") (See test history) - div.webhook-options - div.webhook-options-wrapper - a.icon.icon-floppy(href="", title="Save Webhook") - a.icon.icon-edit(href="", title="Edit Webhook") - a.icon.icon-delete(href="", title="Delete Webhook") - div.webhooks-history - div.history-single - div - span.history-response.history-success - span.history-date Just now - span.icon.icon-arrow-bottom - div.history-single - div - span.history-response.history-error - span.history-date Just now - span.icon.icon-arrow-bottom + a.add-new.icon.icon-floppy(href="", title="Save Webhook") + a.cancel-new.icon.icon-delete(href="", title="Cancel Webhook") - // - form - fieldset - label(for="secret-key") Secret key - input(type="text", name="secret-key", ng-model="github.secret", placeholder="Secret key", id="secret-key") - - fieldset - .select-input-text(tg-select-input-text) - div - label(for="payload-url") Payload URL - .field-with-option - input(type="text", ng-model="github.webhooks_url", name="payload-url", readonly="readonly", placeholder="Payload URL", id="payload-url") - .option-wrapper.select-input-content - .icon.icon-copy - .help-copy Copy to clipboard: Ctrl+C - - button(type="submit", class="hidden") - a.button.button-green.submit-button(href="", title="Save") Save - - - a.help-button(href="https://taiga.io/support/github-integration/", target="_blank") + a.help-button(href="https://taiga.io/support/webhooks/", target="_blank") span.icon.icon-help span Do you need help? Check out our support page! diff --git a/app/styles/dependencies/helpers.scss b/app/styles/dependencies/helpers.scss index a17e8dff..54a5114d 100644 --- a/app/styles/dependencies/helpers.scss +++ b/app/styles/dependencies/helpers.scss @@ -12,6 +12,7 @@ %text {font-family: 'opensans-regular', Arial, Helvetica, sans-serif; line-height: 1.3rem;} %bold {font-family: 'opensans-semibold', Arial, Helvetica, sans-serif;} %taiga {font-family: 'taiga';} +%mono {font-family: 'courier new', 'monospace';} %lightbox { background: rgba($white, .95); diff --git a/app/styles/dependencies/mixins.scss b/app/styles/dependencies/mixins.scss index 81e547d2..1b6bf72f 100644 --- a/app/styles/dependencies/mixins.scss +++ b/app/styles/dependencies/mixins.scss @@ -20,7 +20,7 @@ @mixin slide($max, $overflow, $min: 0) { max-height: $min; transition: max-height .5s ease-in; - #{$overflow}: hidden; + overflow: #{$overflow}; &.open { transition: max-height .5s ease-in; max-height: $max; diff --git a/app/styles/modules/admin/admin-third-parties-webhooks.scss b/app/styles/modules/admin/admin-third-parties-webhooks.scss index aaac4f1a..96ca1ed1 100644 --- a/app/styles/modules/admin/admin-third-parties-webhooks.scss +++ b/app/styles/modules/admin/admin-third-parties-webhooks.scss @@ -78,32 +78,53 @@ .webhook-url-inputs { display: flex; flex-direction: row; - input { - flex-basis: 1; + flex-wrap: nowrap; + justify-content: center; + fieldset { + flex-grow: 1; margin-right: .3rem; } } + + .webhooks-history{ + @include slide(1000px, hidden, $min: 0); + } + + .history-single-wrapper { + border-bottom: 1px solid $whitish; + margin-left: 22%; + } .history-single { align-items: center; - border-bottom: 1px solid $whitish; cursor: pointer; display: flex; justify-content: space-between; - margin-left: 22%; padding: .5rem; transition: background .2s linear; &:hover { background: rgba($fresh-taiga, .1); transition: background .2s linear; } + &.history-single-open { + &:hover { + background: none; + } + .icon-arrow-bottom { + transform: rotate(180deg); + transition: transform .3s linear; + } + } + .icon-arrow-bottom { + transform: rotate(0); + transition: transform .3s linear; + } } - .history-response { + .history-response-icon { background: $gray; border-radius: 25%; display: inline-block; height: .8rem; margin-right: .5rem; - vertical-align: middle; width: .8rem; &.history-success { background: $fresh-taiga; @@ -112,4 +133,64 @@ background: $red; } } + .history-single-response { + @include slide(1000px, hidden, $min: 0); + &.open { + margin-top: 1rem; + } + } + .history-single-request-header, + .history-single-response-header { + display: flex; + justify-content: space-between; + padding: .5rem 0; + span:first-child { + @extend %bold; + color: $gray-light; + } + a { + @extend %small; + color: $gray-light; + &:hover { + color: $fresh-taiga; + transition: color .2s linear; + } + } + .icon { + margin-right: .3rem; + vertical-align: middle; + } + } + .history-single-request-body, + .history-single-response-body { + .response-container { + @extend %mono; + align-content: center; + align-items: center; + background: $whitish; + display: flex; + flex-direction: row; + justify-content: space-around; + margin-bottom: .5rem; + } + span { + @extend %small; + color: $gray-light; + flex-basis: 20%; + flex-grow: 1; + flex-shrink: 0; + text-align: center; + } + textarea { + @extend %mono; + border: 0; + flex-grow: 2; + min-height: 7.5rem; + } + } + .history-single-response-body { + textarea { + min-height: 10rem; + } + } }