diff --git a/CHANGELOG.md b/CHANGELOG.md index eaf817c5..7fb54d9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ## 1.6.0 ??? (Unreleased) ### Features -- ... +- Added custom fields per project for user stories, tasks and issues. ### Misc - New contrib plugin for hipchat (by Δndrea Stagi) diff --git a/app/coffee/app.coffee b/app/coffee/app.coffee index e7f49f72..76e82774 100644 --- a/app/coffee/app.coffee +++ b/app/coffee/app.coffee @@ -39,17 +39,23 @@ taiga.sessionId = taiga.generateUniqueSessionIdentifier() configure = ($routeProvider, $locationProvider, $httpProvider, $provide, $tgEventsProvider, tgLoaderProvider) -> $routeProvider.when("/", {templateUrl: "project/projects.html", resolve: {loader: tgLoaderProvider.add()}}) + $routeProvider.when("/project/:pslug/", {templateUrl: "project/project.html"}) - $routeProvider.when("/project/:pslug/backlog", - {templateUrl: "backlog/backlog.html", resolve: {loader: tgLoaderProvider.add()}}) - $routeProvider.when("/project/:pslug/taskboard/:sslug", - {templateUrl: "taskboard/taskboard.html", resolve: {loader: tgLoaderProvider.add()}}) + $routeProvider.when("/project/:pslug/search", {templateUrl: "search/search.html", reloadOnSearch: false}) + + $routeProvider.when("/project/:pslug/backlog", + {templateUrl: "backlog/backlog.html", resolve: {loader: tgLoaderProvider.add()}}) + $routeProvider.when("/project/:pslug/kanban", {templateUrl: "kanban/kanban.html", resolve: {loader: tgLoaderProvider.add()}}) + # Milestone + $routeProvider.when("/project/:pslug/taskboard/:sslug", + {templateUrl: "taskboard/taskboard.html", resolve: {loader: tgLoaderProvider.add()}}) + # User stories $routeProvider.when("/project/:pslug/us/:usref", {templateUrl: "us/us-detail.html", resolve: {loader: tgLoaderProvider.add()}}) @@ -74,7 +80,7 @@ configure = ($routeProvider, $locationProvider, $httpProvider, $provide, $tgEven $routeProvider.when("/project/:pslug/issue/:issueref", {templateUrl: "issue/issues-detail.html", resolve: {loader: tgLoaderProvider.add()}}) - # Admin + # Admin - Project Profile $routeProvider.when("/project/:pslug/admin/project-profile/details", {templateUrl: "admin/admin-project-profile.html"}) $routeProvider.when("/project/:pslug/admin/project-profile/default-values", @@ -83,12 +89,17 @@ configure = ($routeProvider, $locationProvider, $httpProvider, $provide, $tgEven {templateUrl: "admin/admin-project-modules.html"}) $routeProvider.when("/project/:pslug/admin/project-profile/export", {templateUrl: "admin/admin-project-export.html"}) + # Admin Project Values $routeProvider.when("/project/:pslug/admin/project-values/us-status", {templateUrl: "admin/admin-project-values-us-status.html"}) $routeProvider.when("/project/:pslug/admin/project-values/us-points", {templateUrl: "admin/admin-project-values-us-points.html"}) + $routeProvider.when("/project/:pslug/admin/project-values/us-extras", + {templateUrl: "admin/admin-project-values-us-extras.html"}) $routeProvider.when("/project/:pslug/admin/project-values/task-status", {templateUrl: "admin/admin-project-values-task-status.html"}) + $routeProvider.when("/project/:pslug/admin/project-values/task-extras", + {templateUrl: "admin/admin-project-values-task-extras.html"}) $routeProvider.when("/project/:pslug/admin/project-values/issue-status", {templateUrl: "admin/admin-project-values-issue-status.html"}) $routeProvider.when("/project/:pslug/admin/project-values/issue-types", @@ -97,10 +108,15 @@ configure = ($routeProvider, $locationProvider, $httpProvider, $provide, $tgEven {templateUrl: "admin/admin-project-values-issue-priorities.html"}) $routeProvider.when("/project/:pslug/admin/project-values/issue-severities", {templateUrl: "admin/admin-project-values-issue-severities.html"}) + $routeProvider.when("/project/:pslug/admin/project-values/issue-extras", + {templateUrl: "admin/admin-project-values-issue-extras.html"}) + # Admin - Memberships $routeProvider.when("/project/:pslug/admin/memberships", {templateUrl: "admin/admin-memberships.html"}) + # Admin - Roles $routeProvider.when("/project/:pslug/admin/roles", {templateUrl: "admin/admin-roles.html"}) + # Admin - Third Parties $routeProvider.when("/project/:pslug/admin/third-parties/webhooks", {templateUrl: "admin/admin-third-parties-webhooks.html"}) $routeProvider.when("/project/:pslug/admin/third-parties/github", @@ -109,6 +125,7 @@ configure = ($routeProvider, $locationProvider, $httpProvider, $provide, $tgEven {templateUrl: "admin/admin-third-parties-gitlab.html"}) $routeProvider.when("/project/:pslug/admin/third-parties/bitbucket", {templateUrl: "admin/admin-third-parties-bitbucket.html"}) + # Admin - Contrib Plugins $routeProvider.when("/project/:pslug/admin/contrib/:plugin", {templateUrl: "contrib/main.html"}) diff --git a/app/coffee/modules/admin/project-values.coffee b/app/coffee/modules/admin/project-values.coffee index bee3d41c..48f52d63 100644 --- a/app/coffee/modules/admin/project-values.coffee +++ b/app/coffee/modules/admin/project-values.coffee @@ -320,3 +320,299 @@ ColorSelectionDirective = () -> } module.directive("tgColorSelection", ColorSelectionDirective) + + +############################################################################# +## Custom Attributes Controller +############################################################################# + +class ProjectCustomAttributesController extends mixOf(taiga.Controller, taiga.PageMixin) + @.$inject = [ + "$scope", + "$rootScope", + "$tgRepo", + "$tgResources", + "$routeParams", + "$q", + "$tgLocation", + "$tgNavUrls", + "$appTitle", + ] + + constructor: (@scope, @rootscope, @repo, @rs, @params, @q, @location, @navUrls, @appTitle) -> + @scope.project = {} + + promise = @.loadInitialData() + + promise.then () => + @appTitle.set("Project Custom Attributes - " + @scope.sectionName + " - " + @scope.project.name) + + promise.then null, @.onInitialDataError.bind(@) + + loadInitialData: => + promise = @repo.resolve({pslug: @params.pslug}).then (data) => + @scope.projectId = data.project + return data + + return promise.then( => @q.all([ + @.loadProject(), + @.loadCustomAttributes(), + ])) + + ######################### + # Project + ######################### + + loadProject: => + return @rs.projects.get(@scope.projectId).then (project) => + @scope.project = project + @scope.$emit('project:loaded', project) + return project + + ######################### + # Custom Attribute + ######################### + + loadCustomAttributes: => + return @rs.customAttributes[@scope.type].list(@scope.projectId).then (customAttributes) => + @scope.customAttributes = customAttributes + @scope.maxOrder = _.max(customAttributes, "order").order + return customAttributes + + createCustomAttribute: (attrValues) => + return @repo.create("custom-attributes/#{@scope.type}", attrValues) + + saveCustomAttribute: (attrModel) => + return @repo.save(attrModel) + + deleteCustomAttribute: (attrModel) => + return @repo.remove(attrModel) + + moveCustomAttributes: (attrModel, newIndex) => + customAttributes = @scope.customAttributes + r = customAttributes.indexOf(attrModel) + customAttributes.splice(r, 1) + customAttributes.splice(newIndex, 0, attrModel) + + _.each customAttributes, (val, idx) -> + val.order = idx + + @repo.saveAll(customAttributes) + + +module.controller("ProjectCustomAttributesController", ProjectCustomAttributesController) + + +############################################################################# +## Custom Attributes Directive +############################################################################# + +ProjectCustomAttributesDirective = ($log, $confirm, animationFrame) -> + link = ($scope, $el, $attrs) -> + $ctrl = $el.controller() + + $scope.$on "$destroy", -> + $el.off() + + ################################## + # Drag & Drop + ################################## + sortableEl = $el.find(".js-sortable") + + sortableEl.sortable({ + handle: ".js-view-custom-field", + dropOnEmpty: true + revert: 400 + axis: "y" + }) + + sortableEl.on "sortstop", (event, ui) -> + itemEl = ui.item + itemAttr = itemEl.scope().attr + itemIndex = itemEl.index() + $ctrl.moveCustomAttributes(itemAttr, itemIndex) + + ################################## + # New custom attribute + ################################## + + showCreateForm = -> + $el.find(".js-new-custom-field").removeClass("hidden") + + hideCreateForm = -> + $el.find(".js-new-custom-field").addClass("hidden") + + showAddButton = -> + $el.find(".js-add-custom-field-button").removeClass("hidden") + + hideAddButton = -> + $el.find(".js-add-custom-field-button").addClass("hidden") + + showCancelButton = -> + $el.find(".js-cancel-new-custom-field-button").removeClass("hidden") + + hideCancelButton = -> + $el.find(".js-cancel-new-custom-field-button").addClass("hidden") + + resetNewAttr = -> + $scope.newAttr = {} + + create = (formEl) -> + form = formEl.checksley() + return if not form.validate() + + onSucces = => + $ctrl.loadCustomAttributes() + hideCreateForm() + resetNewAttr() + $confirm.notify("success") + + onError = (data) => + form.setErrors(data) + $confirm.notify("error") + + attr = $scope.newAttr + attr.project = $scope.projectId + attr.order = if $scope.maxOrder then $scope.maxOrder + 1 else 1 + + $ctrl.createCustomAttribute(attr).then(onSucces, onError) + + cancelCreate = -> + hideCreateForm() + resetNewAttr() + + $scope.$watch "customAttributes", (customAttributes) -> + return if not customAttributes + + if customAttributes.length == 0 + hideCancelButton() + hideAddButton() + showCreateForm() + else + hideCreateForm() + showAddButton() + showCancelButton() + + $el.on "click", ".js-add-custom-field-button", (event) -> + event.preventDefault() + + showCreateForm() + + $el.on "click", ".js-create-custom-field-button", debounce 2000, (event) -> + event.preventDefault() + target = angular.element(event.currentTarget) + formEl = target.closest("form") + + create(formEl) + + $el.on "click", ".js-cancel-new-custom-field-button", (event) -> + event.preventDefault() + + cancelCreate() + + $el.on "keyup", ".js-new-custom-field input", (event) -> + if event.keyCode == 13 # Enter + target = angular.element(event.currentTarget) + formEl = target.closest("form") + create(formEl) + else if event.keyCode == 27 # Esc + cancelCreate() + + ################################## + # Edit custom attribute + ################################## + + showEditForm = (formEl) -> + formEl.find(".js-view-custom-field").addClass("hidden") + formEl.find(".js-edit-custom-field").removeClass("hidden") + + hideEditForm = (formEl) -> + formEl.find(".js-edit-custom-field").addClass("hidden") + formEl.find(".js-view-custom-field").removeClass("hidden") + + revertChangesInCustomAttribute = (formEl) -> + $scope.$apply -> + formEl.scope().attr.revert() + + update = (formEl) -> + form = formEl.checksley() + return if not form.validate() + + onSucces = => + $ctrl.loadCustomAttributes() + hideEditForm(formEl) + $confirm.notify("success") + + onError = (data) => + form.setErrors(data) + $confirm.notify("error") + + attr = formEl.scope().attr + $ctrl.saveCustomAttribute(attr).then(onSucces, onError) + + cancelUpdate = (formEl) -> + hideEditForm(formEl) + revertChangesInCustomAttribute(formEl) + + $el.on "click", ".js-edit-custom-field-button", (event) -> + event.preventDefault() + target = angular.element(event.currentTarget) + formEl = target.closest("form") + + showEditForm(formEl) + + $el.on "click", ".js-update-custom-field-button", debounce 2000, (event) -> + event.preventDefault() + target = angular.element(event.currentTarget) + formEl = target.closest("form") + + update(formEl) + + $el.on "click", ".js-cancel-edit-custom-field-button", (event) -> + event.preventDefault() + target = angular.element(event.currentTarget) + formEl = target.closest("form") + + cancelUpdate(formEl) + + $el.on "keyup", ".js-edit-custom-field input", (event) -> + if event.keyCode == 13 # Enter + target = angular.element(event.currentTarget) + formEl = target.closest("form") + update(formEl) + else if event.keyCode == 27 # Esc + target = angular.element(event.currentTarget) + formEl = target.closest("form") + cancelUpdate(formEl) + + ################################## + # Delete custom attribute + ################################## + + deleteCustomAttribute = (formEl) -> + attr = formEl.scope().attr + + title = "Delete custom attribute" # i18n + subtitle = "Remeber that all values in this custom field will be deleted.
Are you sure you want to continue?" + message = attr.name + $confirm.ask(title, subtitle, message).then (finish) -> + onSucces = -> + $ctrl.loadCustomAttributes().finally -> + finish() + + onError = -> + finish(false) + $confirm.notify("error", null, "We have not been able to delete '#{message}'.") + + $ctrl.deleteCustomAttribute(attr).then(onSucces, onError) + + $el.on "click", ".js-delete-custom-field-button", debounce 2000, (event) -> + event.preventDefault() + target = angular.element(event.currentTarget) + formEl = target.closest("form") + + deleteCustomAttribute(formEl) + + return {link: link} + +module.directive("tgProjectCustomAttributes", ["$log", "$tgConfirm", "animationFrame", ProjectCustomAttributesDirective]) diff --git a/app/coffee/modules/base.coffee b/app/coffee/modules/base.coffee index 3e44ac58..2ae8983e 100644 --- a/app/coffee/modules/base.coffee +++ b/app/coffee/modules/base.coffee @@ -86,11 +86,14 @@ urls = { "project-admin-project-profile-export": "/project/:project/admin/project-profile/export" "project-admin-project-values-us-status": "/project/:project/admin/project-values/us-status" "project-admin-project-values-us-points": "/project/:project/admin/project-values/us-points" + "project-admin-project-values-us-extras": "/project/:project/admin/project-values/us-extras" "project-admin-project-values-task-status": "/project/:project/admin/project-values/task-status" + "project-admin-project-values-task-extras": "/project/:project/admin/project-values/task-extras" "project-admin-project-values-issue-status": "/project/:project/admin/project-values/issue-status" "project-admin-project-values-issue-types": "/project/:project/admin/project-values/issue-types" "project-admin-project-values-issue-priorities": "/project/:project/admin/project-values/issue-priorities" "project-admin-project-values-issue-severities": "/project/:project/admin/project-values/issue-severities" + "project-admin-project-values-issue-extras": "/project/:project/admin/project-values/issue-extras" "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" diff --git a/app/coffee/modules/common/custom-field-values.coffee b/app/coffee/modules/common/custom-field-values.coffee new file mode 100644 index 00000000..dd3e661b --- /dev/null +++ b/app/coffee/modules/common/custom-field-values.coffee @@ -0,0 +1,197 @@ +### +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino Garcia +# Copyright (C) 2014 David Barragán Merino +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: modules/common/custom-field-values.coffee +### + +taiga = @.taiga +bindMethods = @.taiga.bindMethods +bindOnce = @.taiga.bindOnce +debounce = @.taiga.debounce +generateHash = taiga.generateHash + +module = angular.module("taigaCommon") + + +class CustomAttributesValuesController extends taiga.Controller + @.$inject = ["$scope", "$rootScope", "$tgRepo", "$tgResources", "$tgConfirm", "$q"] + + constructor: (@scope, @rootscope, @repo, @rs, @confirm, @q) -> + bindMethods(@) + @.type = null + @.objectId = null + @.projectId = null + @.customAttributes = [] + @.customAttributesValues = null + + initialize: (type, objectId) -> + @.project = @scope.project + @.type = type + @.objectId = objectId + @.projectId = @scope.projectId + + loadCustomAttributesValues: -> + return @.customAttributesValues if not @.objectId + return @rs.customAttributesValues[@.type].get(@.objectId).then (customAttributesValues) => + @.customAttributes = @.project["#{@.type}_custom_attributes"] + @.customAttributesValues = customAttributesValues + return customAttributesValues + + getAttributeValue: (attribute) -> + attributeValue = _.clone(attribute, false) + attributeValue.value = @.customAttributesValues.attributes_values[attribute.id] + return attributeValue + + updateAttributeValue: (attributeValue) -> + onSuccess = => + @rootscope.$broadcast("custom-attributes-values:edit") + + onError = (response) => + @confirm.notify("error") + return @q.reject() + + # We need to update the full array so angular understand the model is modified + attributesValues = _.clone(@.customAttributesValues.attributes_values, true) + attributesValues[attributeValue.id] = attributeValue.value + @.customAttributesValues.attributes_values = attributesValues + @.customAttributesValues.id = @.objectId + return @repo.save(@.customAttributesValues).then(onSuccess, onError) + + +CustomAttributesValuesDirective = ($templates, $storage) -> + template = $templates.get("custom-attributes/custom-attributes-values.html", true) + collapsedHash = (type) -> + return generateHash(["custom-attributes-collapsed", type]) + + link = ($scope, $el, $attrs, $ctrls) -> + $ctrl = $ctrls[0] + $model = $ctrls[1] + + bindOnce $scope, $attrs.ngModel, (value) -> + $ctrl.initialize($attrs.type, value.id) + $ctrl.loadCustomAttributesValues() + + $el.on "click", ".custom-fields-header a", -> + hash = collapsedHash($attrs.type) + collapsed = not($storage.get(hash) or false) + $storage.set(hash, collapsed) + if collapsed + $el.find(".custom-fields-header a").removeClass("open") + $el.find(".custom-fields-body").removeClass("open") + else + $el.find(".custom-fields-header a").addClass("open") + $el.find(".custom-fields-body").addClass("open") + + $scope.$on "$destroy", -> + $el.off() + + templateFn = ($el, $attrs) -> + collapsed = $storage.get(collapsedHash($attrs.type)) or false + + return template({ + requiredEditionPerm: $attrs.requiredEditionPerm + collapsed: collapsed + }) + + return { + require: ["tgCustomAttributesValues", "ngModel"] + controller: CustomAttributesValuesController + controllerAs: "ctrl" + restrict: "AE" + scope: true + link: link + template: templateFn + } + +module.directive("tgCustomAttributesValues", ["$tgTemplate", "$tgStorage", CustomAttributesValuesDirective]) + + +CustomAttributeValueDirective = ($template, $selectedText) -> + template = $template.get("custom-attributes/custom-attribute-value.html", true) + templateEdit = $template.get("custom-attributes/custom-attribute-value-edit.html", true) + + link = ($scope, $el, $attrs, $ctrl) -> + render = (attributeValue, edit=false) -> + value = attributeValue.value + ctx = { + id: attributeValue.id + name: attributeValue.name + description: attributeValue.description + value: value + isEditable: isEditable() + } + + if edit or not value + html = templateEdit(ctx) + else + html = template(ctx) + + $el.html(html) + + isEditable = -> + permissions = $scope.project.my_permissions + requiredEditionPerm = $attrs.requiredEditionPerm + return permissions.indexOf(requiredEditionPerm) > -1 + + saveAttributeValue = -> + attributeValue.value = $el.find("input").val() + + $scope.$apply -> + $ctrl.updateAttributeValue(attributeValue).then -> + render(attributeValue, false) + + $el.on "keyup", "input[name=description]", (event) -> + if event.keyCode == 13 + submit(event) + else if event.keyCode == 27 + render(attributeValue, false) + + ## Actions (on view mode) + $el.on "click", ".custom-field-value.read-mode", -> + return if not isEditable() + return if $selectedText.get().length + render(attributeValue, true) + $el.find("input[name='description']").focus().select() + + $el.on "click", "a.icon-edit", (event) -> + event.preventDefault() + render(attributeValue, true) + $el.find("input[name='description']").focus().select() + + ## Actions (on edit mode) + submit = debounce 2000, (event) => + event.preventDefault() + saveAttributeValue() + + $el.on "submit", "form", submit + $el.on "click", "a.icon-floppy", submit + + $scope.$on "$destroy", -> + $el.off() + + # Bootstrap + attributeValue = $scope.$eval($attrs.tgCustomAttributeValue) + render(attributeValue) + + return { + link: link + require: "^tgCustomAttributesValues" + restrict: "AE" + } + +module.directive("tgCustomAttributeValue", ["$tgTemplate", "$selectedText", CustomAttributeValueDirective]) diff --git a/app/coffee/modules/common/history.coffee b/app/coffee/modules/common/history.coffee index 0f7651b8..fcd1d961 100644 --- a/app/coffee/modules/common/history.coffee +++ b/app/coffee/modules/common/history.coffee @@ -173,6 +173,34 @@ HistoryDirective = ($log, $loading, $qqueue, $template, $confirm) -> return _.flatten(attachments).join("\n") + renderCustomAttributesEntry = (value) -> + customAttributes = _.map value, (changes, type) -> + if type == "new" + return _.map changes, (change) -> + return templateChangeGeneric({ + name: change.name, + from: formatChange(""), + to: formatChange(change.value) + }) + else if type == "deleted" + return _.map changes, (change) -> + # TODO: i18n + return templateChangeDiff({ + name: "deleted custom attribute", + diff: change.name + }) + else + return _.map changes, (change) -> + customAttrsChanges = _.map change.changes, (values) -> + return templateChangeGeneric({ + name: change.name + from: formatChange(values[0]) + to: formatChange(values[1]) + }) + return _.flatten(customAttrsChanges).join("\n") + + return _.flatten(customAttributes).join("\n") + renderChangeEntry = (field, value) -> if field == "description" return templateChangeDiff({name: getHumanizedFieldName("description"), diff: value[1]}) @@ -182,6 +210,8 @@ HistoryDirective = ($log, $loading, $qqueue, $template, $confirm) -> return templateChangePoints({points: value}) else if field == "attachments" return renderAttachmentEntry(value) + else if field == "custom_attributes" + return renderCustomAttributesEntry(value) else if field in ["tags", "watchers"] name = getHumanizedFieldName(field) removed = _.difference(value[0], value[1]) diff --git a/app/coffee/modules/issues/detail.coffee b/app/coffee/modules/issues/detail.coffee index eea923dc..2be4c7d7 100644 --- a/app/coffee/modules/issues/detail.coffee +++ b/app/coffee/modules/issues/detail.coffee @@ -85,6 +85,9 @@ class IssueDetailController extends mixOf(taiga.Controller, taiga.PageMixin) @rootscope.$broadcast("history:reload") @.loadIssue() + @scope.$on "custom-attributes-values:edit", => + @rootscope.$broadcast("history:reload") + initializeOnDeleteGoToUrl: -> ctx = {project: @scope.project.slug} if @scope.project.is_issues_activated diff --git a/app/coffee/modules/resources.coffee b/app/coffee/modules/resources.coffee index 9f067e78..1761ae39 100644 --- a/app/coffee/modules/resources.coffee +++ b/app/coffee/modules/resources.coffee @@ -102,6 +102,16 @@ urls = { "attachments/task": "/tasks/attachments" "attachments/wiki_page": "/wiki/attachments" + # Custom Attributess + "custom-attributes/userstory": "/userstory-custom-attributes" + "custom-attributes/issue": "/issue-custom-attributes" + "custom-attributes/task": "/task-custom-attributes" + + # Custom field values + "custom-attributes-values/userstory": "/userstories/custom-attributes-values" + "custom-attributes-values/issue": "/issues/custom-attributes-values" + "custom-attributes-values/task": "/tasks/custom-attributes-values" + # Feedback "feedback": "/feedback" @@ -133,6 +143,8 @@ module.run([ "$log", "$tgResources", "$tgProjectsResourcesProvider", + "$tgCustomAttributesResourcesProvider", + "$tgCustomAttributesValuesResourcesProvider", "$tgMembershipsResourcesProvider", "$tgNotifyPoliciesResourcesProvider", "$tgInvitationsResourcesProvider", diff --git a/app/coffee/modules/resources/custom-attributes-values.coffee b/app/coffee/modules/resources/custom-attributes-values.coffee new file mode 100644 index 00000000..5322c639 --- /dev/null +++ b/app/coffee/modules/resources/custom-attributes-values.coffee @@ -0,0 +1,44 @@ +### +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino Garcia +# Copyright (C) 2014 David Barragán Merino +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: modules/resources/custom-field-values.coffee +### + +taiga = @.taiga + +resourceProvider = ($repo) -> + _get = (objectId, resource) -> + return $repo.queryOne(resource, objectId) + + service = { + userstory: { + get: (objectId) -> _get(objectId, "custom-attributes-values/userstory") + } + task: { + get: (objectId) -> _get(objectId, "custom-attributes-values/task") + } + issue: { + get: (objectId) -> _get(objectId, "custom-attributes-values/issue") + } + } + + return (instance) -> + instance.customAttributesValues = service + +module = angular.module("taigaResources") +module.factory("$tgCustomAttributesValuesResourcesProvider", ["$tgRepo", resourceProvider]) diff --git a/app/coffee/modules/resources/custom-attributes.coffee b/app/coffee/modules/resources/custom-attributes.coffee new file mode 100644 index 00000000..cf14c398 --- /dev/null +++ b/app/coffee/modules/resources/custom-attributes.coffee @@ -0,0 +1,48 @@ +### +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino Garcia +# Copyright (C) 2014 David Barragán Merino +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: modules/resources/projects.coffee +### + + +taiga = @.taiga +sizeFormat = @.taiga.sizeFormat + + +resourceProvider = ($repo) -> + _list = (projectId, resource) -> + return $repo.queryMany(resource, {project: projectId}) + + service = { + userstory:{ + list: (projectId) -> _list(projectId, "custom-attributes/userstory") + } + task:{ + list: (projectId) -> _list(projectId, "custom-attributes/task") + } + issue: { + list: (projectId) -> _list(projectId, "custom-attributes/issue") + } + } + + return (instance) -> + instance.customAttributes = service + + +module = angular.module("taigaResources") +module.factory("$tgCustomAttributesResourcesProvider", ["$tgRepo", resourceProvider]) diff --git a/app/coffee/modules/tasks/detail.coffee b/app/coffee/modules/tasks/detail.coffee index 1dd56d59..353fbaff 100644 --- a/app/coffee/modules/tasks/detail.coffee +++ b/app/coffee/modules/tasks/detail.coffee @@ -71,6 +71,8 @@ class TaskDetailController extends mixOf(taiga.Controller, taiga.PageMixin) @rootscope.$broadcast("history:reload") @scope.$on "attachment:delete", => @rootscope.$broadcast("history:reload") + @scope.$on "custom-attributes-values:edit", => + @rootscope.$broadcast("history:reload") initializeOnDeleteGoToUrl: -> ctx = {project: @scope.project.slug} diff --git a/app/coffee/modules/userstories/detail.coffee b/app/coffee/modules/userstories/detail.coffee index 6db4ccac..7347f72d 100644 --- a/app/coffee/modules/userstories/detail.coffee +++ b/app/coffee/modules/userstories/detail.coffee @@ -80,6 +80,9 @@ class UserStoryDetailController extends mixOf(taiga.Controller, taiga.PageMixin) @scope.$on "attachment:delete", => @rootscope.$broadcast("history:reload") + @scope.$on "custom-attributes-values:edit", => + @rootscope.$broadcast("history:reload") + initializeOnDeleteGoToUrl: -> ctx = {project: @scope.project.slug} @scope.onDeleteGoToUrl = @navUrls.resolve("project", ctx) diff --git a/app/partials/admin/admin-project-values-issue-extras.jade b/app/partials/admin/admin-project-values-issue-extras.jade new file mode 100644 index 00000000..9335bfa3 --- /dev/null +++ b/app/partials/admin/admin-project-values-issue-extras.jade @@ -0,0 +1,17 @@ +div.wrapper(tg-project-custom-attributes, ng-controller="ProjectCustomAttributesController as ctrl", + ng-init="section='admin'; type='issue'; sectionName='Issue extra'") + sidebar.menu-secondary.sidebar(tg-admin-navigation="project-values") + include ../includes/modules/admin-menu + + sidebar.menu-tertiary.sidebar(tg-admin-navigation="values-issue-extras") + include ../includes/modules/admin-submenu-project-values + + section.main.admin-common + include ../includes/components/mainTitle + p.admin-subtitle Specify here issue custom fields. The new field will appear on your issue detail. + + div.custom-field-options + a.button.button-green.js-add-custom-field-button(href="",title="Add a custom field in issues") + | Add custom field + + include ../includes/modules/admin/admin-custom-attributes diff --git a/app/partials/admin/admin-project-values-task-extras.jade b/app/partials/admin/admin-project-values-task-extras.jade new file mode 100644 index 00000000..95b50a1d --- /dev/null +++ b/app/partials/admin/admin-project-values-task-extras.jade @@ -0,0 +1,17 @@ +div.wrapper(tg-project-custom-attributes, ng-controller="ProjectCustomAttributesController as ctrl", + ng-init="section='admin'; type='task'; sectionName='Task extra'") + sidebar.menu-secondary.sidebar(tg-admin-navigation="project-values") + include ../includes/modules/admin-menu + + sidebar.menu-tertiary.sidebar(tg-admin-navigation="values-task-extras") + include ../includes/modules/admin-submenu-project-values + + section.main.admin-common + include ../includes/components/mainTitle + p.admin-subtitle Specify here task custom fields. The new field will appear on your task detail. + + div.custom-field-options + a.button.button-green.js-add-custom-field-button(href="",title="Add a custom field in tasks") + | Add custom field + + include ../includes/modules/admin/admin-custom-attributes diff --git a/app/partials/admin/admin-project-values-us-extras.jade b/app/partials/admin/admin-project-values-us-extras.jade new file mode 100644 index 00000000..40339494 --- /dev/null +++ b/app/partials/admin/admin-project-values-us-extras.jade @@ -0,0 +1,17 @@ +div.wrapper(tg-project-custom-attributes, ng-controller="ProjectCustomAttributesController as ctrl", + ng-init="section='admin'; type='userstory'; sectionName='US extra'") + sidebar.menu-secondary.sidebar(tg-admin-navigation="project-values") + include ../includes/modules/admin-menu + + sidebar.menu-tertiary.sidebar(tg-admin-navigation="values-us-extras") + include ../includes/modules/admin-submenu-project-values + + section.main.admin-common + include ../includes/components/mainTitle + p.admin-subtitle Specify here user story custom fields. The new field will appear on your user story detail. + + div.custom-field-options + a.button.button-green.js-add-custom-field-button(href="",title="Add a custom field in user stories") + | Add custom field + + include ../includes/modules/admin/admin-custom-attributes diff --git a/app/partials/custom-attributes/custom-attribute-value-edit.jade b/app/partials/custom-attributes/custom-attribute-value-edit.jade new file mode 100644 index 00000000..d3c3aabc --- /dev/null +++ b/app/partials/custom-attributes/custom-attribute-value-edit.jade @@ -0,0 +1,14 @@ +form.custom-field-single.editable + div.custom-field-data + label.custom-field-name(for="custom-field-description") + <%- name %> + <% if (description){ %> + span.custom-field-description + <%- description %> + <% } %> + + div.custom-field-value + input#custom-field-description(name="description", type="text", value!="<%- value %>") + + div.custom-field-options + a.icon.icon-floppy(href="", title="Save Custom Field") diff --git a/app/partials/custom-attributes/custom-attribute-value.jade b/app/partials/custom-attributes/custom-attribute-value.jade new file mode 100644 index 00000000..b2502c04 --- /dev/null +++ b/app/partials/custom-attributes/custom-attribute-value.jade @@ -0,0 +1,17 @@ +div.custom-field-single + div.custom-field-data + span.custom-field-name + <%- name %> + <% if (description){ %> + span.custom-field-description + <%- description %> + <% } %> + + div.custom-field-value.read-mode + span + <%- value %> + + <% if (isEditable) { %> + div.custom-field-options + a.icon.icon-edit(href="", title="Edit Custom Field") + <% } %> diff --git a/app/partials/custom-attributes/custom-attributes-values.jade b/app/partials/custom-attributes/custom-attributes-values.jade new file mode 100644 index 00000000..cadbb100 --- /dev/null +++ b/app/partials/custom-attributes/custom-attributes-values.jade @@ -0,0 +1,7 @@ +section.duty-custom-fields(ng-show="ctrl.customAttributes.length") + div.custom-fields-header + span Custom Fields + // Remove .open class on click on this button in both .icon and .custom-fields-body to close + a.icon.icon-arrow-bottom(class!="<% if (!collapsed) { %>open<% } %>") + div.custom-fields-body(class!="<% if (!collapsed) { %>open<% } %>") + div(ng-repeat="att in ctrl.customAttributes", tg-custom-attribute-value="ctrl.getAttributeValue(att)", required-edition-perm!="<%- requiredEditionPerm %>") diff --git a/app/partials/includes/modules/admin-submenu-project-values.jade b/app/partials/includes/modules/admin-submenu-project-values.jade index 47cc1134..31d45ae9 100644 --- a/app/partials/includes/modules/admin-submenu-project-values.jade +++ b/app/partials/includes/modules/admin-submenu-project-values.jade @@ -14,11 +14,21 @@ section.admin-submenu span.title US points span.icon.icon-arrow-right + li#adminmenu-values-us-extras + a(href="", tg-nav="project-admin-project-values-us-extras:project=project.slug") + span.title US extras + span.icon.icon-arrow-right + li#adminmenu-values-task-status a(href="", tg-nav="project-admin-project-values-task-status:project=project.slug") span.title Task statuses span.icon.icon-arrow-right + li#adminmenu-values-task-extras + a(href="", tg-nav="project-admin-project-values-task-extras:project=project.slug") + span.title Task extras + span.icon.icon-arrow-right + li#adminmenu-values-issue-status a(href="", tg-nav="project-admin-project-values-issue-status:project=project.slug") span.title Issue statuses @@ -38,3 +48,8 @@ section.admin-submenu a(href="", tg-nav="project-admin-project-values-issue-severities:project=project.slug") span.title Issue Severities span.icon.icon-arrow-right + + li#adminmenu-values-issue-extras + a(href="", tg-nav="project-admin-project-values-issue-extras:project=project.slug") + span.title Issue extras + span.icon.icon-arrow-right diff --git a/app/partials/includes/modules/admin/admin-custom-attributes.jade b/app/partials/includes/modules/admin/admin-custom-attributes.jade new file mode 100644 index 00000000..318a60be --- /dev/null +++ b/app/partials/includes/modules/admin/admin-custom-attributes.jade @@ -0,0 +1,48 @@ +section.custom-fields-table.basic-table + div.table-header + div.row + div.custom-name + span Name + div.custom-description + span Description + div.custom-options + + div.table-body + div.js-sortable + form.js-form(ng-repeat="attr in customAttributes track by attr.id") + div.row.single-custom-field.js-view-custom-field + span.icon.icon-drag-v + div.custom-name + span {{ attr.name }} + div.custom-description + span {{ attr.description }} + div.custom-options + div.custom-options-wrapper + a.js-edit-custom-field-button.icon.icon-edit(href="", title="Edit Custom Field") + a.js-delete-custom-field-button.icon.icon-delete(href="", title="Delete Custom Field") + + div.row.single-custom-field.js-edit-custom-field.hidden + fieldset.custom-name + input(type="text", name="name", placeholder="Set your custom field name", + ng-model="attr.name", data-required="true" data-maxlength="64") + fieldset.custom-description + input(type="text", name="description", placeholder="Set your custom field description", + ng-model="attr.description") + + fieldset.custom-options + div.custom-options-wrapper + a.js-update-custom-field-button.icon.icon-floppy(href="", title="Update Custom Field") + a.js-cancel-edit-custom-field-button.icon.icon-delete(href="", title="Cancel edition") + + form.row.single-custom-field.js-new-custom-field.hidden + fieldset.custom-name + input(type="text", name="name", placeholder="Set your custom field name", + ng-model="newAttr.name", data-required="true", data-maxlength="64") + fieldset.custom-description + input(type="text", name="description", placeholder="Set your custom field description", + ng-model="newAttr.description") + + fieldset.custom-options + div.custom-options-wrapper + a.js-create-custom-field-button.icon.icon-floppy(href="", title="Save Custom Field") + a.js-cancel-new-custom-field-button.icon.icon-delete(href="", title="Cancel creation") diff --git a/app/partials/issue/issues-detail.jade b/app/partials/issue/issues-detail.jade index 8c90aeb9..ab528bc9 100644 --- a/app/partials/issue/issues-detail.jade +++ b/app/partials/issue/issues-detail.jade @@ -35,6 +35,9 @@ div.wrapper(ng-controller="IssueDetailController as ctrl", section.duty-content(tg-editable-description, ng-model="issue", required-perm="modify_issue") + // Custom Fields + tg-custom-attributes-values(ng-model="issue", type="issue", project="project", required-edition-perm="modify_issue") + tg-attachments(ng-model="issue", type="issue") tg-history(ng-model="issue", type="issue") diff --git a/app/partials/task/task-detail.jade b/app/partials/task/task-detail.jade index dd0075a0..4d149454 100644 --- a/app/partials/task/task-detail.jade +++ b/app/partials/task/task-detail.jade @@ -40,6 +40,9 @@ div.wrapper(ng-controller="TaskDetailController as ctrl", section.duty-content(tg-editable-description, ng-model="task", required-perm="modify_task") + // Custom Fields + tg-custom-attributes-values(ng-model="task", type="task", project="project", required-edition-perm="modify_task") + tg-attachments(ng-model="task", type="task") tg-history(ng-model="task", type="task") diff --git a/app/partials/us/us-detail.jade b/app/partials/us/us-detail.jade index 5a78e15c..8c8708a1 100644 --- a/app/partials/us/us-detail.jade +++ b/app/partials/us/us-detail.jade @@ -39,6 +39,9 @@ div.wrapper(ng-controller="UserStoryDetailController as ctrl", section.duty-content(tg-editable-description, ng-model="us", required-perm="modify_us") + // Custom Fields + tg-custom-attributes-values(ng-model="us", type="userstory", project="project", required-edition-perm="modify_us") + include ../includes/modules/related-tasks tg-attachments(ng-model="us", type="us") diff --git a/app/styles/modules/admin/admin-custom-attributes.scss b/app/styles/modules/admin/admin-custom-attributes.scss new file mode 100644 index 00000000..d110cf23 --- /dev/null +++ b/app/styles/modules/admin/admin-custom-attributes.scss @@ -0,0 +1,97 @@ +.custom-field-options { + margin-bottom: 1rem; + text-align: right; +} + +.custom-fields-table { + .row { + border-bottom: 0; + padding: .5rem 0; + + } + .table-header { + @extend %bold; + border-bottom: 1px solid $gray-light; + .custom-name span, + .custom-description span { + padding-left: 1.1rem; + } + } + .table-body { + .row:hover { + background: rgba($fresh-taiga, .05); + cursor: move; + transition: background .2s linear; + .icon-drag-v, + .custom-options-wrapper { + opacity: 1; + transition: opacity .2s linear; + } + } + form { + &.row:hover { + background: none; + cursor: default; + } + } + .custom-description { + color: $gray-light; + } + } + .single-custom-field { + border-bottom: 1px solid $whitish; + color: $gray; + } + .icon-drag-v { + color: $gray-light; + opacity: 0; + padding: 0 .1rem; + transition: color .2s linear; + vertical-align: middle; + &:hover { + color: $gray; + cursor: move; + transition: color .2s linear; + } + } + .custom-name, + .custom-description { + color: $gray; + margin-right: .5rem; + } + .custom-name { + flex-basis: 25%; + flex-shrink: 0; + } + .custom-description { + @include ellipsis(100%); + flex-basis: 90%; + flex-grow: 8; + } + .custom-options { + flex-basis: 100px; + flex-grow: 0; + flex-shrink: 0; + 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; + } + } + } + .custom-options-wrapper { + opacity: 0; + transition: opacity .3s linear; + } + form { + .custom-options-wrapper { + opacity: 1; + } + } + +} diff --git a/app/styles/modules/common/custom-fields.scss b/app/styles/modules/common/custom-fields.scss new file mode 100644 index 00000000..9500491a --- /dev/null +++ b/app/styles/modules/common/custom-fields.scss @@ -0,0 +1,79 @@ +.duty-custom-fields { + margin-bottom: 2rem; + .custom-fields-header { + @extend %bold; + align-content: space-between; + align-items: center; + background: $whitish; + display: flex; + justify-content: space-between; + padding: .5rem 1rem; + .icon-arrow-bottom { + @extend %large; + cursor: pointer; + transform: rotate(-90deg); + transition: transform .2s linear; + &.open { + transform: rotate(0); + transition: transform .2s linear; + } + } + } + .custom-fields-body { + @include slide(1000px, hidden, $min: 0); + } + .custom-field-single { + border-bottom: 1px solid $whitish; + display: flex; + padding: 1rem; + &:hover { + .custom-field-options { + opacity: 1; + } + } + &.editable { + .custom-field-options { + margin-top: .5rem; + } + } + .custom-field-options { + opacity: 0; + transition: opacity .2s linear; + a { + color: $gray-light; + } + a:hover { + color: $green-taiga; + } + } + } + .custom-field-data { + flex: 0; + flex-basis: 200px; + .custom-field-name { + @extend %bold; + display: block; + } + .custom-field-description { + @extend %small; + color: $gray-light; + display: block; + line-height: .9rem; + } + } + .custom-field-options { + margin: 0; + } + .custom-field-value { + flex: 1; + padding: 0 1rem 0 2rem; + } + form { + label { + cursor: pointer; + } + input { + width: 100%; + } + } +} diff --git a/main-sass.js b/main-sass.js index 8db0d5bd..e8314453 100644 --- a/main-sass.js +++ b/main-sass.js @@ -75,6 +75,7 @@ exports.files = function () { 'modules/common/history', 'modules/common/wizard', 'modules/common/external-reference', + 'modules/common/custom-fields', //Project modules 'modules/home-projects-list', @@ -125,6 +126,7 @@ exports.files = function () { 'modules/admin/admin-membership-table', 'modules/admin/admin-project-profile', 'modules/admin/default-values', + 'modules/admin/admin-custom-attributes', 'modules/admin/project-values', 'modules/admin/third-parties', 'modules/admin/admin-third-parties-webhooks',