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/app.coffee b/app/coffee/app.coffee index 2349b3a7..e7f49f72 100644 --- a/app/coffee/app.coffee +++ b/app/coffee/app.coffee @@ -101,6 +101,8 @@ configure = ($routeProvider, $locationProvider, $httpProvider, $provide, $tgEven {templateUrl: "admin/admin-memberships.html"}) $routeProvider.when("/project/:pslug/admin/roles", {templateUrl: "admin/admin-roles.html"}) + $routeProvider.when("/project/:pslug/admin/third-parties/webhooks", + {templateUrl: "admin/admin-third-parties-webhooks.html"}) $routeProvider.when("/project/:pslug/admin/third-parties/github", {templateUrl: "admin/admin-third-parties-github.html"}) $routeProvider.when("/project/:pslug/admin/third-parties/gitlab", diff --git a/app/coffee/modules/admin/third-parties.coffee b/app/coffee/modules/admin/third-parties.coffee index fc7abc4c..3e611b46 100644 --- a/app/coffee/modules/admin/third-parties.coffee +++ b/app/coffee/modules/admin/third-parties.coffee @@ -24,9 +24,240 @@ 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) => + for log in webhooklogs + log.validStatus = 200 <= log.status < 300 + log.prettySentHeaders = _.map(_.pairs(log.request_headers), ([header, value]) -> "#{header}: #{value}").join("\n") + log.prettySentData = JSON.stringify(log.request_data.data, undefined, 2) + log.prettyDate = moment(log.created).format("DD MMM YYYY [at] hh:mm:ss") # TODO: i18n + + webhook.logs_counter = webhooklogs.length + 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)") # TODO: i18n + else + textElement.text("(Show history)") # TODO: i18n + + 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") + + openHistory = () -> + $el.find(".webhooks-history").addClass("open") + + 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", () -> + openHistory() + $rs.webhooks.test(webhook.id).then => + updateLogs() + + $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: i18n + message = "Webhook '#{webhook.name}'" #TODO: i18n + + $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/base.coffee b/app/coffee/modules/base.coffee index 5a98d548..3e44ac58 100644 --- a/app/coffee/modules/base.coffee +++ b/app/coffee/modules/base.coffee @@ -93,6 +93,7 @@ urls = { "project-admin-project-values-issue-severities": "/project/:project/admin/project-values/issue-severities" "project-admin-memberships": "/project/:project/admin/memberships" "project-admin-roles": "/project/:project/admin/roles" + "project-admin-third-parties-webhooks": "/project/:project/admin/third-parties/webhooks" "project-admin-third-parties-github": "/project/:project/admin/third-parties/github" "project-admin-third-parties-gitlab": "/project/:project/admin/third-parties/gitlab" "project-admin-third-parties-bitbucket": "/project/:project/admin/third-parties/bitbucket" diff --git a/app/coffee/modules/common/loading.coffee b/app/coffee/modules/common/loading.coffee index b8f5ad7a..916fa044 100644 --- a/app/coffee/modules/common/loading.coffee +++ b/app/coffee/modules/common/loading.coffee @@ -27,7 +27,6 @@ class TgLoadingService extends taiga.Service target.data('loading-old-content', target.html()) target.addClass('loading') target.html("loading...") - debugger finish: (target) -> if target.hasClass('loading') 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/admin/admin-third-parties-webhooks.jade b/app/partials/admin/admin-third-parties-webhooks.jade new file mode 100644 index 00000000..e56468a5 --- /dev/null +++ b/app/partials/admin/admin-third-parties-webhooks.jade @@ -0,0 +1,99 @@ +block head + title Taiga Your agile, free, and open source project management tool + +block content + div.wrapper.roles(ng-controller="WebhooksController as ctrl", + ng-init="section='admin'") + sidebar.menu-secondary.sidebar(tg-admin-navigation="Webhooks") + include ../includes/modules/admin-menu + sidebar.menu-tertiary.sidebar(tg-admin-navigation="third-parties-webhooks") + include ../includes/modules/admin-submenu-third-parties + + section.main.admin-common.admin-webhooks(tg-new-webhook) + include ../includes/components/mainTitle + + p.admin-subtitle Webhooks notify external services about events in Taiga, like comments, user stories.... + div.webhooks-options + a.button.button-green.hidden.add-webhook(href="",title="Add a New Webhook") Add Webhook + + section.webhooks-table.basic-table + div.table-header + div.row + div.webhook-service Name + div.webhook-url URL + div.webhook-options + div.table-body + 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="log.validStatus ? 'history-success' : 'history-error'", title="{{log.status}}") + 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 + 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.add-new.icon.icon-floppy(href="", title="Save Webhook") + a.cancel-new.icon.icon-delete(href="", title="Cancel Webhook") + + 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/partials/includes/modules/admin-menu.jade b/app/partials/includes/modules/admin-menu.jade index 44f3d6d2..e14c5849 100644 --- a/app/partials/includes/modules/admin-menu.jade +++ b/app/partials/includes/modules/admin-menu.jade @@ -21,8 +21,8 @@ section.admin-menu span.title Roles & Permissions span.icon.icon-arrow-right li#adminmenu-third-parties - a(href="" tg-nav="project-admin-third-parties-github:project=project.slug") - span.title Third parties + a(href="" tg-nav="project-admin-third-parties-webhooks:project=project.slug") + span.title Integrations span.icon.icon-arrow-right li#adminmenu-contrib(ng-show="contribPlugins.length > 0") a(href="" tg-nav="project-admin-contrib:project=project.slug,plugin=contribPlugins[0].slug") diff --git a/app/partials/includes/modules/admin-submenu-third-parties.jade b/app/partials/includes/modules/admin-submenu-third-parties.jade index f491ebb1..2e30e607 100644 --- a/app/partials/includes/modules/admin-submenu-third-parties.jade +++ b/app/partials/includes/modules/admin-submenu-third-parties.jade @@ -1,9 +1,13 @@ section.admin-submenu header - h1 Third parties + h1 Services nav ul + li#adminmenu-third-parties-webhooks.third-parties-webhooks + a(href="", tg-nav="project-admin-third-parties-webhooks:project=project.slug") + span.title Webhooks + span.icon.icon-arrow-right li#adminmenu-third-parties-github a(href="", tg-nav="project-admin-third-parties-github:project=project.slug") span.title Github 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-menu.scss b/app/styles/modules/admin/admin-menu.scss index ff92c3b4..99b82728 100644 --- a/app/styles/modules/admin/admin-menu.scss +++ b/app/styles/modules/admin/admin-menu.scss @@ -11,7 +11,6 @@ a { display: block; padding: 1rem 0 1rem 1rem; - &.active, &:hover { .icon { opacity: 1; @@ -19,6 +18,12 @@ } } } + .active { + .icon { + opacity: 1; + transition: opacity .3s linear; + } + } .icon { color: $blackish; float: right; diff --git a/app/styles/modules/admin/admin-third-parties-webhooks.scss b/app/styles/modules/admin/admin-third-parties-webhooks.scss new file mode 100644 index 00000000..e8638bb2 --- /dev/null +++ b/app/styles/modules/admin/admin-third-parties-webhooks.scss @@ -0,0 +1,193 @@ +.admin-webhooks { + .webhooks-table { + .row { + border-bottom: 0; + padding: .5rem 0; + } + .row:hover { + .webhook-options-wrapper { + opacity: 1; + transition: opacity .2s linear; + } + } + } + + .table-header { + @extend %bold; + border-bottom: 1px solid $gray-light; + } + .table-body { + .webhook-service { + color: $gray; + } + } + .single-webhook-wrapper { + border-bottom: 1px solid $whitish; + } + .webhooks-options { + margin-bottom: 1rem; + text-align: right; + } + + .webhook-service, + .webhook-url { + margin-right: .5rem; + } + .webhook-service { + flex-basis: 20%; + flex-grow: 0; + } + .webhook-url { + flex-basis: 400px; + flex-grow: 8; + span { + @include ellipsis($width: 65%); + color: $gray-light; + display: inline-block; + vertical-align: middle; + } + a { + color: $green-taiga; + margin-left: .5rem; + &:hover { + color: $fresh-taiga; + } + } + } + .webhook-options { + flex-basis: 100px; + flex-grow: 0; + min-width: 100px; + text-align: center; + a { + color: $gray-light; + margin-right: .5rem; + transition: color .2s linear; + vertical-align: middle; + &:hover { + color: $green-taiga; + transition: color .2s linear; + + } + } + } + .webhook-options-wrapper { + opacity: 0; + transition: opacity .3s linear; + } + .webhook-url-inputs { + display: flex; + flex-direction: row; + 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; + cursor: pointer; + display: flex; + justify-content: space-between; + 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-icon { + background: $gray; + border-radius: 25%; + display: inline-block; + height: .8rem; + margin-right: .5rem; + width: .8rem; + &.history-success { + background: $fresh-taiga; + } + &.history-error { + background: $red; + } + } + .history-single-response { + @include slide(1000px, hidden, $min: 0); + } + .history-single-request-header, + .history-single-response-header { + display: flex; + justify-content: space-between; + padding: 1.5rem 0 .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; + } + } +} diff --git a/app/styles/modules/admin/third-parties.scss b/app/styles/modules/admin/third-parties.scss index 0c3608c7..92331d11 100644 --- a/app/styles/modules/admin/third-parties.scss +++ b/app/styles/modules/admin/third-parties.scss @@ -19,7 +19,7 @@ textarea { height: 10rem; } - .button-green { + .submit-button { color: $white; display: block; text-align: center; diff --git a/main-sass.js b/main-sass.js index 40f1cbce..1f266882 100644 --- a/main-sass.js +++ b/main-sass.js @@ -125,6 +125,7 @@ exports.files = function () { 'modules/admin/default-values', 'modules/admin/project-values', 'modules/admin/third-parties', + 'modules/admin/admin-third-parties-webhooks', 'modules/admin/contrib', //Modules user Settings